coradoc-html 1.1.7

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 (124) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/lib/coradoc/html/base.rb +157 -0
  4. data/lib/coradoc/html/config.rb +467 -0
  5. data/lib/coradoc/html/converter_base.rb +177 -0
  6. data/lib/coradoc/html/converters/admonition.rb +180 -0
  7. data/lib/coradoc/html/converters/attribute.rb +68 -0
  8. data/lib/coradoc/html/converters/attribute_reference.rb +60 -0
  9. data/lib/coradoc/html/converters/audio.rb +165 -0
  10. data/lib/coradoc/html/converters/base.rb +615 -0
  11. data/lib/coradoc/html/converters/bibliography.rb +82 -0
  12. data/lib/coradoc/html/converters/bibliography_entry.rb +108 -0
  13. data/lib/coradoc/html/converters/block_image.rb +72 -0
  14. data/lib/coradoc/html/converters/bold.rb +34 -0
  15. data/lib/coradoc/html/converters/break.rb +32 -0
  16. data/lib/coradoc/html/converters/comment_block.rb +42 -0
  17. data/lib/coradoc/html/converters/comment_line.rb +54 -0
  18. data/lib/coradoc/html/converters/cross_reference.rb +59 -0
  19. data/lib/coradoc/html/converters/document.rb +108 -0
  20. data/lib/coradoc/html/converters/example.rb +114 -0
  21. data/lib/coradoc/html/converters/highlight.rb +34 -0
  22. data/lib/coradoc/html/converters/include.rb +68 -0
  23. data/lib/coradoc/html/converters/inline_image.rb +41 -0
  24. data/lib/coradoc/html/converters/italic.rb +34 -0
  25. data/lib/coradoc/html/converters/line_break.rb +31 -0
  26. data/lib/coradoc/html/converters/link.rb +46 -0
  27. data/lib/coradoc/html/converters/list_item.rb +75 -0
  28. data/lib/coradoc/html/converters/listing.rb +99 -0
  29. data/lib/coradoc/html/converters/literal.rb +102 -0
  30. data/lib/coradoc/html/converters/monospace.rb +34 -0
  31. data/lib/coradoc/html/converters/open.rb +78 -0
  32. data/lib/coradoc/html/converters/ordered.rb +53 -0
  33. data/lib/coradoc/html/converters/paragraph.rb +46 -0
  34. data/lib/coradoc/html/converters/quote.rb +113 -0
  35. data/lib/coradoc/html/converters/reviewer_comment.rb +74 -0
  36. data/lib/coradoc/html/converters/reviewer_note.rb +134 -0
  37. data/lib/coradoc/html/converters/section.rb +90 -0
  38. data/lib/coradoc/html/converters/sidebar.rb +113 -0
  39. data/lib/coradoc/html/converters/source.rb +137 -0
  40. data/lib/coradoc/html/converters/source_code.rb +16 -0
  41. data/lib/coradoc/html/converters/span.rb +61 -0
  42. data/lib/coradoc/html/converters/strikethrough.rb +34 -0
  43. data/lib/coradoc/html/converters/subscript.rb +34 -0
  44. data/lib/coradoc/html/converters/superscript.rb +34 -0
  45. data/lib/coradoc/html/converters/table.rb +85 -0
  46. data/lib/coradoc/html/converters/table_cell.rb +203 -0
  47. data/lib/coradoc/html/converters/table_row.rb +45 -0
  48. data/lib/coradoc/html/converters/template_html_converter.rb +105 -0
  49. data/lib/coradoc/html/converters/term.rb +58 -0
  50. data/lib/coradoc/html/converters/text_element.rb +44 -0
  51. data/lib/coradoc/html/converters/underline.rb +34 -0
  52. data/lib/coradoc/html/converters/unordered.rb +47 -0
  53. data/lib/coradoc/html/converters/verse.rb +105 -0
  54. data/lib/coradoc/html/converters/video.rb +179 -0
  55. data/lib/coradoc/html/element_mapping.rb +210 -0
  56. data/lib/coradoc/html/entity.rb +137 -0
  57. data/lib/coradoc/html/input/cleaner.rb +163 -0
  58. data/lib/coradoc/html/input/config.rb +79 -0
  59. data/lib/coradoc/html/input/converters/a.rb +90 -0
  60. data/lib/coradoc/html/input/converters/aside.rb +23 -0
  61. data/lib/coradoc/html/input/converters/audio.rb +50 -0
  62. data/lib/coradoc/html/input/converters/base.rb +116 -0
  63. data/lib/coradoc/html/input/converters/blockquote.rb +25 -0
  64. data/lib/coradoc/html/input/converters/br.rb +19 -0
  65. data/lib/coradoc/html/input/converters/bypass.rb +83 -0
  66. data/lib/coradoc/html/input/converters/code.rb +25 -0
  67. data/lib/coradoc/html/input/converters/div.rb +25 -0
  68. data/lib/coradoc/html/input/converters/dl.rb +106 -0
  69. data/lib/coradoc/html/input/converters/drop.rb +28 -0
  70. data/lib/coradoc/html/input/converters/em.rb +23 -0
  71. data/lib/coradoc/html/input/converters/figure.rb +58 -0
  72. data/lib/coradoc/html/input/converters/h.rb +76 -0
  73. data/lib/coradoc/html/input/converters/head.rb +30 -0
  74. data/lib/coradoc/html/input/converters/hr.rb +20 -0
  75. data/lib/coradoc/html/input/converters/ignore.rb +22 -0
  76. data/lib/coradoc/html/input/converters/img.rb +110 -0
  77. data/lib/coradoc/html/input/converters/li.rb +35 -0
  78. data/lib/coradoc/html/input/converters/mark.rb +21 -0
  79. data/lib/coradoc/html/input/converters/markup.rb +107 -0
  80. data/lib/coradoc/html/input/converters/math.rb +46 -0
  81. data/lib/coradoc/html/input/converters/ol.rb +46 -0
  82. data/lib/coradoc/html/input/converters/p.rb +81 -0
  83. data/lib/coradoc/html/input/converters/pass_through.rb +19 -0
  84. data/lib/coradoc/html/input/converters/pre.rb +59 -0
  85. data/lib/coradoc/html/input/converters/q.rb +24 -0
  86. data/lib/coradoc/html/input/converters/strong.rb +22 -0
  87. data/lib/coradoc/html/input/converters/sub.rb +40 -0
  88. data/lib/coradoc/html/input/converters/sup.rb +40 -0
  89. data/lib/coradoc/html/input/converters/table.rb +64 -0
  90. data/lib/coradoc/html/input/converters/td.rb +70 -0
  91. data/lib/coradoc/html/input/converters/text.rb +67 -0
  92. data/lib/coradoc/html/input/converters/th.rb +20 -0
  93. data/lib/coradoc/html/input/converters/tr.rb +28 -0
  94. data/lib/coradoc/html/input/converters/video.rb +53 -0
  95. data/lib/coradoc/html/input/converters.rb +122 -0
  96. data/lib/coradoc/html/input/errors.rb +22 -0
  97. data/lib/coradoc/html/input/html_converter.rb +170 -0
  98. data/lib/coradoc/html/input/plugin.rb +169 -0
  99. data/lib/coradoc/html/input/plugins/plateau.rb +229 -0
  100. data/lib/coradoc/html/input/postprocessor.rb +31 -0
  101. data/lib/coradoc/html/input.rb +68 -0
  102. data/lib/coradoc/html/output.rb +95 -0
  103. data/lib/coradoc/html/renderer.rb +409 -0
  104. data/lib/coradoc/html/spa.rb +309 -0
  105. data/lib/coradoc/html/static.rb +293 -0
  106. data/lib/coradoc/html/template_config.rb +151 -0
  107. data/lib/coradoc/html/template_helpers.rb +58 -0
  108. data/lib/coradoc/html/template_locator.rb +114 -0
  109. data/lib/coradoc/html/theme/base.rb +231 -0
  110. data/lib/coradoc/html/theme/classic_renderer.rb +390 -0
  111. data/lib/coradoc/html/theme/modern/components/ui_components.rb +344 -0
  112. data/lib/coradoc/html/theme/modern/css_generator.rb +311 -0
  113. data/lib/coradoc/html/theme/modern/javascript_generator.rb +314 -0
  114. data/lib/coradoc/html/theme/modern/serializers/document_serializer.rb +382 -0
  115. data/lib/coradoc/html/theme/modern/tailwind_config_builder.rb +164 -0
  116. data/lib/coradoc/html/theme/modern/vue_template_generator.rb +374 -0
  117. data/lib/coradoc/html/theme/modern_renderer.rb +250 -0
  118. data/lib/coradoc/html/theme/registry.rb +153 -0
  119. data/lib/coradoc/html/theme.rb +13 -0
  120. data/lib/coradoc/html/transform/from_core_model.rb +32 -0
  121. data/lib/coradoc/html/transform/to_core_model.rb +39 -0
  122. data/lib/coradoc/html/version.rb +7 -0
  123. data/lib/coradoc/html.rb +255 -0
  124. metadata +264 -0
