metanorma-document 0.2.0 → 0.2.2

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/.rubocop_todo.yml +231 -53
  3. data/README.adoc +59 -18
  4. data/data/stylesheets/components/bibliography.css +2 -2
  5. data/data/stylesheets/components/inline.css +11 -12
  6. data/docs/html-renderer.adoc +261 -0
  7. data/lib/metanorma/document/version.rb +1 -1
  8. data/lib/metanorma/html/base_renderer.rb +210 -257
  9. data/lib/metanorma/html/bipm_renderer.rb +0 -1
  10. data/lib/metanorma/html/cc_renderer.rb +0 -1
  11. data/lib/metanorma/html/drops/admonition_drop.rb +26 -0
  12. data/lib/metanorma/html/drops/block_element_drop.rb +20 -0
  13. data/lib/metanorma/html/drops/example_drop.rb +35 -0
  14. data/lib/metanorma/html/drops/figure_drop.rb +53 -0
  15. data/lib/metanorma/html/drops/formula_drop.rb +44 -0
  16. data/lib/metanorma/html/drops/note_drop.rb +32 -0
  17. data/lib/metanorma/html/drops/sourcecode_drop.rb +37 -0
  18. data/lib/metanorma/html/drops.rb +7 -0
  19. data/lib/metanorma/html/iec_renderer.rb +0 -1
  20. data/lib/metanorma/html/ieee_renderer.rb +0 -1
  21. data/lib/metanorma/html/ietf_renderer.rb +0 -1
  22. data/lib/metanorma/html/iho_renderer.rb +0 -1
  23. data/lib/metanorma/html/iso_renderer.rb +77 -209
  24. data/lib/metanorma/html/itu_renderer.rb +0 -1
  25. data/lib/metanorma/html/ogc_renderer.rb +5 -6
  26. data/lib/metanorma/html/oiml_renderer.rb +0 -1
  27. data/lib/metanorma/html/pdfa_renderer.rb +0 -1
  28. data/lib/metanorma/html/ribose_renderer.rb +0 -1
  29. data/lib/metanorma/html/standard_renderer.rb +63 -82
  30. data/lib/metanorma/html/templates/_admonition.html.liquid +4 -0
  31. data/lib/metanorma/html/templates/_doc_title.html.liquid +1 -1
  32. data/lib/metanorma/html/templates/_example.html.liquid +3 -0
  33. data/lib/metanorma/html/templates/_figure.html.liquid +6 -0
  34. data/lib/metanorma/html/templates/_formula.html.liquid +6 -0
  35. data/lib/metanorma/html/templates/_iso_doc_title.html.liquid +2 -2
  36. data/lib/metanorma/html/templates/_note.html.liquid +3 -0
  37. data/lib/metanorma/html/templates/_sourcecode.html.liquid +4 -0
  38. data/lib/metanorma/html.rb +0 -1
  39. metadata +16 -3
  40. data/lib/metanorma/html/component_registry.rb +0 -37
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "liquid"
4
4
  require "nokogiri"
5
+ require "cgi"
5
6
  require_relative "drops/footnote_drop"
6
7
 
7
8
  module Metanorma
@@ -17,43 +18,33 @@ module Metanorma
17
18
  class BaseRenderer
18
19
  LOGO_DIR = File.expand_path("../../../data/logos", __dir__)
19
20
 
