Skip to content

Commit c419a6c

Browse files
committed
refactor(release): Extract gh-pages file manipulation code into a tested class
1 parent 3c59ae5 commit c419a6c

File tree

5 files changed

+931
-168
lines changed

5 files changed

+931
-168
lines changed
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
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

toys-release/toys/.lib/toys/release/steps.rb

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "fileutils"
4+
require "toys/release/gh_pages_logic"
45
require "toys/utils/gems"
56

67
module Toys
@@ -300,8 +301,12 @@ def run(step_context)
300301
dest_dir = ::File.join(component_dir, "v#{step_context.release_version}")
301302
check_existence(step_context, dest_dir)
302303
copy_docs_dir(step_context, dest_dir)
303-
update_404_page(step_context, gh_pages_dir)
304-
update_index_pages(step_context, gh_pages_dir)
304+
logic = ::Toys::Release::GhPagesLogic.new(step_context.repository.settings)
305+
logic.update_version_pages(
306+
gh_pages_dir,
307+
step_context.component.settings,
308+
step_context.release_version
309+
) { |msg| step_context.warning(msg) }
305310
push_docs_to_git(step_context, gh_pages_dir)
306311
end
307312

@@ -341,39 +346,6 @@ def copy_docs_dir(step_context, dest_dir)
341346
::FileUtils.cp_r(source_dir, dest_dir)
342347
end
343348

344-
def update_404_page(step_context, gh_pages_dir)
345-
path = ::File.join(gh_pages_dir, "404.html")
346-
unless ::File.file?(path)
347-
step_context.warning("404.html not found. Skipping.")
348-
return
349-
end
350-
content = ::File.read(path)
351-
version_var = step_context.component.settings.gh_pages_version_var
352-
content.sub!(/#{Regexp.escape(version_var)} = "[\w.]+";/,
353-
"#{version_var} = \"#{step_context.release_version}\";")
354-
::File.write(path, content)
355-
end
356-
357-
def update_index_pages(step_context, gh_pages_dir)
358-
subdir = step_context.component.settings.gh_pages_directory
359-
dir_suffix = subdir == "." ? "" : "/#{subdir}"
360-
settings = step_context.repository.settings
361-
version = step_context.release_version
362-
redirect_url = "https://#{settings.repo_owner}.github.io/#{settings.repo_name}#{dir_suffix}/v#{version}"
363-
["index.html", "latest/index.html"].each do |filename|
364-
path = "#{gh_pages_dir}#{dir_suffix}/#{filename}"
365-
unless ::File.file?(path)
366-
step_context.warning("#{path} not found. Skipping.")
367-
next
368-
end
369-
content = ::File.read(path)
370-
content.gsub!(/ href="[^"]+"/, " href=\"#{redirect_url}\"")
371-
content.gsub!(/ content="0; url=[^"]+"/, " content=\"0; url=#{redirect_url}\"")
372-
content.gsub!(/window\.location\.replace\("[^"]+"\)/, "window.location.replace(\"#{redirect_url}\")")
373-
::File.write(path, content)
374-
end
375-
end
376-
377349
def push_docs_to_git(step_context, gh_pages_dir)
378350
::Dir.chdir(gh_pages_dir) do
379351
step_context.repository.git_commit("Generated docs for #{step_context.release_description}",

toys-release/toys/.test/helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
$LOAD_PATH.unshift(::File.join(::File.dirname(__dir__), ".lib"))
1515

1616
require "toys/release/artifact_dir"
17+
require "toys/release/gh_pages_logic"
1718
require "toys/release/change_set"
1819
require "toys/release/changelog_file"
1920
require "toys/release/commit_info"

0 commit comments

Comments
 (0)