@@ -0,0 +1,615 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Html
5
+ module Converters
6
+ # Base class for HTML output converters
7
+ #
8
+ # This class handles ONLY CoreModel types for HTML output.
9
+ # Source models should be transformed to CoreModel before HTML conversion:
10
+ #
11
+ # core_model = Coradoc::Transform::SourceToCoreModel.transform(source_model)
12
+ # html = Coradoc::Html::Static.convert(core_model)
13
+ #
14
+ class Base
15
+ class << self
16
+ # Convert CoreModel to HTML
17
+ # @param model [Coradoc::CoreModel::Base] CoreModel to convert
18
+ # @param state [Hash] Conversion state
19
+ # @return [String] HTML string
20
+ def to_html(model, state = {})
21
+ raise NotImplementedError, "#{self}.to_html must be implemented"
22
+ end
23
+
24
+ # Convert content to HTML (CoreModel → HTML)
25
+ # @param content [various] Content to convert
26
+ # @param state [Hash] Conversion state
27
+ # @return [String] HTML string
28
+ def convert_content_to_html(content, state = {})
29
+ return '' if content.nil?
30
+
31
+ # Handle primitives first
32
+ case content
33
+ when String
34
+ return escape_html(content)
35
+ when Array
36
+ return content.map { |item| convert_content_to_html(item, state) }.join
37
+ when Numeric
38
+ return escape_html(content.to_s)
39
+ when TrueClass, FalseClass
40
+ return escape_html(content.to_s)
41
+ end
42
+
43
+ # Handle CoreModel types
44
+ # NOTE: AnnotationBlock must be checked before Block since AnnotationBlock < Block.
45
+ # We use is_a? directly instead of defined?() because CoreModel uses autoload.
46
+ # The defined?() check doesn't trigger autoload, so it returns nil even when
47
+ # the class is available via autoload. Using is_a? triggers the autoload.
48
+ return render_core_inline_element(content, state) if content.is_a?(Coradoc::CoreModel::InlineElement)
49
+
50
+ return render_core_annotation_block(content, state) if content.is_a?(Coradoc::CoreModel::AnnotationBlock)
51
+
52
+ return render_core_block(content, state) if content.is_a?(Coradoc::CoreModel::Block)
53
+
54
+ if content.is_a?(Coradoc::CoreModel::StructuralElement)
55
+ # Use Section converter for sections
56
+ return Coradoc::Html::Converters::Section.to_html(content, state) if content.section?
57
+
58
+ return render_core_structural_element(content, state)
59
+ end
60
+
61
+ return render_core_list_block(content, state) if content.is_a?(Coradoc::CoreModel::ListBlock)
62
+
63
+ return render_core_list_item(content, state) if content.is_a?(Coradoc::CoreModel::ListItem)
64
+
65
+ return Coradoc::Html::Converters::Table.to_html(content, state) if content.is_a?(Coradoc::CoreModel::Table)
66
+
67
+ return render_core_table_row(content, state) if content.is_a?(Coradoc::CoreModel::TableRow)
68
+
69
+ return render_core_table_cell(content, state) if content.is_a?(Coradoc::CoreModel::TableCell)
70
+
71
+ return render_core_term(content, state) if content.is_a?(Coradoc::CoreModel::Term)
72
+
73
+ if content.is_a?(Coradoc::CoreModel::Image)
74
+ return render_core_inline_image(content, state) if content.inline
75
+
76
+ return render_core_block_image(content, state)
77
+
78
+ end
79
+
80
+ return render_core_footnote(content, state) if content.is_a?(Coradoc::CoreModel::Footnote)
81
+
82
+ if content.is_a?(Coradoc::CoreModel::FootnoteReference)
83
+ return render_core_footnote_reference(content,
84
+ state)
85
+ end
86
+
87
+ return render_core_abbreviation(content, state) if content.is_a?(Coradoc::CoreModel::Abbreviation)
88
+
89
+ return render_core_definition_list(content, state) if content.is_a?(Coradoc::CoreModel::DefinitionList)
90
+
91
+ return render_core_definition_item(content, state) if content.is_a?(Coradoc::CoreModel::DefinitionItem)
92
+
93
+ return render_core_toc(content, state) if content.is_a?(Coradoc::CoreModel::Toc)
94
+
95
+ return render_core_toc_entry(content, state) if content.is_a?(Coradoc::CoreModel::TocEntry)
96
+
97
+ return render_core_bibliography(content, state) if content.is_a?(Coradoc::CoreModel::Bibliography)
98
+
99
+ if content.is_a?(Coradoc::CoreModel::BibliographyEntry)
100
+ return render_core_bibliography_entry(content,
101
+ state)
102
+ end
103
+
104
+ # Handle unknown types gracefully
105
+ handle_unknown_content(content, state)
106
+ end
107
+
108
+ # === CoreModel rendering methods ===
109
+
110
+ # Render CoreModel inline element
111
+ def render_core_inline_element(element, state = {})
112
+ case element.format_type
113
+ when 'bold'
114
+ "<strong>#{convert_content_to_html(element.content, state)}</strong>"
115
+ when 'italic'
116
+ "<em>#{convert_content_to_html(element.content, state)}</em>"
117
+ when 'monospace'
118
+ "<code>#{convert_content_to_html(element.content, state)}</code>"
119
+ when 'superscript'
120
+ "<sup>#{convert_content_to_html(element.content, state)}</sup>"
121
+ when 'subscript'
122
+ "<sub>#{convert_content_to_html(element.content, state)}</sub>"
123
+ when 'underline'
124
+ "<u>#{convert_content_to_html(element.content, state)}</u>"
125
+ when 'strikethrough'
126
+ "<del>#{convert_content_to_html(element.content, state)}</del>"
127
+ when 'highlight'
128
+ "<mark>#{convert_content_to_html(element.content, state)}</mark>"
129
+ when 'link'
130
+ href = element.target || element.metadata&.dig(:href) || '#'
131
+ "<a href=\"#{escape_attribute(href)}\">#{convert_content_to_html(element.content, state)}</a>"
132
+ when 'xref'
133
+ href = element.target || element.metadata&.dig(:href) || '#'
134
+ "<a href=\"##{escape_attribute(href)}\">#{convert_content_to_html(element.content, state)}</a>"
135
+ when 'footnote'
136
+ footnote_id = element.target || element.metadata&.dig(:id) || ''
137
+ "<sup class=\"footnote\" id=\"fn-#{escape_attribute(footnote_id)}\">#{convert_content_to_html(
138
+ element.content, state
139
+ )}</sup>"
140
+ when 'stem'
141
+ "<code class=\"stem\">#{escape_html(element.content)}</code>"
142
+ when 'term'
143
+ # Term reference: term:[text] or term:[text,display]
144
+ %(<span class="term" data-term-ref="#{escape_attribute(element.content)}">#{escape_html(element.content)}</span>)
145
+ when 'break'
146
+ break_type = element.metadata&.dig(:break_type) || 'thematic'
147
+ break_type == 'thematic' ? '<hr>' : '<br>'
148
+ when 'quotation'
149
+ "<q>#{convert_content_to_html(element.content, state)}</q>"
150
+ when 'small'
151
+ "<small>#{convert_content_to_html(element.content, state)}</small>"
152
+ when 'span'
153
+ render_core_span(element, state)
154
+ else
155
+ convert_content_to_html(element.content, state)
156
+ end
157
+ end
158
+
159
+ # Render CoreModel span
160
+ def render_core_span(element, state = {})
161
+ attrs = build_class_attribute(element.metadata&.dig(:class))
162
+ "<span#{attrs}>#{convert_content_to_html(element.content, state)}</span>"
163
+ end
164
+
165
+ # Render CoreModel block
166
+ def render_core_block(block, state = {})
167
+ attrs = build_html_attributes(block.id, block.title)
168
+
169
+ # Get renderable content (children if present, otherwise content)
170
+ renderable = block.renderable_content
171
+
172
+ semantic = resolve_block_semantic_type(block)
173
+
174
+ case semantic
175
+ when :paragraph
176
+ content = convert_content_to_html(renderable, state)
177
+ return "<p#{attrs}>#{content}</p>" if content && !content.empty?
178
+
179
+ ''
180
+ when :source_code
181
+ lang = block.language || block.metadata&.dig(:language)
182
+ lang_attr = lang ? " data-lang=\"#{escape_attribute(lang)}\"" : ''
183
+ "<pre#{attrs}><code#{lang_attr}>#{escape_html(block.flat_text)}</code></pre>"
184
+ when :quote, :verse
185
+ "<blockquote#{attrs}>#{convert_content_to_html(renderable, state)}</blockquote>"
186
+ when :example
187
+ "<div class=\"example\"#{attrs}>#{convert_content_to_html(renderable, state)}</div>"
188
+ when :sidebar
189
+ "<aside class=\"sidebar\"#{attrs}>#{convert_content_to_html(renderable, state)}</aside>"
190
+ when :literal
191
+ "<pre class=\"literal\"#{attrs}>#{escape_html(block.flat_text)}</pre>"
192
+ when :pass
193
+ block.flat_text
194
+ when :listing
195
+ "<pre#{attrs}>#{escape_html(block.flat_text)}</pre>"
196
+ when :open
197
+ "<div#{attrs}>#{convert_content_to_html(renderable, state)}</div>"
198
+ when :verse
199
+ "<blockquote#{attrs}>#{convert_content_to_html(renderable, state)}</blockquote>"
200
+ when :comment, :reviewer
201
+ ''
202
+ when :horizontal_rule
203
+ "<hr#{attrs}>"
204
+ else
205
+ "<div#{attrs}>#{convert_content_to_html(renderable, state)}</div>"
206
+ end
207
+ end
208
+
209
+ # Resolve the semantic type from a block via polymorphic dispatch.
210
+ # Block#resolve_semantic_type handles class-level semantic_type →
211
+ # block_semantic_type attribute → element_type → delimiter fallback.
212
+ def resolve_block_semantic_type(block)
213
+ block.resolve_semantic_type ||
214
+ resolve_format_specific_semantic(block)
215
+ end
216
+
217
+ # Format-specific semantic mappings not covered by the core model
218
+ def resolve_format_specific_semantic(block)
219
+ delim = block.delimiter_type
220
+ return nil unless delim && !delim.empty?
221
+
222
+ case delim
223
+ when "'''", '---', '___', '***' then :horizontal_rule
224
+ when '[verse]' then :verse
225
+ when 'comment' then :comment
226
+ when 'paragraph' then :paragraph
227
+ end
228
+ end
229
+
230
+ # Render CoreModel structural element
231
+ def render_core_structural_element(element, state = {})
232
+ attrs = build_html_attributes(element.id, nil)
233
+
234
+ children_html = (element.children || []).map { |c| convert_content_to_html(c, state) }.join
235
+ case element.element_type
236
+ when 'document'
237
+ "<article#{attrs}>#{children_html}</article>"
238
+ when 'header'
239
+ "<header#{attrs}>#{children_html}</header>"
240
+ when 'section'
241
+ level = element.heading_level
242
+ level = [level, 6].min
243
+ title_html = element.title ? "<h#{level}>#{escape_html(element.title)}</h#{level}>" : ''
244
+ "<section#{attrs}>#{title_html}#{children_html}</section>"
245
+ else
246
+ "<div#{attrs}>#{children_html}</div>"
247
+ end
248
+ end
249
+
250
+ # Render CoreModel list block
251
+ def render_core_list_block(list, state = {})
252
+ attrs = build_html_attributes(list.id, list.title)
253
+
254
+ items_html = (list.items || []).map { |i| convert_content_to_html(i, state) }.join
255
+ case list.marker_type
256
+ when 'unordered'
257
+ "<ul#{attrs}>#{items_html}</ul>"
258
+ when 'ordered'
259
+ "<ol#{attrs}>#{items_html}</ol>"
260
+ when 'definition'
261
+ "<dl#{attrs}>#{items_html}</dl>"
262
+ else
263
+ "<ul#{attrs}>#{items_html}</ul>"
264
+ end
265
+ end
266
+
267
+ # Render CoreModel list item
268
+ def render_core_list_item(item, state = {})
269
+ # Use renderable_content to get children if present
270
+ renderable = item.renderable_content
271
+ content = convert_content_to_html(renderable, state)
272
+
273
+ # Handle nested list
274
+ content += convert_content_to_html(item.nested_list, state) if item.nested_list
275
+
276
+ "<li>#{content}</li>"
277
+ end
278
+
279
+ # Render CoreModel annotation block (admonition)
280
+ def render_core_annotation_block(block, state = {})
281
+ attrs = build_html_attributes(block.id, block.title)
282
+ type_class = block.annotation_type ? " #{escape_html(block.annotation_type)}" : ''
283
+ label = block.annotation_label || block.annotation_type&.upcase
284
+
285
+ html = "<div class=\"admonition#{type_class}\"#{attrs}>"
286
+ html += "<div class=\"admonition-label\">#{escape_html(label)}</div>" if label
287
+ renderable = block.renderable_content
288
+ html += convert_content_to_html(renderable, state)
289
+ html += '</div>'
290
+ html
291
+ end
292
+
293
+ # Render CoreModel table row
294
+ def render_core_table_row(row, state = {})
295
+ cells = row.cells || row.columns || []
296
+ cells_html = cells.map { |c| convert_content_to_html(c, state) }.join
297
+ tag = row.header ? 'thead' : 'tr'
298
+ "<#{tag}>#{cells_html}</#{tag}>"
299
+ end
300
+
301
+ # Render CoreModel table cell
302
+ def render_core_table_cell(cell, state = {})
303
+ tag = cell.header ? 'th' : 'td'
304
+ attrs = ''
305
+ attrs += " colspan=\"#{cell.colspan}\"" if cell.colspan
306
+ attrs += " rowspan=\"#{cell.rowspan}\"" if cell.rowspan
307
+ attrs += " style=\"text-align: #{escape_html(cell.alignment)}\"" if cell.alignment
308
+
309
+ # Use renderable_content to get children if present, otherwise content
310
+ renderable = cell.renderable_content
311
+ content = convert_content_to_html(renderable, state)
312
+ "<#{tag}#{attrs}>#{content}</#{tag}>"
313
+ end
314
+
315
+ # Render CoreModel term
316
+ def render_core_term(term, _state = {})
317
+ term_text = term.text || ''
318
+ term_type = term.term_type || term.type || 'term'
319
+ display_text = term.render_text&.strip&.empty? ? false : term.render_text
320
+ display_text ||= term_text
321
+
322
+ %(<span class="term term-#{escape_attribute(term_type)}" data-term-ref="#{escape_attribute(term_text)}">#{escape_html(display_text)}</span>)
323
+ end
324
+
325
+ # Render CoreModel inline image
326
+ def render_core_inline_image(image, _state = {})
327
+ attrs = "src=\"#{escape_attribute(image.src)}\""
328
+ attrs += " alt=\"#{escape_attribute(image.alt)}\"" if image.alt
329
+ attrs += " width=\"#{escape_attribute(image.width)}\"" if image.width
330
+ attrs += " height=\"#{escape_attribute(image.height)}\"" if image.height
331
+
332
+ %(<img #{attrs}>)
333
+ end
334
+
335
+ # Render CoreModel block image
336
+ def render_core_block_image(image, _state = {})
337
+ attrs = build_html_attributes(image.id, nil)
338
+ img_attrs = "src=\"#{escape_attribute(image.src)}\""
339
+ img_attrs += " alt=\"#{escape_attribute(image.alt)}\"" if image.alt
340
+ img_attrs += " width=\"#{escape_attribute(image.width)}\"" if image.width
341
+ img_attrs += " height=\"#{escape_attribute(image.height)}\"" if image.height
342
+
343
+ html = "<figure#{attrs}>"
344
+ html += %(<img #{img_attrs}>)
345
+ html += "<figcaption>#{escape_html(image.caption)}</figcaption>" if image.caption
346
+ html += '</figure>'
347
+ html
348
+ end
349
+
350
+ # Render CoreModel footnote
351
+ def render_core_footnote(footnote, state = {})
352
+ footnote_id = footnote.id || ''
353
+ content = footnote.content || footnote.inline_content
354
+
355
+ if footnote_id.empty?
356
+ # Anonymous footnote
357
+ text = content.is_a?(Array) ? content.join : content.to_s
358
+ title_text = text[0..50]
359
+ %(<sup class="footnote" title="#{escape_attribute(title_text)}">#{convert_content_to_html(content,
360
+ state)}</sup>)
361
+ else
362
+ # Named footnote reference
363
+ %(<sup class="footnote"><a href="#fn-#{escape_attribute(footnote_id)}" id="fnref-#{escape_attribute(footnote_id)}">#{escape_html(footnote_id)}</a></sup>)
364
+ end
365
+ end
366
+
367
+ # Render CoreModel footnote reference
368
+ def render_core_footnote_reference(ref, _state = {})
369
+ footnote_id = ref.id || ''
370
+ %(<sup class="footnote"><a href="#fn-#{escape_attribute(footnote_id)}">[#{escape_html(footnote_id)}]</a></sup>)
371
+ end
372
+
373
+ # Render CoreModel abbreviation
374
+ def render_core_abbreviation(abbr, _state = {})
375
+ term = abbr.term || ''
376
+ definition = abbr.definition || ''
377
+ %(<abbr title="#{escape_attribute(definition)}">#{escape_html(term)}</abbr>)
378
+ end
379
+
380
+ # Render CoreModel definition list
381
+ def render_core_definition_list(dl, state = {})
382
+ attrs = build_html_attributes(dl.id, dl.title)
383
+ items_html = (dl.items || []).map { |i| convert_content_to_html(i, state) }.join
384
+ "<dl#{attrs}>#{items_html}</dl>"
385
+ end
386
+
387
+ # Render CoreModel definition item
388
+ def render_core_definition_item(item, state = {})
389
+ term_html = convert_content_to_html(item.term, state)
390
+ definitions_html = (item.definitions || []).map { |d| "<dd>#{convert_content_to_html(d, state)}</dd>" }.join
391
+ "<dt>#{term_html}</dt>#{definitions_html}"
392
+ end
393
+
394
+ # Render CoreModel TOC
395
+ def render_core_toc(toc, state = {})
396
+ attrs = build_html_attributes(nil, nil)
397
+ attrs += ' class="toc"'
398
+ entries_html = (toc.entries || []).map { |e| convert_content_to_html(e, state) }.join
399
+ "<nav#{attrs}><h2>Table of Contents</h2><ul>#{entries_html}</ul></nav>"
400
+ end
401
+
402
+ # Render CoreModel TOC entry
403
+ def render_core_toc_entry(entry, state = {})
404
+ id = entry.id || ''
405
+ title = entry.title || ''
406
+ entry.level || 1
407
+ number = entry.number
408
+ display_title = number ? "#{number}. #{title}" : title
409
+
410
+ item_html = if id.empty?
411
+ escape_html(display_title)
412
+ else
413
+ %(<a href="##{escape_attribute(id)}">#{escape_html(display_title)}</a>)
414
+ end
415
+
416
+ children_html = (entry.children || []).map { |c| convert_content_to_html(c, state) }.join
417
+ children_html = "<ul>#{children_html}</ul>" unless children_html.empty?
418
+
419
+ "<li>#{item_html}#{children_html}</li>"
420
+ end
421
+
422
+ def render_core_bibliography(bib, state = {})
423
+ attrs = %( class="bibliography")
424
+ attrs += %( id="#{escape_attribute(bib.id)}") if bib.id
425
+
426
+ title_html = (%(<h2 class="bibliography-title">#{escape_html(bib.title)}</h2>) if bib.title && !bib.title.to_s.empty?)
427
+
428
+ entries_html = Array(bib.entries).map { |e| convert_content_to_html(e, state) }.join("\n")
429
+
430
+ inner = ''
431
+ inner += "#{title_html}\n" if title_html
432
+ inner += "<div class=\"bibliography-entries\">\n#{entries_html}\n</div>" unless entries_html.empty?
433
+
434
+ "<section#{attrs}>\n#{inner}\n</section>"
435
+ end
436
+
437
+ def render_core_bibliography_entry(entry, _state = {})
438
+ entry_id = entry.anchor_name || entry.document_id
439
+ anchor_html = entry_id ? %(<a id="#{escape_attribute(entry_id)}" class="bibliography-anchor"></a>) : ''
440
+ label = entry.document_id || ''
441
+ ref_text = entry.ref_text || ''
442
+ label_html = label.empty? ? '' : %(<span class="bibliography-label">#{escape_html(label)}</span> )
443
+
444
+ "<div class=\"bibliography-entry\">#{anchor_html}#{label_html}#{escape_html(ref_text)}</div>"
445
+ end
446
+
447
+ # Handle unknown content types
448
+ def handle_unknown_content(content, _state = {})
449
+ if content.is_a?(Coradoc::CoreModel::Base)
450
+ raise ArgumentError,
451
+ "Unknown CoreModel type for HTML conversion: #{content.class}. " \
452
+ 'Expected a recognized CoreModel type.'
453
+ end
454
+
455
+ # Handle non-CoreModel types (strings from mixed content, etc.)
456
+ escape_html(content.to_s)
457
+ end
458
+
459
+ # Extract text from unknown model types as a fallback
460
+ def extract_text_fallback(content)
461
+ if content.is_a?(Coradoc::CoreModel::Base)
462
+ if content.class.attributes.key?(:text) && content.text
463
+ text_val = content.text
464
+ return text_val if text_val.is_a?(String)
465
+
466
+ return text_val.to_s
467
+ end
468
+
469
+ if content.class.attributes.key?(:content) && content.content
470
+ content_val = content.content
471
+ if content_val.is_a?(String)
472
+ return content_val
473
+ elsif content_val.is_a?(Array)
474
+ return content_val.map { |item| convert_content_to_html(item, {}) }.join
475
+ end
476
+ end
477
+
478
+ return content.href.to_s if content.class.attributes.key?(:href) && content.href
479
+ return content.term.to_s if content.class.attributes.key?(:term) && content.term
480
+ return content.id.to_s if content.class.attributes.key?(:id) && content.id
481
+ return content.name.to_s if content.class.attributes.key?(:name) && content.name
482
+ end
483
+
484
+ ''
485
+ end
486
+
487
+ # === Helper methods ===
488
+
489
+ # Build HTML attributes string
490
+ def build_html_attributes(id, title)
491
+ attrs = ''
492
+ attrs += " id=\"#{escape_attribute(id)}\"" if id && !id.to_s.empty?
493
+ attrs += " title=\"#{escape_attribute(title)}\"" if title && !title.to_s.empty?
494
+ attrs
495
+ end
496
+
497
+ # Build class attribute
498
+ def build_class_attribute(class_name)
499
+ class_name ? " class=\"#{escape_attribute(class_name)}\"" : ''
500
+ end
501
+
502
+ # Find converter for model class
503
+ def find_converter_for_model(model_class)
504
+ Coradoc::Html::Base.find_converter(model_class)
505
+ end
506
+
507
+ # Type-safe lookup of converter class by name
508
+ def find_converter_class_by_name(converter_name)
509
+ klass = Coradoc::Html::Converters.const_get(converter_name, false)
510
+ return klass if klass <= Coradoc::Html::Converters::Base
511
+
512
+ nil
513
+ rescue NameError
514
+ nil
515
+ end
516
+
517
+ # Escape HTML entities
518
+ def escape_html(text)
519
+ Coradoc::Html::Base.escape_html(text)
520
+ end
521
+
522
+ # Escape HTML attribute values
523
+ def escape_attribute(value)
524
+ return '' if value.nil?
525
+
526
+ value.to_s.gsub(/&/, '&amp;').gsub(/"/, '&quot;').gsub(/</, '&lt;').gsub(/>/, '&gt;')
527
+ end
528
+
529
+ # Build HTML element
530
+ def build_element(tag, content = nil, attributes = {})
531
+ Coradoc::Html::Base.build_element(tag, content, attributes)
532
+ end
533
+
534
+ # Extract attributes from a CoreModel
535
+ # @param model [Coradoc::CoreModel::Base] Model to extract attributes from
536
+ # @return [Hash] Attributes hash
537
+ def extract_model_attributes(model)
538
+ Coradoc::Html::Base.extract_attributes(model)
539
+ end
540
+
541
+ # === HTML Input Direction (HTML → CoreModel) ===
542
+
543
+ # Process children of an HTML node
544
+ # @param node [Nokogiri::XML::Node] Parent node
545
+ # @param state [Hash] Conversion state
546
+ # @return [Array] Array of converted content
547
+ def treat_children(node, state = {})
548
+ return [] unless node&.children
549
+
550
+ node.children.flat_map do |child|
551
+ convert_node_to_core(child, state)
552
+ end.compact
553
+ end
554
+
555
+ # Convert HTML node to CoreModel
556
+ # @param node [Nokogiri::XML::Node] Node to convert
557
+ # @param state [Hash] Conversion state
558
+ # @return [Coradoc::CoreModel::Base, String, nil]
559
+ def convert_node_to_core(node, state = {})
560
+ case node.type
561
+ when Nokogiri::XML::Node::TEXT_NODE
562
+ text = node.text
563
+ return nil if text.strip.empty? && !state[:preserve_whitespace]
564
+
565
+ text
566
+ when Nokogiri::XML::Node::ELEMENT_NODE
567
+ convert_element_to_core(node, state)
568
+ when Nokogiri::XML::Node::COMMENT_NODE
569
+ nil # Skip comments
570
+ end
571
+ end
572
+
573
+ # Convert HTML element to CoreModel
574
+ # @param node [Nokogiri::XML::Node] Element node
575
+ # @param state [Hash] Conversion state
576
+ # @return [Coradoc::CoreModel::Base, Array, nil]
577
+ def convert_element_to_core(node, state = {})
578
+ # Delegate to Html::Input::Converters for HTML input
579
+ # This maintains separation between input and output converters
580
+ if defined?(Coradoc::Html::Input::Converters)
581
+ converter = Coradoc::Html::Input::Converters.lookup(node.name)
582
+ if converter
583
+ result = converter.to_coradoc(node, state)
584
+ # Transform to CoreModel if needed
585
+ return transform_to_coremodel(result) if result
586
+ end
587
+ end
588
+
589
+ # Fallback: treat children
590
+ treat_children(node, state)
591
+ end
592
+
593
+ # Transform model to CoreModel
594
+ # @param model [Object] Model to transform
595
+ # @return [Coradoc::CoreModel::Base, Object]
596
+ def transform_to_coremodel(model)
597
+ # Already a CoreModel type - return as-is
598
+ model
599
+ end
600
+
601
+ # Extract attributes from HTML node
602
+ # @param node [Nokogiri::XML::Node] HTML node
603
+ # @return [Hash] Attributes hash
604
+ def extract_node_attributes(node)
605
+ return {} unless node.is_a?(Nokogiri::XML::Node)
606
+
607
+ node.attributes.each_with_object({}) do |(name, attr), hash|
608
+ hash[name.to_sym] = attr.value
609
+ end
610
+ end
611
+ end
612
+ end
613
+ end
614
+ end
615
+ end