metanorma-core 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75534ea08c92e8ce3d6c9011c6f0ffb3cda325ba1a0955ca3715f12c8023ff84
4
- data.tar.gz: d8afbb2d77828838eb694ede69fcb7bed5d680f7b224913726c55d6d6e2537e5
3
+ metadata.gz: ed26c327953bc5f8571454087342a413f4c5c7e9f1f4030b3f438cccc7e037d1
4
+ data.tar.gz: c7d871e81da7a7c84615ef357a3abea8717ef1a1fb3380af6b5ae436f25eede9
5
5
  SHA512:
6
- metadata.gz: 40e5e611703c2f81216430d2af7e14ec4544c2de8feec7bf02ff00964c7df08e315ec46a1fc24deca556e161ff91b40493aee80bcdf6890eaaaea6a2b3474793
7
- data.tar.gz: a7da456887f1dd604d1834d416299756f420839d6a76c284d49211c2203a9b7781d5cd79ec7cd12de49416602c13541ad6688b40758497e510f0fa6d7a1d360d
6
+ metadata.gz: 24192aefbe3788198362fec82be7b6e95336a34ca358aba0ee7b20a85f8d772b79f6f077f300a6a9970c69f24f13ca412805485c0d73e41ab33c5ded8764a55b
7
+ data.tar.gz: 723335b30b227377c3f552506f81081c892c4772c489ddc143a63d8f909257d29795c7b99cfb0a23e996559c5b9dbc77ae5a98ee4500c4929ddcd82f81474a38
@@ -1,6 +1,26 @@
1
1
  module Metanorma
2
+ # Asciidoctor extensions registered globally when metanorma-core is
3
+ # loaded. Provides the +include::pattern[]+ glob support used across
4
+ # the metanorma stack.
2
5
  module AsciidoctorExtensions
6
+ # Asciidoctor +IncludeProcessor+ that expands glob patterns in
7
+ # +include::+ directives. When the +target+ contains a +*+, the
8
+ # files matching the glob (relative to the including reader's
9
+ # +dir+) are included in reverse-sorted order, each separated
10
+ # from the next by a blank line unless the +adjoin-option+
11
+ # attribute is set.
12
+ #
13
+ # @example In an Asciidoc document
14
+ # include::sections/*.adoc[]
3
15
  class GlobIncludeProcessor < ::Asciidoctor::Extensions::IncludeProcessor
16
+ # @param _doc [Asciidoctor::Document] containing document (unused).
17
+ # @param reader [Asciidoctor::Reader] the include site's reader.
18
+ # @param target_glob [String] glob pattern relative to
19
+ # +reader.dir+.
20
+ # @param attributes [Hash] include directive attributes;
21
+ # recognises +"adjoin-option"+ to suppress the inter-file
22
+ # blank line.
23
+ # @return [Asciidoctor::Reader] the reader, mutated in place.
4
24
  def process(_doc, reader, target_glob, attributes)
5
25
  Dir[File.join reader.dir, target_glob].sort.reverse_each do |target|
6
26
  content = File.readlines target
@@ -10,6 +30,12 @@ module Metanorma
10
30
  reader
11
31
  end
12
32
 
33
+ # Whether this processor handles the given include +target+.
34
+ # Triggers on any target containing +*+ — sufficient for the
35
+ # glob patterns the metanorma stack uses.
36
+ #
37
+ # @param target [String] include directive target.
38
+ # @return [Boolean]
13
39
  def handles?(target)
14
40
  target.include? "*"
15
41
  end
@@ -1,17 +1,41 @@
1
1
  module Metanorma
2
+ # Configuration mixin for the +Metanorma+ module. Provides the
3
+ # +Metanorma.configure+ block-based setup pattern and the
4
+ # +Metanorma.configuration+ accessor for read access.
5
+ #
6
+ # @example
7
+ # Metanorma.configure do |c|
8
+ # c.logs = %i[error fatal]
9
+ # end
2
10
  module Config
11
+ # Yield the singleton {Metanorma::Configuration} for in-place
12
+ # mutation, e.g. flipping which log severities are printed.
13
+ #
14
+ # @yieldparam [Metanorma::Configuration] the singleton config.
15
+ # @return [Metanorma::Configuration, nil] the config if no block
16
+ # was given (preserving the original semantics), otherwise the
17
+ # block's return value.
3
18
  def configure
4
19
  if block_given?
5
20
  yield configuration
6
21
  end
7
22
  end
8
23
 
24
+ # Lazily-instantiated singleton {Metanorma::Configuration} for the
25
+ # +Metanorma+ module.
26
+ #
27
+ # @return [Metanorma::Configuration]
9
28
  def configuration
10
29
  @configuration ||= Configuration.new
11
30
  end
12
31
  end
13
32
 
33
+ # Mutable runtime configuration for the metanorma stack. Currently
34
+ # carries only the +logs+ severity allowlist consumed by
35
+ # {Metanorma::Util#log}; other settings can be added here as
36
+ # cross-cutting needs arise.
14
37
  class Configuration
38
+ # @return [Array<Symbol>] severities printed by {Metanorma::Util#log}.
15
39
  attr_accessor :logs
16
40
 
