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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release-provenance.yml +5 -2
  3. data/.rubocop_todo.yml +27 -0
  4. data/CLAUDE.md +17 -5
  5. data/README.adoc +237 -11
  6. data/lib/suma/anchor_validator.rb +85 -0
  7. data/lib/suma/artifact_server.rb +102 -0
  8. data/lib/suma/cli/artifact_server_cli.rb +22 -0
  9. data/lib/suma/cli/build.rb +4 -0
  10. data/lib/suma/cli/core.rb +50 -4
  11. data/lib/suma/cli/export.rb +1 -1
  12. data/lib/suma/cli/extract_terms.rb +1 -1
  13. data/lib/suma/cli/generate_register.rb +1 -1
  14. data/lib/suma/cli/ingest_translations.rb +36 -0
  15. data/lib/suma/cli/package_gcr.rb +51 -0
  16. data/lib/suma/cli/validate.rb +16 -0
  17. data/lib/suma/cli.rb +4 -1
  18. data/lib/suma/closure_validator.rb +124 -20
  19. data/lib/suma/corpus_graph.rb +79 -16
  20. data/lib/suma/gcr_package.rb +230 -0
  21. data/lib/suma/link_validation_result.rb +8 -0
  22. data/lib/suma/link_validator.rb +0 -3
  23. data/lib/suma/output_canonicalizer.rb +107 -0
  24. data/lib/suma/processor.rb +94 -20
  25. data/lib/suma/provenance.rb +92 -12
  26. data/lib/suma/schema_collection.rb +1 -0
  27. data/lib/suma/schema_compiler.rb +79 -13
  28. data/lib/suma/schema_manifest_generator.rb +10 -3
  29. data/lib/suma/schema_profile.rb +12 -0
  30. data/lib/suma/schema_template/anchors.rb +154 -0
  31. data/lib/suma/schema_template/document.rb +2 -93
  32. data/lib/suma/schema_template/plain.rb +10 -6
  33. data/lib/suma/schema_template.rb +1 -0
  34. data/lib/suma/site_build.rb +153 -0
  35. data/lib/suma/staged_collection_builder.rb +11 -3
  36. data/lib/suma/translation_ingester.rb +169 -0
  37. data/lib/suma/urn_resolver.rb +51 -1
  38. data/lib/suma/version.rb +1 -1
  39. data/lib/suma.rb +7 -0
  40. metadata +13 -2
@@ -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 = paths.flat_map do |path|
141
- File.directory?(path) ? Dir.glob(File.join(path, "**/*")) : [path]
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
@@ -125,6 +125,7 @@ module Suma
125
125
  schema: express,
126
126
  output_path: @output_path_docs.join(express.id),
127
127
  template: template_class.new(express.id),
128
+ no_valid: @options[:no_valid],
128
129
  )
129
130
 
130
131
  @docs[express.id] = compiler
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "fileutils"
5
+ require "json"
4
6
  require "pathname"
5
7
 
6
8
  module Suma
@@ -12,20 +14,32 @@ module Suma
12
14
  # that the caller injects. This split means templates can be tested as pure
13
15
  # functions and the compiler can be tested with a real adoc fixture, without
14
16
  # either depending on the other.
17
+ #
18
+ # Rendering is skipped when the adoc, the schema config, the schema source,
19
+ # and the rendering toolchain are unchanged since the outputs were written:
20
+ # the render key is stored beside the outputs.
15
21
  class SchemaCompiler
22
+ RENDER_KEY_FILE = ".suma-render-key"
23
+
16
24
  attr_reader :schema, :id, :output_path, :template
17
25
 
18
- def initialize(schema:, output_path:, template:)
26
+ def initialize(schema:, output_path:, template:, no_valid: false)
19
27
  @schema = schema
20
28
  @id = schema.id
21
29
  @output_path = output_path
22
30
  @template = template
31
+ @no_valid = no_valid
23
32
  end
24
33
 
25
34
  def compile
26
35
  save_config
27
- save_adoc
36
+ adoc = template.render(config_relative_path)
37
+ save_adoc(adoc)
38
+ key = render_key(adoc)
39
+ return reuse_render if rendered_current?(key)
40
+
28
41
  invoke_metanorma
42
+ write_render_key(key)
29
43
  self
30
44
  end
31
45
 
@@ -37,6 +51,13 @@ module Suma
37
51
  template.extensions
