suma 0.5.8 → 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/.github/workflows/release-provenance.yml +5 -2
- data/.rubocop_todo.yml +27 -0
- data/CLAUDE.md +17 -5
- data/README.adoc +237 -11
- 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/urn_resolver.rb +51 -1
- data/lib/suma/version.rb +1 -1
- data/lib/suma.rb +7 -0
- metadata +13 -2
data/lib/suma/corpus_graph.rb
CHANGED
|
@@ -13,6 +13,12 @@ module Suma
|
|
|
13
13
|
# change-impact analysis (S3) and dependency-driven builds (S6). Nodes are
|
|
14
14
|
# schemas and their declared elements; edges are USE/REFERENCE interfaces.
|
|
15
15
|
class CorpusGraph
|
|
16
|
+
# One USE/REFERENCE import: the target schema, its kind, and the named
|
|
17
|
+
# items (renames already applied — the visible name is +items+).
|
|
18
|
+
InterfaceEdge = Struct.new(:kind, :target, :items, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
EMPTY_VISIBLE = {}.freeze
|
|
21
|
+
|
|
16
22
|
attr_reader :schema_ids
|
|
17
23
|
|
|
18
24
|
def initialize(repositories)
|
|
@@ -24,6 +30,7 @@ module Suma
|
|
|
24
30
|
repo.schemas.each { |schema| add_schema(schema) }
|
|
25
31
|
end
|
|
26
32
|
@schema_ids = @schemas.keys
|
|
33
|
+
@visibility = build_visibility
|
|
27
34
|
freeze
|
|
28
35
|
end
|
|
29
36
|
|
|
@@ -47,20 +54,18 @@ module Suma
|
|
|
47
54
|
@schemas.key?(normalize(id))
|
|
48
55
|
end
|
|
49
56
|
|
|
50
|
-
# Does +schema_id+ (or
|
|
51
|
-
# EXPRESS resolves unqualified names against the local schema and
|
|
52
|
-
#
|
|
57
|
+
# Does +schema_id+ (or the schemas it imports) declare +element_id+?
|
|
58
|
+
# EXPRESS resolves unqualified names against the local schema and the
|
|
59
|
+
# schema's interface visibility, including USE-FROM cascades.
|
|
53
60
|
#
|
|
54
61
|
# @param schema_id [String]
|
|
55
62
|
# @param element_id [String]
|
|
56
63
|
# @return [Boolean]
|
|
57
64
|
def element_visible?(schema_id, element_id)
|
|
58
65
|
key = normalize(schema_id)
|
|
59
|
-
return false unless @
|
|
60
|
-
|
|
61
|
-
return true if @elements[key].key?(normalize(element_id))
|
|
66
|
+
return false unless @visibility.key?(key)
|
|
62
67
|
|
|
63
|
-
|
|
68
|
+
@visibility[key].key?(normalize(element_id))
|
|
64
69
|
end
|
|
65
70
|
|
|
66
71
|
# Schemas that +schema_id+ USEs FROM or REFERENCEs.
|
|
@@ -68,7 +73,7 @@ module Suma
|
|
|
68
73
|
# @param schema_id [String]
|
|
69
74
|
# @return [Array<String>] normalized ids of interface targets
|
|
70
75
|
def interface_targets(schema_id)
|
|
71
|
-
@interfaces[normalize(schema_id)] || []
|
|
76
|
+
(@interfaces[normalize(schema_id)] || []).map(&:target)
|
|
72
77
|
end
|
|
73
78
|
|
|
74
79
|
# Transitive interface closure: every schema +schema_id+ depends on,
|
|
@@ -110,11 +115,21 @@ module Suma
|
|
|
110
115
|
key = normalize(schema.id)
|
|
111
116
|
@schemas[key] = true
|
|
112
117
|
@elements[key] = declared_elements(schema)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
@interfaces[key] = Array(schema.interfaces).filter_map do |interface|
|
|
119
|
+
build_edge(interface)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def build_edge(interface)
|
|
124
|
+
target = interface.schema&.id
|
|
125
|
+
return nil unless target
|
|
126
|
+
|
|
127
|
+
items = Array(interface.items).filter_map do |item|
|
|
128
|
+
item.id || (item.ref.id if item.ref.respond_to?(:id))
|
|
129
|
+
end
|
|
130
|
+
InterfaceEdge.new(kind: interface.kind.to_s,
|
|
131
|
+
target: normalize(target),
|
|
132
|
+
items: items)
|
|
118
133
|
end
|
|
119
134
|
|
|
120
135
|
def declared_elements(schema)
|
|
@@ -128,9 +143,57 @@ module Suma
|
|
|
128
143
|
index
|
|
129
144
|
end
|
|
130
145
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
146
|
+
# EXPRESS interface visibility (ISO 10303-11 §13.3): a schema sees its
|
|
147
|
+
# declarations plus its imports. An unrestricted USE re-exports
|
|
148
|
+
# everything its target makes visible, so visibility cascades through
|
|
149
|
+
# USE chains; a named import exposes only the named (renamed) items;
|
|
150
|
+
# a REFERENCE exposes the target's declarations without cascading.
|
|
151
|
+
#
|
|
152
|
+
# WG12 corpora ship circular USE interfaces, so this is a monotone
|
|
153
|
+
# fixed-point iteration — sound under cycles, and each pass only adds.
|
|
154
|
+
def build_visibility
|
|
155
|
+
visible = {}
|
|
156
|
+
@schemas.each_key { |key| visible[key] = (@elements[key] || {}).dup }
|
|
157
|
+
|
|
158
|
+
changed = true
|
|
159
|
+
while changed
|
|
160
|
+
changed = false
|
|
161
|
+
@schemas.each_key do |key|
|
|
162
|
+
changed ||= apply_visibility_pass(key, visible)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
visible
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def apply_visibility_pass(key, visible)
|
|
169
|
+
names = visible[key]
|
|
170
|
+
changed = false
|
|
171
|
+
(@interfaces[key] || []).each do |edge|
|
|
172
|
+
next unless @schemas.key?(edge.target)
|
|
173
|
+
|
|
174
|
+
changed ||= import_edge_names(names, edge, visible)
|
|
175
|
+
end
|
|
176
|
+
changed
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def import_edge_names(names, edge, visible)
|
|
180
|
+
changed = false
|
|
181
|
+
imported_names(edge, visible).each_key do |name|
|
|
182
|
+
next if names.key?(name)
|
|
183
|
+
|
|
184
|
+
names[name] = true
|
|
185
|
+
changed = true
|
|
186
|
+
end
|
|
187
|
+
changed
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def imported_names(edge, visible)
|
|
191
|
+
if edge.items.any?
|
|
192
|
+
edge.items.to_h { |name| [normalize(name), true] }
|
|
193
|
+
elsif edge.kind == "USE"
|
|
194
|
+
visible[edge.target] || EMPTY_VISIBLE
|
|
195
|
+
else
|
|
196
|
+
@elements[edge.target] || EMPTY_VISIBLE
|
|
134
197
|
end
|
|
135
198
|
end
|
|
136
199
|
|
|
@@ -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
|