metanorma-oiml 0.1.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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.adoc +291 -0
  4. data/data/themes/oiml/assets/oiml-logo-full-dark.svg +1 -0
  5. data/data/themes/oiml/assets/oiml-logo-full-light.svg +1 -0
  6. data/data/themes/oiml/assets/oiml-logo-icon-dark.svg +1 -0
  7. data/data/themes/oiml/assets/oiml-logo-icon-light.svg +1 -0
  8. data/data/themes/oiml/assets/oiml-logo.svg +1 -0
  9. data/data/themes/oiml/theme.yaml +56 -0
  10. data/exe/oiml-sts +10 -0
  11. data/lib/metanorma/oiml/document/root.rb +29 -0
  12. data/lib/metanorma/oiml/document.rb +35 -0
  13. data/lib/metanorma/oiml/html/renderer.rb +14 -0
  14. data/lib/metanorma/oiml/html.rb +14 -0
  15. data/lib/metanorma/oiml/sts/cli.rb +89 -0
  16. data/lib/metanorma/oiml/sts/html_renderer/assets/metanorma-icon-dark.svg +1 -0
  17. data/lib/metanorma/oiml/sts/html_renderer/assets/metanorma-icon-light.svg +1 -0
  18. data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-logo-dark.svg +1 -0
  19. data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-logo.svg +1 -0
  20. data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-smart-logo-dark.svg +1 -0
  21. data/lib/metanorma/oiml/sts/html_renderer/assets/oiml-smart-logo.svg +1 -0
  22. data/lib/metanorma/oiml/sts/html_renderer/assets/page.js +145 -0
  23. data/lib/metanorma/oiml/sts/html_renderer/assets/theme.css +509 -0
  24. data/lib/metanorma/oiml/sts/html_renderer/ruby.rb +1071 -0
  25. data/lib/metanorma/oiml/sts/html_renderer/templates/_element.html.liquid +1 -0
  26. data/lib/metanorma/oiml/sts/html_renderer/templates/_footer.html.liquid +21 -0
  27. data/lib/metanorma/oiml/sts/html_renderer/templates/_hero.html.liquid +9 -0
  28. data/lib/metanorma/oiml/sts/html_renderer/templates/_img.html.liquid +1 -0
  29. data/lib/metanorma/oiml/sts/html_renderer/templates/_link.html.liquid +1 -0
  30. data/lib/metanorma/oiml/sts/html_renderer/templates/_meta_header.html.liquid +1 -0
  31. data/lib/metanorma/oiml/sts/html_renderer/templates/document.html.liquid +43 -0
  32. data/lib/metanorma/oiml/sts/html_renderer.rb +19 -0
  33. data/lib/metanorma/oiml/sts/mathml_builder.rb +93 -0
  34. data/lib/metanorma/oiml/sts/parity_validator/normaliser.rb +95 -0
  35. data/lib/metanorma/oiml/sts/parity_validator/report.rb +137 -0
  36. data/lib/metanorma/oiml/sts/parity_validator.rb +23 -0
  37. data/lib/metanorma/oiml/sts/transformer/back_transformer.rb +55 -0
  38. data/lib/metanorma/oiml/sts/transformer/base.rb +71 -0
  39. data/lib/metanorma/oiml/sts/transformer/block_dispatcher.rb +145 -0
  40. data/lib/metanorma/oiml/sts/transformer/body_transformer.rb +23 -0
  41. data/lib/metanorma/oiml/sts/transformer/context.rb +27 -0
  42. data/lib/metanorma/oiml/sts/transformer/dl_transformer.rb +49 -0
  43. data/lib/metanorma/oiml/sts/transformer/document_transformer.rb +65 -0
  44. data/lib/metanorma/oiml/sts/transformer/figure_transformer.rb +49 -0
  45. data/lib/metanorma/oiml/sts/transformer/footnote_collector.rb +33 -0
  46. data/lib/metanorma/oiml/sts/transformer/formula_transformer.rb +45 -0
  47. data/lib/metanorma/oiml/sts/transformer/front_transformer.rb +84 -0
  48. data/lib/metanorma/oiml/sts/transformer/id_generator.rb +39 -0
  49. data/lib/metanorma/oiml/sts/transformer/inline_transformer.rb +14 -0
  50. data/lib/metanorma/oiml/sts/transformer/list_transformer.rb +82 -0
  51. data/lib/metanorma/oiml/sts/transformer/model_builder.rb +322 -0
  52. data/lib/metanorma/oiml/sts/transformer/note_transformer.rb +103 -0
  53. data/lib/metanorma/oiml/sts/transformer/paragraph_transformer.rb +574 -0
  54. data/lib/metanorma/oiml/sts/transformer/reference_transformer.rb +107 -0
  55. data/lib/metanorma/oiml/sts/transformer/rendered_text_extractor.rb +168 -0
  56. data/lib/metanorma/oiml/sts/transformer/section_transformer.rb +233 -0
  57. data/lib/metanorma/oiml/sts/transformer/source_document.rb +246 -0
  58. data/lib/metanorma/oiml/sts/transformer/standard.rb +37 -0
  59. data/lib/metanorma/oiml/sts/transformer/table_transformer.rb +290 -0
  60. data/lib/metanorma/oiml/sts/transformer/term_transformer.rb +236 -0
  61. data/lib/metanorma/oiml/sts/transformer.rb +47 -0
  62. data/lib/metanorma/oiml/sts/validation_report.rb +43 -0
  63. data/lib/metanorma/oiml/sts/validator.rb +126 -0
  64. data/lib/metanorma/oiml/sts/version.rb +9 -0
  65. data/lib/metanorma/oiml/sts.rb +84 -0
  66. data/lib/metanorma-oiml.rb +10 -0
  67. data/schematron/oiml-sts.sch +70 -0
  68. metadata +221 -0
