suma 0.5.9 → 0.5.10
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/.rubocop_todo.yml +27 -0
- data/CLAUDE.md +17 -5
- data/README.adoc +101 -0
- data/lib/suma/anchor_validator.rb +85 -0
- data/lib/suma/artifact_server.rb +102 -0
- data/lib/suma/cli/artifact_server_cli.rb +22 -0
- data/lib/suma/cli/build.rb +4 -0
- data/lib/suma/cli/core.rb +50 -4
- data/lib/suma/cli/export.rb +1 -1
- data/lib/suma/cli/extract_terms.rb +1 -1
- data/lib/suma/cli/generate_register.rb +1 -1
- data/lib/suma/cli/ingest_translations.rb +36 -0
- data/lib/suma/cli/package_gcr.rb +51 -0
- data/lib/suma/cli/validate.rb +16 -0
- data/lib/suma/cli.rb +4 -1
- data/lib/suma/closure_validator.rb +124 -20
- data/lib/suma/corpus_graph.rb +79 -16
- data/lib/suma/gcr_package.rb +230 -0
- data/lib/suma/link_validation_result.rb +8 -0
- data/lib/suma/link_validator.rb +0 -3
- data/lib/suma/output_canonicalizer.rb +107 -0
- data/lib/suma/processor.rb +94 -20
- data/lib/suma/provenance.rb +92 -12
- data/lib/suma/schema_collection.rb +1 -0
- data/lib/suma/schema_compiler.rb +79 -13
- data/lib/suma/schema_manifest_generator.rb +10 -3
- data/lib/suma/schema_profile.rb +12 -0
- data/lib/suma/schema_template/anchors.rb +154 -0
- data/lib/suma/schema_template/document.rb +2 -93
- data/lib/suma/schema_template/plain.rb +10 -6
- data/lib/suma/schema_template.rb +1 -0
- data/lib/suma/site_build.rb +153 -0
- data/lib/suma/staged_collection_builder.rb +11 -3
- data/lib/suma/translation_ingester.rb +169 -0
- data/lib/suma/version.rb +1 -1
- data/lib/suma.rb +7 -0
- metadata +12 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "zip"
|
|
5
|
+
require "glossarist"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Suma
|
|
9
|
+
# Packages extracted Glossarist concepts into a GCR (Glossarist Concept
|
|
10
|
+
# Repository) archive — the sealed distribution format consumed by the
|
|
11
|
+
# Glossarist Vocabulary Browser (docs/gcr-spec.md in concept-browser).
|
|
12
|
+
#
|
|
13
|
+
# Standard variant only: metadata.yaml + register.yaml + concepts/ in the
|
|
14
|
+
# browser's canonical flat format (termid, term, {lang} blocks). The -full
|
|
15
|
+
# variant (SKOS/TBX/JSONL compilations) is out of scope.
|
|
16
|
+
class GcrPackage
|
|
17
|
+
SCHEMA_VERSION = "1.0.0"
|
|
18
|
+
|
|
19
|
+
attr_reader :shortname, :version
|
|
20
|
+
|
|
21
|
+
# @param concepts_dir [String] TermExtractor output directory
|
|
22
|
+
# @param register_file [String] register.yaml (dataset identity)
|
|
23
|
+
# @param shortname [String] dataset id (datasets.yml `id`)
|
|
24
|
+
# @param version [String] semver of this dataset release
|
|
25
|
+
# @param title [String] human-readable dataset title
|
|
26
|
+
# @param description [String] dataset description
|
|
27
|
+
# @param created_at [Time, nil] package timestamp; pinned packages pass
|
|
28
|
+
# a fixed time so the archive is byte-reproducible
|
|
29
|
+
def initialize(concepts_dir:, register_file:, shortname:, version:,
|
|
30
|
+
title:, description:, created_at: nil)
|
|
31
|
+
@concepts_dir = File.expand_path(concepts_dir)
|
|
32
|
+
@register_file = File.expand_path(register_file)
|
|
33
|
+
@shortname = shortname
|
|
34
|
+
@version = version
|
|
35
|
+
@title = title
|
|
36
|
+
@description = description
|
|
37
|
+
@created_at = created_at || Time.now
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Write the versioned and unversioned .gcr archives.
|
|
41
|
+
#
|
|
42
|
+
# @param output_dir [String]
|
|
43
|
+
# @return [Array<String>] written archive paths
|
|
44
|
+
def write(output_dir)
|
|
45
|
+
FileUtils.mkdir_p(output_dir)
|
|
46
|
+
entries = package_entries
|
|
47
|
+
[versioned_name, unversioned_name].map do |name|
|
|
48
|
+
path = File.join(output_dir, name)
|
|
49
|
+
write_zip(path, entries)
|
|
50
|
+
Utils.log "Wrote GCR package: #{path}"
|
|
51
|
+
path
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Canonical concept hashes, in deterministic (termid) order.
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<Hash>]
|
|
58
|
+
def canonical_concepts
|
|
59
|
+
@canonical_concepts ||= concept_documents
|
|
60
|
+
.map { |document| canonicalize(document) }
|
|
61
|
+
.sort_by { |hash| hash["termid"] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Hash] metadata.yaml content
|
|
65
|
+
def metadata
|
|
66
|
+
register = Glossarist::DatasetRegister.from_file(@register_file)
|
|
67
|
+
{
|
|
68
|
+
"shortname" => @shortname,
|
|
69
|
+
"version" => @version,
|
|
70
|
+
"title" => @title,
|
|
71
|
+
"description" => @description,
|
|
72
|
+
"glossarist_version" => Glossarist::VERSION,
|
|
73
|
+
"created_at" => @created_at.utc.iso8601,
|
|
74
|
+
"created_by" => "suma CLI",
|
|
75
|
+
# Top-level summary keys as consumed by the Glossarist Vocabulary
|
|
76
|
+
# Browser and the Glossarist::GcrMetadata model.
|
|
77
|
+
"concept_count" => statistics["total_concepts"],
|
|
78
|
+
"languages" => statistics["languages"],
|
|
79
|
+
"uri" => register.urn,
|
|
80
|
+
"uri_aliases" => register.urn_aliases,
|
|
81
|
+
"statistics" => statistics,
|
|
82
|
+
"schema_version" => SCHEMA_VERSION,
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def statistics
|
|
87
|
+
languages = canonical_concepts
|
|
88
|
+
.flat_map { |hash| hash.keys - %w[termid term] }
|
|
89
|
+
.uniq.sort
|
|
90
|
+
{
|
|
91
|
+
"total_concepts" => canonical_concepts.length,
|
|
92
|
+
"languages" => languages,
|
|
93
|
+
"concepts_with_definitions" => concepts_with("definition"),
|
|
94
|
+
"concepts_with_sources" => concepts_with("sources"),
|
|
95
|
+
"concepts_by_status" => { "valid" => canonical_concepts.length },
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def concepts_with(key)
|
|
100
|
+
canonical_concepts.count do |hash|
|
|
101
|
+
hash.values.any? { |block| block.is_a?(Hash) && Array(block[key]).any? }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def concept_documents
|
|
108
|
+
@concept_documents ||= Dir[File.join(@concepts_dir,
|
|
109
|
+
"*.yaml")].map do |path|
|
|
110
|
+
Glossarist::V3::ConceptDocument.from_yamls(File.read(path))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Convert a V3 ConceptDocument stream to the browser's canonical flat
|
|
115
|
+
# concept hash (termid, term, one block per language).
|
|
116
|
+
def canonicalize(document)
|
|
117
|
+
concept = document.concept
|
|
118
|
+
hash = { "termid" => concept.data.id }
|
|
119
|
+
document.localizations.each do |localized|
|
|
120
|
+
data = localized.data
|
|
121
|
+
language = data.language_code
|
|
122
|
+
hash[language] = language_block(data)
|
|
123
|
+
hash["term"] ||= preferred_designation(data.terms)
|
|
124
|
+
end
|
|
125
|
+
validate_canonical!(hash)
|
|
126
|
+
hash
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def language_block(data)
|
|
130
|
+
block = { "terms" => term_entries(data) }
|
|
131
|
+
definitions = Array(data.definition)
|
|
132
|
+
unless definitions.empty?
|
|
133
|
+
block["definition"] =
|
|
134
|
+
definition_entries(definitions)
|
|
135
|
+
end
|
|
136
|
+
notes = Array(data.notes).map do |note|
|
|
137
|
+
note.respond_to?(:content) ? note.content : note.to_s
|
|
138
|
+
end
|
|
139
|
+
block["notes"] = notes unless notes.empty?
|
|
140
|
+
sources = Array(data.sources).map { |source| source_hash(source) }
|
|
141
|
+
block["sources"] = sources unless sources.empty?
|
|
142
|
+
block["language_code"] = data.language_code
|
|
143
|
+
block
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def term_entries(data)
|
|
147
|
+
Array(data.terms).map do |term|
|
|
148
|
+
{
|
|
149
|
+
"type" => term.type,
|
|
150
|
+
"designation" => term.designation,
|
|
151
|
+
"normative_status" => term.normative_status,
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def definition_entries(definitions)
|
|
157
|
+
definitions.map { |definition| { "content" => definition.content } }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def source_hash(source)
|
|
161
|
+
ref = source.origin&.ref
|
|
162
|
+
origin = {}
|
|
163
|
+
if ref
|
|
164
|
+
origin["ref"] =
|
|
165
|
+
{ "source" => ref.source, "version" => ref.version }.compact
|
|
166
|
+
end
|
|
167
|
+
{ "type" => source.type, "origin" => origin }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def preferred_designation(terms)
|
|
171
|
+
preferred = Array(terms).find do |term|
|
|
172
|
+
term.normative_status == "preferred"
|
|
173
|
+
end
|
|
174
|
+
(preferred || Array(terms).first)&.designation
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def validate_canonical!(hash)
|
|
178
|
+
termid = hash["termid"]
|
|
179
|
+
unless termid.is_a?(String) && !termid.empty?
|
|
180
|
+
raise Error, "concept without termid"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
return if language_blocks(hash).any? do |block|
|
|
184
|
+
Array(block["terms"]).any?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
raise Error, "concept #{termid} has no language block with a term"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def language_blocks(hash)
|
|
191
|
+
hash.values.grep(Hash)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def package_entries
|
|
195
|
+
entries = {
|
|
196
|
+
"metadata.yaml" => "#{YAML.dump(metadata)}\n",
|
|
197
|
+
"register.yaml" => File.read(@register_file),
|
|
198
|
+
}
|
|
199
|
+
seen = {}
|
|
200
|
+
canonical_concepts.each do |hash|
|
|
201
|
+
termid = hash["termid"]
|
|
202
|
+
if seen[termid]
|
|
203
|
+
raise Error, "duplicate termid in package: #{termid}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
seen[termid] = true
|
|
207
|
+
entries["concepts/#{termid}.yaml"] = YAML.dump(hash)
|
|
208
|
+
end
|
|
209
|
+
entries
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def versioned_name
|
|
213
|
+
"#{@shortname}-#{@version}.gcr"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def unversioned_name
|
|
217
|
+
"#{@shortname}.gcr"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def write_zip(path, entries)
|
|
221
|
+
buffer = Zip::OutputStream.write_buffer do |stream|
|
|
222
|
+
entries.each do |name, content|
|
|
223
|
+
stream.put_next_entry(name)
|
|
224
|
+
stream.write(content)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
File.binwrite(path, buffer.string)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
data/lib/suma/link_validator.rb
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "uuidtools"
|
|
5
|
+
|
|
6
|
+
module Suma
|
|
7
|
+
# Removes Metanorma's nondeterminism from collection output.
|
|
8
|
+
#
|
|
9
|
+
# Metanorma assigns a random UUID to every collection manifest node that
|
|
10
|
+
# lacks an identifier (Manifest#identifier's default) and stamps each
|
|
11
|
+
# rendered document with a random 8-letter type marker
|
|
12
|
+
# (collection/xrefprocess). Both leak into the emitted manifest and the XML
|
|
13
|
+
# output, so two identical builds produce different bytes.
|
|
14
|
+
#
|
|
15
|
+
# Manifest identifiers are replaced IN THE MODEL before serialization:
|
|
16
|
+
# Psych's quoting of the resulting strings is value-dependent (a few
|
|
17
|
+
# percent of UUID-looking values get single-quoted), so patching the dumped
|
|
18
|
+
# YAML afterwards would preserve a random quoting decision. Values that
|
|
19
|
+
# appear in the source YAML are preserved.
|
|
20
|
+
class OutputCanonicalizer
|
|
21
|
+
UUID_PATTERN =
|
|
22
|
+
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
|
|
23
|
+
FULL_UUID_PATTERN = /\A#{UUID_PATTERN.source}\z/
|
|
24
|
+
CANONICAL_TYPE = "SUMASUMA"
|
|
25
|
+
NAMESPACE = UUIDTools::UUID.parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
|
|
26
|
+
|
|
27
|
+
def initialize(output_directory:, sources: [])
|
|
28
|
+
@output_directory = File.expand_path(output_directory)
|
|
29
|
+
@sources = sources.map { |path| File.expand_path(path) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Replace generated manifest identifiers with deterministic UUIDv5s.
|
|
33
|
+
# Must run before the manifest is serialized and the site key computed.
|
|
34
|
+
def canonicalize_manifest(manifest)
|
|
35
|
+
canonicalize_node(manifest, "collection-manifest")
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Canonicalize the rendered site's root XML documents.
|
|
40
|
+
def canonicalize_documents
|
|
41
|
+
Dir.glob(File.join(@output_directory, "*.xml")).each do |file|
|
|
42
|
+
canonicalize_document(file)
|
|
43
|
+
end
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def canonicalize_node(node, seed)
|
|
50
|
+
return unless node
|
|
51
|
+
|
|
52
|
+
if generated_uuid?(node.identifier)
|
|
53
|
+
node.identifier = uuid_v5("suma-manifest:#{seed}").to_s
|
|
54
|
+
end
|
|
55
|
+
Array(node.entry).each_with_index do |child, index|
|
|
56
|
+
canonicalize_node(child, "#{seed}/#{index}")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def generated_uuid?(value)
|
|
61
|
+
value.to_s.match?(FULL_UUID_PATTERN) && !preserved.include?(value)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def canonicalize_document(file)
|
|
65
|
+
content = File.read(file)
|
|
66
|
+
canonical = replace_uuids(content, File.basename(file))
|
|
67
|
+
canonical = replace_type(canonical)
|
|
68
|
+
File.write(file, canonical) if canonical != content
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def replace_uuids(content, seed)
|
|
72
|
+
index = 0
|
|
73
|
+
content.gsub(UUID_PATTERN) do |uuid|
|
|
74
|
+
next uuid if preserved.include?(uuid)
|
|
75
|
+
|
|
76
|
+
index += 1
|
|
77
|
+
uuid_v5("suma-document:#{seed}:#{index}").to_s
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def replace_type(content)
|
|
82
|
+
content.sub(/(<metanorma\b[^>]*\btype=")[A-Z]{8}(")/) do
|
|
83
|
+
"#{Regexp.last_match(1)}#{CANONICAL_TYPE}#{Regexp.last_match(2)}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# UUIDs that occur in the site's source YAML. Identifiers the author
|
|
88
|
+
# wrote must survive; only generated ones are replaced. YAML is read as
|
|
89
|
+
# bytes: an author-supplied file with non-UTF-8 bytes must not fail the
|
|
90
|
+
# build, and the pattern is ASCII-only.
|
|
91
|
+
def preserved
|
|
92
|
+
@preserved ||= yaml_sources.flat_map do |file|
|
|
93
|
+
File.binread(file).scan(UUID_PATTERN)
|
|
94
|
+
end.uniq
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def yaml_sources
|
|
98
|
+
@sources.select do |file|
|
|
99
|
+
file.match?(/\.ya?ml\z/i) && File.file?(file)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def uuid_v5(name)
|
|
104
|
+
UUIDTools::UUID.sha1_create(NAMESPACE, name)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/suma/processor.rb
CHANGED
|
@@ -14,7 +14,8 @@ module Suma
|
|
|
14
14
|
def initialize(metanorma_yaml_path:, schemas_all_path:, compile: true,
|
|
15
15
|
output_directory: "_site", staged: false,
|
|
16
16
|
incremental: false, state_file: nil, jobs: 1,
|
|
17
|
-
provenance_file: nil, sign_method: nil, sign_key: nil
|
|
17
|
+
provenance_file: nil, sign_method: nil, sign_key: nil,
|
|
18
|
+
no_valid: false)
|
|
18
19
|
@metanorma_yaml_path = metanorma_yaml_path
|
|
19
20
|
@schemas_all_path = schemas_all_path
|
|
20
21
|
@compile_flag = compile
|
|
@@ -28,6 +29,9 @@ module Suma
|
|
|
28
29
|
@provenance_file = provenance_file
|
|
29
30
|
@sign_method = sign_method
|
|
30
31
|
@sign_key = sign_key
|
|
32
|
+
# Skip Metanorma document validation (metanorma `--no-valid`). Opt-in:
|
|
33
|
+
# for collections whose flavor's validators are still in flight.
|
|
34
|
+
@no_valid = no_valid
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
def run
|
|
@@ -39,7 +43,6 @@ module Suma
|
|
|
39
43
|
Utils.log "Compiling schema collection..."
|
|
40
44
|
compile_schema(schemas_all_path, collection_config)
|
|
41
45
|
|
|
42
|
-
Utils.log "Compiling complete collection..."
|
|
43
46
|
compile_collection(collection_config, output_directory)
|
|
44
47
|
end
|
|
45
48
|
|
|
@@ -56,6 +59,7 @@ module Suma
|
|
|
56
59
|
@provenance_file,
|
|
57
60
|
inputs: provenance_inputs,
|
|
58
61
|
outputs: provenance_outputs,
|
|
62
|
+
exclude: provenance_excludes,
|
|
59
63
|
)
|
|
60
64
|
if @sign_method
|
|
61
65
|
signature = Provenance.sign(
|
|
@@ -80,6 +84,31 @@ module Suma
|
|
|
80
84
|
@output_directory]
|
|
81
85
|
end
|
|
82
86
|
|
|
87
|
+
# Build metadata that is not site content: the site freshness state, the
|
|
88
|
+
# staged-build store, and a provenance file written inside the output tree.
|
|
89
|
+
def provenance_excludes
|
|
90
|
+
excludes = [File.join(@output_directory, SiteBuild::STATE_FILE)]
|
|
91
|
+
if @staged
|
|
92
|
+
excludes << File.join(@output_directory,
|
|
93
|
+
StagedCollectionBuilder::STORE_DIRNAME)
|
|
94
|
+
excludes << File.join(@output_directory, "_staged_reinflate")
|
|
95
|
+
end
|
|
96
|
+
excludes + provenance_artifacts.select { |path| within_output?(path) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def provenance_artifacts
|
|
100
|
+
return [] unless @provenance_file
|
|
101
|
+
|
|
102
|
+
base = File.expand_path(@provenance_file)
|
|
103
|
+
[base, "#{base}.minisig", "#{base}.bundle"]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def within_output?(path)
|
|
107
|
+
full = File.expand_path(path)
|
|
108
|
+
output = File.expand_path(@output_directory)
|
|
109
|
+
full == output || full.start_with?("#{output}#{File::SEPARATOR}")
|
|
110
|
+
end
|
|
111
|
+
|
|
83
112
|
def export_schema_config
|
|
84
113
|
site_config = Suma::SiteConfig::Config.from_file(metanorma_yaml_path)
|
|
85
114
|
|
|
@@ -108,6 +137,7 @@ module Suma
|
|
|
108
137
|
incremental: @incremental,
|
|
109
138
|
state_file: @state_file,
|
|
110
139
|
jobs: @jobs,
|
|
140
|
+
no_valid: @no_valid,
|
|
111
141
|
},
|
|
112
142
|
)
|
|
113
143
|
|
|
@@ -115,36 +145,80 @@ module Suma
|
|
|
115
145
|
end
|
|
116
146
|
|
|
117
147
|
def compile_collection(collection_config, output_directory)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
148
|
+
prepare_collection_manifest(collection_config)
|
|
149
|
+
write_collection_manifest(collection_config)
|
|
150
|
+
|
|
151
|
+
if site_build.current?
|
|
152
|
+
Utils.log "Collection outputs are up to date; 0 documents rendered."
|
|
153
|
+
return
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
Utils.log "Rendering collection site..."
|
|
157
|
+
render_collection(collection_config, output_directory)
|
|
158
|
+
canonicalizer.canonicalize_documents
|
|
159
|
+
site_build.record
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Generated manifest identifiers are canonicalized in the model, before
|
|
163
|
+
# serialization: patching the dumped YAML would preserve Psych's
|
|
164
|
+
# value-dependent (random) quoting decision.
|
|
165
|
+
def prepare_collection_manifest(collection_config)
|
|
166
|
+
ManifestTraverser.new(collection_config.manifest)
|
|
167
|
+
.remove_schemas_only_sources
|
|
168
|
+
canonicalizer.canonicalize_manifest(collection_config.manifest)
|
|
169
|
+
end
|
|
121
170
|
|
|
171
|
+
def write_collection_manifest(collection_config)
|
|
172
|
+
collection_config.to_file(COLLECTION_OUTPUT_PATH)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def render_collection(collection_config, output_directory)
|
|
122
176
|
if @staged
|
|
123
|
-
#
|
|
124
|
-
# its own process
|
|
177
|
+
# The emitted manifest is the staging input; each member is compiled
|
|
178
|
+
# in its own process and then reinflated into +output_directory+.
|
|
125
179
|
StagedCollectionBuilder.new(
|
|
126
180
|
collection_config_path: COLLECTION_OUTPUT_PATH,
|
|
127
181
|
output_directory: output_directory,
|
|
128
|
-
coverpage:
|
|
182
|
+
coverpage: collection_coverpage(collection_config),
|
|
183
|
+
no_valid: @no_valid,
|
|
129
184
|
).build
|
|
130
185
|
else
|
|
131
|
-
|
|
186
|
+
Metanorma::Collection.parse(COLLECTION_OUTPUT_PATH)
|
|
187
|
+
.render(collection_options(collection_config, output_directory))
|
|
132
188
|
end
|
|
133
189
|
end
|
|
134
190
|
|
|
135
|
-
def
|
|
136
|
-
|
|
137
|
-
ManifestTraverser.new(collection_config.manifest).remove_schemas_only_sources
|
|
138
|
-
collection_config.to_file(new_collection_config_path)
|
|
139
|
-
|
|
140
|
-
collection = Metanorma::Collection.parse(new_collection_config_path)
|
|
141
|
-
|
|
142
|
-
collection_opts = {
|
|
191
|
+
def collection_options(collection_config, output_directory)
|
|
192
|
+
{
|
|
143
193
|
output_folder: output_directory,
|
|
144
|
-
compile:
|
|
145
|
-
coverpage: collection_config
|
|
194
|
+
compile: compile_flags,
|
|
195
|
+
coverpage: collection_coverpage(collection_config),
|
|
146
196
|
}
|
|
147
|
-
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def compile_flags
|
|
200
|
+
{ install_fonts: false }.tap do |flags|
|
|
201
|
+
flags[:novalid] = true if @no_valid
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def collection_coverpage(collection_config)
|
|
206
|
+
collection_config.coverpage || "cover.html"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def site_build
|
|
210
|
+
@site_build ||= SiteBuild.new(
|
|
211
|
+
root: File.dirname(File.expand_path(metanorma_yaml_path)),
|
|
212
|
+
output_directory: output_directory,
|
|
213
|
+
inputs: [schemas_all_path],
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def canonicalizer
|
|
218
|
+
@canonicalizer ||= OutputCanonicalizer.new(
|
|
219
|
+
output_directory: output_directory,
|
|
220
|
+
sources: site_build.source_files,
|
|
221
|
+
)
|
|
148
222
|
end
|
|
149
223
|
end
|
|
150
224
|
end
|
data/lib/suma/provenance.rb
CHANGED
|
@@ -83,6 +83,9 @@ module Suma
|
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# Gems whose versions determine rendered document bytes.
|
|
87
|
+
RENDER_GEM_PREFIXES = %w[metanorma- isodoc].freeze
|
|
88
|
+
|
|
86
89
|
# @return [Hash] toolchain versions that determine output bytes
|
|
87
90
|
def self.toolchain
|
|
88
91
|
{
|
|
@@ -98,30 +101,63 @@ module Suma
|
|
|
98
101
|
versions
|
|
99
102
|
end
|
|
100
103
|
|
|
104
|
+
# Toolchain identity for cached rendered documents: the provenance
|
|
105
|
+
# toolchain plus every installed Metanorma/Isodoc gem, so a renderer or
|
|
106
|
+
# flavor update invalidates cached renders even when source is unchanged.
|
|
107
|
+
def self.render_toolchain
|
|
108
|
+
@render_toolchain ||= toolchain.merge("render" => render_gems)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.render_gems
|
|
112
|
+
gem_specs.select { |spec| render_gem?(spec.name) }
|
|
113
|
+
.sort_by { |spec| [spec.name, spec.version.to_s] }
|
|
114
|
+
.map { |spec| "#{spec.name}-#{spec.version}" }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.render_gem?(name)
|
|
118
|
+
name == "metanorma" || RENDER_GEM_PREFIXES.any? do |prefix|
|
|
119
|
+
name.start_with?(prefix)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.gem_specs
|
|
124
|
+
Gem::Specification.select { |spec| spec.name.match?(/\A(?:metanorma|isodoc)/) }
|
|
125
|
+
end
|
|
126
|
+
|
|
101
127
|
# @param inputs [Array<String>] files or directories
|
|
102
128
|
# @param outputs [Array<String>] files or directories
|
|
129
|
+
# @param exclude [Array<String>] files or directories omitted from the
|
|
130
|
+
# manifest and from the unrecorded-output check
|
|
103
131
|
# @return [Hash] manifest
|
|
104
|
-
def self.record(inputs:, outputs:)
|
|
132
|
+
def self.record(inputs:, outputs:, exclude: [])
|
|
133
|
+
excluded = exclude.map { |path| File.expand_path(path) }
|
|
105
134
|
{
|
|
106
135
|
"toolchain" => toolchain,
|
|
107
|
-
"inputs" => digests(inputs),
|
|
108
|
-
"outputs" => digests(outputs),
|
|
136
|
+
"inputs" => digests(inputs, exclude: excluded),
|
|
137
|
+
"outputs" => digests(outputs, exclude: excluded),
|
|
138
|
+
"output_roots" => output_roots(outputs, exclude: excluded),
|
|
139
|
+
"excluded" => excluded.map { |path| relative(path) }.sort,
|
|
109
140
|
}
|
|
110
141
|
end
|
|
111
142
|
|
|
112
143
|
# Write a provenance manifest as canonical JSON.
|
|
113
|
-
def self.write(path, inputs:, outputs:)
|
|
144
|
+
def self.write(path, inputs:, outputs:, exclude: [])
|
|
114
145
|
FileUtils.mkdir_p(File.dirname(path))
|
|
115
|
-
manifest = record(inputs: inputs, outputs: outputs)
|
|
146
|
+
manifest = record(inputs: inputs, outputs: outputs, exclude: exclude)
|
|
116
147
|
File.write(path, "#{JSON.pretty_generate(manifest)}\n")
|
|
117
148
|
path
|
|
118
149
|
end
|
|
119
150
|
|
|
120
|
-
# Independently recompute every digest in the manifest
|
|
151
|
+
# Independently recompute every digest in the manifest and flag files
|
|
152
|
+
# that appeared under a recorded output root but are not in the manifest.
|
|
121
153
|
#
|
|
122
154
|
# @return [Array<String>] problems (empty = verified)
|
|
123
155
|
def self.verify(path)
|
|
124
156
|
manifest = JSON.parse(File.read(path))
|
|
157
|
+
digest_problems(manifest) + unrecorded_outputs(manifest)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.digest_problems(manifest)
|
|
125
161
|
SECTIONS.flat_map do |section|
|
|
126
162
|
manifest.fetch(section, {}).filter_map do |relative, digest|
|
|
127
163
|
full = File.expand_path(relative)
|
|
@@ -134,18 +170,59 @@ module Suma
|
|
|
134
170
|
end
|
|
135
171
|
end
|
|
136
172
|
|
|
173
|
+
def self.unrecorded_outputs(manifest)
|
|
174
|
+
recorded = manifest.fetch("outputs", {})
|
|
175
|
+
excluded = expanded(manifest["excluded"])
|
|
176
|
+
Array(manifest["output_roots"]).flat_map do |root|
|
|
177
|
+
output_files(root)
|
|
178
|
+
.reject { |file| recorded.key?(file) || excluded?(file, excluded) }
|
|
179
|
+
.map { |file| "outputs: unrecorded #{file}" }
|
|
180
|
+
end.sort
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def self.output_files(root)
|
|
184
|
+
full_root = File.expand_path(root)
|
|
185
|
+
return [] unless File.directory?(full_root)
|
|
186
|
+
|
|
187
|
+
files([full_root]).map { |file| relative(file) }.uniq
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def self.expanded(paths)
|
|
191
|
+
Array(paths).map { |path| File.expand_path(path) }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def self.excluded?(relative_path, excluded)
|
|
195
|
+
full = File.expand_path(relative_path)
|
|
196
|
+
excluded.any? do |path|
|
|
197
|
+
full == path || full.start_with?("#{path}#{File::SEPARATOR}")
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
137
201
|
# @param paths [Array<String>] files or directories
|
|
202
|
+
# @param exclude [Array<String>] expanded paths to skip
|
|
138
203
|
# @return [Hash{String=>String}] relative path → sha256
|
|
139
|
-
def self.digests(paths)
|
|
140
|
-
files
|
|
141
|
-
|
|
142
|
-
end
|
|
143
|
-
files.select { |file| File.file?(file) }
|
|
204
|
+
def self.digests(paths, exclude: [])
|
|
205
|
+
files(paths)
|
|
206
|
+
.reject { |file| excluded?(file, exclude) }
|
|
144
207
|
.map { |file| relative(file) }
|
|
145
208
|
.uniq.sort
|
|
146
209
|
.to_h { |file| [file, Digest::SHA256.file(file).hexdigest] }
|
|
147
210
|
end
|
|
148
211
|
|
|
212
|
+
def self.files(paths)
|
|
213
|
+
paths.flat_map do |path|
|
|
214
|
+
File.directory?(path) ? Dir.glob(File.join(path, "**/*")) : [path]
|
|
215
|
+
end.select { |file| File.file?(file) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Relative paths of the directory entries among +outputs+.
|
|
219
|
+
def self.output_roots(outputs, exclude: [])
|
|
220
|
+
outputs.select { |path| File.directory?(path) }
|
|
221
|
+
.map { |path| relative(path) }
|
|
222
|
+
.reject { |path| excluded?(path, exclude) }
|
|
223
|
+
.uniq.sort
|
|
224
|
+
end
|
|
225
|
+
|
|
149
226
|
def self.relative(path)
|
|
150
227
|
Pathname.new(File.expand_path(path))
|
|
151
228
|
.relative_path_from(Pathname.getwd)
|
|
@@ -154,6 +231,9 @@ module Suma
|
|
|
154
231
|
path.to_s
|
|
155
232
|
end
|
|
156
233
|
|
|
157
|
-
private_class_method :optional_versions, :sign_with, :executable
|
|
234
|
+
private_class_method :optional_versions, :sign_with, :executable?,
|
|
235
|
+
:render_gems, :render_gem?, :gem_specs,
|
|
236
|
+
:digest_problems, :unrecorded_outputs, :output_files,
|
|
237
|
+
:expanded, :excluded?, :files
|
|
158
238
|
end
|
|
159
239
|
end
|