17
41
  def initialize
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "asciidoctor"
4
+ require "nokogiri"
5
+
6
+ module Metanorma
7
+ module Core
8
+ # Inline-snippet boilerplate handling shared across metanorma-standoc
9
+ # and metanorma (collection layer). Provides Liquid + inline-Asciidoc
10
+ # substitution into docidentifier-like snippets, plus the
11
+ # option-isolating Asciidoctor convert wrapper used to keep nested
12
+ # conversions from leaking attribute / extension-registry state.
13
+ #
14
+ # Standoc's Cleanup::Boilerplate includes this module and overrides
15
+ # {#boilerplate_snippet_cleanup} to apply standoc-specific namespace
16
+ # cleanup and footnote separation; the metanorma collection layer
17
+ # calls into it as module functions
18
+ # (e.g. +Metanorma::Core::Boilerplate.docidentifier_boilerplate_isodoc+)
19
+ # without including the module — both forms are supported via
20
+ # +extend self+ at the bottom of this module.
21
+ module Boilerplate
22
+ # Asciidoctor attributes that are safe to inherit from an outer
23
+ # conversion context into an isolated nested convert. Anything not
24
+ # in this set is dropped.
25
+ SAFE_SHARED_ATTRIBUTES = {
26
+ "source-highlighter" => "html-pipeline",
27
+ "nofooter" => "",
28
+ "no-header-footer" => "",
29
+ }.freeze
30
+
31
+ # Convert a snippet of Asciidoc-with-Liquid text into the
32
+ # localised, cleaned-up XML string suitable for substitution into
33
+ # a surrounding document. Three stages run in order:
34
+ #
35
+ # 1. Liquid substitution via +isodoc.populate_template+.
36
+ # 2. Asciidoc-to-XML conversion via {#adoc2xml} (wraps in a
37
+ # headless dummy document, runs an isolated Asciidoctor
38
+ # convert, extracts the +//sections+ subtree).
39
+ # 3. {#boilerplate_snippet_cleanup} extension hook (default
40
+ # identity; standoc overrides for namespace-cleanup +
41
+ # footnote separation).
42
+ # 4. Localisation via +isodoc.i18n.l10n+.
43
+ #
44
+ # @param adoc [String] Snippet of Asciidoc text, possibly
45
+ # containing Liquid expressions like
46
+ # +{% if seriesabbr %}{{seriesabbr}}{% endif %}+.
47
+ # @param isodoc [#populate_template, #i18n] An isodoc converter
48
+ # instance. Must respond to +populate_template(text, options)+
49
+ # for Liquid substitution and +i18n+ (returning an object
50
+ # responding to +l10n(text, lang, script)+).
51
+ # @param lang [String] BCP-47 language tag (e.g. "en") passed to
52
+ # +l10n+. Owned by the caller because the docidentifier-template
53
+ # pipeline runs outside the isodoc converter's own +@lang+
54
+ # state in the collection use case.
55
+ # @param script [String] ISO-15924 script tag (e.g. "Latn"),
56
+ # passed to +l10n+ for the same reason as +lang+.
57
+ # @param backend [Symbol] Asciidoctor backend symbol
58
+ # (e.g. +:standoc+, +:iso+). Determines which converter
59
+ # Asciidoctor dispatches to for the inner conversion.
60
+ # @param flush_caches [Boolean] If true, the dummy document
61
+ # wrapper includes +:flush-caches:+, telling Asciidoctor to
62
+ # discard cached parse results before running this snippet.
63
+ # Standoc threads its converter-level +@flush_caches+ through
64
+ # here. Defaults to false.
65
+ # @param localdir [String, nil] Filesystem path used as the inner
66
+ # conversion's +:base_dir+ if the caller does not supply one
67
+ # explicitly. Standoc passes its +@localdir+; the collection
68
+ # layer passes the collection's +@dirname+.
69
+ # @return [String] The substituted, cleaned-up XML/text snippet
70
+ # ready to splice into the surrounding document.
71
+ def boilerplate_snippet_convert(adoc, isodoc, lang:, script:, backend:,
72
+ flush_caches: false, localdir: nil)
73
+ b = isodoc.populate_template(adoc, nil)
74
+ node = adoc2xml(b, backend, flush_caches: flush_caches,
75
+ localdir: localdir)
76
+ ret = boilerplate_snippet_cleanup(node)
77
+ isodoc.i18n.l10n(ret.children.to_xml, lang, script).strip
78
+ end
79
+
80
+ # Extension hook invoked by {#boilerplate_snippet_convert} on the
81
+ # output of {#adoc2xml} before localisation. Default
82
+ # implementation is the identity. Standoc's Cleanup::Boilerplate
83
+ # overrides it to apply boilerplate_xml_cleanup and footnote
84
+ # renumbering; the metanorma collection layer leaves it as
85
+ # identity (no standoc-namespace cleanup needed at that stage).
86
+ #
87
+ # @param node [Nokogiri::XML::Node] The +//sections+ subtree
88
+ # returned by Asciidoctor for the snippet.
89
+ # @return [Nokogiri::XML::Node] The (possibly transformed) node
90
+ # whose children will be serialised as the snippet's output.
91
+ def boilerplate_snippet_cleanup(node)
92
+ node
93
+ end
94
+
95
+ # Iterate over every +<docidentifier @boilerplate>+ element in
96
+ # +xmldoc+ and replace its content with the Liquid-substituted,
97
+ # Asciidoc-rendered output. Called from standoc's cleanup
98
+ # pipeline (post-processing semantic XML) and from the metanorma
99
+ # collection layer (pre-processing the collection bibdata before
100
+ # MergeBibitems hands it to Relaton — see issue
101
+ # https://github.com/metanorma/metanorma/issues/558).
102
+ #
103
+ # The +@boilerplate+ attribute is removed in all matched cases;
104
+ # substitution is performed only when its value is +"true"+.
105
+ # The output of {#boilerplate_snippet_convert} is a serialised
106
+ # +<sections><p>...</p></sections>+; the inner +<p>+ children
107
+ # are spliced into the docidentifier (or the raw output if no
108
+ # +<p>+ was produced).
109
+ #
110
+ # @param xmldoc [Nokogiri::XML::Document, Nokogiri::XML::Node]
111
+ # The document or subtree to scan. **Mutated in place**: every
112
+ # matched +<docidentifier @boilerplate>+ has its
113
+ # +@boilerplate+ attribute stripped, and (when its value was
114
+ # +"true"+) its content replaced with the substituted output.
115
+ # @param isodoc [#populate_template, #i18n] Isodoc instance, see
116
+ # {#boilerplate_snippet_convert}.
117
+ # @param lang [String] see {#boilerplate_snippet_convert}.
118
+ # @param script [String] see {#boilerplate_snippet_convert}.
119
+ # @param backend [Symbol] see {#boilerplate_snippet_convert}.
120
+ # @param flush_caches [Boolean] see {#boilerplate_snippet_convert}.
121
+ # @param localdir [String, nil] see {#boilerplate_snippet_convert}.
122
+ # @return [Nokogiri::XML::Document, Nokogiri::XML::Node] The
123
+ # input +xmldoc+, mutated in place.
124
+ # @see .docidentifier_templates?
125
+ def docidentifier_boilerplate_isodoc(xmldoc, isodoc, lang:, script:,
126
+ backend:, flush_caches: false,
127
+ localdir: nil)
128
+ xmldoc.xpath("//docidentifier[@boilerplate]").each do |d|
129
+ do_substitute = d["boilerplate"] == "true"
130
+ d.delete("boilerplate")
131
+ do_substitute or next
132
+ id = boilerplate_snippet_convert(
133
+ d.children.to_xml, isodoc,
134
+ lang: lang, script: script, backend: backend,
135
+ flush_caches: flush_caches, localdir: localdir,
136
+ )
137
+ p_node = Nokogiri::XML(id).at("//p")
138
+ new_children = p_node ? p_node.children.to_xml : id
139
+ # If the rendered template is blank (e.g. a Liquid template
140
+ # that gates on a missing docnumeric), drop the
141
+ # <docidentifier> entirely instead of leaving an empty
142
+ # element behind. Downstream Relaton flavours
143
+ # (relaton-iho, relaton-cc, …) eagerly call Pubid::*::Identifier.parse
144
+ # on the docidentifier content; an empty string is truthy
145
+ # in Ruby and would crash that parser, so the empty element
146
+ # must not survive this pass.
147
+ if new_children.to_s.strip.empty?
148
+ d.remove
149
+ else
150
+ d.children = new_children
151
+ end
152
+ end
153
+ xmldoc
154
+ end
155
+
156
+ # Predicate: are there any +<docidentifier @boilerplate="true">+
157
+ # nodes in +xmldoc+ that {#docidentifier_boilerplate_isodoc} would
158
+ # substitute? Callers use this to decide whether to refresh
159
+ # downstream state (e.g. re-seed +isodoc.meta+ from the resolved
160
+ # bibdata) after the substitution pass — cheap pre-check, avoids
161
+ # depending on a return-value side channel from the mutating
162
+ # substitution method.
163
+ #
164
+ # @param xmldoc [Nokogiri::XML::Document, Nokogiri::XML::Node]
165
+ # @return [Boolean]
166
+ def docidentifier_templates?(xmldoc)
167
+ xmldoc.xpath("//docidentifier[@boilerplate = 'true']").any?
168
+ end
169
+
170
+ # Wrap +text+ in the standard headless dummy document used across
171
+ # the metanorma stack and run an isolated Asciidoctor convert
172
+ # against the given backend. Returns the +//sections+ subtree as
173
+ # a Nokogiri node so callers can splice its children into a
174
+ # surrounding document.
175
+ #
176
+ # If +text+ is already valid XML (root element parses), it is
177
+ # returned verbatim — this lets callers stash pre-converted XML
178
+ # alongside Asciidoc snippets without a special case.
179
+ #
180
+ # @param text [String] Asciidoc snippet, or already-converted XML.
181
+ # @param flavour [Symbol] Asciidoctor backend.
182
+ # @param flush_caches [Boolean] Add +:flush-caches:+ to the
183
+ # dummy header. See {#boilerplate_snippet_convert}.
184
+ # @param localdir [String, nil] Forwarded to
185
+ # {#isolated_asciidoctor_convert} via the options hash.
186
+ # @return [Nokogiri::XML::Node, String] +//sections+ subtree
187
+ # for converted Asciidoc; original +text+ if input was XML.
188
+ def adoc2xml(text, flavour, flush_caches: false, localdir: nil)
189
+ Nokogiri::XML(text).root and return text
190
+ f = flush_caches ? ":flush-caches:\n" : ""
191
+ doc = <<~ADOC
192
+ = X
193
+ A
194
+ :semantic-metadata-headless: true
195
+ :no-isobib:
196
+ #{f}:novalid:
197
+ :!sectids:
198
+
199
+ #{text}
200
+ ADOC
201
+ c = isolated_asciidoctor_convert(
202
+ doc, backend: flavour, header_footer: true, localdir: localdir,
203
+ )
204
+ Nokogiri::XML(c).at("//xmlns:sections")
205
+ end
206
+
207
+ # Run +Asciidoctor.convert+ with curated options so that
208
+ # attributes, +base_dir+, and safe-mode setting do NOT leak in
209
+ # from any outer conversion context. Forces +novalid+ for the
210
+ # inner conversion. The conversion stack is tracked in
211
+ # +@isolated_conversion_stack+ for diagnostics; the +ensure+
212
+ # pop guarantees the marker is balanced even on exception.
213
+ #
214
+ # +localdir+ may be passed inside +options+ as +:localdir+; if
215
+ # so it becomes +:base_dir+ for the inner convert (unless the
216
+ # caller supplied an explicit +:base_dir+). The +:localdir+ key
217
+ # is stripped before delegating to +Asciidoctor.convert+ so it
218
+ # does not appear as an unknown option. Callers that include
219
+ # this module from a class with +@localdir+ get +:base_dir+
220
+ # wired up from there as a fallback.
221
+ #
222
+ # @param content [String] Asciidoc input.
223
+ # @param options [Hash] Asciidoctor convert options. Recognised
224
+ # special key: +:localdir+ (used for +:base_dir+ defaulting
225
+ # and stripped before forwarding).
226
+ # @return [String] Asciidoctor convert output.
227
+ def isolated_asciidoctor_convert(content, options = {})
228
+ @isolated_conversion_stack ||= []
229
+ @isolated_conversion_stack << true
230
+ begin
231
+ preserved = extract_preserved_options(options)
232
+ options = options.dup
233
+ options.delete(:localdir)
234
+ isolated = preserved.merge(options).merge(
235
+ attributes: (preserved[:attributes] || {}).merge(
236
+ "novalid" => "",
237
+ ),
238
+ )
239
+ ::Asciidoctor.convert(content, isolated)
240
+ ensure
241
+ @isolated_conversion_stack.pop
242
+ end
243
+ end
244
+
245
+ # Compute the option set carried over from outer conversion
246
+ # state: a curated subset (+:safe+, +:base_dir+) plus the
247
+ # SAFE_SHARED_ATTRIBUTES hash if the caller did not supply
248
+ # +:attributes+. Caller's own option hash takes precedence
249
+ # for everything except +"novalid"+, which the caller of
250
+ # {#isolated_asciidoctor_convert} forces.
251
+ #
252
+ # @param user_opt [Hash] Caller-supplied options. Recognised:
253
+ # +:safe+, +:attributes+, +:base_dir+, +:localdir+.
254
+ # @return [Hash] Preserved options to merge in front of
255
+ # +user_opt+ for the inner +Asciidoctor.convert+.
256
+ def extract_preserved_options(user_opt)
257
+ options = {}
258
+ options[:safe] = user_opt[:safe] if user_opt.key?(:safe)
259
+ localdir = user_opt[:localdir] ||
260
+ (defined?(@localdir) ? @localdir : nil)
261
+ if localdir && !user_opt.key?(:base_dir)
262
+ options[:base_dir] = localdir
263
+ end
264
+ if user_opt[:attributes].nil?
265
+ options[:attributes] = SAFE_SHARED_ATTRIBUTES.dup
266
+ end
267
+ options
268
+ end
269
+
270
+ # Make every method callable as both
271
+ # +Metanorma::Core::Boilerplate.method+ (for the metanorma
272
+ # collection-layer caller, which does not include the module)
273
+ # and as a regular public instance method (for includers like
274
+ # +Metanorma::Standoc::Cleanup::Boilerplate+).
275
+ extend self
276
+ end
277
+ end
278
+ end
@@ -4,9 +4,27 @@ require "date"
4
4
 
