zard-doc 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4a7ff58279b52ea8ca3e15ffdf25f54214d20c5ececaeb641282e85050d3d5a
4
- data.tar.gz: 9ede39071e88157e47387cf29ae4b4c04a8af656c396e26c783824b671c52ce4
3
+ metadata.gz: 2bb123e1e6f985214a4916cb89bbec02b5bbb1ecd9e93ef56d4881b8ffa738bc
4
+ data.tar.gz: d6891457dad716acd147f3b63a2d1218688fd1c968f14e3f0f97cdef1dc9d1d4
5
5
  SHA512:
6
- metadata.gz: ed9d9d91a23b003910e34db882cfec727154822a60bf66c92e2f996bd9c2c94d0796c15e9603be569846b43985585eae8f74a24a722ca0fe3e60b7d985217ebe
7
- data.tar.gz: 9d9d1e2a24d28de2d6080bc397734ab2f1de2d4623dc9cc4c3e70335e3801c0e8ee6d3aeff7b48e36a1234fd3d950407eb660d7452e9ab0c0f31c2a1ec657d45
6
+ metadata.gz: 4b24539a15c3464ab8b92a168fb52523603aaa968716e67b5012c968a0aa49eea44a7629fb43e58d9dd1fda6ef587a1e9683acccd7a88ae1b17870eac4b8354e
7
+ data.tar.gz: 0024cafbd06fa78029dc2b9997d51ec0ee59db984e89d25ca34f82f8b59c0e69e522eafc16a94d0347dcf3a19ba2cd01b5f32ec8bc252be3b91e43027c6cc96f
data/README.md CHANGED
@@ -2,18 +2,11 @@
2
2
 
3
3
  `zard-doc` renders API documentation from the versioned model produced by `zard`.
4
4
 
