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 +4 -4
- data/README.md +17 -10
- data/lib/zard/doc/build.rb +105 -0
- data/lib/zard/doc/build_configuration.rb +127 -0
- data/lib/zard/doc/build_export.rb +100 -0
- data/lib/zard/doc/build_publisher.rb +206 -0
- data/lib/zard/doc/build_renderer.rb +306 -0
- data/lib/zard/doc/cli.rb +100 -2
- data/lib/zard/doc/reference_markup.rb +271 -0
- data/lib/zard/doc/renderer.rb +160 -21
- data/lib/zard/doc/version.rb +1 -1
- data/lib/zard/doc.rb +2 -1
- metadata +10 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2bb123e1e6f985214a4916cb89bbec02b5bbb1ecd9e93ef56d4881b8ffa738bc
|
|
4
|
+
data.tar.gz: d6891457dad716acd147f3b63a2d1218688fd1c968f14e3f0f97cdef1dc9d1d4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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",
|
|
9
|
-
gem "zard-doc",
|
|
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(/[&<>]/, "&" => "&", "<" => "<", ">" => ">").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
|