pandorga 1.2.1

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 (376) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +77 -0
  3. data/DESIGN.md +1434 -0
  4. data/LICENSE +21 -0
  5. data/README.md +81 -0
  6. data/_data/themes/dark.yml +97 -0
  7. data/_data/themes/light.yml +120 -0
  8. data/_data/themes/shared.yml +37 -0
  9. data/_data/translations.yml +85 -0
  10. data/_includes/chord-tab.html +38 -0
  11. data/_includes/content-runtime/00-config.html +22 -0
  12. data/_includes/content-runtime/10-utils.html +176 -0
  13. data/_includes/content-runtime/20-markdown.html +264 -0
  14. data/_includes/content-runtime/25-code-blocks.html +315 -0
  15. data/_includes/content-runtime/30-includes-bib.html +190 -0
  16. data/_includes/content-runtime/30-includes-xcite.html +122 -0
  17. data/_includes/content-runtime/30-includes.html +365 -0
  18. data/_includes/content-runtime/40-listing.html +225 -0
  19. data/_includes/content-runtime/41-listing-helpers.html +71 -0
  20. data/_includes/content-runtime/42-listing-filter.html +208 -0
  21. data/_includes/content-runtime/43-blocks.html +379 -0
  22. data/_includes/content-runtime/50-detail.html +861 -0
  23. data/_includes/content-runtime/55-toc.html +176 -0
  24. data/_includes/content-runtime/60-fragments.html +567 -0
  25. data/_includes/content-runtime/90-bootstrap.html +76 -0
  26. data/_includes/cut-in.html +10 -0
  27. data/_includes/cv/cv-box.html +18 -0
  28. data/_includes/dict-card.html +24 -0
  29. data/_includes/figure.html +36 -0
  30. data/_includes/home/band-index.html +43 -0
  31. data/_includes/home/band-listing.html +49 -0
  32. data/_includes/mathjax.html +10 -0
  33. data/_includes/ongoing-product.html +1 -0
  34. data/_includes/page/content-runtime.html +20 -0
  35. data/_includes/page/date_evolution.html +19 -0
  36. data/_includes/page/figure-lightbox.html +102 -0
  37. data/_includes/page/formatted_date.html +15 -0
  38. data/_includes/page/hero-default.html +18 -0
  39. data/_includes/page/listing-runtime-attrs.html +8 -0
  40. data/_includes/page/mermaid.html +101 -0
  41. data/_includes/page/network-graph.html +1253 -0
  42. data/_includes/page/page-header.html +41 -0
  43. data/_includes/page/professional-connections.html +2 -0
  44. data/_includes/page/site-footer.html +101 -0
  45. data/_includes/page/timeline-section.html +868 -0
  46. data/_includes/plotly.html +22 -0
  47. data/_includes/theme/dev-reexport.html +100 -0
  48. data/_includes/theme/favicons.html +4 -0
  49. data/_includes/theme/font-loader.html +55 -0
  50. data/_includes/theme/shell-theme-boot.html +115 -0
  51. data/_includes/theme/site-logo.html +6 -0
  52. data/_includes/theme/tailwind-theme-colors.json.liquid +65 -0
  53. data/_includes/theme/tailwind-theme-fonts.json.liquid +6 -0
  54. data/_includes/theme/theme-context.liquid +20 -0
  55. data/_includes/theme/theme-toggle.html +94 -0
  56. data/_includes/theme/theme-vars-block.liquid +161 -0
  57. data/_includes/theme/theme-vars.html +16 -0
  58. data/_includes/transcription.html +80 -0
  59. data/_includes/tweet.html +22 -0
  60. data/_includes/video.html +11 -0
  61. data/_includes/xcite.html +3 -0
  62. data/_includes/youtube-short.html +26 -0
  63. data/_includes/youtube.html +12 -0
  64. data/_layouts/article.html +27 -0
  65. data/_layouts/articles.html +534 -0
  66. data/_layouts/bibliography.html +417 -0
  67. data/_layouts/blog.html +544 -0
  68. data/_layouts/cv.html +393 -0
  69. data/_layouts/home.html +319 -0
  70. data/_layouts/network.html +9 -0
  71. data/_layouts/pandorga/articles.html +4 -0
  72. data/_layouts/pandorga/bibliography.html +4 -0
  73. data/_layouts/pandorga/blog.html +4 -0
  74. data/_layouts/pandorga/cv.html +4 -0
  75. data/_layouts/pandorga/media.html +4 -0
  76. data/_layouts/pandorga/network.html +4 -0
  77. data/_layouts/pandorga/portfolio.html +4 -0
  78. data/_layouts/post.html +7 -0
  79. data/_layouts/product.html +5 -0
  80. data/_layouts/project.html +27 -0
  81. data/_layouts/projects.html +534 -0
  82. data/_layouts/resources.html +387 -0
  83. data/_layouts/shell.html +203 -0
  84. data/_layouts/text.html +246 -0
  85. data/_plugins/bibliography_artifact.rb +57 -0
  86. data/_plugins/content_backend.rb +37 -0
  87. data/_plugins/content_collections.rb +51 -0
  88. data/_plugins/content_detail_paths.rb +58 -0
  89. data/_plugins/content_headers.rb +73 -0
  90. data/_plugins/content_keys.rb +310 -0
  91. data/_plugins/content_local_serve.rb +168 -0
  92. data/_plugins/content_site_adapter.rb +159 -0
  93. data/_plugins/content_validators_hook.rb +20 -0
  94. data/_plugins/content_writing_index.rb +52 -0
  95. data/_plugins/content_writing_paths.rb +55 -0
  96. data/_plugins/force_utf8_encoding.rb +13 -0
  97. data/_plugins/git_chronology.rb +211 -0
  98. data/_plugins/git_metadata.rb +43 -0
  99. data/_plugins/job_sections.rb +25 -0
  100. data/_plugins/lib/content_validators.rb +27 -0
  101. data/_plugins/lib/resource_validator.rb +56 -0
  102. data/_plugins/lib/tag_validator.rb +153 -0
  103. data/_plugins/lib/text_excerpt.rb +56 -0
  104. data/_plugins/lib/writing_entry.rb +107 -0
  105. data/_plugins/local_detail_rewrites.rb +194 -0
  106. data/_plugins/timeline.rb +507 -0
  107. data/_plugins/writing_slug_limits.rb +70 -0
  108. data/assets/css/article.css +1330 -0
  109. data/assets/css/base.css +1432 -0
  110. data/assets/css/cv.css +13 -0
  111. data/assets/css/global-link.css +94 -0
  112. data/assets/css/ledger/bibliography.css +307 -0
  113. data/assets/css/ledger/chrome.css +118 -0
  114. data/assets/css/ledger/cv.css +836 -0
  115. data/assets/css/ledger/detail.css +1270 -0
  116. data/assets/css/ledger/footer.css +298 -0
  117. data/assets/css/ledger/home.css +1490 -0
  118. data/assets/css/ledger/network.css +106 -0
  119. data/assets/css/ledger/projects.css +716 -0
  120. data/assets/css/ledger/rascunhos.css +379 -0
  121. data/assets/css/ledger/sources.css +327 -0
  122. data/assets/css/ledger/writing.css +754 -0
  123. data/assets/tailwind-play.js +15 -0
  124. data/examples/full/Gemfile +7 -0
  125. data/examples/full/_config.yml +92 -0
  126. data/examples/full/_redirects +3 -0
  127. data/examples/full/content/collections/articles/2026-01-01-welcome.md +9 -0
  128. data/examples/full/content/collections/data/contacts.yml +3 -0
  129. data/examples/full/content/collections/data/education.yml +4 -0
  130. data/examples/full/content/collections/jobs/research-fellow.md +12 -0
  131. data/examples/full/content/collections/posts/2026-01-02-note.md +9 -0
  132. data/examples/full/content/collections/products/ledger-tool.md +6 -0
  133. data/examples/full/content/collections/projects/demo-kit.md +10 -0
  134. data/examples/full/content/collections/resources/style-guide.md +7 -0
  135. data/examples/full/content/pages/cv/summary.md +1 -0
  136. data/examples/full/content/pages/headers.yml +20 -0
  137. data/examples/full/index.html +4 -0
  138. data/examples/minimal/Gemfile +7 -0
  139. data/examples/minimal/_config.yml +47 -0
  140. data/examples/minimal/_redirects +3 -0
  141. data/examples/minimal/content/collections/articles/2026-01-01-hello.md +10 -0
  142. data/examples/minimal/content/collections/articles/2026-01-02-second.md +9 -0
  143. data/examples/minimal/content/pages/headers.yml +6 -0
  144. data/examples/minimal/index.html +4 -0
  145. data/exe/pandorga +7 -0
  146. data/functions/api/studio/[[path]].js +562 -0
  147. data/functions/api/studio/_lib/auth.js +131 -0
  148. data/functions/api/studio/_lib/blocks.js +156 -0
  149. data/functions/api/studio/_lib/gemini.js +62 -0
  150. data/functions/api/studio/_lib/github.js +278 -0
  151. data/functions/api/studio/_lib/guidelines.js +143 -0
  152. data/functions/api/studio/_lib/paths.js +76 -0
  153. data/functions/api/studio/_lib/prompts.js +165 -0
  154. data/functions/api/studio/_lib/rate-limit.js +40 -0
  155. data/functions/api/studio/_lib/rewrite.js +136 -0
  156. data/functions/api/studio/_lib/sanitize.js +143 -0
  157. data/lib/pandorga/cli.rb +66 -0
  158. data/lib/pandorga/commands/doctor.rb +66 -0
  159. data/lib/pandorga/commands/export.rb +30 -0
  160. data/lib/pandorga/commands/install_functions.rb +41 -0
  161. data/lib/pandorga/commands/new.rb +113 -0
  162. data/lib/pandorga/commands/publish.rb +53 -0
  163. data/lib/pandorga/commands/serve.rb +34 -0
  164. data/lib/pandorga/commands/studio_schema.rb +309 -0
  165. data/lib/pandorga/jekyll/generator.rb +74 -0
  166. data/lib/pandorga/jekyll/plugins/bibliography_artifact.rb +57 -0
  167. data/lib/pandorga/jekyll/plugins/content_backend.rb +37 -0
  168. data/lib/pandorga/jekyll/plugins/content_collections.rb +51 -0
  169. data/lib/pandorga/jekyll/plugins/content_detail_paths.rb +58 -0
  170. data/lib/pandorga/jekyll/plugins/content_headers.rb +73 -0
  171. data/lib/pandorga/jekyll/plugins/content_keys.rb +310 -0
  172. data/lib/pandorga/jekyll/plugins/content_local_serve.rb +168 -0
  173. data/lib/pandorga/jekyll/plugins/content_site_adapter.rb +159 -0
  174. data/lib/pandorga/jekyll/plugins/content_validators_hook.rb +20 -0
  175. data/lib/pandorga/jekyll/plugins/content_writing_index.rb +52 -0
  176. data/lib/pandorga/jekyll/plugins/content_writing_paths.rb +55 -0
  177. data/lib/pandorga/jekyll/plugins/force_utf8_encoding.rb +13 -0
  178. data/lib/pandorga/jekyll/plugins/git_chronology.rb +211 -0
  179. data/lib/pandorga/jekyll/plugins/git_metadata.rb +43 -0
  180. data/lib/pandorga/jekyll/plugins/job_sections.rb +25 -0
  181. data/lib/pandorga/jekyll/plugins/lib/content_validators.rb +27 -0
  182. data/lib/pandorga/jekyll/plugins/lib/resource_validator.rb +56 -0
  183. data/lib/pandorga/jekyll/plugins/lib/tag_validator.rb +153 -0
  184. data/lib/pandorga/jekyll/plugins/lib/text_excerpt.rb +56 -0
  185. data/lib/pandorga/jekyll/plugins/lib/writing_entry.rb +107 -0
  186. data/lib/pandorga/jekyll/plugins/local_detail_rewrites.rb +194 -0
  187. data/lib/pandorga/jekyll/plugins/timeline.rb +507 -0
  188. data/lib/pandorga/jekyll/plugins/writing_slug_limits.rb +70 -0
  189. data/lib/pandorga/jekyll.rb +15 -0
  190. data/lib/pandorga/object_store_env.rb +51 -0
  191. data/lib/pandorga/registry/schema.yml +115 -0
  192. data/lib/pandorga/registry.rb +279 -0
  193. data/lib/pandorga/version.rb +5 -0
  194. data/lib/pandorga.rb +19 -0
  195. data/scripts/cdn-allowlist.json +19 -0
  196. data/scripts/content/backfill-content-keys.rb +99 -0
  197. data/scripts/content/band_art.rb +362 -0
  198. data/scripts/content/export-content-json.rb +741 -0
  199. data/scripts/content/flatten-ansi-capture.mjs +153 -0
  200. data/scripts/content/hero_portraits.rb +28 -0
  201. data/scripts/content/publish-content-to-object-store.sh +78 -0
  202. data/scripts/content/publish-content-to-r2.sh +5 -0
  203. data/scripts/content/sync-content-json-to-r2.rb +167 -0
  204. data/scripts/content/sync-content-redirects.rb +12 -0
  205. data/scripts/generate-configuration-doc.rb +127 -0
  206. data/scripts/lib/content_license.rb +50 -0
  207. data/scripts/lib/content_storage_config.rb +70 -0
  208. data/scripts/lib/normalize-display-math.mjs +52 -0
  209. data/scripts/lib/r2_json_sync.rb +76 -0
  210. data/scripts/lib/withdrawn_public_objects.rb +23 -0
  211. data/scripts/lib/writing_index_contract.rb +61 -0
  212. data/scripts/pdf/md-to-pdf.mjs +1400 -0
  213. data/scripts/pdf/md-to-pdf.sh +27 -0
  214. data/scripts/test/fixtures/dual-template/_config.yml +32 -0
  215. data/scripts/test/fixtures/dual-template/index.html +4 -0
  216. data/scripts/test/render-parity/article-note.json +5 -0
  217. data/scripts/test/render-parity/blockquote.json +5 -0
  218. data/scripts/test/render-parity/blocks/ansi-basic.json +7 -0
  219. data/scripts/test/render-parity/blocks/ansi-bold-is-bright.json +7 -0
  220. data/scripts/test/render-parity/blocks/ansi-drops-cursor-moves.json +7 -0
  221. data/scripts/test/render-parity/blocks/ansi-escapes-markup.json +7 -0
  222. data/scripts/test/render-parity/blocks/ansi-extended-colour.json +7 -0
  223. data/scripts/test/render-parity/blocks/ansi-real-control-byte.json +7 -0
  224. data/scripts/test/render-parity/blocks/archive-card-date-once.json +19 -0
  225. data/scripts/test/render-parity/blocks/archive-card-empty-foot.json +7 -0
  226. data/scripts/test/render-parity/blocks/archive-card-footer-dates.json +22 -0
  227. data/scripts/test/render-parity/blocks/archive-card-meta-parts.json +26 -0
  228. data/scripts/test/render-parity/blocks/archive-card.json +32 -0
  229. data/scripts/test/render-parity/blocks/cta-link.json +13 -0
  230. data/scripts/test/render-parity/blocks/lang-pill-pt.json +8 -0
  231. data/scripts/test/render-parity/blocks/lang-pill.json +8 -0
  232. data/scripts/test/render-parity/blocks/ledger-row-meta-parts.json +29 -0
  233. data/scripts/test/render-parity/blocks/ledger-row.json +35 -0
  234. data/scripts/test/render-parity/blocks/meta-strip.json +27 -0
  235. data/scripts/test/render-parity/blocks/poster-media-image.json +8 -0
  236. data/scripts/test/render-parity/blocks/poster-media-video.json +9 -0
  237. data/scripts/test/render-parity/blocks/section-banner.json +13 -0
  238. data/scripts/test/render-parity/blocks/system-card-custom-meta.json +22 -0
  239. data/scripts/test/render-parity/blocks/system-card.json +48 -0
  240. data/scripts/test/render-parity/blocks/tag-chips-links.json +12 -0
  241. data/scripts/test/render-parity/blocks/tag-chips.json +9 -0
  242. data/scripts/test/render-parity/cut-in-blank-line.json +9 -0
  243. data/scripts/test/render-parity/cut-in-right.json +9 -0
  244. data/scripts/test/render-parity/cut-in.json +8 -0
  245. data/scripts/test/render-parity/figure.json +5 -0
  246. data/scripts/test/render-parity/load-runtime.mjs +337 -0
  247. data/scripts/test/render-parity/ongoing-product.json +5 -0
  248. data/scripts/test/render-parity/plotly.json +5 -0
  249. data/scripts/test/render-parity/poem.json +5 -0
  250. data/scripts/test/render-parity/run-absent-field-checks.mjs +232 -0
  251. data/scripts/test/render-parity/run-block-fixture.mjs +86 -0
  252. data/scripts/test/render-parity/run-detail-parity-checks.mjs +123 -0
  253. data/scripts/test/render-parity/run-field-flow-checks.mjs +227 -0
  254. data/scripts/test/render-parity/run-fixture.mjs +159 -0
  255. data/scripts/test/render-parity/selectors.mjs +43 -0
  256. data/scripts/test/render-parity/video.json +5 -0
  257. data/scripts/test/render-parity/youtube-short.json +10 -0
  258. data/scripts/test/render-parity/youtube.json +8 -0
  259. data/scripts/test/test-absent-field-absent-element.rb +43 -0
  260. data/scripts/test/test-cdn-pins.rb +44 -0
  261. data/scripts/test/test-cloudflare-watch-paths-doc.rb +31 -0
  262. data/scripts/test/test-configuration-doc.rb +36 -0
  263. data/scripts/test/test-content-detail-urls.rb +346 -0
  264. data/scripts/test/test-content-keys.rb +110 -0
  265. data/scripts/test/test-content-license.rb +45 -0
  266. data/scripts/test/test-content-render-parity.rb +38 -0
  267. data/scripts/test/test-content-validators.rb +12 -0
  268. data/scripts/test/test-css-structure.rb +72 -0
  269. data/scripts/test/test-cv-export-contract.rb +158 -0
  270. data/scripts/test/test-detail-render-parity.rb +40 -0
  271. data/scripts/test/test-dev-reexport.rb +122 -0
  272. data/scripts/test/test-doctor.rb +39 -0
  273. data/scripts/test/test-dual-template-instances.rb +87 -0
  274. data/scripts/test/test-dual-template-runtime.rb +167 -0
  275. data/scripts/test/test-examples-build.rb +60 -0
  276. data/scripts/test/test-export-content-json-incremental.rb +138 -0
  277. data/scripts/test/test-export.rb +53 -0
  278. data/scripts/test/test-exported-fields-reach-the-view.rb +60 -0
  279. data/scripts/test/test-form-control-theming.rb +143 -0
  280. data/scripts/test/test-git-chronology.rb +414 -0
  281. data/scripts/test/test-hero-portraits.mjs +97 -0
  282. data/scripts/test/test-hero-portraits.rb +162 -0
  283. data/scripts/test/test-home-band-art.rb +135 -0
  284. data/scripts/test/test-install-functions.rb +39 -0
  285. data/scripts/test/test-language-export.rb +79 -0
  286. data/scripts/test/test-listing-filter-contract.rb +119 -0
  287. data/scripts/test/test-math-row-breaks.mjs +79 -0
  288. data/scripts/test/test-math-row-breaks.rb +18 -0
  289. data/scripts/test/test-new.rb +113 -0
  290. data/scripts/test/test-no-personal-data.rb +85 -0
  291. data/scripts/test/test-public-cv-export.rb +164 -0
  292. data/scripts/test/test-r2-config-consistency.rb +57 -0
  293. data/scripts/test/test-r2-json-sync.rb +193 -0
  294. data/scripts/test/test-registry-home.rb +34 -0
  295. data/scripts/test/test-runtime-js-syntax.rb +64 -0
  296. data/scripts/test/test-slim-writing-index.rb +126 -0
  297. data/scripts/test/test-studio-api-contract.rb +99 -0
  298. data/scripts/test/test-studio-content-files.rb +79 -0
  299. data/scripts/test/test-studio-liquid-roundtrip.mjs +179 -0
  300. data/scripts/test/test-studio-rate-limit.mjs +70 -0
  301. data/scripts/test/test-studio-rewrite-api-contract.rb +59 -0
  302. data/scripts/test/test-studio-rewrite-sanitize.mjs +210 -0
  303. data/scripts/test/test-studio-schema-composition.rb +126 -0
  304. data/scripts/test/test-studio-slug-config.rb +167 -0
  305. data/scripts/test/test-template-packages.rb +51 -0
  306. data/scripts/test/test-text-excerpt.rb +51 -0
  307. data/scripts/test/test-unwrap-media-src.mjs +39 -0
  308. data/scripts/test/test-unwrap-media-src.rb +19 -0
  309. data/scripts/test/test-withdrawn-content-export.rb +69 -0
  310. data/scripts/test/test-writing-index-export-slugs.rb +84 -0
  311. data/scripts/test/test-writing-slug-limits.rb +61 -0
  312. data/scripts/test/test-xcite-chronology.rb +106 -0
  313. data/scripts/validate.sh +74 -0
  314. data/scripts/visual/build-cv-pages-json.js +33 -0
  315. data/scripts/visual/build-site-pages-json.js +94 -0
  316. data/scripts/visual/publish-visual-inspection-to-r2.sh +42 -0
  317. data/scripts/visual/take-screenshots.js +88 -0
  318. data/scripts/visual/visual-inspection-pages.json +113 -0
  319. data/studio/assets/index-B_j0pMD5.css +1 -0
  320. data/studio/assets/index-Ch4YEv9g.js +285 -0
  321. data/studio/index.html +27 -0
  322. data/studio/schema.yml +869 -0
  323. data/studio-app/README.md +27 -0
  324. data/studio-app/index.html +26 -0
  325. data/studio-app/package-lock.json +2499 -0
  326. data/studio-app/package.json +26 -0
  327. data/studio-app/skills-lock.json +47 -0
  328. data/studio-app/src/api/client.js +71 -0
  329. data/studio-app/src/editor/body-editor.js +70 -0
  330. data/studio-app/src/editor/liquid-core.js +382 -0
  331. data/studio-app/src/editor/liquid.js +77 -0
  332. data/studio-app/src/lib/document.js +207 -0
  333. data/studio-app/src/lib/list-controls.js +317 -0
  334. data/studio-app/src/lib/schema.js +66 -0
  335. data/studio-app/src/lib/yaml-doc.js +100 -0
  336. data/studio-app/src/main.js +3693 -0
  337. data/studio-app/src/media/browser.js +578 -0
  338. data/studio-app/src/preview/render.js +430 -0
  339. data/studio-app/src/styles/studio.css +2826 -0
  340. data/studio-app/vite.config.js +25 -0
  341. data/templates/articles/README.md +40 -0
  342. data/templates/articles/fixtures/article.md +9 -0
  343. data/templates/articles/screenshots/README.md +6 -0
  344. data/templates/articles/studio.yml +73 -0
  345. data/templates/articles/template.yml +21 -0
  346. data/templates/bibliography/README.md +41 -0
  347. data/templates/bibliography/fixtures/references.json +9 -0
  348. data/templates/bibliography/screenshots/README.md +6 -0
  349. data/templates/bibliography/studio.yml +66 -0
  350. data/templates/bibliography/template.yml +18 -0
  351. data/templates/blog/README.md +38 -0
  352. data/templates/blog/fixtures/post.md +9 -0
  353. data/templates/blog/screenshots/README.md +6 -0
  354. data/templates/blog/studio.yml +69 -0
  355. data/templates/blog/template.yml +20 -0
  356. data/templates/cv/README.md +34 -0
  357. data/templates/cv/fixtures/job.md +12 -0
  358. data/templates/cv/screenshots/README.md +6 -0
  359. data/templates/cv/studio.yml +200 -0
  360. data/templates/cv/template.yml +25 -0
  361. data/templates/media/README.md +38 -0
  362. data/templates/media/fixtures/resource.md +7 -0
  363. data/templates/media/screenshots/README.md +6 -0
  364. data/templates/media/studio.yml +58 -0
  365. data/templates/media/template.yml +17 -0
  366. data/templates/network/README.md +34 -0
  367. data/templates/network/fixtures/sources.json +3 -0
  368. data/templates/network/screenshots/README.md +6 -0
  369. data/templates/network/studio.yml +28 -0
  370. data/templates/network/template.yml +10 -0
  371. data/templates/portfolio/README.md +39 -0
  372. data/templates/portfolio/fixtures/project.md +10 -0
  373. data/templates/portfolio/screenshots/README.md +6 -0
  374. data/templates/portfolio/studio.yml +89 -0
  375. data/templates/portfolio/template.yml +18 -0
  376. metadata +505 -0