5
5
  module Metanorma
6
6
  module Core
7
+ # Locate and load the right flavor gem (e.g. +metanorma-iso+,
8
+ # +metanorma-itu+) for a given standard type, registering its
9
+ # processor with {Metanorma::Registry} as a side effect.
10
+ #
11
+ # Standard types may be specified as a "taste" name
12
+ # (e.g. +:bipm+, +:icc+) which is mapped to its canonical flavor
13
+ # via {Metanorma::TasteRegister}. Flavor gems follow the
14
+ # +metanorma-<flavor>+ naming convention.
15
+ #
16
+ # The +load_flavor+ entry point both resolves and loads, and is
17
+ # idempotent: if the flavor's processor is already registered,
18
+ # no gem load is attempted.
7
19
  module FlavorLoader
8
20
  module_function
9
21
 
22
+ # Resolve a standard type or taste name to its canonical flavor
23
+ # symbol via the TasteRegister.
24
+ #
25
+ # @param stdtype [Symbol, String] standard type or taste name.
26
+ # @return [Symbol] canonical flavor symbol (the input if no
27
+ # taste alias matched).
10
28
  def taste2flavor(stdtype)
11
29
  stdtype = stdtype.to_sym
12
30
  tastes = Metanorma::TasteRegister.instance.aliases