20
- # Map legacy XML class names to clean, semantic names.
21
- CLASS_MAP = {
22
- "zzSTDTitle1" => "doc-title",
23
- "coverpage_docnumber" => "cover-doc-id",
24
- "coverpage_docstage" => "cover-stage",
25
- "doctitle-en" => "cover-title",
26
- "ForewordTitle" => "foreword-title",
27
- "IntroTitle" => "intro-title",
28
- "Annex" => "annex-title",
29
- "Section3" => "section-sub",
30
- "TermNum" => "term-number",
31
- "Terms" => "term-name",
32
- "DeprecatedTerms" => "term-deprecated",
33
- "domain" => "term-domain",
34
- "boldtitle" => "bold-title",
35
- "note_label" => "note-label",
36
- "termnote_label" => "term-note-label",
37
- "example_label" => "example-label",
38
- "stddocNumber" => "std-doc-number",
39
- "stdyear" => "std-year",
40
- "sourcecode-name" => "code-name",
21
+ # HTML-specific class names for inline spans, keyed by the XML span role.
22
+ # The XML class_attr is INPUT only — we never emit it in HTML.
23
+ SPAN_ROLE_CLASSES = {
24
+ "boldtitle" => "title-text",
25
+ "nonboldtitle" => "subtitle-text",
26
+ "citeapp" => "xref-app",
27
+ "citefig" => "xref-fig",
28
+ "citesec" => "xref-section",
29
+ "citetbl" => "xref-table",
30
+ "fmt-autonum-delim" => "number-delim",
41
31
  "fmt-caption-label" => "caption-label",
42
- "fmt-autonum-delim" => "autonum-delim",
43
- "fmt-element-name" => "element-name",
44
32
  "fmt-caption-delim" => "caption-delim",
45
- "smallcap" => "small-caps",
46
- "obligation" => "obligation-text",
47
- "tableblock" => "table-block",
48
- "Biblio" => "biblio-entry",
49
- "Note" => "note-block",
50
- "source" => "term-source",
51
- "nonboldtitle" => "doc-subtitle",
52
- "stddocPartNumber" => "doc-part-number",
53
- "stddocTitle" => "doc-part-title",
54
- "std_publisher" => "doc-publisher",
55
- "stdpublisher" => "doc-publisher-name",
33
+ "fmt-element-name" => "element-label",
34
+ "fmt-comma" => "comma",
35
+ "fmt-conn" => "connector",
36
+ "fmt-label-delim" => "label-delim",
37
+ "fmt-obligation" => "obligation-text",
38
+ "fmt-xref-container" => "xref-container",
56
39
  "fmt-xref-label" => "xref-label",
40
+ "std_publisher" => "ref-publisher",
41
+ "stdpublisher" => "ref-publisher-name",
42
+ "stddocNumber" => "ref-doc-number",
43
+ "stddocTitle" => "ref-title",
44
+ "stddocPartNumber" => "ref-part-number",
45
+ "stdyear" => "ref-year",
46
+ "date" => "date",
47
+ "smallcap" => "small-caps",
57
48
  }.freeze
58
49
 
59
50
  METANORMA_LOGO = "metanorma-logo.svg"
@@ -71,6 +62,40 @@ module Metanorma
71
62
 
72
63
  # --- Public API ---
73
64
 
65
+ # Facade object for Drops to call renderer methods without exposing
66
+ # the full private interface. Delegates to the renderer internally.
67
+ class RendererContext
68
+ def initialize(renderer)
69
+ @renderer = renderer
70
+ end
71
+
72
+ def safe_attr(obj, method_name) = @renderer.send(:safe_attr, obj, method_name)
73
+ def escape_html(text) = @renderer.send(:escape_html, text)
74
+ def extract_block_label(block, default) = @renderer.send(:extract_block_label, block, default)
75
+ def extract_plain_text(node) = @renderer.send(:extract_plain_text, node)
76
+ def capture_output(&) = @renderer.send(:capture_output, &)
77
+ def render_paragraph(p) = @renderer.send(:render_paragraph, p)
78
+ def render_mixed_inline(node) = @renderer.send(:render_mixed_inline, node)
79
+ def render_inline_element(el) = @renderer.send(:render_inline_element, el)
80
+ def render_unordered_list(ul) = @renderer.send(:render_unordered_list, ul)
81
+ def render_ordered_list(ol) = @renderer.send(:render_ordered_list, ol)
82
+ def render_definition_list(dl) = @renderer.send(:render_definition_list, dl)
83
+ def render_sourcecode(sc) = @renderer.send(:render_sourcecode, sc)
84
+ def render_table(t) = @renderer.send(:render_table, t)
85
+ def render_figure(f) = @renderer.send(:render_figure, f)
86
+ def render_quote(q) = @renderer.send(:render_quote, q)
87
+ def render_formula(f) = @renderer.send(:render_formula, f)
88
+ def render_note(n) = @renderer.send(:render_note, n)
89
+ def render_image(img) = @renderer.send(:render_image, img)
90
+ def render_stem_content(stem) = @renderer.send(:render_stem_content, stem)
91
+ def register_figure_entry(...) = @renderer.send(:register_figure_entry, ...)
92
+ def render_liquid(template_name, assigns) = @renderer.send(:render_liquid, template_name, assigns)
93
+ end
94
+
95
+ def renderer_context
96
+ @renderer_context ||= RendererContext.new(self)
97
+ end
98
+
74
99
  def to_html
75
100
  @output
76
101
  end
@@ -117,11 +142,14 @@ module Metanorma
117
142
 
118
143
  # --- Document Assembly ---
119
144
 
120
- TEMPLATE_CACHE = Hash.new { |h, k| h[k] = Liquid::Template.parse(File.read(k)) }
145
+ TEMPLATE_CACHE = {}
146
+ TEMPLATE_CACHE_MUTEX = Mutex.new
121
147
 
122
148
  def render_liquid(template_name, assigns)
123
149
  template_path = File.join(TEMPLATES_ROOT, template_name)
124
- template = TEMPLATE_CACHE[template_path]
150
+ template = TEMPLATE_CACHE_MUTEX.synchronize do
151
+ TEMPLATE_CACHE[template_path] ||= Liquid::Template.parse(File.read(template_path))
152
+ end
125
153
  assigns = assigns.transform_keys(&:to_s) if assigns.is_a?(Hash)
126
154
  template.render(assigns)
127
155
  end
@@ -132,16 +160,16 @@ module Metanorma
132
160
  footer = build_footer
133
161
 
134
162
  render_liquid("document.html.liquid", {
135
- "lang" => language,
136
- "title" => html_title,
137
- "font_url" => flavor_font_url,
138
- "styles" => build_styles,
139
- "header" => header,
140
- "toc" => toc_html,
141
- "body" => body,
142
- "footer" => footer,
143
- "scripts" => build_scripts,
144
- })
163
+ "lang" => language,
164
+ "title" => html_title,
165
+ "font_url" => flavor_font_url,
166
+ "styles" => build_styles,
167
+ "header" => header,
168
+ "toc" => toc_html,
169
+ "body" => body,
170
+ "footer" => footer,
171
+ "scripts" => build_scripts,
172
+ })
145
173
  end
146
174
 
147
175
  # --- Header and Footer ---
@@ -157,20 +185,15 @@ module Metanorma
157
185
  end
158
186
 
159
187
  render_liquid("_header.html.liquid", {
160
- "publisher_logos" => pub_logos,
161
- "doc_id" => display_id,
162
- "doc_title" => header_title_text,
163
- })
188
+ "publisher_logos" => pub_logos,
189
+ "doc_id" => display_id,
190
+ "doc_title" => header_title_text,
191
+ })
164
192
  end
165
193
 
166
194
  def header_title_text
167
195
  raw = html_title.to_s.split(" — ").first.to_s.gsub(/<[^>]+>/, "")
