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,337 @@
1
+ // load-runtime.mjs — assemble and evaluate the real content runtime in Node.
2
+ //
3
+ // Listings and detail views render in the browser from JS string templates
4
+ // spread over _includes/content-runtime/*.html. A Ruby gate cannot see them
5
+ // and a Jekyll build never executes them, which is how a block that renders
6
+ // an element with no backing field ships green. This loader concatenates the
7
+ // partials in the exact order _includes/page/content-runtime.html includes
8
+ // them, evaluates the result beNewsreader a deliberately thin DOM shim, and hands
9
+ // back the same object 90-bootstrap.html returns to the browser.
10
+ //
11
+ // The shim is small on purpose: createElement, className, setAttribute,
12
+ // innerHTML, and just enough child-node traversal for textExcerpt(). No DOM
13
+ // library is vendored, and pulling one in would hide which browser APIs the
14
+ // runtime actually depends on. Anything a block starts using that is missing
15
+ // here fails loudly rather than quietly returning undefined.
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ // marked lives in scripts/node_modules; the render-parity gate already runs
22
+ // node from scripts/. Without it renderMarkdown falls back to a single
23
+ // escaped paragraph, which is enough for the blocks but makes excerpt
24
+ // assertions meaningless, so import it when it resolves.
25
+ let marked;
26
+ try {
27
+ ({ marked } = await import('marked'));
28
+ } catch {
29
+ marked = undefined;
30
+ }
31
+
32
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
33
+ export const REPO_ROOT = path.resolve(HERE, '..', '..', '..');
34
+ const ASSEMBLY = path.join(REPO_ROOT, '_includes', 'page', 'content-runtime.html');
35
+ const PARTIAL_DIR = path.join(REPO_ROOT, '_includes', 'content-runtime');
36
+
37
+ const VOID_TAGS = new Set(['img', 'br', 'hr', 'input', 'meta', 'link', 'source']);
38
+
39
+ const NODE = { ELEMENT_NODE: 1, TEXT_NODE: 3, COMMENT_NODE: 8 };
40
+
41
+ const ENTITIES = {
42
+ '&': '&',
43
+ '&lt;': '<',
44
+ '&gt;': '>',
45
+ '&quot;': '"',
46
+ '&#39;': "'",
47
+ '&#96;': '`',
48
+ '&nbsp;': ' '
49
+ };
50
+
51
+ function decodeEntities(text) {
52
+ return String(text).replace(/&(?:amp|lt|gt|quot|nbsp|#39|#96);/g, (m) => ENTITIES[m] ?? m);
53
+ }
54
+
55
+ function makeTextNode(text) {
56
+ return { nodeType: NODE.TEXT_NODE, textContent: decodeEntities(text), childNodes: [] };
57
+ }
58
+
59
+ // A deliberately small HTML parser. textExcerpt() walks childNodes and reads
60
+ // textContent, so a shim that only stored innerHTML as a string would make
61
+ // every listing preview come back empty and the excerpt assertions vacuous.
62
+ function parseHtml(html) {
63
+ const nodes = [];
64
+ const stack = [{ childNodes: nodes }];
65
+ const token = /<!--[\s\S]*?-->|<\/([a-zA-Z][\w-]*)\s*>|<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g;
66
+ let cursor = 0;
67
+ let match;
68
+
69
+ const push = (node) => stack[stack.length - 1].childNodes.push(node);
70
+ const text = (raw) => {
71
+ if (raw) push(makeTextNode(raw));
72
+ };
73
+
74
+ while ((match = token.exec(html)) !== null) {
75
+ text(html.slice(cursor, match.index));
76
+ cursor = token.lastIndex;
77
+ if (match[0].startsWith('<!--')) continue;
78
+
79
+ if (match[1]) {
80
+ const closing = match[1].toLowerCase();
81
+ for (let i = stack.length - 1; i > 0; i -= 1) {
82
+ if (stack[i].tagName && stack[i].tagName.toLowerCase() === closing) {
83
+ stack.length = i;
84
+ break;
85
+ }
86
+ }
87
+ continue;
88
+ }
89
+
90
+ const element = makeElement(match[2]);
91
+ const attrs = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
92
+ let attr;
93
+ while ((attr = attrs.exec(match[3] || '')) !== null) {
94
+ element.setAttribute(attr[1], decodeEntities(attr[2] ?? attr[3] ?? ''));
95
+ }
96
+ push(element);
97
+ if (!match[4] && !VOID_TAGS.has(element.tagName.toLowerCase())) stack.push(element);
98
+ }
99
+ text(html.slice(cursor));
100
+ return nodes;
101
+ }
102
+
103
+ function collectText(node) {
104
+ if (node.nodeType === NODE.TEXT_NODE) return node.textContent;
105
+ return (node.childNodes || []).map(collectText).join('');
106
+ }
107
+
108
+ function serializeAttrs(element) {
109
+ const pairs = [];
110
+ if (element.className) pairs.push(['class', element.className]);
111
+ for (const [key, value] of element._attrs) {
112
+ if (key === 'class') continue;
113
+ pairs.push([key, value]);
114
+ }
115
+ return pairs.map(([k, v]) => ` ${k}="${v}"`).join('');
116
+ }
117
+
118
+ function makeElement(tagName) {
119
+ const tag = String(tagName).toLowerCase();
120
+ const element = {
121
+ nodeType: NODE.ELEMENT_NODE,
122
+ tagName: tag.toUpperCase(),
123
+ className: '',
124
+ _innerHTML: '',
125
+ childNodes: [],
126
+ children: [],
127
+ _attrs: new Map(),
128
+ get innerHTML() {
129
+ return this._innerHTML;
130
+ },
131
+ set innerHTML(value) {
132
+ this._innerHTML = String(value ?? '');
133
+ this._appended = [];
134
+ this._sync();
135
+ },
136
+ _appended: [],
137
+ _sync() {
138
+ this.childNodes = parseHtml(this._innerHTML).concat(this._appended);
139
+ this.children = this.childNodes.filter((n) => n.nodeType === NODE.ELEMENT_NODE);
140
+ },
141
+ get textContent() {
142
+ return collectText(this);
143
+ },
144
+ set textContent(value) {
145
+ this.innerHTML = String(value ?? '').replace(/[<>&]/g, (c) => `&#${c.charCodeAt(0)};`);
146
+ },
147
+ setAttribute(key, value) {
148
+ if (key === 'class') this.className = String(value);
149
+ else this._attrs.set(key, String(value));
150
+ },
151
+ getAttribute(key) {
152
+ if (key === 'class') return this.className;
153
+ return this._attrs.has(key) ? this._attrs.get(key) : null;
154
+ },
155
+ hasAttribute(key) {
156
+ return key === 'class' ? this.className !== '' : this._attrs.has(key);
157
+ },
158
+ removeAttribute(key) {
159
+ this._attrs.delete(key);
160
+ },
161
+ appendChild(child) {
162
+ this._appended.push(child);
163
+ this._sync();
164
+ return child;
165
+ },
166
+ addEventListener() { },
167
+ classList: {
168
+ add: (name) => {
169
+ const names = element.className.split(/\s+/).filter(Boolean);
170
+ if (!names.includes(name)) names.push(name);
171
+ element.className = names.join(' ');
172
+ },
173
+ remove: (name) => {
174
+ element.className = element.className
175
+ .split(/\s+/)
176
+ .filter((n) => n && n !== name)
177
+ .join(' ');
178
+ },
179
+ contains: (name) => element.className.split(/\s+/).includes(name)
180
+ },
181
+ querySelector() {
182
+ return null;
183
+ },
184
+ querySelectorAll() {
185
+ return [];
186
+ },
187
+ closest() {
188
+ return null;
189
+ },
190
+ get outerHTML() {
191
+ const attrs = serializeAttrs(this);
192
+ if (VOID_TAGS.has(tag)) return `<${tag}${attrs}>`;
193
+ const appended = this._appended.map((child) => child.outerHTML || '').join('');
194
+ return `<${tag}${attrs}>${this._innerHTML}${appended}</${tag}>`;
195
+ }
196
+ };
197
+ return element;
198
+ }
199
+
200
+ function makeSandbox(overrides = {}) {
201
+ const documentShim = {
202
+ readyState: 'loading',
203
+ createElement: makeElement,
204
+ createDocumentFragment: () => makeElement('div'),
205
+ addEventListener() { },
206
+ removeEventListener() { },
207
+ dispatchEvent() { },
208
+ querySelector() {
209
+ return null;
210
+ },
211
+ querySelectorAll() {
212
+ return [];
213
+ },
214
+ getElementById() {
215
+ return null;
216
+ },
217
+ body: makeElement('body'),
218
+ documentElement: makeElement('html')
219
+ };
220
+
221
+ const windowShim = {
222
+ ContentRuntimeConfig: {
223
+ contentBase: '',
224
+ includeUnpublished: false,
225
+ defaultAuthor: 'Test Author',
226
+ translations: {},
227
+ tocMinHeadings: 3
228
+ },
229
+ location: {
230
+ origin: 'https://example.test',
231
+ pathname: '/pages/articles/',
232
+ search: '',
233
+ href: 'https://example.test/pages/articles/'
234
+ },
235
+ document: documentShim,
236
+ marked,
237
+ matchMedia: () => ({ matches: false, addEventListener() { }, addListener() { } }),
238
+ addEventListener() { },
239
+ setTimeout,
240
+ clearTimeout,
241
+ requestAnimationFrame: (fn) => setTimeout(fn, 0),
242
+ ...overrides
243
+ };
244
+ windowShim.window = windowShim;
245
+
246
+ return { windowShim, documentShim };
247
+ }
248
+
249
+ function includeOrder() {
250
+ const assembly = fs.readFileSync(ASSEMBLY, 'utf8');
251
+ const names = [];
252
+ const pattern = /\{%\s*include\s+content-runtime\/([\w.-]+\.html)\s*%\}/g;
253
+ let match;
254
+ while ((match = pattern.exec(assembly)) !== null) {
255
+ // 00-config.html is a <script> tag outside the IIFE, not runtime source.
256
+ if (match[1] === '00-config.html') continue;
257
+ names.push(match[1]);
258
+ }
259
+ if (names.length === 0) {
260
+ throw new Error(`No content-runtime includes found in ${ASSEMBLY}`);
261
+ }
262
+ return names;
263
+ }
264
+
265
+ // Every runtime partial is wrapped in {% raw %} so Liquid leaves the JS alone.
266
+ // The JS inside contains {% ... %} in regex literals, so the check has to be
267
+ // "no Liquid outside a raw region", not "no braces anywhere".
268
+ function stripRawMarkers(name, body) {
269
+ const parts = body.split(/\{%-?\s*(?:end)?raw\s*-?%\}/);
270
+ const outside = parts.filter((_, index) => index % 2 === 0).join('');
271
+ if (/\{[{%]/.test(outside)) {
272
+ throw new Error(`${name} contains Liquid outside {% raw %}; the loader cannot evaluate it`);
273
+ }
274
+ return parts.join('');
275
+ }
276
+
277
+ /** The assembled runtime source, in include order, with Liquid markers gone. */
278
+ export function runtimeSource() {
279
+ return includeOrder()
280
+ .map((name) => {
281
+ const body = fs.readFileSync(path.join(PARTIAL_DIR, name), 'utf8');
282
+ return `/* ---- ${name} ---- */\n${stripRawMarkers(name, body)}`;
283
+ })
284
+ .join('\n');
285
+ }
286
+
287
+ // 90-bootstrap.html ends the IIFE with `return { ... }`, so nothing can be
288
+ // appended after it. Some functions worth testing — hydrateFrontMatter above
289
+ // all — are deliberately not on that export list. Getters prepended to the
290
+ // body close over the whole scope and resolve on access, which works for
291
+ // hoisted function declarations and for `var` bindings alike, and costs the
292
+ // browser bundle nothing because it only exists here.
293
+ function exposeInternals(names) {
294
+ if (!names.length) return '';
295
+ const props = names.map((name) => ` get ${name}() { return ${name}; }`).join(',\n');
296
+ return `window.__runtimeInternals = {\n${props}\n};\n`;
297
+ }
298
+
299
+ /**
300
+ * Evaluate the runtime and return the object 90-bootstrap.html exports,
301
+ * together with the shims so a caller can inspect what the runtime touched.
302
+ *
303
+ * @param {object} options
304
+ * @param {string[]} [options.expose] runtime-internal names to reach as well
305
+ */
306
+ export function loadRuntime(options = {}) {
307
+ const { expose = [], ...overrides } = options;
308
+ const { windowShim, documentShim } = makeSandbox(overrides);
309
+ const factory = new Function(
310
+ 'window',
311
+ 'document',
312
+ 'Node',
313
+ 'DOMParser',
314
+ `'use strict';\n${exposeInternals(expose)}${runtimeSource()}`
315
+ );
316
+ const runtime = factory(windowShim, documentShim, NODE, function DOMParser() {
317
+ throw new Error('The runtime reached DOMParser; the loader has no XML parser');
318
+ });
319
+ if (!runtime || typeof runtime !== 'object') {
320
+ throw new Error('The content runtime did not return its export object');
321
+ }
322
+ const internals = windowShim.__runtimeInternals || {};
323
+ expose.forEach((name) => {
324
+ if (internals[name] === undefined) {
325
+ throw new Error(`The content runtime defines no "${name}" to expose`);
326
+ }
327
+ });
328
+ return { runtime, internals, window: windowShim, document: documentShim };
329
+ }
330
+
331
+ /** Render a block that returns a DOM node or a string, uniformly as HTML. */
332
+ export function toHtml(result) {
333
+ if (result == null) return '';
334
+ if (typeof result === 'string') return result;
335
+ if (typeof result.outerHTML === 'string') return result.outerHTML;
336
+ throw new Error(`Cannot serialize block result of type ${typeof result}`);
337
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "ongoing-product",
3
+ "markdown": "{% include ongoing-product.html %}\n\nProduct description follows.",
4
+ "selectors": ["ongoing-product-badge"]
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "plotly",
3
+ "markdown": "{% include plotly.html src=\"/media/plots/test-chart.html\" caption=\"Test chart\" width=\"90\" height=\"480\" alt=\"Alt text\" %}",
4
+ "selectors": ["figure.plotly-figure", "iframe.plotly-embed-frame", "figcaption"]
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "poem",
3
+ "markdown": "<div class=\"poem\" markdown=\"1\">\nLine one\n\nLine two\n</div>",
4
+ "selectors": ["poem"]
5
+ }
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ // run-absent-field-checks.mjs — the project's central invariant, executed.
3
+ //
4
+ // An element renders only when the field backing it is present and
5
+ // non-empty. An absent field means an absent element: never a placeholder,
6
+ // never an em dash, never a derived stand-in, and never an empty wrapper
7
+ // whose CSS still paints a rule or a gap.
8
+ //
9
+ // Every case below is a shape real content produces. Roughly half the corpus
10
+ // has no `language`; most posts have no `thumbnail`; `tags` arrived only with
11
+ // the redesign, so older entries have none. The build succeeds either way,
12
+ // which is exactly why this needs a test rather than an eye.
13
+ //
14
+ // Driven by scripts/test/test-absent-field-absent-element.rb.
15
+
16
+ import { loadRuntime, toHtml } from './load-runtime.mjs';
17
+ import { matchesSelector, countClass } from './selectors.mjs';
18
+
19
+ const { runtime } = loadRuntime();
20
+ const failures = [];
21
+ let checked = 0;
22
+
23
+ function record(label, problem) {
24
+ checked += 1;
25
+ if (problem) failures.push(`${label}: ${problem}`);
26
+ }
27
+
28
+ /** The block must render the empty string — not a wrapper, not whitespace. */
29
+ function rendersNothing(label, block, args) {
30
+ let html;
31
+ try {
32
+ html = toHtml(runtime[block](...args));
33
+ } catch (error) {
34
+ record(label, `threw ${error.message}`);
35
+ return;
36
+ }
37
+ record(label, html === '' ? null : `rendered ${JSON.stringify(html)}`);
38
+ }
39
+
40
+ /** The block renders, but must not contain the element the absent field backs. */
41
+ function omits(label, block, args, forbidden) {
42
+ let html;
43
+ try {
44
+ html = toHtml(runtime[block](...args));
45
+ } catch (error) {
46
+ record(label, `threw ${error.message}`);
47
+ return;
48
+ }
49
+ const rendered = forbidden.filter((selector) => matchesSelector(html, selector));
50
+ record(label, rendered.length ? `rendered ${rendered.join(', ')} in ${html}` : null);
51
+ }
52
+
53
+ function separatorCount(label, parts, expected) {
54
+ const html = toHtml(runtime.renderMetaStrip(parts));
55
+ const actual = countClass(html, 'ledger-meta-sep');
56
+ record(
57
+ label,
58
+ actual === expected ? null : `expected ${expected} separators, got ${actual} in ${html}`
59
+ );
60
+ }
61
+
62
+ // ── language ──────────────────────────────────────────────────────────────
63
+ // Half the corpus never declared one; a neutral pill would be a lie.
64
+ [
65
+ ['undefined', undefined],
66
+ ['null', null],
67
+ ['empty string', ''],
68
+ ['whitespace', ' '],
69
+ ['unknown code', 'de-DE'],
70
+ ['false', false],
71
+ ['number zero', 0]
72
+ ].forEach(([shape, value]) => {
73
+ rendersNothing(`renderLangPill / ${shape}`, 'renderLangPill', [value]);
74
+ });
75
+
76
+ // ── tags ──────────────────────────────────────────────────────────────────
77
+ // `tags` replaced `category` in the redesign, so pre-redesign entries have
78
+ // none, and a hand-edited file can leave an empty or blank-item list.
79
+ [
80
+ ['undefined', undefined],
81
+ ['null', null],
82
+ ['empty array', []],
83
+ ['array of empty strings', ['', '']],
84
+ ['array of whitespace', [' ', '\t']],
85
+ ['array of nulls', [null, undefined]],
86
+ ['not an array', 'Engineering'],
87
+ ['empty string', '']
88
+ ].forEach(([shape, value]) => {
89
+ rendersNothing(`renderTagChips / ${shape}`, 'renderTagChips', [value]);
90
+ });
91
+
92
+ // ── thumbnail ─────────────────────────────────────────────────────────────
93
+ // Most posts have none. An empty <div class="ledger-poster"> is a bordered
94
+ // plate with nothing in it, which reads as a broken image.
95
+ [
96
+ ['undefined', undefined],
97
+ ['null', null],
98
+ ['empty string', ''],
99
+ ['whitespace', ' \n ']
100
+ ].forEach(([shape, value]) => {
101
+ rendersNothing(`renderPosterMedia / ${shape}`, 'renderPosterMedia', [value, 'Alt text']);
102
+ });
103
+
104
+ // ── url / label ───────────────────────────────────────────────────────────
105
+ // A CTA with no destination is an arrow that goes nowhere.
106
+ [
107
+ ['no href', ['', 'Read article']],
108
+ ['null href', [null, 'Read article']],
109
+ ['no label', ['/articles/wsl/', '']],
110
+ ['null label', ['/articles/wsl/', null]],
111
+ ['neither', ['', '']]
112
+ ].forEach(([shape, args]) => {
113
+ rendersNothing(`renderCtaLink / ${shape}`, 'renderCtaLink', args);
114
+ });
115
+
116
+ // ── section banner ────────────────────────────────────────────────────────
117
+ // The title is the only required part; a kicker alone is furniture.
118
+ [
119
+ ['no options', []],
120
+ ['empty options', [{}]],
121
+ ['kicker only', [{ kicker: '02' }]],
122
+ ['note only', [{ note: '12 entries' }]],
123
+ ['empty title', [{ title: '', kicker: '02', note: '12 entries' }]]
124
+ ].forEach(([shape, args]) => {
125
+ rendersNothing(`renderSectionBanner / ${shape}`, 'renderSectionBanner', args);
126
+ });
127
+
128
+ // Optional parts of a banner that does render must still stay absent.
129
+ omits(
130
+ 'renderSectionBanner / title only keeps kicker and note out',
131
+ 'renderSectionBanner',
132
+ [{ title: 'Long-form inquiries' }],
133
+ ['ledger-kicker', 'ledger-section-note']
134
+ );
135
+
136
+ // ── meta strip separators ─────────────────────────────────────────────────
137
+ // The strip is the block most exposed to the rule: it joins parts a caller
138
+ // may or may not have. A dropped part must take its slash with it, or the
139
+ // ledger line reads "02 Jan 2026 /" with nothing after the rule.
140
+ rendersNothing('renderMetaStrip / no parts', 'renderMetaStrip', [[]]);
141
+ rendersNothing('renderMetaStrip / undefined', 'renderMetaStrip', [undefined]);
142
+ rendersNothing('renderMetaStrip / all parts empty', 'renderMetaStrip', [['', null, undefined, ' ']]);
143
+ separatorCount('renderMetaStrip / one part has no separator', ['02 Jan 2026'], 0);
144
+ separatorCount('renderMetaStrip / empty trailing part drops its separator', ['02 Jan 2026', ''], 0);
145
+ separatorCount('renderMetaStrip / empty leading part drops its separator', ['', '02 Jan 2026'], 0);
146
+ separatorCount('renderMetaStrip / empty middle part drops its separator', ['a', '', 'b'], 1);
147
+ separatorCount('renderMetaStrip / null middle part drops its separator', ['a', null, 'b'], 1);
148
+ separatorCount('renderMetaStrip / three survivors take two separators', ['a', 'b', 'c'], 2);
149
+ separatorCount(
150
+ 'renderMetaStrip / mixed absences keep one separator',
151
+ [null, 'a', '', undefined, 'b', ' '],
152
+ 1
153
+ );
154
+
155
+ // ── cards and rows: the composed case ─────────────────────────────────────
156
+ // A bare entry — title and nothing else — is what a freshly written draft
157
+ // looks like before any optional field is filled in.
158
+ const bare = { slug: 'bare-draft', front_matter: { title: 'A draft with nothing else' } };
159
+
160
+ omits(
161
+ 'renderLedgerRow / bare entry',
162
+ 'renderLedgerRow',
163
+ [bare, { href: '/posts/bare-draft/' }],
164
+ ['lang-pill', 'ledger-chip-row', 'ledger-chip', 'ledger-row-copy', 'ledger-meta', 'ledger-row-head']
165
+ );
166
+
167
+ omits(
168
+ 'renderLedgerRow / no cta label leaves no footer rule',
169
+ 'renderLedgerRow',
170
+ [bare, { href: '/posts/bare-draft/' }],
171
+ ['ledger-row-foot', 'ledger-cta']
172
+ );
173
+
174
+ omits(
175
+ 'renderArchiveCard / bare entry',
176
+ 'renderArchiveCard',
177
+ [bare, { href: '/articles/bare-draft/' }],
178
+ ['lang-pill', 'ledger-chip-row', 'ledger-chip', 'ledger-card-copy', 'ledger-meta', 'date-evolution']
179
+ );
180
+
181
+ omits(
182
+ 'renderArchiveCard / dates stay out of the footer unless asked',
183
+ 'renderArchiveCard',
184
+ [
185
+ { front_matter: { title: 'Dated', date: '2026-02-24', last_updated: '2026-08-22' } },
186
+ { href: '/articles/dated/', ctaLabel: 'Read article' }
187
+ ],
188
+ ['date-evolution']
189
+ );
190
+
191
+ omits(
192
+ 'renderSystemCard / project with no media, tags, or links',
193
+ 'renderSystemCard',
194
+ [{ front_matter: { project: 'Bossa' } }, { href: '/projects/bossa/' }],
195
+ [
196
+ 'ledger-poster',
197
+ 'ledger-chip-row',
198
+ 'ledger-chip',
199
+ 'ledger-card-copy',
200
+ 'ledger-meta',
201
+ 'ledger-status',
202
+ // The footer draws a rule above itself, so a project with nothing to link
203
+ // out to must not render one. Its own page is reached from the title.
204
+ 'ledger-card-foot'
205
+ ]
206
+ );
207
+
208
+ omits(
209
+ 'renderSystemCard / label without status renders no status chip',
210
+ 'renderSystemCard',
211
+ [{ front_matter: { project: 'Bossa', label: 'Simulation' } }, { href: '/projects/bossa/' }],
212
+ ['ledger-status']
213
+ );
214
+
215
+ separatorCount('renderSystemCard / label without status drops the separator', ['Simulation', ''], 0);
216
+
217
+ // The listing preview is derived from description, then excerpt, then body.
218
+ // With all three absent there is nothing to say, so there is no paragraph.
219
+ omits(
220
+ 'renderLedgerRow / no description, excerpt, or body renders no copy',
221
+ 'renderLedgerRow',
222
+ [{ front_matter: { title: 'Silent' }, body_markdown: '' }, { href: '/posts/silent/' }],
223
+ ['ledger-row-copy']
224
+ );
225
+
226
+ if (failures.length) {
227
+ console.error(`Absent-field rule violated in ${failures.length} of ${checked} cases:`);
228
+ failures.forEach((line) => console.error(` - ${line}`));
229
+ process.exit(1);
230
+ }
231
+
232
+ console.log(` ${checked} absent-field cases rendered nothing, as required.`);
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ // run-block-fixture.mjs — render one shared ledger block and check its HTML.
3
+ //
4
+ // Sibling of run-fixture.mjs, which covers the Markdown body pipeline. This
5
+ // one covers _includes/content-runtime/43-blocks.html: the card, row, chip,
6
+ // pill and meta-strip renderers every listing now shares. It calls the real
7
+ // runtime through load-runtime.mjs rather than a second implementation, so a
8
+ // fixture fails when the shipped block changes, not when a copy drifts.
9
+ //
10
+ // Fixture shape:
11
+ // name label printed on success
12
+ // block function name on the ContentRuntime export
13
+ // args positional arguments; see hydrateArgs for the callable forms
14
+ // empty true → the block must render nothing at all
15
+ // selectors must be present (see selectors.mjs for the vocabulary)
16
+ // forbidden must be absent — this is where the hard rule lives
17
+ // contains raw substrings that must appear (attributes, entities, text)
18
+ // excludes raw substrings that must not appear
19
+ // counts { "class-name": n } exact occurrence counts
20
+
21
+ import fs from 'node:fs';
22
+ import { loadRuntime, toHtml } from './load-runtime.mjs';
23
+ import { missingSelectors, presentSelectors, countClass } from './selectors.mjs';
24
+
25
+ // JSON cannot carry the callbacks the blocks accept, so fixtures declare them
26
+ // declaratively and this turns them into functions.
27
+ function hydrateArgs(value) {
28
+ if (Array.isArray(value)) return value.map(hydrateArgs);
29
+ if (value && typeof value === 'object') {
30
+ if (typeof value.$hrefTemplate === 'string') {
31
+ const template = value.$hrefTemplate;
32
+ return (input) => template.replace('{value}', encodeURIComponent(String(input)));
33
+ }
34
+ const out = {};
35
+ for (const [key, inner] of Object.entries(value)) out[key] = hydrateArgs(inner);
36
+ return out;
37
+ }
38
+ return value;
39
+ }
40
+
41
+ const fixturePath = process.argv[2];
42
+ const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
43
+ const { runtime } = loadRuntime();
44
+
45
+ const block = runtime[fixture.block];
46
+ if (typeof block !== 'function') {
47
+ console.error(`Fixture ${fixture.name}: the runtime exports no block named ${fixture.block}`);
48
+ process.exit(1);
49
+ }
50
+
51
+ const html = toHtml(block(...hydrateArgs(fixture.args || [])));
52
+ const failures = [];
53
+
54
+ if (fixture.empty === true && html !== '') {
55
+ failures.push(`expected no output, got ${JSON.stringify(html)}`);
56
+ }
57
+
58
+ const missing = missingSelectors(html, fixture.selectors);
59
+ if (missing.length) failures.push(`missing selectors: ${missing.join(', ')}`);
60
+
61
+ const present = presentSelectors(html, fixture.forbidden);
62
+ if (present.length) failures.push(`forbidden selectors rendered: ${present.join(', ')}`);
63
+
64
+ (fixture.contains || []).forEach((needle) => {
65
+ if (!html.includes(needle)) failures.push(`missing substring: ${JSON.stringify(needle)}`);
66
+ });
67
+
68
+ (fixture.excludes || []).forEach((needle) => {
69
+ if (html.includes(needle)) failures.push(`forbidden substring: ${JSON.stringify(needle)}`);
70
+ });
71
+
72
+ Object.entries(fixture.counts || {}).forEach(([className, expected]) => {
73
+ const actual = countClass(html, className);
74
+ if (actual !== expected) {
75
+ failures.push(`expected ${expected} .${className}, got ${actual}`);
76
+ }
77
+ });
78
+
79
+ if (failures.length) {
80
+ console.error(`Fixture ${fixture.name} (${fixture.block}):`);
81
+ failures.forEach((line) => console.error(` - ${line}`));
82
+ console.error(` rendered: ${html}`);
83
+ process.exit(1);
84
+ }
85
+
86
+ console.log(`Block ${fixture.name}: OK`);