5
- See the [ZARD usage guide](https://github.com/rigortype/zard/blob/master/docs/usage.md) for installation, canonical tag syntax, model access, limitations, and exit codes. The `0.0.1` candidate is not yet published on RubyGems. Until then, install both gems from the repository:
5
+ See the [ZARD usage guide](https://github.com/rigortype/zard/blob/master/docs/usage.md) for installation, canonical tag syntax, model access, limitations, and exit codes. Install the 0.0.2 pair with Bundler:
6
6
 
7
7
  ```ruby
8
- gem "zard", github: "rigortype/zard"
9
- gem "zard-doc", github: "rigortype/zard"
10
- ```
11
-
12
- After publication, use the released pair:
13
-
14
- ```ruby
15
- gem "zard", "~> 0.0.1"
16
- gem "zard-doc", "~> 0.0.1"
8
+ gem "zard", "~> 0.0.2"
9
+ gem "zard-doc", "~> 0.0.2"
17
10
  ```
18
11
 
19
12
  ```ruby
@@ -39,6 +32,20 @@ Render Markdown to standard output with:
39
32
  zard-doc render lib
40
33
  ```
41
34
 
35
+ Version 0.0.2 provides hierarchical monorepo documentation with `zard-doc build --config zard-doc.yml`. Named scopes separate applications; namespace or file layouts, direct documentation dependencies, and managed output updates are described in the [usage guide](https://github.com/rigortype/zard/blob/master/docs/usage.md#build-a-documentation-tree). Optional experimental Rigor lens integration is provided by the separate `zard-rigor` gem.
36
+
37
+ `zard-doc build --config zard-doc.yml --format json` exports one experimental JSON document to stdout instead of publishing Markdown. It includes public declarations with or without prose, unparsed contracts, source provenance and digests, and scoped page assignments and links. See the [JSON export contract](https://github.com/rigortype/zard/blob/master/docs/json-export.md). The export needs no Rigor installation and is not a Rigor lens result.
38
+
39
+ Version 0.0.2 also provides annotation formatting:
40
+
41
+ ```console
42
+ zard-doc format lib/example.rb
43
+ zard-doc format --check lib
44
+ zard-doc format --write lib
45
+ ```
46
+
47
+ Formatting preserves contract payloads, descriptions, and line endings. Ambiguous documentation and unsafe contract reorderings retain their text with diagnostics. Plain mode prints one input; check mode exits with 1 when changes are needed; write mode validates all inputs before changing files. Source errors exit with 1 and invocation or I/O errors with 2. Formatting diagnostics go to standard error.
48
+
42
49
  Directories are searched recursively for Ruby source files. Files are processed in stable sorted order and duplicate paths are ignored.
43
50
  Unreadable inputs are reported together; rendering never emits partial Markdown when any input cannot be read.
44
51
  Use `-` as a path to read Ruby source from standard input.
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zard/doc"
4
+
5
+ module Zard
6
+ module Doc
7
+ class BuildError < StandardError
8
+ attr_reader :status
9
+
10
+ def initialize(message, status: 2)
11
+ super(message)
12
+ @status = status
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+ require_relative "build_configuration"
19
+ require_relative "build_publisher"
20
+ require_relative "build_renderer"
21
+ require_relative "build_export"
22
+
23
+ module Zard
24
+ module Doc
25
+ class Build
26
+ attr_reader :diagnostics
27
+
28
+ def initialize(path, layout: nil, scopes: [], format: "markdown", stdout: $stdout, enricher: nil)
29
+ raise BuildError, "format must be markdown or json" unless %w[markdown json].include?(format)
30
+
31
+ @configuration = BuildConfiguration.new(path, layout: layout, scopes: scopes)
32
+ @format = format
33
+ @stdout = stdout
34
+ @enricher = enricher
35
+ @partial = !scopes.empty?
36
+ @diagnostics = []
37
+ end
38
+
39
+ def run
40
+ files = {}
41
+ documents = {}
42
+ digests = {}
43
+ input_errors = []
44
+ @configuration.selected_scopes.each do |name|
45
+ files[name] = @configuration.source_files(name)
46
+ documents[name] = files.fetch(name).keys.filter_map do |path|
47
+ source = File.read(path)
48
+ digests[path] = Digest::SHA256.hexdigest(source)
49
+ document = Zard.parse(source, path: path)
50
+ document.diagnostics.each { |diagnostic| @diagnostics << format_diagnostic(diagnostic) }
51
+ document
52
+ rescue SystemCallError => error
53
+ input_errors << "#{path}: #{error.message}"
54
+ nil
55
+ end
56
+ end
57
+ @diagnostics.concat(input_errors)
58
+ return 2 unless input_errors.empty?
59
+ return 1 if documents.values.flatten.any? { |document| document.diagnostics.any? { |diagnostic| diagnostic.severity == :error } }
60
+
61
+ renderer = BuildRenderer.new(scope_documents: documents, scope_files: files,
62
+ scopes: @configuration.scopes, layout: @configuration.layout, include_undocumented: @format == "json")
63
+ pages = renderer.render
64
+ @diagnostics.concat(renderer.diagnostics)
65
+ if @format == "json"
66
+ export = BuildExport.new(@configuration, documents, digests)
67
+ json = export.render(renderer, enricher: @enricher)
68
+ @diagnostics |= renderer.diagnostics
69
+ @stdout.write(json)
70
+ return 0
71
+ end
72
+
73
+ destinations = @configuration.selected_scopes.to_h do |name|
74
+ [name, @configuration.scopes.fetch(name).fetch(:destination)]
75
+ end
76
+ publisher = BuildPublisher.new(@configuration.docroot)
77
+ if @partial
78
+ pages["index.md"] = scope_index(publisher.scopes.merge(destinations))
79
+ publisher.publish(pages, selected_scopes: destinations, links: renderer.links)
80
+ else
81
+ publisher.publish(pages, scopes: destinations, links: renderer.links)
82
+ end
83
+ 0
84
+ end
85
+
86
+ private
87
+
88
+ def scope_index(destinations)
89
+ links = destinations.sort.map do |name, destination|
90
+ label = name.gsub(/[&<>]/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;").gsub(/[\\\[\]`*_~]/) { |character| "\\#{character}" }
91
+ path = "#{destination}/index.md".b.gsub(/[^A-Za-z0-9._~\/-]/) { |byte| "%%%02X" % byte.ord }
92
+ "- [#{label}](#{path})"
93
+ end
94
+ "# API documentation\n\n#{links.join("\n")}\n"
95
+ end
96
+
97
+ def format_diagnostic(diagnostic)
98
+ span = diagnostic.span
99
+ "#{span.path}:#{span.start_line}:#{span.start_column + 1}: #{diagnostic.severity} #{diagnostic.code} #{diagnostic.message}"
100
+ end
101
+ end
102
+
103
+ private_constant :BuildError, :BuildConfiguration, :BuildPublisher, :BuildRenderer, :BuildExport, :Build
104
+ end
105
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+
6
+ module Zard
7
+ module Doc
8
+ class BuildConfiguration
9
+ attr_reader :base, :docroot, :layout, :selected_scopes, :scopes
10
+
11
+ def initialize(path, layout: nil, scopes: [])
12
+ base = @base = File.realpath(File.dirname(File.expand_path(path)))
13
+ text = File.read(path)
14
+ validate_yaml_keys(Psych.parse_stream(text))
15
+ raw = YAML.safe_load(text, permitted_classes: [], permitted_symbols: [], aliases: false)
16
+ mapping(raw, %w[docroot layout scopes], "configuration")
17
+ @docroot = File.expand_path(string(raw["docroot"], "docroot"), base)
18
+ @layout = layout || raw.fetch("layout", "namespace")
19
+ fail_build("layout must be namespace or file") unless %w[namespace file].include?(@layout)
20
+ definitions = raw["scopes"]
21
+ fail_build("scopes must be a nonempty mapping") unless definitions.is_a?(Hash) && !definitions.empty?
22
+ @scopes = definitions.to_h do |name, definition|
23
+ string(name, "scope name")
24
+ mapping(definition, %w[destination references sources], "scope #{name}")
25
+ sources = definition["sources"]
26
+ fail_build("scope #{name} sources must be a nonempty array") unless sources.is_a?(Array) && !sources.empty?
27
+ [name, {destination: relative(definition["destination"], "destination"),
28
+ references: strings(definition.fetch("references", []), "references"),
29
+ sources: sources.map { |source| source_mapping(source, base) }}]
30
+ end
31
+ @scopes.each do |name, definition|
32
+ definition[:references].each { |reference| fail_build("unknown dependency #{reference} in scope #{name}") unless @scopes.key?(reference) }
33
+ end
34
+ requested = scopes.empty? ? @scopes.keys : scopes
35
+ @selected_scopes = []
36
+ visit = lambda do |name|
37
+ fail_build("unknown scope #{name}") unless @scopes.key?(name)
38
+ next if @selected_scopes.include?(name)
39
+ @selected_scopes << name
40
+ @scopes.fetch(name)[:references].sort.each { |reference| visit.call(reference) }
41
+ end
42
+ requested.sort.each { |name| visit.call(name) }
43
+ @selected_scopes.sort!
44
+ @selected_scopes.combination(2) do |left, right|
45
+ a = @scopes.fetch(left).fetch(:destination)
46
+ b = @scopes.fetch(right).fetch(:destination)
47
+ fail_build("overlapping scope destinations: #{left} and #{right}") if a == b || a.start_with?(b + "/") || b.start_with?(a + "/")
48
+ end
49
+ rescue Psych::Exception, SystemCallError, ArgumentError => error
50
+ fail_build("cannot load configuration #{path}: #{error.message}")
51
+ end
52
+
53
+ def source_files(name)
54
+ definition = @scopes.fetch(name)
55
+ roots = definition[:sources].map { |source| File.realpath(source[:root]) }
56
+ files = {}
57
+ definition[:sources].each do |source|
58
+ source[:include].flat_map { |glob| Dir.glob(glob, base: source[:root], flags: File::FNM_DOTMATCH) }.uniq.sort.each do |relative_path|
59
+ next unless relative_path.end_with?(".rb")
60
+ next if source[:exclude].any? { |glob| File.fnmatch?(glob, relative_path, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH) }
61
+ path = File.join(source[:root], relative_path)
62
+ canonical = File.realpath(path)
63
+ fail_build("source symlink escapes declared roots: #{path}") unless roots.any? { |root| canonical.start_with?(root + File::SEPARATOR) }
64
+ next unless File.file?(canonical)
65
+ fail_build("unreadable source: #{path}") unless File.readable?(canonical)
66
+ output = [source[:prefix], relative_path.sub(/\.rb\z/, ".md")].reject(&:empty?).join("/")
67
+ fail_build("conflicting output assignments for #{canonical}") if files.key?(canonical) && files[canonical] != output
68
+ files[canonical] = output
69
+ end
70
+ end
71
+ fail_build("scope #{name} selects no Ruby files") if files.empty?
72
+ files.sort.to_h
73
+ rescue SystemCallError => error
74
+ fail_build("cannot discover scope #{name}: #{error.message}")
75
+ end
76
+
77
+ private
78
+
79
+ def validate_yaml_keys(node)
80
+ if node.is_a?(Psych::Nodes::Mapping)
81
+ keys = node.children.each_slice(2).map(&:first)
82
+ values = keys.map { |key| key.respond_to?(:value) ? key.value : nil }
83
+ fail_build("duplicate or invalid YAML mapping key") if values.include?(nil) || values.uniq.length != values.length
84
+ end
85
+ node.children&.each { |child| validate_yaml_keys(child) }
86
+ end
87
+
88
+ def source_mapping(source, base)
89
+ mapping(source, %w[root include exclude prefix], "source")
90
+ root = File.expand_path(string(source["root"], "source root"), base)
91
+ fail_build("missing source root: #{root}") unless File.directory?(root)
92
+ includes = strings(source.fetch("include", ["**/*.rb"]), "include")
93
+ excludes = strings(source.fetch("exclude", []), "exclude")
94
+ (includes + excludes).each { |glob| relative(glob, "source glob") }
95
+ {root: root, include: includes, exclude: excludes, prefix: relative(source.fetch("prefix", ""), "prefix", empty: true)}
96
+ end
97
+
98
+ def mapping(value, keys, label)
99
+ fail_build("#{label} must be a mapping") unless value.is_a?(Hash)
100
+ unknown = value.keys - keys
101
+ fail_build("unknown #{label} fields: #{unknown.join(", ")}") unless unknown.empty?
102
+ end
103
+
104
+ def strings(value, label)
105
+ fail_build("#{label} must be an array") unless value.is_a?(Array)
106
+ value.each { |entry| string(entry, label) }
107
+ value
108
+ end
109
+
110
+ def string(value, label)
111
+ fail_build("#{label} must be a nonempty string") unless value.is_a?(String) && !value.empty? && !value.include?("\0")
112
+ value
113
+ end
114
+
115
+ def relative(value, label, empty: false)
116
+ return "" if empty && value == ""
117
+ string(value, label)
118
+ fail_build("#{label} must be a relative nonescaping path: #{value}") if Pathname.new(value).absolute? || value.split(/[\\\/]/).any? { |part| part == ".." || part == "." } || value.include?("\\")
119
+ value.sub(%r{/+\z}, "")
120
+ end
121
+
122
+ def fail_build(message)
123
+ raise BuildError.new(message, status: 2)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ module Zard
7
+ module Doc
8
+ class BuildExport
9
+ def initialize(configuration, documents, digests)
10
+ @configuration = configuration
11
+ @documents = documents
12
+ @digests = digests
13
+ end
14
+
15
+ def render(renderer, enricher: nil)
16
+ declarations = renderer.export_declarations
17
+ data = {schema: "zard-doc/v0", revision: 1,
18
+ producer: {zard: Zard::VERSION, zard_doc: Zard::Doc::VERSION},
19
+ layout: @configuration.layout,
20
+ scopes: @configuration.selected_scopes.map { |name| scope(name, declarations.fetch(name)) },
21
+ links: renderer.links.sort.to_h,
22
+ warnings: renderer.diagnostics.map { |message| relative_message(message) }}
23
+ data = enricher.call(data, base: @configuration.base) if enricher
24
+ JSON.pretty_generate(data) + "\n"
25
+ rescue JSON::GeneratorError => error
26
+ raise BuildError, "cannot export documentation JSON: #{error.message}"
27
+ end
28
+
29
+ private
30
+
31
+ def scope(name, entries)
32
+ config = @configuration.scopes.fetch(name)
33
+ declarations = entries.map { |entry| declaration(name, entry) }
34
+ {name: name, destination: config.fetch(:destination),
35
+ references: config.fetch(:references).uniq.sort,
36
+ files: @documents.fetch(name).sort_by(&:path).map { |document| source_file(document) },
37
+ declarations: declarations,
38
+ pages: declarations.group_by { |item| item.fetch(:page) }.sort.map do |path, items|
39
+ {path: path, declarations: items.map { |item| item.fetch(:id) }}
40
+ end}
41
+ end
42
+
43
+ def source_file(document)
44
+ {path: relative_path(document.path), digest: @digests.fetch(document.path),
45
+ diagnostics: document.diagnostics.map do |diagnostic|
46
+ {code: diagnostic.code, severity: diagnostic.severity,
47
+ message: relative_message(diagnostic.message), span: span(diagnostic.span)}
48
+ end}
49
+ end
50
+
51
+ def declaration(scope, entry)
52
+ item = entry.fetch(:declaration)
53
+ {id: "#{scope}:#{entry.fetch(:anchor)}", kind: item.kind, name: item.name,
54
+ namespace: item.namespace, display_name: entry.fetch(:display_name),
55
+ visibility: item.visibility, parameters: item.parameters,
56
+ parameter_details: item.parameter_details&.map do |parameter|
57
+ {kind: parameter.kind, name: parameter.name, source: parameter.source, span: span(parameter.span),
58
+ default: parameter.default, default_span: span(parameter.default_span)}
59
+ end,
60
+ page: entry.fetch(:page), anchor: entry.fetch(:anchor), references: entry.fetch(:references),
61
+ receiver: item.receiver, receiver_span: span(item.receiver_span),
62
+ refinement: item.refinement, refinement_span: span(item.refinement_span),
63
+ alias_target: item.alias_target, superclass: item.superclass, superclass_span: span(item.superclass_span),
64
+ container_builder: item.container_builder, container_builder_span: span(item.container_builder_span),
65
+ mixins: item.mixins.map { |mixin| {kind: mixin.kind, target: mixin.target, span: span(mixin.span)} },
66
+ relationships: entry.fetch(:relationships).map { |relationship| relationship.merge(span: span(relationship.fetch(:span))) },
67
+ span: span(item.span), comment_span: span(item.comment_span),
68
+ documentation: item.documentation.map { |tag| documentation(tag, entry.fetch(:descriptions).fetch(tag)) },
69
+ contracts: item.contracts.map { |contract| contract(contract) }}
70
+ end
71
+
72
+ def documentation(tag, rendered)
73
+ {name: tag.name, owner: tag.owner, subject: tag.subject, claim: tag.claim,
74
+ description: tag.description, rendered_description: rendered, raw: tag.raw, span: span(tag.span)}
75
+ end
76
+
77
+ def contract(item)
78
+ {channel: item.channel, payload: item.payload, note: item.note, raw: item.raw, span: span(item.span)}
79
+ end
80
+
81
+ def span(item)
82
+ return unless item
83
+
84
+ {path: relative_path(item.path), start_line: item.start_line, end_line: item.end_line,
85
+ start_column: item.start_column, end_column: item.end_column,
86
+ start_offset: item.start_offset, end_offset: item.end_offset}
87
+ end
88
+
89
+ def relative_path(path)
90
+ Pathname.new(path).relative_path_from(Pathname.new(@configuration.base)).to_s
91
+ end
92
+
93
+ def relative_message(message)
94
+ @digests.keys.sort_by { |path| -path.length }.reduce(message) do |text, path|
95
+ text.gsub(path, relative_path(path))
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "tmpdir"
7
+ require "pathname"
8
+
9
+ module Zard
10
+ module Doc
11
+ class BuildPublisher
12
+ MANIFEST = ".zard-manifest.json"
13
+
14
+ def initialize(docroot)
15
+ expanded = File.expand_path(docroot)
16
+ fail_build("output symlink is not allowed: #{expanded}") if File.symlink?(expanded)
17
+ ancestor = File.dirname(expanded)
18
+ suffix = [File.basename(expanded)]
19
+ until File.exist?(ancestor) || File.symlink?(ancestor)
20
+ suffix.unshift(File.basename(ancestor))
21
+ ancestor = File.dirname(ancestor)
22
+ end
23
+ @docroot = File.join(File.realpath(ancestor), *suffix)
24
+ @case_insensitive = Dir.mktmpdir(".zard-case-", File.realpath(ancestor)) do |probe|
25
+ alternate = File.join(File.dirname(probe), File.basename(probe).swapcase)
26
+ File.exist?(alternate) && File.identical?(probe, alternate)
27
+ end
28
+ rescue SystemCallError => error
29
+ fail_build("cannot resolve documentation root: #{error.message}")
30
+ end
31
+
32
+ def scopes
33
+ manifest.fetch("scopes")
34
+ end
35
+
36
+ def publish(pages, selected_scopes: nil, scopes: {}, links: {})
37
+ previous = manifest
38
+ old_pages = previous.fetch("pages")
39
+ old_pages.each do |path, checksum|
40
+ target = safe_path(path)
41
+ fail_build("managed page was edited or removed: #{target}; restore it before rebuilding") unless File.file?(target) && Digest::SHA256.file(target).hexdigest == checksum
42
+ end
43
+ scope_map = selected_scopes ? previous.fetch("scopes").merge(selected_scopes) : scopes
44
+ validate_scopes(scope_map)
45
+ destinations = selected_scopes&.flat_map { |name, destination| [destination, previous.fetch("scopes")[name]] }&.compact
46
+ removed = old_pages.keys.select { |path| !selected_scopes || path == "index.md" || destinations.any? { |destination| path.start_with?(destination + "/") } }
47
+ retained = old_pages.except(*removed)
48
+ if selected_scopes && !retained.empty? && previous["version"] == 1
49
+ fail_build("retained pages lack link metadata; run a full build before rebuilding individual scopes")
50
+ end
51
+ pages.each do |path, content|
52
+ target = safe_path(path)
53
+ fail_build("page content must be text: #{path}") unless content.is_a?(String)
54
+ fail_build("output collides with retained scope: #{path}") if retained.key?(path)
55
+ if File.exist?(target) && !old_pages.key?(path)
56
+ owner = old_pages.keys.find { |old| File.identical?(safe_path(old), target) }
57
+ fail_build("output collides with retained scope: #{path}") if owner && retained.key?(owner)
58
+ fail_build("output collides with unowned file: #{target}") unless owner
59
+ end
60
+ end
61
+ all_paths = retained.keys + pages.keys
62
+ filesystem_paths = all_paths.map { |path| @case_insensitive ? path.downcase : path }
63
+ fail_build("output paths collide on this filesystem") if filesystem_paths.uniq.length != filesystem_paths.length
64
+ filesystem_paths.each { |path| fail_build("output file/directory collision: #{path}") if filesystem_paths.any? { |other| other.start_with?(path + "/") } }
65
+ checksums = retained.merge(pages.transform_values { |content| Digest::SHA256.hexdigest(content) })
66
+ combined_links = previous.fetch("links", {}).select { |path, _| retained.key?(path) }.merge(links)
67
+ validate_links(combined_links, checksums)
68
+ validate_link_destinations(combined_links, pages, retained)
69
+ payload = JSON.pretty_generate({"version" => 2, "pages" => checksums.sort.to_h, "scopes" => scope_map.sort.to_h, "links" => combined_links.sort.to_h}) + "\n"
70
+ transaction(pages.merge(MANIFEST => payload), (removed + pages.keys + [MANIFEST]).uniq)
71
+ rescue SystemCallError, IOError => error
72
+ fail_build("cannot publish documentation: #{error.message}")
73
+ end
74
+
75
+ private
76
+
77
+ def manifest
78
+ target = safe_path(MANIFEST, manifest: true)
79
+ return {"version" => 2, "pages" => {}, "scopes" => {}, "links" => {}} unless File.exist?(target)
80
+ fail_build("invalid manifest: #{target}") unless File.file?(target)
81
+ data = JSON.parse(File.read(target))
82
+ valid = data.is_a?(Hash) && data["pages"].is_a?(Hash) &&
83
+ ((data["version"] == 1 && data.keys.sort == %w[pages scopes version]) ||
84
+ (data["version"] == 2 && data.keys.sort == %w[links pages scopes version]))
85
+ fail_build("invalid manifest schema: #{target}") unless valid
86
+ data["pages"].each do |path, checksum|
87
+ safe_path(path)
88
+ fail_build("invalid manifest checksum for #{path}") unless checksum.is_a?(String) && /\A[0-9a-f]{64}\z/.match?(checksum)
89
+ end
90
+ validate_scopes(data["scopes"])
91
+ validate_links(data["links"], data["pages"]) if data["version"] == 2
92
+ data
93
+ rescue JSON::ParserError, SystemCallError => error
94
+ fail_build("cannot read generation manifest: #{error.message}")
95
+ end
96
+
97
+ def validate_links(links, pages)
98
+ fail_build("invalid manifest links") unless links.is_a?(Hash)
99
+ links.each do |source, targets|
100
+ fail_build("invalid manifest link source: #{source}") unless pages.key?(source) && targets.is_a?(Array)
101
+ targets.each do |target|
102
+ valid = target.is_a?(Hash) && target.keys.sort == %w[anchor path] &&
103
+ pages.key?(target["path"]) && target["anchor"].is_a?(String) && /\Azard-[0-9a-f]+\z/.match?(target["anchor"])
104
+ fail_build("retained or generated page #{source} has an invalid link target; include affected scopes or run a full build") unless valid
105
+ end
106
+ end
107
+ end
108
+
109
+ def validate_link_destinations(links, pages, retained)
110
+ contents = {}
111
+ links.each do |source, targets|
112
+ targets.each do |target|
113
+ path = target.fetch("path")
114
+ text = (contents[path] ||= pages.fetch(path) { File.read(safe_path(path)) })
115
+ next if text.include?(%(<a id="#{target.fetch("anchor")}"></a>))
116
+
117
+ label = retained.key?(source) ? "retained" : "generated"
118
+ fail_build("#{label} page #{source} links to a missing anchor in #{path}; include affected scopes or run a full build")
119
+ end
120
+ end
121
+ end
122
+
123
+ def validate_scopes(scope_map)
124
+ fail_build("invalid manifest scopes") unless scope_map.is_a?(Hash)
125
+ scope_map.each do |name, destination|
126
+ fail_build("invalid scope name") unless name.is_a?(String) && !name.empty?
127
+ safe_path(destination)
128
+ end
129
+ scope_map.to_a.combination(2) do |(left, a), (right, b)|
130
+ a = a.downcase if @case_insensitive
131
+ b = b.downcase if @case_insensitive
132
+ fail_build("overlapping scope destinations: #{left} and #{right}") if a == b || a.start_with?(b + "/") || b.start_with?(a + "/")
133
+ end
134
+ end
135
+
136
+ def safe_path(path, manifest: false)
137
+ invalid = !path.is_a?(String) || path.empty? || path.include?("\0") || path.include?("\\") || Pathname.new(path).absolute? || path.split("/", -1).any? { |part| ["", ".", ".."].include?(part) }
138
+ fail_build("unsafe output path: #{path.inspect}") if invalid || (!manifest && path == MANIFEST)
139
+ target = File.join(@docroot, path)
140
+ current = target
141
+ loop do
142
+ fail_build("output symlink is not allowed: #{current}") if File.symlink?(current)
143
+ parent = File.dirname(current)
144
+ break if parent == current
145
+ current = parent
146
+ end
147
+ target
148
+ end
149
+
150
+ def transaction(pages, affected)
151
+ FileUtils.mkdir_p(File.dirname(@docroot))
152
+ staging = Dir.mktmpdir(".zard-build-", File.dirname(@docroot))
153
+ backup = File.join(staging, "backup")
154
+ prepared = File.join(staging, "prepared")
155
+ existed = {}
156
+ affected.each do |path|
157
+ target = safe_path(path, manifest: path == MANIFEST)
158
+ existed[path] = File.file?(target)
159
+ next unless existed[path]
160
+ saved = File.join(backup, path)
161
+ FileUtils.mkdir_p(File.dirname(saved))
162
+ FileUtils.cp(target, saved)
163
+ end
164
+ pages.each do |path, content|
165
+ target = File.join(prepared, path)
166
+ FileUtils.mkdir_p(File.dirname(target))
167
+ File.write(target, content)
168
+ end
169
+ changed = []
170
+ begin
171
+ affected.each do |path|
172
+ target = safe_path(path, manifest: path == MANIFEST)
173
+ changed << path
174
+ if pages.key?(path)
175
+ FileUtils.mkdir_p(File.dirname(target))
176
+ File.rename(File.join(prepared, path), target)
177
+ elsif File.exist?(target)
178
+ File.unlink(target)
179
+ end
180
+ end
181
+ rescue SystemCallError, IOError, BuildError => error
182
+ begin
183
+ changed.reverse_each do |path|
184
+ target = safe_path(path, manifest: path == MANIFEST)
185
+ if existed[path]
186
+ FileUtils.cp(File.join(backup, path), target)
187
+ elsif File.file?(target)
188
+ File.unlink(target)
189
+ end
190
+ end
191
+ rescue SystemCallError, IOError, BuildError => recovery_error
192
+ retained_backup = true
193
+ fail_build("publication failed: #{error.message}; recovery failed: #{recovery_error.message}; restore files from #{backup}")
194
+ end
195
+ fail_build("publication failed; previous pages restored: #{error.message}")
196
+ end
197
+ ensure
198
+ FileUtils.remove_entry(staging) if staging && !retained_backup
199
+ end
200
+
201
+ def fail_build(message)
202
+ raise BuildError.new(message, status: 2)
203
+ end
204
+ end
205
+ end
206
+ end