168
- raw.length > 60 ? raw[0, 57] + "..." : raw
169
- end
170
-
171
- # Reader controls — kept for backward compat with flavor renderers
172
- def build_reader_controls
173
- ""
196
+ raw.length > 60 ? "#{raw[0, 57]}..." : raw
174
197
  end
175
198
 
176
199
  def build_publisher_logos
@@ -192,10 +215,6 @@ module Metanorma
192
215
  end.join("\n")
193
216
  end
194
217
 
195
- def detect_publishers
196
- flavor_publishers(extract_primary_doc_id)
197
- end
198
-
199
218
  def load_logo_svg(filename, height: 32)
200
219
  path = File.join(LOGO_DIR, filename)
201
220
  return nil unless File.exist?(path)
@@ -205,13 +224,12 @@ module Metanorma
205
224
  svg = svg.sub(/\A\s*<!--.*?-->\s*/m, "")
206
225
  svg = svg.sub(/<path[^>]*style="fill:#e3000f[^"]*"[^>]*\/>/, "")
207
226
  svg = svg.sub(/<svg\s/, '<svg class="header-logo" ')
208
- if svg.match?(/<svg[^>]*\sheight="[^"]*"/)
209
- svg = svg.sub(/(<svg[^>]*?)(\sheight="[^"]*")/, "\\1 height=\"#{height}\"")
210
- else
211
- svg = svg.sub(/(<svg\b)/, "\\1 height=\"#{height}\"")
212
- end
213
- svg = svg.sub(/(<svg[^>]*?)\swidth="[^"]*"/, '\1')
214
- svg
227
+ svg = if svg.match?(/<svg[^>]*\sheight="[^"]*"/)
228
+ svg.sub(/(<svg[^>]*?)(\sheight="[^"]*")/, "\\1 height=\"#{height}\"")
229
+ else
230
+ svg.sub(/(<svg\b)/, "\\1 height=\"#{height}\"")
231
+ end
232
+ svg.sub(/(<svg[^>]*?)\swidth="[^"]*"/, '\1')
215
233
  rescue StandardError
216
234
  nil
217
235
  end
@@ -219,9 +237,9 @@ module Metanorma
219
237
  def build_footer
220
238
  mn_logo = load_logo_svg(METANORMA_LOGO, height: 20)
221
239
  render_liquid("_footer.html.liquid", {
222
- "mn_logo" => mn_logo,
223
- "generated_at" => Time.now.strftime('%Y-%m-%d %H:%M'),
224
- })
240
+ "mn_logo" => mn_logo,
241
+ "generated_at" => Time.now.strftime("%Y-%m-%d %H:%M"),
242
+ })
225
243
  end
226
244
 
227
245
  # --- ToC generation ---
@@ -231,32 +249,32 @@ module Metanorma
231
249
  main_lines = if entries.empty?
232
250
  ["<li class=\"toc-empty\">No entries</li>"]
233
251
  else
234
- entries.map { |e|
252
+ entries.map do |e|
235
253
  id = e[:id].to_s
236
254
  text = escape_html(e[:text].to_s)
237
255
  lvl = e[:level]
238
256
  "<li class=\"toc-level-#{lvl}\"><a href=\"##{id}\" class=\"toc-link\" data-target=\"#{id}\">#{text}</a></li>"
239
- }
257
+ end
240
258
  end
241
259
 
242
260
  # List of Figures — at top of sidebar
243
261
  unless @figure_entries.empty?
244
262
  top_lines << "<li class=\"toc-list-header\" data-list=\"figures\"><button class=\"toc-list-toggle\" aria-expanded=\"false\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><rect x=\"1\" y=\"2\" width=\"14\" height=\"12\" rx=\"1\"/><circle cx=\"5\" cy=\"6.5\" r=\"1.5\"/><path d=\"M1 12l4-4 2 2 3-3 5 5\"/></svg> Figures <span class=\"toc-list-count\">(#{@figure_entries.size})</span></button></li>"
245
- @figure_entries.each { |f|
263
+ @figure_entries.each do |f|
246
264
  id = f[:id].to_s
247
265
  text = escape_html(f[:text].to_s)
248
266
  top_lines << "<li class=\"toc-list-item toc-figures\" style=\"display:none\"><a href=\"##{id}\" class=\"toc-link\" data-target=\"#{id}\">#{text}</a></li>"
249
- }
267
+ end
250
268
  end
251
269
 
252
270
  # List of Tables — at top of sidebar
253
271
  unless @table_entries.empty?
254
272
  top_lines << "<li class=\"toc-list-header\" data-list=\"tables\"><button class=\"toc-list-toggle\" aria-expanded=\"false\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><rect x=\"1\" y=\"2\" width=\"14\" height=\"12\" rx=\"1\"/><line x1=\"1\" y1=\"6\" x2=\"15\" y2=\"6\"/><line x1=\"1\" y1=\"10\" x2=\"15\" y2=\"10\"/><line x1=\"7\" y1=\"2\" x2=\"7\" y2=\"14\"/></svg> Tables <span class=\"toc-list-count\">(#{@table_entries.size})</span></button></li>"
255
- @table_entries.each { |t|
273
+ @table_entries.each do |t|
256
274
  id = t[:id].to_s
257
275
  text = escape_html(t[:text].to_s)
258
276
  top_lines << "<li class=\"toc-list-item toc-tables\" style=\"display:none\"><a href=\"##{id}\" class=\"toc-link\" data-target=\"#{id}\">#{text}</a></li>"
259
- }
277
+ end
260
278
  end
261
279
 
262
280
  top_lines << "<li class=\"toc-divider\"></li>" unless top_lines.empty?