@@ -14,10 +32,24 @@ module Metanorma
14
32
  stdtype
15
33
  end
16
34
 
35
+ # Map a canonical flavor symbol to its gem name.
36
+ #
37
+ # @param stdtype [Symbol, String] canonical flavor symbol.
38
+ # @return [String] gem name (e.g. +"metanorma-iso"+).
17
39
  def stdtype2flavor_gem(stdtype)
18
40
  "metanorma-#{stdtype}"
19
41
  end
20
42
 
43
+ # Load the flavor gem for +stdtype+ if its processor is not yet
44
+ # registered, and return the canonical flavor symbol.
45
+ #
46
+ # On a fatal LoadError, an error log file is written and
47
+ # +abort+ is called via {Metanorma::Util#log} (severity +:fatal+).
48
+ # If the gem loads but does not register a processor under the
49
+ # expected canonical name, the same fatal-abort path runs.
50
+ #
51
+ # @param stdtype [Symbol, String] standard type or taste name.
52
+ # @return [Symbol] canonical flavor symbol.
21
53
  def load_flavor(stdtype)
22
54
  canonical = taste2flavor(stdtype)
23
55
  gem_name = stdtype2flavor_gem(canonical)
@@ -29,6 +61,13 @@ module Metanorma
29
61
  canonical
30
62
  end
31
63
 
64
+ # Require the flavor gem and log success / failure. On +LoadError+,
65
+ # delegates to {.write_flavor_error_log} which produces a fatal
66
+ # abort. Used internally by {.load_flavor}.
67
+ #
68
+ # @param gem_name [String] gem name (e.g. +"metanorma-iso"+).
69
+ # @param stdtype [Symbol, String] standard type for the user-facing
70
+ # info log line.
32
71
  def require_flavor_gem(gem_name, stdtype)
33
72
  Metanorma::Util.log("[metanorma] Info: Loading `#{gem_name}` gem " \
34
73
  "for standard type `#{stdtype}`.", :info)
@@ -39,6 +78,13 @@ module Metanorma
39
78
  write_flavor_error_log(e, gem_name)
40
79
  end
41
80
 
81
+ # Write a dated error-log file capturing a failed gem load and
82
+ # abort with a user-facing fatal message that points the user at
83
+ # the metanorma issue tracker.
84
+ #
85
+ # @param err [Exception] the LoadError raised by +require+.
86
+ # @param gem_name [String] gem name that failed to load.
87
+ # @return [void] (calls +abort+ via {Metanorma::Util#log}).
42
88
  def write_flavor_error_log(err, gem_name)
43
89
  error_log = "#{Date.today}-error.log"
44
90
  File.write(error_log, err)
