metanorma-document 0.2.9 → 0.3.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 (394) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +72 -81
  3. data/CLAUDE.md +26 -0
  4. data/README.adoc +144 -0
  5. data/Rakefile +14 -2
  6. data/TODO.finalize/01-split-multi-class-files.md +8 -0
  7. data/TODO.finalize/02-remove-data-dist-iso-document-frontend.md +6 -0
  8. data/TODO.finalize/03-align-frontend-iife-and-gha-build.md +11 -0
  9. data/TODO.finalize/04-theme-per-flavor-config-driven.md +9 -0
  10. data/TODO.finalize/05-migrate-all-html-to-liquid.md +16 -0
  11. data/TODO.finalize/06-integrate-pubid-library.md +16 -0
  12. data/TODO.finalize/07-replace-relaton-models-with-relaton-bib.md +39 -0
  13. data/TODO.finalize/08-refactor-renderer-architecture.md +25 -0
  14. data/TODO.finalize/09-eliminate-code-smells.md +14 -0
  15. data/TODO.finalize/10-comprehensive-spec-coverage.md +24 -0
  16. data/TODO.finalize/11-config-driven-flavor-metadata.md +20 -0
  17. data/TODO.finalize/12-add-frozen-string-literal.md +6 -0
  18. data/TODO.finalize/13-extract-composition-from-god-class.md +96 -0
  19. data/TODO.finalize/14-config-driven-section-ordering.md +31 -0
  20. data/TODO.finalize/15-theme-directories-per-flavor.md +46 -0
  21. data/TODO.finalize/16-theme-lutaml-model-schema.md +44 -0
  22. data/data/javascripts/components/mn-footnotes.js +68 -0
  23. data/data/javascripts/components/mn-lightbox.js +18 -2
  24. data/data/javascripts/components/mn-shortcuts.js +2 -0
  25. data/data/javascripts/core/mn-navigation.js +8 -0
  26. data/data/javascripts/core/mn-theme.js +9 -9
  27. data/data/logos/oiml-logo-full-dark.svg +1 -0
  28. data/data/logos/oiml-logo-full-light.svg +1 -0
  29. data/data/logos/oiml-logo-icon-dark.svg +1 -0
  30. data/data/logos/oiml-logo-icon-light.svg +1 -0
  31. data/data/logos/oiml-logo.svg +1 -0
  32. data/data/logos/pdfa-logo.svg +31 -94
  33. data/data/stylesheets/base/_layout.css +3 -0
  34. data/data/stylesheets/base/_reset.css +3 -3
  35. data/data/stylesheets/base/_typography.css +0 -5
  36. data/data/stylesheets/components/bibliography.css +3 -3
  37. data/data/stylesheets/components/cover.css +3 -1
  38. data/data/stylesheets/components/figure.css +13 -1
  39. data/data/stylesheets/components/footnote.css +65 -5
  40. data/data/stylesheets/components/formula.css +44 -0
  41. data/data/stylesheets/components/header.css +17 -2
  42. data/data/stylesheets/components/inline.css +1 -1
  43. data/data/stylesheets/components/table.css +15 -2
  44. data/data/stylesheets/components/term.css +0 -1
  45. data/data/themes/bipm.yaml +60 -0
  46. data/data/themes/cc.yaml +43 -0
  47. data/data/themes/icc.yaml +39 -0
  48. data/data/themes/iec.yaml +44 -0
  49. data/data/themes/ieee.yaml +43 -0
  50. data/data/themes/ietf.yaml +45 -0
  51. data/data/themes/iho.yaml +46 -0
  52. data/data/themes/iso.yaml +40 -0
  53. data/data/themes/itu.yaml +45 -0
  54. data/data/themes/ogc.yaml +51 -0
  55. data/data/themes/oiml.yaml +56 -0
  56. data/data/themes/pdfa.yaml +44 -0
  57. data/data/themes/ribose.yaml +43 -0
  58. data/exe/metanorma-document +11 -0
  59. data/frontend/e2e/sample-html.test.ts +256 -0
  60. data/frontend/index.html +12 -0
  61. data/frontend/package-lock.json +4725 -0
  62. data/frontend/package.json +32 -0
  63. data/frontend/postcss.config.js +6 -0
  64. data/frontend/src/App.vue +79 -0
  65. data/frontend/src/app.ts +21 -0
  66. data/frontend/src/components/AppSidebar.vue +77 -0
  67. data/frontend/src/components/DocumentReader.vue +180 -0
  68. data/frontend/src/components/MirrorRenderer.vue +23 -0
  69. data/frontend/src/components/ReadingProgressBar.vue +29 -0
  70. data/frontend/src/components/TextRenderer.vue +131 -0
  71. data/frontend/src/components/TocItem.vue +57 -0
  72. data/frontend/src/components/TopBar.vue +49 -0
  73. data/frontend/src/components/__tests__/MirrorRenderer.test.ts +178 -0
  74. data/frontend/src/components/blocks/AdmonitionBlock.vue +74 -0
  75. data/frontend/src/components/blocks/ClauseBlock.vue +53 -0
  76. data/frontend/src/components/blocks/ExampleBlock.vue +21 -0
  77. data/frontend/src/components/blocks/FallbackBlock.vue +18 -0
  78. data/frontend/src/components/blocks/FigureBlock.vue +26 -0
  79. data/frontend/src/components/blocks/FootnotesBlock.vue +25 -0
  80. data/frontend/src/components/blocks/ListBlocks.vue +62 -0
  81. data/frontend/src/components/blocks/NoteBlock.vue +21 -0
  82. data/frontend/src/components/blocks/ParagraphBlock.vue +25 -0
  83. data/frontend/src/components/blocks/QuoteBlock.vue +25 -0
  84. data/frontend/src/components/blocks/ReviewBlock.vue +39 -0
  85. data/frontend/src/components/blocks/SourcecodeBlock.vue +52 -0
  86. data/frontend/src/components/blocks/TableBlock.vue +48 -0
  87. data/frontend/src/components/blocks/index.ts +36 -0
  88. data/frontend/src/stores/__tests__/documentStore.fixture.test.ts +272 -0
  89. data/frontend/src/stores/__tests__/documentStore.test.ts +143 -0
  90. data/frontend/src/stores/__tests__/ebookStore.test.ts +145 -0
  91. data/frontend/src/stores/__tests__/uiStore.test.ts +42 -0
  92. data/frontend/src/stores/documentStore.ts +153 -0
  93. data/frontend/src/stores/ebookStore.ts +179 -0
  94. data/frontend/src/stores/uiStore.ts +32 -0
  95. data/frontend/src/styles.css +156 -0
  96. data/frontend/src/types/__tests__/rendering-contract.test.ts +172 -0
  97. data/frontend/src/types/rendering-contract.ts +209 -0
  98. data/frontend/src/utils/__tests__/typeMetadata.test.ts +102 -0
  99. data/frontend/src/utils/typeMetadata.ts +121 -0
  100. data/frontend/tailwind.config.js +18 -0
  101. data/frontend/test/fixtures/iso-document.json +271 -0
  102. data/frontend/tmp/debug_e2e.cjs +46 -0
  103. data/frontend/tsconfig.json +25 -0
  104. data/frontend/tsconfig.node.json +11 -0
  105. data/frontend/vite.config.ts +37 -0
  106. data/frontend/vitest.e2e.config.ts +9 -0
  107. data/lib/metanorma/bipm_document/metadata/bipm_bibliographic_item.rb +0 -14
  108. data/lib/metanorma/bipm_document/metadata/depiction_element.rb +18 -0
  109. data/lib/metanorma/bipm_document/metadata.rb +1 -1
  110. data/lib/metanorma/document/cli.rb +116 -0
  111. data/lib/metanorma/document/components/ancillary_blocks/callout_annotation.rb +27 -0
  112. data/lib/metanorma/document/components/ancillary_blocks/sourcecode_block.rb +0 -65
  113. data/lib/metanorma/document/components/ancillary_blocks/sourcecode_body.rb +18 -0
  114. data/lib/metanorma/document/components/ancillary_blocks.rb +4 -0
  115. data/lib/metanorma/document/components/bib_data/bib_data.rb +7 -0
  116. data/lib/metanorma/document/components/blocks/classification_value.rb +21 -0
  117. data/lib/metanorma/document/components/blocks/permission_model.rb +28 -0
  118. data/lib/metanorma/document/components/blocks/recommendation_model.rb +28 -0
  119. data/lib/metanorma/document/components/blocks/requirement_base.rb +30 -0
  120. data/lib/metanorma/document/components/blocks/requirement_classification.rb +20 -0
  121. data/lib/metanorma/document/components/blocks/requirement_description.rb +43 -0
  122. data/lib/metanorma/document/components/blocks/requirement_inherit.rb +24 -0
  123. data/lib/metanorma/document/components/blocks/requirement_model.rb +0 -131
  124. data/lib/metanorma/document/components/blocks.rb +8 -7
  125. data/lib/metanorma/document/components/id_elements/image.rb +2 -0
  126. data/lib/metanorma/document/components/inline/asciimath_element.rb +2 -0
  127. data/lib/metanorma/document/components/inline/attribution_element.rb +2 -0
  128. data/lib/metanorma/document/components/inline/bcp14_element.rb +2 -0
  129. data/lib/metanorma/document/components/inline/biblio_tag_element.rb +2 -0
  130. data/lib/metanorma/document/components/inline/br_element.rb +2 -0
  131. data/lib/metanorma/document/components/inline/comma_element.rb +2 -0
  132. data/lib/metanorma/document/components/inline/concept_element.rb +2 -0
  133. data/lib/metanorma/document/components/inline/display_text_element.rb +2 -0
  134. data/lib/metanorma/document/components/inline/em_raw_element.rb +2 -0
  135. data/lib/metanorma/document/components/inline/enum_comma_element.rb +2 -0
  136. data/lib/metanorma/document/components/inline/eref_element.rb +2 -0
  137. data/lib/metanorma/document/components/inline/fmt_admitted_element.rb +4 -0
  138. data/lib/metanorma/document/components/inline/fmt_annotation_body_element.rb +4 -0
  139. data/lib/metanorma/document/components/inline/fmt_annotation_end_element.rb +4 -0
  140. data/lib/metanorma/document/components/inline/fmt_annotation_start_element.rb +4 -0
  141. data/lib/metanorma/document/components/inline/fmt_concept_element.rb +4 -0
  142. data/lib/metanorma/document/components/inline/fmt_definition_element.rb +4 -0
  143. data/lib/metanorma/document/components/inline/fmt_fn_body_element.rb +4 -0
  144. data/lib/metanorma/document/components/inline/fmt_fn_label_element.rb +4 -0
  145. data/lib/metanorma/document/components/inline/fmt_footnote_container_element.rb +4 -0
  146. data/lib/metanorma/document/components/inline/fmt_identifier_element.rb +4 -0
  147. data/lib/metanorma/document/components/inline/fmt_name_element.rb +4 -0
  148. data/lib/metanorma/document/components/inline/fmt_preferred_element.rb +4 -0
  149. data/lib/metanorma/document/components/inline/fmt_sourcecode_element.rb +4 -0
  150. data/lib/metanorma/document/components/inline/fmt_stem_element.rb +10 -0
  151. data/lib/metanorma/document/components/inline/fmt_termsource_element.rb +4 -0
  152. data/lib/metanorma/document/components/inline/fmt_title_element.rb +4 -0
  153. data/lib/metanorma/document/components/inline/fmt_xref_element.rb +4 -0
  154. data/lib/metanorma/document/components/inline/fmt_xref_label_element.rb +4 -0
  155. data/lib/metanorma/document/components/inline/fn_element.rb +2 -0
  156. data/lib/metanorma/document/components/inline/link_element.rb +2 -0
  157. data/lib/metanorma/document/components/inline/localized_string_element.rb +2 -0
  158. data/lib/metanorma/document/components/inline/localized_strings_element.rb +2 -0
  159. data/lib/metanorma/document/components/inline/location_element.rb +24 -0
  160. data/lib/metanorma/document/components/inline/math_element.rb +2 -0
  161. data/lib/metanorma/document/components/inline/name_with_id_element.rb +4 -0
  162. data/lib/metanorma/document/components/inline/rendered_display.rb +23 -0
  163. data/lib/metanorma/document/components/inline/rendered_math_element.rb +21 -0
  164. data/lib/metanorma/document/components/inline/semantic_content.rb +51 -0
  165. data/lib/metanorma/document/components/inline/semantic_math_element.rb +20 -0
  166. data/lib/metanorma/document/components/inline/semx_child_element.rb +2 -0
  167. data/lib/metanorma/document/components/inline/semx_element.rb +12 -1
  168. data/lib/metanorma/document/components/inline/small_cap_element.rb +2 -0
  169. data/lib/metanorma/document/components/inline/span_element.rb +10 -15
  170. data/lib/metanorma/document/components/inline/stem_inline_element.rb +7 -1
  171. data/lib/metanorma/document/components/inline/strong_raw_element.rb +2 -0
  172. data/lib/metanorma/document/components/inline/sub_element.rb +2 -0
  173. data/lib/metanorma/document/components/inline/sup_element.rb +2 -0
  174. data/lib/metanorma/document/components/inline/tab_element.rb +2 -0
  175. data/lib/metanorma/document/components/inline/title_with_annotation_element.rb +4 -0
  176. data/lib/metanorma/document/components/inline/tt_element.rb +2 -0
  177. data/lib/metanorma/document/components/inline/variant_title_element.rb +2 -0
  178. data/lib/metanorma/document/components/inline/vocabulary.rb +184 -0
  179. data/lib/metanorma/document/components/inline/xref_element.rb +10 -0
  180. data/lib/metanorma/document/components/inline.rb +12 -0
  181. data/lib/metanorma/document/components/multi_paragraph/quote_author_element.rb +20 -0
  182. data/lib/metanorma/document/components/multi_paragraph/quote_block.rb +0 -29
  183. data/lib/metanorma/document/components/multi_paragraph.rb +2 -0
  184. data/lib/metanorma/document/components/reference_elements/source_element.rb +0 -31
  185. data/lib/metanorma/document/components/reference_elements/source_modification.rb +21 -0
  186. data/lib/metanorma/document/components/reference_elements/source_origin.rb +28 -0
  187. data/lib/metanorma/document/components/reference_elements.rb +2 -2
  188. data/lib/metanorma/document/components/tables/col_element.rb +18 -0
  189. data/lib/metanorma/document/components/tables/col_group_element.rb +18 -0
  190. data/lib/metanorma/document/components/tables/table_block.rb +0 -22
  191. data/lib/metanorma/document/components/tables/table_body_section.rb +16 -0
  192. data/lib/metanorma/document/components/tables/table_foot_section.rb +16 -0
  193. data/lib/metanorma/document/components/tables/table_head_section.rb +16 -0
  194. data/lib/metanorma/document/components/tables/table_section.rb +0 -25
  195. data/lib/metanorma/document/components/tables.rb +5 -3
  196. data/lib/metanorma/document/relaton/address.rb +0 -27
  197. data/lib/metanorma/document/relaton/bib_item_locality.rb +3 -12
  198. data/lib/metanorma/document/relaton/copyright_association.rb +0 -10
  199. data/lib/metanorma/document/relaton/copyright_owner.rb +16 -0
  200. data/lib/metanorma/document/relaton/edition.rb +4 -7
  201. data/lib/metanorma/document/relaton/formatted_address.rb +29 -0
  202. data/lib/metanorma/document/relaton/logo_element.rb +18 -0
  203. data/lib/metanorma/document/relaton/organization.rb +0 -14
  204. data/lib/metanorma/document/relaton/phone.rb +3 -9
  205. data/lib/metanorma/document/relaton/place_type.rb +0 -13
  206. data/lib/metanorma/document/relaton/price_type.rb +3 -9
  207. data/lib/metanorma/document/relaton/region_element.rb +18 -0
  208. data/lib/metanorma/document/relaton.rb +6 -1
  209. data/lib/metanorma/document/version.rb +1 -1
  210. data/lib/metanorma/document.rb +2 -0
  211. data/lib/metanorma/html/asset_pipeline.rb +1 -0
  212. data/lib/metanorma/html/base_renderer.rb +284 -1057
  213. data/lib/metanorma/html/bipm_renderer.rb +15 -144
  214. data/lib/metanorma/html/cc_renderer.rb +1 -45
  215. data/lib/metanorma/html/component/base.rb +2 -14
  216. data/lib/metanorma/html/component/index_section.rb +41 -43
  217. data/lib/metanorma/html/drops/admonition_drop.rb +1 -4
  218. data/lib/metanorma/html/drops/biblio_entry_drop.rb +50 -0
  219. data/lib/metanorma/html/drops/block_element_drop.rb +7 -2
  220. data/lib/metanorma/html/drops/example_drop.rb +1 -4
  221. data/lib/metanorma/html/drops/figure_drop.rb +31 -22
  222. data/lib/metanorma/html/drops/footnote_drop.rb +1 -1
  223. data/lib/metanorma/html/drops/formula_drop.rb +45 -16
  224. data/lib/metanorma/html/drops/note_drop.rb +9 -7
  225. data/lib/metanorma/html/drops/sourcecode_drop.rb +12 -5
  226. data/lib/metanorma/html/drops.rb +2 -0
  227. data/lib/metanorma/html/flavor.rb +45 -0
  228. data/lib/metanorma/html/flavor_registry.rb +49 -0
  229. data/lib/metanorma/html/generator.rb +108 -31
  230. data/lib/metanorma/html/icc_renderer.rb +0 -39
  231. data/lib/metanorma/html/iec_renderer.rb +1 -44
  232. data/lib/metanorma/html/ieee_renderer.rb +1 -45
  233. data/lib/metanorma/html/ietf_renderer.rb +1 -48
  234. data/lib/metanorma/html/iho_renderer.rb +1 -46
  235. data/lib/metanorma/html/iso_renderer.rb +290 -343
  236. data/lib/metanorma/html/itu_renderer.rb +1 -45
  237. data/lib/metanorma/html/ogc_renderer.rb +15 -76
  238. data/lib/metanorma/html/oiml_renderer.rb +18 -38
  239. data/lib/metanorma/html/pdfa_renderer.rb +0 -39
  240. data/lib/metanorma/html/renderer_delegation.rb +21 -0
  241. data/lib/metanorma/html/renderers/block_renderer.rb +372 -0
  242. data/lib/metanorma/html/renderers/element_order_traversal.rb +34 -0
  243. data/lib/metanorma/html/renderers/inline_renderer.rb +642 -0
  244. data/lib/metanorma/html/renderers/pubid_renderer.rb +47 -0
  245. data/lib/metanorma/html/renderers/section_renderer.rb +193 -0
  246. data/lib/metanorma/html/renderers.rb +10 -0
  247. data/lib/metanorma/html/ribose_renderer.rb +1 -45
  248. data/lib/metanorma/html/standard_renderer.rb +344 -230
  249. data/lib/metanorma/html/templates/_audio.html.liquid +1 -0
  250. data/lib/metanorma/html/templates/_biblio_entry.html.liquid +1 -0
  251. data/lib/metanorma/html/templates/_bipm_cover.html.liquid +27 -0
  252. data/lib/metanorma/html/templates/_boilerplate.html.liquid +1 -0
  253. data/lib/metanorma/html/templates/_bookmark.html.liquid +1 -0
  254. data/lib/metanorma/html/templates/_br.html.liquid +1 -0
  255. data/lib/metanorma/html/templates/_definition_list.html.liquid +6 -0
  256. data/lib/metanorma/html/templates/_element.html.liquid +1 -0
  257. data/lib/metanorma/html/templates/_fn_marker.html.liquid +1 -0
  258. data/lib/metanorma/html/templates/_footnotes.html.liquid +6 -6
  259. data/lib/metanorma/html/templates/_formula.html.liquid +1 -1
  260. data/lib/metanorma/html/templates/_header.html.liquid +1 -1
  261. data/lib/metanorma/html/templates/_heading.html.liquid +1 -0
  262. data/lib/metanorma/html/templates/_image.html.liquid +1 -0
  263. data/lib/metanorma/html/templates/_inline_span.html.liquid +1 -0
  264. data/lib/metanorma/html/templates/_iso_cover.html.liquid +1 -1
  265. data/lib/metanorma/html/templates/_link.html.liquid +1 -0
  266. data/lib/metanorma/html/templates/_list.html.liquid +3 -0
  267. data/lib/metanorma/html/templates/_list_item.html.liquid +1 -0
  268. data/lib/metanorma/html/templates/_main_content.html.liquid +1 -0
  269. data/lib/metanorma/html/templates/_math_container.html.liquid +1 -0
  270. data/lib/metanorma/html/templates/_paragraph.html.liquid +1 -0
  271. data/lib/metanorma/html/templates/_prefatory_section.html.liquid +1 -0
  272. data/lib/metanorma/html/templates/_pubid_identifier.html.liquid +1 -0
  273. data/lib/metanorma/html/templates/_quote.html.liquid +4 -0
  274. data/lib/metanorma/html/templates/_ref_date.html.liquid +1 -0
  275. data/lib/metanorma/html/templates/_ref_title.html.liquid +1 -0
  276. data/lib/metanorma/html/templates/_section.html.liquid +5 -0
  277. data/lib/metanorma/html/templates/_standard_cover.html.liquid +31 -0
  278. data/lib/metanorma/html/templates/_standard_doc_title.html.liquid +3 -0
  279. data/lib/metanorma/html/templates/_stem_span.html.liquid +1 -0
  280. data/lib/metanorma/html/templates/_table.html.liquid +9 -0
  281. data/lib/metanorma/html/templates/_term_domain.html.liquid +1 -0
  282. data/lib/metanorma/html/templates/_term_example.html.liquid +1 -0
  283. data/lib/metanorma/html/templates/_term_note.html.liquid +1 -0
  284. data/lib/metanorma/html/templates/_term_number.html.liquid +1 -0
  285. data/lib/metanorma/html/templates/_term_source.html.liquid +1 -0
  286. data/lib/metanorma/html/templates/_term_text_note.html.liquid +1 -0
  287. data/lib/metanorma/html/templates/_video.html.liquid +1 -0
  288. data/lib/metanorma/html/templates/_wrapped_preface.html.liquid +1 -0
  289. data/lib/metanorma/html/templates/document.html.liquid +4 -0
  290. data/lib/metanorma/html/theme.rb +180 -107
  291. data/lib/metanorma/html.rb +16 -0
  292. data/lib/metanorma/ietf_document/metadata/ietf_bib_data_extension_type.rb +0 -25
  293. data/lib/metanorma/ietf_document/metadata/pi_settings.rb +32 -0
  294. data/lib/metanorma/ietf_document/metadata.rb +2 -0
  295. data/lib/metanorma/iso_document/metadata/doctype_element.rb +20 -0
  296. data/lib/metanorma/iso_document/metadata/iso_bib_data_extension_type.rb +0 -48
  297. data/lib/metanorma/iso_document/metadata/iso_bibliographic_item.rb +0 -72
  298. data/lib/metanorma/iso_document/metadata/iso_document_status.rb +0 -30
  299. data/lib/metanorma/iso_document/metadata/iso_localized_title.rb +4 -1
  300. data/lib/metanorma/iso_document/metadata/language_element.rb +18 -0
  301. data/lib/metanorma/iso_document/metadata/project_number.rb +22 -0
  302. data/lib/metanorma/iso_document/metadata/script_element.rb +18 -0
  303. data/lib/metanorma/iso_document/metadata/stage_element.rb +26 -0
  304. data/lib/metanorma/iso_document/metadata/stagename_element.rb +18 -0
  305. data/lib/metanorma/iso_document/metadata/structured_identifier.rb +0 -17
  306. data/lib/metanorma/iso_document/metadata/title_collection.rb +1 -0
  307. data/lib/metanorma/iso_document/metadata.rb +9 -4
  308. data/lib/metanorma/iso_document/terms/iso_term.rb +0 -13
  309. data/lib/metanorma/iso_document/terms/term_domain_element.rb +18 -0
  310. data/lib/metanorma/iso_document/terms.rb +1 -0
  311. data/lib/metanorma/itu_document/metadata/itu_bib_data_extension_type.rb +0 -37
  312. data/lib/metanorma/itu_document/metadata/meeting_date.rb +18 -0
  313. data/lib/metanorma/itu_document/metadata/meeting_element.rb +18 -0
  314. data/lib/metanorma/itu_document/metadata/study_period.rb +18 -0
  315. data/lib/metanorma/itu_document/metadata.rb +3 -4
  316. data/lib/metanorma/mirror/default_registry.rb +170 -0
  317. data/lib/metanorma/mirror/handler_registry.rb +60 -0
  318. data/lib/metanorma/mirror/handler_result.rb +37 -0
  319. data/lib/metanorma/mirror/handlers/admonition.rb +18 -0
  320. data/lib/metanorma/mirror/handlers/example.rb +21 -0
  321. data/lib/metanorma/mirror/handlers/figure.rb +53 -0
  322. data/lib/metanorma/mirror/handlers/formula.rb +35 -0
  323. data/lib/metanorma/mirror/handlers/inline/catalog.rb +57 -0
  324. data/lib/metanorma/mirror/handlers/inline/rich_html_renderer.rb +159 -0
  325. data/lib/metanorma/mirror/handlers/inline/text_extractor.rb +116 -0
  326. data/lib/metanorma/mirror/handlers/inline.rb +226 -0
  327. data/lib/metanorma/mirror/handlers/list.rb +110 -0
  328. data/lib/metanorma/mirror/handlers/note.rb +26 -0
  329. data/lib/metanorma/mirror/handlers/paragraph.rb +19 -0
  330. data/lib/metanorma/mirror/handlers/quote.rb +16 -0
  331. data/lib/metanorma/mirror/handlers/review.rb +19 -0
  332. data/lib/metanorma/mirror/handlers/section.rb +149 -0
  333. data/lib/metanorma/mirror/handlers/sourcecode.rb +27 -0
  334. data/lib/metanorma/mirror/handlers/structural.rb +68 -0
  335. data/lib/metanorma/mirror/handlers/table.rb +81 -0
  336. data/lib/metanorma/mirror/handlers/term.rb +100 -0
  337. data/lib/metanorma/mirror/handlers.rb +60 -0
  338. data/lib/metanorma/mirror/id_strategy/positional.rb +166 -0
  339. data/lib/metanorma/mirror/id_strategy/preserve.rb +12 -0
  340. data/lib/metanorma/mirror/id_strategy.rb +34 -0
  341. data/lib/metanorma/mirror/math_util.rb +53 -0
  342. data/lib/metanorma/mirror/metadata.rb +37 -0
  343. data/lib/metanorma/mirror/model/container.rb +47 -0
  344. data/lib/metanorma/mirror/model/factory.rb +52 -0
  345. data/lib/metanorma/mirror/model/guide.rb +25 -0
  346. data/lib/metanorma/mirror/model/leaf.rb +17 -0
  347. data/lib/metanorma/mirror/model/mark.rb +53 -0
  348. data/lib/metanorma/mirror/model/node.rb +47 -0
  349. data/lib/metanorma/mirror/model/soft_break.rb +33 -0
  350. data/lib/metanorma/mirror/model/text.rb +34 -0
  351. data/lib/metanorma/mirror/model.rb +16 -0
  352. data/lib/metanorma/mirror/output/builder.rb +39 -0
  353. data/lib/metanorma/mirror/output/formats/base_format.rb +90 -0
  354. data/lib/metanorma/mirror/output/formats/inline_format.rb +81 -0
  355. data/lib/metanorma/mirror/output/formats.rb +53 -0
  356. data/lib/metanorma/mirror/output/html_renderer.rb +95 -0
  357. data/lib/metanorma/mirror/output/html_renderers/block_renderers.rb +178 -0
  358. data/lib/metanorma/mirror/output/html_renderers/inline_renderer.rb +61 -0
  359. data/lib/metanorma/mirror/output/html_renderers/list_renderers.rb +96 -0
  360. data/lib/metanorma/mirror/output/html_renderers/mark_renderers.rb +41 -0
  361. data/lib/metanorma/mirror/output/html_renderers/section_renderers.rb +129 -0
  362. data/lib/metanorma/mirror/output/html_renderers/structural_renderers.rb +80 -0
  363. data/lib/metanorma/mirror/output/html_renderers/table_renderers.rb +87 -0
  364. data/lib/metanorma/mirror/output/html_renderers.rb +76 -0
  365. data/lib/metanorma/mirror/output/pipeline.rb +92 -0
  366. data/lib/metanorma/mirror/output/pipeline_context.rb +22 -0
  367. data/lib/metanorma/mirror/output.rb +14 -0
  368. data/lib/metanorma/mirror/rewriter.rb +121 -0
  369. data/lib/metanorma/mirror/safe_attr.rb +16 -0
  370. data/lib/metanorma/mirror/serialization/json_serializer.rb +25 -0
  371. data/lib/metanorma/mirror/serialization/yaml_serializer.rb +20 -0
  372. data/lib/metanorma/mirror/serialization.rb +10 -0
  373. data/lib/metanorma/mirror/transformer.rb +110 -0
  374. data/lib/metanorma/mirror.rb +31 -0
  375. data/lib/metanorma/standard_document/annotation.rb +37 -0
  376. data/lib/metanorma/standard_document/block_attributes.rb +15 -2
  377. data/lib/metanorma/standard_document/blocks/block_source.rb +0 -23
  378. data/lib/metanorma/standard_document/blocks/source_origin_element.rb +23 -0
  379. data/lib/metanorma/standard_document/blocks.rb +1 -1
  380. data/lib/metanorma/standard_document/terms/term_expression.rb +0 -31
  381. data/lib/metanorma/standard_document/terms/term_name_element.rb +29 -0
  382. data/lib/metanorma/standard_document/terms.rb +1 -0
  383. data/script/render_both.rb +18 -0
  384. metadata +272 -13
  385. data/.github/workflows/rake.yml +0 -21
  386. data/.github/workflows/release.yml +0 -25
  387. data/.github/workflows/roundtrip-samples.yml +0 -132
  388. data/.gitignore +0 -19
  389. data/.rspec +0 -3
  390. data/.rubocop.yml +0 -22
  391. data/Gemfile +0 -28
  392. data/bin/console +0 -15
  393. data/bin/setup +0 -8
  394. data/metanorma-document.gemspec +0 -35