@@ -307,12 +325,25 @@ module Metanorma
307
325
  end
308
326
 
309
327
  if node.is_a?(Lutaml::Model::Serializable)
310
- return true if (node.public_send(:fmt_title) rescue nil)
311
- return true if (node.public_send(:displayorder) rescue nil)
328
+ return true if begin
329
+ node.fmt_title
330
+ rescue StandardError
331
+ nil
332
+ end
333
+ return true if begin
334
+ node.displayorder
335
+ rescue StandardError
336
+ nil
337
+ end
312
338
 
313
339
  %i[preface sections annex bibliography].each do |attr|
314
- val = node.public_send(attr) rescue nil
340
+ val = begin
341
+ node.public_send(attr)
342
+ rescue StandardError
343
+ nil
344
+ end
315
345
  next unless val
346
+
316
347
  Array(val).each { |v| return true if check_presentation_markers(v) }
317
348
  end
318
349
 
@@ -405,6 +436,7 @@ module Metanorma
405
436
  indices = Hash.new(0)
406
437
  node.element_order.each do |el|
407
438
  next unless el.is_a?(Lutaml::Xml::Element)
439
+
408
440
  if el.text?
409
441
  parts << el.text_content.to_s
410
442
  elsif el.name == "tab"
@@ -434,42 +466,42 @@ module Metanorma
434
466
  parts << (t.is_a?(Array) ? t.join : t.to_s) if t
435
467
  end
436
468
 
437
- parts.join.strip.gsub(/\u00A0/, " ")
469
+ parts.join.strip.gsub("\u00A0", " ")
438
470
  end
439
471
 
440
472
  # Dispatch to the appropriate render method based on node class.
441
- def render(node, **opts)
473
+ def render(node, **)
442
474
  case node
443
475
  when Metanorma::Document::Components::Paragraphs::ParagraphBlock
444
- render_paragraph(node, **opts)
476
+ render_paragraph(node, **)
445
477
  when Metanorma::Document::Components::Tables::TableBlock
446
- render_table(node, **opts)
478
+ render_table(node, **)
447
479
  when Metanorma::Document::Components::Lists::UnorderedList
448
- render_unordered_list(node, **opts)
480
+ render_unordered_list(node, **)
449
481
  when Metanorma::Document::Components::Lists::OrderedList
450
- render_ordered_list(node, **opts)
482
+ render_ordered_list(node, **)
451
483
  when Metanorma::Document::Components::Lists::DefinitionList
452
- render_definition_list(node, **opts)
484
+ render_definition_list(node, **)
453
485
  when Metanorma::Document::Components::AncillaryBlocks::FigureBlock
454
- render_figure(node, **opts)
486
+ render_figure(node, **)
455
487
  when Metanorma::Document::Components::Blocks::NoteBlock
456
- render_note(node, **opts)
488
+ render_note(node, **)
457
489
  when Metanorma::Document::Components::AncillaryBlocks::ExampleBlock
458
- render_example(node, **opts)
490
+ render_example(node, **)
459
491
  when Metanorma::Document::Components::AncillaryBlocks::SourcecodeBlock
460
- render_sourcecode(node, **opts)
492
+ render_sourcecode(node, **)
461
493
  when Metanorma::Document::Components::AncillaryBlocks::FormulaBlock
462
- render_formula(node, **opts)
494
+ render_formula(node, **)
463
495
  when Metanorma::Document::Components::MultiParagraph::QuoteBlock
464
- render_quote(node, **opts)
496
+ render_quote(node, **)
465
497
  when Metanorma::Document::Components::MultiParagraph::AdmonitionBlock
466
- render_admonition(node, **opts)
498
+ render_admonition(node, **)
467
499
  when Metanorma::Document::Components::Sections::HierarchicalSection
468
- render_hierarchical_section(node, **opts)
500
+ render_hierarchical_section(node, **)
469
501
  when Metanorma::Document::Components::Sections::BasicSection
470
- render_basic_section(node, **opts)
502
+ render_basic_section(node, **)
471
503
  when Metanorma::Document::Components::Sections::ContentSection
472
- render_content_section(node, **opts)
504
+ render_content_section(node, **)
473
505
  when Metanorma::Document::Components::EmptyElements::PageBreakElement
474
506
  ""
475
507
  when Metanorma::Document::Components::IdElements::Bookmark
@@ -488,7 +520,7 @@ module Metanorma
488
520
  # --- Block-level rendering ---
489
521
 
490
522
  def render_paragraph(p, **_opts)
491
- attrs = element_attrs(id: safe_attr(p, :id), class: safe_attr(p, :class_attr), style: alignment_style(safe_attr(p, :alignment)))
523
+ attrs = element_attrs(id: safe_attr(p, :id), style: alignment_style(safe_attr(p, :alignment)))
492
524
  tag("p", attrs) { render_mixed_inline(p) }
493
525
  end
494
526
 
@@ -531,19 +563,21 @@ module Metanorma
531
563
  if table.colgroup&.col && !table.colgroup.col.empty?
532
564
  return table.colgroup.col.size
533
565
  end
566
+
534
567
  # Walk all rows to find max column count, accounting for colspan
535
568
  max_cols = 0
536
- [:thead, :tbody, :tfoot].each do |section|
569
+ %i[thead tbody tfoot].each do |section|
537
570
  sec = table.public_send(section)
538
571
  next unless sec&.tr
572
+
539
573
  sec.tr.each do |tr|
540
574
  cols = 0