38
52
  end
39
53
 
54
+ # Options handed to Metanorma::Compile. Exposed for specs.
55
+ def compile_options
56
+ { agree_to_terms: true, install_fonts: false }.tap do |options|
57
+ options[:novalid] = true if @no_valid
58
+ end
59
+ end
60
+
40
61
  private
41
62
 
42
63
  def filename_adoc(ext = "adoc")
@@ -47,15 +68,20 @@ module Suma
47
68
  File.join(output_path, "schema_#{id}.yaml")
48
69
  end
49
70
 
50
- def save_adoc
51
- log_relative "Save EXPRESS adoc", filename_adoc
52
-
53
- FileUtils.mkdir_p(File.dirname(filename_adoc))
71
+ def render_key_path
72
+ File.join(output_path, RENDER_KEY_FILE)
73
+ end
54
74
 
55
- config_relative = Pathname.new(filename_config)
75
+ def config_relative_path
76
+ Pathname.new(filename_config)
56
77
  .relative_path_from(Pathname.new(File.dirname(filename_adoc)))
78
+ end
79
+
80
+ def save_adoc(content)
81
+ log_relative "Save EXPRESS adoc", filename_adoc
57
82
 
58
- File.write(filename_adoc, template.render(config_relative))
83
+ FileUtils.mkdir_p(File.dirname(filename_adoc))
84
+ File.write(filename_adoc, content)
59
85
  end
60
86
 
61
87
  def save_config
@@ -68,16 +94,56 @@ module Suma
68
94
  config.save_to_path(filename_config)
69
95
  end
70
96
 
97
+ # Everything the rendered document depends on. The schema source is an
98
+ # input even though the adoc references it only by path.
99
+ def render_key(adoc)
100
+ Digest::SHA256.hexdigest(
101
+ [adoc, File.read(filename_config), schema_source_digest,
102
+ JSON.generate(Provenance.render_toolchain)].join("\0"),
103
+ )
104
+ end
105
+
106
+ def schema_source_digest
107
+ path = schema.path.to_s
108
+ File.file?(path) ? Digest::SHA256.file(path).hexdigest : "missing:#{path}"
109
+ end
110
+
111
+ def rendered_current?(key)
112
+ File.file?(render_key_path) && File.read(render_key_path) == key &&
113
+ expected_output_paths.all? { |path| File.file?(path) }
114
+ end
115
+
116
+ def expected_output_paths
117
+ extensions.split(",").map { |ext| filename_adoc(ext.strip) }
118
+ end
119
+
120
+ def write_render_key(key)
121
+ File.write(render_key_path, key)
122
+ end
123
+
124
+ def reuse_render
125
+ log_relative "Reusing rendered schema doc (id: #{id})", filename_adoc
126
+ self
127
+ end
128
+
71
129
  def invoke_metanorma
72
130
  log_relative "Compiling schema (id: #{id})", filename_adoc
73
- Metanorma::Compile.new.compile(
74
- filename_adoc,
75
- agree_to_terms: true,
76
- install_fonts: false,
77
- )
131
+ Metanorma::Compile.new.compile(filename_adoc, **compile_options)
132
+ verify_outputs!
78
133
  log_relative "Compiling schema (id: #{id}) ... done!", filename_adoc
79
134
  end
80
135
 
136
+ # Metanorma::Compile#compile can return nil for a handled failure instead
137
+ # of raising. Never cache a render that produced no output.
138
+ def verify_outputs!
139
+ missing = expected_output_paths.reject { |path| File.file?(path) }
140
+ return if missing.empty?
141
+
142
+ names = missing.map { |path| File.basename(path) }.join(", ")
143
+ raise CompilationError,
144
+ "Metanorma produced no #{names} for schema #{id}"
145
+ end
146
+
81
147
  def log_relative(prefix, path)
82
148
  relative = Pathname.new(path).relative_path_from(Dir.pwd)
83
149
  Utils.log "#{prefix}: #{relative}"
@@ -48,11 +48,18 @@ exclude_paths: nil)
48
48
  YAML.safe_load(File.read(file_path, encoding: "UTF-8"), aliases: true)
49
49
  end
50
50
 
51
+ # Collection manifest files referenced by the site manifest. Both
52
+ # manifest shapes are handled: legacy `docref` entries and the
53
+ # nested `documents` entries (metanorma 2.x collection manifests).
54
+ # Only `file` references count — `fileref` entries are attachments
55
+ # (documents, schema exports), not collection manifests.
51
56
  def load_manifest_files(collection_files)
