docbook 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CLAUDE.md +19 -0
  4. data/CODE_OF_CONDUCT.md +10 -0
  5. data/README.adoc +335 -0
  6. data/Rakefile +12 -0
  7. data/docs/.lycheeignore +33 -0
  8. data/docs/Gemfile +10 -0
  9. data/docs/INDEX.adoc +67 -0
  10. data/docs/_config.yml +186 -0
  11. data/docs/advanced/element-classes.adoc +185 -0
  12. data/docs/advanced/frontend-customization.adoc +193 -0
  13. data/docs/advanced/index.adoc +14 -0
  14. data/docs/advanced/templates.adoc +123 -0
  15. data/docs/features/element-coverage.adoc +373 -0
  16. data/docs/features/html-output/data-model.adoc +285 -0
  17. data/docs/features/html-output/directory-mode.adoc +180 -0
  18. data/docs/features/html-output/index.adoc +90 -0
  19. data/docs/features/html-output/single-file-mode.adoc +125 -0
  20. data/docs/features/index-generation.adoc +197 -0
  21. data/docs/features/index.adoc +63 -0
  22. data/docs/features/numbering.adoc +183 -0
  23. data/docs/features/toc-generation.adoc +150 -0
  24. data/docs/features/xinclude/fragid-schemes.adoc +287 -0
  25. data/docs/features/xinclude/index.adoc +119 -0
  26. data/docs/features/xinclude/text-includes.adoc +123 -0
  27. data/docs/features/xinclude/xml-includes.adoc +167 -0
  28. data/docs/getting-started/index.adoc +50 -0
  29. data/docs/getting-started/installation.adoc +113 -0
  30. data/docs/getting-started/quick-start.adoc +161 -0
  31. data/docs/guides/converting-article.adoc +188 -0
  32. data/docs/guides/converting-book.adoc +192 -0
  33. data/docs/guides/index.adoc +12 -0
  34. data/docs/guides/roundtrip-testing.adoc +129 -0
  35. data/docs/interfaces/cli/format-command.adoc +109 -0
  36. data/docs/interfaces/cli/index.adoc +73 -0
  37. data/docs/interfaces/cli/roundtrip-command.adoc +125 -0
  38. data/docs/interfaces/cli/to-html-command.adoc +178 -0
  39. data/docs/interfaces/cli/validate-command.adoc +104 -0
  40. data/docs/interfaces/index.adoc +101 -0
  41. data/docs/interfaces/ruby-api/html-output.adoc +186 -0
  42. data/docs/interfaces/ruby-api/index.adoc +111 -0
  43. data/docs/interfaces/ruby-api/parsing.adoc +202 -0
  44. data/docs/interfaces/ruby-api/xinclude.adoc +162 -0
  45. data/docs/interfaces/ruby-api/xref-resolution.adoc +156 -0
  46. data/docs/lychee.toml +42 -0
  47. data/docs/reference/cli-options.adoc +155 -0
  48. data/docs/reference/content-block-types.adoc +243 -0
  49. data/docs/reference/glossary.adoc +119 -0
  50. data/docs/reference/index.adoc +12 -0
  51. data/docs/reference/supported-elements.adoc +749 -0
  52. data/docs/understanding/architecture.adoc +145 -0
  53. data/docs/understanding/content-pipeline.adoc +102 -0
  54. data/docs/understanding/data-models.adoc +156 -0
  55. data/docs/understanding/index.adoc +34 -0
  56. data/exe/docbook +7 -0
  57. data/frontend/dist/app.css +1 -0
  58. data/frontend/dist/app.iife.js +24 -0
  59. data/frontend/package-lock.json +1445 -0
  60. data/frontend/package.json +22 -0
  61. data/frontend/src/App.vue +230 -0
  62. data/frontend/src/app.ts +8 -0
  63. data/frontend/src/components/AppSidebar.vue +116 -0
  64. data/frontend/src/components/AppendixSection.vue +39 -0
  65. data/frontend/src/components/BlockRenderer.vue +358 -0
  66. data/frontend/src/components/ChapterSection.vue +32 -0
  67. data/frontend/src/components/ContentRenderer.vue +13 -0
  68. data/frontend/src/components/EbookContainer.vue +147 -0
  69. data/frontend/src/components/EbookTopBar.vue +116 -0
  70. data/frontend/src/components/PartSection.vue +44 -0
  71. data/frontend/src/components/ReferenceEntry.vue +80 -0
  72. data/frontend/src/components/SearchModal.vue +286 -0
  73. data/frontend/src/components/SectionContent.vue +31 -0
  74. data/frontend/src/components/SettingsPanel.vue +236 -0
  75. data/frontend/src/components/TocTreeItem.vue +135 -0
  76. data/frontend/src/composables/useEbookStore.ts +191 -0
  77. data/frontend/src/composables/useSearch.ts +249 -0
  78. data/frontend/src/env.d.ts +7 -0
  79. data/frontend/src/stores/documentStore.ts +221 -0
  80. data/frontend/src/stores/uiStore.ts +98 -0
  81. data/frontend/src/styles.css +253 -0
  82. data/frontend/tsconfig.json +24 -0
  83. data/frontend/tsconfig.node.json +11 -0
  84. data/frontend/vite.config.ts +30 -0
  85. data/lib/docbook/cli.rb +123 -0
  86. data/lib/docbook/document.rb +67 -0
  87. data/lib/docbook/elements/abbrev.rb +16 -0
  88. data/lib/docbook/elements/acknowledgements.rb +22 -0
  89. data/lib/docbook/elements/address.rb +18 -0
  90. data/lib/docbook/elements/alt.rb +16 -0
  91. data/lib/docbook/elements/annotation.rb +18 -0
  92. data/lib/docbook/elements/appendix.rb +34 -0
  93. data/lib/docbook/elements/article.rb +31 -0
  94. data/lib/docbook/elements/att.rb +15 -0
  95. data/lib/docbook/elements/attribution.rb +20 -0
  96. data/lib/docbook/elements/audioobject.rb +18 -0
  97. data/lib/docbook/elements/author.rb +18 -0
  98. data/lib/docbook/elements/bibliography.rb +24 -0
  99. data/lib/docbook/elements/bibliomixed.rb +40 -0
  100. data/lib/docbook/elements/biblioref.rb +20 -0
  101. data/lib/docbook/elements/blockquote.rb +26 -0
  102. data/lib/docbook/elements/book.rb +36 -0
  103. data/lib/docbook/elements/buildtarget.rb +16 -0
  104. data/lib/docbook/elements/callout.rb +22 -0
  105. data/lib/docbook/elements/calloutlist.rb +22 -0
  106. data/lib/docbook/elements/caution.rb +30 -0
  107. data/lib/docbook/elements/chapter.rb +62 -0
  108. data/lib/docbook/elements/citation.rb +16 -0
  109. data/lib/docbook/elements/citerefentry.rb +26 -0
  110. data/lib/docbook/elements/citetitle.rb +20 -0
  111. data/lib/docbook/elements/classname.rb +16 -0
  112. data/lib/docbook/elements/code.rb +16 -0
  113. data/lib/docbook/elements/colophon.rb +22 -0
  114. data/lib/docbook/elements/computeroutput.rb +18 -0
  115. data/lib/docbook/elements/copyright.rb +17 -0
  116. data/lib/docbook/elements/danger.rb +28 -0
  117. data/lib/docbook/elements/date.rb +16 -0
  118. data/lib/docbook/elements/dedication.rb +22 -0
  119. data/lib/docbook/elements/dir.rb +16 -0
  120. data/lib/docbook/elements/emphasis.rb +18 -0
  121. data/lib/docbook/elements/entry.rb +30 -0
  122. data/lib/docbook/elements/entrytbl.rb +22 -0
  123. data/lib/docbook/elements/equation.rb +26 -0
  124. data/lib/docbook/elements/example.rb +30 -0
  125. data/lib/docbook/elements/fieldsynopsis.rb +21 -0
  126. data/lib/docbook/elements/figure.rb +28 -0
  127. data/lib/docbook/elements/filename.rb +16 -0
  128. data/lib/docbook/elements/firstname.rb +16 -0
  129. data/lib/docbook/elements/firstterm.rb +18 -0
  130. data/lib/docbook/elements/footnote.rb +22 -0
  131. data/lib/docbook/elements/footnoteref.rb +21 -0
  132. data/lib/docbook/elements/foreignphrase.rb +18 -0
  133. data/lib/docbook/elements/formalpara.rb +20 -0
  134. data/lib/docbook/elements/function.rb +16 -0
  135. data/lib/docbook/elements/glossary.rb +24 -0
  136. data/lib/docbook/elements/glossdef.rb +18 -0
  137. data/lib/docbook/elements/glossentry.rb +26 -0
  138. data/lib/docbook/elements/glosssee.rb +18 -0
  139. data/lib/docbook/elements/glossseealso.rb +18 -0
  140. data/lib/docbook/elements/glossterm.rb +18 -0
  141. data/lib/docbook/elements/holder.rb +16 -0
  142. data/lib/docbook/elements/honorific.rb +16 -0
  143. data/lib/docbook/elements/imagedata.rb +29 -0
  144. data/lib/docbook/elements/imageobject.rb +22 -0
  145. data/lib/docbook/elements/important.rb +30 -0
  146. data/lib/docbook/elements/index.rb +26 -0
  147. data/lib/docbook/elements/indexdiv.rb +22 -0
  148. data/lib/docbook/elements/indexentry.rb +22 -0
  149. data/lib/docbook/elements/indexterm.rb +34 -0
  150. data/lib/docbook/elements/info.rb +25 -0
  151. data/lib/docbook/elements/informalexample.rb +24 -0
  152. data/lib/docbook/elements/informalfigure.rb +20 -0
  153. data/lib/docbook/elements/inlinemediaobject.rb +22 -0
  154. data/lib/docbook/elements/itemizedlist.rb +21 -0
  155. data/lib/docbook/elements/legalnotice.rb +22 -0
  156. data/lib/docbook/elements/link.rb +26 -0
  157. data/lib/docbook/elements/listitem.rb +32 -0
  158. data/lib/docbook/elements/literal.rb +16 -0
  159. data/lib/docbook/elements/literallayout.rb +22 -0
  160. data/lib/docbook/elements/mediaobject.rb +26 -0
  161. data/lib/docbook/elements/msg.rb +20 -0
  162. data/lib/docbook/elements/msgexplan.rb +22 -0
  163. data/lib/docbook/elements/msgset.rb +22 -0
  164. data/lib/docbook/elements/note.rb +30 -0
  165. data/lib/docbook/elements/orderedlist.rb +23 -0
  166. data/lib/docbook/elements/orgname.rb +16 -0
  167. data/lib/docbook/elements/para.rb +61 -0
  168. data/lib/docbook/elements/paragraph_like.rb +18 -0
  169. data/lib/docbook/elements/parameter.rb +16 -0
  170. data/lib/docbook/elements/part.rb +38 -0
  171. data/lib/docbook/elements/personname.rb +22 -0
  172. data/lib/docbook/elements/phrase.rb +20 -0
  173. data/lib/docbook/elements/preface.rb +58 -0
  174. data/lib/docbook/elements/primary.rb +16 -0
  175. data/lib/docbook/elements/procedure.rb +24 -0
  176. data/lib/docbook/elements/productname.rb +18 -0
  177. data/lib/docbook/elements/productnumber.rb +16 -0
  178. data/lib/docbook/elements/programlisting.rb +28 -0
  179. data/lib/docbook/elements/property.rb +16 -0
  180. data/lib/docbook/elements/pubdate.rb +16 -0
  181. data/lib/docbook/elements/publishername.rb +16 -0
  182. data/lib/docbook/elements/quotation.rb +24 -0
  183. data/lib/docbook/elements/quote.rb +18 -0
  184. data/lib/docbook/elements/refentry.rb +32 -0
  185. data/lib/docbook/elements/refentrytitle.rb +16 -0
  186. data/lib/docbook/elements/reference.rb +26 -0
  187. data/lib/docbook/elements/refmeta.rb +29 -0
  188. data/lib/docbook/elements/refmiscinfo.rb +16 -0
  189. data/lib/docbook/elements/refname.rb +16 -0
  190. data/lib/docbook/elements/refnamediv.rb +22 -0
  191. data/lib/docbook/elements/refpurpose.rb +16 -0
  192. data/lib/docbook/elements/refsect1.rb +22 -0
  193. data/lib/docbook/elements/refsect2.rb +22 -0
  194. data/lib/docbook/elements/refsect3.rb +22 -0
  195. data/lib/docbook/elements/refsection.rb +32 -0
  196. data/lib/docbook/elements/releaseinfo.rb +16 -0
  197. data/lib/docbook/elements/remark.rb +20 -0
  198. data/lib/docbook/elements/replaceable.rb +16 -0
  199. data/lib/docbook/elements/row.rb +15 -0
  200. data/lib/docbook/elements/screen.rb +28 -0
  201. data/lib/docbook/elements/secondary.rb +16 -0
  202. data/lib/docbook/elements/sect1.rb +26 -0
  203. data/lib/docbook/elements/sect2.rb +24 -0
  204. data/lib/docbook/elements/sect3.rb +24 -0
  205. data/lib/docbook/elements/sect4.rb +24 -0
  206. data/lib/docbook/elements/sect5.rb +22 -0
  207. data/lib/docbook/elements/section.rb +66 -0
  208. data/lib/docbook/elements/see.rb +16 -0
  209. data/lib/docbook/elements/seealso.rb +18 -0
  210. data/lib/docbook/elements/set.rb +26 -0
  211. data/lib/docbook/elements/setindex.rb +24 -0
  212. data/lib/docbook/elements/sidebar.rb +22 -0
  213. data/lib/docbook/elements/simplesect.rb +32 -0
  214. data/lib/docbook/elements/step.rb +26 -0
  215. data/lib/docbook/elements/substeps.rb +18 -0
  216. data/lib/docbook/elements/subtitle.rb +16 -0
  217. data/lib/docbook/elements/surname.rb +16 -0
  218. data/lib/docbook/elements/table.rb +24 -0
  219. data/lib/docbook/elements/tag.rb +20 -0
  220. data/lib/docbook/elements/tbody.rb +15 -0
  221. data/lib/docbook/elements/term.rb +37 -0
  222. data/lib/docbook/elements/tertiary.rb +16 -0
  223. data/lib/docbook/elements/textobject.rb +18 -0
  224. data/lib/docbook/elements/tfoot.rb +15 -0
  225. data/lib/docbook/elements/tgroup.rb +21 -0
  226. data/lib/docbook/elements/thead.rb +15 -0
  227. data/lib/docbook/elements/tip.rb +30 -0
  228. data/lib/docbook/elements/title.rb +16 -0
  229. data/lib/docbook/elements/toc.rb +24 -0
  230. data/lib/docbook/elements/tocdiv.rb +22 -0
  231. data/lib/docbook/elements/tocentry.rb +20 -0
  232. data/lib/docbook/elements/topic.rb +26 -0
  233. data/lib/docbook/elements/type.rb +16 -0
  234. data/lib/docbook/elements/uri.rb +16 -0
  235. data/lib/docbook/elements/userinput.rb +18 -0
  236. data/lib/docbook/elements/variable.rb +16 -0
  237. data/lib/docbook/elements/variablelist.rb +19 -0
  238. data/lib/docbook/elements/varlistentry.rb +17 -0
  239. data/lib/docbook/elements/version.rb +16 -0
  240. data/lib/docbook/elements/videoobject.rb +18 -0
  241. data/lib/docbook/elements/warning.rb +30 -0
  242. data/lib/docbook/elements/xref.rb +18 -0
  243. data/lib/docbook/elements/year.rb +16 -0
  244. data/lib/docbook/elements.rb +204 -0
  245. data/lib/docbook/models/document_metadata.rb +30 -0
  246. data/lib/docbook/models/document_root.rb +33 -0
  247. data/lib/docbook/models/index_entry.rb +28 -0
  248. data/lib/docbook/models/reading_position.rb +22 -0
  249. data/lib/docbook/models/section_number.rb +18 -0
  250. data/lib/docbook/models/section_root.rb +29 -0
  251. data/lib/docbook/models/toc_node.rb +24 -0
  252. data/lib/docbook/models.rb +16 -0
  253. data/lib/docbook/output/data.rb +196 -0
  254. data/lib/docbook/output/html.rb +1450 -0
  255. data/lib/docbook/output/index_generator.rb +281 -0
  256. data/lib/docbook/output.rb +8 -0
  257. data/lib/docbook/services/document_builder.rb +258 -0
  258. data/lib/docbook/services/element_to_hash.rb +287 -0
  259. data/lib/docbook/services/index_generator.rb +134 -0
  260. data/lib/docbook/services/numbering_service.rb +186 -0
  261. data/lib/docbook/services/toc_generator.rb +138 -0
  262. data/lib/docbook/services.rb +14 -0
  263. data/lib/docbook/templates/document.html.liquid +20 -0
  264. data/lib/docbook/templates/partials/_head.liquid +39 -0
  265. data/lib/docbook/templates/partials/_scripts.liquid +10 -0
  266. data/lib/docbook/version.rb +5 -0
  267. data/lib/docbook/xinclude_resolver.rb +217 -0
  268. data/lib/docbook/xref_resolver.rb +78 -0
  269. data/lib/docbook.rb +17 -0
  270. data/sig/docbook.rbs +4 -0
  271. metadata +385 -0