@@ -0,0 +1,1400 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * md-to-pdf.mjs — Render editorial Markdown (articles, posts, …) to a shareable A4 PDF.
4
+ *
5
+ * Usage:
6
+ * node scripts/pdf/md-to-pdf.mjs -i <input.md> -o <output.pdf>
7
+ *
8
+ * Spec: docs/features/markdown-pdf.md
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { marked } from 'marked';
15
+ import yaml from 'js-yaml';
16
+ import puppeteer from 'puppeteer';
17
+ import { normalizeMathRegion } from '../lib/normalize-display-math.mjs';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
21
+
22
+ function usage(exitCode = 1) {
23
+ console.error('Usage: node scripts/pdf/md-to-pdf.mjs -i <input.md> -o <output.pdf>');
24
+ process.exit(exitCode);
25
+ }
26
+
27
+ function parseArgs(argv) {
28
+ const args = { input: null, output: null };
29
+ for (let i = 0; i < argv.length; i += 1) {
30
+ const a = argv[i];
31
+ if (a === '-i' || a === '--input') {
32
+ args.input = argv[++i];
33
+ } else if (a === '-o' || a === '--output') {
34
+ args.output = argv[++i];
35
+ } else if (a === '-h' || a === '--help') {
36
+ usage(0);
37
+ } else {
38
+ console.error(`Unknown argument: ${a}`);
39
+ usage(1);
40
+ }
41
+ }
42
+ if (!args.input || !args.output) usage(1);
43
+ return args;
44
+ }
45
+
46
+ function escapeHtml(value) {
47
+ return String(value ?? '')
48
+ .replace(/&/g, '&amp;')
49
+ .replace(/</g, '&lt;')
50
+ .replace(/>/g, '&gt;')
51
+ .replace(/"/g, '&quot;');
52
+ }
53
+
54
+ function escapeAttr(value) {
55
+ return escapeHtml(value).replace(/'/g, '&#39;');
56
+ }
57
+
58
+ // Same delimiters as kramdown's MathJax config (_config.yml) — see docs/content/elements.md.
59
+ function containsMathMarkup(markdown) {
60
+ const text = String(markdown || '');
61
+ if (/\$\$[\s\S]+?\$\$/.test(text)) return true;
62
+ if (/\\\([\s\S]+?\\\)/.test(text)) return true;
63
+ if (/\\\[[\s\S]+?\\\]/.test(text)) return true;
64
+ // Inline `$...$`, mirroring MathJax's own rule: no whitespace touching the delimiters.
65
+ if (/\$[^\s$](?:[^$]*?[^\s$])?\$/.test(text)) return true;
66
+ return false;
67
+ }
68
+
69
+ // marked/CommonMark would otherwise mangle LaTeX inside math regions: backslash
70
+ // escapes strip `\,`, `\{`, and turn a `\\` row break into a hard `<br>`;
71
+ // underscores and asterisks can be read as emphasis (`x_i`, `a*b`). Pulling the
72
+ // whole region out into an opaque placeholder before marked.parse — and putting
73
+ // the original text back (HTML-escaped) afterwards — sidesteps all of that
74
+ // instead of trying to out-guess Markdown's escaping rules. Code spans/fences
75
+ // are left untouched.
76
+ const MATH_REGION_PATTERN = /\$\$[\s\S]+?\$\$|\\\[[\s\S]+?\\\]|\\\([\s\S]+?\\\)|\$[^\s$](?:[^$]*?[^\s$])?\$/g;
77
+ const MATH_PLACEHOLDER_PREFIX = '\uE000MDPDFMATH';
78
+ const MATH_PLACEHOLDER_SUFFIX = '\uE001';
79
+
80
+ function extractMathRegions(markdown, mathRegistry) {
81
+ const text = String(markdown || '');
82
+ const segments = text.split(/(```[\s\S]*?```|`[^`\n]*`)/g);
83
+ return segments
84
+ .map((segment, i) => {
85
+ if (i % 2 === 1) return segment;
86
+ return segment.replace(MATH_REGION_PATTERN, (mathMatch) => {
87
+ const index = mathRegistry.length;
88
+ // Same row-break repair as the browser content runtime.
89
+ mathRegistry.push(normalizeMathRegion(mathMatch));
90
+ return `${MATH_PLACEHOLDER_PREFIX}${index}${MATH_PLACEHOLDER_SUFFIX}`;
91
+ });
92
+ })
93
+ .join('');
94
+ }
95
+
96
+ function restoreMathRegions(html, mathRegistry) {
97
+ let result = String(html || '');
98
+ mathRegistry.forEach((mathText, index) => {
99
+ result = result.split(`${MATH_PLACEHOLDER_PREFIX}${index}${MATH_PLACEHOLDER_SUFFIX}`).join(escapeHtml(mathText));
100
+ });
101
+ return result;
102
+ }
103
+
104
+ function parseIncludeAttrs(raw) {
105
+ const attrs = {};
106
+ let text = String(raw || '');
107
+ text = text.replace(/([\w-]+)\s*=\s*(?:"([\s\S]*?)"|'([\s\S]*?)')/g, (_, key, dbl, sgl) => {
108
+ attrs[key] = dbl != null ? dbl : sgl;
109
+ return '';
110
+ });
111
+ text.replace(/([\w-]+)\s*=\s*([^\s]+)/g, (_, key, val) => {
112
+ if (!(key in attrs)) attrs[key] = val;
113
+ return '';
114
+ });
115
+ return attrs;
116
+ }
117
+
118
+ function unwrapMediaSrc(path) {
119
+ const value = String(path == null ? '' : path).trim();
120
+ if (!value) return '';
121
+ const md = value.match(/^\[([^\]]*)\]\(([^)\s]+)\)\s*$/);
122
+ if (md) return String(md[2] || '').trim();
123
+ const angle = value.match(/^<([^>\s]+)>\s*$/);
124
+ if (angle) return String(angle[1] || '').trim();
125
+ return value;
126
+ }
127
+
128
+ function cutInHtml(attrs) {
129
+ const src = unwrapMediaSrc(attrs.src);
130
+ if (!src) return '';
131
+ const sideClass = attrs.side === 'right' ? ' article-cut-in--right' : '';
132
+ return `<img class="article-cut-in${sideClass}" src="${escapeAttr(src)}" alt="${escapeAttr(attrs.alt || '')}" width="120" loading="lazy">`;
133
+ }
134
+
135
+ function figureHtml(attrs) {
136
+ const src = unwrapMediaSrc(attrs.src);
137
+ if (!src) return '';
138
+ const caption = attrs.caption || '';
139
+ const source = attrs.source || '';
140
+ const width = attrs.width ? `max-width:${escapeAttr(attrs.width)}%;` : 'max-width:100%;';
141
+ const capParts = [];
142
+ if (caption) capParts.push(escapeHtml(caption));
143
+ if (source) capParts.push(`Source: ${escapeHtml(source)}`);
144
+ return [
145
+ `<figure class="pdf-figure" style="${width}">`,
146
+ `<img src="${escapeAttr(src)}" alt="${escapeAttr(attrs.alt || caption || 'Figure')}" loading="lazy">`,
147
+ capParts.length ? `<figcaption>${capParts.join(' | ')}</figcaption>` : '',
148
+ '</figure>',
149
+ ].join('');
150
+ }
151
+
152
+ function mediaNoteHtml(label, url) {
153
+ const href = url ? `<a href="${escapeAttr(url)}">${escapeHtml(url)}</a>` : '';
154
+ return `<aside class="pdf-media-note"><strong>${escapeHtml(label)}</strong>${href ? ` | ${href}` : ''}</aside>`;
155
+ }
156
+
157
+ // A readable substitute for embeds that cannot play in a static PDF: a linked
158
+ // thumbnail (when one exists) plus a plain "go watch it" link, no mention of
159
+ // what got "omitted" — the reader just wants to know where to watch it.
160
+ function mediaWatchCardHtml({ thumbSrc, watchUrl, title, caption, linkLabel }) {
161
+ const titleHtml = title ? `<strong>${escapeHtml(title)}</strong>` : '';
162
+ const captionHtml = caption ? `<span class="pdf-media-desc">${escapeHtml(caption)}</span>` : '';
163
+ const label = escapeHtml(linkLabel || 'Watch the video');
164
+ const thumbHtml = thumbSrc
165
+ ? [
166
+ `<a class="pdf-media-thumb-link" href="${escapeAttr(watchUrl)}">`,
167
+ `<img src="${escapeAttr(thumbSrc)}" alt="${escapeAttr(title || label)}">`,
168
+ '<span class="pdf-media-play">&#9658;</span>',
169
+ '</a>',
170
+ ].join('')
171
+ : '<div class="pdf-media-noimg"><span class="pdf-media-play">&#9658;</span></div>';
172
+ return [
173
+ '<div class="pdf-media-card">',
174
+ thumbHtml,
175
+ '<div class="pdf-media-caption">',
176
+ titleHtml,
177
+ captionHtml,
178
+ `<a href="${escapeAttr(watchUrl)}">${label} &#8599;</a>`,
179
+ '</div>',
180
+ '</div>',
181
+ ].join('');
182
+ }
183
+
184
+ function youtubeWatchUrl(id) {
185
+ return `https://www.youtube.com/watch?v=${id}`;
186
+ }
187
+
188
+ function youtubeThumbnailUrl(id) {
189
+ return `https://img.youtube.com/vi/${id}/hqdefault.jpg`;
190
+ }
191
+
192
+ const youtubeTitleCache = new Map();
193
+
194
+ // Best-effort: the noembed/oEmbed title makes the card useful even without a
195
+ // caption. A failed or slow lookup just falls back to a generic label.
196
+ async function fetchYoutubeTitle(id) {
197
+ if (youtubeTitleCache.has(id)) return youtubeTitleCache.get(id);
198
+ const promise = (async () => {
199
+ try {
200
+ const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(youtubeWatchUrl(id))}&format=json`;
201
+ const res = await fetch(oembedUrl, { signal: AbortSignal.timeout(8000) });
202
+ if (!res.ok) return null;
203
+ const data = await res.json();
204
+ return typeof data.title === 'string' ? data.title : null;
205
+ } catch (err) {
206
+ console.warn(`Could not fetch YouTube title for ${id}: ${err.message}`);
207
+ return null;
208
+ }
209
+ })();
210
+ youtubeTitleCache.set(id, promise);
211
+ return promise;
212
+ }
213
+
214
+ function youtubePlaceholder(index) {
215
+ return `<div class="pdf-youtube-placeholder" data-youtube-id="${index}"></div>`;
216
+ }
217
+
218
+ function registerYoutubeCard(registry, { id, caption, isShort }) {
219
+ if (!id) return '';
220
+ const index = registry.length;
221
+ registry.push({ index, id, caption, isShort });
222
+ return youtubePlaceholder(index);
223
+ }
224
+
225
+ async function resolveYoutubeCard(entry) {
226
+ const title = await fetchYoutubeTitle(entry.id);
227
+ return mediaWatchCardHtml({
228
+ thumbSrc: youtubeThumbnailUrl(entry.id),
229
+ watchUrl: youtubeWatchUrl(entry.id),
230
+ title,
231
+ caption: entry.caption,
232
+ linkLabel: entry.isShort ? 'Watch the Short on YouTube' : 'Watch on YouTube',
233
+ });
234
+ }
235
+
236
+ async function resolveYoutubePlaceholders(bodyHtml, registry) {
237
+ const replacements = await Promise.all(registry.map(resolveYoutubeCard));
238
+ let html = bodyHtml;
239
+ registry.forEach((entry, i) => {
240
+ html = html.replace(youtubePlaceholder(entry.index), replacements[i]);
241
+ });
242
+ return html;
243
+ }
244
+
245
+ function videoHtml(attrs) {
246
+ const src = unwrapMediaSrc(attrs.src);
247
+ if (!src) return '';
248
+ const caption = attrs.caption || '';
249
+ return mediaWatchCardHtml({
250
+ thumbSrc: null,
251
+ watchUrl: src,
252
+ title: caption || null,
253
+ caption: '',
254
+ linkLabel: 'Watch the video',
255
+ });
256
+ }
257
+
258
+ function plotlyPlaceholder(index) {
259
+ return `<div class="pdf-plotly-placeholder" data-plotly-id="${index}"></div>`;
260
+ }
261
+
262
+ function plotlyFallbackHtml(attrs) {
263
+ const src = attrs.src || '(no path)';
264
+ return mediaNoteHtml('Interactive Plotly chart omitted in PDF', src);
265
+ }
266
+
267
+ function plotlySnapshotHtml(attrs, dataUri) {
268
+ const caption = attrs.caption || '';
269
+ const source = attrs.source || '';
270
+ const width = attrs.width ? `max-width:${escapeAttr(attrs.width)}%;` : 'max-width:100%;';
271
+ const capParts = [];
272
+ if (caption) capParts.push(escapeHtml(caption));
273
+ if (source) capParts.push(`Source: ${escapeHtml(source)}`);
274
+ return [
275
+ `<figure class="pdf-figure" style="${width}">`,
276
+ // No loading="lazy": the data URI is already embedded, and lazy-loading would
277
+ // defer the image past Puppeteer's initial viewport, leaving it blank in print.
278
+ `<img src="${dataUri}" alt="${escapeAttr(attrs.alt || caption || 'Chart snapshot')}">`,
279
+ capParts.length ? `<figcaption>${capParts.join(' | ')}</figcaption>` : '',
280
+ '</figure>',
281
+ ].join('');
282
+ }
283
+
284
+ // Resolve a plotly.html `src` (site-root-relative `/media/plots/…` or an
285
+ // absolute URL) to a Puppeteer navigation target and a local existence check.
286
+ function resolvePlotlyTarget(src) {
287
+ if (/^https?:\/\//i.test(src)) {
288
+ return { url: src, localPath: null };
289
+ }
290
+ const localPath = path.join(REPO_ROOT, 'content', src.replace(/^\/+/, ''));
291
+ return { url: `file://${localPath}`, localPath };
292
+ }
293
+
294
+ async function screenshotPlotlyChart(browser, attrs) {
295
+ const src = unwrapMediaSrc(attrs.src);
296
+ if (!src) return null;
297
+
298
+ const { url, localPath } = resolvePlotlyTarget(src);
299
+ if (localPath && !fs.existsSync(localPath)) {
300
+ console.warn(`Plotly chart not found, falling back to note: ${src}`);
301
+ return null;
302
+ }
303
+
304
+ const heightRaw = parseInt(attrs.height, 10);
305
+ const height = Number.isFinite(heightRaw) && heightRaw > 0 ? heightRaw : 480;
306
+
307
+ const page = await browser.newPage();
308
+ try {
309
+ await page.setViewport({ width: 1000, height: height + 40, deviceScaleFactor: 2 });
310
+ await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
311
+ await page
312
+ .waitForSelector('.js-plotly-plot .main-svg, .js-plotly-plot .gl-container', { timeout: 15000 })
313
+ .catch(() => {});
314
+ await new Promise((resolve) => setTimeout(resolve, 300));
315
+
316
+ // The mode bar (zoom/pan/download icons) is inert in a static snapshot; hide it.
317
+ await page.evaluate(() => {
318
+ document.querySelectorAll('.modebar-container').forEach((el) => {
319
+ el.style.display = 'none';
320
+ });
321
+ });
322
+
323
+ const handle = (await page.$('.plotly-graph-div')) || (await page.$('body'));
324
+ if (!handle) return null;
325
+ const buffer = await handle.screenshot({ type: 'png' });
326
+ return `data:image/png;base64,${buffer.toString('base64')}`;
327
+ } catch (err) {
328
+ console.warn(`Failed to screenshot Plotly chart (${src}): ${err.message}`);
329
+ return null;
330
+ } finally {
331
+ await page.close();
332
+ }
333
+ }
334
+
335
+ async function resolvePlotlyPlaceholders(bodyHtml, registry, browser) {
336
+ let html = bodyHtml;
337
+ for (const entry of registry) {
338
+ const dataUri = await screenshotPlotlyChart(browser, entry.attrs);
339
+ const replacement = dataUri ? plotlySnapshotHtml(entry.attrs, dataUri) : plotlyFallbackHtml(entry.attrs);
340
+ html = html.replace(plotlyPlaceholder(entry.index), replacement);
341
+ }
342
+ return html;
343
+ }
344
+
345
+ function ongoingProductHtml() {
346
+ return '<span class="pdf-badge">Currently active</span>';
347
+ }
348
+
349
+ // Mirrors normalizeDataPath in _includes/content-runtime/10-utils.html, but
350
+ // resolves against the on-disk YAML source instead of the exported JSON.
351
+ function resolveDataYamlPath(rawValue, fallbackDir) {
352
+ let value = String(rawValue || '').trim();
353
+ if (!value) return null;
354
+ value = value.replace(/^content\/collections\/data\//, '');
355
+ value = value.replace(/^_data\//, '');
356
+ value = value.replace(/^data\//, '');
357
+ if (!/\.(ya?ml)$/i.test(value)) {
358
+ if (value.indexOf('/') === -1 && fallbackDir) value = `${fallbackDir}/${value}`;
359
+ value = `${value}.yml`;
360
+ }
361
+ return path.join(REPO_ROOT, 'content/collections/data', value);
362
+ }
363
+
364
+ function loadDataYaml(rawValue, fallbackDir) {
365
+ const filePath = resolveDataYamlPath(rawValue, fallbackDir);
366
+ if (!filePath || !fs.existsSync(filePath)) return null;
367
+ try {
368
+ return yaml.load(fs.readFileSync(filePath, 'utf8'));
369
+ } catch (err) {
370
+ console.warn(`Failed to parse YAML data file (${filePath}): ${err.message}`);
371
+ return null;
372
+ }
373
+ }
374
+
375
+ function chordTabPlaceholder(index) {
376
+ return `<div class="pdf-chordtab-placeholder" data-chordtab-id="${index}"></div>`;
377
+ }
378
+
379
+ function chordTabPdfHtml(data) {
380
+ const sections = (data && Array.isArray(data.sections) && data.sections) || [];
381
+ const introHtml = data && data.intro ? `<div class="pdf-chord-tab-intro"><pre>${escapeHtml(data.intro)}</pre></div>` : '';
382
+ const sectionsHtml = sections
383
+ .map((section) => {
384
+ const label =
385
+ section && section.label ? `<span class="pdf-chord-tab-section-label">${escapeHtml(section.label)}</span>` : '';
386
+ const content = section && section.content ? escapeHtml(section.content) : '';
387
+ return `<div class="pdf-chord-tab-section">${label}<pre>${content}</pre></div>`;
388
+ })
389
+ .join('');
390
+ return ['<div class="pdf-chord-tab">', '<h2>Cifra</h2>', introHtml, sectionsHtml, '</div>'].join(
391
+ '',
392
+ );
393
+ }
394
+
395
+ function chordTabFallbackHtml() {
396
+ return mediaNoteHtml('Chord tab omitted in PDF', '');
397
+ }
398
+
399
+ function resolveChordTabPlaceholders(bodyHtml, registry) {
400
+ let html = bodyHtml;
401
+ for (const entry of registry) {
402
+ const rawPath = entry.attrs.path || entry.attrs.file || entry.attrs.src || entry.attrs.tab;
403
+ const data = loadDataYaml(rawPath, 'tabs');
404
+ const replacement = data ? chordTabPdfHtml(data) : chordTabFallbackHtml();
405
+ html = html.replace(chordTabPlaceholder(entry.index), replacement);
406
+ }
407
+ return html;
408
+ }
409
+
410
+ function loadSiteConfig() {
411
+ const configPath = path.join(REPO_ROOT, '_config.yml');
412
+ try {
413
+ const raw = fs.readFileSync(configPath, 'utf8');
414
+ return yaml.load(raw) || {};
415
+ } catch {
416
+ return {};
417
+ }
418
+ }
419
+
420
+ function collectXciteIndex() {
421
+ const index = new Map();
422
+ const roots = [
423
+ path.join(REPO_ROOT, 'content/collections/articles'),
424
+ path.join(REPO_ROOT, 'content/collections/posts'),
425
+ path.join(REPO_ROOT, 'content/collections/products'),
426
+ path.join(REPO_ROOT, 'content/collections/projects'),
427
+ ];
428
+ for (const dir of roots) {
429
+ if (!fs.existsSync(dir)) continue;
430
+ for (const name of fs.readdirSync(dir)) {
431
+ if (!/\.(md|markdown)$/i.test(name)) continue;
432
+ const full = path.join(dir, name);
433
+ try {
434
+ const text = fs.readFileSync(full, 'utf8');
435
+ const parsed = splitFrontMatter(text);
436
+ const key = String(parsed.data.key || '').trim();
437
+ if (!key) continue;
438
+ index.set(key, {
439
+ title: parsed.data.title || key,
440
+ type: path.basename(dir),
441
+ });
442
+ } catch {
443
+ /* ignore unreadable files */
444
+ }
445
+ }
446
+ }
447
+ return index;
448
+ }
449
+
450
+ function xciteHtml(key, index, siteUrl) {
451
+ const item = index.get(key);
452
+ if (!item) return '<span class="pdf-xcite-missing">[' + escapeHtml(key) + '?]</span>';
453
+ // Construct the URL based on the collection type, defaulting to articles if unknown
454
+ const baseUrl = String(siteUrl || '').replace(/\/$/, '');
455
+ let collectionSegment = 'articles';
456
+ if (item.type === 'posts') collectionSegment = 'posts';
457
+ else if (item.type === 'products') collectionSegment = 'products';
458
+ else if (item.type === 'projects') collectionSegment = 'projects';
459
+
460
+ const href = baseUrl + '/' + collectionSegment + '/' + escapeAttr(key) + '/';
461
+ return '<a href="' + href + '" class="pdf-xcite" target="_blank" rel="noopener noreferrer"><em>' + escapeHtml(item.title) + '</em></a>';
462
+ }
463
+
464
+ function splitFrontMatter(text) {
465
+ const cleaned = String(text || '').replace(/^\uFEFF/, '');
466
+ if (!cleaned.startsWith('---\n') && !cleaned.startsWith('---\r\n')) {
467
+ return { data: {}, body: cleaned };
468
+ }
469
+ const match = cleaned.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
470
+ if (!match) return { data: {}, body: cleaned };
471
+ let data = {};
472
+ try {
473
+ data = yaml.load(match[1]) || {};
474
+ } catch (err) {
475
+ throw new Error(`Failed to parse YAML front matter: ${err.message}`);
476
+ }
477
+ return { data, body: match[2] };
478
+ }
479
+
480
+
481
+ // --- Bibliography (Citations) ---
482
+
483
+ function collectBibIndex() {
484
+ const index = {};
485
+ const dir = path.join(REPO_ROOT, 'content/collections/bibliography');
486
+ if (!fs.existsSync(dir)) return index;
487
+ for (const name of fs.readdirSync(dir)) {
488
+ if (!/\.ya?ml$/i.test(name)) continue;
489
+ try {
490
+ const text = fs.readFileSync(path.join(dir, name), 'utf8');
491
+ const entry = yaml.load(text) || {};
492
+ const key = path.basename(name, path.extname(name));
493
+ index[key] = entry;
494
+ } catch (err) {
495
+ console.warn(`Failed to parse bib file ${name}: ${err.message}`);
496
+ }
497
+ }
498
+ return index;
499
+ }
500
+
501
+ function bibField(entry, name) {
502
+ return String(entry[name] == null ? '' : entry[name]).trim();
503
+ }
504
+
505
+ function splitBibAuthors(raw) {
506
+ const authors = [];
507
+ let current = '';
508
+ let depth = 0;
509
+ const text = String(raw || '');
510
+ for (let i = 0; i < text.length; i++) {
511
+ const ch = text[i];
512
+ if (ch === '{') {
513
+ depth++;
514
+ current += ch;
515
+ continue;
516
+ }
517
+ if (ch === '}') {
518
+ depth--;
519
+ current += ch;
520
+ continue;
521
+ }
522
+ if (depth === 0 && text.slice(i, i + 5) === ' and ') {
523
+ if (current.trim()) authors.push(current.trim());
524
+ current = '';
525
+ i += 4;
526
+ continue;
527
+ }
528
+ current += ch;
529
+ }
530
+ if (current.trim()) authors.push(current.trim());
531
+ return authors;
532
+ }
533
+
534
+ function formatIeeeAuthor(name) {
535
+ const author = String(name || '').trim();
536
+ if (!author) return '';
537
+ if (author.charAt(0) === '{' && author.charAt(author.length - 1) === '}') {
538
+ return escapeHtml(author.slice(1, -1));
539
+ }
540
+ const parts = author.split(',');
541
+ if (parts.length < 2) return escapeHtml(author);
542
+ const last = parts[0].trim();
543
+ const first = parts.slice(1).join(',').trim();
544
+ const initials = first.split(/\s+/).filter(Boolean).map((word) => {
545
+ return escapeHtml(word.charAt(0)) + '.';
546
+ }).join(' ');
547
+ return initials + ' ' + escapeHtml(last);
548
+ }
549
+
550
+ function formatIeeeAuthors(field) {
551
+ const authors = splitBibAuthors(field);
552
+ if (!authors.length) return '';
553
+ const formatted = authors.map(formatIeeeAuthor);
554
+ if (formatted.length === 1) return formatted[0];
555
+ if (formatted.length === 2) return formatted[0] + ' and ' + formatted[1];
556
+ return formatted.slice(0, -1).join(', ') + ', and ' + formatted[formatted.length - 1];
557
+ }
558
+
559
+ function formatIeeeEntry(entry, number) {
560
+ const type = (entry && entry.type) || 'misc';
561
+ const author = formatIeeeAuthors(bibField(entry, 'author'));
562
+ const title = bibField(entry, 'title');
563
+ const year = bibField(entry, 'year');
564
+ const url = bibField(entry, 'url');
565
+ const parts = [];
566
+
567
+ if (type === 'book') {
568
+ if (author) parts.push(author);
569
+ if (title) parts.push('<em>' + escapeHtml(title) + '</em>');
570
+ const publisher = bibField(entry, 'publisher');
571
+ const tail = [publisher, year].filter(Boolean).join(', ');
572
+ if (tail) parts.push(escapeHtml(tail) + '.');
573
+ } else if (type === 'article') {
574
+ if (author) parts.push(author + ',');
575
+ if (title) parts.push('"' + escapeHtml(title) + ',"');
576
+ const journal = bibField(entry, 'journal');
577
+ if (journal) parts.push('<em>' + escapeHtml(journal) + '</em>');
578
+ const meta = [];
579
+ const volume = bibField(entry, 'volume');
580
+ const issue = bibField(entry, 'number');
581
+ const pages = bibField(entry, 'pages').replace(/--/g, '\u2013');
582
+ if (volume) meta.push('vol. ' + escapeHtml(volume));
583
+ if (issue) meta.push('no. ' + escapeHtml(issue));
584
+ if (pages) meta.push('pp. ' + escapeHtml(pages));
585
+ if (meta.length) parts.push(meta.join(', ') + ',');
586
+ if (year) parts.push(escapeHtml(year) + '.');
587
+ } else if (type === 'techreport') {
588
+ if (author) parts.push(author + ',');
589
+ if (title) parts.push('"' + escapeHtml(title) + ',"');
590
+ const institution = bibField(entry, 'institution');
591
+ if (institution) parts.push(escapeHtml(institution) + ',');
592
+ if (year) parts.push(escapeHtml(year) + '.');
593
+ } else {
594
+ if (author) parts.push(author + ',');
595
+ if (title) parts.push('"' + escapeHtml(title) + ',"');
596
+ const howpublished = bibField(entry, 'howpublished');
597
+ if (howpublished) parts.push(escapeHtml(howpublished) + ',');
598
+ if (year) parts.push(escapeHtml(year) + '.');
599
+ }
600
+
601
+ const note = bibField(entry, 'note');
602
+ if (note) parts.push(escapeHtml(note) + '.');
603
+
604
+ let text = parts.join(' ');
605
+ if (url) {
606
+ text += ' [Online]. Available: <a href="' + escapeAttr(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(url) + '</a>';
607
+ }
608
+ return text;
609
+ }
610
+
611
+ function processCitations(markdown, bibIndex, citedKeysOut) {
612
+ const keyToNumber = {};
613
+ return String(markdown || '').replace(/\{%-?\s*cite\s+([^%\s]+)[\s\S]*?-?%\}/g, (_, rawKey) => {
614
+ const key = String(rawKey || '').trim();
615
+ if (!key) return '';
616
+ if (!Object.prototype.hasOwnProperty.call(keyToNumber, key)) {
617
+ citedKeysOut.push(key);
618
+ keyToNumber[key] = citedKeysOut.length;
619
+ }
620
+ const number = keyToNumber[key];
621
+ if (!bibIndex[key]) {
622
+ return `<sup class='pdf-cite-missing'>[${escapeHtml(key)}?]</sup>`;
623
+ }
624
+ return `<sup class='pdf-cite'><a href='#cite-${escapeAttr(key)}'>[${number}]</a></sup>`;
625
+ });
626
+ }
627
+
628
+ function buildReferencesHtml(citedKeys, bibIndex, lang) {
629
+ if (!citedKeys.length) return '';
630
+ const items = citedKeys.map((key, index) => {
631
+ const number = index + 1;
632
+ const entry = bibIndex[key];
633
+ const body = entry ? formatIeeeEntry(entry, number) : `${escapeHtml(key)} (missing bibliography entry)`;
634
+ return `<li id="cite-${escapeAttr(key)}"><span>[${number}]</span> ${body}</li>`;
635
+ }).join('');
636
+ const heading = String(lang || '').toLowerCase().startsWith('pt') ? 'Referências' : 'References';
637
+ return `<div class="pdf-references">
638
+ <h2>${escapeHtml(heading)}</h2>
639
+ <ul class="pdf-bib-list">${items}</ul>
640
+ </div>`;
641
+ }
642
+
643
+ // --- End Bibliography ---
644
+
645
+ function formatDefHtml(def) {
646
+ return escapeHtml(String(def || '').trim()).replace(/\r\n|\r|\n/g, '<br>');
647
+ }
648
+
649
+ function dictCardHtml(attrs) {
650
+ const lemma = String(attrs.lemma || '').trim();
651
+ if (!lemma) return '';
652
+ const locale = String(attrs.locale || '').trim().toLowerCase();
653
+ const meta = String(attrs.meta || '').trim();
654
+ const label = String(attrs.country || meta || locale).trim();
655
+ const note = String(attrs.note || '').trim();
656
+ const source = String(attrs.source || '').trim();
657
+ const url = String(attrs.url || '').trim();
658
+ const accessed = String(attrs.accessed || '').trim();
659
+ const citeExtra = String(attrs.cite_extra || '').trim();
660
+ const linkLabel = String(attrs.link_label || url).trim();
661
+ const parts = [
662
+ `<article class="sov-card${locale ? ` sov-card--${escapeAttr(locale)}` : ''}" role="listitem">`,
663
+ '<div class="sov-card__head">',
664
+ ];
665
+ if (locale) {
666
+ parts.push(
667
+ `<span class="sov-card__flag" title="${escapeAttr(label)}"><span class="fi fi-${escapeAttr(locale)}" role="img" aria-label="${escapeAttr(label)}"></span></span>`,
668
+ );
669
+ }
670
+ if (meta) parts.push(`<span class="sov-card__meta">${escapeHtml(meta)}</span>`);
671
+ parts.push('</div>');
672
+ parts.push(`<p class="sov-card__lemma">${escapeHtml(lemma)}</p>`);
673
+ if (attrs.def) parts.push(`<div class="sov-card__def">${formatDefHtml(attrs.def)}</div>`);
674
+ if (note) parts.push(`<p class="sov-card__note">${note}</p>`);
675
+ if (source) {
676
+ let cite = `${escapeHtml(source)}, s.v. &ldquo;${escapeHtml(lemma)},&rdquo;`;
677
+ if (url) cite += ` <a href="${escapeAttr(url)}">${escapeHtml(linkLabel)}</a>`;
678
+ if (accessed) cite += `, accessed ${escapeHtml(accessed)}`;
679
+ cite += '.';
680
+ if (citeExtra) cite += ` ${escapeHtml(citeExtra)}`;
681
+ parts.push(`<p class="sov-card__cite">${cite}</p>`);
682
+ }
683
+ parts.push('</article>');
684
+ return parts.join('');
685
+ }
686
+
687
+ function tweetHtml(attrs) {
688
+ const body = String(attrs.body || '').trim();
689
+ if (!body) return '';
690
+ const handle = String(attrs.handle || '').trim().replace(/^@/, '');
691
+ const icon = String(attrs.icon || 'chat').trim() || 'chat';
692
+ const url = String(attrs.url || '').trim();
693
+ const date = String(attrs.date || '').trim();
694
+ const note = String(attrs.note || '').trim();
695
+ const name = String(attrs.name || '').trim();
696
+ const linkLabel = url.replace(/^https?:\/\//, '');
697
+ const parts = [
698
+ '<article class="sov-tweet">',
699
+ '<div class="sov-tweet__head">',
700
+ `<span class="sov-tweet__avatar" aria-hidden="true"><span class="material-symbols-outlined">${escapeHtml(icon)}</span></span>`,
701
+ '<span class="sov-tweet__id">',
702
+ ];
703
+ if (name) parts.push(`<span class="sov-tweet__name">${escapeHtml(name)}</span>`);
704
+ if (handle) parts.push(`<span class="sov-tweet__handle">@${escapeHtml(handle)}</span>`);
705
+ parts.push('</span></div>');
706
+ parts.push(`<p class="sov-tweet__body">${body}</p>`);
707
+ if (url || date) {
708
+ let cite = '';
709
+ if (url) cite += `<a href="${escapeAttr(url)}">${escapeHtml(linkLabel)}</a>`;
710
+ if (url && date) cite += ' · ';
711
+ if (date) cite += escapeHtml(date);
712
+ parts.push(`<p class="sov-tweet__cite">${cite}</p>`);
713
+ }
714
+ if (note) parts.push(`<p class="sov-tweet__note">${note}</p>`);
715
+ parts.push('</article>');
716
+ return parts.join('');
717
+ }
718
+
719
+ function wrapDictCardGrid(html) {
720
+ return String(html || '').replace(
721
+ /(?:<article\b[^>]*\bsov-card\b[\s\S]*?<\/article>\s*){2,}/g,
722
+ (block) => {
723
+ if (/\bsov-grid\b/.test(block)) return block;
724
+ return `<div class="sov-grid" role="list">${block.trim()}</div>\n`;
725
+ },
726
+ );
727
+ }
728
+
729
+ function preprocessLiquid(markdown, xciteIndex, plotlyRegistry, youtubeRegistry, chordTabRegistry, siteUrl) {
730
+ const text = String(markdown || '')
731
+ .replace(/\{%-?\s*include\s+cut-in\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => cutInHtml(parseIncludeAttrs(raw)))
732
+ .replace(/\{%-?\s*include\s+figure\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => figureHtml(parseIncludeAttrs(raw)))
733
+ .replace(/\{%-?\s*include\s+plotly\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => {
734
+ const attrs = parseIncludeAttrs(raw);
735
+ const index = plotlyRegistry.length;
736
+ plotlyRegistry.push({ index, attrs });
737
+ return plotlyPlaceholder(index);
738
+ })
739
+ .replace(/\{%-?\s*include\s+chord-tab\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => {
740
+ const attrs = parseIncludeAttrs(raw);
741
+ const index = chordTabRegistry.length;
742
+ chordTabRegistry.push({ index, attrs });
743
+ return chordTabPlaceholder(index);
744
+ })
745
+ .replace(/\{%-?\s*include\s+youtube-short\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => {
746
+ const attrs = parseIncludeAttrs(raw);
747
+ const ids = [attrs.src || attrs.id || attrs.id1, attrs.src2 || attrs.id2].filter(Boolean);
748
+ return ids.map((id) => registerYoutubeCard(youtubeRegistry, { id, caption: attrs.caption, isShort: true })).join('');
749
+ })
750
+ .replace(/\{%-?\s*include\s+youtube\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => {
751
+ const attrs = parseIncludeAttrs(raw);
752
+ const id = attrs.id || attrs.youtube_id;
753
+ return registerYoutubeCard(youtubeRegistry, { id, caption: attrs.caption, isShort: false });
754
+ })
755
+ .replace(/\{%-?\s*include\s+video\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => videoHtml(parseIncludeAttrs(raw)))
756
+ .replace(/\{%-?\s*include\s+dict-card\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => dictCardHtml(parseIncludeAttrs(raw)))
757
+ .replace(/\{%-?\s*include\s+tweet\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => tweetHtml(parseIncludeAttrs(raw)))
758
+ .replace(/\{%-?\s*include\s+ongoing-product\.html\s*-?%\}/g, () => ongoingProductHtml())
759
+ .replace(/\{%-?\s*include\s+xcite\.html\s+key=["']([^"']+)["'][\s\S]*?-?%\}/g, (_, key) =>
760
+ xciteHtml(key, xciteIndex, siteUrl),
761
+ )
762
+ .replace(/\{%-?\s*include\s+[\w.-]+\s+[\s\S]*?-?%\}/g, '')
763
+ .replace(/\{%-?\s*assign\s+[\s\S]*?-?%\}/g, '');
764
+ return wrapDictCardGrid(text);
765
+ }
766
+
767
+ function preprocessMarkdownDivs(markdown) {
768
+ let text = String(markdown || '');
769
+ text = text.replace(
770
+ /<div\s+([^>]*class="[^"]*\barticle-note\b[^"]*"[^>]*)>([\s\S]*?)<\/div>/gi,
771
+ (match, openTag, inner) => {
772
+ if (!/\bmarkdown\s*=\s*["']1["']/i.test(openTag)) return match;
773
+ return `<div class="article-note">${marked.parse(inner.trim())}</div>`;
774
+ },
775
+ );
776
+ text = text.replace(
777
+ /<div\s+([^>]*class="[^"]*\bpoem\b[^"]*"[^>]*)>([\s\S]*?)<\/div>/gi,
778
+ (match, openTag, inner) => {
779
+ if (!/\bmarkdown\s*=\s*["']1["']/i.test(openTag)) return match;
780
+ return `<div class="poem"><p>${inner.trim()}</p></div>`;
781
+ },
782
+ );
783
+ return text;
784
+ }
785
+
786
+ function formatDateTime(date, timeZone = 'Europe/Paris') {
787
+ return new Intl.DateTimeFormat('en-GB', {
788
+ timeZone,
789
+ year: 'numeric',
790
+ month: 'long',
791
+ day: 'numeric',
792
+ hour: '2-digit',
793
+ minute: '2-digit',
794
+ hour12: false,
795
+ }).format(date);
796
+ }
797
+
798
+ function htmlLang(languageRaw) {
799
+ const raw = String(languageRaw || '').trim().toLowerCase();
800
+ if (raw.startsWith('pt')) return 'pt';
801
+ if (raw.startsWith('es')) return 'es';
802
+ if (raw.startsWith('fr')) return 'fr';
803
+ return 'en';
804
+ }
805
+
806
+ function buildHtml({ data, bodyHtml, site, generatedAt, hasMath }) {
807
+ const identityName = site?.pandorga?.identity?.name || site.author || '';
808
+ const author = data.author || identityName;
809
+ const title = data.title || 'Untitled';
810
+ const description = data.description || '';
811
+ const year = String(generatedAt.getFullYear());
812
+ const generatedLabel = formatDateTime(generatedAt, site.timezone || 'Europe/Paris');
813
+ const authorUrl = String(site?.pandorga?.identity?.url || site.url || '').replace(/\/$/, '');
814
+
815
+ // Same delimiters/config as _includes/mathjax.html; SVG output prints crisply at any scale.
816
+ const mathJaxHtml = hasMath
817
+ ? `<script>
818
+ window.MathJax = {
819
+ tex: {
820
+ inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],
821
+ displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']]
822
+ },
823
+ svg: { fontCache: 'global' },
824
+ startup: {
825
+ typeset: true,
826
+ pageReady: function () {
827
+ return MathJax.startup.defaultPageReady().then(function () {
828
+ window.__mathjaxReady = true;
829
+ });
830
+ }
831
+ }
832
+ };
833
+ </script>
834
+ <script src="https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg.js" id="MathJax-script"></script>`
835
+ : '';
836
+
837
+ return `<!DOCTYPE html>
838
+ <html lang="${escapeAttr(htmlLang(data.language))}">
839
+ <head>
840
+ <meta charset="utf-8">
841
+ <title>${escapeHtml(title)}</title>
842
+ <link rel="preconnect" href="https://fonts.googleapis.com">
843
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
844
+ <link href="https://fonts.googleapis.com/css2?family=STIX+Two+Text:ital,wght@0,400;0,600;1,400&family=Work+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
845
+ ${mathJaxHtml}
846
+ <style>
847
+ :root {
848
+ --ink: #1a1c1c;
849
+ --muted: #5c5f5f;
850
+ --soft: #9a9c9c;
851
+ --rule: #d8d9da;
852
+ --panel: #f3f3f4;
853
+ --primary: #34596b;
854
+ --secondary: #3f6b4f;
855
+ --tertiary: #5e5d42;
856
+ --paper: #ffffff;
857
+ }
858
+ * { box-sizing: border-box; }
859
+ body {
860
+ margin: 0;
861
+ color: var(--ink);
862
+ background: var(--paper);
863
+ font-family: "Work Sans", "Helvetica Neue", sans-serif;
864
+ font-size: 11pt;
865
+ line-height: 1.65;
866
+ /* One body line box; heading lead-in spacing is measured in these units. */
867
+ --body-line: 18.15pt;
868
+ }
869
+ .sheet {
870
+ max-width: 40rem;
871
+ margin: 0 auto;
872
+ }
873
+ .masthead {
874
+ border-bottom: 1px solid var(--rule);
875
+ padding-bottom: 1rem;
876
+ margin-bottom: 1.1rem;
877
+ }
878
+ h1.doc-title {
879
+ font-family: "STIX Two Text", "Times New Roman", serif;
880
+ /* 25% larger than the previous 1.85rem + 4pt title size (32.75pt at 16px root). */
881
+ font-size: 32.75pt;
882
+ font-weight: 600;
883
+ line-height: 1.2;
884
+ margin: 0 0 0.65rem;
885
+ color: var(--ink);
886
+ }
887
+ .deck {
888
+ font-size: 1.02rem;
889
+ color: var(--muted);
890
+ margin: 0 0 0.95rem;
891
+ line-height: 1.45;
892
+ }
893
+ .byline {
894
+ margin: 0 0 0.65rem;
895
+ font-size: 0.84rem;
896
+ color: var(--muted);
897
+ line-height: 1.45;
898
+ }
899
+ .byline a {
900
+ color: var(--muted);
901
+ text-decoration: none;
902
+ border-bottom: 1px solid var(--rule);
903
+ }
904
+ .byline a:hover { color: var(--primary); }
905
+ .share-note {
906
+ margin: 0;
907
+ font-size: 0.78rem;
908
+ color: var(--soft);
909
+ line-height: 1.4;
910
+ }
911
+ /* Section titles: body face + semibold; #### matches body size, +3pt per level up.
912
+ No rule or accent colour — hierarchy is size and spacing only.
913
+ Lead-in shrinks with level importance (≈1.05 body line boxes at h4). */
914
+ .body h1, .body h2, .body h3, .body h4 {
915
+ font-family: "Work Sans", "Helvetica Neue", sans-serif;
916
+ font-weight: 600;
917
+ color: var(--ink);
918
+ line-height: 1.3;
919
+ page-break-after: avoid;
920
+ break-after: avoid;
921
+ }
922
+ .body h1 {
923
+ font-size: 20pt;
924
+ margin: calc(1.68 * var(--body-line)) 0 calc(0.715 * var(--body-line));
925
+ }
926
+ .body h2 {
927
+ font-size: 17pt;
928
+ margin: calc(1.47 * var(--body-line)) 0 calc(0.65 * var(--body-line));
929
+ }
930
+ .body h3 {
931
+ font-size: 14pt;
932
+ margin: calc(1.26 * var(--body-line)) 0 calc(0.585 * var(--body-line));
933
+ }
934
+ .body h4 {
935
+ font-size: 11pt;
936
+ margin: calc(1.05 * var(--body-line)) 0 calc(0.52 * var(--body-line));
937
+ }
938
+ .body p { margin: 0 0 var(--body-line); }
939
+ .body hr {
940
+ border: 0;
941
+ border-top: 1px solid var(--rule);
942
+ margin: 1.2rem 0;
943
+ clear: both;
944
+ }
945
+ .body img.article-cut-in {
946
+ float: left;
947
+ display: block;
948
+ width: calc(6 * var(--body-line) * 3 / 4);
949
+ height: calc(6 * var(--body-line));
950
+ max-width: 36%;
951
+ object-fit: cover;
952
+ object-position: center 18%;
953
+ margin: 0.15em 0.9em 0.45em 0;
954
+ }
955
+ .body img.article-cut-in--right {
956
+ float: right;
957
+ margin: 0.15em 0 0.45em 0.9em;
958
+ }
959
+ .body a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
960
+ .body ul, .body ol { margin: 0 0 0.85rem 1.25rem; }
961
+ .body li { margin-bottom: 0.25rem; }
962
+ .body blockquote {
963
+ margin: 0 0 1rem;
964
+ padding: 0.15rem 0 0.15rem 0.9rem;
965
+ border-left: 3px solid var(--tertiary);
966
+ color: var(--muted);
967
+ }
968
+ .body table {
969
+ width: 100%;
970
+ border-collapse: collapse;
971
+ margin: 0 0 1rem;
972
+ font-size: 0.92rem;
973
+ }
974
+ .body th, .body td {
975
+ border: 1px solid var(--rule);
976
+ padding: 0.35rem 0.5rem;
977
+ text-align: left;
978
+ vertical-align: top;
979
+ }
980
+ .body th { background: var(--panel); font-weight: 600; }
981
+ .body code {
982
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
983
+ font-size: 0.88em;
984
+ background: var(--panel);
985
+ padding: 0.05em 0.3em;
986
+ }
987
+ .body pre {
988
+ background: var(--panel);
989
+ padding: 0.75rem 0.9rem;
990
+ overflow-x: auto;
991
+ font-size: 0.82rem;
992
+ margin: 0 0 1rem;
993
+ }
994
+ .body pre code { background: none; padding: 0; }
995
+ .pdf-figure {
996
+ margin: 1.1rem auto;
997
+ text-align: center;
998
+ page-break-inside: avoid;
999
+ }
1000
+ .pdf-figure img {
1001
+ max-width: 100%;
1002
+ height: auto;
1003
+ display: block;
1004
+ margin: 0 auto;
1005
+ }
1006
+
1007
+ .pdf-figure figcaption {
1008
+ margin-top: 0.4rem;
1009
+ font-size: 0.78rem;
1010
+ color: var(--muted);
1011
+ font-style: italic;
1012
+ }
1013
+ /* Bibliography */
1014
+ sup.pdf-cite, sup.pdf-cite-missing { font-size: 0.75em; line-height: 0; vertical-align: super; }
1015
+ sup.pdf-cite a { color: var(--primary); text-decoration: none; }
1016
+ sup.pdf-cite-missing { color: var(--muted); }
1017
+ .pdf-references { margin-top: 2rem; border-top: 1px solid var(--rule); padding-top: 1rem; }
1018
+ .pdf-references h2 { font-size: 1.2rem; font-weight: 600; margin-bottom: 1rem; color: var(--ink); border: none; padding: 0; }
1019
+ .pdf-bib-list { list-style: none; padding-left: 0; margin-left: 0; }
1020
+ .pdf-bib-list li { margin-bottom: 0.8rem; font-size: 0.92rem; line-height: 1.5; text-indent: -1.8rem; padding-left: 1.8rem; }
1021
+ .pdf-bib-list li span:first-child { display: inline-block; width: 1.8rem; text-indent: 0; color: var(--muted); }
1022
+ .pdf-bib-list li a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
1023
+
1024
+ /* Dictionary & Tweets */
1025
+ .sov-grid {
1026
+ display: grid;
1027
+ grid-template-columns: 1fr 1fr;
1028
+ gap: 1.0rem;
1029
+ margin: 1.25rem 0 1.75rem;
1030
+ page-break-inside: avoid;
1031
+ }
1032
+ .sov-card {
1033
+ border: 1px solid color-mix(in srgb, var(--rule) 45%, transparent);
1034
+ border-left: 3px solid var(--secondary);
1035
+ border-radius: 0;
1036
+ background: color-mix(in srgb, var(--panel) 92%, transparent);
1037
+ padding: 0.7rem 0.85rem 0.8rem;
1038
+ display: flex;
1039
+ flex-direction: column;
1040
+ gap: 0.5rem;
1041
+ min-width: 0;
1042
+ font-size: 0.85rem;
1043
+ }
1044
+ .sov-card p { margin: 0; }
1045
+ .sov-card__head {
1046
+ display: flex;
1047
+ align-items: center;
1048
+ gap: 0.55rem;
1049
+ flex-wrap: wrap;
1050
+ }
1051
+ .sov-card__flag {
1052
+ display: inline-flex;
1053
+ align-items: center;
1054
+ justify-content: center;
1055
+ height: 1.65rem;
1056
+ padding: 0 0.4rem;
1057
+ border: 1.5px solid color-mix(in srgb, currentColor 40%, var(--rule));
1058
+ border-radius: 0;
1059
+ line-height: 1;
1060
+ }
1061
+ .sov-card__meta {
1062
+ font-size: 0.72rem;
1063
+ letter-spacing: 0.04em;
1064
+ text-transform: uppercase;
1065
+ opacity: 0.8;
1066
+ font-weight: 600;
1067
+ }
1068
+ .sov-card__lemma { font-weight: 600; font-size: 1.05rem; }
1069
+ .sov-card__def { font-size: 0.95em; }
1070
+ .sov-card__cite, .sov-card__note { font-size: 0.82em; opacity: 0.8; }
1071
+ .sov-tweet {
1072
+ max-width: 34rem;
1073
+ margin: 1.25rem 0 1.75rem;
1074
+ padding: 0.95rem 1.15rem 1rem;
1075
+ border: 1px solid color-mix(in srgb, var(--rule) 45%, transparent);
1076
+ border-radius: 0;
1077
+ background: color-mix(in srgb, var(--panel) 92%, transparent);
1078
+ font-size: 0.95rem;
1079
+ page-break-inside: avoid;
1080
+ }
1081
+ .sov-tweet p { margin: 0; }
1082
+ .sov-tweet__head {
1083
+ display: flex;
1084
+ align-items: center;
1085
+ gap: 0.55rem;
1086
+ margin-bottom: 0.85rem;
1087
+ }
1088
+ .sov-tweet__avatar {
1089
+ width: 2.2rem;
1090
+ height: 2.2rem;
1091
+ background: color-mix(in srgb, var(--primary) 25%, transparent);
1092
+ display: inline-flex;
1093
+ align-items: center;
1094
+ justify-content: center;
1095
+ font-weight: 300;
1096
+ font-size: 1.25rem;
1097
+ }
1098
+ .sov-tweet__id {
1099
+ display: flex;
1100
+ flex-direction: column;
1101
+ line-height: 1.15;
1102
+ }
1103
+ .sov-tweet__name { font-weight: 700; font-size: 0.95rem; }
1104
+ .sov-tweet__handle { font-size: 0.82rem; opacity: 0.75; }
1105
+ .sov-tweet__body {
1106
+ margin: 0 0 0.5rem !important;
1107
+ font-size: 0.98rem;
1108
+ line-height: 1.65;
1109
+ }
1110
+ .sov-tweet__body em { font-style: italic; }
1111
+ .sov-tweet__cite {
1112
+ margin: 0 0 0.55rem !important;
1113
+ font-size: 0.82rem;
1114
+ line-height: 1.5;
1115
+ opacity: 0.8;
1116
+ }
1117
+ .sov-tweet__cite a { text-decoration: none; }
1118
+ .sov-tweet__note {
1119
+ margin: 0 !important;
1120
+ font-size: 0.88rem;
1121
+ line-height: 1.6;
1122
+ opacity: 0.9;
1123
+ }
1124
+
1125
+ /* End-of-section brand strips (raw HTML in Markdown).
1126
+ One body line box before and after (same unit as heading lead-in). */
1127
+ .article-brand-strip {
1128
+ display: block;
1129
+ text-align: center;
1130
+ margin: var(--body-line) 0;
1131
+ line-height: 0;
1132
+ page-break-inside: avoid;
1133
+ break-inside: avoid;
1134
+ }
1135
+ .article-brand-strip img {
1136
+ display: inline-block;
1137
+ vertical-align: middle;
1138
+ width: auto;
1139
+ height: 2.4rem;
1140
+ max-width: 12rem;
1141
+ object-fit: contain;
1142
+ }
1143
+ .article-brand-strip img + img {
1144
+ margin-left: 2ch;
1145
+ }
1146
+ .article-brand-strip-dark {
1147
+ display: inline-flex;
1148
+ align-items: center;
1149
+ justify-content: center;
1150
+ box-sizing: border-box;
1151
+ padding: 0.25rem 0.4rem;
1152
+ background: #111;
1153
+ }
1154
+ .article-brand-strip-dark img { height: 2rem; }
1155
+ .pdf-media-note {
1156
+ margin: 0.9rem 0;
1157
+ padding: 0.65rem 0.8rem;
1158
+ border: 1px solid var(--rule);
1159
+ background: var(--panel);
1160
+ font-size: 0.85rem;
1161
+ color: var(--muted);
1162
+ }
1163
+ .pdf-badge {
1164
+ display: inline-block;
1165
+ padding: 0.1rem 0.45rem;
1166
+ background: var(--panel);
1167
+ font-size: 0.78rem;
1168
+ color: var(--muted);
1169
+ }
1170
+ .pdf-xcite { color: var(--secondary); font-style: italic; }
1171
+ .pdf-xcite-missing { color: var(--muted); }
1172
+ mjx-container { overflow-x: auto; max-width: 100%; }
1173
+ mjx-container[display="true"] { margin: 0.6rem 0 1rem; }
1174
+ /* Readable substitute for embeds that cannot play in a static PDF: a linked
1175
+ thumbnail (or plain icon) plus a "go watch it" link — no "omitted" wording. */
1176
+ .pdf-media-card {
1177
+ margin: 1.1rem 0;
1178
+ border: 1px solid var(--rule);
1179
+ page-break-inside: avoid;
1180
+ }
1181
+ .pdf-media-thumb-link {
1182
+ display: block;
1183
+ position: relative;
1184
+ text-decoration: none;
1185
+ line-height: 0;
1186
+ }
1187
+ .pdf-media-thumb-link img {
1188
+ display: block;
1189
+ width: 100%;
1190
+ height: auto;
1191
+ }
1192
+ .pdf-media-play {
1193
+ position: absolute;
1194
+ top: 50%;
1195
+ left: 50%;
1196
+ transform: translate(-50%, -50%);
1197
+ width: 2.4rem;
1198
+ height: 2.4rem;
1199
+ border-radius: 50%;
1200
+ background: rgba(20, 20, 20, 0.68);
1201
+ color: #fff;
1202
+ font-size: 0.95rem;
1203
+ display: flex;
1204
+ align-items: center;
1205
+ justify-content: center;
1206
+ }
1207
+ .pdf-media-noimg {
1208
+ padding: 1.1rem 0;
1209
+ text-align: center;
1210
+ background: var(--panel);
1211
+ }
1212
+ .pdf-media-noimg .pdf-media-play {
1213
+ position: static;
1214
+ transform: none;
1215
+ display: inline-flex;
1216
+ background: var(--muted);
1217
+ }
1218
+ .pdf-media-caption {
1219
+ padding: 0.6rem 0.8rem;
1220
+ font-size: 0.85rem;
1221
+ color: var(--muted);
1222
+ }
1223
+ .pdf-media-caption strong { display: block; color: var(--ink); margin-bottom: 0.15rem; }
1224
+ .pdf-media-desc { display: block; margin-bottom: 0.3rem; }
1225
+ .pdf-media-caption a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
1226
+ .pdf-chord-tab { margin: 1.6rem 0 0.4rem; }
1227
+ .pdf-chord-tab h2 {
1228
+ font-size: 1.25rem;
1229
+ font-weight: 400;
1230
+ margin: 1.4rem 0 0.7rem;
1231
+ padding-top: 0.9rem;
1232
+ border-top: 1px solid var(--rule);
1233
+ color: var(--ink);
1234
+ }
1235
+ .pdf-chord-tab pre {
1236
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1237
+ font-size: 0.7rem;
1238
+ line-height: 1.55;
1239
+ white-space: pre;
1240
+ margin: 0;
1241
+ color: var(--ink);
1242
+ }
1243
+ .pdf-chord-tab-intro { margin: 0 0 1rem; }
1244
+ .pdf-chord-tab-intro pre { color: var(--tertiary); }
1245
+ .pdf-chord-tab-section { margin-bottom: 1.1rem; page-break-inside: avoid; }
1246
+ .pdf-chord-tab-section-label {
1247
+ display: block;
1248
+ font-size: 0.72rem;
1249
+ text-transform: uppercase;
1250
+ letter-spacing: 0.06em;
1251
+ color: var(--tertiary);
1252
+ margin-bottom: 0.3rem;
1253
+ }
1254
+ .article-note {
1255
+ margin: 1rem 0;
1256
+ padding: 0.75rem 0.9rem;
1257
+ background: var(--panel);
1258
+ border-left: 3px solid var(--tertiary);
1259
+ font-size: 0.92rem;
1260
+ }
1261
+ .poem p {
1262
+ white-space: pre-line;
1263
+ margin: 0 0 0.75rem;
1264
+ }
1265
+ .end-mark {
1266
+ margin-top: 2rem;
1267
+ padding-top: 0.75rem;
1268
+ border-top: 1px solid var(--rule);
1269
+ font-size: 0.75rem;
1270
+ color: var(--muted);
1271
+ }
1272
+ @media print {
1273
+ a { color: var(--primary); }
1274
+ }
1275
+ </style>
1276
+ </head>
1277
+ <body>
1278
+ <main class="sheet">
1279
+ <header class="masthead">
1280
+ <h1 class="doc-title">${escapeHtml(title)}</h1>
1281
+ ${description ? `<p class="deck">${escapeHtml(description)}</p>` : ''}
1282
+ <p class="byline">Written by <a href="${escapeAttr(authorUrl)}">${escapeHtml(author)}</a> | Generated ${escapeHtml(generatedLabel)}</p>
1283
+ <p class="share-note">Private document. Redistribution or publication without permission is not allowed.</p>
1284
+ </header>
1285
+ <article class="body">
1286
+ ${bodyHtml}
1287
+ </article>
1288
+ </main>
1289
+ </body>
1290
+ </html>`;
1291
+ }
1292
+
1293
+ async function launchBrowser() {
1294
+ return puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
1295
+ }
1296
+
1297
+ async function writePdf(browser, html, outputPath, footerMeta) {
1298
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1299
+
1300
+ const page = await browser.newPage();
1301
+ try {
1302
+ await page.setContent(html, { waitUntil: 'networkidle0', timeout: 120000 });
1303
+
1304
+ const hasMathJax = await page.evaluate(() => typeof window.MathJax !== 'undefined');
1305
+ if (hasMathJax) {
1306
+ await page
1307
+ .waitForFunction('window.__mathjaxReady === true', { timeout: 20000 })
1308
+ .catch((err) => console.warn(`MathJax did not finish typesetting before timeout: ${err.message}`));
1309
+ }
1310
+
1311
+ const shortTitle =
1312
+ footerMeta.title.length > 48 ? `${footerMeta.title.slice(0, 45)}…` : footerMeta.title;
1313
+
1314
+ await page.pdf({
1315
+ path: outputPath,
1316
+ format: 'A4',
1317
+ printBackground: true,
1318
+ displayHeaderFooter: true,
1319
+ headerTemplate: '<div></div>',
1320
+ // Footer size matches masthead .share-note (0.78rem ≈ 12.5px). Self-contained
1321
+ // HTML — does not load site CSS, so the site root rem scale never applies.
1322
+ footerTemplate: `
1323
+ <div style="width:100%;font-size:12.5px;color:#9a9c9c;font-family:Work Sans,Helvetica,sans-serif;padding:0 14mm;display:flex;justify-content:space-between;align-items:center;">
1324
+ <span>© ${escapeHtml(footerMeta.year)}</span>
1325
+ <span>${escapeHtml(shortTitle)}</span>
1326
+ <span><span class="pageNumber"></span>/<span class="totalPages"></span></span>
1327
+ </div>
1328
+ `,
1329
+ margin: { top: '16mm', right: '16mm', bottom: '18mm', left: '16mm' },
1330
+ });
1331
+ } finally {
1332
+ await page.close();
1333
+ }
1334
+ }
1335
+
1336
+ async function main() {
1337
+ const { input, output } = parseArgs(process.argv.slice(2));
1338
+ const inputPath = path.resolve(input);
1339
+ const outputPath = path.resolve(output);
1340
+
1341
+ if (!fs.existsSync(inputPath)) {
1342
+ console.error(`Input not found: ${inputPath}`);
1343
+ process.exit(1);
1344
+ }
1345
+
1346
+ const site = loadSiteConfig();
1347
+ const source = fs.readFileSync(inputPath, 'utf8');
1348
+ const { data, body } = splitFrontMatter(source);
1349
+ const xciteIndex = collectXciteIndex();
1350
+ const plotlyRegistry = [];
1351
+ const youtubeRegistry = [];
1352
+ const chordTabRegistry = [];
1353
+ const hasMath = containsMathMarkup(body);
1354
+ const bodyHasChordInclude = /\{%-?\s*include\s+chord-tab\.html/.test(body);
1355
+
1356
+ const bibIndex = collectBibIndex();
1357
+ const citedKeys = [];
1358
+ const mathRegistry = [];
1359
+ let prepared = preprocessMarkdownDivs(
1360
+ preprocessLiquid(processCitations(body, bibIndex, citedKeys), xciteIndex, plotlyRegistry, youtubeRegistry, chordTabRegistry, String(site?.pandorga?.identity?.url || site.url || '')),
1361
+ );
1362
+ if (hasMath) prepared = extractMathRegions(prepared, mathRegistry);
1363
+ // Mirrors the content runtime: front-matter `tab:` renders at the end of the
1364
+ // body when the writer did not place an explicit chord-tab include.
1365
+ if (data.tab && !bodyHasChordInclude) {
1366
+ const index = chordTabRegistry.length;
1367
+ chordTabRegistry.push({ index, attrs: { tab: data.tab } });
1368
+ prepared += `\n\n${chordTabPlaceholder(index)}\n`;
1369
+ }
1370
+
1371
+ let bodyHtml = marked.parse(prepared, { gfm: true, breaks: false });
1372
+ if (hasMath) bodyHtml = restoreMathRegions(bodyHtml, mathRegistry);
1373
+ bodyHtml = await resolveYoutubePlaceholders(bodyHtml, youtubeRegistry);
1374
+ bodyHtml = resolveChordTabPlaceholders(bodyHtml, chordTabRegistry);
1375
+ if (citedKeys.length) {
1376
+ bodyHtml += '\n' + buildReferencesHtml(citedKeys, bibIndex, data.language);
1377
+ }
1378
+ const generatedAt = new Date();
1379
+ const title = data.title || path.basename(inputPath, path.extname(inputPath));
1380
+
1381
+ const browser = await launchBrowser();
1382
+ try {
1383
+ bodyHtml = await resolvePlotlyPlaceholders(bodyHtml, plotlyRegistry, browser);
1384
+ const html = buildHtml({ data, bodyHtml, site, generatedAt, hasMath });
1385
+ await writePdf(browser, html, outputPath, {
1386
+ title,
1387
+ year: String(generatedAt.getFullYear()),
1388
+ });
1389
+ } finally {
1390
+ await browser.close();
1391
+ }
1392
+
1393
+ const sizeKb = Math.round(fs.statSync(outputPath).size / 1024);
1394
+ console.log(`PDF written: ${outputPath} (${sizeKb} KB)`);
1395
+ }
1396
+
1397
+ main().catch((err) => {
1398
+ console.error(err);
1399
+ process.exit(1);
1400
+ });