52
- collection_files.map do |c|
57
+ collection_files.flat_map do |c|
53
58
  collection_data = load_yaml(c)
54
- collection_data["manifest"]["docref"].map { |docref| docref["file"] }
55
- end.flatten
59
+ manifest = collection_data["manifest"] || {}
60
+ entries = Array(manifest["docref"]) + Array(manifest["documents"])
61
+ entries.filter_map { |entry| entry["file"] }
62
+ end
56
63
  end
57
64
 
58
65
  def load_project_schemas(manifest_files)
@@ -126,9 +126,21 @@ module Suma
126
126
  category_ids: %w[other],
127
127
  )
128
128
 
129
+ # buildingSMART IFC: one standalone schema per edition (IFC4, IFC4X3,
130
+ # ...), entities prefixed Ifc, no ARM/MIM-style decomposition.
131
+ IFC = new(
132
+ id: "ifc",
133
+ rules: [],
134
+ suffix_labels: {},
135
+ acronyms: %w[bim ifc idf 2d 3d 4d 5d],
136
+ acronym_patterns: [/\Aifc\d+(x\d+)*\z/],
137
+ category_ids: %w[other],
138
+ )
139
+
129
140
  REGISTRY = {
130
141
  "iso10303" => ISO_10303,
131
142
  "generic" => GENERIC,
143
+ "ifc" => IFC,
132
144
  }.freeze
133
145
  end
134
146
  end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ module SchemaTemplate