@@ -0,0 +1,1450 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+ require "liquid"
6
+ require "marcel"
7
+ require_relative "data"
8
+ require_relative "index_generator"
9
+
10
+ module Docbook
11
+ module Output
12
+ TEMPLATE_PATH = File.join(__dir__, "..", "templates", "document.html.liquid")
13
+ TEMPLATES_ROOT = File.join(__dir__, "..", "templates")
14
+ FRONTEND_ROOT = File.expand_path("../../../frontend/dist", __dir__)
15
+
16
+ # Configure Liquid template engine with file system for includes
17
+ # Liquid::LocalFileSystem root should be the templates directory
18
+ # When using {% include 'partials/head' %}, it looks for {root}/partials/_head.liquid
19
+ Liquid::Environment.default.file_system = Liquid::LocalFileSystem.new(TEMPLATES_ROOT)
20
+
21
+ class Html
22
+ TEMPLATE_PATH = File.join(__dir__, "..", "templates", "document.html.liquid")
23
+ FRONTEND_ROOT = File.expand_path("../../../frontend/dist", __dir__)
24
+
25
+ def initialize(document, xref_resolver: nil, output_mode: :single_file, base_path: nil, output_path: nil)
26
+ @document = document
27
+ @xref_resolver = xref_resolver
28
+ @output_mode = output_mode
29
+ @base_path = base_path
30
+ @output_path = output_path
31
+ @image_cache = {}
32
+ @index_collector = IndexCollector.new(document)
33
+ end
34
+
35
+ def to_html
36
+ sections_data = collect_sections
37
+ numbering_hash = build_numbering_map(sections_data)
38
+ all_index_terms = @index_collector.collect
39
+ content_hash = build_content_map(sections_data, all_index_terms)
40
+
41
+ # Build the output model
42
+ output = DocbookOutput.new(
43
+ title: extract_title,
44
+ toc: Toc.new(
45
+ sections: sections_data,
46
+ numbering: numbering_hash.map { |id, value| NumberingEntry.new(id: id, value: value) }
47
+ ),
48
+ content: ContentData.new(
49
+ entries: content_hash.map { |key, value| ContentEntry.new(key: key, value: value) }
50
+ ),
51
+ index: build_index(all_index_terms)
52
+ )
53
+
54
+ template_content = File.read(TEMPLATE_PATH)
55
+
56
+ if @output_mode == :directory
57
+ # Write full DocbookOutput as JSON for Vue to fetch
58
+ FileUtils.mkdir_p(@output_path) if @output_path
59
+ data_path = @output_path ? File.join(@output_path, "docbook.data.json") : "docbook.data.json"
60
+ File.write(data_path, output.to_json)
61
+
62
+ template = Liquid::Template.parse(template_content)
63
+ rendered = template.render(
64
+ "docbook_title" => output.title || "DocBook Document",
65
+ "base_url" => base_url,
66
+ "assets_inline" => false,
67
+ "app_css" => File.read(File.join(FRONTEND_ROOT, "app.css")),
68
+ "app_js" => File.read(File.join(FRONTEND_ROOT, "app.iife.js")),
69
+ "data_url" => "docbook.data.json"
70
+ )
71
+ rendered = rendered.gsub('[[DOCBOOK_DATA]]', 'null /* loaded from docbook.data.json */')
72
+ .gsub('[[DOCBOOK_TOC]]', 'null')
73
+ .gsub('[[DOCBOOK_CONTENT]]', 'null')
74
+ .gsub('[[DOCBOOK_INDEX]]', 'null')
75
+ else
76
+ # single_file: embed everything inline via gsub placeholders
77
+ docbook_json = DocumentData.new(title: output.title).to_json.gsub('</script>', '<\\/script>')
78
+ toc_json = output.toc.to_json.gsub('</script>', '<\\/script>')
79
+ content_json = output.content.to_json.gsub('</script>', '<\\/script>')
80
+ index_json = output.index&.to_json&.gsub('</script>', '<\\/script>') || 'null'
81
+
82
+ template = Liquid::Template.parse(template_content)
83
+ rendered = template.render(
84
+ "docbook_title" => output.title || "DocBook Document",
85
+ "base_url" => base_url,
86
+ "assets_inline" => true,
87
+ "app_css" => File.read(File.join(FRONTEND_ROOT, "app.css")),
88
+ "app_js" => File.read(File.join(FRONTEND_ROOT, "app.iife.js"))
89
+ )
90
+
91
+ rendered = rendered.gsub('[[DOCBOOK_DATA]]', docbook_json)
92
+ .gsub('[[DOCBOOK_TOC]]', toc_json)
93
+ .gsub('[[DOCBOOK_CONTENT]]', content_json)
94
+ .gsub('[[DOCBOOK_INDEX]]', index_json)
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ # ── Output Mode Helpers ──────────────────────────────────────────
101
+
102
+ def base_url
103
+ @output_mode == :directory ? "." : ""
104
+ end
105
+
106
+ # ── XRef Resolution (using pre-built resolver) ──────────────────
107
+
108
+ def resolve_xref_text(xref)
109
+ linkend = xref.linkend&.to_s
110
+ return "" unless linkend
111
+ @xref_resolver&.title_for(linkend) || linkend
112
+ end
113
+
114
+ # ── Section Data Collection ─────────────────────────────────────
115
+
116
+ def collect_sections
117
+ sections = []
118
+ case @document
119
+ when Docbook::Elements::Book
120
+ each_attr(@document, :preface) do |pf|
121
+ sections << SectionData.new(
122
+ id: element_id(pf),
123
+ title: best_title(pf) || "Preface",
124
+ type: 'preface'
125
+ )
126
+ end
127
+ each_attr(@document, :part) do |part|
128
+ sections << SectionData.new(
129
+ id: element_id(part),
130
+ title: best_title(part) || "Part",
131
+ type: 'part',
132
+ children: collect_part_children(part)
133
+ )
134
+ end
135
+ each_attr(@document, :chapter) do |ch|
136
+ sections << SectionData.new(
137
+ id: element_id(ch),
138
+ title: best_title(ch) || "Chapter",
139
+ type: 'chapter'
140
+ )
141
+ end
142
+ each_attr(@document, :appendix) do |ap|
143
+ sections << SectionData.new(
144
+ id: element_id(ap),
145
+ title: best_title(ap) || "Appendix",
146
+ type: 'appendix',
147
+ children: collect_appendix_children(ap)
148
+ )
149
+ end
150
+ each_attr(@document, :glossary) do |g|
151
+ sections << SectionData.new(
152
+ id: element_id(g),
153
+ title: best_title(g) || "Glossary",
154
+ type: 'glossary'
155
+ )
156
+ end
157
+ each_attr(@document, :bibliography) do |b|
158
+ sections << SectionData.new(
159
+ id: element_id(b),
160
+ title: best_title(b) || "References",
161
+ type: 'bibliography'
162
+ )
163
+ end
164
+ each_attr(@document, :index) do |idx|
165
+ sections << SectionData.new(
166
+ id: element_id(idx),
167
+ title: best_title(idx) || "Index",
168
+ type: 'index'
169
+ )
170
+ end
171
+ when Docbook::Elements::Article
172
+ each_attr(@document, :section) do |s|
173
+ sections << SectionData.new(
174
+ id: element_id(s),
175
+ title: best_title(s) || "Section",
176
+ type: 'section',
177
+ children: collect_section_children(s)
178
+ )
179
+ end
180
+ when Docbook::Elements::Reference
181
+ # Reference is a top-level element containing refentry children
182
+ each_attr(@document, :refentry) do |re|
183
+ sections << SectionData.new(
184
+ id: element_id(re),
185
+ title: best_title(re) || "Reference Entry",
186
+ type: 'reference'
187
+ )
188
+ end
189
+ end
190
+ sections
191
+ end
192
+
193
+ def collect_part_children(part)
194
+ children = []
195
+ each_attr(part, :preface) do |pf|
196
+ children << SectionData.new(
197
+ id: element_id(pf),
198
+ title: best_title(pf) || "Preface",
199
+ type: 'preface',
200
+ children: collect_appendix_children(pf)
201
+ )
202
+ end
203
+ each_attr(part, :chapter) do |ch|
204
+ children << SectionData.new(
205
+ id: element_id(ch),
206
+ title: best_title(ch) || "Chapter",
207
+ type: 'chapter',
208
+ children: collect_section_children(ch)
209
+ )
210
+ end
211
+ each_attr(part, :reference) do |ref|
212
+ children << SectionData.new(
213
+ id: element_id(ref),
214
+ title: best_title(ref) || "Reference",
215
+ type: 'chapter'
216
+ )
217
+ end
218
+ each_attr(part, :appendix) do |ap|
219
+ children << SectionData.new(
220
+ id: element_id(ap),
221
+ title: best_title(ap) || "Appendix",
222
+ type: 'appendix',
223
+ children: collect_appendix_children(ap)
224
+ )
225
+ end
226
+ each_attr(part, :glossary) do |g|
227
+ children << SectionData.new(
228
+ id: element_id(g),
229
+ title: best_title(g) || "Glossary",
230
+ type: 'glossary'
231
+ )
232
+ end
233
+ each_attr(part, :bibliography) do |b|
234
+ children << SectionData.new(
235
+ id: element_id(b),
236
+ title: best_title(b) || "References",
237
+ type: 'bibliography'
238
+ )
239
+ end
240
+ each_attr(part, :index) do |idx|
241
+ children << SectionData.new(
242
+ id: element_id(idx),
243
+ title: best_title(idx) || "Index",
244
+ type: 'index'
245
+ )
246
+ end
247
+ children
248
+ end
249
+
250
+ def collect_appendix_children(appendix)
251
+ children = []
252
+ each_attr(appendix, :section) do |s|
253
+ children << SectionData.new(
254
+ id: element_id(s),
255
+ title: best_title(s) || "Section",
256
+ type: 'section',
257
+ children: collect_section_children(s)
258
+ )
259
+ end
260
+ children
261
+ end
262
+
263
+ def collect_section_children(parent)
264
+ children = []
265
+ each_attr(parent, :section) do |s|
266
+ children << SectionData.new(
267
+ id: element_id(s),
268
+ title: best_title(s) || "Section",
269
+ type: 'section',
270
+ children: collect_section_children(s)
271
+ )
272
+ end
273
+ children
274
+ end
275
+
276
+ # ── Numbering ──────────────────────────────────────────────────
277
+
278
+ def build_numbering_map(sections)
279
+ builder = NumberingBuilder.new
280
+ part_index = 0
281
+
282
+ sections.each do |sec|
283
+ case sec.type
284
+ when 'part'
285
+ part_num = builder.next_part
286
+ builder.set_number(sec.id, part_num)
287
+ # Parts have chapters inside them - use same part_index for all children
288
+ number_section_tree(sec.children, builder, part_index: part_index, section_scope: sec.id, parent_number: part_num)
289
+ part_index += 1
290
+ when 'chapter', 'reference'
291
+ chapter_num = builder.next_chapter(part_index)
292
+ builder.set_number(sec.id, chapter_num)
293
+ # Number children sections scoped to this chapter, prefix with chapter number
294
+ number_section_tree(sec.children, builder, chapter_id: sec.id, chapter_num: chapter_num, part_index: part_index, section_scope: sec.id, parent_number: chapter_num)
295
+ when 'appendix'
296
+ appendix_num = builder.next_appendix
297
+ appendix_full = "Appendix #{appendix_num}"
298
+ builder.set_number(sec.id, appendix_full)
299
+ number_section_tree(sec.children, builder, part_index: part_index, section_scope: sec.id, parent_number: appendix_full)
300
+ else
301
+ # preface, glossary, bibliography, index - no numbering typically
302
+ number_section_tree(sec.children, builder, part_index: part_index)
303
+ end
304
+ end
305
+
306
+ builder.numbering
307
+ end
308
+
309
+ # Recursively number sections within a chapter
310
+ # @param children [Array<SectionData>] child sections to number
311
+ # @param builder [NumberingBuilder] numbering builder
312
+ # @param chapter_id [String, nil] the chapter xml_id for section scope tracking
313
+ # @param chapter_num [String, nil] the chapter number for prefixing sections
314
+ # @param appendix_prefix [String, nil] the appendix prefix for sections (e.g., "Appendix A")
315
+ # @param part_index [Integer] the part index for chapter numbering scope
316
+ # @param section_scope [String] the xml_id of the parent section for numbering scope
317
+ # @param parent_number [String, nil] the full number of the parent section for building hierarchical numbers
318
+ def number_section_tree(children, builder, chapter_id: nil, chapter_num: nil, appendix_prefix: nil, part_index: 0, section_scope: nil, parent_number: nil)
319
+ return if children.nil?
320
+ children.each do |child|
321
+ case child.type
322
+ when 'section'
323
+ # Scope numbering to this section's ID so siblings share a counter
324
+ scope_id = section_scope || chapter_id || "root_#{part_index}"
325
+ section_num = builder.next_section(scope_id)
326
+ # Build full number from parent chain: chapter_num.parent_num.section_num
327
+ # For appendix_prefix, strip "Appendix " to get just the letter for child numbering
328
+ base_prefix = appendix_prefix ? appendix_prefix.sub(/\AAppendix\s*/, '') : nil
329
+ prefix = base_prefix || parent_number || chapter_num
330
+ full_num = prefix ? "#{prefix}.#{section_num}" : section_num
331
+ builder.set_number(child.id, full_num)
332
+ # Recurse for nested sections, passing this section as the new scope
333
+ number_section_tree(child.children, builder,
334
+ chapter_id: chapter_id, chapter_num: chapter_num, appendix_prefix: appendix_prefix,
335
+ part_index: part_index, section_scope: child.id, parent_number: full_num)
336
+ when 'chapter', 'reference'
337
+ new_chapter_num = builder.next_chapter(part_index)
338
+ builder.set_number(child.id, new_chapter_num)
339
+ number_section_tree(child.children, builder, chapter_id: child.id, chapter_num: new_chapter_num, part_index: part_index, section_scope: child.id, parent_number: new_chapter_num)
340
+ when 'appendix'
341
+ appendix_num = builder.next_appendix
342
+ appendix_full = "Appendix #{appendix_num}"
343
+ builder.set_number(child.id, appendix_full)
344
+ # Prefix appendix sections with "Appendix A.", "Appendix B.", etc.
345
+ number_section_tree(child.children, builder, chapter_id: chapter_id, chapter_num: chapter_num, appendix_prefix: appendix_full, part_index: part_index, section_scope: child.id, parent_number: appendix_full)
346
+ else
347
+ # Other types (preface, glossary, etc.) - no numbering, just recurse
348
+ number_section_tree(child.children, builder, chapter_id: chapter_id, chapter_num: chapter_num, appendix_prefix: appendix_prefix, part_index: part_index, section_scope: section_scope, parent_number: parent_number)
349
+ end
350
+ end
351
+ end
352
+
353
+ # ── Content Map Building ───────────────────────────────────────
354
+
355
+ def build_content_map(sections, all_index_terms = [])
356
+ content = {}
357
+
358
+ unless sections.empty?
359
+ collect_all_sections(sections).each do |sec|
360
+ content[sec.id] = build_section_content_data(sec)
361
+ end
362
+ end
363
+
364
+ if @document.is_a?(Docbook::Elements::Article) && sections.empty?
365
+ content["article-content"] = build_article_content_data
366
+ end
367
+
368
+ # Generate index content if there are index elements
369
+ if @document.respond_to?(:index)
370
+ Array(@document.index).each do |idx|
371
+ index_content = build_index_content(idx, all_index_terms)
372
+ content[idx.xml_id || "index"] = index_content if index_content
373
+ end
374
+ end
375
+
376
+ # Generate setindex content if present
377
+ if @document.respond_to?(:setindex)
378
+ Array(@document.setindex).each do |sidx|
379
+ index_content = build_setindex_content(sidx, all_index_terms)
380
+ content[sidx.xml_id || "setindex"] = index_content if index_content
381
+ end
382
+ end
383
+
384
+ content
385
+ end
386
+
387
+ def build_index(all_index_terms)
388
+ return Index.new(groups: []) if all_index_terms.empty?
389
+
390
+ generator = IndexGenerator.new(all_index_terms, @xref_resolver)
391
+ groups = generator.generate
392
+
393
+ index = Index.new(title: "Index")
394
+ groups.each do |group|
395
+ index_group = IndexGroup.new(letter: group[:letter])
396
+ group[:entries].each do |entry|
397
+ index_group.entries << IndexTerm.new(
398
+ primary: entry[:primary],
399
+ secondary: entry[:secondary],
400
+ tertiary: entry[:tertiary],
401
+ section_id: entry[:section_id],
402
+ section_title: entry[:section_title],
403
+ sort_as: entry[:primary_sort],
404
+ sees: entry[:sees],
405
+ see_alsos: entry[:see_alsos]
406
+ )
407
+ end
408
+ index.groups << index_group
409
+ end
410
+ index
411
+ end
412
+
413
+ def build_index_content(index_element, all_index_terms)
414
+ return nil if all_index_terms.empty?
415
+
416
+ index_type = index_element.type
417
+ # Filter indexterms by type
418
+ filtered_terms = all_index_terms.select { |t| t[:type] == index_type }
419
+
420
+ return nil if filtered_terms.empty?
421
+
422
+ generator = IndexGenerator.new(filtered_terms, @xref_resolver)
423
+ index_data = generator.generate
424
+
425
+ section_content = SectionContent.new(section_id: index_element.xml_id || "index")
426
+ section_content.add_block(ContentBlock.new(
427
+ type: :index_section,
428
+ text: index_element.title&.content,
429
+ children: index_data.map { |group| index_group_to_block(group) }
430
+ ))
431
+ section_content
432
+ end
433
+
434
+ def build_setindex_content(setindex_element, all_index_terms)
435
+ generator = IndexGenerator.new(all_index_terms, @xref_resolver)
436
+ index_data = generator.generate
437
+
438
+ section_content = SectionContent.new(section_id: setindex_element.xml_id || "setindex")
439
+ section_content.add_block(ContentBlock.new(
440
+ type: :index_section,
441
+ text: setindex_element.title&.content,
442
+ children: index_data.map { |group| index_group_to_block(group) }
443
+ ))
444
+ section_content
445
+ end
446
+
447
+ def index_group_to_block(group)
448
+ block = ContentBlock.new(type: :index_letter, text: group[:letter])
449
+ block.children = group[:entries].map { |entry| index_entry_to_block(entry) }
450
+ block
451
+ end
452
+
453
+ def index_entry_to_block(entry)
454
+ block = ContentBlock.new(type: :index_entry)
455
+
456
+ # Primary term
457
+ primary = ContentBlock.new(type: :text, text: entry[:primary])
458
+ block.children << primary
459
+
460
+ # Section link
461
+ if entry[:section_id] && entry[:section_title]
462
+ link = ContentBlock.new(
463
+ type: :index_reference,
464
+ text: entry[:section_title],
465
+ src: "##{entry[:section_id]}"
466
+ )
467
+ block.children << link
468
+ end
469
+
470
+ # See entries
471
+ entry[:sees].each do |see|
472
+ block.children << ContentBlock.new(
473
+ type: :index_see,
474
+ text: "see #{see}"
475
+ )
476
+ end
477
+
478
+ # See also entries
479
+ entry[:see_alsos].each do |see_also|
480
+ block.children << ContentBlock.new(
481
+ type: :index_see_also,
482
+ text: "see also #{see_also}"
483
+ )
484
+ end
485
+
486
+ block
487
+ end
488
+
489
+ def collect_all_sections(sections)
490
+ result = []
491
+ sections.each do |sec|
492
+ result << sec
493
+ result.concat(collect_all_sections(sec.children)) if sec.children && sec.children.any?
494
+ end
495
+ result
496
+ end
497
+
498
+ def build_section_content_data(sec)
499
+ element = find_section_element(sec.id)
500
+ return SectionContent.new(section_id: sec.id) unless element
501
+
502
+ section_content = SectionContent.new(section_id: sec.id)
503
+ # RefEntry has its content in refsection children, not from each_mixed_content
504
+ if element.is_a?(Docbook::Elements::RefEntry)
505
+ section_content.add_block(build_refentry_block(element))
506
+ else
507
+ build_element_content(element, section_content)
508
+ end
509
+ section_content
510
+ end
511
+
512
+ def build_article_content_data
513
+ section_content = SectionContent.new(section_id: "article-content")
514
+ build_element_content(@document, section_content)
515
+ section_content
516
+ end
517
+
518
+ def build_element_content(element, section_content)
519
+ # Use each_mixed_content to process elements in document order
520
+ return unless element.respond_to?(:each_mixed_content)
521
+
522
+ element.each_mixed_content do |node|
523
+ case node
524
+ when String
525
+ # Skip pure whitespace strings between elements
526
+ next if node =~ /\A\s*\z/
527
+
528
+ when Docbook::Elements::Para
529
+ section_content.add_block(build_para_block(node))
530
+
531
+ when Docbook::Elements::MediaObject
532
+ process_mediaobject(node, section_content)
533
+
534
+ when Docbook::Elements::ProgramListing
535
+ code_text = build_code_content(node)
536
+ next if code_text.to_s.strip.empty?
537
+ section_content.add_block(ContentBlock.new(
538
+ type: :code,
539
+ text: code_text,
540
+ language: node.language
541
+ ))
542
+
543
+ when Docbook::Elements::Screen
544
+ code_text = build_code_content(node)
545
+ next if code_text.to_s.strip.empty?
546
+ section_content.add_block(ContentBlock.new(
547
+ type: :code,
548
+ text: code_text,
549
+ language: node.language
550
+ ))
551
+
552
+ when Docbook::Elements::BlockQuote
553
+ block = ContentBlock.new(type: :blockquote)
554
+ if node.attribution
555
+ block.text = node.attribution.content
556
+ end
557
+ node.each_mixed_content do |child|
558
+ case child
559
+ when Docbook::Elements::Para
560
+ block.children << build_para_block(child)
561
+ end
562
+ end
563
+ section_content.add_block(block)
564
+
565
+ when Docbook::Elements::OrderedList
566
+ section_content.add_block(build_ordered_list_block(node))
567
+
568
+ when Docbook::Elements::ItemizedList
569
+ section_content.add_block(build_itemized_list_block(node))
570
+
571
+ when Docbook::Elements::VariableList
572
+ section_content.add_block(build_variablelist_block(node))
573
+
574
+ when Docbook::Elements::Note
575
+ section_content.add_block(build_admonition_block(:note, node))
576
+ when Docbook::Elements::Warning
577
+ section_content.add_block(build_admonition_block(:warning, node))
578
+ when Docbook::Elements::Caution
579
+ section_content.add_block(build_admonition_block(:caution, node))
580
+ when Docbook::Elements::Important
581
+ section_content.add_block(build_admonition_block(:important, node))
582
+ when Docbook::Elements::Tip
583
+ section_content.add_block(build_admonition_block(:tip, node))
584
+ when Docbook::Elements::Danger
585
+ section_content.add_block(build_admonition_block(:danger, node))
586
+
587
+ when Docbook::Elements::Figure
588
+ process_figure(node, section_content)
589
+
590
+ when Docbook::Elements::Example
591
+ section_content.add_block(build_example_block(node))
592
+
593
+ when Docbook::Elements::InformalFigure
594
+ process_informalfigure(node, section_content)
595
+
596
+ when Docbook::Elements::GlossEntry
597
+ section_content.add_block(build_glossentry_block(node))
598
+
599
+ when Docbook::Elements::Bibliomixed
600
+ section_content.add_block(build_bibliomixed_block(node))
601
+
602
+ when Docbook::Elements::IndexDiv
603
+ block = ContentBlock.new(type: :index_section, text: node.title&.content)
604
+ section_content.add_block(block)
605
+
606
+ when Docbook::Elements::LiteralLayout
607
+ if node.content
608
+ section_content.add_block(ContentBlock.new(
609
+ type: :code,
610
+ text: node.content.to_s
611
+ ))
612
+ end
613
+
614
+ when Docbook::Elements::Simplesect
615
+ block = ContentBlock.new(type: :section)
616
+ block.children ||= []
617
+ if node.title
618
+ block.children << ContentBlock.new(
619
+ type: :heading,
620
+ text: node.title.content.to_s
621
+ )
622
+ end
623
+ ss_blocks = build_block_content_from_element(node)
624
+ block.children.concat(ss_blocks)
625
+ section_content.add_block(block)
626
+
627
+ when Docbook::Elements::Section
628
+ # Nested section - create nested content
629
+ nested = SectionContent.new(section_id: element_id(node))
630
+ build_element_content(node, nested)
631
+ block = ContentBlock.new(type: :section, children: nested.blocks)
632
+ section_content.add_block(block)
633
+
634
+ when Docbook::Elements::RefEntry
635
+ section_content.add_block(build_refentry_block(node))
636
+
637
+ when Docbook::Elements::FormalPara
638
+ # Formal para has title + para content
639
+ if node.title
640
+ block = ContentBlock.new(type: :paragraph)
641
+ block.children ||= []
642
+ block.children << ContentBlock.new(type: :strong, text: node.title.content.to_s)
643
+ block.children.concat(build_inline_content(node))
644
+ section_content.add_block(block)
645
+ elsif node.para
646
+ Array(node.para).each { |p| section_content.add_block(build_para_block(p)) }
647
+ end
648
+
649
+ else
650
+ # Skip unknown node types
651
+ end
652
+ end
653
+ end
654
+
655
+ def process_mediaobject(mo, section_content)
656
+ Array(mo.imageobject).each do |io|
657
+ next unless io.imagedata
658
+ fileref = io.imagedata.fileref
659
+ src = fileref ? process_image(fileref) : ""
660
+ alt = io.alt&.content
661
+ section_content.add_block(ContentBlock.new(
662
+ type: :image,
663
+ src: src,
664
+ alt: alt
665
+ ))
666
+ end
667
+ end
668
+
669
+ def process_figure(fig, section_content)
670
+ fig_title = fig.title&.content
671
+ Array(fig.mediaobject).each do |mo|
672
+ mo_alt = mo.alt&.content if mo.respond_to?(:alt) && mo.alt
673
+ Array(mo.imageobject).each do |io|
674
+ next unless io.imagedata
675
+ fileref = io.imagedata.fileref
676
+ src = fileref ? process_image(fileref) : ""
677
+ alt = mo_alt || io.alt&.content || fig_title
678
+ section_content.add_block(ContentBlock.new(
679
+ type: :image,
680
+ src: src,
681
+ alt: alt,
682
+ title: fig_title
683
+ ))
684
+ end
685
+ Array(mo.textobject).each do |to|
686
+ # TextObject has content as a string, not nested para
687
+ if to.content && !to.content.to_s.strip.empty?
688
+ section_content.add_block(ContentBlock.new(
689
+ type: :paragraph,
690
+ text: to.content.to_s
691
+ ))
692
+ end
693
+ end
694
+ end
695
+ Array(fig.programlisting).each do |pl|
696
+ code_text = build_code_content(pl)
697
+ section_content.add_block(ContentBlock.new(type: :code, text: code_text, language: pl.language)) unless code_text.to_s.strip.empty?
698
+ end
699
+ Array(fig.screen).each do |s|
700
+ code_text = build_code_content(s)
701
+ section_content.add_block(ContentBlock.new(type: :code, text: code_text, language: s.language)) unless code_text.to_s.strip.empty?
702
+ end
703
+ end
704
+
705
+ def process_informalfigure(ifig, section_content)
706
+ Array(ifig.mediaobject).each do |mo|
707
+ Array(mo.imageobject).each do |io|
708
+ next unless io.imagedata
709
+ fileref = io.imagedata.fileref
710
+ src = fileref ? process_image(fileref) : ""
711
+ alt = io.alt&.content
712
+ section_content.add_block(ContentBlock.new(
713
+ type: :image,
714
+ src: src,
715
+ alt: alt
716
+ ))
717
+ end
718
+ end
719
+ end
720
+
721
+ def build_ordered_list_block(ol)
722
+ block = ContentBlock.new(type: :ordered_list)
723
+ block.children ||= []
724
+ Array(ol.listitem).each do |li|
725
+ item = ContentBlock.new(type: :list_item)
726
+ item.children ||= []
727
+ li_blocks = build_block_content_from_element(li)
728
+ item.children.concat(li_blocks)
729
+ block.children << item
730
+ end
731
+ block
732
+ end
733
+
734
+ def build_itemized_list_block(ul)
735
+ block = ContentBlock.new(type: :itemized_list)
736
+ block.children ||= []
737
+ Array(ul.listitem).each do |li|
738
+ item = ContentBlock.new(type: :list_item)
739
+ item.children ||= []
740
+ li_blocks = build_block_content_from_element(li)
741
+ item.children.concat(li_blocks)
742
+ block.children << item
743
+ end
744
+ block
745
+ end
746
+
747
+ def build_variablelist_block(vl)
748
+ vl_block = ContentBlock.new(type: :definition_list)
749
+ vl_block.children ||= []
750
+ Array(vl.varlistentry).each do |entry|
751
+ # Term block - rendered in special style
752
+ term_block = ContentBlock.new(type: :definition_term)
753
+ term_block.children ||= []
754
+ Array(entry.term).each do |t|
755
+ term_content = build_inline_content_for_term(t)
756
+ term_block.children << ContentBlock.new(type: :text, text: term_content)
757
+ end
758
+ vl_block.children << term_block
759
+
760
+ # Definition description block - contains the listitem content
761
+ if entry.listitem
762
+ def_block = ContentBlock.new(type: :definition_description)
763
+ def_block.children ||= []
764
+ li_blocks = build_block_content_from_element(entry.listitem)
765
+ def_block.children.concat(li_blocks)
766
+ vl_block.children << def_block
767
+ end
768
+ end
769
+ vl_block
770
+ end
771
+
772
+ def build_example_block(ex)
773
+ block = ContentBlock.new(type: :example)
774
+ block.children ||= []
775
+ block.text = ex.title&.content
776
+ ex.each_mixed_content do |child|
777
+ case child
778
+ when Docbook::Elements::Para
779
+ block.children << build_para_block(child)
780
+ when Docbook::Elements::ProgramListing
781
+ code_text = build_code_content(child)
782
+ block.children << ContentBlock.new(type: :code, text: code_text, language: child.language) unless code_text.to_s.strip.empty?
783
+ when Docbook::Elements::Screen
784
+ code_text = build_code_content(child)
785
+ block.children << ContentBlock.new(type: :code, text: code_text, language: child.language) unless code_text.to_s.strip.empty?
786
+ when Docbook::Elements::LiteralLayout
787
+ block.children << ContentBlock.new(type: :code, text: child.content.to_s) if child.content
788
+ when Docbook::Elements::Figure
789
+ # Process figure within example
790
+ fig_block = ContentBlock.new(type: :image)
791
+ Array(child.mediaobject).each do |mo|
792
+ Array(mo.imageobject).each do |io|
793
+ next unless io.imagedata
794
+ fig_block.src = process_image(io.imagedata.fileref)
795
+ fig_block.alt = io.alt&.content
796
+ end
797
+ end
798
+ block.children << fig_block
799
+ end
800
+ end
801
+ block
802
+ end
803
+
804
+ def build_informalexample_block(element)
805
+ block = ContentBlock.new(type: :example_output)
806
+ block.children ||= []
807
+ element.each_mixed_content do |child|
808
+ case child
809
+ when Docbook::Elements::Para
810
+ block.children << build_para_block(child)
811
+ when Docbook::Elements::ProgramListing
812
+ code_text = build_code_content(child)
813
+ block.children << ContentBlock.new(type: :code, text: code_text, language: child.language) unless code_text.to_s.strip.empty?
814
+ when Docbook::Elements::Screen
815
+ code_text = build_code_content(child)
816
+ block.children << ContentBlock.new(type: :code, text: code_text, language: child.language) unless code_text.to_s.strip.empty?
817
+ when Docbook::Elements::LiteralLayout
818
+ block.children << ContentBlock.new(type: :code, text: child.content.to_s) if child.content
819
+ end
820
+ end
821
+ block
822
+ end
823
+
824
+ def build_glossentry_block(entry)
825
+ block = ContentBlock.new(type: :definition_list)
826
+ block.children ||= []
827
+ # Term
828
+ term_block = ContentBlock.new(type: :definition_term)
829
+ term_block.children ||= []
830
+ if entry.glossterm
831
+ term_text = entry.glossterm.content.to_s
832
+ term_block.children << ContentBlock.new(type: :text, text: term_text)
833
+ end
834
+ block.children << term_block
835
+ # Definition
836
+ if entry.glossdef
837
+ def_block = ContentBlock.new(type: :definition_description)
838
+ def_block.children ||= []
839
+ entry.glossdef.each_mixed_content do |child|
840
+ case child
841
+ when Docbook::Elements::Para
842
+ def_block.children << build_para_block(child)
843
+ end
844
+ end
845
+ block.children << def_block
846
+ end
847
+ block
848
+ end
849
+
850
+ def build_bibliomixed_block(bm)
851
+ block = ContentBlock.new(type: :bibliography_entry)
852
+ block.children ||= []
853
+
854
+ # Abbrev
855
+ if bm.abbrev&.content
856
+ block.children << ContentBlock.new(type: :biblio_abbrev, text: bm.abbrev.content)
857
+ end
858
+
859
+ # Author with personname
860
+ Array(bm.author).each do |author|
861
+ if author.personname
862
+ pn = author.personname
863
+ first = pn.firstname&.content
864
+ sur = pn.surname&.content
865
+ name_text = [first, sur].compact.join(" ")
866
+ if name_text.any?
867
+ block.children << ContentBlock.new(type: :biblio_personname, text: name_text, class_name: "first-last personname")
868
+ end
869
+ end
870
+ end
871
+
872
+ # Personname (inline)
873
+ Array(bm.personname).each do |pn|
874
+ first = pn.firstname&.content
875
+ sur = pn.surname&.content
876
+ name_text = [first, sur].compact.join(" ")
877
+ if name_text.any?
878
+ block.children << ContentBlock.new(type: :biblio_personname, text: name_text, class_name: "first-last personname")
879
+ end
880
+ end
881
+
882
+ # Firstname and surname separately
883
+ if bm.firstname&.content
884
+ block.children << ContentBlock.new(type: :biblio_firstname, text: bm.firstname.content, class_name: "firstname")
885
+ end
886
+ if bm.surname&.content
887
+ block.children << ContentBlock.new(type: :biblio_surname, text: bm.surname.content, class_name: "surname")
888
+ end
889
+
890
+ # Citetitle
891
+ Array(bm.citetitle).each do |ct|
892
+ if ct.href
893
+ block.children << ContentBlock.new(type: :link, text: ct.content, src: ct.href, class_name: "title")
894
+ else
895
+ block.children << ContentBlock.new(type: :biblio_citetitle, text: ct.content, class_name: "title")
896
+ end
897
+ end
898
+
899
+ # Orgname
900
+ Array(bm.orgname).each do |on|
901
+ if on.content
902
+ block.children << ContentBlock.new(type: :biblio_orgname, text: on.content, class_name: "orgname")
903
+ end
904
+ end
905
+
906
+ # Publishername
907
+ Array(bm.publishername).each do |pn|
908
+ if pn.content
909
+ block.children << ContentBlock.new(type: :biblio_publishername, text: pn.content, class_name: "publishername")
910
+ end
911
+ end
912
+
913
+ # Pubdate
914
+ if bm.pubdate
915
+ block.children << ContentBlock.new(type: :biblio_pubdate, text: bm.pubdate.to_s, class_name: "pubdate")
916
+ end
917
+
918
+ # Links
919
+ Array(bm.link).each do |lnk|
920
+ block.children << ContentBlock.new(type: :link, text: lnk.content, src: lnk.href)
921
+ end
922
+
923
+ # Trailing text content
924
+ trailing = bm.content.is_a?(Array) ? bm.content.join : bm.content.to_s
925
+ block.children << ContentBlock.new(type: :text, text: trailing) if trailing
926
+
927
+ block.text = block.children.map { |c| c.text || "" }.join(" ")
928
+ block
929
+ end
930
+
931
+ def build_refentry_block(re)
932
+ block = ContentBlock.new(type: :reference_entry)
933
+ block.children ||= []
934
+
935
+ # Determine the NAME of this entry (priority: refname > refentrytitle > fieldsynopsis.varname)
936
+ entry_name = nil
937
+ if re.refnamediv&.refname && re.refnamediv.refname.any?
938
+ entry_name = re.refnamediv.refname.map { |n| n.content }.join(" ")
939
+ elsif re.refmeta&.refentrytitle
940
+ entry_name = re.refmeta.refentrytitle.content
941
+ elsif re.refmeta&.respond_to?(:fieldsynopsis) && re.refmeta.fieldsynopsis
942
+ # fieldsynopsis is a collection, find first with varname
943
+ re.refmeta.fieldsynopsis.each do |fs|
944
+ # fs.varname might be nil due to namespace issues, so try direct content access
945
+ if fs.respond_to?(:varname) && fs.varname.is_a?(String) && !fs.varname.empty?
946
+ entry_name = fs.varname
947
+ break
948
+ elsif fs.respond_to?(:content)
949
+ # Fallback: extract varname from content using regex
950
+ # Content might be an array or string containing "varname" followed by the actual value
951
+ content_str = fs.content.is_a?(Array) ? fs.content.join(" ") : fs.content.to_s
952
+ # Match "varname" followed by whitespace and the value
953
+ varname_match = content_str.match(/varname\s+([^\n]+)/)
954
+ if varname_match
955
+ entry_name = varname_match[1].strip
956
+ break
957
+ end
958
+ end
959
+ end
960
+ end
961
+
962
+ # Fallback: use refpurpose as name if still no name found
963
+ if entry_name.nil? && re.refnamediv&.refpurpose
964
+ content = re.refnamediv.refpurpose.content
965
+ # Handle both string and array content (mixed content from inline elements)
966
+ purpose_text = if content.is_a?(Array)
967
+ content.join("")
968
+ elsif content.is_a?(String)
969
+ content
970
+ else
971
+ content.to_s
972
+ end
973
+ # Truncate and clean up the purpose to use as a name
974
+ entry_name = purpose_text.length > 50 ? purpose_text[0..47] + "..." : purpose_text
975
+ end
976
+
977
+ # Determine the BADGE (e.g., "pi" for processing instructions, "param" for template params)
978
+ entry_badge = nil
979
+ if re.refnamediv&.refclass
980
+ entry_badge = re.refnamediv.refclass.content.to_s
981
+ elsif entry_name&.start_with?("$")
982
+ entry_badge = "param"
983
+ end
984
+
985
+ # Add badge first (top of entry)
986
+ if entry_badge
987
+ block.children << ContentBlock.new(
988
+ type: :reference_badge,
989
+ text: entry_badge
990
+ )
991
+ end
992
+
993
+ # Add the NAME (headword)
994
+ if entry_name
995
+ block.children << ContentBlock.new(
996
+ type: :reference_name,
997
+ text: entry_name
998
+ )
999
+ end
1000
+
1001
+ # Process refmeta refmiscinfo as metadata (only if not already captured as name)
1002
+ if re.refmeta
1003
+ if re.refmeta.respond_to?(:refmiscinfo) && re.refmeta.refmiscinfo
1004
+ re.refmeta.refmiscinfo.each do |info|
1005
+ block.children << ContentBlock.new(
1006
+ type: :reference_meta,
1007
+ text: info.content
1008
+ ) if info && info.content
1009
+ end
1010
+ end
1011
+ if re.refmeta.manvolnum
1012
+ block.children << ContentBlock.new(
1013
+ type: :reference_meta,
1014
+ text: "(#{re.refmeta.manvolnum})"
1015
+ )
1016
+ end
1017
+ end
1018
+
1019
+ # Process refnamediv refpurpose - THE DEFINITION
1020
+ if re.refnamediv&.refpurpose
1021
+ content = re.refnamediv.refpurpose.content
1022
+ # Handle both string and array content (mixed content from inline elements)
1023
+ definition_text = if content.is_a?(Array)
1024
+ content.join("")
1025
+ elsif content.is_a?(String)
1026
+ content
1027
+ else
1028
+ content.to_s
1029
+ end
1030
+ block.children << ContentBlock.new(
1031
+ type: :reference_definition,
1032
+ text: definition_text
1033
+ )
1034
+ end
1035
+
1036
+ # Process refsection sections (CONTENT of this entry)
1037
+ # NOTE: Skip rs.title - the entry_name already serves as the header
1038
+ Array(re.refsection).each do |rs|
1039
+ rs_block = ContentBlock.new(type: :description_section)
1040
+ rs_block.children ||= []
1041
+ # Don't add title - entry_name is the headword
1042
+ rs_block.children.concat(build_block_content_from_element(rs))
1043
+ block.children << rs_block
1044
+ end
1045
+ block
1046
+ end
1047
+
1048
+ def build_admonition_block(type, element)
1049
+ block = ContentBlock.new(type: type, text: title_of(element))
1050
+ block.children ||= []
1051
+ each_attr(element, :para) { |p| block.children << build_para_block(p) }
1052
+ each_attr(element, :programlisting) do |pl|
1053
+ code_text = build_code_content(pl)
1054
+ block.children << ContentBlock.new(type: :code, text: code_text, language: pl.language) unless code_text.to_s.strip.empty?
1055
+ end
1056
+ each_attr(element, :screen) do |s|
1057
+ code_text = build_code_content(s)
1058
+ block.children << ContentBlock.new(type: :code, text: code_text, language: s.language) unless code_text.to_s.strip.empty?
1059
+ end
1060
+ each_attr(element, :literallayout) do |ll|
1061
+ block.children << ContentBlock.new(type: :code, text: ll.content.to_s) if ll.content
1062
+ end
1063
+ block
1064
+ end
1065
+
1066
+ def build_para_block(p)
1067
+ block = ContentBlock.new(type: :paragraph)
1068
+ block.children = build_inline_content(p)
1069
+ block
1070
+ end
1071
+
1072
+ # Build all block-level content from an element (used for listitems, simplesects, etc.)
1073
+ def build_block_content_from_element(element)
1074
+ blocks = []
1075
+ return blocks unless element.respond_to?(:each_mixed_content)
1076
+
1077
+ element.each_mixed_content do |node|
1078
+ case node
1079
+ when String
1080
+ # Skip whitespace strings
1081
+ next if node =~ /\A\s*\z/
1082
+ when Docbook::Elements::Para
1083
+ blocks << build_para_block(node)
1084
+ when Docbook::Elements::LiteralLayout
1085
+ blocks << ContentBlock.new(type: :code, text: node.content.to_s) if node.content
1086
+ when Docbook::Elements::ProgramListing
1087
+ code_text = build_code_content(node)
1088
+ blocks << ContentBlock.new(type: :code, text: code_text, language: node.language) unless code_text.to_s.strip.empty?
1089
+ when Docbook::Elements::Screen
1090
+ code_text = build_code_content(node)
1091
+ blocks << ContentBlock.new(type: :code, text: code_text, language: node.language) unless code_text.to_s.strip.empty?
1092
+ when Docbook::Elements::Simplesect
1093
+ block = ContentBlock.new(type: :section)
1094
+ block.children ||= []
1095
+ if node.title
1096
+ block.children << ContentBlock.new(type: :heading, text: node.title.content.to_s)
1097
+ end
1098
+ ss_blocks = build_block_content_from_element(node)
1099
+ block.children.concat(ss_blocks)
1100
+ blocks << block
1101
+ when Docbook::Elements::VariableList
1102
+ blocks << build_variablelist_block(node)
1103
+ when Docbook::Elements::InformalExample
1104
+ blocks << build_informalexample_block(node)
1105
+ end
1106
+ end
1107
+ blocks
1108
+ end
1109
+
1110
+ # Build inline content (children) from element using each_mixed_content
1111
+ # This yields proper model objects (not XML elements) in document order
1112
+ def build_inline_content(element)
1113
+ children = []
1114
+ element.each_mixed_content do |node|
1115
+ case node
1116
+ when String
1117
+ children << ContentBlock.new(type: :text, text: node) if node&.strip&.then { !_1.empty? }
1118
+ when Docbook::Elements::Emphasis
1119
+ children << build_emphasis_block(node)
1120
+ when Docbook::Elements::Link
1121
+ children << build_link_block(node)
1122
+ when Docbook::Elements::Xref
1123
+ children << build_xref_block(node)
1124
+ when Docbook::Elements::Code, Docbook::Elements::Literal
1125
+ children << ContentBlock.new(type: :codetext, text: extract_text_content(node))
1126
+ when Docbook::Elements::Filename, Docbook::Elements::ProductName,
1127
+ Docbook::Elements::ClassName, Docbook::Elements::Function,
1128
+ Docbook::Elements::Parameter, Docbook::Elements::BuildTarget,
1129
+ Docbook::Elements::Dir, Docbook::Elements::Replaceable
1130
+ children << ContentBlock.new(type: :codetext, text: node.content)
1131
+ when Docbook::Elements::Quote
1132
+ # Handle quote with nested replaceable
1133
+ if Array(node.replaceable).any?
1134
+ text = node.replaceable.map(&:content).join("")
1135
+ children << ContentBlock.new(type: :text, text: "\"#{text}\"")
1136
+ else
1137
+ children << ContentBlock.new(type: :text, text: "\"#{node.content}\"")
1138
+ end
1139
+ when Docbook::Elements::UserInput, Docbook::Elements::Screen
1140
+ children << ContentBlock.new(type: :codetext, text: node.content)
1141
+ when Docbook::Elements::Citetitle
1142
+ if node.href
1143
+ children << ContentBlock.new(type: :citation_link, text: node.content, src: node.href.to_s)
1144
+ else
1145
+ children << ContentBlock.new(type: :citation, text: node.content)
1146
+ end
1147
+ when Docbook::Elements::Biblioref
1148
+ # Use linkend as display text since biblioref is often self-closing
1149
+ text = node.content.to_s.empty? ? node.linkend : node.content
1150
+ children << ContentBlock.new(type: :biblioref, text: text, src: "##{node.linkend}")
1151
+ when Docbook::Elements::FirstTerm, Docbook::Elements::Glossterm
1152
+ children << ContentBlock.new(type: :emphasis, text: node.content)
1153
+ when Docbook::Elements::Tag
1154
+ # Render tag elements like <appendix> as inline code text
1155
+ tag_content = node.content.to_s
1156
+ children << ContentBlock.new(type: :codetext, text: "<#{tag_content}>")
1157
+ when Docbook::Elements::Att
1158
+ children << ContentBlock.new(type: :codetext, text: node.content.to_s)
1159
+ when Docbook::Elements::Inlinemediaobject
1160
+ children << build_inline_image_block(node)
1161
+ else
1162
+ # Generic fallback for any other Serializable
1163
+ children << ContentBlock.new(type: :text, text: node.content.to_s) if node.content
1164
+ end
1165
+ end
1166
+ children
1167
+ end
1168
+
1169
+ def build_emphasis_block(el)
1170
+ type = case el.role
1171
+ when "bold", "strong" then :strong
1172
+ when "italic" then :italic
1173
+ else :emphasis
1174
+ end
1175
+ ContentBlock.new(type: type, text: el.content)
1176
+ end
1177
+
1178
+ def build_link_block(el)
1179
+ href = if el.xlink_href
1180
+ el.xlink_href.to_s
1181
+ elsif el.linkend
1182
+ "##{el.linkend}"
1183
+ else
1184
+ "#"
1185
+ end
1186
+ ContentBlock.new(type: :link, text: el.content, src: href)
1187
+ end
1188
+
1189
+ def build_xref_block(el)
1190
+ href = el.linkend ? "##{el.linkend}" : "#"
1191
+ text = resolve_xref_text(el)
1192
+ text ||= el.content.to_s if el.content
1193
+ ContentBlock.new(type: :xref, text: text || href, src: href)
1194
+ end
1195
+
1196
+ def build_inline_image_block(el)
1197
+ if el.imageobject&.imagedata&.fileref
1198
+ src = process_image(el.imageobject.imagedata.fileref)
1199
+ alt = el.alt&.content
1200
+ ContentBlock.new(type: :inline_image, src: src, alt: alt)
1201
+ else
1202
+ ContentBlock.new(type: :text, text: "[image]")
1203
+ end
1204
+ end
1205
+
1206
+ # Extract text content from an element, using each_mixed_content
1207
+ # to handle CDATA sections that don't get mapped to .content
1208
+ def extract_text_content(el)
1209
+ if el.respond_to?(:each_mixed_content) && (el.content.nil? || el.content.to_s.empty?)
1210
+ text = []
1211
+ el.each_mixed_content { |n| text << n.to_s if n.is_a?(String) }
1212
+ return text.join unless text.empty?
1213
+ end
1214
+ el.content.to_s
1215
+ end
1216
+
1217
+ # Build text content from a screen/programlisting element, handling mixed content
1218
+ # Returns the concatenated text content
1219
+ def build_code_content(el)
1220
+ return el.content.to_s unless el.respond_to?(:each_mixed_content)
1221
+
1222
+ result = []
1223
+ el.each_mixed_content do |node|
1224
+ case node
1225
+ when String
1226
+ result << node
1227
+ when Docbook::Elements::UserInput, Docbook::Elements::ComputerOutput,
1228
+ Docbook::Elements::Code, Docbook::Elements::Literal
1229
+ result << node.content.to_s
1230
+ when Docbook::Elements::Emphasis
1231
+ result << node.content.to_s
1232
+ when Docbook::Elements::Filename, Docbook::Elements::ProductName
1233
+ result << node.content.to_s
1234
+ when Docbook::Elements::Link
1235
+ result << node.content.to_s
1236
+ when Docbook::Elements::Xref
1237
+ result << node.content.to_s
1238
+ when Docbook::Elements::Tag
1239
+ result << "<#{node.content}>"
1240
+ when Docbook::Elements::Att
1241
+ result << node.content.to_s
1242
+ else
1243
+ result << node.content.to_s if node.respond_to?(:content) && node.content
1244
+ end
1245
+ end
1246
+ result.join("")
1247
+ end
1248
+
1249
+ # Build text content from a term element, handling mixed content
1250
+ # Uses each_mixed_content since Term now has typed inline elements
1251
+ def build_inline_content_for_term(term_el)
1252
+ return "" unless term_el.respond_to?(:each_mixed_content)
1253
+
1254
+ result = []
1255
+ term_el.each_mixed_content do |node|
1256
+ case node
1257
+ when String
1258
+ result << node if node&.strip&.then { !_1.empty? }
1259
+ when Docbook::Elements::Code, Docbook::Elements::Literal
1260
+ result << node.content.to_s
1261
+ when Docbook::Elements::Emphasis
1262
+ result << node.content.to_s
1263
+ when Docbook::Elements::Filename, Docbook::Elements::ProductName
1264
+ result << node.content.to_s
1265
+ when Docbook::Elements::Link
1266
+ result << node.content.to_s
1267
+ when Docbook::Elements::Xref
1268
+ result << node.content.to_s
1269
+ when Docbook::Elements::Tag
1270
+ result << "<#{node.content}>"
1271
+ when Docbook::Elements::Att
1272
+ result << node.content.to_s
1273
+ when Docbook::Elements::Property
1274
+ result << node.content.to_s
1275
+ end
1276
+ end
1277
+ result.join("")
1278
+ end
1279
+
1280
+ def find_section_element(id)
1281
+ return find_in_document_fallback(id) unless @xref_resolver
1282
+ @xref_resolver[id] || find_in_document_fallback(id)
1283
+ end
1284
+
1285
+ def find_in_document_fallback(id)
1286
+ find_in_document(@document, id)
1287
+ end
1288
+
1289
+ # Recursively search for element with matching id using typed collections
1290
+ def find_in_document(el, id)
1291
+ return el if element_id(el) == id
1292
+
1293
+ case el
1294
+ when Docbook::Elements::Book
1295
+ [
1296
+ el.part, el.chapter, el.appendix, el.preface,
1297
+ el.glossary, el.bibliography, el.index
1298
+ ].each do |children|
1299
+ Array(children).each do |child|
1300
+ result = find_in_document(child, id)
1301
+ return result if result
1302
+ end
1303
+ end
1304
+ when Docbook::Elements::Part
1305
+ [
1306
+ el.chapter, el.appendix, el.reference, el.preface,
1307
+ el.glossary, el.bibliography, el.index
1308
+ ].each do |children|
1309
+ Array(children).each do |child|
1310
+ result = find_in_document(child, id)
1311
+ return result if result
1312
+ end
1313
+ end
1314
+ when Docbook::Elements::Article
1315
+ Array(el.section).each do |child|
1316
+ result = find_in_document(child, id)
1317
+ return result if result
1318
+ end
1319
+ when Docbook::Elements::Chapter, Docbook::Elements::Appendix,
1320
+ Docbook::Elements::Section, Docbook::Elements::Preface
1321
+ Array(el.section).each do |child|
1322
+ result = find_in_document(child, id)
1323
+ return result if result
1324
+ end
1325
+ when Docbook::Elements::Reference
1326
+ Array(el.refentry).each do |child|
1327
+ result = find_in_document(child, id)
1328
+ return result if result
1329
+ end
1330
+ end
1331
+
1332
+ nil
1333
+ end
1334
+
1335
+ # ── Image Processing ────────────────────────────────────────────
1336
+
1337
+ def process_image(fileref)
1338
+ return fileref unless @base_path
1339
+
1340
+ if @output_mode == :single_file
1341
+ embed_image_base64(fileref)
1342
+ else
1343
+ find_file_path(fileref) || fileref
1344
+ end
1345
+ end
1346
+
1347
+ # Find the actual file path by searching in base_path and parent directories
1348
+ # This handles cases where media/ is at a different level than xml/
1349
+ # Also tries prepending common prefixes like 'resources/' when direct path not found
1350
+ def find_file_path(fileref)
1351
+ # Try variations of the path with common prefixes
1352
+ path_prefixes = ["", "resources/", "../resources/"]
1353
+ search_paths = []
1354
+
1355
+ # Build search paths: base_path and all parents
1356
+ path = @base_path
1357
+ loop do
1358
+ search_paths << path
1359
+ parent = File.dirname(path)
1360
+ break if parent == path
1361
+ path = parent
1362
+ end
1363
+
1364
+ # For each search path, try with and without resource prefixes
1365
+ search_paths.each do |base|
1366
+ path_prefixes.each do |prefix|
1367
+ full_path = File.join(base, prefix, fileref)
1368
+ return full_path if File.exist?(full_path)
1369
+ end
1370
+ end
1371
+ nil
1372
+ end
1373
+
1374
+ def embed_image_base64(fileref)
1375
+ return @image_cache[fileref] if @image_cache[fileref]
1376
+
1377
+ full_path = find_file_path(fileref)
1378
+ return fileref unless full_path && File.exist?(full_path)
1379
+
1380
+ data = File.binread(full_path)
1381
+ mime = Marcel::MimeType.for(data)
1382
+ encoded = Base64.strict_encode64(data)
1383
+ @image_cache[fileref] = "data:#{mime};base64,#{encoded}"
1384
+ end
1385
+
1386
+ # ── Utility ─────────────────────────────────────────────────────
1387
+
1388
+ def escape(text)
1389
+ return "" if text.nil?
1390
+ ERB::Util.html_escape(text.to_s)
1391
+ end
1392
+
1393
+ def each_attr(obj, name)
1394
+ val = obj.send(name)
1395
+ return unless val
1396
+ Array(val).each { |item| yield item }
1397
+ rescue NoMethodError
1398
+ nil
1399
+ end
1400
+
1401
+ # Get title content from element - elements with title attribute
1402
+ def title_of(el)
1403
+ case el
1404
+ when Docbook::Elements::Article, Docbook::Elements::Book
1405
+ el.info&.title&.content
1406
+ when Docbook::Elements::Section, Docbook::Elements::Chapter, Docbook::Elements::Appendix,
1407
+ Docbook::Elements::Preface, Docbook::Elements::Part, Docbook::Elements::Reference
1408
+ # These elements may have title inside info or as direct child
1409
+ el.info&.title&.content || el.title&.content
1410
+ when Docbook::Elements::RefEntry
1411
+ # RefEntry title is in refnamediv.refname, not in title element
1412
+ el.refnamediv&.refname&.first&.content
1413
+ when Docbook::Elements::Note, Docbook::Elements::Warning, Docbook::Elements::Caution,
1414
+ Docbook::Elements::Important, Docbook::Elements::Tip, Docbook::Elements::Danger
1415
+ el.title&.content
1416
+ when Docbook::Elements::BlockQuote, Docbook::Elements::Figure, Docbook::Elements::Example,
1417
+ Docbook::Elements::Equation
1418
+ el.title&.content
1419
+ else
1420
+ el.title&.content rescue nil
1421
+ end
1422
+ end
1423
+
1424
+ def best_title(el)
1425
+ title_of(el)
1426
+ end
1427
+
1428
+ def element_id(el)
1429
+ el.xml_id || begin
1430
+ t = best_title(el)
1431
+ t&.downcase&.gsub(/[^a-z0-9]+/, "-")&.gsub(/^-|-$/, "") || "el-#{el.object_id}"
1432
+ end
1433
+ end
1434
+
1435
+ # ── Title Extraction ─────────────────────────────────────────────
1436
+
1437
+ def extract_title
1438
+ case @document
1439
+ when Docbook::Elements::Book, Docbook::Elements::Article
1440
+ best_title(@document) || "DocBook"
1441
+ when Docbook::Elements::Section, Docbook::Elements::Preface,
1442
+ Docbook::Elements::Chapter, Docbook::Elements::Appendix
1443
+ best_title(@document) || "DocBook"
1444
+ else
1445
+ "DocBook"
1446
+ end
1447
+ end
1448
+ end
1449
+ end
1450
+ end