@@ -6,15 +6,18 @@ require "cgi"
6
6
 
7
7
  module Metanorma
8
8
  module Html
9
- # Renders BasicDocument components to HTML.
10
- # Subclassed by StandardRenderer and flavor-specific renderers.
11
- # Owns the full HTML document generation pipeline: body content, header,
12
- # footer, ToC sidebar, CSS (via Theme), and JavaScript.
9
+ module Renderers
10
+ autoload :InlineRenderer, "metanorma/html/renderers/inline_renderer"
11
+ autoload :BlockRenderer, "metanorma/html/renderers/block_renderer"
12
+ autoload :SectionRenderer, "metanorma/html/renderers/section_renderer"
13
+ autoload :PubidRenderer, "metanorma/html/renderers/pubid_renderer"
14
+ autoload :ElementOrderTraversal,
15
+ "metanorma/html/renderers/element_order_traversal"
16
+ end
17
+
13
18
  class BaseRenderer
14
19
  LOGO_DIR = File.expand_path("../../../data/logos", __dir__)
15
20
 
16
- # HTML-specific class names for inline spans, keyed by the XML span role.
17
- # The XML class_attr is INPUT only — we never emit it in HTML.
18
21
  SPAN_ROLE_CLASSES = {
19
22
  "boldtitle" => "title-text",
20
23
  "nonboldtitle" => "subtitle-text",
@@ -44,8 +47,6 @@ module Metanorma
44
47
 
45
48
  METANORMA_LOGO = "metanorma-logo.svg"
46
49
 
47
- # Type-to-method registry for OCP dispatch.
48
- # Each subclass gets its own hash; lookup traverses ancestors.
49
50
  class << self
50
51
  def render_registry
51
52
  @render_registry ||= {}
@@ -64,8 +65,12 @@ module Metanorma
64
65
  end
65
66
  end
66
67
 
68
+ attr_reader :inline_renderer, :block_renderer, :section_renderer,
69
+ :pubid_renderer, :index_term_collector, :footnote_collector
70
+ private :inline_renderer, :block_renderer, :section_renderer,
71
+ :pubid_renderer
72
+
67
73
  def initialize
68
- @output = +""
69
74
  @toc_entries = []
70
75
  @figure_entries = []
71
76
  @table_entries = []
@@ -73,86 +78,93 @@ module Metanorma
73
78
  @footnote_collector = Component::FootnoteCollector.new
74
79
  @current_section_id = nil
75
80
  @current_section_number = nil
76
- end
77
81
 
78
- # --- Public API ---
82
+ @inline_renderer = Renderers::InlineRenderer.new(self)
83
+ @block_renderer = Renderers::BlockRenderer.new(self)
84
+ @section_renderer = Renderers::SectionRenderer.new(self)
85
+ @pubid_renderer = Renderers::PubidRenderer.new(self)
86
+ end
79
87
 
80
- # Facade object for Drops to call renderer methods without exposing
81
- # the full private interface. Delegates via method_missing with an
82
- # explicit allowlist — adding new delegations only requires updating
83
- # DELEGATED_METHODS, not writing a new one-liner.
84
88
  class RendererContext
85
- DELEGATED_METHODS = %i[
86
- safe_attr escape_html extract_block_label extract_plain_text
87
- capture_output render_paragraph render_mixed_inline
88
- render_inline_element render_unordered_list render_ordered_list
89
- render_definition_list render_sourcecode render_table
90
- render_figure render_quote render_formula render_note
91
- render_image render_stem_content register_figure_entry
92
- render_liquid render_block_children
93
- ].freeze
89
+ include RendererDelegation
94
90
 
95
91
  def initialize(renderer)
96
92
  @renderer = renderer
97
93
  end
98
94
 
99
- def respond_to_missing?(method_name, include_private = false)
100
- DELEGATED_METHODS.include?(method_name) || super
101
- end
102
-
103
- private
104
-
105
- def method_missing(method_name, ...)
106
- if DELEGATED_METHODS.include?(method_name)
107
- @renderer.public_send(method_name, ...)
108
- else
109
- super
110
- end
111
- end
95
+ def render_paragraph(...) = @renderer.render_paragraph(...)
96
+ def render_inline_element(...)= @renderer.render_inline_element(...)
97
+ def render_unordered_list(...)= @renderer.render_unordered_list(...)
98
+ def render_ordered_list(...)= @renderer.render_ordered_list(...)
99
+ def render_definition_list(...)= @renderer.render_definition_list(...)
100
+ def render_sourcecode(...) = @renderer.render_sourcecode(...)
101
+ def render_table(...) = @renderer.render_table(...)
102
+ def render_figure(...) = @renderer.render_figure(...)
103
+ def render_quote(...) = @renderer.render_quote(...)
104
+ def render_formula(...) = @renderer.render_formula(...)
105
+ def render_note(...) = @renderer.render_note(...)
106
+ def render_image(...) = @renderer.render_image(...)
107
+ def render_stem_content(...)= @renderer.render_stem_content(...)
108
+ def register_figure_entry(...)= @renderer.register_figure_entry(...)
109
+ def render_note_children(...) = @renderer.render_note_children(...)
110
+ def render_simple_children(...) = @renderer.render_simple_children(...)
111
+ def render_full_block_children(...) = @renderer.render_full_block_children(...)
112
112
  end
113
113
 
114
114
  def renderer_context
115
115
  @renderer_context ||= RendererContext.new(self)
116
116
  end
117
117
 
118
- def to_html
119
- @output
120
- end
121
-
122
118
  def toc_entries
123
119
  @toc_entries
124
120
  end
125
121
 
126
- # Generate a complete HTML document from a presentation XML document.
127
- def generate_full_document(document)
122
+ attr_writer :document, :theme
123
+
124
+ def generate_full_document(document, **)
128
125
  @document = document
129
126
  validate_presentation_xml!
130
127
 
131
- # First pass: render body content (collects ToC entries as side effect)
132
- render(@document)
133
- body = @output
128
+ body = render(@document) || ""
134
129
 
135
130
  assemble_document(body)
136
131
  end
137
132
 
138
- # --- Flavor configuration hooks (override in subclasses) ---
133
+ # --- Flavor configuration hooks ---
139
134
 
140
135
  def theme
141
- @theme ||= Theme.new
136
+ @theme ||= resolve_theme
137
+ end
138
+
139
+ def resolve_theme
140
+ flavor = flavor_name
141
+ flavor ? Theme.load(flavor) : Theme.new
142
+ end
143
+
144
+ def flavor_name
145
+ return nil unless defined?(@document) && @document
146
+
147
+ Metanorma::Html::Generator.flavors.name_for(@document.class)
148
+ end
149
+
150
+ # Pubid module for the current document's flavor, or nil if the
151
+ # flavor has no Pubid support. Queries the shared FlavorRegistry.
152
+ def pubid_module
153
+ return nil unless defined?(@document) && @document
154
+
155
+ Metanorma::Html::Generator.flavors.pubid_module_for(@document.class)
142
156
  end
143
157
 
144
158
  def flavor_publishers(_doc_id)
145
- []
159
+ theme.publishers
146
160
  end
147
161
 
148
162
  def flavor_publisher_name
149
- pubs = flavor_publishers(extract_primary_doc_id)
150
- pubs.empty? ? nil : pubs.join("/")
163
+ theme.publisher_name
151
164
  end
152
165
 
153
- # Map of publisher name => logo filename. Override in flavor renderers.
154
166
  def publisher_logo_map
155
- {}
167
+ theme.logos_light
156
168
  end
157
169
 
158
170
  def flavor_font_url
@@ -165,7 +177,7 @@ module Metanorma
165
177
  TEMPLATE_CACHE_MUTEX = Mutex.new
166
178
 
167
179
  def render_liquid(template_name, assigns)
168
- template_path = File.join(TEMPLATES_ROOT, template_name)
180
+ template_path = theme.resolve_template(template_name)
169
181
  template = TEMPLATE_CACHE_MUTEX.synchronize do
170
182
  TEMPLATE_CACHE[template_path] ||= Liquid::Template.parse(File.read(template_path))
171
183
  end
@@ -211,7 +223,7 @@ module Metanorma
211
223
  end
212
224
 
213
225
  def header_title_text
214
- raw = html_title.to_s.split(" — ").first.to_s.gsub(/<[^>]+>/, "")
226
+ raw = html_title.to_s.split(" — ").first.to_s
215
227
  raw.length > 60 ? "#{raw[0, 57]}..." : raw
216
228
  end
217
229
 
@@ -220,7 +232,7 @@ module Metanorma
220
232
  logo_map = publisher_logo_map
221
233
  return "" if publishers.empty? && logo_map.empty?
222
234
 
223
- # Use flavor-declared publishers; fall back to logo map keys
235
+ dark_logo_map = theme.logos_dark
224
236
  display_pubs = publishers.empty? ? logo_map.keys : publishers
225
237
 
226
238
  display_pubs.filter_map do |pub|
@@ -230,18 +242,28 @@ module Metanorma
230
242
  svg = load_logo_svg(filename, height: 26)
231
243
  next unless svg
232
244
 
233
- "<span class=\"brand-logo\" aria-label=\"#{pub} logo\">#{svg}</span>"
245
+ light_span = "<span class=\"brand-logo brand-logo-light\" aria-label=\"#{pub} logo\">#{svg}</span>"
246
+
247
+ dark_span = ""
248
+ dark_filename = dark_logo_map[pub]
249
+ if dark_filename
250
+ dark_svg = load_logo_svg(dark_filename, height: 26)
251
+ if dark_svg
252
+ dark_span = "<span class=\"brand-logo brand-logo-dark\" aria-label=\"#{pub} logo\">#{dark_svg}</span>"
253
+ end
254
+ end
255
+
256
+ "#{light_span}\n#{dark_span}"
234
257
  end.join("\n")
235
258
  end
236
259
 
237
260
  def load_logo_svg(filename, height: 32)
238
- path = File.join(LOGO_DIR, filename)
261
+ path = theme.resolve_asset(filename) || File.join(LOGO_DIR, filename)
239
262
  return nil unless File.exist?(path)
240
263
 
241
264
  svg = File.read(path)
242
265
  svg = svg.sub(/\A<\?xml[^?]*\?>\s*/, "")
243
266
  svg = svg.sub(/\A\s*<!--.*?-->\s*/m, "")
244
- svg = svg.sub(/<path[^>]*style="fill:#e3000f[^"]*"[^>]*\/>/, "")
245
267
  svg = svg.sub(/<svg\s/, '<svg class="header-logo" ')
246
268
  svg = if svg.match?(/<svg[^>]*\sheight="[^"]*"/)
247
269
  svg.sub(/(<svg[^>]*?)(\sheight="[^"]*")/,
@@ -297,7 +319,10 @@ module Metanorma
297
319
  def build_styles
298
320
  pipeline = AssetPipeline.new
299
321
  css = pipeline.compile_css(flavor_css: flavor_css_module)
300
- "#{theme.to_css_root}\n#{css}\n#{theme.to_css_extras}"
322
+ parts = [theme.to_css_root, css, theme.to_css_extras]
323
+ custom_css_path = theme.theme_css_path
324
+ parts << File.read(custom_css_path) if custom_css_path
325
+ parts.join("\n")
301
326
  end
302
327
 
303
328
  # --- Validation ---
@@ -316,12 +341,11 @@ module Metanorma
316
341
  return false unless node
317
342
  return false if node.is_a?(String)
318
343
 
319
- if node.is_a?(Metanorma::Document::Root) && node.type == "presentation"
320
- return true
321
- end
322
-
323
344
  if node.is_a?(Lutaml::Model::Serializable)
324
345
  node_attrs = node.class.attributes
346
+ if node_attrs.key?(:type) && node.type == "presentation"
347
+ return true
348
+ end
325
349
  if node_attrs.key?(:fmt_title) && node.fmt_title
326
350
  return true
327
351
  end
@@ -363,16 +387,21 @@ module Metanorma
363
387
  end
364
388
 
365
389
  def html_title
366
- bibdata = @document.bibdata
367
- return "Document" unless bibdata
390
+ extract_display_title(@document.bibdata) || "Document"
391
+ end
368
392
 
369
- titles = bibdata.titles
370
- if titles
371
- title = bibdata.title_for("en")
372
- title.to_s
373
- else
374
- "Document"
375
- end
393
+ def extract_display_title(bibdata)
394
+ return nil unless bibdata
395
+
396
+ title = bibdata.title_for("en") if bibdata.is_a?(Metanorma::Document::Components::BibData::BibData)
397
+ return title.to_s if title && !title.to_s.empty?
398
+
399
+ titles = safe_attr(bibdata, :title)
400
+ return nil unless titles && !titles.is_a?(String) && !titles.empty?
401
+
402
+ en = titles.find { |t| safe_attr(t, :language) == "en" }
403
+ found = en || titles.first
404
+ extract_text_value(found).to_s
376
405
  end
377
406
 
378
407
  def extract_primary_doc_id
@@ -393,7 +422,7 @@ module Metanorma
393
422
  text.strip.empty? ? nil : text.strip
394
423
  end
395
424
 
396
- # --- CSS loader ---
425
+ # --- Registration helpers ---
397
426
 
398
427
  def register_toc_entry(id:, level:, text:)
399
428
  @toc_entries << { id: id, level: level, text: text }
@@ -403,12 +432,14 @@ module Metanorma
403
432
  @figure_entries << { id: id, text: text }
404
433
  end
405
434
 
435
+ def figure_entries
436
+ @figure_entries
437
+ end
438
+
406
439
  def register_table_entry(id:, text:)
407
440
  @table_entries << { id: id, text: text }
408
441
  end
409
442
 
410
- attr_reader :index_term_collector, :footnote_collector
411
-
412
443
  def extract_plain_text(node)
413
444
  return node.to_s if node.is_a?(String)
414
445
  return extract_text_value(node).to_s unless node.is_a?(Lutaml::Model::Serializable)
@@ -417,12 +448,8 @@ module Metanorma
417
448
  xml_mapping = node.class.mappings_for(:xml, node.lutaml_register)
418
449
 
419
450
  if node.element_order.is_a?(Array) && xml_mapping
420
- element_to_attr = {}
421
- xml_mapping.mapping_elements_hash.each_value do |rule_or_array|
422
- Array(rule_or_array).each do |rule|
423
- element_to_attr[rule.name.to_s] = rule.to
424
- end
425
- end
451
+ element_to_attr =
452
+ Renderers::ElementOrderTraversal.element_to_attr_map(xml_mapping)
426
453
 
427
454
  indices = Hash.new(0)
428
455
  node.element_order.each do |el|
@@ -431,38 +458,39 @@ module Metanorma
431
458
  if el.text?
432
459
  parts << el.text_content.to_s
433
460
  elsif el.name == "tab"
434
- parts << "\u00A0\u00A0"
461
+ parts << " "
435
462
  elsif el.name == "br"
436
463
  parts << " "
437
464
  elsif el.element?
438
465
  attr_name = element_to_attr[el.name]
439
- next unless attr_name
440
-
441
- coll = node.public_send(attr_name)
442
- obj = if coll.is_a?(Array)
443
- idx = indices[attr_name]
444
- indices[attr_name] += 1
445
- coll[idx]
446
- else
447
- coll
448
- end
449
- parts << extract_plain_text(obj) if obj
466
+ if attr_name
467
+ coll = node.public_send(attr_name)
468
+ obj = if coll.is_a?(Array)
469
+ idx = indices[attr_name]
470
+ indices[attr_name] += 1
471
+ coll[idx]
472
+ else
473
+ coll
474
+ end
475
+ text = extract_plain_text(obj)
476
+ parts << (text.empty? ? " " : text)
477
+ elsif el.name == "span"
478
+ parts << " "
479
+ end
450
480
  end
451
481
  end
452
482
  end
453
483
 
454
- # Fallback: try .text
455
484
  if parts.join.strip.empty?
456
485
  t = safe_attr(node, :text)
457
486
  parts << (t.is_a?(Array) ? t.join : t.to_s) if t
458
487
  end
459
488
 
460
- parts.join.strip.gsub("\u00A0", " ")
489
+ parts.join.strip.gsub(" ", " ")
461
490
  end
462
491
 
463
- # Dispatch to the appropriate render method via type registry.
464
- # Lookups traverse the ancestor chain so subclasses inherit
465
- # parent registrations and can override them independently.
492
+ # --- Dispatch ---
493
+
466
494
  def render(node, **)
467
495
  return escape_html(node) if node.is_a?(String)
468
496
 
@@ -470,20 +498,18 @@ module Metanorma
470
498
  method ? public_send(method, node, **) : ""
471
499
  end
472
500
 
473
- # Dispatch to the appropriate inline render method via type registry.
474
501
  def render_inline_element(element, **)
475
- return "" if element.nil?
476
- return escape_html(element) if element.is_a?(String)
477
-
478
- method = lookup_dispatch(element.class, :inline_registry)
479
- if method
480
- public_send(method, element)
481
- elsif element.is_a?(Lutaml::Model::Serializable) && element.mixed?
482
- render_mixed_inline(element)
483
- end
502
+ @inline_renderer.render_inline_element(element)
484
503
  end
485
504
 
486
- # --- Type registrations (class-level, evaluated at class load time) ---
505
+ def is_title_element?(node, section)
506
+ title = safe_attr(section, :title)
507
+ return false unless title
508
+
509
+ node.equal?(title)
510
+ end
511
+
512
+ # --- Type registrations ---
487
513
 
488
514
  register_render Metanorma::Document::Components::Paragraphs::ParagraphBlock,
489
515
  :render_paragraph
@@ -582,51 +608,40 @@ module Metanorma
582
608
  :render_index
583
609
  register_inline_render Metanorma::Document::Components::Blocks::NoteBlock,
584
610
  :render_note_inline
585
- # All Fmt* elements delegate to render_mixed_inline
586
- register_inline_render Metanorma::Document::Components::Inline::FmtNameElement,
587
- :render_mixed_inline
588
- register_inline_render Metanorma::Document::Components::Inline::FmtTitleElement,
589
- :render_mixed_inline
590
- register_inline_render Metanorma::Document::Components::Inline::FmtXrefLabelElement,
591
- :render_mixed_inline
592
- register_inline_render Metanorma::Document::Components::Inline::FmtFnLabelElement,
593
- :render_mixed_inline
594
- register_inline_render Metanorma::Document::Components::Inline::FmtConceptElement,
595
- :render_mixed_inline
596
- register_inline_render Metanorma::Document::Components::Inline::FmtAnnotationStartElement,
597
- :render_mixed_inline
598
- register_inline_render Metanorma::Document::Components::Inline::FmtAnnotationEndElement,
599
- :render_mixed_inline
600
- register_inline_render Metanorma::Document::Components::Inline::FmtAnnotationBodyElement,
601
- :render_mixed_inline
602
- register_inline_render Metanorma::Document::Components::Inline::VariantTitleElement,
603
- :render_mixed_inline
604
- register_inline_render Metanorma::Document::Components::Inline::LocalizedStringElement,
605
- :render_mixed_inline
606
- register_inline_render Metanorma::Document::Components::Inline::TitleWithAnnotationElement,
607
- :render_mixed_inline
608
- register_inline_render Metanorma::Document::Components::Inline::BiblioTagElement,
609
- :render_mixed_inline
610
- register_inline_render Metanorma::Document::Components::Inline::NameWithIdElement,
611
- :render_mixed_inline
612
- register_inline_render Metanorma::Document::Components::Inline::DisplayTextElement,
613
- :render_mixed_inline
614
- register_inline_render Metanorma::Document::Components::Inline::FmtFootnoteContainerElement,
615
- :render_mixed_inline
616
- register_inline_render Metanorma::Document::Components::Inline::FmtFnBodyElement,
617
- :render_mixed_inline
618
- register_inline_render Metanorma::Document::Components::Inline::FmtPreferredElement,
619
- :render_mixed_inline
620
- register_inline_render Metanorma::Document::Components::Inline::FmtDefinitionElement,
621
- :render_mixed_inline
622
- register_inline_render Metanorma::Document::Components::Inline::FmtTermsourceElement,
623
- :render_mixed_inline
624
- register_inline_render Metanorma::Document::Components::Inline::FmtAdmittedElement,
625
- :render_mixed_inline
626
- register_inline_render Metanorma::Document::Components::Inline::FmtIdentifierElement,
627
- :render_mixed_inline
628
- register_inline_render Metanorma::Document::Components::Inline::FmtSourcecodeElement,
629
- :render_mixed_inline
611
+
612
+ # Transparent inline wrappers: element classes whose render
613
+ # semantics are "iterate mixed_content and render each child".
614
+ # Grouped here so adding a new transparent wrapper is one entry,
615
+ # not another register_inline_render line. Classes with a more
616
+ # specific handler register themselves above.
617
+ TRANSPARENT_INLINE_WRAPPERS = [
618
+ Metanorma::Document::Components::Inline::FmtNameElement,
619
+ Metanorma::Document::Components::Inline::FmtTitleElement,
620
+ Metanorma::Document::Components::Inline::FmtXrefLabelElement,
621
+ Metanorma::Document::Components::Inline::FmtFnLabelElement,
622
+ Metanorma::Document::Components::Inline::FmtConceptElement,
623
+ Metanorma::Document::Components::Inline::FmtAnnotationStartElement,
624
+ Metanorma::Document::Components::Inline::FmtAnnotationEndElement,
625
+ Metanorma::Document::Components::Inline::FmtAnnotationBodyElement,
626
+ Metanorma::Document::Components::Inline::VariantTitleElement,
627
+ Metanorma::Document::Components::Inline::LocalizedStringElement,
628
+ Metanorma::Document::Components::Inline::TitleWithAnnotationElement,
629
+ Metanorma::Document::Components::Inline::BiblioTagElement,
630
+ Metanorma::Document::Components::Inline::NameWithIdElement,
631
+ Metanorma::Document::Components::Inline::DisplayTextElement,
632
+ Metanorma::Document::Components::Inline::FmtFootnoteContainerElement,
633
+ Metanorma::Document::Components::Inline::FmtFnBodyElement,
634
+ Metanorma::Document::Components::Inline::FmtPreferredElement,
635
+ Metanorma::Document::Components::Inline::FmtDefinitionElement,
636
+ Metanorma::Document::Components::Inline::FmtTermsourceElement,
637
+ Metanorma::Document::Components::Inline::FmtAdmittedElement,
638
+ Metanorma::Document::Components::Inline::FmtIdentifierElement,
639
+ Metanorma::Document::Components::Inline::FmtSourcecodeElement,
640
+ ].freeze
641
+
642
+ TRANSPARENT_INLINE_WRAPPERS.each do |klass|
643
+ register_inline_render klass, :render_mixed_inline
644
+ end
630
645
 
631
646
  def lookup_dispatch(type_class, registry_method)
632
647
  self.class.ancestors.each do |ancestor|
@@ -647,941 +662,163 @@ module Metanorma
647
662
  nil
648
663
  end
649
664
 
650
- # --- Block-level rendering ---
651
-
652
- def render_paragraph(p, **_opts)
653
- attrs = element_attrs(id: safe_attr(p, :id),
654
- style: alignment_style(safe_attr(
655
- p, :alignment
656
- )))
657
- tag("p", attrs) { render_mixed_inline(p) }
658
- end
659
-
660
- def render_table(table, **_opts)
661
- attrs = element_attrs(id: safe_attr(table, :id), class: "table-block")
662
- table_id = safe_attr(table, :id)
663
- name_el = safe_attr(table, :fmt_name) || safe_attr(table, :name)
664
- if table_id && name_el
665
- register_table_entry(id: table_id, text: extract_plain_text(name_el))
666
- end
667
- col_count = table_column_count(table)
668
- @output << "<div class=\"table-scroll-wrapper\">"
669
- tag("table", attrs) do
670
- name_el = safe_attr(table, :fmt_name) || safe_attr(table, :name)
671
- if name_el
672
- @output << "<caption>"
673
- render_inline_element(name_el)
674
- @output << "</caption>"
675
- end
676
- @output << "<colgroup>" if table.colgroup
677
- render_table_colgroup(table.colgroup) if table.colgroup
678
- @output << "</colgroup>" if table.colgroup
679
- render_table_section(table.thead, "thead") if table.thead
680
- render_table_section(table.tbody, "tbody") if table.tbody
681
- if table.tfoot || (table.note && !table.note.empty?)
682
- @output << "<tfoot>"
683
- render_table_section_rows(table.tfoot) if table.tfoot
684
- if table.note && !table.note.empty?
685
- @output << "<tr><td colspan=\"#{col_count}\" class=\"table-notes\">"
686
- table.note.each { |n| render_note(n) }
687
- @output << "</td></tr>"
688
- end
689
- @output << "</tfoot>"
690
- end
691
- end
692
- @output << "</div>"
693
- end
694
-
695
- def table_column_count(table)
696
- if table.colgroup&.col && !table.colgroup.col.empty?
697
- return table.colgroup.col.size
698
- end
699
-
700
- # Walk all rows to find max column count, accounting for colspan
701
- max_cols = 0
702
- %i[thead tbody tfoot].each do |section|
703
- sec = table.public_send(section)
704
- next unless sec&.tr
705
-
706
- sec.tr.each do |tr|
707
- cols = 0
708
- Array(tr.th).each do |th|
709
- cols += th.colspan && th.colspan > 1 ? th.colspan : 1
710
- end
711
- Array(tr.td).each do |td|
712
- cols += td.colspan && td.colspan > 1 ? td.colspan : 1
713
- end
714
- max_cols = cols if cols > max_cols
715
- end
716
- end
717
- max_cols.positive? ? max_cols : 1
718
- end
719
-
720
- def render_table_colgroup(colgroup)
721
- colgroup.col&.each do |col|
722
- attrs = element_attrs(style: col.width ? "width: #{col.width}" : nil)
723
- @output << "<col#{attrs}>"
724
- end
725
- end
726
-
727
- def render_table_section(section, tag_name)
728
- @output << "<#{tag_name}>"
729
- render_table_section_rows(section)
730
- @output << "</#{tag_name}>"
731
- end
732
-
733
- def render_table_section_rows(section)
734
- section.tr&.each do |tr|
735
- @output << "<tr>"
736
- walked = walk_ordered(tr) do |type, obj|
737
- next unless type == :element
738
-
739
- render_table_cell(obj)
740
- end
741
- unless walked
742
- Array(tr.th).each { |th| render_table_cell(th, "th") }
743
- Array(tr.td).each { |td| render_table_cell(td, "td") }
744
- end
745
- @output << "</tr>"
746
- end
747
- end
748
-
749
- def render_unordered_list(ul, **_opts)
750
- attrs = element_attrs(id: safe_attr(ul, :id))
751
- tag("ul", attrs) do
752
- ul.listitem&.each { |li| render_list_item(li) }
753
- end
754
- end
755
-
756
- def render_table_cell(cell, force_tag = nil)
757
- tag_name = force_tag || (cell.is_a?(Metanorma::Document::Components::Tables::HeaderTableCell) ? "th" : "td")
758
- attrs = element_attrs(
759
- colspan: safe_attr(cell, :colspan),
760
- rowspan: safe_attr(cell, :rowspan),
761
- align: safe_attr(cell, :alignment),
762
- valign: safe_attr(cell, :vertical_alignment),
763
- )
764
- @output << "<#{tag_name}#{attrs}>"
765
- render_cell_content(cell)
766
- @output << "</#{tag_name}>"
767
- end
768
-
769
- def render_ordered_list(ol, **_opts)
770
- attrs = element_attrs(id: safe_attr(ol, :id),
771
- start: safe_attr(ol, :start), type: safe_attr(ol, :type_attr))
772
- tag("ol", attrs) do
773
- ol.listitem&.each { |li| render_list_item(li) }
774
- end
775
- end
776
-
777
- def render_list_item(li)
778
- li_id = safe_attr(li, :id)
779
- attrs = li_id ? %( id="#{escape_html(li_id)}") : ""
780
- @output << "<li#{attrs}>"
781
- render_mixed_content_in_order(li)
782
- @output << "</li>"
783
- end
665
+ # --- Delegation to sub-renderers ---
784
666
 
785
- # Render all children of a mixed-content node in document order,
786
- # dispatching block elements to their render methods.
787
- def render_mixed_content_in_order(node)
788
- node.each_mixed_content do |child|
789
- case child
790
- when String
791
- @output << escape_html(child)
792
- else
793
- if block_element?(child)
794
- render(child)
795
- else
796
- render_inline_element(child)
797
- end
798
- end
799
- end
800
- end
801
-
802
- # Collect all renderable children from a node in document order,
803
- # sorted by displayorder when available. Uses walk_ordered to traverse
804
- # element_order, and also gathers typed attributes that may not appear
805
- # in element_order (e.g. terms, definitions on section models).
806
- def collect_ordered_children(section)
807
- children = []
808
-
809
- walk_ordered(section) do |type, obj|
810
- next if %i[text tab].include?(type)
811
-
812
- children << obj
813
- end
814
-
815
- # Gather typed attributes that may not appear in element_order
816
- supplementary_attrs = %i[terms definitions]
817
- supplementary_attrs.each do |attr|
818
- val = safe_attr(section, attr)
819
- next if val.nil?
820
-
821
- Array(val).each do |v|
822
- children << v unless children.include?(v)
823
- end
824
- end
825
-
826
- children.compact!
827
- sort_by_displayorder(children)
828
- end
829
-
830
- # Render children of a section in displayorder, skipping title elements.
831
- def render_ordered_content(section, level = 1)
832
- children = collect_ordered_children(section)
833
- children.each do |node|
834
- next if node.is_a?(String)
835
- next if is_title_element?(node, section)
836
-
837
- render(node, level: level + 1)
838
- end
839
- end
840
-
841
- def sort_by_displayorder(children)
842
- children.sort_by do |node|
843
- order = if node.is_a?(Lutaml::Model::Serializable) &&
844
- node.class.attributes.key?(:displayorder)
845
- node.displayorder
846
- end
847
- order &&= order.to_i
848
- order || Float::INFINITY
849
- end
850
- end
851
-
852
- def render_definition_list(dl, **_opts)
853
- attrs = element_attrs(id: safe_attr(dl, :id))
854
- tag("dl", attrs) do
855
- dl.dt&.each_with_index do |dt, i|
856
- @output << "<dt>"
857
- render_mixed_inline(dt)
858
- @output << "</dt>"
859
- dd = dl.dd&.[](i)
860
- if dd
861
- @output << "<dd>"
862
- render_mixed_inline(dd)
863
- @output << "</dd>"
864
- end
865
- end
866
- end
867
- end
868
-
869
- def render_figure(figure, **_opts)
870
- drop = Drops::FigureDrop.from_model(figure, renderer: renderer_context)
871
- @output << render_liquid("_figure.html.liquid", { "block" => drop })
872
- end
873
-
874
- def render_image(image)
875
- src_val = safe_attr(image, :src) || safe_attr(image, :source)
876
- attrs = element_attrs(
877
- id: safe_attr(image, :id),
878
- src: src_val,
879
- alt: safe_attr(image, :alt),
880
- height: safe_attr(image, :height),
881
- width: safe_attr(image, :width),
882
- )
883
- @output << "<img#{attrs} />"
884
- end
885
-
886
- def render_video(video)
887
- attrs = element_attrs(
888
- id: safe_attr(video, :id),
889
- src: safe_attr(video, :src),
890
- )
891
- @output << "<video#{attrs} controls></video>"
892
- end
893
-
894
- def render_audio(audio)
895
- attrs = element_attrs(
896
- id: safe_attr(audio, :id),
897
- src: safe_attr(audio, :src),
898
- )
899
- @output << "<audio#{attrs} controls></audio>"
900
- end
901
-
902
- def render_note(note, **_opts)
903
- drop = Drops::NoteDrop.from_model(note, renderer: renderer_context)
904
- @output << render_liquid("_note.html.liquid", { "block" => drop })
905
- end
906
-
907
- def render_example(example, **_opts)
908
- drop = Drops::ExampleDrop.from_model(example,
909
- renderer: renderer_context)
910
- @output << render_liquid("_example.html.liquid", { "block" => drop })
911
- end
912
-
913
- def render_sourcecode(sc, **_opts)
914
- drop = Drops::SourcecodeDrop.from_model(sc, renderer: renderer_context)
915
- @output << render_liquid("_sourcecode.html.liquid", { "block" => drop })
916
- end
917
-
918
- def render_formula(formula, **_opts)
919
- drop = Drops::FormulaDrop.from_model(formula,
920
- renderer: renderer_context)
921
- @output << render_liquid("_formula.html.liquid", { "block" => drop })
922
- end
923
-
924
- def render_quote(quote, **_opts)
925
- attrs = element_attrs(id: safe_attr(quote, :id), class: "quote")
926
- tag("blockquote", attrs) do
927
- quote.paragraphs&.each { |para| render_paragraph(para) }
928
- quote.ul&.each { |ul| render_unordered_list(ul) }
929
- quote.ol&.each { |ol| render_ordered_list(ol) }
930
- if quote.attribution
931
- @output << "<footer>"
932
- render_mixed_inline(quote.attribution)
933
- @output << "</footer>"
934
- end
935
- end
936
- end
937
-
938
- def render_admonition(admonition, **_opts)
939
- drop = Drops::AdmonitionDrop.from_model(admonition,
940
- renderer: renderer_context)
941
- @output << render_liquid("_admonition.html.liquid", { "block" => drop })
942
- end
943
-
944
- def render_bookmark(bookmark, **_opts)
945
- @output << %(<a id="#{escape_html(safe_attr(bookmark, :id).to_s)}"></a>)
946
- end
947
-
948
- # Renders the typed child collections of a block element (paragraphs,
949
- # lists, nested blocks) in a standard order. Used by Drops and section
950
- # rendering to avoid duplicating the enumeration in each consumer.
951
- #
952
- # +children+ is a Hash mapping attr names to render-method symbols:
953
- # { paragraphs: :render_paragraph, ul: :render_unordered_list, ... }
954
- #
955
- # Each key is sent to +model+ via safe_attr; non-nil results are
956
- # dispatched to the corresponding render method.
957
- def render_block_children(model, children:)
958
- children.each do |attr, render_method|
959
- values = safe_attr(model, attr)
960
- next if values.nil?
961
-
962
- Array(values).each { |v| public_send(render_method, v) }
963
- end
964
- end
965
-
966
- # Standard child set for container blocks (example, note, etc.)
967
- BLOCK_CHILDREN = {
968
- paragraphs: :render_paragraph,
969
- ul: :render_unordered_list,
970
- ol: :render_ordered_list,
971
- dl: :render_definition_list,
972
- sourcecode: :render_sourcecode,
973
- table: :render_table,
974
- figure: :render_figure,
975
- quote: :render_quote,
976
- formula: :render_formula,
977
- }.freeze
978
-
979
- # Minimal child set for simple blocks (admonition, note with content only)
980
- SIMPLE_CHILDREN = {
981
- paragraphs: :render_paragraph,
982
- }.freeze
983
-
984
- # Note-style child set (paragraphs + lists + dl, no nested blocks)
985
- NOTE_CHILDREN = {
986
- paragraphs: :render_paragraph,
987
- ul: :render_unordered_list,
988
- ol: :render_ordered_list,
989
- dl: :render_definition_list,
990
- quote: :render_quote,
991
- }.freeze
992
-
993
- # --- Section rendering ---
994
-
995
- def render_basic_section(section, level: 1, **_opts)
996
- attrs = element_attrs(id: safe_attr(section, :id))
997
- tag("div", attrs) do
998
- render_section_title(section, level)
999
- section.blocks&.each { |block| render(block) }
1000
- section.notes&.each { |note| render_note(note) }
1001
- end
1002
- end
1003
-
1004
- def render_hierarchical_section(section, level: 1, **_opts)
1005
- attrs = element_attrs(id: safe_attr(section, :id))
1006
- tag("div", attrs) do
1007
- render_section_title(section, level)
1008
- render_section_content(section, level)
1009
- section.subsections&.each { |sub| render(sub, level: level + 1) }
1010
- end
1011
- end
1012
-
1013
- def render_content_section(section, level: 1, **_opts)
1014
- render_hierarchical_section(section, level: level, **_opts)
1015
- end
1016
-
1017
- def render_section_title(section, level)
1018
- titles = section.title
1019
- return unless titles && !titles.empty?
1020
-
1021
- h = "h#{[[level, 6].min, 1].max}"
1022
- title_text = extract_title_text(titles)
1023
- @output << "<#{h}>#{escape_html(title_text)}</#{h}>"
1024
- end
1025
-
1026
- def render_section_content(section, _level)
1027
- section.blocks&.each { |block| render(block) }
1028
- section.notes&.each { |note| render_note(note) }
1029
- end
1030
-
1031
- # --- Inline content rendering ---
1032
-
1033
- # Walk element_order in document order, resolving each element to its
1034
- # Ruby object via the XML mapping, and yielding (element_order_entry, resolved_object)
1035
- # to the given block. Handles tab elements as nbsp. Skips elements not in the mapping.
1036
- #
1037
- # This is the single ordered-walk primitive used by all mixed content renderers:
1038
- # - render_mixed_inline (inline-only)
1039
- # - render_cell_content (mixed block/inline)
1040
- # - render_semx_content (filtered display attrs only)
1041
- def walk_ordered(node, allow_filter: nil)
1042
- return false unless node.is_a?(Lutaml::Model::Serializable)
1043
- return false unless node.element_order.is_a?(Array) && !node.element_order.empty?
1044
-
1045
- xml_mapping = node.class.mappings_for(:xml, node.lutaml_register)
1046
- return false unless xml_mapping
1047
-
1048
- element_to_attr = {}
1049
- xml_mapping.mapping_elements_hash.each_value do |rule_or_array|
1050
- Array(rule_or_array).each do |rule|
1051
- element_to_attr[rule.name] = rule.to
1052
- element_to_attr[rule.name.to_s] = rule.to if rule.name.is_a?(Symbol)
1053
- end
1054
- end
1055
-
1056
- skip_indices = build_semx_skip_set(node)
1057
-
1058
- indices = Hash.new(0)
1059
-
1060
- node.element_order.each_with_index do |el, i|
1061
- next if skip_indices.include?(i)
1062
-
1063
- if el.text?
1064
- text = el.text_content
1065
- yield :text, text if text && block_given?
1066
- elsif el.element?
1067
- # Handle <tab/> elements
1068
- if el.name == "tab"
1069
- yield :tab, nil if block_given?
1070
- next
1071
- end
1072
-
1073
- attr_name = element_to_attr[el.name]
1074
- next unless attr_name
1075
-
1076
- # Apply optional filter (used by semx to skip semantic attrs)
1077
- next if allow_filter && !allow_filter.include?(attr_name)
1078
-
1079
- coll = node.public_send(attr_name)
1080
- obj = if coll.is_a?(Array)
1081
- idx = indices[attr_name]
1082
- indices[attr_name] += 1
1083
- coll[idx]
1084
- else
1085
- coll
1086
- end
1087
- yield :element, obj if obj && block_given?
1088
- end
1089
- end
1090
- true
1091
- end
1092
-
1093
- # In presentation XML, semantic elements are followed by <semx> wrappers
1094
- # or <fmt-*> display elements. Skip source elements to avoid duplicates.
1095
- def build_semx_skip_set(node)
1096
- skip_after = {
1097
- "link" => "semx",
1098
- "xref" => "semx",
1099
- "eref" => "semx",
1100
- "stem" => nil,
1101
- "concept" => "fmt-concept",
1102
- "refterm" => nil,
1103
- "renderterm" => nil,
1104
- "origin" => "semx",
1105
- }
1106
- skip = {}
1107
- node.element_order.each_with_index do |el, i|
1108
- next unless el.element?
1109
-
1110
- next_tag = skip_after[el.name]
1111
- next unless next_tag
1112
-
1113
- next_el = node.element_order[i + 1]
1114
- if next_tag.nil?
1115
- skip[i] = true
1116
- elsif next_el&.element? && next_el.name == next_tag
1117
- skip[i] = true
1118
- end
1119
- end
1120
- skip
1121
- end
1122
-
1123
- # Table cells can contain both block-level content (p, ul, ol, dl)
1124
- # and inline content (text, em, strong, etc.) in document order.
1125
- def render_cell_content(cell)
1126
- walked = walk_ordered(cell) do |type, obj|
1127
- case type
1128
- when :text
1129
- @output << escape_html(obj)
1130
- when :tab
1131
- @output << "\u00a0\u00a0"
1132
- when :element
1133
- if block_element?(obj)
1134
- render(obj)
1135
- else
1136
- render_inline_element(obj)
1137
- end
1138
- end
1139
- end
1140
- unless walked
1141
- render_mixed_content_in_order(cell)
1142
- end
667
+ # Inline rendering delegation
668
+ def walk_ordered(node, allow_filter: nil, &)
669
+ @inline_renderer.walk_ordered(node, allow_filter: allow_filter, &)
1143
670
  end
1144
671
 
1145
672
  def render_mixed_inline(node)
1146
- # Models using map_all_content: raw XML in content
1147
- if raw_content_node?(node)
1148
- raw = node.content
1149
- if raw.is_a?(String) && !raw.strip.empty?
1150
- @output << render_raw_content(raw)
1151
- return
1152
- end
1153
- end
1154
-
1155
- if node.is_a?(Lutaml::Model::Serializable) && node.element_order && !node.element_order.empty?
1156
- render_ordered_inline(node)
1157
- elsif node.is_a?(Lutaml::Model::Serializable)
1158
- node.each_mixed_content do |child|
1159
- case child
1160
- when String
1161
- @output << escape_html(child)
1162
- else
1163
- render_inline_element(child)
1164
- end
1165
- end
1166
- else
1167
- render_inline_collections(node)
1168
- end
673
+ @inline_renderer.render_mixed_inline(node)
1169
674
  end
1170
675
 
1171
- def raw_content_node?(node)
1172
- node.is_a?(Lutaml::Model::Serializable) &&
1173
- node.content.is_a?(String)
1174
- rescue NoMethodError
1175
- false
1176
- end
676
+ def render_cell_content(cell) = @inline_renderer.render_cell_content(cell)
677
+ def render_em(el) = @inline_renderer.render_em(el)
678
+ def render_strong(el) = @inline_renderer.render_strong(el)
679
+ def render_tt(el) = @inline_renderer.render_tt(el)
680
+ def render_sub(el) = @inline_renderer.render_sub(el)
681
+ def render_sup(el) = @inline_renderer.render_sup(el)
682
+ def render_small_caps(el) = @inline_renderer.render_small_caps(el)
683
+ def render_underline(el) = @inline_renderer.render_underline(el)
684
+ def render_strike(el) = @inline_renderer.render_strike(el)
685
+ def render_br(*) = @inline_renderer.render_br
686
+ def render_tab(*) = @inline_renderer.render_tab
687
+ def render_span(el) = @inline_renderer.render_span(el)
688
+ def render_fn_inline(el) = @inline_renderer.render_fn_inline(el)
689
+ def render_stem(el) = @inline_renderer.render_stem(el)
690
+ def render_semx_inline(el) = @inline_renderer.render_semx_inline(el)
691
+ def render_fmt_xref(el) = @inline_renderer.render_fmt_xref(el)
692
+ def render_comma(*) = @inline_renderer.render_comma
693
+ def render_math(el) = @inline_renderer.render_math(el)
694
+ def render_asciimath(el) = @inline_renderer.render_asciimath(el)
695
+ def render_index(el) = @inline_renderer.render_index(el)
696
+ def render_note_inline(el) = @inline_renderer.render_note_inline(el)
1177
697
 
1178
- # Iterate element_order directly, preserving whitespace text nodes
1179
- # that each_mixed_content drops (it skips text where text.strip.empty?)
1180
- def render_ordered_inline(node)
1181
- walked = walk_ordered(node) do |type, obj|
1182
- case type
1183
- when :text
1184
- @output << escape_html(obj)
1185
- when :tab
1186
- @output << "\u00a0\u00a0"
1187
- when :element
1188
- render_inline_element(obj)
1189
- end
1190
- end
1191
- unless walked
1192
- node.each_mixed_content do |child|
1193
- case child
1194
- when String
1195
- @output << escape_html(child)
1196
- else
1197
- render_inline_element(child)
1198
- end
1199
- end
1200
- end
698
+ def render_semx_content(el,
699
+ **)
700
+ @inline_renderer.render_semx_content(el, **)
1201
701
  end
1202
702
 
1203
- # Inline adapter methods for registry dispatch
703
+ def render_stem_content(stem) = @inline_renderer.render_stem_content(stem)
704
+ def render_link(link) = @inline_renderer.render_link(link)
705
+ def render_xref(xref) = @inline_renderer.render_xref(xref)
706
+ def render_eref(eref) = @inline_renderer.render_eref(eref)
707
+ def render_fn(fn) = @inline_renderer.render_fn(fn)
708
+ def render_concept(concept) = @inline_renderer.render_concept(concept)
709
+ def render_fmt_stem(fmt_stem) = @inline_renderer.render_fmt_stem(fmt_stem)
710
+ def render_mixed_content_in_order(node) = @inline_renderer.render_mixed_content_in_order(node)
1204
711
 
1205
- def render_em(el)
1206
- render_inline_tag("em", el)
712
+ # Block rendering delegation
713
+ def render_paragraph(p,
714
+ **)
715
+ @block_renderer.render_paragraph(p, **)
1207
716
  end
1208
717
 
1209
- def render_strong(el)
1210
- render_inline_tag("strong", el)
718
+ def render_table(table,
719
+ **)
720
+ @block_renderer.render_table(table, **)
1211
721
  end
1212
722
 
1213
- def render_tt(el)
1214
- render_inline_tag("tt", el)
723
+ def render_unordered_list(ul,
724
+ **)
725
+ @block_renderer.render_unordered_list(ul, **)
1215
726
  end
1216
727
 
1217
- def render_sub(el)
1218
- render_inline_tag("sub", el)
728
+ def render_ordered_list(ol,
729
+ **)
730
+ @block_renderer.render_ordered_list(ol, **)
1219
731
  end
1220
732
 
1221
- def render_sup(el)
1222
- render_inline_tag("sup", el)
733
+ def render_definition_list(dl,
734
+ **)
735
+ @block_renderer.render_definition_list(dl, **)
1223
736
  end
1224
737
 
1225
- def render_small_caps(el)
1226
- render_inline_tag("span", el, class: "small-caps")
738
+ def render_figure(figure,
739
+ **)
740
+ @block_renderer.render_figure(figure, **)
1227
741
  end
1228
742
 
1229
- def render_underline(el)
1230
- render_inline_tag("u", el)
1231
- end
743
+ def render_image(image) = @block_renderer.render_image(image)
744
+ def render_video(video) = @block_renderer.render_video(video)
745
+ def render_audio(audio) = @block_renderer.render_audio(audio)
746
+ def render_note(note, **) = @block_renderer.render_note(note, **)
1232
747
 
1233
- def render_strike(el)
1234
- render_inline_tag("s", el)
748
+ def render_example(example,
749
+ **)
750
+ @block_renderer.render_example(example, **)
1235
751
  end
1236
752
 
1237
- def render_br(*)
1238
- @output << "<br />"
753
+ def render_sourcecode(sc,
754
+ **)
755
+ @block_renderer.render_sourcecode(sc, **)
1239
756
  end
1240
757
 
1241
- def render_tab(*)
1242
- @output << "\u00a0\u00a0"
758
+ def render_formula(formula,
759
+ **)
760
+ @block_renderer.render_formula(formula, **)
1243
761
  end
1244
762
 
1245
- def render_span(el)
1246
- xml_class = safe_attr(el, :class_attr).to_s
1247
- html_class = html_class_for_span(xml_class) unless xml_class.empty?
1248
- attrs = element_attrs(style: safe_attr(el, :style), class: html_class)
1249
- tag("span", attrs) { render_mixed_inline(el) }
763
+ def render_quote(quote,
764
+ **)
765
+ @block_renderer.render_quote(quote, **)
1250
766
  end
1251
767
 
1252
- def render_fn_inline(el)
1253
- render_fn(el)
768
+ def render_admonition(admonition,
769
+ **)
770
+ @block_renderer.render_admonition(admonition, **)
1254
771
  end
1255
772
 
1256
- def render_stem(el)
1257
- @output << render_stem_content(el)
773
+ def render_bookmark(bookmark,
774
+ **)
775
+ @block_renderer.render_bookmark(bookmark, **)
1258
776
  end
1259
777
 
1260
- def render_semx_inline(el)
1261
- render_semx_content(el)
778
+ def render_block_children(model,
779
+ children:)
780
+ @block_renderer.render_block_children(model, children: children)
1262
781
  end
1263
782
 
1264
- def render_fmt_xref(el)
1265
- target = safe_attr(el, :target) || safe_attr(el, :to_attr)
1266
- if target
1267
- attrs = element_attrs(href: "##{escape_html(target)}", class: "xref")
1268
- tag("a", attrs) { render_mixed_inline(el) }
1269
- else
1270
- render_mixed_inline(el)
1271
- end
1272
- end
783
+ def render_note_children(model) = @block_renderer.render_note_children(model)
784
+ def render_simple_children(model) = @block_renderer.render_simple_children(model)
785
+ def render_full_block_children(model) = @block_renderer.render_full_block_children(model)
1273
786
 
1274
- def render_comma(*)
1275
- @output << ", "
787
+ # Section rendering delegation
788
+ def render_basic_section(section,
789
+ **)
790
+ @section_renderer.render_basic_section(section, **)
1276
791
  end
1277
792
 
1278
- def render_math(el)
1279
- @output << el.content.to_s
793
+ def render_hierarchical_section(section,
794
+ **)
795
+ @section_renderer.render_hierarchical_section(section, **)
1280
796
  end
1281
797
 
1282
- def render_asciimath(el)
1283
- @output << %(<span class="stem">#{escape_html(Array(el.text).join)}</span>)
798
+ def render_content_section(section,
799
+ **)
800
+ @section_renderer.render_content_section(section, **)
1284
801
  end
1285
802
 
1286
- def render_index(el)
1287
- collect_index_term(el)
1288
- ""
803
+ def render_ordered_content(section,
804
+ level = 1)
805
+ @section_renderer.render_ordered_content(section, level)
1289
806
  end
1290
807
 
1291
- def render_note_inline(el)
1292
- render_note(el)
1293
- end
808
+ def collect_ordered_children(section) = @section_renderer.collect_ordered_children(section)
809
+ def sort_by_displayorder(children) = @section_renderer.sort_by_displayorder(children)
1294
810
 
1295
- def render_inline_collections(node)
1296
- texts = node.text
1297
- if texts.is_a?(Array)
1298
- texts.each do |t|
1299
- if t.is_a?(String)
1300
- @output << escape_html(t)
1301
- else
1302
- render_inline_element(t)
1303
- end
1304
- end
1305
- elsif texts.is_a?(String)
1306
- @output << escape_html(texts)
1307
- end
1308
-
1309
- inline_attrs = %i[em strong smallcap sub sup tt underline strike
1310
- xref eref link span stem concept fn br tab keyword
1311
- fmt_annotation_start fmt_annotation_end
1312
- fmt_stem fmt_fn_label fmt_concept
1313
- bookmark image semx fmt_xref_label]
1314
- inline_attrs.each do |attr|
1315
- values = safe_attr(node, attr)
1316
- next if values.nil?
1317
-
1318
- Array(values).each { |v| render_inline_element(v) }
1319
- end
811
+ def render_preface(preface,
812
+ **)
813
+ @section_renderer.render_preface(preface, **)
1320
814
  end
1321
815
 
1322
- # Render SemxElement display content only, skipping semantic linkage.
1323
- # semx wraps both semantic data (origin, xref, source, etc.) and
1324
- # display content (fmt-xref, span, strong, etc.). Only render display.
1325
- def render_semx_content(element, **_opts)
1326
- display_attrs = %i[text fmt_xref fmt_link fmt_concept span strong em sup p semx
1327
- asciimath math sub_child tt_child br_child tab_child
1328
- stem_child figure_child formula_child sourcecode_child]
1329
- label_stripped = false
1330
-
1331
- walked = walk_ordered(element,
1332
- allow_filter: display_attrs) do |type, obj|
1333
- case type
1334
- when :text
1335
- text = obj
1336
- if !label_stripped
1337
- text = deduplicate_semx_text(text, @output)
1338
- label_stripped = true
1339
- end
1340
- @output << escape_html(text)
1341
- when :element
1342
- if obj.is_a?(Metanorma::Document::Components::Paragraphs::ParagraphBlock)
1343
- render_paragraph(obj)
1344
- else
1345
- render_inline_element(obj)
1346
- end
1347
- end
1348
- end
1349
-
1350
- unless walked
1351
- display_attrs.each do |attr|
1352
- val = safe_attr(element, attr)
1353
- next if val.nil?
1354
-
1355
- if val.is_a?(Array)
1356
- val.each do |v|
1357
- if v.is_a?(Metanorma::Document::Components::Paragraphs::ParagraphBlock)
1358
- render_paragraph(v)
1359
- else
1360
- render_inline_element(v)
1361
- end
1362
- end
1363
- elsif val.is_a?(String)
1364
- @output << escape_html(val)
1365
- else
1366
- render_inline_element(val)
1367
- end
1368
- end
1369
- end
1370
- end
1371
-
1372
- def deduplicate_semx_text(semx_text, output)
1373
- first_word = semx_text[/\A\s*(\S+)/, 1]
1374
- return semx_text unless first_word
1375
-
1376
- tail = output[-200..]
1377
- return semx_text unless tail&.rstrip&.end_with?(first_word)
1378
-
1379
- semx_text.sub(/\A\s*#{Regexp.escape(first_word)}\s*/, "")
1380
- end
1381
-
1382
- def render_inline_tag(tag_name, element, **extra_attrs)
1383
- tag(tag_name, element_attrs(**extra_attrs)) do
1384
- render_mixed_inline(element)
1385
- end
1386
- end
1387
-
1388
- # Process raw XML content from map_all_content models.
1389
- # Strips source elements (xref, eref, stem) that have a following <semx>
1390
- # wrapper, keeping only the semx display content.
1391
- def render_raw_content(raw_xml)
1392
- doc = Nokogiri::XML.fragment(raw_xml)
1393
- # Convert fmt-link elements to HTML <a> tags before stripping wrappers
1394
- doc.css("fmt-link").each do |el|
1395
- target = el["target"] || el["href"]
1396
- if target
1397
- display_text = target.delete_prefix("mailto:")
1398
- a = doc.document.create_element("a", display_text, "href" => target)
1399
- el.replace(a)
1400
- else
1401
- el.replace(el.children)
1402
- end
1403
- end
1404
- # Remove source elements that precede a <semx> sibling,
1405
- # deduplicating any label text that appears in both the source
1406
- # paragraph and the semx display content.
1407
- doc.traverse do |node|
1408
- next unless node.element?
1409
- next unless %w[xref eref stem link].include?(node.name)
1410
-
1411
- next_sib = node.next_sibling
1412
- while next_sib.is_a?(Nokogiri::XML::Text) && next_sib.text.strip.empty?
1413
- next_sib = next_sib.next_sibling
1414
- end
1415
- next unless next_sib&.element? && next_sib.name == "semx"
1416
-
1417
- deduplicate_semx_label(node, next_sib)
1418
- node.remove
1419
- end
1420
- # Strip presentation wrappers, keeping inner content
1421
- %w[semx fmt-xref].each do |tag|
1422
- doc.css(tag).each { |el| el.replace(el.children) }
1423
- end
1424
- # Remap XML class names to HTML-specific class names
1425
- doc.css("[class]").each do |el|
1426
- el["class"] = el["class"].split(/\s+/).map do |c|
1427
- html_class_for_span(c)
1428
- end.join(" ")
1429
- end
1430
- doc.inner_html
1431
- end
1432
-
1433
- def deduplicate_semx_label(source_node, semx_node)
1434
- first_text = semx_node.children.find do |c|
1435
- c.text? && !c.text.strip.empty?
1436
- end
1437
- return unless first_text
1438
-
1439
- semx_prefix = first_text.text[/\A(\s*\S+)/, 1]
1440
- return unless semx_prefix && !semx_prefix.strip.empty?
1441
-
1442
- prev = source_node.previous_sibling
1443
- return unless prev.is_a?(Nokogiri::XML::Text)
1444
-
1445
- label = semx_prefix.strip
1446
- prev_text = prev.text.rstrip
1447
- return unless prev_text.end_with?(label)
1448
-
1449
- prev.content = prev_text.sub(/#{Regexp.escape(label)}\s*\z/, "")
1450
- first_text.content = first_text.text.sub(
1451
- /\A\s*#{Regexp.escape(label)}\s*/, ""
1452
- )
1453
- end
1454
-
1455
- def render_link(link)
1456
- target = safe_attr(link, :target) || safe_attr(link, :href)
1457
- attrs = element_attrs(href: target, id: safe_attr(link, :id))
1458
- tag("a", attrs) do
1459
- content = safe_attr(link, :content)
1460
- if content && !Array(content).join.strip.empty?
1461
- render_mixed_inline(link)
1462
- else
1463
- display_text = target.to_s.delete_prefix("mailto:")
1464
- @output << escape_html(display_text)
1465
- end
1466
- end
1467
- end
1468
-
1469
- def render_xref(xref)
1470
- target = safe_attr(xref, :target) || safe_attr(xref, :to_attr)
1471
- attrs = element_attrs(href: "##{escape_html(target)}",
1472
- id: safe_attr(xref,
1473
- :id))
1474
- tag("a", attrs) { render_mixed_inline(xref) }
1475
- end
1476
-
1477
- def render_eref(eref)
1478
- citeas = safe_attr(eref, :citeas)
1479
- if citeas
1480
- @output << escape_html(citeas)
1481
- else
1482
- render_mixed_inline(eref)
1483
- end
1484
- end
1485
-
1486
- def render_fn(fn)
1487
- fn_id = safe_attr(fn, :id)
1488
- number = @footnote_collector.register(fn)
1489
-
1490
- attrs = element_attrs(id: fn_id, class: "fn-marker")
1491
- tag("span", attrs) do
1492
- label = safe_attr(fn, :fn_label) || safe_attr(fn, :reference)
1493
- if label
1494
- @output << "<sup><a href=\"##{escape_html("footnote-#{number}")}\" " \
1495
- "class=\"fn-link\" id=\"#{escape_html("fnref-#{number}")}\">" \
1496
- "#{escape_html(label.to_s)}</a></sup>"
1497
- end
1498
- end
1499
- end
1500
-
1501
- def render_concept(concept)
1502
- render_mixed_inline(concept)
1503
- end
1504
-
1505
- # --- Stem/math rendering ---
1506
-
1507
- def render_fmt_stem(fmt_stem)
1508
- semx_items = Array(fmt_stem.semx)
1509
- return if semx_items.empty?
1510
-
1511
- semx = semx_items.first
1512
- math_items = Array(semx.math)
1513
- ascii_items = Array(semx.asciimath)
1514
-
1515
- # Collect source formats for interactive copy dropdown
1516
- source_formats = {}
1517
- if ascii_items.any?
1518
- ascii_text = ascii_items.map { |a| a.text.to_s.strip }.join
1519
- source_formats["asciimath"] = ascii_text unless ascii_text.empty?
1520
- end
1521
-
1522
- data_attrs = ""
1523
- unless source_formats.empty?
1524
- data_attrs = " data-stem-formats='#{escape_html(source_formats.to_json)}'"
1525
- end
1526
-
1527
- if math_items.any?
1528
- content = math_items.map { |m| m.content.to_s }.join
1529
- unless content.empty?
1530
- @output << "<span class=\"math-container\"#{data_attrs}>"
1531
- @output << "<math xmlns=\"http://www.w3.org/1998/Math/MathML\">#{content}</math>"
1532
- @output << "</span>"
1533
- end
1534
- elsif ascii_items.any?
1535
- # No MathML — render asciimath as fallback
1536
- text = ascii_items.map { |a| a.text.to_s.strip }.join
1537
- @output << "<span class=\"stem\"#{data_attrs}>#{escape_html(text)}</span>"
1538
- end
1539
- end
1540
-
1541
- def render_stem_content(stem)
1542
- return "" if stem.nil?
1543
-
1544
- # StemInlineElement — source element, skip (rendered via FmtStemElement)
1545
- if stem.is_a?(Metanorma::Document::Components::Inline::StemInlineElement)
1546
- return ""
1547
- end
1548
-
1549
- # FmtStemElement — already handled by render_fmt_stem
1550
- if stem.is_a?(Metanorma::Document::Components::Inline::FmtStemElement)
1551
- return ""
1552
- end
1553
-
1554
- # TextElements::StemElement — block math (no fmt- counterpart for display formulas)
1555
- if stem.is_a?(Metanorma::Document::Components::TextElements::StemElement)
1556
- if stem.math
1557
- math_val = stem.math
1558
- return math_val.to_xml if math_val.is_a?(Nokogiri::XML::Node)
1559
- return math_val.to_xml if defined?(Moxml::Document) && math_val.is_a?(Moxml::Document)
1560
-
1561
- math_items = Array(math_val)
1562
- math_items.join
1563
- elsif stem.asciimath
1564
- text = extract_text_value(stem.asciimath)
1565
- return %(<span class="stem">#{escape_html(text)}</span>)
1566
- end
1567
- if stem.latexmath
1568
- text = extract_text_value(stem.latexmath)
1569
- return %(<span class="stem">#{escape_html(text)}</span>)
1570
- end
1571
- end
1572
-
1573
- text = extract_text_value(stem)
1574
- text.empty? ? "" : %(<span class="stem">#{escape_html(text)}</span>)
1575
- end
816
+ # Pubid rendering delegation
817
+ def parse_pubid(docidentifier_string) = @pubid_renderer.parse_pubid(docidentifier_string)
818
+ def pubid_to_html(identifier) = @pubid_renderer.pubid_to_html(identifier)
1576
819
 
1577
820
  # --- Helper methods ---
1578
821
 
1579
- def tag(name, attrs_str)
1580
- @output << "<#{name}#{attrs_str}>"
1581
- yield
1582
- @output << "</#{name}>"
1583
- end
1584
-
1585
822
  def element_attrs(**attrs)
1586
823
  parts = []
1587
824
  attrs.each do |k, v|
@@ -1644,7 +881,6 @@ module Metanorma
1644
881
  end
1645
882
 
1646
883
  def extract_block_label(block, default)
1647
- # Presentation XML puts label in <name> child element
1648
884
  names = safe_attr(block, :name)
1649
885
  if names && !names.empty?
1650
886
  name = names.is_a?(Array) ? names.first : names
@@ -1652,7 +888,6 @@ module Metanorma
1652
888
  return text unless text.to_s.strip.empty?
1653
889
  end
1654
890
 
1655
- # Fallback: autonum XML attribute
1656
891
  autonum = safe_attr(block, :autonum)
1657
892
  if autonum && !autonum.to_s.empty?
1658
893
  number = autonum.to_s
@@ -1669,9 +904,10 @@ module Metanorma
1669
904
  end
1670
905
 
1671
906
  def extract_title_text(titles)
1672
- return "" if titles.nil? || titles.empty?
907
+ return "" if titles.nil?
908
+ return extract_text_value(titles).to_s unless titles.is_a?(Array)
909
+ return "" if titles.empty?
1673
910
 
1674
- titles = Array(titles)
1675
911
  title = titles.first
1676
912
  extract_text_value(title).to_s
1677
913
  end
@@ -1709,29 +945,20 @@ module Metanorma
1709
945
  end
1710
946
 
1711
947
  def render_footnotes_section
1712
- return if @footnote_collector.empty?
948
+ return nil if @footnote_collector.empty?
1713
949
 
1714
950
  drops = @footnote_collector.to_a.map do |entry|
1715
951
  content_html = ""
1716
952
  if entry.content && !entry.content.empty?
1717
- content_html = capture_output do
1718
- Array(entry.content).each { |p| render_paragraph(p) }
1719
- end
953
+ content_html = Array(entry.content).filter_map do |p|
954
+ render_paragraph(p)
955
+ end.join
1720
956
  end
1721
957
  Drops::FootnoteDrop.new(entry, content_html)
1722
958
  end
1723
959
 
1724
- @output << render_liquid("_footnotes.html.liquid",
1725
- { "footnotes" => drops })
1726
- end
1727
-
1728
- def capture_output
1729
- old_output = @output
1730
- @output = +""
1731
- yield
1732
- result = @output
1733
- @output = old_output
1734
- result
960
+ render_liquid("_footnotes.html.liquid",
961
+ { "footnotes" => drops })
1735
962
  end
1736
963
  end
1737
964
  end