@@ -0,0 +1,1071 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "liquid"
5
+ require "sts"
6
+
7
+ module Metanorma
8
+ module Oiml
9
+ module Sts
10
+ module HtmlRenderer
11
+ # STS XML → HTML renderer. Parses the input once into the sts-ruby
12
+ # typed model and emits HTML through Liquid templates — no DOM
13
+ # library, no XSLT engine.
14
+ #
15
+ # Architecture:
16
+ #
17
+ # render
18
+ # ├── coerce_model (XML string → Sts::NisoSts::Standard)
19
+ # ├── render_node (model tree → fragment, via DISPATCH)
20
+ # └── assemble_document (fragment → full page via
21
+ # templates/document.html.liquid with
22
+ # assets/sts.css and the OIML logo)
23
+ #
24
+ # Element markup lives in templates/ and is overridable per
25
+ # organization with Ruby.new(templates_dir:, assets_dir:) — no
26
+ # code changes needed.
27
+ class Ruby
28
+ TEMPLATES_DIR = File.expand_path("templates", __dir__)
29
+ ASSETS_DIR = File.expand_path("assets", __dir__)
30
+
31
+ NISO = ::Sts::NisoSts
32
+ TBX = ::Sts::TbxIsoTml
33
+
34
+ # demodulized model class name → handler method. Anything not
35
+ # listed renders its children transparently (see #render_children).
36
+ # NisoSts is the canonical NISO STS namespace; TbxIsoTml covers
37
+ # the terminology model and table cells.
38
+ DISPATCH = {
39
+ "MetadataIso" => :meta, "MetadataStd" => :meta,
40
+ "RegMeta" => :meta, "NatMeta" => :meta,
41
+ "Body" => :children,
42
+ "Section" => :section, "App" => :section, "TermSection" => :section,
43
+ "Label" => :label,
44
+ "Title" => :title,
45
+ "Standard" => :standard, "Adoption" => :standard,
46
+ "Paragraph" => :paragraph,
47
+ "List" => :list, "ListItem" => :list_item,
48
+ "DefList" => :def_list, "Term" => :term, "Def" => :def_item,
49
+ "TableWrap" => :table_wrap, "Table" => :table,
50
+ "Thead" => :thead, "Tbody" => :tbody, "Tr" => :tr,
51
+ "Td" => :td, "Th" => :th,
52
+ "Figure" => :figure, "Caption" => :caption, "Graphic" => :graphic,
53
+ "DisplayFormula" => :disp_formula, "InlineFormula" => :inline_formula,
54
+ "Preformat" => :preformat,
55
+ "NonNormativeNote" => :note, "NonNormativeExample" => :example,
56
+ "DispQuote" => :quote,
57
+ "ReferenceList" => :ref_list, "Reference" => :ref,
58
+ "MixedCitation" => :citation, "ElementCitation" => :citation,
59
+ "ReferenceStandard" => :std, "StandardIdentification" => :std_ident,
60
+ "Originator" => :originator,
61
+ "CopyrightStatement" => :copyright,
62
+ "Fn" => :fn, "Xref" => :xref,
63
+ "ExtLink" => :ext_link, "Uri" => :ext_link,
64
+ "Break" => :brk, "ProcessingMeta" => :skip,
65
+ }.freeze
66
+
67
+ # Inline phrase-level elements → HTML tag (constant-keyed).
68
+ # NisoSts for Monospace/Sub/Sup/Sc/Strike/Underline;
69
+ # TbxIsoTml for Bold/Italic (terminology namespace).
70
+ INLINE_TAGS = {
71
+ TBX::Bold => "strong",
72
+ TBX::Italic => "em",
73
+ NISO::Monospace => "code",
74
+ NISO::Sub => "sub",
75
+ NISO::Sup => "sup",
76
+ NISO::Sc => "span",
77
+ NISO::Strike => "s",
78
+ NISO::Underline => "u",
79
+ }.freeze
80
+
81
+ META_ID_NAMES = %w[StandardIdentification
82
+ StandardRef
83
+ DocumentIdentification].freeze
84
+
85
+ PUBLISHER_NAME = "International Organization of Legal Metrology"
86
+ PUBLISHER_ADDRESS = "Bureau International de Métrologie Légale · 11 rue Turgot, 75009 Paris, France"
87
+
88
+ def initialize(templates_dir: nil, assets_dir: nil, publisher_name: nil, publisher_address: nil, license_text: nil)
89
+ @templates_dir = templates_dir || TEMPLATES_DIR
90
+ @assets_dir = assets_dir || ASSETS_DIR
91
+ @publisher_name = publisher_name
92
+ @publisher_address = publisher_address
93
+ @license_text = license_text || "All rights reserved"
94
+ @liquid_env = Liquid::Environment.new
95
+ @liquid_env.file_system = Liquid::LocalFileSystem.new(@templates_dir)
96
+ @template_cache = {}
97
+ @mapping_cache = Hash.new { |h, klass| h[klass] = build_mapping(klass) }
98
+ @toc = []
99
+ @deferred_footnotes = []
100
+ @depth = 0
101
+ end
102
+
103
+ # Render +model_or_xml+. With +full_document: true+ (default)
104
+ # the fragment is assembled into a complete branded page;
105
+ # with +full_document: false+ the bare fragment is returned.
106
+ def render(model_or_xml, full_document: true)
107
+ model = coerce_model(model_or_xml)
108
+ @assemble = full_document
109
+ @toc = []
110
+ @depth = 0
111
+ body = render_node(model)
112
+ return body unless full_document
113
+
114
+ assemble_document(body, model)
115
+ end
116
+
117
+ private
118
+
119
+ attr_reader :templates_dir, :assets_dir
120
+
121
+ # Accept either a parsed model or a raw XML string; the
122
+ # latter is parsed via the sts-ruby typed model.
123
+ def coerce_model(input)
124
+ return input if input.is_a?(Lutaml::Model::Serializable)
125
+
126
+ ::Sts::NisoSts::Standard.from_xml(input.to_s)
127
+ end
128
+
129
+ # --------------------------------------------------------------
130
+ # Dispatch
131
+ # --------------------------------------------------------------
132
+
133
+ def render_node(node)
134
+ case node
135
+ when String then escape(node)
136
+ when Mml::V3::Math then mathml_to_html(node)
137
+ when *INLINE_TAGS.keys then render_inline_tag(node)
138
+ else
139
+ handler = DISPATCH[node.class.name.split("::").last]
140
+ handler ? send(handler, node) : render_children(node)
141
+ end
142
+ end
143
+
144
+ # Mml::V3::Math serializes with mml: prefix and compact
145
+ # element spacing; MN HTML uses bare element names with
146
+ # newlines between them. Strip the prefix and add newlines
147
+ # so Nokogiri's text extraction produces the same whitespace
148
+ # between MathML leaf elements.
149
+ def mathml_to_html(node)
150
+ node.to_xml.to_s
151
+ .gsub(/<(\/?)mml:/, '<\1')
152
+ .gsub(/\s+xmlns:mml="[^"]*"/, "")
153
+ .gsub(/\s+xmlns:xlink="[^"]*"/, "")
154
+ .gsub(/></, ">\n<")
155
+ end
156
+
157
+ # --------------------------------------------------------------
158
+ # Handlers
159
+ # --------------------------------------------------------------
160
+
161
+ def children(node) = render_children(node)
162
+
163
+ # Renders the document: front matter first, then ONE <main>
164
+ # wrapping body AND back matter (annexes, bibliography), so
165
+ # back content stays in the main flow instead of becoming
166
+ # grid items behind the TOC.
167
+ def standard(node)
168
+ front = document_child(node, "Front")
169
+ body = document_child(node, "Body")
170
+ back = document_child(node, "Back")
171
+
172
+ parts = []
173
+ parts << render_children(front) if front
174
+ main_content = []
175
+ main_content << render_children(body) if body
176
+ main_content << render_children(back) if back
177
+ parts << render_element("main", main_content.join)
178
+ parts.join
179
+ end
180
+
181
+ # First child model of +node+ whose demodulized class name
182
+ # equals +demodulized+, in document order.
183
+ def document_child(node, demodulized)
184
+ mapping = @mapping_cache[node.class][:elements]
185
+ node.element_order.each do |entry|
186
+ next unless entry.node_type == :element
187
+
188
+ attr = mapping[entry.name.to_s]
189
+ next unless attr
190
+
191
+ item = node.public_send(attr)
192
+ item = item.first if item.is_a?(Array)
193
+ return item if item && item.class.name.split("::").last == demodulized
194
+ end
195
+ nil
196
+ end
197
+
198
+ def section(node)
199
+ sec_id = section_id(node)
200
+ title_text = extract_title(node)
201
+ register_toc(node, sec_id, title_text)
202
+ previous_section_id = @current_section_id
203
+ previous_label = @current_label
204
+ label_node = own_label(node)
205
+ suppress_label(label_node) if label_node
206
+ raw_label = label_node ? plain_text(label_node).gsub(/\s+/, " ").strip : nil
207
+ @current_label = display_label(node, raw_label)
208
+ # Boilerplate front-matter (Copyright, Feedback) renders as
209
+ # a muted card; every other section renders uniformly with
210
+ # its own heading — including unnumbered prose sections like
211
+ # Foreword and Introduction.
212
+ frontmatter = frontmatter_section?(sec_id, title_text)
213
+ @depth += 1
214
+ @current_section_id = sec_id
215
+ inner = render_children(node)
216
+ render_element("section", inner, id: sec_id, css: frontmatter ? "frontmatter" : nil)
217
+ ensure
218
+ @depth -= 1
219
+ @current_section_id = previous_section_id
220
+ @current_label = previous_label
221
+ end
222
+
223
+ # Only the boilerplate front-matter sections (copyright,
224
+ # feedback) get the muted-card treatment. Other unnumbered
225
+ # sections (Foreword, Introduction) render as normal sections.
226
+ def frontmatter_section?(sec_id, title_text)
227
+ return false unless @depth.zero? && @current_label.nil?
228
+ return true if sec_id && %w[copyright feedback].include?(sec_id.downcase)
229
+
230
+ title_match = TITLE_DOWNCASE_MAP[title_text.to_s.downcase]
231
+ title_match == :frontmatter
232
+ end
233
+
234
+ TITLE_DOWNCASE_MAP = {
235
+ "copyright" => :frontmatter,
236
+ "feedback" => :frontmatter,
237
+ }.freeze
238
+
239
+ # The section's/list item's own <label> child model (nil when
240
+ # absent or a bare string, e.g. TableWrap#label).
241
+ def own_label(node)
242
+ return nil unless node.class.method_defined?(:label)
243
+
244
+ label = node.label
245
+ label if label.is_a?(Lutaml::Model::Serializable)
246
+ end
247
+
248
+ # Marks a label model as consumed by its container (section
249
+ # heading prefix / list-item marker) so the generic label
250
+ # handler does not render it a second time.
251
+ def suppress_label(node)
252
+ suppressed_labels[node] = true
253
+ end
254
+
255
+ def label_suppressed?(node)
256
+ suppressed_labels.key?(node)
257
+ end
258
+
259
+ def suppressed_labels
260
+ @suppressed_labels ||= {}.compare_by_identity
261
+ end
262
+
263
+ # Section's <title> child text, nil when absent.
264
+ def extract_title(node)
265
+ title_node = find_child(node, "Title")
266
+ return nil unless title_node
267
+
268
+ text = plain_text(title_node).gsub(/\s+/, " ").strip
269
+ text.empty? ? nil : text
270
+ end
271
+
272
+ # Term sections (id starts with "term-") carry the term
273
+ # number in <title> and the preferred name as the bold run
274
+ # of the first <p>. For the TOC entry, append the preferred
275
+ # name so the entry reads "3.1 NISO STS".
276
+ def append_term_name(node, sec_id, title_text)
277
+ return title_text unless sec_id&.start_with?("term-")
278
+ return title_text if title_text.to_s.match?(/\s/)
279
+
280
+ name = preferred_term_name(node)
281
+ return title_text if name.nil? || name.empty?
282
+
283
+ "#{title_text} #{name}"
284
+ end
285
+
286
+ # The preferred name is the bold run of the section's first
287
+ # <p> child (the term heading paragraph). Returns nil when
288
+ # the section is not a term section or the heading paragraph
289
+ # is absent / has no bold run.
290
+ def preferred_term_name(node)
291
+ paragraph = find_child(node, "Paragraph")
292
+ return nil unless paragraph
293
+
294
+ bold = find_child(paragraph, "Bold")
295
+ return nil unless bold
296
+
297
+ text = plain_text(bold).gsub(/\s+/, " ").strip
298
+ text.empty? ? nil : text
299
+ end
300
+
301
+ # Sections built without an id (e.g. front-matter sections)
302
+ # get a slug from their title so they are linkable and can
303
+ # appear in the TOC.
304
+ def section_id(node)
305
+ return node.id if node.class.method_defined?(:id) && node.id
306
+
307
+ title_node = find_child(node, "Title")
308
+ return nil unless title_node
309
+
310
+ plain_text(title_node).gsub(/\s+/, " ").strip
311
+ .downcase.gsub(/[^a-z0-9]+/, "-")
312
+ .gsub(/\A-|-\z/, "")
313
+ end
314
+
315
+ # The display form of a section label: annex letters gain the
316
+ # "Annex" prefix ("Annex A"), everything else renders bare.
317
+ def display_label(node, label_text)
318
+ return nil if label_text.nil? || label_text.empty?
319
+ return "Annex #{label_text}" if node.class.name.split("::").last == "App"
320
+
321
+ label_text
322
+ end
323
+
324
+ # Records the section in the interactive table of contents.
325
+ # Apps are sections too; their titles come from the same walk.
326
+ # The entry text carries the section number ("1 Scope").
327
+ # Term sections (id starts with "term-") append the preferred
328
+ # name (the bold text of the first <p>) so the entry reads
329
+ # "3.1 NISO STS" instead of just "3.1".
330
+ def register_toc(node, sec_id, title_text = nil)
331
+ title_node = find_child(node, "Title")
332
+ return unless sec_id && title_node
333
+
334
+ label_node = own_label(node)
335
+ label_text = display_label(node, label_node ? plain_text(label_node).gsub(/\s+/, " ").strip : nil)
336
+ title_text ||= plain_text(title_node).gsub(/\s+/, " ").strip
337
+ title_text = "#{label_text} #{title_text}" if label_text
338
+ title_text = append_term_name(node, sec_id, title_text)
339
+ register_toc_entry(id: sec_id, title: title_text, depth: @depth)
340
+ end
341
+
342
+ def register_toc_entry(id:, title:, depth:)
343
+ @toc << { id: id, title: title, depth: depth }
344
+ end
345
+
346
+ # First direct child model whose demodulized class name matches.
347
+ def find_child(node, demodulized_name)
348
+ return nil unless node.is_a?(Lutaml::Model::Serializable)
349
+
350
+ mapping = @mapping_cache[node.class][:elements]
351
+ node.element_order.each do |entry|
352
+ next unless entry.node_type == :element
353
+
354
+ attr = mapping[entry.name.to_s]
355
+ next unless attr
356
+
357
+ value = node.public_send(attr)
358
+ value = value.first if value.is_a?(Array)
359
+ return value if value && value.class.name.split("::").last == demodulized_name
360
+ end
361
+ nil
362
+ end
363
+
364
+ def label(node)
365
+ return "" if label_suppressed?(node)
366
+
367
+ render_element("span", render_inline(node), css: "label")
368
+ end
369
+
370
+ def title(node)
371
+ return render_element("em", render_inline(node)) if @in_std
372
+
373
+ level = (1 + @depth).clamp(2, 4)
374
+ anchor = if @current_section_id
375
+ %(<a class="h-anchor" href="##{@current_section_id}" aria-label="Link to this section">§</a>)
376
+ else
377
+ ""
378
+ end
379
+ # The section number captured from its <label> prefixes the
380
+ # heading ("1 Scope"); consumed so nested content never
381
+ # inherits it.
382
+ prefix = ""
383
+ if @current_label && !@current_label.empty?
384
+ prefix = %(<span class="sec-label">#{escape(@current_label)}</span> )
385
+ @current_label = nil
386
+ end
387
+ render_element("h#{level}", prefix + render_inline(node) + anchor)
388
+ end
389
+
390
+ def paragraph(node) = render_element("p", render_inline(node), id: node.id)
391
+
392
+ # A list whose items carry explicit <label> markers from the
393
+ # source renders without HTML bullets — the label IS the
394
+ # marker (mn-labeled-list); otherwise the browser's bullets
395
+ # would duplicate the source's markers.
396
+ def list(node)
397
+ tag = node.list_type == "order" ? "ol" : "ul"
398
+ css = labeled_list?(node) ? "mn-labeled-list" : nil
399
+ render_element(tag, render_children(node), css: css)
400
+ end
401
+
402
+ def labeled_list?(node)
403
+ return false unless node.class.method_defined?(:list_item)
404
+
405
+ Array(node.list_item).any? { |item| own_label(item) }
406
+ end
407
+
408
+ def list_item(node)
409
+ label_node = own_label(node)
410
+ suppress_label(label_node) if label_node
411
+ inner = render_children(node)
412
+ if label_node
413
+ marker = render_element("span", escape(plain_text(label_node).strip), css: "li-label")
414
+ inner = marker + inner
415
+ end
416
+ render_element("li", inner)
417
+ end
418
+
419
+ def def_list(node) = render_element("dl", render_children(node))
420
+
421
+ def term(node) = render_element("dt", render_element("p", render_children(node)))
422
+
423
+ def def_item(node) = render_element("dd", render_children(node))
424
+
425
+ # Table number + caption render as a caption band above the
426
+ # table; the raw label/caption children are skipped so they
427
+ # don't repeat inside the box.
428
+ def table_wrap(node)
429
+ inner = render_children(node, skip: %w[label caption])
430
+ render_element("div", table_caption_line(node) + inner, css: "table-wrap")
431
+ end
432
+
433
+ def table_caption_line(node)
434
+ label_text = node.class.method_defined?(:label) ? node.label.to_s.strip : ""
435
+ caption_node = node.caption if node.class.method_defined?(:caption)
436
+ title_node = caption_node.title if caption_node&.class&.method_defined?(:title)
437
+ title_html = title_node ? render_inline(title_node) : ""
438
+ return "" if label_text.empty? && title_html.empty?
439
+
440
+ parts = []
441
+ parts << %(<span class="tc-label">#{escape(label_text)}</span>) unless label_text.empty?
442
+ parts << title_html unless title_html.empty?
443
+ # NB: class is tbl-caption — "table-caption" collides with
444
+ # Tailwind's own display utility (display: table-caption).
445
+ %(<p class="tbl-caption">#{parts.join('<span class="tc-delim"> — </span>')}</p>)
446
+ end
447
+
448
+ # Render the table, then append a <tfoot> with one row per
449
+ # footnote encountered in any cell. MN renders table-cell
450
+ # footnotes this way (one <td> per footnote in <tfoot>), so
451
+ # the body text appears as a matchable cell instead of being
452
+ # deferred to end-of-document like paragraph footnotes.
453
+ def table(node)
454
+ @table_fns ||= []
455
+ @table_fns.push([])
456
+ body = render_children(node)
457
+ collected = @table_fns.pop
458
+ parts = [body]
459
+ unless collected.empty?
460
+ tfoot_cells = collected.map do |fn_body|
461
+ cell = render_element("p", escape(fn_body))
462
+ inner_div = %(<div class="TableFootnote">#{cell}</div>)
463
+ %(<td colspan="4">#{inner_div}</td>)
464
+ end.join
465
+ parts << "<tfoot><tr>#{tfoot_cells}</tr></tfoot>"
466
+ end
467
+ render_element("table", parts.join)
468
+ end
469
+
470
+ def thead(node) = render_element("thead", render_children(node))
471
+
472
+ def tbody(node) = render_element("tbody", render_children(node))
473
+
474
+ def tr(node) = render_element("tr", render_children(node))
475
+
476
+ def td(node) = render_element("td", render_children(node))
477
+
478
+ def th(node) = render_element("th", render_children(node))
479
+
480
+ def figure(node) = render_element("figure", render_children(node))
481
+
482
+ def caption(node) = render_element("figcaption", render_children(node))
483
+
484
+ def graphic(node)
485
+ render_liquid("_img.html.liquid", {
486
+ "src" => node.href.to_s,
487
+ "alt" => node.alttext.to_s,
488
+ })
489
+ end
490
+
491
+ def disp_formula(node) = render_element("div", render_inline(node), css: "formula")
492
+
493
+ def inline_formula(node) = render_element("span", render_inline(node), css: "formula")
494
+
495
+ def preformat(node) = render_element("pre", render_inline(node))
496
+
497
+ # Notes carrying their own label (term notes, "Note 1 to
498
+ # entry: …") render the label inside the first paragraph, the
499
+ # way Metanorma marks up term notes; plain notes get the kind
500
+ # label ("NOTE") — also injected inside the first <p> so it
501
+ # appears in the same text node MN produces for parity.
502
+ def note(node)
503
+ label_node = own_label(node)
504
+ suppress_label(label_node) if label_node
505
+ inner = render_children(node)
506
+ label_text = label_node ? plain_text(label_node).strip : "NOTE"
507
+ label_html = %(<span class="note-label">#{escape(label_text)}</span> )
508
+ inner = inner.sub(/<p(?:\s[^>]*)?>/, "\\0#{label_html}")
509
+ render_element("div", inner, css: "note")
510
+ end
511
+
512
+ def example(node)
513
+ label_node = own_label(node)
514
+ suppress_label(label_node) if label_node
515
+ inner = render_children(node)
516
+ if label_node
517
+ label_html = %(<span class="example-label">#{escape(plain_text(label_node).strip)}</span> )
518
+ inner = inner.sub(/<p(?:\s[^>]*)?>/, "\\0#{label_html}")
519
+ else
520
+ inner = kind_label("EXAMPLE") + inner
521
+ end
522
+ render_element("div", inner, css: "example")
523
+ end
524
+
525
+ def kind_label(text)
526
+ %(<span class="note-label">#{text}</span> )
527
+ end
528
+
529
+ def quote(node) = render_element("blockquote", render_children(node))
530
+
531
+ # A ref-list with a title (the back-matter bibliography) gets a
532
+ # TOC entry and an anchor; an untitled one (e.g. the normative
533
+ # references list nested in its numbered section) stays
534
+ # anonymous — the enclosing section carries the TOC entry.
535
+ def ref_list(node)
536
+ title_node = find_child(node, "Title")
537
+ title_text = title_node ? plain_text(title_node).gsub(/\s+/, " ").strip : nil
538
+ sec_id = title_text ? title_text.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "") : nil
539
+ sec_id = nil if sec_id && sec_id.empty?
540
+ register_toc_entry(id: sec_id, title: title_text, depth: @depth) if sec_id
541
+ previous_section_id = @current_section_id
542
+ @current_section_id = sec_id
543
+ render_element("div", render_children(node), id: sec_id, css: "ref-list")
544
+ ensure
545
+ @current_section_id = previous_section_id
546
+ end
547
+
548
+ # A ref with a structured <std> renders label + std (std-ref
549
+ # and title); the plain mixed-citation is skipped to avoid
550
+ # repeating the same reference three times. The label sits
551
+ # inside the body <p> — a reference reads "[1] Citation…".
552
+ def ref(node)
553
+ # Prefer the structured <std> child (std-ref + title joined
554
+ # by ", " — the NISO STS citation idiom) when present; it
555
+ # carries the same text as mixed-citation without duplication.
556
+ std_node = find_model(node, "ReferenceStandard")
557
+ content = std_node ? render_node(std_node) : render_children(node, skip: %w[label])
558
+ body = render_element("p", ref_label(node) + content, css: "ref-body")
559
+ render_element("div", body, css: "ref")
560
+ end
561
+
562
+ def ref_label(node)
563
+ label_node = find_model(node, "Label")
564
+ return "" unless label_node
565
+
566
+ render_element("span", render_inline(label_node), css: "label") + " "
567
+ end
568
+
569
+ # First typed model in the tree whose demodulized class name
570
+ # equals +demodulized+, depth-first.
571
+ def find_model(node, demodulized)
572
+ found = nil
573
+ walk_models(node) do |model|
574
+ found ||= model if model.class.name.split("::").last == demodulized
575
+ end
576
+ found
577
+ end
578
+
579
+ def citation(node) = render_element("span", render_inline(node), css: "citation")
580
+
581
+ # <std> inside a bibliography ref: keep everything inline —
582
+ # titles become <em>, never headings. std-ref and title join
583
+ # with the NISO STS citation idiom ", ". NisoSts stores
584
+ # title as a bare String on ReferenceStandard (not as a
585
+ # separate Title model), so we read it via #title and escape.
586
+ def std(node)
587
+ @in_std = true
588
+ parts = []
589
+ ref_node = find_model(node, "StandardRef")
590
+ parts << render_element("span", render_inline(ref_node), css: "std-ref") if ref_node
591
+ title_str = node.title if node.class.method_defined?(:title)
592
+ parts << render_element("em", escape(title_str.to_s)) if title_str && !title_str.to_s.empty?
593
+ render_element("span", parts.join(", "), css: "std")
594
+ ensure
595
+ @in_std = false
596
+ end
597
+
598
+ def std_ident(node) = render_element("span", render_inline(node), css: "std-ident")
599
+
600
+ # <originator> inside std-ref: separated, quieter.
601
+ def originator(node) = render_element("span", render_inline(node), css: "originator")
602
+
603
+ def doc_id(node) = render_element("span", render_inline(node), css: "doc-id")
604
+
605
+ def copyright(node) = render_element("span", render_inline(node), css: "copyright")
606
+
607
+ # Fn inside a paragraph renders as an inline <sup> marker only
608
+ # (the body paragraphs are already rendered as the fn's
609
+ # children — but block-level <div> inside <p> breaks the
610
+ # paragraph in HTML parsers, so we keep just the marker when
611
+ # Fn inside a paragraph: render inline <sup> marker only.
612
+ # The fn body paragraphs are collected in @deferred_footnotes
613
+ # and rendered at the end of the document so they become
614
+ # matchable <p> elements (block <p> inside inline context
615
+ # breaks HTML parsers). Fn inside a table cell routes the
616
+ # body to the enclosing table's <tfoot> instead (see #table),
617
+ # matching MN's table-footnote layout and avoiding duplicate
618
+ # rendering at end of document.
619
+ def fn(node)
620
+ label_node = own_label(node)
621
+ suppress_label(label_node) if label_node
622
+ label_text = label_node ? plain_text(label_node).strip : ""
623
+ display_label = label_text.match?(/\A\d+\z/) ? "#{label_text})" : label_text
624
+ # NisoSts::Fn stores paragraphs in #paragraph; TbxIsoTml::Fn
625
+ # stores them in #p. Accept either so the renderer handles
626
+ # footnotes from paragraphs and table cells uniformly.
627
+ fn_paragraphs = if node.class.method_defined?(:paragraph)
628
+ Array(node.paragraph)
629
+ elsif node.class.method_defined?(:p)
630
+ Array(node.p)
631
+ else
632
+ []
633
+ end
634
+ body_parts = fn_paragraphs.map { |para| plain_text(para) }
635
+ body_text = body_parts.join(" ")
636
+ unless body_text.empty?
637
+ if @table_fns&.last
638
+ @table_fns.last.push(body_text)
639
+ else
640
+ @deferred_footnotes << { "label" => display_label, "body" => body_text }
641
+ end
642
+ end
643
+ render_element("sup", escape(display_label), css: "fn-label")
644
+ end
645
+
646
+ def ext_link(node)
647
+ render_link(href: node.href, text: inline_or_content(node))
648
+ end
649
+
650
+ def xref(node)
651
+ rid = node.rid.to_s
652
+ text = render_inline(node).strip
653
+ text = "[#{rid}]" if text.empty?
654
+
655
+ render_link(href: "##{rid}", text: text)
656
+ end
657
+
658
+ def brk(_node) = "<br/>"
659
+
660
+ def skip(_node) = ""
661
+
662
+ def meta(node)
663
+ return "" if @assemble
664
+
665
+ title = find_text(node, ["TitleWrap", "Title"])
666
+ docid = find_text(node, META_ID_NAMES)
667
+ return "" if title.empty? && docid.empty?
668
+
669
+ render_liquid("_meta_header.html.liquid", {
670
+ "docid" => docid, "title" => title
671
+ })
672
+ end
673
+
674
+ def render_inline_tag(node)
675
+ tag = INLINE_TAGS[node.class]
676
+ inner = render_inline(node)
677
+ return render_element("span", inner, css: "small-caps") if tag == "span"
678
+
679
+ render_element(tag, inner)
680
+ end
681
+
682
+ # --------------------------------------------------------------
683
+ # Walking (document-order traversal of the typed model)
684
+ # --------------------------------------------------------------
685
+
686
+ # Children of a non-mixed container in document order.
687
+ # Repeated elements of a collection appear as one element_order
688
+ # entry PER ITEM, so collection items are consumed one entry at
689
+ # a time (per-attribute index counters). +skip+ lists element
690
+ # names the caller renders itself (e.g. table-wrap's label and
691
+ # caption, which become the caption band).
692
+ def render_children(node, skip: [])
693
+ return render_inline(node) if mixed_model?(node)
694
+
695
+ mapping = @mapping_cache[node.class][:elements]
696
+ indices = Hash.new(0)
697
+ node.element_order.filter_map do |entry|
698
+ case entry.node_type
699
+ when :text
700
+ escape(entry.text_content.to_s)
701
+ when :element
702
+ next if skip.include?(entry.name.to_s)
703
+
704
+ attr = mapping[entry.name.to_s]
705
+ next unless attr
706
+
707
+ item = collection_item_at(node, attr, indices)
708
+ next unless item
709
+
710
+ rendered = render_node(item)
711
+ rendered unless rendered.empty?
712
+ end
713
+ end.join
714
+ end
715
+
716
+ # Mixed-content (inline) walk; non-mixed models route back to
717
+ # the ordered walk.
718
+ def render_inline(node)
719
+ return render_children(node) unless mixed_model?(node)
720
+
721
+ node.each_mixed_content.filter_map do |child|
722
+ rendered = child.is_a?(String) ? escape(child) : render_node(child)
723
+ rendered unless rendered.empty?
724
+ end.join
725
+ end
726
+
727
+ def mixed_model?(node)
728
+ @mapping_cache[node.class][:mixed]
729
+ end
730
+
731
+ # element name → attribute name + mixed-content flag, per model
732
+ # class (cached).
733
+ def build_mapping(klass)
734
+ return { elements: {}, mixed: false } unless klass.respond_to?(:mappings_for)
735
+
736
+ instance = klass.allocate
737
+ xml_mapping = klass.mappings_for(:xml, instance.lutaml_register)
738
+ elements = {}
739
+ xml_mapping.mapping_elements_hash.each_value do |rule_or_array|
740
+ Array(rule_or_array).each { |rule| elements[rule.name.to_s] = rule.to }
741
+ end
742
+ { elements: elements, mixed: xml_mapping.mixed_content? }
743
+ rescue Lutaml::Model::Error
744
+ { elements: {}, mixed: false }
745
+ end
746
+
747
+ def collection_item_at(node, attr, indices)
748
+ value = node.public_send(attr)
749
+ return value unless value.is_a?(Array)
750
+
751
+ item = value[indices[attr]]
752
+ indices[attr] += 1
753
+ item
754
+ end
755
+
756
+ # --------------------------------------------------------------
757
+ # Document assembly (full page)
758
+ # --------------------------------------------------------------
759
+
760
+ def assemble_document(body, model)
761
+ meta = meta_info(model)
762
+ footnotes = deferred_footnotes_html
763
+ title_p = body_title_paragraph(meta)
764
+ render_liquid("document.html.liquid", {
765
+ "lang" => "en",
766
+ "title" => meta[:title],
767
+ "css" => stylesheet,
768
+ "logo_light" => logo_svg_light,
769
+ "logo_dark" => logo_svg_dark,
770
+ "docid" => meta[:docid],
771
+ "toc" => toc_html,
772
+ "hero" => hero_html(meta),
773
+ "content" => title_p + body + footnotes,
774
+ "footer" => footer_html(meta),
775
+ "js" => javascript,
776
+ })
777
+ end
778
+
779
+ # MN renders the document title as a <p class="zzSTDTitle1">
780
+ # at the start of the body. Emitting a matching paragraph
781
+ # keeps the title in the parity check's paragraph set.
782
+ def body_title_paragraph(meta)
783
+ return "" if meta[:title].to_s.empty?
784
+
785
+ render_element("p", escape(meta[:title]), css: "doc-title")
786
+ end
787
+
788
+ # Deferred footnotes collected from fn() calls during rendering.
789
+ # Each fn body becomes a matchable <p> at the end of the document.
790
+ def deferred_footnotes_html
791
+ return "" if @deferred_footnotes.empty?
792
+
793
+ parts = @deferred_footnotes.map do |fn|
794
+ render_element("p", escape(fn["body"]), css: "footnote")
795
+ end
796
+ parts.join("\n")
797
+ end
798
+
799
+ def hero_html(meta)
800
+ return "" if meta[:title].empty?
801
+
802
+ chips = []
803
+ chips << meta[:docid] unless meta[:docid].empty?
804
+ chips << "Series #{meta[:series]}" unless meta[:series].empty?
805
+ chips << meta[:year] unless meta[:year].empty?
806
+ chips << "EN"
807
+ render_liquid("_hero.html.liquid", {
808
+ "eyebrow" => @publisher_name || PUBLISHER_NAME,
809
+ "title" => meta[:title],
810
+ "chips" => chips,
811
+ })
812
+ end
813
+
814
+ def footer_html(meta)
815
+ render_liquid("_footer.html.liquid", {
816
+ "copyright" => meta[:copyright],
817
+ "holder" => meta[:holder],
818
+ "year" => meta[:year],
819
+ "docid" => meta[:docid],
820
+ "series" => meta[:series],
821
+ "publisher_name" => @publisher_name || PUBLISHER_NAME,
822
+ "publisher_address" => @publisher_address || PUBLISHER_ADDRESS,
823
+ "license" => @license_text,
824
+ "version" => ::Metanorma::Oiml::Sts::VERSION,
825
+ "mn_icon_light" => metanorma_icon_light,
826
+ "mn_icon_dark" => metanorma_icon_dark,
827
+ })
828
+ end
829
+
830
+ # Metanorma icon (aequitate verum) in light/dark variants,
831
+ # from metanorma.org.
832
+ def metanorma_icon_light
833
+ @mn_icon_light ||= File.read(File.join(assets_dir, "metanorma-icon-light.svg"))
834
+ .sub(/\A<\?xml[^?]*\?>\s*/, "")
835
+ end
836
+
837
+ def metanorma_icon_dark
838
+ @mn_icon_dark ||= File.read(File.join(assets_dir, "metanorma-icon-dark.svg"))
839
+ .sub(/\A<\?xml[^?]*\?>\s*/, "")
840
+ end
841
+
842
+ # Flat TOC list with depth classes (indented via CSS); consumed
843
+ # by the scroll-spy script in the page.
844
+ def toc_html
845
+ return "" if @toc.empty?
846
+
847
+ items = @toc.map do |entry|
848
+ %(<li><a class="toc-link toc-link-d#{entry[:depth]}" href="##{entry[:id]}">#{escape(entry[:title])}</a></li>)
849
+ end.join
850
+ %(<nav id="toc" class="toc-panel sticky top-[68px] self-start max-h-[calc(100vh-5rem)] overflow-y-auto py-4 pr-3 text-sm [scrollbar-width:thin]" aria-label="Contents"><h2 class="toc-heading m-0 mb-2 ml-4 text-xs font-bold tracking-[0.14em] uppercase text-ink-faint">Contents</h2><ol class="toc-list list-none m-0 p-0">#{items}</ol></nav>)
851
+ end
852
+
853
+ def meta_info(model)
854
+ meta_node = find_meta_node(model)
855
+ return { title: "", docid: "" } unless meta_node
856
+
857
+ { title: title_text(meta_node),
858
+ docid: doc_id(meta_node),
859
+ year: find_text(meta_node, ["CopyrightYear", "PubDate", "Year"]),
860
+ series: doc_series(meta_node),
861
+ copyright: find_text(meta_node, ["CopyrightStatement"]),
862
+ holder: find_text(meta_node, ["CopyrightHolder"]) }
863
+ end
864
+
865
+ # NisoSts TitleWrap stores the title parts as bare Strings
866
+ # (main:, intro:, compl:, full:) rather than as typed Title
867
+ # model children, so plain_text on the wrap returns "".
868
+ # Prefer main, then fall back to compl / full / intro, then
869
+ # fall back to the generic find_text walk for older layouts.
870
+ def title_text(meta_node)
871
+ wrap = find_model(meta_node, "TitleWrap")
872
+ return find_text(meta_node, ["Title"]) unless wrap
873
+
874
+ %i[main compl full intro].each do |attr|
875
+ next unless wrap.class.method_defined?(attr)
876
+ val = wrap.public_send(attr)
877
+ val = val.text if val.respond_to?(:text) && !val.text.nil?
878
+ return val.to_s.strip unless val.to_s.strip.empty?
879
+ end
880
+ find_text(meta_node, ["Title"])
881
+ end
882
+
883
+ # Display form of the document identifier, rebuilt from the
884
+ # decomposed <std-ident> parts ("OIML" + "x" + "999" →
885
+ # "OIML X 999"), dated with the copyright year
886
+ # ("OIML X 999:2026"). Falls back to raw text extraction when
887
+ # the std-ident carries no originator (legacy/other layouts).
888
+ def doc_id(meta_node)
889
+ std_ident = find_model(meta_node, "StandardIdentification")
890
+ return find_text(meta_node, META_ID_NAMES) unless std_ident
891
+
892
+ originator = find_text(std_ident, %w[Originator])
893
+ return find_text(meta_node, META_ID_NAMES) if originator.empty?
894
+
895
+ parts = [originator,
896
+ find_text(std_ident, %w[DocType]).upcase,
897
+ find_text(std_ident, %w[DocNumber])].reject(&:empty?)
898
+ id = parts.join(" ")
899
+ part_number = find_text(std_ident, %w[PartNumber])
900
+ id += "-#{part_number}" unless part_number.empty?
901
+ year = find_text(meta_node, %w[CopyrightYear])
902
+ id += ":#{year}" if !id.empty? && year.match?(/\A\d{4}\z/)
903
+ id
904
+ end
905
+
906
+ # The oiml-doc-series letter recorded in
907
+ # <custom-meta name="oiml-doc-series"> (X 999 §4).
908
+ def doc_series(meta_node)
909
+ series = ""
910
+ walk_models(meta_node) do |model|
911
+ next unless model.instance_of?(::Sts::NisoSts::CustomMeta)
912
+
913
+ name = model.class.method_defined?(:meta_name) ? model.meta_name.to_s : ""
914
+ value = model.class.method_defined?(:meta_value) ? model.meta_value.to_s : ""
915
+ series = value if name == "oiml-doc-series" && !value.empty?
916
+ end
917
+ series
918
+ end
919
+
920
+ # Yields every typed model in the tree, depth-first.
921
+ def walk_models(node, &block)
922
+ return unless node.is_a?(Lutaml::Model::Serializable)
923
+
924
+ yield node
925
+ node.class.attributes.each_value do |attr_def|
926
+ Array(node.public_send(attr_def.name)).compact.each do |child|
927
+ walk_models(child, &block) if child.is_a?(Lutaml::Model::Serializable)
928
+ end
929
+ end
930
+ end
931
+
932
+ # First metadata block found under the document front matter.
933
+ # Recognises any subclass of the NisoSts metadata containers
934
+ # (MetadataIso → <iso-meta>, MetadataStd → <std-meta>) via
935
+ # is_a?, so OIML (or any other future subclass) is picked up
936
+ # without extending a hard-coded class-name list.
937
+ def find_meta_node(node)
938
+ return node if meta_node?(node)
939
+ return nil unless node.is_a?(Lutaml::Model::Serializable)
940
+
941
+ node.class.attributes.each_value do |attr_def|
942
+ value = node.public_send(attr_def.name)
943
+ Array(value).compact.each do |child|
944
+ next unless child.is_a?(Lutaml::Model::Serializable)
945
+
946
+ found = find_meta_node(child)
947
+ return found if found
948
+ end
949
+ end
950
+ nil
951
+ end
952
+
953
+ def meta_node?(node)
954
+ node.is_a?(::Sts::NisoSts::MetadataIso) ||
955
+ node.is_a?(::Sts::NisoSts::MetadataStd)
956
+ end
957
+
958
+ def stylesheet
959
+ @stylesheet ||= File.read(File.join(assets_dir, "theme.css"))
960
+ end
961
+
962
+ # Light- and dark-mode logo variants (theme picks visibility).
963
+ # Uses the OIML SMART logos (distinct from the legacy OIML logo).
964
+ def logo_svg_light
965
+ @logo_svg_light ||= read_asset("oiml-smart-logo.svg")
966
+ end
967
+
968
+ def logo_svg_dark
969
+ @logo_svg_dark ||= read_asset("oiml-smart-logo-dark.svg")
970
+ end
971
+
972
+ def read_asset(name)
973
+ File.read(File.join(assets_dir, name))
974
+ .sub(/\A<\?xml[^?]*\?>\s*/, "")
975
+ end
976
+
977
+ # Page behaviour: scroll-spy TOC, mobile TOC drawer, back-to-top,
978
+ # heading-anchor copy. Vanilla JS, no dependencies.
979
+ def javascript
980
+ @javascript ||= File.read(File.join(assets_dir, "page.js"))
981
+ end
982
+
983
+ # --------------------------------------------------------------
984
+ # Text extraction
985
+ # --------------------------------------------------------------
986
+
987
+ # First non-empty plain text under the node whose demodulized
988
+ # class name is in +names+.
989
+ def find_text(node, names)
990
+ if names.include?(node.class.name.split("::").last)
991
+ text = plain_text(node).gsub(/\s+/, " ").strip
992
+ return text unless text.empty?
993
+ end
994
+
995
+ node.class.attributes.each_value do |attr_def|
996
+ value = node.public_send(attr_def.name)
997
+ Array(value).compact.each do |child|
998
+ next unless child.is_a?(Lutaml::Model::Serializable)
999
+
1000
+ found = find_text(child, names)
1001
+ return found unless found.empty?
1002
+ end
1003
+ end
1004
+ ""
1005
+ end
1006
+
1007
+ def plain_text(node)
1008
+ return node.to_s unless node.is_a?(Lutaml::Model::Serializable)
1009
+
1010
+ children = mixed_model?(node) ? node.each_mixed_content.to_a : ordered_content_parts(node)
1011
+ children.map do |child|
1012
+ child.is_a?(String) ? child : plain_text(child)
1013
+ end.join
1014
+ end
1015
+
1016
+ def ordered_content_parts(node)
1017
+ mapping = @mapping_cache[node.class][:elements]
1018
+ indices = Hash.new(0)
1019
+ node.element_order.filter_map do |entry|
1020
+ if entry.node_type == :text
1021
+ entry.text_content.to_s
1022
+ else
1023
+ attr = mapping[entry.name.to_s]
1024
+ next unless attr
1025
+
1026
+ collection_item_at(node, attr, indices)
1027
+ end
1028
+ end
1029
+ end
1030
+
1031
+ def inline_or_content(node)
1032
+ rendered = render_inline(node)
1033
+ rendered.empty? ? node.content.to_s : rendered
1034
+ end
1035
+
1036
+ # --------------------------------------------------------------
1037
+ # Liquid emitters
1038
+ # --------------------------------------------------------------
1039
+
1040
+ def render_element(tag, content, id: nil, css: nil)
1041
+ render_liquid("_element.html.liquid", {
1042
+ "tag" => tag, "id" => id,
1043
+ "css" => css, "content" => content
1044
+ })
1045
+ end
1046
+
1047
+ def render_link(href:, text:)
1048
+ render_liquid("_link.html.liquid", {
1049
+ "href" => href.to_s, "text" => text
1050
+ })
1051
+ end
1052
+
1053
+ def render_liquid(template_name, assigns)
1054
+ template = @template_cache[template_name] ||= begin
1055
+ path = File.join(templates_dir, template_name)
1056
+ # chomp: template files must not contribute their own
1057
+ # trailing newline into the output (it surfaces as a
1058
+ # visible space between inline elements)
1059
+ Liquid::Template.parse(File.read(path).chomp, environment: @liquid_env)
1060
+ end
1061
+ template.render(assigns)
1062
+ end
1063
+
1064
+ def escape(text)
1065
+ CGI.escapeHTML(text.to_s)
1066
+ end
1067
+ end
1068
+ end
1069
+ end
1070
+ end
1071
+ end