|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require "erb" |
| 4 | +require "fileutils" |
| 5 | + |
| 6 | +module Toys |
| 7 | + module Release |
| 8 | + ## |
| 9 | + # Logic for generating and updating gh-pages documentation site files. |
| 10 | + # |
| 11 | + class GhPagesLogic |
| 12 | + ## |
| 13 | + # Create a GhPagesLogic instance. |
| 14 | + # |
| 15 | + # @param repo_settings [Toys::Release::RepoSettings] Repository settings |
| 16 | + # |
| 17 | + def initialize(repo_settings) |
| 18 | + @enabled_component_settings = repo_settings.all_component_settings.select(&:gh_pages_enabled) |
| 19 | + raise ::ArgumentError, "No components have gh-pages enabled" if @enabled_component_settings.empty? |
| 20 | + @url_base_path = "#{repo_settings.repo_owner}.github.io/#{repo_settings.repo_name}" |
| 21 | + @default_redirect_url = "https://#{component_base_path(@enabled_component_settings.first)}/latest" |
| 22 | + end |
| 23 | + |
| 24 | + ## |
| 25 | + # Clean up non-index files from the v0 subdirectory of each gh-pages- |
| 26 | + # enabled component. The given block is called for each component whose |
| 27 | + # v0 directory contains files other than index.html, and receives the |
| 28 | + # directory path and the list of files to remove. The block should return |
| 29 | + # true to remove the files, or false to skip. If no block is given, files |
| 30 | + # are removed unconditionally. |
| 31 | + # |
| 32 | + # @param gh_pages_dir [String] Path to the gh-pages working tree |
| 33 | + # @yieldparam directory [String] Path relative to gh_pages_dir of the v0 dir |
| 34 | + # @yieldparam children [Array<String>] Non-index filenames to remove |
| 35 | + # @yieldreturn [boolean] Whether to remove the files |
| 36 | + # @return [Array<Hash>] Results, one per enabled component, each with |
| 37 | + # keys :directory (relative path), :children, and :removed |
| 38 | + # |
| 39 | + def cleanup_v0_directories(gh_pages_dir, &confirm) |
| 40 | + @enabled_component_settings.map do |comp_settings| |
| 41 | + cleanup_component_v0(gh_pages_dir, comp_settings, &confirm) |
| 42 | + end |
| 43 | + end |
| 44 | + |
| 45 | + ## |
| 46 | + # Generate all gh-pages scaffold files into the given directory. |
| 47 | + # The given block is called for each file that needs to be created or |
| 48 | + # overwritten (but NOT for unchanged files), and receives the destination |
| 49 | + # path, the status (:new or :overwrite), and the existing file type |
| 50 | + # (only meaningful for :overwrite). The block should return true to |
| 51 | + # write the file, or false to skip. |
| 52 | + # |
| 53 | + # @param gh_pages_dir [String] Path to the gh-pages working tree |
| 54 | + # @param template_dir [String] Path to the directory containing ERB |
| 55 | + # templates for gh-pages files |
| 56 | + # @yieldparam destination [String] Path relative to gh_pages_dir of the destination file |
| 57 | + # @yieldparam status [Symbol] :new or :overwrite |
| 58 | + # @yieldparam existing_ftype [String,nil] The ftype of the existing entry |
| 59 | + # @yieldreturn [boolean] Whether to write the file |
| 60 | + # @return [Array<Hash>] Results, one per file considered, each with |
| 61 | + # keys :destination (relative path) and :outcome (:wrote, :skipped, or :unchanged) |
| 62 | + # |
| 63 | + def generate_files(gh_pages_dir, template_dir, &confirm) |
| 64 | + results = [] |
| 65 | + @enabled_component_settings.each do |comp_settings| |
| 66 | + generate_component_files(gh_pages_dir, template_dir, comp_settings, results, &confirm) |
| 67 | + end |
| 68 | + generate_toplevel_files(gh_pages_dir, template_dir, results, &confirm) |
| 69 | + generate_html404(gh_pages_dir, template_dir, results, &confirm) |
| 70 | + results |
| 71 | + end |
| 72 | + |
| 73 | + ## |
| 74 | + # Update the 404 page and redirect index pages for a new component |
| 75 | + # release. The optional block is called with a warning message when a |
| 76 | + # required file is not found. |
| 77 | + # |
| 78 | + # @param gh_pages_dir [String] Path to the gh-pages working tree |
| 79 | + # @param component_settings [Toys::Release::ComponentSettings] Settings |
| 80 | + # for the component being released |
| 81 | + # @param version [Gem::Version] The new version being released |
| 82 | + # @yieldparam warning [String] A warning message for a missing file |
| 83 | + # |
| 84 | + def update_version_pages(gh_pages_dir, component_settings, version, &on_warning) |
| 85 | + update_404_page(gh_pages_dir, component_settings, version, &on_warning) |
| 86 | + update_index_pages(gh_pages_dir, component_settings, version, &on_warning) |
| 87 | + end |
| 88 | + |
| 89 | + private |
| 90 | + |
| 91 | + # Context object for ERB template rendering |
| 92 | + class ErbContext |
| 93 | + def initialize(data) |
| 94 | + data.each { |name, value| instance_variable_set("@#{name}", value) } |
| 95 | + freeze |
| 96 | + end |
| 97 | + |
| 98 | + # @private |
| 99 | + def self.get(data) |
| 100 | + new(data).instance_eval { binding } |
| 101 | + end |
| 102 | + end |
| 103 | + private_constant :ErbContext |
| 104 | + |
| 105 | + # Struct carrying info about a component for the 404 template |
| 106 | + CompInfo = ::Struct.new(:base_path, :regexp_source, :version_var) |
| 107 | + private_constant :CompInfo |
| 108 | + |
| 109 | + # Cleans up a single component's v0 directory, yielding to the caller for confirmation. |
| 110 | + def cleanup_component_v0(gh_pages_dir, comp_settings) |
| 111 | + relative_dir = simplifying_join(comp_settings.gh_pages_directory, "v0") |
| 112 | + directory = ::File.expand_path(relative_dir, gh_pages_dir) |
| 113 | + ::FileUtils.mkdir_p(directory) |
| 114 | + children = ::Dir.children(directory) - ["index.html"] |
| 115 | + removed = false |
| 116 | + if !children.empty? && (!block_given? || yield(relative_dir, children)) |
| 117 | + children.each { |child| ::FileUtils.remove_entry(::File.join(directory, child), true) } |
| 118 | + removed = true |
| 119 | + end |
| 120 | + {directory: relative_dir, children: children, removed: removed} |
| 121 | + end |
| 122 | + |
| 123 | + # Returns the URL base path for a component, incorporating its gh_pages_directory if set. |
| 124 | + def component_base_path(comp_settings) |
| 125 | + simplifying_join(@url_base_path, comp_settings.gh_pages_directory) |
| 126 | + end |
| 127 | + |
| 128 | + # Scans the component's gh-pages directory and returns the highest existing released version, |
| 129 | + # or "0" if no versioned subdirectories exist yet. |
| 130 | + def current_component_version(gh_pages_dir, comp_settings) |
| 131 | + base_dir = ::File.expand_path(comp_settings.gh_pages_directory, gh_pages_dir) |
| 132 | + latest = ::Gem::Version.new("0") |
| 133 | + return latest unless ::File.directory?(base_dir) |
| 134 | + ::Dir.children(base_dir).each do |child| |
| 135 | + next unless /^v\d+(\.\d+)*$/.match?(child) |
| 136 | + next unless ::File.directory?(::File.join(base_dir, child)) |
| 137 | + version = ::Gem::Version.new(child[1..]) |
| 138 | + latest = version if version > latest |
| 139 | + end |
| 140 | + latest |
| 141 | + end |
| 142 | + |
| 143 | + # Renders an ERB template from template_dir with the given data hash and returns the result. |
| 144 | + def render_template(template_dir, template_name, data) |
| 145 | + template_path = ::File.join(template_dir, template_name) |
| 146 | + raise "Unable to find template #{template_name}" unless ::File.file?(template_path) |
| 147 | + erb = ::ERB.new(::File.read(template_path)) |
| 148 | + erb.result(ErbContext.get(data)) |
| 149 | + end |
| 150 | + |
| 151 | + # Updates the version variable assignment in 404.html for the given component. |
| 152 | + def update_404_page(gh_pages_dir, component_settings, version) |
| 153 | + path = ::File.join(gh_pages_dir, "404.html") |
| 154 | + unless ::File.file?(path) |
| 155 | + yield "404.html not found. Skipping." if block_given? |
| 156 | + return |
| 157 | + end |
| 158 | + content = ::File.read(path) |
| 159 | + version_var = component_settings.gh_pages_version_var |
| 160 | + content.sub!(/#{::Regexp.escape(version_var)} = "[\w.]+";/, |
| 161 | + "#{version_var} = \"#{version}\";") |
| 162 | + ::File.write(path, content) |
| 163 | + end |
| 164 | + |
| 165 | + # Updates the redirect URLs in index.html and latest/index.html to point at the new version. |
| 166 | + def update_index_pages(gh_pages_dir, component_settings, version) |
| 167 | + redirect_url = "https://#{component_base_path(component_settings)}/v#{version}" |
| 168 | + ["index.html", "latest/index.html"].each do |filename| |
| 169 | + relative_path = simplifying_join(component_settings.gh_pages_directory, filename) |
| 170 | + absolute_path = ::File.expand_path(relative_path, gh_pages_dir) |
| 171 | + unless ::File.file?(absolute_path) |
| 172 | + yield "#{relative_path} not found. Skipping." if block_given? |
| 173 | + next |
| 174 | + end |
| 175 | + content = ::File.read(absolute_path) |
| 176 | + content.gsub!(/ href="[^"]+"/, " href=\"#{redirect_url}\"") |
| 177 | + content.gsub!(/ content="0; url=[^"]+"/, " content=\"0; url=#{redirect_url}\"") |
| 178 | + content.gsub!(/window\.location\.replace\("[^"]+"\)/, "window.location.replace(\"#{redirect_url}\")") |
| 179 | + ::File.write(absolute_path, content) |
| 180 | + end |
| 181 | + end |
| 182 | + |
| 183 | + # Returns File.lstat for path, or nil if the path does not exist. |
| 184 | + def safe_lstat(path) |
| 185 | + ::File.lstat(path) |
| 186 | + rescue ::SystemCallError |
| 187 | + nil |
| 188 | + end |
| 189 | + |
| 190 | + # Returns the contents of path as a string, or nil if the file cannot be read. |
| 191 | + def safe_read(path) |
| 192 | + ::File.read(path) |
| 193 | + rescue ::SystemCallError |
| 194 | + nil |
| 195 | + end |
| 196 | + |
| 197 | + # Joins paths, simplifying if either argument is "." |
| 198 | + def simplifying_join(path1, path2) |
| 199 | + if path1 == "." |
| 200 | + path2 |
| 201 | + elsif path2 == "." |
| 202 | + path1 |
| 203 | + else |
| 204 | + "#{path1}/#{path2}" |
| 205 | + end |
| 206 | + end |
| 207 | + |
| 208 | + # Writes content to a relative destination, appending to results. Skips unchanged files |
| 209 | + # without calling the block; calls the block for new or overwrite cases to confirm. |
| 210 | + def write_file(gh_pages_dir, relative_destination, content, results, &confirm) |
| 211 | + destination = ::File.expand_path(relative_destination, gh_pages_dir) |
| 212 | + stat = safe_lstat(destination) |
| 213 | + if stat |
| 214 | + if stat.file? && safe_read(destination) == content |
| 215 | + results << {destination: relative_destination, outcome: :unchanged} |
| 216 | + return |
| 217 | + end |
| 218 | + status = :overwrite |
| 219 | + ftype = stat.ftype |
| 220 | + else |
| 221 | + status = :new |
| 222 | + ftype = nil |
| 223 | + end |
| 224 | + proceed = confirm ? confirm.call(relative_destination, status, ftype) : true |
| 225 | + if proceed |
| 226 | + ::FileUtils.mkdir_p(::File.dirname(destination)) |
| 227 | + ::FileUtils.remove_entry(destination, true) if stat |
| 228 | + ::File.write(destination, content) |
| 229 | + results << {destination: relative_destination, outcome: :wrote} |
| 230 | + else |
| 231 | + results << {destination: relative_destination, outcome: :skipped} |
| 232 | + end |
| 233 | + end |
| 234 | + |
| 235 | + # Generates the v0 placeholder, component index, and latest/index redirect for one component. |
| 236 | + def generate_component_files(gh_pages_dir, template_dir, comp_settings, results, &confirm) |
| 237 | + version = current_component_version(gh_pages_dir, comp_settings) |
| 238 | + redirect_url = "https://#{component_base_path(comp_settings)}/v#{version}" |
| 239 | + subdir = comp_settings.gh_pages_directory |
| 240 | + |
| 241 | + write_file(gh_pages_dir, simplifying_join(subdir, "v0/index.html"), |
| 242 | + render_template(template_dir, "empty.html.erb", {name: comp_settings.name}), |
| 243 | + results, &confirm) |
| 244 | + write_file(gh_pages_dir, simplifying_join(subdir, "index.html"), |
| 245 | + render_template(template_dir, "redirect.html.erb", {redirect_url: redirect_url}), |
| 246 | + results, &confirm) |
| 247 | + write_file(gh_pages_dir, simplifying_join(subdir, "latest/index.html"), |
| 248 | + render_template(template_dir, "redirect.html.erb", {redirect_url: redirect_url}), |
| 249 | + results, &confirm) |
| 250 | + end |
| 251 | + |
| 252 | + # Generates .nojekyll, .gitignore, and (when no root component exists) the root index redirect. |
| 253 | + def generate_toplevel_files(gh_pages_dir, template_dir, results, &confirm) |
| 254 | + write_file(gh_pages_dir, ".nojekyll", "", results, &confirm) |
| 255 | + write_file(gh_pages_dir, ".gitignore", render_template(template_dir, "gitignore.erb", {}), results, &confirm) |
| 256 | + |
| 257 | + return if @enabled_component_settings.any? { |s| s.gh_pages_directory == "." } |
| 258 | + |
| 259 | + write_file(gh_pages_dir, "index.html", |
| 260 | + render_template(template_dir, "redirect.html.erb", {redirect_url: @default_redirect_url}), |
| 261 | + results, &confirm) |
| 262 | + end |
| 263 | + |
| 264 | + # Generates 404.html with version variables and redirect-replacement regexps for all components. |
| 265 | + def generate_html404(gh_pages_dir, template_dir, results, &confirm) |
| 266 | + version_vars = {} |
| 267 | + replacement_info = @enabled_component_settings.map do |comp_settings| |
| 268 | + version_vars[comp_settings.gh_pages_version_var] = |
| 269 | + current_component_version(gh_pages_dir, comp_settings) |
| 270 | + base_path = component_base_path(comp_settings) |
| 271 | + regexp_source = "//#{::Regexp.escape(base_path)}/latest(/|$)" |
| 272 | + CompInfo.new(base_path, regexp_source, comp_settings.gh_pages_version_var) |
| 273 | + end |
| 274 | + template_params = { |
| 275 | + default_redirect_url: @default_redirect_url, |
| 276 | + version_vars: version_vars, |
| 277 | + replacement_info: replacement_info, |
| 278 | + } |
| 279 | + write_file(gh_pages_dir, "404.html", render_template(template_dir, "404.html.erb", template_params), |
| 280 | + results, &confirm) |
| 281 | + end |
| 282 | + end |
| 283 | + end |
| 284 | +end |
0 commit comments