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,414 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "tmpdir"
6
+
7
+ require_relative "../../_plugins/git_chronology"
8
+
9
+ def check!(condition, message = "condition was false")
10
+ raise "Assertion failed: #{message}" unless condition
11
+ end
12
+
13
+ def assert_equal(expected, actual, message = nil)
14
+ return if expected == actual
15
+
16
+ label = message || "expected #{expected.inspect}, got #{actual.inspect}"
17
+ raise "Assertion failed: #{label}"
18
+ end
19
+
20
+ def git!(argv, env: {}, chdir:)
21
+ full_env = {
22
+ "GIT_AUTHOR_NAME" => "Test",
23
+ "GIT_AUTHOR_EMAIL" => "test@example.com",
24
+ "GIT_COMMITTER_NAME" => "Test",
25
+ "GIT_COMMITTER_EMAIL" => "test@example.com"
26
+ }.merge(env)
27
+ system(full_env, *argv, chdir: chdir) || raise("git command failed: #{argv.join(' ')}")
28
+ end
29
+
30
+ def with_temp_git_repo
31
+ GitChronology.reset_cache!
32
+ Dir.mktmpdir("git-chronology") do |tmpdir|
33
+ git!(%w[git init -b main], chdir: tmpdir)
34
+ git!(%w[git config user.name Test], chdir: tmpdir)
35
+ git!(%w[git config user.email test@example.com], chdir: tmpdir)
36
+ yield tmpdir
37
+ end
38
+ end
39
+
40
+ puts "Testing GitChronology..."
41
+
42
+ with_temp_git_repo do |repo|
43
+ path = "content/collections/articles/sample.md"
44
+ file = File.join(repo, path)
45
+ FileUtils.mkdir_p(File.dirname(file))
46
+
47
+ File.write(
48
+ file,
49
+ <<~MARKDOWN
50
+ ---
51
+ title: Original title
52
+ ---
53
+ Body version one.
54
+ MARKDOWN
55
+ )
56
+ git!(
57
+ %W[git add #{path}],
58
+ chdir: repo,
59
+ env: {
60
+ "GIT_AUTHOR_DATE" => "2026-01-10T12:00:00",
61
+ "GIT_COMMITTER_DATE" => "2026-01-10T12:00:00"
62
+ }
63
+ )
64
+ git!(
65
+ %w[git commit -m create],
66
+ chdir: repo,
67
+ env: {
68
+ "GIT_AUTHOR_DATE" => "2026-01-10T12:00:00",
69
+ "GIT_COMMITTER_DATE" => "2026-01-10T12:00:00"
70
+ }
71
+ )
72
+
73
+ File.write(
74
+ file,
75
+ <<~MARKDOWN
76
+ ---
77
+ title: Renamed title
78
+ thumbnail: /media/images/new-thumb.png
79
+ ---
80
+ Body version one.
81
+ MARKDOWN
82
+ )
83
+ git!(%W[git add #{path}], chdir: repo)
84
+ git!(
85
+ %w[git commit -m front-matter-only],
86
+ chdir: repo,
87
+ env: {
88
+ "GIT_AUTHOR_DATE" => "2026-02-15T12:00:00",
89
+ "GIT_COMMITTER_DATE" => "2026-02-15T12:00:00"
90
+ }
91
+ )
92
+
93
+ File.write(
94
+ file,
95
+ <<~MARKDOWN
96
+ ---
97
+ title: Renamed title
98
+ thumbnail: /media/images/new-thumb.png
99
+ ---
100
+ Body version two.
101
+ MARKDOWN
102
+ )
103
+ git!(%W[git add #{path}], chdir: repo)
104
+ git!(
105
+ %w[git commit -m body-edit],
106
+ chdir: repo,
107
+ env: {
108
+ "GIT_AUTHOR_DATE" => "2026-03-20T12:00:00",
109
+ "GIT_COMMITTER_DATE" => "2026-03-20T12:00:00"
110
+ }
111
+ )
112
+
113
+ File.write(
114
+ file,
115
+ <<~MARKDOWN
116
+ ---
117
+ title: Another title tweak
118
+ thumbnail: /media/images/another-thumb.png
119
+ ---
120
+ Body version two.
121
+ MARKDOWN
122
+ )
123
+ git!(%W[git add #{path}], chdir: repo)
124
+ git!(
125
+ %w[git commit -m front-matter-only-after-body],
126
+ chdir: repo,
127
+ env: {
128
+ "GIT_AUTHOR_DATE" => "2026-04-01T12:00:00",
129
+ "GIT_COMMITTER_DATE" => "2026-04-01T12:00:00"
130
+ }
131
+ )
132
+
133
+ Dir.chdir(repo) do
134
+ chronology = GitChronology.chronology_for(path, type: "article")
135
+ assert_equal "2026-01-10", chronology["date"]
136
+ assert_equal "2026-03-20", chronology["last_updated"], "front-matter-only commits must not advance last_updated"
137
+ assert_equal "2026-04-01", GitChronology.git_date(path, :updated), "file-level git_date still tracks any edit"
138
+ end
139
+ end
140
+
141
+ with_temp_git_repo do |repo|
142
+ path = "content/collections/posts/do-avesso.md"
143
+ file = File.join(repo, path)
144
+ FileUtils.mkdir_p(File.dirname(file))
145
+
146
+ File.write(
147
+ file,
148
+ <<~MARKDOWN
149
+ ---
150
+ title: Do avesso
151
+ date: '2026-05-15'
152
+ thumbnail: "/media/thumbnails/old.png"
153
+ ---
154
+ <div class="poem" markdown="1">
155
+
156
+ Minha goela,
157
+ meus entréns.
158
+
159
+ </div>
160
+ MARKDOWN
161
+ )
162
+ git!(
163
+ %W[git add #{path}],
164
+ chdir: repo,
165
+ env: {
166
+ "GIT_AUTHOR_DATE" => "2026-05-15T12:00:00",
167
+ "GIT_COMMITTER_DATE" => "2026-05-15T12:00:00"
168
+ }
169
+ )
170
+ git!(
171
+ %w[git commit -m create-post],
172
+ chdir: repo,
173
+ env: {
174
+ "GIT_AUTHOR_DATE" => "2026-05-15T12:00:00",
175
+ "GIT_COMMITTER_DATE" => "2026-05-15T12:00:00"
176
+ }
177
+ )
178
+
179
+ File.write(
180
+ file,
181
+ <<~MARKDOWN
182
+ ---
183
+ title: Do avesso
184
+ date: 2026-05-15
185
+ thumbnail: https://example.com/new-thumb.jpg
186
+ last_updated: 2026-08-28
187
+ ---
188
+ <div class="poem" markdown="1">
189
+ Minha goela,
190
+ meus entréns.
191
+
192
+ </div>
193
+ MARKDOWN
194
+ )
195
+ git!(%W[git add #{path}], chdir: repo)
196
+ git!(
197
+ %w[git commit -m studio-front-matter-and-whitespace],
198
+ chdir: repo,
199
+ env: {
200
+ "GIT_AUTHOR_DATE" => "2026-08-28T12:00:00",
201
+ "GIT_COMMITTER_DATE" => "2026-08-28T12:00:00"
202
+ }
203
+ )
204
+
205
+ Dir.chdir(repo) do
206
+ chronology = GitChronology.chronology_for(path, type: "post")
207
+ assert_equal "2026-05-15", chronology["date"]
208
+ assert_equal "2026-05-15", chronology["last_updated"],
209
+ "CMS whitespace reformat with front-matter edits must not advance last_updated"
210
+ end
211
+ end
212
+
213
+ with_temp_git_repo do |repo|
214
+ # Regression for the 2026-06-19 content-path migration class of bugs: git log --follow
215
+ # reaches _posts/… history but git show sha:content/collections/posts/… fails on those
216
+ # commits unless the historical path is used. See docs/content/architecture.md.
217
+ old_path = "_posts/sample.md"
218
+ new_path = "content/collections/posts/sample.md"
219
+ old_file = File.join(repo, old_path)
220
+ new_file = File.join(repo, new_path)
221
+ FileUtils.mkdir_p(File.dirname(old_file))
222
+
223
+ File.write(
224
+ old_file,
225
+ <<~MARKDOWN
226
+ ---
227
+ title: Original
228
+ ---
229
+ Body version one.
230
+ MARKDOWN
231
+ )
232
+ git!(%W[git add #{old_path}], chdir: repo)
233
+ git!(
234
+ %w[git commit -m create-at-old-path],
235
+ chdir: repo,
236
+ env: {
237
+ "GIT_AUTHOR_DATE" => "2026-05-01T12:00:00",
238
+ "GIT_COMMITTER_DATE" => "2026-05-01T12:00:00"
239
+ }
240
+ )
241
+
242
+ File.write(
243
+ old_file,
244
+ <<~MARKDOWN
245
+ ---
246
+ title: Original
247
+ ---
248
+ Body version two.
249
+ MARKDOWN
250
+ )
251
+ git!(%W[git add #{old_path}], chdir: repo)
252
+ git!(
253
+ %w[git commit -m body-edit-before-rename],
254
+ chdir: repo,
255
+ env: {
256
+ "GIT_AUTHOR_DATE" => "2026-05-15T12:00:00",
257
+ "GIT_COMMITTER_DATE" => "2026-05-15T12:00:00"
258
+ }
259
+ )
260
+
261
+ FileUtils.mkdir_p(File.dirname(new_file))
262
+ git!(%W[git mv #{old_path} #{new_path}], chdir: repo)
263
+ git!(
264
+ %w[git commit -m rename-only],
265
+ chdir: repo,
266
+ env: {
267
+ "GIT_AUTHOR_DATE" => "2026-06-19T12:00:00",
268
+ "GIT_COMMITTER_DATE" => "2026-06-19T12:00:00"
269
+ }
270
+ )
271
+
272
+ File.write(
273
+ new_file,
274
+ <<~MARKDOWN
275
+ ---
276
+ title: Retitled after rename
277
+ ---
278
+ Body version two.
279
+ MARKDOWN
280
+ )
281
+ git!(%W[git add #{new_path}], chdir: repo)
282
+ git!(
283
+ %w[git commit -m front-matter-after-rename],
284
+ chdir: repo,
285
+ env: {
286
+ "GIT_AUTHOR_DATE" => "2026-06-20T12:00:00",
287
+ "GIT_COMMITTER_DATE" => "2026-06-20T12:00:00"
288
+ }
289
+ )
290
+
291
+ Dir.chdir(repo) do
292
+ chronology = GitChronology.chronology_for(new_path, type: "post")
293
+ assert_equal "2026-05-01", chronology["date"]
294
+ assert_equal "2026-05-15", chronology["last_updated"], "rename-only migration must not advance last_updated"
295
+ end
296
+ end
297
+
298
+ with_temp_git_repo do |repo|
299
+ # Regression for content/articles → content/collections/articles rename (2026-07).
300
+ old_path = "content/articles/sample.md"
301
+ new_path = "content/collections/articles/sample.md"
302
+ old_file = File.join(repo, old_path)
303
+ new_file = File.join(repo, new_path)
304
+ FileUtils.mkdir_p(File.dirname(old_file))
305
+
306
+ File.write(
307
+ old_file,
308
+ <<~MARKDOWN
309
+ ---
310
+ title: Original
311
+ ---
312
+ Body version one.
313
+ MARKDOWN
314
+ )
315
+ git!(%W[git add #{old_path}], chdir: repo)
316
+ git!(
317
+ %w[git commit -m create-at-old-article-path],
318
+ chdir: repo,
319
+ env: {
320
+ "GIT_AUTHOR_DATE" => "2026-05-10T12:00:00",
321
+ "GIT_COMMITTER_DATE" => "2026-05-10T12:00:00"
322
+ }
323
+ )
324
+
325
+ File.write(
326
+ old_file,
327
+ <<~MARKDOWN
328
+ ---
329
+ title: Original
330
+ ---
331
+ Body version two.
332
+ MARKDOWN
333
+ )
334
+ git!(%W[git add #{old_path}], chdir: repo)
335
+ git!(
336
+ %w[git commit -m body-edit-before-article-rename],
337
+ chdir: repo,
338
+ env: {
339
+ "GIT_AUTHOR_DATE" => "2026-05-18T12:00:00",
340
+ "GIT_COMMITTER_DATE" => "2026-05-18T12:00:00"
341
+ }
342
+ )
343
+
344
+ FileUtils.mkdir_p(File.dirname(new_file))
345
+ git!(%W[git mv #{old_path} #{new_path}], chdir: repo)
346
+ git!(
347
+ %w[git commit -m rename-article-into-collections],
348
+ chdir: repo,
349
+ env: {
350
+ "GIT_AUTHOR_DATE" => "2026-07-12T12:00:00",
351
+ "GIT_COMMITTER_DATE" => "2026-07-12T12:00:00"
352
+ }
353
+ )
354
+
355
+ File.write(
356
+ new_file,
357
+ <<~MARKDOWN
358
+ ---
359
+ title: Retitled after rename
360
+ ---
361
+ Body version two.
362
+ MARKDOWN
363
+ )
364
+ git!(%W[git add #{new_path}], chdir: repo)
365
+ git!(
366
+ %w[git commit -m front-matter-after-article-rename],
367
+ chdir: repo,
368
+ env: {
369
+ "GIT_AUTHOR_DATE" => "2026-07-13T12:00:00",
370
+ "GIT_COMMITTER_DATE" => "2026-07-13T12:00:00"
371
+ }
372
+ )
373
+
374
+ Dir.chdir(repo) do
375
+ chronology = GitChronology.chronology_for(new_path, type: "article")
376
+ assert_equal "2026-05-10", chronology["date"]
377
+ assert_equal "2026-05-18", chronology["last_updated"], "article rename into collections must not advance last_updated"
378
+ end
379
+ end
380
+
381
+ with_temp_git_repo do |repo|
382
+ # Legacy imports: filename carries the original publication date; Git first-add is later.
383
+ path = "content/collections/posts/2021-03-17-o-espaco.md"
384
+ file = File.join(repo, path)
385
+ FileUtils.mkdir_p(File.dirname(file))
386
+
387
+ File.write(
388
+ file,
389
+ <<~MARKDOWN
390
+ ---
391
+ title: O espaco
392
+ date: 2021-03-17
393
+ ---
394
+ Body from the legacy platform.
395
+ MARKDOWN
396
+ )
397
+ git!(%W[git add #{path}], chdir: repo)
398
+ git!(
399
+ %w[git commit -m import-legacy-post],
400
+ chdir: repo,
401
+ env: {
402
+ "GIT_AUTHOR_DATE" => "2026-05-01T12:00:00",
403
+ "GIT_COMMITTER_DATE" => "2026-05-01T12:00:00"
404
+ }
405
+ )
406
+
407
+ Dir.chdir(repo) do
408
+ chronology = GitChronology.chronology_for(path, type: "post")
409
+ assert_equal "2021-03-17", chronology["date"], "creation date must follow filename/front matter, not Git first-add"
410
+ assert_equal "2021-03-17", chronology["last_updated"], "body-only chronology with a single commit falls back to creation date"
411
+ end
412
+ end
413
+
414
+ puts "GitChronology OK"
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // AC-HERO-06 and AC-HERO-07. Executes the functions in
3
+ // _includes/content-runtime/60-fragments.html rather than a copy of them.
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+
9
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
10
+ const source = fs.readFileSync(
11
+ path.join(root, '_includes/content-runtime/60-fragments.html'),
12
+ 'utf8'
13
+ );
14
+
15
+ const start = source.indexOf('function heroPortraitEntries');
16
+ const end = source.indexOf('function applyHeroPortrait');
17
+ if (start < 0 || end < start) {
18
+ console.error('hero portrait functions missing from 60-fragments.html');
19
+ process.exit(1);
20
+ }
21
+
22
+ const api = new Function(
23
+ `${source.slice(start, end)}\nreturn { heroPortraitEntries, pickHeroPortrait };`
24
+ )();
25
+
26
+ const { heroPortraitEntries, pickHeroPortrait } = api;
27
+
28
+ let failed = 0;
29
+
30
+ function check(condition, message) {
31
+ if (!condition) {
32
+ console.error(`FAIL: ${message}`);
33
+ failed += 1;
34
+ }
35
+ }
36
+
37
+ function same(got, expected, message) {
38
+ const left = JSON.stringify(got);
39
+ const right = JSON.stringify(expected);
40
+ check(left === right, `${message}: got ${left}, want ${right}`);
41
+ }
42
+
43
+ // AC-HERO-07
44
+ same(heroPortraitEntries(null), [], 'null payload');
45
+ same(heroPortraitEntries({ src: '/media/images/hero/a.jpg' }), [], 'object payload');
46
+ same(
47
+ heroPortraitEntries([
48
+ null,
49
+ [],
50
+ { alt: 'no src' },
51
+ { src: '' },
52
+ { src: ' ' },
53
+ { src: 12 },
54
+ ]),
55
+ [],
56
+ 'ineligible rows'
57
+ );
58
+ same(
59
+ heroPortraitEntries([
60
+ { src: ' /media/images/hero/a.JPG ' },
61
+ { src: '/media/images/hero/b.JPG', alt: ' face ' },
62
+ { src: '/media/images/hero/c.JPG', alt: 4 },
63
+ { src: '/media/images/hero/d.JPG', alt: ' ' },
64
+ ]),
65
+ [
66
+ { src: '/media/images/hero/a.JPG', alt: '' },
67
+ { src: '/media/images/hero/b.JPG', alt: 'face' },
68
+ { src: '/media/images/hero/c.JPG', alt: '' },
69
+ { src: '/media/images/hero/d.JPG', alt: '' },
70
+ ],
71
+ 'normalised rows'
72
+ );
73
+
74
+ // AC-HERO-06
75
+ const two = [
76
+ { src: '/media/images/hero/a.JPG', alt: '' },
77
+ { src: '/media/images/hero/b.JPG', alt: 'second' },
78
+ ];
79
+ check(pickHeroPortrait([], () => 0) === null, 'empty list');
80
+ check(pickHeroPortrait(null, () => 0) === null, 'null list');
81
+ same(pickHeroPortrait(two, () => 0), two[0], 'roll 0');
82
+ same(pickHeroPortrait(two, () => 0.5), two[1], 'roll 0.5');
83
+ same(pickHeroPortrait(two, () => 0.999), two[1], 'roll just below 1');
84
+ same(pickHeroPortrait(two, () => 1), two[0], 'roll 1');
85
+ same(pickHeroPortrait(two, () => -0.1), two[0], 'negative roll');
86
+ same(pickHeroPortrait(two, () => Number.NaN), two[0], 'NaN roll');
87
+ same(pickHeroPortrait(two, () => '0'), two[0], 'non-number roll');
88
+ same(pickHeroPortrait([two[0]], () => 0.999), two[0], 'single entry');
89
+
90
+ const three = two.concat([{ src: '/media/images/hero/c.JPG', alt: '' }]);
91
+ same(pickHeroPortrait(three, () => 0.4), three[1], 'roll 0.4 of three');
92
+
93
+ if (failed) {
94
+ console.error(`hero portraits: ${failed} failure(s)`);
95
+ process.exit(1);
96
+ }
97
+ console.log('PASS: hero portrait picker');
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Home hero portraits (docs/features/hero-portraits.md).
5
+ #
6
+ # AC-HERO-01 the pool directory and the list agree
7
+ # AC-HERO-02 the export writes pages/home/portraits.json
8
+ # AC-HERO-03 Studio does not register an editorial list
9
+ # AC-HERO-04 the plate starts with no photograph
10
+ # AC-HERO-05 the runtime draws once per load and fades the file in after it loads
11
+ # AC-HERO-06 / AC-HERO-07 the picker, executed from the runtime source
12
+
13
+ require "fileutils"
14
+ require "json"
15
+ require "open3"
16
+ require "pathname"
17
+ require "tmpdir"
18
+ require "yaml"
19
+ require "date"
20
+
21
+ require_relative "../content/hero_portraits"
22
+
23
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
24
+ EXPORT = ROOT.join("scripts/content/export-content-json.rb")
25
+ LAYOUT = ROOT.join("_layouts/home.html")
26
+ HOME_CSS = ROOT.join("assets/css/ledger/home.css")
27
+ RUNTIME = ROOT.join("_includes/content-runtime/60-fragments.html")
28
+ SCHEMA = ROOT.join("studio/schema.yml")
29
+ PICKER = ROOT.join("scripts/test/test-hero-portraits.mjs")
30
+ POOL = ROOT.join("content/media/images/hero")
31
+
32
+ def check!(condition, message = "condition was false")
33
+ raise "Assertion failed: #{message}" unless condition
34
+ end
35
+
36
+ puts "Testing home hero portraits..."
37
+
38
+ # ── AC-HERO-01 ──────────────────────────────────────────────────────────────
39
+
40
+ check!(POOL.directory?, "missing #{POOL}")
41
+ check!(!ROOT.join("content/pages/home/portraits.yml").exist?,
42
+ "the author does not keep a portrait list")
43
+
44
+ pool_names = POOL.children.select(&:file?).map { |path| path.basename.to_s }.sort
45
+ check!(pool_names == %w[DSC00571.JPG DSC00632.JPG DSC00721.JPG DSC00727.JPG],
46
+ "the hero folder must hold the four uploaded photographs, got #{pool_names.inspect}")
47
+
48
+ Dir.mktmpdir("hero-pool") do |dir|
49
+ root = Pathname.new(dir)
50
+ pool = root.join("media/images/hero")
51
+ pool.mkpath
52
+ FileUtils.touch(pool.join("b.JPG"))
53
+ FileUtils.touch(pool.join("a.png"))
54
+ FileUtils.touch(pool.join("note.txt"))
55
+
56
+ got = HeroPortraits.entries(root)
57
+ check!(got == [
58
+ { "src" => "/media/images/hero/a.png" },
59
+ { "src" => "/media/images/hero/b.JPG" }
60
+ ],
61
+ "the folder is the pool and non-images stay out, got #{got.inspect}")
62
+ end
63
+
64
+ # ── AC-HERO-03 ──────────────────────────────────────────────────────────────
65
+
66
+ schema = YAML.safe_load(SCHEMA.read, permitted_classes: [Date, Time], aliases: true) || {}
67
+
68
+ def each_content_entry(nodes, &block)
69
+ Array(nodes).each do |entry|
70
+ next unless entry.is_a?(Hash)
71
+
72
+ if entry["type"].to_s == "group"
73
+ each_content_entry(entry["items"], &block)
74
+ else
75
+ yield entry
76
+ end
77
+ end
78
+ end
79
+
80
+ registered = nil
81
+ each_content_entry(schema["content"]) do |entry|
82
+ registered = entry if entry["path"].to_s == "content/pages/home/portraits.yml"
83
+ end
84
+ check!(registered.nil?, "studio/schema.yml must not register a portrait list")
85
+
86
+ # ── AC-HERO-04 and AC-HERO-05 (source shape) ───────────────────────────────
87
+
88
+ layout = LAYOUT.read
89
+ check!(!layout.include?("profile-monologue"),
90
+ "the deleted shell photograph must not be referenced")
91
+ check!(!ROOT.join("assets/images/profile-monologue.JPG").exist?,
92
+ "profile-monologue.JPG must not exist")
93
+ hero_img = layout[/<img[^>]*data-content-hero-portrait[^>]*>/m]
94
+ check!(hero_img && !hero_img.include?("src=") && hero_img.include?('alt=""'),
95
+ "the plate starts without a photographic src and keeps an empty alt, got #{hero_img.inspect}")
96
+
97
+ home_css = HOME_CSS.read
98
+ check!(home_css.include?("background: black"),
99
+ "the plate waits on black")
100
+ plate_img = home_css[/^\.home-hero-plate img \{.*?\n\}/m]
101
+ check!(plate_img&.match?(/opacity:\s*0/) && plate_img.match?(/transition:[^;]*opacity/m),
102
+ "the photograph stays hidden and fades in, got #{plate_img.inspect}")
103
+ shown = home_css[/^\.home-hero-plate img\.is-shown \{.*?\n\}/m]
104
+ check!(shown&.match?(/opacity:\s*1/),
105
+ "a ready photograph fades to full opacity, got #{shown.inspect}")
106
+
107
+ runtime = RUNTIME.read
108
+ start = runtime.index("function heroPortraitEntries")
109
+ finish = runtime.index("function hydratePageFragments")
110
+ check!(start && finish && finish > start, "hero portrait functions must precede hydratePageFragments")
111
+ body = runtime[start...finish]
112
+ check!(body.include?("loadJson('pages/home/portraits.json')"), "the runtime must fetch the exported list")
113
+ check!(body.include?("pickHeroPortrait(entries, Math.random)"),
114
+ "the draw must use Math.random once per load")
115
+ check!(body.include?("return next();"),
116
+ "a failed image load must try the next pool file")
117
+ check!(body.include?("probe.onload = function ()"), "src must change from the image onload")
118
+ onload = body[/probe\.onload = function \(\) \{.*?\n \};/m]
119
+ check!(onload&.include?("img.src = nextSrc") && onload.include?("img.alt = nextAlt") &&
120
+ onload.include?("img.classList.add('is-shown')"),
121
+ "src, alt, and the fade-in must be applied together after load, got #{onload.inspect}")
122
+ onerror = body[/probe\.onerror = function \(\) \{.*?\n \};/m]
123
+ check!(onerror && !onerror.include?("img.src") && !onerror.include?("is-shown"),
124
+ "a failed image load must leave the plate black, got #{onerror.inspect}")
125
+ check!(!body.include?("sessionStorage") && !body.include?("localStorage"),
126
+ "the draw must not persist a choice")
127
+ check!(runtime.include?("hydrateHeroPortrait();"), "hydratePageFragments must draw the plate")
128
+
129
+ # ── AC-HERO-06 / AC-HERO-07 ─────────────────────────────────────────────────
130
+
131
+ stdout, stderr, status = Open3.capture3("node", PICKER.to_s)
132
+ print stdout
133
+ warn stderr unless stderr.strip.empty?
134
+ check!(status.success?, "picker cases failed")
135
+
136
+ # ── AC-HERO-02 ──────────────────────────────────────────────────────────────
137
+
138
+ Dir.mktmpdir("hero-portraits") do |dir|
139
+ out = Pathname.new(dir).join("export")
140
+ env = { "CONTENT_API_BASE_URL" => "", "CONTENT_EXPORT_SKIP_VALIDATE" => "1" }
141
+ command = ["ruby", EXPORT.to_s, ROOT.join("content").to_s, out.to_s]
142
+ output, export_status = Open3.capture2e(env, *command, chdir: ROOT.to_s)
143
+ check!(export_status.success?, "export failed:\n#{output}")
144
+
145
+ exported = out.join("pages/home/portraits.json")
146
+ check!(exported.file?, "the export must write pages/home/portraits.json")
147
+ payload = JSON.parse(exported.read)
148
+ expected = HeroPortraits.entries(ROOT.join("content"))
149
+ check!(payload == expected, "the exported list must be the hero folder, got #{payload.inspect}")
150
+ srcs = payload.map { |entry| entry["src"] }
151
+ check!(srcs == pool_names.map { |name| "/media/images/hero/#{name}" },
152
+ "the export must name the four photographs, got #{srcs.inspect}")
153
+ check!(srcs.none? { |src| src.include?("profile-monologue") },
154
+ "the deleted monologue file must not stay in the draw")
155
+
156
+ manifest = JSON.parse(out.join("manifest.json").read)
157
+ listed = Array(manifest.dig("files", "page_headers"))
158
+ check!(listed.include?("pages/home/portraits.json"),
159
+ "manifest page_headers must include the portrait list, got #{listed.inspect}")
160
+ end
161
+
162
+ puts "All home hero portrait checks passed."