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,123 @@
1
+ #!/usr/bin/env node
2
+ // run-detail-parity-checks.mjs — the two detail paths must agree.
3
+ //
4
+ // A detail page is built twice. The public site renders it in the browser
5
+ // from _includes/content-runtime/50-detail.html, which delegates to the
6
+ // shared blocks; the private/studio build renders the same page at build time
7
+ // from _layouts/text.html, which reimplements the tag chips and the language
8
+ // pill in Liquid because Liquid cannot call the JS. That layout's own comment
9
+ // promises it emits "exactly the class contract" the runtime emits, and
10
+ // assets/css/ledger/detail.css styles both from one set of rules — so the day
11
+ // the two disagree, one of them loses its styling and the build says nothing.
12
+ //
13
+ // Prints the contract it found, for the Ruby gate to relay.
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { loadRuntime, toHtml, REPO_ROOT } from './load-runtime.mjs';
18
+
19
+ const LIQUID_DETAIL = path.join(REPO_ROOT, '_layouts', 'text.html');
20
+ const RUNTIME_DETAIL = path.join(REPO_ROOT, '_includes', 'content-runtime', '50-detail.html');
21
+ const BLOCKS = path.join(REPO_ROOT, '_includes', 'content-runtime', '43-blocks.html');
22
+
23
+ const { runtime } = loadRuntime();
24
+ const failures = [];
25
+
26
+ const liquid = fs.readFileSync(LIQUID_DETAIL, 'utf8');
27
+ const sidebar = fs.readFileSync(RUNTIME_DETAIL, 'utf8');
28
+
29
+ // The runtime detail view must delegate, not grow its own copy.
30
+ ['renderTagChips'].forEach((block) => {
31
+ if (!new RegExp(`${block}\\s*\\(`).test(sidebar)) {
32
+ failures.push(
33
+ `50-detail.html no longer calls ${block}; the detail sidebar has forked from the block library`
34
+ );
35
+ }
36
+ });
37
+
38
+ // Class contract: whatever the shared block emits, the Liquid copy must emit.
39
+ function classesIn(html) {
40
+ const found = new Set();
41
+ const attr = /class="([^"]*)"/g;
42
+ let match;
43
+ while ((match = attr.exec(html)) !== null) {
44
+ match[1].split(/\s+/).filter(Boolean).forEach((name) => found.add(name));
45
+ }
46
+ return [...found];
47
+ }
48
+
49
+ const contract = [
50
+ { label: 'tag chips (linked)', html: toHtml(runtime.renderTagChips(['Engineering'], { hrefFor: () => '/pages/articles/?tag=Engineering' })) },
51
+ { label: 'tag chips (plain)', html: toHtml(runtime.renderTagChips(['Engineering'])) }
52
+ ];
53
+
54
+ // A BEM-style modifier is interpolated on the Liquid side
55
+ // (`lang-pill--{{ page.language }}`), so the stem is what can be compared.
56
+ function emittedByLiquid(name) {
57
+ if (liquid.includes(name)) return true;
58
+ const stem = name.split('--')[0];
59
+ return stem !== name && liquid.includes(`${stem}--`);
60
+ }
61
+
62
+ const reported = new Set();
63
+ contract.forEach(({ label, html }) => {
64
+ if (!html) {
65
+ failures.push(`${label}: the block rendered nothing for a populated field`);
66
+ return;
67
+ }
68
+ classesIn(html).forEach((name) => {
69
+ reported.add(name);
70
+ if (!emittedByLiquid(name)) {
71
+ failures.push(
72
+ `_layouts/text.html never emits "${name}", which the runtime ${label} does. ` +
73
+ 'The private detail build would lose that styling.'
74
+ );
75
+ }
76
+ });
77
+ });
78
+
79
+ // Language vocabulary: one table in JS, one chain of Liquid conditionals.
80
+ const blocks = fs.readFileSync(BLOCKS, 'utf8');
81
+ const table = blocks.match(/var LANGUAGE_LABELS = \{([\s\S]*?)\};/);
82
+ if (!table) {
83
+ failures.push('could not find LANGUAGE_LABELS in 43-blocks.html');
84
+ } else {
85
+ const runtimeLabels = {};
86
+ table[1].replace(/(\w+)\s*:\s*'([^']*)'/g, (_, code, label) => {
87
+ runtimeLabels[code] = label;
88
+ return '';
89
+ });
90
+
91
+ const liquidLabels = {};
92
+ liquid.replace(
93
+ /page\.language\s*==\s*"(\w+)"\s*%\}\{%\s*assign\s+lang_label\s*=\s*"([^"]*)"/g,
94
+ (_, code, label) => {
95
+ liquidLabels[code] = label;
96
+ return '';
97
+ }
98
+ );
99
+
100
+ if (Object.keys(liquidLabels).length === 0) {
101
+ failures.push('found no lang_label assignments in _layouts/text.html; the scan has drifted');
102
+ }
103
+
104
+ const codes = new Set([...Object.keys(runtimeLabels), ...Object.keys(liquidLabels)]);
105
+ codes.forEach((code) => {
106
+ if (runtimeLabels[code] !== liquidLabels[code]) {
107
+ failures.push(
108
+ `language "${code}" reads ${JSON.stringify(runtimeLabels[code])} in 43-blocks.html but ` +
109
+ `${JSON.stringify(liquidLabels[code])} in _layouts/text.html`
110
+ );
111
+ }
112
+ });
113
+
114
+ console.log(` language vocabulary agrees on ${[...codes].sort().join(', ')}`);
115
+ }
116
+
117
+ if (failures.length) {
118
+ console.error(`The two detail paths disagree (${failures.length} problem(s)):`);
119
+ failures.forEach((line) => console.error(` - ${line}`));
120
+ process.exit(1);
121
+ }
122
+
123
+ console.log(` shared class contract: ${[...reported].sort().join(', ')}`);
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ // run-field-flow-checks.mjs — every exported field must reach the view.
3
+ //
4
+ // Two silent-drop traps sit between the exporter and the page, and neither
5
+ // shows up in a build:
6
+ //
7
+ // 1. writingIndexAsListingItem() copies writing_index entries into the
8
+ // { front_matter } shape the blocks read, through an explicit field
9
+ // whitelist. A newly exported field is invisible until someone adds a
10
+ // line there, and nothing complains — that is how `language` reached
11
+ // writing_index.json and still never made it onto a card.
12
+ //
13
+ // 2. hydrateFrontMatter() merges the detail payload's top-level date and
14
+ // last_updated into front_matter. It used to do that after an early
15
+ // return that always fired for exported content, so no detail page ever
16
+ // showed a date.
17
+ //
18
+ // This walks the real export rather than a fixture, so a field added to
19
+ // scripts/content/export-content-json.rb is covered the day it lands.
20
+ //
21
+ // Usage: node run-field-flow-checks.mjs <exportDir>
22
+ // Driven by scripts/test/test-exported-fields-reach-the-view.rb.
23
+
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { loadRuntime } from './load-runtime.mjs';
27
+
28
+ // Keys the exporter writes into a writing_index entry that a listing item is
29
+ // allowed to drop. Every entry needs a reason; an exclusion that turns out to
30
+ // survive is reported too, so the list cannot rot into a rubber stamp.
31
+ const LISTING_EXCLUSIONS = {
32
+ xcites:
33
+ 'Cross-reference resolution reads writing_index.json directly ' +
34
+ '(30-includes-xcite.html). A listing card never renders a citation list.'
35
+ };
36
+
37
+ const exportDir = process.argv[2];
38
+ if (!exportDir) {
39
+ console.error('usage: run-field-flow-checks.mjs <exportDir>');
40
+ process.exit(1);
41
+ }
42
+
43
+ // hydrateFrontMatter is internal to the runtime; 90-bootstrap.html does not
44
+ // export it, but every detail page depends on what it does.
45
+ const { runtime, internals } = loadRuntime({ expose: ['hydrateFrontMatter'] });
46
+ const hydrateFrontMatter = internals.hydrateFrontMatter;
47
+ const failures = [];
48
+ let checked = 0;
49
+
50
+ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
51
+
52
+ // ── 1. writing_index entry → listing item ─────────────────────────────────
53
+
54
+ const entries = JSON.parse(
55
+ fs.readFileSync(path.join(exportDir, 'data', 'writing_index.json'), 'utf8')
56
+ );
57
+ if (!Array.isArray(entries) || entries.length === 0) {
58
+ console.error('writing_index.json is empty; nothing to check');
59
+ process.exit(1);
60
+ }
61
+
62
+ const exportedKeys = new Set();
63
+ entries.forEach((entry) => Object.keys(entry).forEach((key) => exportedKeys.add(key)));
64
+
65
+ Object.keys(LISTING_EXCLUSIONS).forEach((key) => {
66
+ if (!exportedKeys.has(key)) {
67
+ failures.push(
68
+ `exclusion list names "${key}", which the exporter no longer writes — remove it`
69
+ );
70
+ }
71
+ });
72
+
73
+ // One entry per key is enough to prove the whitelist covers it, but which
74
+ // entry matters: an entry whose value is empty would pass a whitelist that
75
+ // dropped the key. Prefer an entry with a populated value.
76
+ function witnessFor(key) {
77
+ return (
78
+ entries.find((entry) => {
79
+ const value = entry[key];
80
+ if (value == null || value === '') return false;
81
+ return !(Array.isArray(value) && value.length === 0);
82
+ }) || entries[0]
83
+ );
84
+ }
85
+
86
+ [...exportedKeys].sort().forEach((key) => {
87
+ checked += 1;
88
+ const entry = witnessFor(key);
89
+ const item = runtime.writingIndexAsListingItem(entry);
90
+ const fm = item.front_matter || {};
91
+ const survives = same(item[key], entry[key]) || same(fm[key], entry[key]);
92
+
93
+ if (Object.prototype.hasOwnProperty.call(LISTING_EXCLUSIONS, key)) {
94
+ if (survives && !same(entry[key], undefined)) {
95
+ failures.push(
96
+ `"${key}" is on the exclusion list but now survives into the listing item — ` +
97
+ 'delete the exclusion rather than leaving it stale'
98
+ );
99
+ }
100
+ return;
101
+ }
102
+
103
+ if (!survives) {
104
+ failures.push(
105
+ `writingIndexAsListingItem drops "${key}" (exporter value ` +
106
+ `${JSON.stringify(entry[key])} for ${entry.key || entry.slug}). ` +
107
+ 'Add it to the whitelist in _includes/content-runtime/60-fragments.html, ' +
108
+ 'or to LISTING_EXCLUSIONS here with a reason.'
109
+ );
110
+ }
111
+ });
112
+
113
+ // The whitelist must not invent values either: a key the exporter never wrote
114
+ // has no business appearing on a listing item.
115
+ const listingKeys = new Set();
116
+ {
117
+ const item = runtime.writingIndexAsListingItem(entries[0]);
118
+ Object.keys(item).forEach((key) => key !== 'front_matter' && listingKeys.add(key));
119
+ Object.keys(item.front_matter || {}).forEach((key) => listingKeys.add(key));
120
+ }
121
+ [...listingKeys].sort().forEach((key) => {
122
+ checked += 1;
123
+ if (!exportedKeys.has(key)) {
124
+ failures.push(
125
+ `writingIndexAsListingItem reads "${key}", which no writing_index entry carries — ` +
126
+ 'the field is always undefined on a card'
127
+ );
128
+ }
129
+ });
130
+
131
+ // ── 2. collection payload → hydrated detail front matter ──────────────────
132
+
133
+ function collectionPayloads() {
134
+ const root = path.join(exportDir, 'collections');
135
+ return fs
136
+ .readdirSync(root, { withFileTypes: true })
137
+ .filter((dirent) => dirent.isDirectory())
138
+ .flatMap((dirent) => {
139
+ const dir = path.join(root, dirent.name);
140
+ return fs
141
+ .readdirSync(dir)
142
+ .filter((name) => name.endsWith('.json'))
143
+ .map((name) => ({
144
+ label: `${dirent.name}/${name}`,
145
+ payload: JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))
146
+ }));
147
+ });
148
+ }
149
+
150
+ const payloads = collectionPayloads();
151
+ if (payloads.length === 0) {
152
+ console.error('no exported collection objects; nothing to hydrate');
153
+ process.exit(1);
154
+ }
155
+
156
+ let datedPayloads = 0;
157
+ payloads.forEach(({ label, payload }) => {
158
+ const before = JSON.parse(JSON.stringify(payload.front_matter || {}));
159
+ const hydrated = hydrateFrontMatter(JSON.parse(JSON.stringify(payload)));
160
+ const fm = hydrated.front_matter || {};
161
+ checked += 1;
162
+
163
+ if (payload.date) {
164
+ datedPayloads += 1;
165
+ if (fm.date !== payload.date) {
166
+ failures.push(
167
+ `${label}: hydrateFrontMatter left front_matter.date as ${JSON.stringify(fm.date)} ` +
168
+ `for a payload dated ${payload.date} — the detail view shows no date`
169
+ );
170
+ }
171
+ }
172
+ if (payload.last_updated && fm.last_updated !== payload.last_updated) {
173
+ failures.push(
174
+ `${label}: hydrateFrontMatter dropped last_updated ` +
175
+ `(${payload.last_updated} → ${JSON.stringify(fm.last_updated)})`
176
+ );
177
+ }
178
+ Object.entries(before).forEach(([key, value]) => {
179
+ if (key === 'date' || key === 'last_updated') return;
180
+ if (!same(fm[key], value)) {
181
+ failures.push(
182
+ `${label}: hydrateFrontMatter changed front_matter.${key} ` +
183
+ `from ${JSON.stringify(value)} to ${JSON.stringify(fm[key])}`
184
+ );
185
+ }
186
+ });
187
+ });
188
+
189
+ if (datedPayloads === 0) {
190
+ failures.push('no exported payload carried a date; the date-promotion check proved nothing');
191
+ }
192
+
193
+ // Raw Markdown that has not been through the exporter still has its block
194
+ // parsed — that path is why the early return existed in the first place.
195
+ {
196
+ checked += 1;
197
+ const raw = hydrateFrontMatter({
198
+ body_markdown: '---\ntitle: Raw\nlanguage: enus\n---\nBody text.\n',
199
+ date: '2026-03-01'
200
+ });
201
+ const fm = raw.front_matter || {};
202
+ if (fm.title !== 'Raw' || fm.language !== 'enus') {
203
+ failures.push(
204
+ `hydrateFrontMatter stopped parsing inline front matter (got ${JSON.stringify(fm)})`
205
+ );
206
+ }
207
+ if (fm.date !== '2026-03-01') {
208
+ failures.push('hydrateFrontMatter dropped the payload date when a block was parsed');
209
+ }
210
+ if (raw.body_markdown.trim() !== 'Body text.') {
211
+ failures.push(`hydrateFrontMatter left the block in body_markdown: ${JSON.stringify(raw.body_markdown)}`);
212
+ }
213
+ }
214
+
215
+ if (failures.length) {
216
+ console.error(`Exported fields did not reach the view (${failures.length} problem(s)):`);
217
+ failures.forEach((line) => console.error(` - ${line}`));
218
+ process.exit(1);
219
+ }
220
+
221
+ const excluded = Object.keys(LISTING_EXCLUSIONS);
222
+ console.log(
223
+ ` ${exportedKeys.size} writing_index keys reach a listing item ` +
224
+ `(${excluded.length} documented exclusion: ${excluded.join(', ')}).`
225
+ );
226
+ console.log(` ${payloads.length} detail payloads hydrate with their dates and front matter intact.`);
227
+ console.log(` ${checked} field-flow checks.`);
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { marked } from 'marked';
6
+ import { missingSelectors } from './selectors.mjs';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+
10
+ function parseIncludeAttrs(raw) {
11
+ const attrs = {};
12
+ let text = String(raw || '');
13
+ text = text.replace(/([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g, (_, key, dbl, sgl) => {
14
+ attrs[key] = dbl != null ? dbl : sgl;
15
+ return '';
16
+ });
17
+ text.replace(/([\w-]+)\s*=\s*([^\s]+)/g, (_, key, val) => {
18
+ if (!(key in attrs)) attrs[key] = val;
19
+ return '';
20
+ });
21
+ return attrs;
22
+ }
23
+
24
+ function escapeAttr(value) {
25
+ return String(value ?? '')
26
+ .replace(/&/g, '&amp;')
27
+ .replace(/</g, '&lt;')
28
+ .replace(/>/g, '&gt;')
29
+ .replace(/"/g, '&quot;');
30
+ }
31
+
32
+ function unwrapMediaSrc(path) {
33
+ const value = String(path == null ? '' : path).trim();
34
+ if (!value) return '';
35
+ const md = value.match(/^\[([^\]]*)\]\(([^)\s]+)\)\s*$/);
36
+ if (md) return String(md[2] || '').trim();
37
+ const angle = value.match(/^<([^>\s]+)>\s*$/);
38
+ if (angle) return String(angle[1] || '').trim();
39
+ return value;
40
+ }
41
+
42
+ function cutInHtml(attrs) {
43
+ const src = unwrapMediaSrc(attrs.src);
44
+ if (!src) return '';
45
+ const sideClass = attrs.side === 'right' ? ' article-cut-in--right' : '';
46
+ return `<figure class="article-cut-in-plate${sideClass}"><img class="article-cut-in${sideClass}" src="${escapeAttr(src)}" alt="${escapeAttr(attrs.alt || '')}" width="120" loading="lazy"></figure>`;
47
+ }
48
+
49
+ function figureHtml(attrs) {
50
+ const src = unwrapMediaSrc(attrs.src);
51
+ if (!src) return '';
52
+ const width = attrs.width ? `max-width:${escapeAttr(attrs.width)}%;` : 'max-width:100%;';
53
+ const caption = attrs.caption || '';
54
+ const lbAttr = caption ? ` data-lightbox-caption="${escapeAttr(caption)}"` : '';
55
+ return [
56
+ `<figure class="my-8 mx-auto text-center w-full" style="display:block;${width}">`,
57
+ `<img class="figure-lightbox-trigger" src="${escapeAttr(src)}" alt="${escapeAttr(attrs.alt || caption)}" loading="lazy" tabindex="0" role="button" aria-label="Enlarge image"${lbAttr}>`,
58
+ caption ? `<figcaption class="mt-3">${escapeAttr(caption)}</figcaption>` : '',
59
+ '</figure>'
60
+ ].join('');
61
+ }
62
+
63
+ function plotlyHtml(attrs) {
64
+ const src = unwrapMediaSrc(attrs.src);
65
+ if (!src) return '';
66
+ const width = attrs.width ? String(attrs.width).trim() : '100';
67
+ const height = attrs.height && /^\d+$/.test(String(attrs.height).trim())
68
+ ? String(attrs.height).trim()
69
+ : '480';
70
+ const caption = attrs.caption || '';
71
+ const title = attrs.alt || caption || 'Interactive chart';
72
+ return [
73
+ `<figure class="plotly-figure my-8 mx-auto text-center w-full" style="display:block;max-width:${escapeAttr(width)}%;">`,
74
+ `<iframe class="plotly-embed-frame" src="${escapeAttr(src)}" title="${escapeAttr(title)}" loading="lazy" scrolling="no" style="width:100%;height:${escapeAttr(height)}px;border:0;display:block;"></iframe>`,
75
+ caption ? `<figcaption class="mt-3">${escapeAttr(caption)}</figcaption>` : '',
76
+ '</figure>'
77
+ ].join('');
78
+ }
79
+
80
+ function youtubeHtml(attrs, isShort) {
81
+ const id = attrs.id || attrs.youtube_id || (isShort ? attrs.src : '');
82
+ if (!id) return '';
83
+ const aspect = isShort ? 'aspect-ratio: 9 / 16;' : 'aspect-ratio: 16 / 9;';
84
+ return `<figure class="my-8 mx-auto text-center w-full"><div style="${aspect}"><iframe src="https://www.youtube.com/embed/${escapeAttr(id)}"></iframe></div></figure>`;
85
+ }
86
+
87
+ function youtubeShortHtml(attrs) {
88
+ const src = attrs.src || attrs.id || attrs.id1;
89
+ const src2 = attrs.src2 || attrs.id2;
90
+ if (!src) return '';
91
+ const frame = (id) =>
92
+ `<iframe width="315" height="560" src="https://www.youtube.com/embed/${escapeAttr(id)}" title="YouTube short"></iframe>`;
93
+ return `<figure class="my-8 mx-auto text-center w-full"><div class="article-youtube-short${src2 ? ' article-youtube-short--pair' : ''}">${frame(src)}${src2 ? frame(src2) : ''}</div></figure>`;
94
+ }
95
+
96
+ function videoHtml(attrs) {
97
+ const src = unwrapMediaSrc(attrs.src);
98
+ if (!src) return '';
99
+ return `<figure><video controls src="${escapeAttr(src)}"></video>${attrs.caption ? `<figcaption>${escapeAttr(attrs.caption)}</figcaption>` : ''}</figure>`;
100
+ }
101
+
102
+ function ongoingProductHtml() {
103
+ return '<span class="ongoing-product-badge" style="display: inline-block; padding: 0.15rem 0.5rem; background: var(--color-surface-container-low); border-radius: 0.25rem; font-size: 0.8rem; color: var(--color-on-surface-variant);">Currently active</span>';
104
+ }
105
+
106
+ function preprocessLiquid(markdown) {
107
+ return String(markdown)
108
+ .replace(/\{%-?\s*include\s+cut-in\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => cutInHtml(parseIncludeAttrs(raw)))
109
+ .replace(/\{%-?\s*include\s+figure\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => figureHtml(parseIncludeAttrs(raw)))
110
+ .replace(/\{%-?\s*include\s+plotly\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => plotlyHtml(parseIncludeAttrs(raw)))
111
+ .replace(/\{%-?\s*include\s+youtube-short\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => youtubeShortHtml(parseIncludeAttrs(raw)))
112
+ .replace(/\{%-?\s*include\s+youtube\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => youtubeHtml(parseIncludeAttrs(raw), false))
113
+ .replace(/\{%-?\s*include\s+video\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => videoHtml(parseIncludeAttrs(raw)))
114
+ .replace(/\{%-?\s*include\s+ongoing-product\.html\s*-?%\}/g, () => ongoingProductHtml());
115
+ }
116
+
117
+ function hasMarkdownAttr(openTag) {
118
+ return /\bmarkdown\s*=\s*["']1["']/i.test(String(openTag || ''));
119
+ }
120
+
121
+ function preprocessMarkdownDivs(markdown) {
122
+ let text = String(markdown || '');
123
+ text = text.replace(/<div\s+([^>]*class="[^"]*\barticle-note\b[^"]*"[^>]*)>([\s\S]*?)<\/div>/gi, (match, openTag, inner) => {
124
+ if (!hasMarkdownAttr(openTag)) return match;
125
+ return `<div class="article-note">${marked.parse(inner.trim())}</div>`;
126
+ });
127
+ text = text.replace(/<div\s+([^>]*class="[^"]*\bpoem\b[^"]*"[^>]*)>([\s\S]*?)<\/div>/gi, (match, openTag, inner) => {
128
+ if (!hasMarkdownAttr(openTag)) return match;
129
+ return `<div class="poem"><p>${inner.trim()}</p></div>`;
130
+ });
131
+ return text;
132
+ }
133
+
134
+ function render(markdown) {
135
+ const prepared = preprocessMarkdownDivs(preprocessLiquid(markdown));
136
+ return marked.parse(prepared, { gfm: true, breaks: false });
137
+ }
138
+
139
+ const fixturePath = process.argv[2];
140
+ const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
141
+ const html = render(fixture.markdown);
142
+ const missing = missingSelectors(html, fixture.selectors);
143
+
144
+ if (missing.length) {
145
+ console.error(`Fixture ${fixture.name}: missing selectors ${missing.join(', ')}`);
146
+ console.error(html);
147
+ process.exit(1);
148
+ }
149
+
150
+ if (fixture.paragraphCount != null) {
151
+ const count = (html.match(/<p\b/gi) || []).length;
152
+ if (count !== fixture.paragraphCount) {
153
+ console.error(`Fixture ${fixture.name}: expected ${fixture.paragraphCount} <p>, got ${count}`);
154
+ console.error(html);
155
+ process.exit(1);
156
+ }
157
+ }
158
+
159
+ console.log(`Fixture ${fixture.name}: OK`);
@@ -0,0 +1,43 @@
1
+ // selectors.mjs — the fixture selector vocabulary, shared by both runners.
2
+ //
3
+ // Fixtures describe expectations as selector-ish strings rather than parsing
4
+ // the HTML, so a fixture stays readable and the runners stay dependency-free.
5
+ // Supported forms:
6
+ // "iframe" / "img" / "video" / "blockquote" bare tag
7
+ // "span.lang-pill" tag with class
8
+ // "ledger-chip-row" class name, or a tag name
9
+ //
10
+ // Class matching is whole-token, not \b-delimited: a hyphen is a word
11
+ // boundary, so `\bledger-chip\b` also matches class="ledger-chip-row" and
12
+ // every count comes out one too high.
13
+
14
+ function classPattern(name, flags = 'i') {
15
+ return new RegExp(`class="(?:[^"]*\\s)?${name}(?:\\s[^"]*)?"`, flags);
16
+ }
17
+
18
+ export function matchesSelector(html, selector) {
19
+ if (selector === 'iframe') return /<iframe\b/i.test(html);
20
+ if (selector === 'img') return /<img\b/i.test(html);
21
+ if (selector === 'video') return /<video\b/i.test(html);
22
+ if (selector === 'blockquote') return /<blockquote\b/i.test(html);
23
+ const tagClass = String(selector).match(/^([a-z][\w-]*)\.([a-z][\w-]*)$/i);
24
+ if (tagClass) {
25
+ const [, tag, cls] = tagClass;
26
+ return new RegExp(`<${tag}\\b[^>]*class="(?:[^"]*\\s)?${cls}(?:\\s[^"]*)?"`, 'i').test(html);
27
+ }
28
+ return classPattern(selector).test(html) || new RegExp(`<${selector}\\b`, 'i').test(html);
29
+ }
30
+
31
+ export function missingSelectors(html, selectors) {
32
+ return (selectors || []).filter((selector) => !matchesSelector(html, selector));
33
+ }
34
+
35
+ export function presentSelectors(html, selectors) {
36
+ return (selectors || []).filter((selector) => matchesSelector(html, selector));
37
+ }
38
+
39
+ /** How many elements carry the class, for "exactly one separator" assertions. */
40
+ export function countClass(html, className) {
41
+ const matches = html.match(classPattern(className, 'gi'));
42
+ return matches ? matches.length : 0;
43
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "video",
3
+ "markdown": "{% include video.html src=\"/media/videos/sample.mp4\" caption=\"Sample\" %}",
4
+ "selectors": ["figure", "video"]
5
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "youtube-short",
3
+ "markdown": "{% include youtube-short.html src=\"abc123\" src2=\"def456\" %}",
4
+ "selectors": [
5
+ "figure",
6
+ "article-youtube-short",
7
+ "article-youtube-short--pair",
8
+ "iframe"
9
+ ]
10
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "youtube",
3
+ "markdown": "{% include youtube.html id=\"dQw4w9WgXcQ\" caption=\"Test video\" %}",
4
+ "selectors": [
5
+ "figure",
6
+ "iframe"
7
+ ]
8
+ }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Gate: an element renders only when the field backing it is present.
5
+ #
6
+ # This is the site's central editorial invariant, and the one the build cannot
7
+ # see. The shared ledger blocks run in the browser, so `jekyll build` succeeds
8
+ # whether a card shows a language pill for a piece that never declared one, an
9
+ # empty poster plate where there is no thumbnail, or a meta strip that ends in
10
+ # a dangling slash. Every failure of this kind has to be caught by executing
11
+ # the blocks, which is what scripts/test/render-parity/run-absent-field-checks.mjs
12
+ # does against the real _includes/content-runtime/43-blocks.html.
13
+ #
14
+ # The positive half of the contract — present field, present element — lives in
15
+ # scripts/test/render-parity/blocks/*.json, so a block cannot pass this gate by
16
+ # rendering nothing at all.
17
+
18
+ require "open3"
19
+ require "pathname"
20
+
21
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
22
+ CHECKS = Pathname.new(__dir__).join("render-parity", "run-absent-field-checks.mjs")
23
+
24
+ def fail!(message)
25
+ warn message
26
+ exit 1
27
+ end
28
+
29
+ puts "Testing the absent-field / absent-element rule..."
30
+
31
+ fail!("missing #{CHECKS}") unless CHECKS.file?
32
+
33
+ stdout, stderr, status = Open3.capture3(
34
+ "node", CHECKS.to_s, chdir: ROOT.join("scripts").to_s
35
+ )
36
+
37
+ print stdout
38
+ unless status.success?
39
+ warn stderr unless stderr.strip.empty?
40
+ fail!("Absent fields rendered elements. An absent field means an absent element.")
41
+ end
42
+
43
+ puts "All absent-field / absent-element checks passed."
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "pathname"
6
+ require "set"
7
+
8
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
9
+ ALLOWLIST = JSON.parse(File.read(ROOT.join("scripts/cdn-allowlist.json")))
10
+ ALLOWED = ALLOWLIST.fetch("allowed_urls").to_set
11
+ SCAN_DIRS = ALLOWLIST.fetch("scan_globs")
12
+
13
+ CDN_ATTR_RE = /
14
+ (?:src|href)\s*=\s*["']
15
+ (https?:\/\/[^"']+)
16
+ ["']
17
+ /ix
18
+
19
+ errors = []
20
+
21
+ SCAN_DIRS.each do |relative|
22
+ dir = ROOT.join(relative)
23
+ next unless dir.directory?
24
+
25
+ Dir.glob(dir.join("**/*.{html,js,liquid}")).sort.each do |file|
26
+ path = Pathname.new(file)
27
+ text = path.read
28
+ text.scan(CDN_ATTR_RE).flatten.each do |url|
29
+ next unless url.include?("cdn.jsdelivr.net") || url.include?("cdn.tailwindcss.com")
30
+
31
+ next if ALLOWED.include?(url)
32
+
33
+ errors << "#{path.relative_path_from(ROOT)}: unlisted CDN URL #{url}"
34
+ end
35
+ end
36
+ end
37
+
38
+ if errors.any?
39
+ warn "CDN pin validation failed:"
40
+ errors.each { |line| warn " - #{line}" }
41
+ exit 1
42
+ end
43
+
44
+ puts "CDN pin validation OK (#{ALLOWED.size} allowed URLs)."