541
- Array(tr.th).each { |th| cols += (th.colspan && th.colspan > 1) ? th.colspan : 1 }
542
- Array(tr.td).each { |td| cols += (td.colspan && td.colspan > 1) ? td.colspan : 1 }
575
+ Array(tr.th).each { |th| cols += th.colspan && th.colspan > 1 ? th.colspan : 1 }
576
+ Array(tr.td).each { |td| cols += td.colspan && td.colspan > 1 ? td.colspan : 1 }
543
577
  max_cols = cols if cols > max_cols
544
578
  end
545
579
  end
546
- max_cols > 0 ? max_cols : 1
580
+ max_cols.positive? ? max_cols : 1
547
581
  end
548
582
 
549
583
  def render_table_colgroup(colgroup)
@@ -564,6 +598,7 @@ module Metanorma
564
598
  @output << "<tr>"
565
599
  walked = walk_ordered(tr) do |type, obj|
566
600
  next unless type == :element
601
+
567
602
  render_table_cell(obj)
568
603
  end
569
604
  unless walked
@@ -575,7 +610,7 @@ module Metanorma
575
610
  end
576
611
 
577
612
  def render_unordered_list(ul, **_opts)
578
- attrs = element_attrs(id: safe_attr(ul, :id), class: safe_attr(ul, :class_attr))
613
+ attrs = element_attrs(id: safe_attr(ul, :id))
579
614
  tag("ul", attrs) do
580
615
  ul.listitem&.each { |li| render_list_item(li) }
581
616
  end
@@ -595,7 +630,7 @@ module Metanorma
595
630
  end
596
631
 
597
632
  def render_ordered_list(ol, **_opts)
598
- attrs = element_attrs(id: safe_attr(ol, :id), class: safe_attr(ol, :class_attr), start: safe_attr(ol, :start), type: safe_attr(ol, :type_attr))
633
+ attrs = element_attrs(id: safe_attr(ol, :id), start: safe_attr(ol, :start), type: safe_attr(ol, :type_attr))
599
634
  tag("ol", attrs) do
600
635
  ol.listitem&.each { |li| render_list_item(li) }
601
636
  end
@@ -634,7 +669,8 @@ module Metanorma
634
669
  children = []
635
670
 
636
671
  walk_ordered(section) do |type, obj|
637
- next if type == :text || type == :tab
672
+ next if %i[text tab].include?(type)
673
+
638
674
  children << obj
639
675
  end
640
676
 
@@ -643,6 +679,7 @@ module Metanorma
643
679
  supplementary_attrs.each do |attr|
644
680
  val = safe_attr(section, attr)
645
681
  next if val.nil?
682
+
646
683
  Array(val).each do |v|
647
684
  children << v unless children.include?(v)
648
685
  end
@@ -665,7 +702,11 @@ module Metanorma
665
702
 
666
703
  def sort_by_displayorder(children)
667
704
  children.sort_by do |node|
668
- order = node.displayorder rescue nil
705
+ order = begin
706
+ node.displayorder
707
+ rescue StandardError
708
+ nil
709
+ end
669
710
  order &&= order.to_i
670
711
  order || Float::INFINITY
671
712
  end
@@ -689,29 +730,8 @@ module Metanorma
689
730
  end
690
731
 
691
732
  def render_figure(figure, **_opts)
692
- attrs = element_attrs(id: safe_attr(figure, :id), class: "figure")
693
- fig_id = safe_attr(figure, :id)
694
- fig_name = safe_attr(figure, :fmt_name) || safe_attr(figure, :name)
695
- if fig_id && fig_name
696
- register_figure_entry(id: fig_id, text: extract_plain_text(fig_name))
697
- end
698
- tag("figure", attrs) do
699
- if figure.image
700
- render_image(figure.image)
701
- elsif safe_attr(figure, :source)
702
- @output << %(<img src="#{escape_html(figure.source)}" />)
703
- end
704
- render_video(figure.video) if safe_attr(figure, :video)
705
- render_audio(figure.audio) if safe_attr(figure, :audio)
706
- figure.figure&.each { |sub| render_figure(sub) }
707
- if safe_attr(figure, :name) || safe_attr(figure, :fmt_name)
708
- @output << "<figcaption>"
709
- render_inline_element(safe_attr(figure, :fmt_name) || figure.name)
710
- @output << "</figcaption>"
711
- end
712
- safe_attr(figure, :note)&.each { |n| render_note(n) }
713
- safe_attr(figure, :dl)&.then { |dl| render_definition_list(dl) }
714
- end
733
+ drop = Drops::FigureDrop.from_model(figure, renderer: renderer_context)
734
+ @output << render_liquid("_figure.html.liquid", { "block" => drop })
715
735
  end
716
736
 
717
737
  def render_image(image)
@@ -743,91 +763,23 @@ module Metanorma
743
763
  end
744
764
 
745
765
  def render_note(note, **_opts)