5
+ # The Liquid anchor block shared by the schema document templates: one
6
+ # [[id]] for every element an Expressir cross-reference may target, so
7
+ # every xref the hyperlinked formatter emits resolves in the rendered
8
+ # document. Expressir links schema-qualified element ids
9
+ # (`schema.entity`) but bare `entity.attribute` pairs, hence the two
10
+ # bookmark forms.
11
+ module Anchors
12
+ # Xref targets are always qualified by the schema's *declared* id, so
13
+ # anchors are too — independent of the manifest key's casing.
14
+ SCHEMA_REF = "{{ schema.id }}"
15
+
16
+ # The raw anchor block contains Liquid comments and newlines that are
17
+ # illegal on the section header line; strip them before interpolating.
18
+ def rendered_anchors
19
+ anchor_block
20
+ .gsub(%r{//[^\r\n]+}, "")
21
+ .gsub(/[\n\r]+/, "")
22
+ .gsub(/^[\n\r]/, "")
23
+ end
24
+
25
+ private
26
+
27
+ def anchor_block
28
+ <<~HEREDOC
29
+ // _fund_cons.liquid
30
+ [[#{schema_id}_funds]]
31
+
32
+ #{constants_and_types_anchors}
33
+
34
+ #{entity_anchors}
35
+
36
+ #{declaration_anchors}
37
+ HEREDOC
38
+ end
39
+
40
+ def constants_and_types_anchors
41
+ <<~HEREDOC
42
+ // _constants.liquid
43
+ {% if schema.constants.size > 0 %}
44
+ #{bookmark('constants')}
45
+ {% for thing in schema.constants %}
46
+ #{bookmark('{{thing.id}}')}
47
+ {% endfor %}
48
+ {% endif %}
49
+
50
+ // _types.liquid
51
+ {% if schema.types.size > 0 %}
52
+ #{bookmark('types')}
53
+ // _type.liquid
54
+ {% for thing in schema.types %}
55
+ #{bookmark('{{thing.id}}')}
56
+ {% if thing.items.size > 0 %}
57
+ // _type_items.liquid
58
+ #{bookmark('{{thing.id}}.items')}
59
+ {% for item in thing.items %}
60
+ #{bookmark('{{thing.id}}.items.{{item.id}}')}
61
+ {% endfor %}
62
+ {% endif %}
63
+ {% endfor %}
64
+ {% endif %}
65
+ HEREDOC
66
+ end
67
+
68
+ def entity_anchors
69
+ <<~HEREDOC
70
+ // _entities.liquid
71
+ {% if schema.entities.size > 0 %}
72
+ #{bookmark('entities')}
73
+ // _entity.liquid
74
+ {% for thing in schema.entities %}
75
+ #{bookmark('{{thing.id}}')}
76
+ {% for attr in thing.attributes %}
77
+ #{bookmark_local('{{thing.id}}.{{attr.id}}')}
78
+ {% endfor %}
79
+ {% endfor %}
80
+ {% endif %}
81
+ HEREDOC
82
+ end
83
+
84
+ def declaration_anchors
85
+ <<~HEREDOC
86
+ // _subtype_constraints.liquid
87
+ {% if schema.subtype_constraints.size > 0 %}
88
+ #{bookmark('subtype_constraints')}
89
+ // _subtype_constraint.liquid
90
+ {% for thing in schema.subtype_constraints %}
91
+ #{bookmark('{{thing.id}}')}
92
+ {% endfor %}
93
+ {% endif %}
94
+
95
+ // _functions.liquid
96
+ {% if schema.functions.size > 0 %}
97
+ #{bookmark('functions')}
98
+ // _function.liquid
99
+ {% for thing in schema.functions %}
100
+ #{callable_anchors}
101
+ {% endfor %}
102
+ {% endif %}
103
+
104
+ // _procedures.liquid
105
+ {% if schema.procedures.size > 0 %}
106
+ #{bookmark('procedures')}
107
+ // _procedure.liquid
108
+ {% for thing in schema.procedures %}
109
+ #{callable_anchors}
110
+ {% endfor %}
111
+ {% endif %}
112
+
113
+ // _rules.liquid
114
+ {% if schema.rules.size > 0 %}
115
+ #{bookmark('rules')}
116
+ // _rule.liquid
117
+ {% for thing in schema.rules %}
118
+ #{bookmark('{{thing.id}}')}
119
+ {% for var in thing.variables %}
120
+ #{bookmark_local('{{thing.id}}.{{var.id}}')}
121
+ {% endfor %}
122
+ {% endfor %}
123
+ {% endif %}
124
+ HEREDOC
125
+ end
126
+
127
+ # Functions and procedures carry parameters and local variables, and
128
+ # Expressir links references to them as bare `owner.name` pairs.
129
+ def callable_anchors
130
+ <<~HEREDOC
131
+ #{bookmark('{{thing.id}}')}
132
+ {% for param in thing.parameters %}
133
+ #{bookmark_local('{{thing.id}}.{{param.id}}')}
134
+ {% endfor %}
135
+ {% for var in thing.variables %}
136
+ #{bookmark_local('{{thing.id}}.{{var.id}}')}
137
+ {% endfor %}
138
+ HEREDOC
139
+ end
140
+
141
+ def bookmark(anchor)
142
+ "[[#{mangled(SCHEMA_REF)}.#{mangled(anchor)}]]"
143
+ end
144
+
145
+ def bookmark_local(anchor)
146
+ "[[#{mangled(anchor)}]]"
147
+ end
148
+
149
+ def mangled(anchor)
150
+ anchor.gsub("}}", ' | replace: "\", "-", "-}}')
151
+ end
152
+ end
153
+ end
154
+ end
@@ -4,10 +4,11 @@ module Suma
4
4
  module SchemaTemplate
5
5
  # Emits an AsciiDoc body with cross-reference anchors for every
6
6
  # schema element so other documents can deep-link into the compiled
7
- # HTML. Only XML is produced — the anchors only resolve against the
7
+ # output. Only XML is produced — the anchors only resolve against the
8
8
  # XML output, not the HTML rendering.
9
9
  class Document
10
10
  EXTENSIONS = "xml"
11
+ include Anchors
11
12
 
12
13
  attr_reader :schema_id
13
14
 
@@ -44,98 +45,6 @@ module Suma
44
45
 
45
46
  ADOC
46
47
  end
47
-
48
- private
49
-
50
- # The raw anchor block contains Liquid comments and newlines that
51
- # are illegal on the section header line; strip them before
52
- # interpolating.
53
- def rendered_anchors
54
- schema_anchors.gsub(%r{//[^\r\n]+}, "").gsub(/[\n\r]+/, "").gsub(
55
- /^[\n\r]/, ""
56
- )
57
- end
58
-
59
- def schema_anchors
60
- <<~HEREDOC
61
- // _fund_cons.liquid
62
- [[#{schema_id}_funds]]
63
-
64
- // _constants.liquid
65
- {% if schema.constants.size > 0 %}
66
- #{bookmark('constants')}
67
- {% for thing in schema.constants %}
68
- #{bookmark('{{thing.id}}')}
69
- {% endfor %}
70
- {% endif %}
71
-
72
- // _types.liquid
73
- {% if schema.types.size > 0 %}
74
- #{bookmark('types')}
75
- // _type.liquid
76
- {% for thing in schema.types %}
77
- #{bookmark('{{thing.id}}')}
78
- {% if thing.items.size > 0 %}
79
- // _type_items.liquid
80
- #{bookmark('{{thing.id}}.items')}
81
- {% for item in thing.items %}
82
- #{bookmark('{{thing.id}}.items.{{item.id}}')}
83
- {% endfor %}
84
- {% endif %}
85
- {% endfor %}
86
- {% endif %}
87
-
88
- // _entities.liquid
89
- {% if schema.entities.size > 0 %}
90
- #{bookmark('entities')}
91
- {% for thing in schema.entities %}
92
- // _entity.liquid
93
- #{bookmark('{{thing.id}}')}
94
- {% endfor %}
95
- {% endif %}
96
-
97
- // _subtype_constraints.liquid
98
- {% if schema.subtype_constraints.size > 0 %}
99
- #{bookmark('subtype_constraints')}
100
- // _subtype_constraint.liquid
101
- {% for thing in schema.subtype_constraints %}
102
- #{bookmark('{{thing.id}}')}
103
- {% endfor %}
104
- {% endif %}
105
-
106
- // _functions.liquid
107
- {% if schema.functions.size > 0 %}
108
- #{bookmark('functions')}
109
- // _function.liquid
110
- {% for thing in schema.functions %}
111
- #{bookmark('{{thing.id}}')}
112
- {% endfor %}
113
- {% endif %}
114
-
115
- // _procedures.liquid
116
- {% if schema.procedures.size > 0 %}
117
- #{bookmark('procedures')}
118
- // _procedure.liquid
119
- {% for thing in schema.procedures %}
120
- #{bookmark('{{thing.id}}')}
121
- {% endfor %}
122
- {% endif %}
123
-
124
- // _rules.liquid
125
- {% if schema.rules.size > 0 %}
126
- #{bookmark('rules')}
127
- // _rule.liquid
128
- {% for thing in schema.rules %}
129
- #{bookmark('{{thing.id}}')}
130
- {% endfor %}
131
- {% endif %}
132
- HEREDOC
133
- end
134
-
135
- def bookmark(anchor)
136
- mangled = anchor.gsub("}}", ' | replace: "\", "-", "-}}')
137
- "[[#{schema_id}.#{mangled}]]"
138
- end
139
48
  end
140
49
  end
141
50
  end
@@ -2,10 +2,13 @@
2
2
 
3
3
  module Suma
4
4
  module SchemaTemplate
5
- # Emits a plain AsciiDoc body for a single EXPRESS schema, producing
6
- # both HTML and XML outputs via Metanorma.
5
+ # Emits an anchored, hyperlinked AsciiDoc body for a single EXPRESS
6
+ # schema, producing both HTML and XML outputs via Metanorma. Every
7
+ # element cross-reference the hyperlinked source emits resolves inside
8
+ # the rendered document through the shared anchor block.
7
9
  class Plain
8
10
  EXTENSIONS = "xml,html"
11
+ include Anchors
9
12
 
10
13
  attr_reader :schema_id
11
14
 
@@ -29,12 +32,13 @@ module Suma
29
32
  ----
30
33
  {% for schema in context.schemas %}
31
34
 
32
- [%unnumbered]
33
- == #{schema_id}
35
+ [[#{schema_id}]]
36
+ [%unnumbered,type=express]
37
+ == #{schema_id} #{rendered_anchors}
34
38
 
35
- [source%unnumbered]
39
+ [source%unnumbered,subs="+macros"]
36
40
  --
37
- {{ schema.formatted }}
41
+ {{ schema.formatted_hyperlinked_adoc }}
38
42
  --
39
43
  {% endfor %}
40
44
  ----
@@ -13,6 +13,7 @@ module Suma
13
13
  #
14
14
  # Composition with the compiler lives in SchemaCompiler.
15
15
  module SchemaTemplate
16
+ autoload :Anchors, "suma/schema_template/anchors"
16
17
  autoload :Plain, "suma/schema_template/plain"
17
18
  autoload :Document, "suma/schema_template/document"
18
19
  end