@@ -58,6 +104,13 @@ module Metanorma
58
104
  Metanorma::Util.log(msg, :fatal)
59
105
  end
60
106
 
107
+ # Fatal-abort path for the case where the flavor gem loaded but
108
+ # did not register a processor for the requested standard type.
109
+ #
110
+ # @param gem_name [String] gem name that loaded.
111
+ # @param stdtype [Symbol, String] standard type that the gem did
112
+ # not register a processor for.
113
+ # @return [void] (calls +abort+ via {Metanorma::Util#log}).
61
114
  def flavor_unsupported(gem_name, stdtype)
62
115
  Metanorma::Util.log("[metanorma] Error: The `#{gem_name}` gem does " \
63
116
  "not support the standard type #{stdtype}. " \
@@ -5,17 +5,58 @@ require_relative "flavor_loader"
5
5
 
6
6
  module Metanorma
7
7
  module Core
8
+ # Construction and initialisation of an isodoc converter instance for a
9
+ # given Metanorma flavor. Two entry points:
10
+ #
11
+ # - {.resolve_converter} — pick the right converter class for the
12
+ # flavor + output stage (presentation XML or HTML), instantiate it,
13
+ # and return a fresh, *uninitialised* converter.
14
+ # - {.init} — wire i18n, metadata, and xref state onto an existing
15
+ # converter, returning it ready for use.
16
+ #
17
+ # Callers usually need both: +init(resolve_converter(flavor), ...)+.
18
+ # The collection layer's +Util::isodoc_create+ is the canonical wrapper
19
+ # around that pair.
8
20
  module Isodoc
21
+ # Stand-in for the Asciidoctor +node+ argument that converters expect
22
+ # when their +presentation_xml_converter+ / +html_converter+ factory
23
+ # methods are invoked outside an actual Asciidoctor conversion. The
24
+ # +attr+ and +attributes+ stubs return empty values so the factory
25
+ # can run without a real document context.
9
26
  class EmptyNode
27
+ # @param _ [String] attribute name (ignored).
28
+ # @return [nil]
10
29
  def attr(_)
11
30
  nil
12
31
  end
13
32
 
33
+ # @return [Hash] empty attribute set.
14
34
  def attributes
15
35
  {}
16
36
  end
17
37
  end
18
38
 
39
+ # Initialise the i18n / metadata / xref state on an existing
40
+ # converter. Mutates the converter in place and returns it.
41
+ #
42
+ # The converter must already implement the standard Metanorma
43
+ # converter contract: +#init_i18n+, +#i18n_init+, +#metadata_init+,
44
+ # +#meta+, +#xref_init+, +#xrefs+, and (optionally) +#info+.
45
+ #
46
+ # @param converter [Object] An IsoDoc-style converter instance,
47
+ # typically returned by {.resolve_converter}.
48
+ # @param lang [String] BCP-47 language tag (e.g. "en").
49
+ # @param script [String] ISO-15924 script tag (e.g. "Latn").
50
+ # @param locale [String, nil] Optional BCP-47 locale tag for
51
+ # region-specific overrides.
52
+ # @param i18nyaml [Hash, String, nil] Either a parsed i18n hash or
53
+ # a path to a YAML file. Forwarded to +init_i18n+.
54
+ # @param xml [Nokogiri::XML::Node, nil] If supplied, +converter.info+
55
+ # is invoked against it so document-level metadata can be primed.
56
+ # @param localdir [String, nil] If supplied, wired into both
57
+ # +converter.meta+ and +converter.xrefs.klass+ so file lookups
58
+ # resolve relative to the right directory.
59
+ # @return [Object] the same +converter+, fully initialised.
19
60
  def self.init(converter, lang:, script:, locale: nil,
20
61
  i18nyaml: nil, xml: nil, localdir: nil)
21
62
  converter.init_i18n(i18nyaml: i18nyaml, language: lang,
@@ -30,6 +71,21 @@ module Metanorma
30
71
  converter
31
72
  end
32
73
 
74
+ # Resolve the right IsoDoc converter for a given Metanorma flavor
75
+ # and output stage, and return a fresh, *uninitialised* instance
76
+ # of it. The flavor's gem (e.g. metanorma-iso) is autoloaded via
77
+ # {Metanorma::Core::FlavorLoader.load_flavor} if it is not already
78
+ # registered.
79
+ #
80
+ # @param flavor [Symbol, String] Metanorma flavor (e.g. +:iso+,
81
+ # +:standoc+) or a taste name resolvable to one.
82
+ # @param presxml [Boolean] If true (default), return the flavor's
83
+ # presentation-XML converter; otherwise its HTML converter.
84
+ # @return [Object] An IsoDoc-style converter instance, ready to
85
+ # pass to {.init}.
86
+ # @raise [RuntimeError] If no Asciidoctor converter is registered
87
+ # for the resolved flavor, or if the converter does not support
88
+ # the requested output stage.
33
89
  def self.resolve_converter(flavor, presxml: true)
34
90
  resolved = Metanorma::Core::FlavorLoader.load_flavor(flavor)
35
91
  conv_class = ::Asciidoctor::Converter.for(resolved.to_s) ||
@@ -43,6 +99,33 @@ module Metanorma
43
99
  "#{presxml ? 'presentation XML' : 'HTML'} conversion"
44
100
  conv_instance.send(method_name, EmptyNode.new)
45
101
  end
102
+
103
+ # Convenience wrapper combining {.resolve_converter} and {.init}:
104
+ # resolve the converter for +flavor+ and immediately initialise it
105
+ # with the supplied i18n / metadata kwargs. Most callers want this
106
+ # one-stop helper rather than the two-step form.
107
+ #
108
+ # @param flavor [Symbol, String] flavor or taste name; resolved via
109
+ # {Metanorma::Core::FlavorLoader.load_flavor}.
110
+ # @param lang [String] BCP-47 language tag (e.g. "en").
111
+ # @param script [String] ISO-15924 script tag (e.g. "Latn").
112
+ # @param locale [String, nil] optional BCP-47 locale tag.
113
+ # @param i18nyaml [Hash, String, nil] either a parsed i18n hash or
114
+ # a path to a YAML file. Forwarded to +init_i18n+.
115
+ # @param xml [Nokogiri::XML::Node, nil] if supplied, +converter.info+
116
+ # is invoked against it for metadata priming.
117
+ # @param localdir [String, nil] wired into +converter.meta+ and
118
+ # +converter.xrefs.klass+ for relative file-lookup resolution.
119
+ # @param presxml [Boolean] if true (default), return the flavor's
120
+ # presentation-XML converter; otherwise its HTML converter.
121
+ # @return [Object] a fully initialised IsoDoc-style converter.
122
+ # @see https://github.com/metanorma/metanorma/issues/558
123
+ def self.create(flavor, lang:, script:, locale: nil, i18nyaml: nil,
124
+ xml: nil, localdir: nil, presxml: true)
125
+ conv = resolve_converter(flavor, presxml: presxml)
126
+ init(conv, lang: lang, script: script, locale: locale,
127
+ i18nyaml: i18nyaml, xml: xml, localdir: localdir)
128
+ end
46
129
  end
47
130
  end
48
131
  end
@@ -1,5 +1,5 @@
1
1
  module Metanorma
2
2
  module Core
3
- VERSION = "0.1.2".freeze
3
+ VERSION = "0.2.0".freeze
4
4
  end
5
5
  end
@@ -2,7 +2,25 @@ require "nokogiri"
2
2
 
3
3
  module Metanorma
4
4
  module Input
5
+ # Asciidoc input processor. Wraps Asciidoctor to convert raw
6
+ # Asciidoc source into the flavor's semantic XML, and parses the
7
+ # document header to extract metanorma- and Asciidoctor-level
8
+ # configuration attributes (used by {Metanorma::Processor#extract_options}
9
+ # and friends).
5
10
  class Asciidoc < Base
11
+ # Convert +file+ to the target backend's output by running
12
+ # +Asciidoctor.convert+ with safe-mode and metanorma-specific
13
+ # attributes.
14
+ #
15
+ # @param file [String] raw Asciidoc source.
16
+ # @param filename [String] +docfile+ value, used by Asciidoctor
17
+ # for include-relative path resolution.
18
+ # @param type [Symbol] Asciidoctor backend symbol (e.g. +:iso+,
19
+ # +:standoc+).
20
+ # @param options [Hash] passthrough options. Recognised:
21
+ # +:log+ (Asciidoctor logger), +:novalid+ (skip validation),
22
+ # +:output_dir+ (forwarded as the +output_dir+ attribute).
23
+ # @return [String] Asciidoctor convert output.
6
24
  def process(file, filename, type, options = {})
7
25
  require "asciidoctor"
8
26
  out_opts = { to_file: false, safe: :safe, backend: type,
@@ -13,12 +31,28 @@ module Metanorma
13
31
  ::Asciidoctor.convert(file, out_opts)
14
32
  end
15
33
 
34
+ # Split a raw Asciidoc file into its header and body. The header
35
+ # is everything up to the first blank line.
36
+ #
37
+ # @param file [String] raw Asciidoc source.
38
+ # @return [Array<(String, String), (nil, nil)>] +[header, body]+;
39
+ # +[nil, nil]+ if +file+ does not split.
16
40
  def header(file)
17
41
  ret = file.split("\n\n", 2) or return [nil, nil]
18
42
  ret[0] and ret[0] += "\n"
19
43
  [ret[0], ret[1]]
20
44
  end
21
45
 
46
+ # Read metanorma-specific attributes from an Asciidoc header.
47
+ # Supports both bare (e.g. +:document-class:+) and +mn-+ prefixed
48
+ # forms (e.g. +:mn-document-class:+); +document-class+ and +flavor+
49
+ # are aliases. Returns a +.compact+'d hash so absent keys are
50
+ # omitted entirely.
51
+ #
52
+ # @param file [String] raw Asciidoc source (header is
53
+ # re-extracted internally).
54
+ # @return [Hash] subset of the keys +:type+, +:extensions+,
55
+ # +:relaton+, +:asciimath+, +:novalid+.
22
56
  def extract_metanorma_options(file)
23
57
  hdr, = header(file)
24
58
  /\n:(?:mn-)?(?:document-class|flavor):\s+(?<type>\S[^\n]*)\n/ =~ hdr
@@ -39,10 +73,20 @@ module Metanorma
39
73
  }.compact
40
74
  end
41
75
 
76
+ # Normalise a bare-attribute form (e.g. +":use-xinclude:"+) so a
77
+ # value-less attribute reads as +"true"+, while a present-value
78
+ # attribute is left in its natural shape.
79
+ #
80
+ # @param attr [String, nil] line matching +":NAME:"+ ...
81
+ # @param name [String] attribute name (without colons).
82
+ # @return [String, nil] +"true"+ if value-less, the value
83
+ # otherwise; +nil+ if +attr+ was +nil+.
42
84
  def empty_attr(attr, name)
43
85
  attr&.sub(/^#{name}:\s*$/, "#{name}: true")&.sub(/^#{name}:\s+/, "")
44
86
  end
45
87
 
88
+ # Asciidoc attributes whose presence is parsed as a string value
89
+ # (no boolean default). See {#extract_options}.
46
90
  ADOC_OPTIONS =
47
91
  %w(htmlstylesheet htmlcoverpage htmlintropage scripts
48
92
  scripts-override scripts-pdf wordstylesheet i18nyaml
@@ -61,19 +105,37 @@ module Metanorma
61
105
  localize-number iso-word-bg-strip-color modspec-identifier-base)
62
106
  .freeze
63
107
 
108
+ # Boolean Asciidoc attributes that default to +true+ if the
109
+ # attribute is bare (no value).
64
110
  EMPTY_ADOC_OPTIONS_DEFAULT_TRUE =
65
111
  %w(data-uri-image suppress-asciimath-dup use-xinclude
66
112
  source-highlighter).freeze
67
113
 
114
+ # Boolean Asciidoc attributes that default to +false+ if the
115
+ # attribute is bare (no value).
68
116
  EMPTY_ADOC_OPTIONS_DEFAULT_FALSE =
69
117
  %w(hierarchical-assets break-up-urls-in-tables toc-figures
70
118
  toc-tables toc-recommendations).freeze
71
119
 
120
+ # Convert an Asciidoc-style attribute name (kebab-case, possibly
121
+ # ending in +-override+ or +-pdf+) into the Ruby symbol used in
122
+ # this gem's option hashes.
123
+ #
124
+ # @param name [String] attribute name from Asciidoc header.
125
+ # @return [Symbol]
72
126
  def attr_name_normalise(name)
73
127
  name.delete("-").sub(/override$/, "_override").sub(/pdf$/, "_pdf")
74
128
  .to_sym
75
129
  end
76
130
 
131
+ # Read processor-relevant options from an Asciidoc header. Three
132
+ # categories are scanned: string-valued ({ADOC_OPTIONS}), boolean
133
+ # default-true ({EMPTY_ADOC_OPTIONS_DEFAULT_TRUE}), and boolean
134
+ # default-false ({EMPTY_ADOC_OPTIONS_DEFAULT_FALSE}). The result
135
+ # is +.compact+'d so absent keys are omitted.
136
+ #
137
+ # @param file [String] raw Asciidoc source.
138
+ # @return [Hash{Symbol => String, Boolean}]
77
139
  def extract_options(file)
78
140
  hdr, = header(file)
79
141
  ret = ADOC_OPTIONS.each_with_object({}) do |w, acc|
@@ -1,6 +1,14 @@
1
1
  module Metanorma
2
2
  module Input
3
+ # Abstract base for input processors. Concrete subclasses
4
+ # (e.g. {Metanorma::Input::Asciidoc}) override {#process} to
5
+ # convert raw input text into ISODoc semantic XML.
3
6
  class Base
7
+ # @param _file [String] raw input contents.
8
+ # @param _filename [String] source filename for relative
9
+ # path resolution.
10
+ # @param _type [Symbol] target backend symbol.
11
+ # @raise [RuntimeError] abstract base — subclasses must override.
4
12
  def process(_file, _filename, _type)
5
13
  raise "This is an abstract class"
6
14
  end
@@ -2,13 +2,39 @@
2
2
  #
3
3
 
4
4
  module Metanorma
5
+ # Abstract base class for Metanorma flavor processors. Each flavor
6
+ # gem (metanorma-iso, metanorma-itu, etc.) defines a subclass and
7
+ # registers it with {Metanorma::Registry}; the registry then
8
+ # dispatches input documents to the appropriate processor based on
9
+ # the document's declared flavor / class.
10
+ #
11
+ # Subclasses MUST set +@short+, +@input_format+, and (if Asciidoctor
12
+ # is the input parser) +@asciidoctor_backend+ in their +initialize+,
13
+ # and MAY override {#output_formats}, {#use_presentation_xml},
14
+ # {#options_preprocess}, and {#output} to customise their output
15
+ # pipeline.
5
16
  class Processor
6
- attr_reader :short, :input_format, :asciidoctor_backend
17
+ # @return [Symbol] short name registered with the Registry
18
+ # (e.g. +:iso+, +:itu+).
19
+ attr_reader :short
7
20
 
21
+ # @return [Symbol] input parser identifier (typically +:asciidoc+).
22
+ attr_reader :input_format
23
+
24
+ # @return [Symbol, nil] Asciidoctor backend symbol used when the
25
+ # processor's input is Asciidoc (e.g. +:iso+, +:standoc+);
26
+ # +nil+ for non-Asciidoc inputs.
27
+ attr_reader :asciidoctor_backend
28
+
29
+ # @raise [RuntimeError] always — concrete flavors must override.
8
30
  def initialize
9
31
  raise "This is an abstract class!"
10
32
  end
11
33
 
34
+ # Mapping of output-format name to file extension. Subclasses
35
+ # typically extend this with HTML / Word / PDF entries.
36
+ #
37
+ # @return [Hash{Symbol => String}] format symbol -> file extension.
12
38
  def output_formats
13
39
  {
14
40
  xml: "xml",
@@ -17,6 +43,17 @@ module Metanorma
17
43
  }
18
44
  end
19
45
 
46
+ # Convert an input file to Metanorma semantic XML by routing it
47
+ # through the {Metanorma::Input::Asciidoc} processor with this
48
+ # processor's Asciidoctor backend. Override for non-Asciidoc
49
+ # inputs.
50
+ #
51
+ # @param file [String] the raw input contents.
52
+ # @param filename [String] the source filename (used for relative
53
+ # path resolution in includes).
54
+ # @param options [Hash] passthrough options for the input
55
+ # processor; see {Metanorma::Input::Asciidoc#process}.
56
+ # @return [String] Metanorma semantic XML.
20
57
  def input_to_isodoc(file, filename, options = {})
21
58
  Metanorma::Input::Asciidoc.new.process(file, filename,
22
59
  @asciidoctor_backend, options)
@@ -26,6 +63,13 @@ module Metanorma
26
63
  # raise "This is an abstract class!"
27
64
  # end
28
65
 
66
+ # Whether the given output extension is downstream of the
67
+ # presentation XML stage (and therefore needs presentation XML
68
+ # generated first). Defaults to true for HTML/Word/PDF. Other formats
69
+ # such as RFC and STS are generated directly from semantic XML
70
+ #
71
+ # @param ext [Symbol] output-format symbol.
72
+ # @return [Boolean]
29
73
  def use_presentation_xml(ext)
30
74
  case ext
31
75
  when :html, :doc, :pdf then true
@@ -34,21 +78,48 @@ module Metanorma
34
78
  end
35
79
  end
36
80
 
81
+ # Mutate +options+ in place to ensure +:output_formats+ is set.
82
+ # Override to add other flavor-specific defaults.
83
+ #
84
+ # @param options [Hash] processor options.
85
+ # @return [Hash] +options+ with +:output_formats+ defaulted.
37
86
  def options_preprocess(options)
38
87
  options[:output_formats] ||= output_formats
39
88
  end
40
89
 
90
+ # Default output-writer: dump the rendered output string to a
91
+ # UTF-8 file. Override for binary formats (Word, PDF) that need
92
+ # different post-processing.
93
+ #
94
+ # @param isodoc_node [String] rendered output content.
95
+ # @param _inname [String] source filename (unused at this base level).
96
+ # @param outname [String] destination path.
97
+ # @param _format [Symbol] output format (unused at this base level).
98
+ # @param _options [Hash] processor options (unused at this base level).
99
+ # @return [Integer] bytes written (Ruby's +File#write+ return).
41
100
  def output(isodoc_node, _inname, outname, _format, _options = {})
42
101
  File.open(outname, "w:UTF-8") { |f| f.write(isodoc_node) }
43
102
  end
44
103
 
104
+ # Read processor-relevant options from the input file's header
105
+ # via {Metanorma::Input::Asciidoc#extract_options}, merging in
106
+ # this processor's +output_formats+ for downstream stages.
107
+ #
108
+ # @param file [String] raw input contents.
109
+ # @return [Hash] extracted options.
45
110
  def extract_options(file)
46
111
  Metanorma::Input::Asciidoc.new.extract_options(file)
47
112
  .merge(output_formats: output_formats)
48
113
  end
49
114
 
115
+ # Read metanorma-specific options from the input file's header
116
+ # via {Metanorma::Input::Asciidoc#extract_metanorma_options}.
117
+ #
118
+ # @param file [String] raw input contents.
119
+ # @return [Hash] metanorma-specific options
120
+ # (+:type+, +:extensions+, +:relaton+, +:asciimath+, +:novalid+).
50
121
  def extract_metanorma_options(file)
51
122
  Metanorma::Input::Asciidoc.new.extract_metanorma_options(file)
52
123
  end
53
124
  end
54
- end
125
+ end
@@ -1,6 +1,20 @@
1
1
  module Metanorma
2
+ # Cross-cutting helpers used by the metanorma stack: logging gated by
3
+ # {Metanorma::Configuration#logs}, ordering of output-format extensions
4
+ # for execution, and recursive hash key normalisation.
2
5
  module Util
3
6
  class << self
7
+ # Print +message+ to stdout if +type+ is in
8
+ # {Metanorma::Configuration#logs}; abort the process if
9
+ # +type+ is +:fatal+ regardless of whether it was printed.
10
+ #
11
+ # @param message [String] the message to print.
12
+ # @param type [Symbol] severity, defaults to +:info+. Common
13
+ # values: +:info+, +:warning+, +:error+, +:fatal+. The
14
+ # configured +logs+ list determines which severities are
15
+ # printed; +:fatal+ always aborts on top of (or instead of)
16
+ # printing.
17
+ # @return [void]
4
18
  def log(message, type = :info)
5
19
  log_types = Metanorma.configuration.logs.map(&:to_s) || []
6
20
 
@@ -13,7 +27,14 @@ module Metanorma
13
27
  end
14
28
  end
15
29
 
16
- # dependency ordering
30
+ # Sort key used to put output-format extensions in execution order.
31
+ # +xml+ runs first because the semantic XML feeds everything else;
32
+ # +rxl+ second; +presentation+ third (consumed by HTML/Word/PDF);
33
+ # everything else last (alphabetical via the caller's sort
34
+ # stability).
35
+ #
36
+ # @param ext [Symbol] output-format extension symbol.
37
+ # @return [Integer] ordinal for sorting.
17
38
  def sort_extensions_execution_ord(ext)
18
39
  case ext
19
40
  when :xml then 0
@@ -24,12 +45,25 @@ module Metanorma
24
45
  end
25
46
  end
26
47
 
48
+ # Stable-sort a list of output-format extensions by execution
49
+ # priority (see {.sort_extensions_execution_ord}).
50
+ #
51
+ # @param ext [Array<Symbol>] extensions to sort.
52
+ # @return [Array<Symbol>] sorted in execution order.
27
53
  def sort_extensions_execution(ext)
28
54
  ext.sort do |a, b|
29
55
  sort_extensions_execution_ord(a) <=> sort_extensions_execution_ord(b)
30
56
  end
31
57
  end
32
58
 
59
+ # Recursively normalise the keys of a Hash (and any nested Hashes
60
+ # / Enumerables) to Strings. Used to canonicalise YAML/JSON-derived
61
+ # configuration that may have been parsed with mixed symbol /
62
+ # string keys.
63
+ #
64
+ # @param hash [Hash, Enumerable, Object] the value to normalise.
65
+ # @return [Hash, Array, Object] the normalised value; non-collections
66
+ # are returned unchanged.
33
67
  def recursive_string_keys(hash)
34
68
  case hash
35
69
  when Hash then hash.map do |k, v|
@@ -10,6 +10,7 @@ require "metanorma/registry/registry"
10
10
  require "metanorma/asciidoctor_extensions"
11
11
  require "metanorma/core/flavor_loader"
12
12
  require "metanorma/core/isodoc"
13
+ require "metanorma/core/boilerplate"
13
14
 
14
15
  module Metanorma
15
16
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metanorma-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-27 00:00:00.000000000 Z
11
+ date: 2026-05-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: asciidoctor
@@ -154,6 +154,7 @@ files:
154
154
  - lib/metanorma/asciidoctor_extensions.rb
155
155
  - lib/metanorma/asciidoctor_extensions/glob_include_processor.rb
156
156
  - lib/metanorma/config/config.rb
157
+ - lib/metanorma/core/boilerplate.rb
157
158
  - lib/metanorma/core/flavor_loader.rb
158
159
  - lib/metanorma/core/isodoc.rb
159
160
  - lib/metanorma/core/version.rb