746
- attrs = element_attrs(id: safe_attr(note, :id), class: "note-block")
747
- tag("div", attrs) do
748
- label = extract_block_label(note, "NOTE")
749
- @output << %(<span class="note-label">#{escape_html(label)}</span>&nbsp;)
750
- if note.content && !note.content.empty?
751
- note.content.each { |para| render_paragraph(para) }
752
- else
753
- render_mixed_inline(note)
754
- end
755
- note.ul&.each { |ul| render_unordered_list(ul) }
756
- note.ol&.each { |ol| render_ordered_list(ol) }
757
- note.dl&.then { |dl| render_definition_list(dl) }
758
- end
766
+ drop = Drops::NoteDrop.from_model(note, renderer: renderer_context)
767
+ @output << render_liquid("_note.html.liquid", { "block" => drop })
759
768
  end
760
769
 
761
770
  def render_example(example, **_opts)
762
- attrs = element_attrs(id: safe_attr(example, :id), class: "example")
763
- tag("div", attrs) do
764
- label = extract_block_label(example, "EXAMPLE")
765
- @output << %(<span class="example-label">#{escape_html(label)}</span>&nbsp;)
766
- if example.paragraphs && !example.paragraphs.empty?
767
- example.paragraphs.each { |para| render_paragraph(para) }
768
- end
769
- example.ul&.each { |ul| render_unordered_list(ul) }
770
- example.ol&.each { |ol| render_ordered_list(ol) }
771
- example.dl&.each { |dl| render_definition_list(dl) } if example.dl
772
- example.sourcecode&.each { |sc| render_sourcecode(sc) }
773
- example.table&.each { |t| render_table(t) }
774
- example.figure&.each { |f| render_figure(f) }
775
- example.quote&.each { |q| render_quote(q) }
776
- example.formula&.each { |f| render_formula(f) }
777
- end
771
+ drop = Drops::ExampleDrop.from_model(example, renderer: renderer_context)
772
+ @output << render_liquid("_example.html.liquid", { "block" => drop })
778
773
  end
779
774
 
780
775
  def render_sourcecode(sc, **_opts)
781
- attrs = element_attrs(id: safe_attr(sc, :id), class: "sourcecode")
782
- lang = safe_attr(sc, :lang)
783
- tag("div", attrs) do
784
- if sc.name
785
- @output << "<p class=\"code-name\">"
786
- render_inline_element(sc.name)
787
- @output << "</p>"
788
- end
789
- code_attrs = lang ? %( lang="#{escape_html(lang)}") : ""
790
- @output << "<pre><code#{code_attrs}>"
791
- # Use body.content if available, else content, else text
792
- code_text = if sc.body && sc.body.content
793
- sc.body.content
794
- elsif sc.content
795
- sc.content
796
- else
797
- ""
798
- end
799
- # body.content from map_all_content may contain pre-escaped HTML
800
- # entities (&lt; etc); decode first to get raw text, then escape
801
- # for HTML output.
802
- raw_text = code_text.gsub("&lt;", "<").gsub("&gt;", ">").gsub("&amp;", "&").gsub("&quot;", "\"")
803
- @output << escape_html(raw_text)
804
- @output << "</code></pre>"
805
- end
776
+ drop = Drops::SourcecodeDrop.from_model(sc, renderer: renderer_context)
777
+ @output << render_liquid("_sourcecode.html.liquid", { "block" => drop })
806
778
  end
807
779
 
808
780
  def render_formula(formula, **_opts)
809
- attrs = element_attrs(id: safe_attr(formula, :id), class: "formula")
810
- tag("div", attrs) do
811
- @output << render_stem_content(formula.stem) if formula.stem
812
-
813
- # Render "where" clause from key element (non-presentation XML)
814
- if formula.key
815
- if formula.key.dl
816
- @output << "<p class=\"formula-where\">where</p>"
817
- render_definition_list(formula.key.dl)
818
- end
819
- formula.key.p&.each { |para| render_paragraph(para) }
820
- end
821
-
822
- formula.dl&.then { |dl| render_definition_list(dl) }
823
-
824
- name_el = safe_attr(formula, :fmt_name) || safe_attr(formula, :name)
825
- if name_el
826
- @output << "<span class=\"formula-number\">"
827
- render_inline_element(name_el)
828
- @output << "</span>"
829
- end
830
- end
781
+ drop = Drops::FormulaDrop.from_model(formula, renderer: renderer_context)
782
+ @output << render_liquid("_formula.html.liquid", { "block" => drop })
831
783
  end
832
784
 
833
785
  def render_quote(quote, **_opts)
@@ -845,12 +797,8 @@ module Metanorma
845
797
  end
846
798
 
847
799
  def render_admonition(admonition, **_opts)
848
- type = safe_attr(admonition, :type) || "note"
849
- attrs = element_attrs(id: safe_attr(admonition, :id), class: "admonition #{type}")
850
- tag("div", attrs) do
851
- @output << "<p class=\"admonition-title\">#{escape_html(type.capitalize)}</p>"
852
- admonition.paragraphs&.each { |para| render_paragraph(para) }
853
- end
800
+ drop = Drops::AdmonitionDrop.from_model(admonition, renderer: renderer_context)
801
+ @output << render_liquid("_admonition.html.liquid", { "block" => drop })
854
802
  end
855
803
 
856
804
  def render_bookmark(bookmark)
@@ -973,13 +921,14 @@ module Metanorma
973
921
  skip = {}
974
922
  node.element_order.each_with_index do |el, i|
975
923
  next unless el.element?
924
+
976
925
  next_tag = skip_after[el.name]
977
926
  next unless next_tag
978
927
 
979
928
  next_el = node.element_order[i + 1]
980
929
  if next_tag.nil?
981
930
  skip[i] = true
982
- elsif next_el && next_el.element? && next_el.name == next_tag
931
+ elsif next_el&.element? && next_el.name == next_tag
983
932
  skip[i] = true
984
933
  end
985
934
  end
@@ -1035,7 +984,9 @@ module Metanorma
1035
984
  end
1036
985
 
1037
986
  def raw_content_node?(node)
1038
- node.is_a?(Metanorma::IsoDocument::RawParagraph)
987
+ node.is_a?(Lutaml::Model::Serializable) &&
988
+ node.respond_to?(:content) &&
989
+ node.content.is_a?(String)
1039
990
  end
1040
991
 
1041
992
  # Iterate element_order directly, preserving whitespace text nodes
@@ -1107,7 +1058,9 @@ module Metanorma
1107
1058
  # Source element — skip; rendered via fmt-xref in semx wrapper
1108
1059
  nil
1109
1060
  when Metanorma::Document::Components::Inline::SpanElement
1110
- attrs = element_attrs(style: safe_attr(element, :style), class: safe_attr(element, :class_attr))
1061
+ xml_class = safe_attr(element, :class_attr).to_s
1062
+ html_class = html_class_for_span(xml_class) unless xml_class.empty?
1063
+ attrs = element_attrs(style: safe_attr(element, :style), class: html_class)
1111
1064
  tag("span", attrs) { render_mixed_inline(element) }
1112
1065
  when Metanorma::Document::Components::Inline::FnElement
1113
1066
  render_fn(element)
@@ -1183,13 +1136,13 @@ module Metanorma
1183
1136
  texts = node.text
1184
1137
  if texts.is_a?(Array)
1185
1138
  texts.each do |t|
1186
- if t.is_a?(Metanorma::Document::Components::Inline::MathElement)
1187
- @output << t.content.to_s
1188
- elsif t.is_a?(Metanorma::Document::Components::Inline::AsciimathElement)
1189
- @output << %(<span class="stem">#{escape_html(Array(t.text).join)}</span>)
1190
- else
1191
- @output << escape_html(t.to_s)
1192
- end
1139
+ @output << if t.is_a?(Metanorma::Document::Components::Inline::MathElement)
1140
+ t.content.to_s
1141
+ elsif t.is_a?(Metanorma::Document::Components::Inline::AsciimathElement)
1142
+ %(<span class="stem">#{escape_html(Array(t.text).join)}</span>)
1143
+ else
1144
+ escape_html(t.to_s)
1145
+ end
1193
1146
  end
1194
1147
  elsif texts.is_a?(String)
1195
1148
  @output << escape_html(texts)
@@ -1239,6 +1192,7 @@ module Metanorma
1239
1192
  display_attrs.each do |attr|
1240
1193
  val = safe_attr(element, attr)
1241
1194
  next if val.nil?
1195
+
1242
1196
  if val.is_a?(Array)
1243
1197
  val.each do |v|
1244
1198
  if v.is_a?(Metanorma::Document::Components::Paragraphs::ParagraphBlock)
@@ -1261,7 +1215,7 @@ module Metanorma
1261
1215
  return semx_text unless first_word
1262
1216
 
1263
1217
  tail = output[-200..]
1264
- return semx_text unless tail && tail.rstrip.end_with?(first_word)
1218
+ return semx_text unless tail&.rstrip&.end_with?(first_word)
1265
1219
 
1266
1220
  semx_text.sub(/\A\s*#{Regexp.escape(first_word)}\s*/, "")
1267
1221
  end
@@ -1279,7 +1233,7 @@ module Metanorma
1279
1233
  doc.css("fmt-link").each do |el|
1280
1234
  target = el["target"] || el["href"]
1281
1235
  if target
1282
- display_text = target.sub(/\Amailto:/, "")
1236
+ display_text = target.delete_prefix("mailto:")
1283
1237
  a = doc.document.create_element("a", display_text, "href" => target)
1284
1238
  el.replace(a)
1285
1239
  else
@@ -1292,11 +1246,12 @@ module Metanorma
1292
1246
  doc.traverse do |node|
1293
1247
  next unless node.element?
1294
1248
  next unless %w[xref eref stem link].include?(node.name)
1249
+
1295
1250
  next_sib = node.next_sibling
1296
1251
  while next_sib.is_a?(Nokogiri::XML::Text) && next_sib.text.strip.empty?
1297
1252
  next_sib = next_sib.next_sibling
1298
1253
  end
1299
- next unless next_sib && next_sib.element? && next_sib.name == "semx"
1254
+ next unless next_sib&.element? && next_sib.name == "semx"
1300
1255
 
1301
1256
  deduplicate_semx_label(node, next_sib)
1302
1257
  node.remove
@@ -1305,6 +1260,10 @@ module Metanorma
1305
1260
  %w[semx fmt-xref].each do |tag|
1306
1261
  doc.css(tag).each { |el| el.replace(el.children) }
1307
1262
  end
1263
+ # Remap XML class names to HTML-specific class names
1264
+ doc.css("[class]").each do |el|
1265
+ el["class"] = el["class"].split(/\s+/).map { |c| html_class_for_span(c) }.join(" ")
1266
+ end
1308
1267
  doc.inner_html
1309
1268
  end
1310
1269
 
@@ -1328,13 +1287,13 @@ module Metanorma
1328
1287
 
1329
1288
  def render_link(link)
1330
1289
  target = safe_attr(link, :target) || safe_attr(link, :href)
1331
- attrs = element_attrs(href: target, id: safe_attr(link, :id), class: safe_attr(link, :class_attr))
1290
+ attrs = element_attrs(href: target, id: safe_attr(link, :id))
1332
1291
  tag("a", attrs) do
1333
1292
  content = safe_attr(link, :content)
1334
1293
  if content && !Array(content).join.strip.empty?
1335
1294
  render_mixed_inline(link)
1336
1295
  else
1337
- display_text = target.to_s.sub(/\Amailto:/, "")
1296
+ display_text = target.to_s.delete_prefix("mailto:")
1338
1297
  @output << escape_html(display_text)
1339
1298
  end
1340
1299
  end
@@ -1342,7 +1301,7 @@ module Metanorma
1342
1301
 
1343
1302
  def render_xref(xref)
1344
1303
  target = safe_attr(xref, :target) || safe_attr(xref, :to_attr)
1345
- attrs = element_attrs(href: "##{escape_html(target)}", id: safe_attr(xref, :id), class: safe_attr(xref, :class_attr))
1304
+ attrs = element_attrs(href: "##{escape_html(target)}", id: safe_attr(xref, :id))
1346
1305
  tag("a", attrs) { render_mixed_inline(xref) }
1347
1306
  end
1348
1307
 
@@ -1460,38 +1419,39 @@ module Metanorma
1460
1419
  attrs.each do |k, v|
1461
1420
  next if v.nil? || v == false || (v.is_a?(String) && v.empty?)
1462
1421
 
1463
- val = k == :class ? translate_class(v.to_s) : v.to_s
1464
- parts << %( #{k}="#{escape_html(val)}")
1422
+ parts << %( #{k}="#{escape_html(v.to_s)}")
1465
1423
  end
1466
1424
  parts.join
1467
1425
  end
1468
1426
 
1469
- def translate_class(class_str)
1470
- class_str.split(/\s+/).map { |c| CLASS_MAP[c] || c }.join(" ")
1427
+ BLOCK_TYPES = Set[
1428
+ Metanorma::Document::Components::Paragraphs::ParagraphBlock,
1429
+ Metanorma::Document::Components::Tables::TableBlock,
1430
+ Metanorma::Document::Components::Lists::UnorderedList,
1431
+ Metanorma::Document::Components::Lists::OrderedList,
1432
+ Metanorma::Document::Components::Lists::DefinitionList,
1433
+ Metanorma::Document::Components::AncillaryBlocks::FigureBlock,
1434
+ Metanorma::Document::Components::Blocks::NoteBlock,
1435
+ Metanorma::Document::Components::AncillaryBlocks::ExampleBlock,
1436
+ Metanorma::Document::Components::AncillaryBlocks::SourcecodeBlock,
1437
+ Metanorma::Document::Components::AncillaryBlocks::FormulaBlock,
1438
+ Metanorma::Document::Components::MultiParagraph::QuoteBlock,
1439
+ Metanorma::Document::Components::MultiParagraph::AdmonitionBlock,
1440
+ Metanorma::Document::Components::Sections::HierarchicalSection,
1441
+ Metanorma::Document::Components::Sections::BasicSection,
1442
+ Metanorma::Document::Components::Sections::ContentSection,
1443
+ ].freeze
1444
+
1445
+ def html_class_for_span(xml_class)
1446
+ SPAN_ROLE_CLASSES[xml_class] || "span-#{xml_class}"
1471
1447
  end
1472
1448
 
1473
1449
  def block_element?(obj)
1474
- obj.is_a?(Metanorma::Document::Components::Paragraphs::ParagraphBlock) ||
1475
- obj.is_a?(Metanorma::Document::Components::Tables::TableBlock) ||
1476
- obj.is_a?(Metanorma::Document::Components::Lists::UnorderedList) ||
1477
- obj.is_a?(Metanorma::Document::Components::Lists::OrderedList) ||
1478
- obj.is_a?(Metanorma::Document::Components::Lists::DefinitionList) ||
1479
- obj.is_a?(Metanorma::Document::Components::AncillaryBlocks::FigureBlock) ||
1480
- obj.is_a?(Metanorma::Document::Components::Blocks::NoteBlock) ||
1481
- obj.is_a?(Metanorma::Document::Components::AncillaryBlocks::ExampleBlock) ||
1482
- obj.is_a?(Metanorma::Document::Components::AncillaryBlocks::SourcecodeBlock) ||
1483
- obj.is_a?(Metanorma::Document::Components::AncillaryBlocks::FormulaBlock) ||
1484
- obj.is_a?(Metanorma::Document::Components::MultiParagraph::QuoteBlock) ||
1485
- obj.is_a?(Metanorma::Document::Components::MultiParagraph::AdmonitionBlock) ||
1486
- obj.is_a?(Metanorma::Document::Components::Sections::HierarchicalSection) ||
1487
- obj.is_a?(Metanorma::Document::Components::Sections::BasicSection) ||
1488
- obj.is_a?(Metanorma::Document::Components::Sections::ContentSection)
1450
+ BLOCK_TYPES.any? { |type| obj.is_a?(type) }
1489
1451
  end
1490
1452
 
1491
1453
  def safe_attr(obj, method_name)
1492
- obj.public_send(method_name)
1493
- rescue NoMethodError
1494
- nil
1454
+ obj.public_send(method_name) if obj.respond_to?(method_name)
1495
1455
  end
1496
1456
 
1497
1457
  def collect_index_term(element)
@@ -1503,7 +1463,7 @@ module Metanorma
1503
1463
  secondary: safe_attr(element, :secondary)&.to_s&.strip,
1504
1464
  tertiary: safe_attr(element, :tertiary)&.to_s&.strip,
1505
1465
  target_id: @current_section_id,
1506
- target_text: @current_section_number
1466
+ target_text: @current_section_number,
1507
1467
  )
1508
1468
  rescue StandardError
1509
1469
  nil
@@ -1543,14 +1503,7 @@ module Metanorma
1543
1503
  end
1544
1504
 
1545
1505
  def escape_html(text)
1546
- return "" if text.nil?
1547
-
1548
- text
1549
- .to_s
1550
- .gsub("&", "&amp;")
1551
- .gsub("<", "&lt;")
1552
- .gsub(">", "&gt;")
1553
- .gsub('"', "&quot;")
1506
+ CGI.escapeHTML(text.to_s)
1554
1507
  end
1555
1508
 
1556
1509
  def extract_text_value(val)