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,164 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Public content-store contract: print-only CV copy stays in Git for private
5
+ # PDF builds and must not appear in the R2 JSON export.
6
+ # Spec: docs/content/architecture.md
7
+
8
+ $stdout.sync = true
9
+ $stderr.sync = true
10
+
11
+ require "date"
12
+ require "json"
13
+ require "open3"
14
+ require "pathname"
15
+ require "tmpdir"
16
+ require "yaml"
17
+
18
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
19
+ EXPORT_SCRIPT = REPO_ROOT.join("scripts/content/export-content-json.rb")
20
+ JOBS_DIR = REPO_ROOT.join("content/collections/jobs")
21
+
22
+ PRIVATE_JOB_FIELDS = %w[body_extended body_single_page].freeze
23
+ PRINT_ONLY_PAGE_FRAGMENTS = %w[
24
+ pages/cv/summary-extended.json
25
+ pages/cv/summary-single-page.json
26
+ pages/cv/ai-practice.json
27
+ ].freeze
28
+
29
+ def fail!(message)
30
+ warn message
31
+ exit 1
32
+ end
33
+
34
+ def check!(condition, message)
35
+ fail!(message) unless condition
36
+ end
37
+
38
+ def run_export!(out_dir)
39
+ stdout, stderr, status = Open3.capture3(
40
+ { "CONTENT_API_BASE_URL" => "" },
41
+ "ruby", EXPORT_SCRIPT.to_s, "content", out_dir.to_s,
42
+ chdir: REPO_ROOT.to_s
43
+ )
44
+ fail!("export failed:\n#{stderr}\n#{stdout}") unless status.success?
45
+
46
+ stdout
47
+ end
48
+
49
+ def split_front_matter(text)
50
+ text = text.sub(/\A\xEF\xBB\xBF/, "")
51
+ return [{}, text] unless text.start_with?("---\n")
52
+
53
+ parts = text.split(/^---\s*$\n?/, 3)
54
+ return [{}, text] unless parts.length == 3
55
+
56
+ data = YAML.safe_load(parts[1], permitted_classes: [Date, Time], aliases: true) || {}
57
+ [data, parts[2]]
58
+ end
59
+
60
+ puts "Testing public CV export redaction..."
61
+
62
+ source_jobs = JOBS_DIR.glob("*.md")
63
+ check!(!source_jobs.empty?, "expected job Markdown under content/collections/jobs")
64
+
65
+ jobs_with_private = source_jobs.select do |path|
66
+ front_matter, = split_front_matter(path.read)
67
+ PRIVATE_JOB_FIELDS.any? { |field| !front_matter[field].to_s.strip.empty? }
68
+ end
69
+ check!(!jobs_with_private.empty?, "expected at least one job with body_extended or body_single_page in Git")
70
+
71
+ extended_summary = REPO_ROOT.join("content/pages/cv/summary-extended.md")
72
+ single_page_summary = REPO_ROOT.join("content/pages/cv/summary-single-page.md")
73
+ check!(extended_summary.file?, "missing print-only #{extended_summary}")
74
+ check!(single_page_summary.file?, "missing print-only #{single_page_summary}")
75
+
76
+ def check_export!(out, jobs_with_private)
77
+ job_json = out.glob("collections/jobs/*.json")
78
+ check!(!job_json.empty?, "export produced no job JSON")
79
+
80
+ job_json.each do |path|
81
+ payload = JSON.parse(path.read)
82
+ front_matter = payload["front_matter"] || {}
83
+ PRIVATE_JOB_FIELDS.each do |field|
84
+ check!(
85
+ !front_matter.key?(field),
86
+ "#{path.basename} public JSON still contains front_matter.#{field}"
87
+ )
88
+ end
89
+
90
+ source = JOBS_DIR.join("#{path.basename('.json')}.md")
91
+ next unless source.file?
92
+
93
+ source_fm, = split_front_matter(source.read)
94
+ next if source_fm["body_public"].to_s.strip.empty?
95
+
96
+ check!(
97
+ !front_matter["body_public"].to_s.strip.empty?,
98
+ "#{path.basename} dropped body_public while redacting print-only fields"
99
+ )
100
+ end
101
+
102
+ PRINT_ONLY_PAGE_FRAGMENTS.each do |relative|
103
+ leaked = out.join(relative)
104
+ check!(!leaked.file?, "print-only fragment exported to public JSON: #{relative}")
105
+ end
106
+
107
+ public_summary = out.join("pages/cv/summary.json")
108
+ check!(public_summary.file?, "public CV summary fragment missing from export")
109
+
110
+ skills_json = out.join("data/skills.json")
111
+ check!(!skills_json.file?, "skills.json should not be exported publicly (private CV PDF sidebar only)")
112
+
113
+ contacts_json = out.join("data/contacts.json")
114
+ check!(contacts_json.file?, "public contacts JSON missing from export")
115
+ contacts = JSON.parse(contacts_json.read)
116
+ check!(contacts.is_a?(Array) && !contacts.empty?, "public contacts JSON should be a non-empty array")
117
+ contacts.each do |contact|
118
+ href = contact.is_a?(Hash) ? contact["href"].to_s : ""
119
+ check!(
120
+ !href.start_with?("mailto:"),
121
+ "data/contacts.json still exports a mailto contact (#{href})"
122
+ )
123
+ end
124
+ check!(
125
+ !contacts_json.read.match?(/mailto:|@gmail\.com/i),
126
+ "data/contacts.json still contains a public email address"
127
+ )
128
+
129
+ print_header = REPO_ROOT.join("_includes/cv/cv-print-header.html")
130
+ check!(print_header.file?, "missing private CV print header")
131
+ check!(
132
+ print_header.read.include?("mailto:"),
133
+ "private CV print header must keep mailto for recruiter PDFs"
134
+ )
135
+
136
+ leaked_blob = out.glob("**/*.json").map(&:read).join("\n")
137
+ jobs_with_private.each do |source|
138
+ front_matter, = split_front_matter(source.read)
139
+ PRIVATE_JOB_FIELDS.each do |field|
140
+ snippet = front_matter[field].to_s.strip.lines.map(&:strip).reject(&:empty?).find { |line| line.length >= 40 }
141
+ next if snippet.nil?
142
+
143
+ check!(
144
+ !leaked_blob.include?(snippet),
145
+ "#{source.basename} #{field} still appears in public JSON (#{snippet[0, 80]}…)"
146
+ )
147
+ end
148
+ end
149
+ end
150
+
151
+ existing = ARGV[0]
152
+ if existing
153
+ out = Pathname.new(existing).expand_path
154
+ check!(out.directory?, "export dir missing: #{out}")
155
+ check_export!(out, jobs_with_private)
156
+ else
157
+ Dir.mktmpdir("public-cv-export-") do |dir|
158
+ out = Pathname.new(dir).join("export")
159
+ run_export!(out)
160
+ check_export!(out, jobs_with_private)
161
+ end
162
+ end
163
+
164
+ puts "Public CV export redaction OK"
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $stdout.sync = true
5
+ $stderr.sync = true
6
+
7
+ require "pathname"
8
+ require_relative "../lib/content_storage_config"
9
+
10
+ REPO_ROOT = ContentStorageConfig.repo_root
11
+ REDIRECTS_FILE = REPO_ROOT.join("_redirects")
12
+ STUDIO_FILE = REPO_ROOT.join("studio/index.html")
13
+ EXPORT_SCRIPT = REPO_ROOT.join("scripts/content/export-content-json.rb")
14
+
15
+ def fail!(message)
16
+ raise message
17
+ end
18
+
19
+ def check!(condition, message)
20
+ fail!(message) unless condition
21
+ end
22
+
23
+ base = ContentStorageConfig.base_url
24
+ check!(base && !base.empty?, "_config.yml content_api_base_url must be set")
25
+
26
+ legacy_host = ContentStorageConfig::LEGACY_GCS_HOST
27
+ [
28
+ ["_config.yml", ContentStorageConfig.config_path.read],
29
+ ["_redirects", REDIRECTS_FILE.read],
30
+ ["studio/index.html", STUDIO_FILE.read],
31
+ ["scripts/content/export-content-json.rb", EXPORT_SCRIPT.read]
32
+ ].each do |label, text|
33
+ check!(!text.include?(legacy_host),
34
+ "#{label} still references GCS (#{legacy_host})")
35
+ end
36
+
37
+ if ContentStorageConfig.placeholder?(base)
38
+ puts "SKIP: R2 URL alignment checks (content_api_base_url is still placeholder: #{base})"
39
+ puts " Fill the URL per docs/ops/cloudflare.md before production cutover."
40
+ exit 0
41
+ end
42
+
43
+ redirects = REDIRECTS_FILE.read
44
+ media_line = redirects.lines.find { |line| line.start_with?("/media/*") }
45
+ check!(media_line, "_redirects must define /media/* → R2")
46
+ check!(media_line.include?(base),
47
+ "/media/* redirect must use content_api_base_url (#{base}); got: #{media_line.strip}")
48
+
49
+ studio = STUDIO_FILE.read
50
+ # Self-hosted Studio SPA: media is HTTPS or /media/* (redirected via _redirects).
51
+ # Do not require a Decap-era CONTENT_MEDIA_BASE constant in index.html.
52
+ check!(!studio.match?(%r{storage\.googleapis\.com|googleapis\.com/storage}),
53
+ "studio/index.html must not hardcode a GCS media host")
54
+ check!(!studio.include?(legacy_host),
55
+ "studio/index.html must not reference legacy GCS host")
56
+
57
+ puts "R2 config consistency OK (#{base})"
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Checksum skip planner for R2 JSON publish. No live credentials.
5
+
6
+ $stdout.sync = true
7
+ $stderr.sync = true
8
+
9
+ require "digest"
10
+ require "fileutils"
11
+ require "json"
12
+ require "open3"
13
+ require "pathname"
14
+ require "tmpdir"
15
+
16
+ require_relative "../lib/r2_json_sync"
17
+ require_relative "../lib/writing_index_contract"
18
+
19
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
20
+ EXPORT_SCRIPT = REPO_ROOT.join("scripts/content/export-content-json.rb")
21
+
22
+ def fail!(message)
23
+ warn message
24
+ exit 1
25
+ end
26
+
27
+ def check!(condition, message)
28
+ fail!(message) unless condition
29
+ end
30
+
31
+ def md5(text)
32
+ Digest::MD5.hexdigest(text)
33
+ end
34
+
35
+ puts "Testing R2 JSON checksum skip planner..."
36
+
37
+ check!(!R2JsonSync.skip_upload?(nil, "abc"), "missing etag must upload")
38
+ check!(!R2JsonSync.skip_upload?("", "abc"), "blank etag must upload")
39
+ check!(!R2JsonSync.skip_upload?("abc-2", "abc"), "multipart etag must upload")
40
+ check!(!R2JsonSync.skip_upload?('"abc-2"', "abc"), "quoted multipart etag must upload")
41
+ check!(R2JsonSync.skip_upload?("abc", "abc"), "matching md5 must skip")
42
+ check!(R2JsonSync.skip_upload?("ABC", "abc"), "etag compare is case-insensitive")
43
+ check!(R2JsonSync.skip_upload?('"abc"', "abc"), "quoted etag must still skip")
44
+ check!(!R2JsonSync.skip_upload?("abc", "def"), "hash mismatch must upload")
45
+ check!(
46
+ !R2JsonSync.skip_upload?("abc", "abc", key: "data/writing_index.json"),
47
+ "writing_index.json must always upload"
48
+ )
49
+
50
+ listed = R2JsonSync.contents_to_etags([
51
+ { "Key" => "collections/articles/a.json", "ETag" => '"aaa"' },
52
+ { "Key" => "collections/", "ETag" => '"dir"' },
53
+ { "Key" => "", "ETag" => '"empty"' }
54
+ ])
55
+ check!(listed == { "collections/articles/a.json" => "aaa" }, "list contents keep file keys only")
56
+
57
+ plan = R2JsonSync.plan(
58
+ [
59
+ { "key" => "collections/articles/a.json", "md5" => "aaa" },
60
+ { "key" => "collections/articles/b.json", "md5" => "bbb" },
61
+ { "key" => "data/writing_index.json", "md5" => "idx" }
62
+ ],
63
+ {
64
+ "collections/articles/a.json" => "aaa",
65
+ "collections/articles/b.json" => "old-b",
66
+ "collections/articles/gone.json" => "zzz",
67
+ "data/writing_index.json" => "idx"
68
+ }
69
+ )
70
+ check!(plan["upload"] == ["collections/articles/b.json", "data/writing_index.json"],
71
+ "mismatch + writing_index upload (got #{plan['upload'].inspect})")
72
+ check!(plan["skip"] == ["collections/articles/a.json"], "unchanged A must skip (got #{plan['skip'].inspect})")
73
+ check!(plan["delete"] == ["collections/articles/gone.json"], "extra remote key must delete")
74
+
75
+ summary = R2JsonSync.summary_line("collections", plan)
76
+ check!(summary.include?("uploaded=2"), "summary uploaded count (#{summary})")
77
+ check!(summary.include?("skipped=1"), "summary skipped count (#{summary})")
78
+ check!(summary.include?("deleted=1"), "summary deleted count (#{summary})")
79
+
80
+ puts " planner OK"
81
+
82
+ puts "Testing export A → sync → change A → incremental export → small upload..."
83
+
84
+ Dir.mktmpdir("r2-json-sync-") do |dir|
85
+ root = Pathname.new(dir)
86
+ content = root.join("content")
87
+ export = root.join("export")
88
+ FileUtils.mkdir_p(content.join("collections/articles"))
89
+
90
+ write_article = lambda do |stem, title, body|
91
+ content.join("collections/articles/#{stem}.md").write(<<~MARKDOWN)
92
+ ---
93
+ title: #{title}
94
+ key: #{stem}
95
+ date: 2026-08-22
96
+ published: true
97
+ ---
98
+ #{body}
99
+ MARKDOWN
100
+ end
101
+
102
+ write_article.call("article-a", "Article A", "Body A version one.")
103
+ write_article.call("article-b", "Article B", "Body B stays put.")
104
+ %w[article-c article-d article-e article-f].each do |stem|
105
+ write_article.call(stem, stem, "Unchanged body for #{stem}.")
106
+ end
107
+
108
+ run_export = lambda do |changed_paths|
109
+ env = {
110
+ "CONTENT_API_BASE_URL" => "",
111
+ "CONTENT_EXPORT_SKIP_VALIDATE" => "1"
112
+ }
113
+ env["CONTENT_EXPORT_CHANGED_PATHS"] = changed_paths.join("\n") unless changed_paths.empty?
114
+ stdout, stderr, status = Open3.capture3(
115
+ env,
116
+ "ruby", EXPORT_SCRIPT.to_s, content.to_s, export.to_s,
117
+ chdir: REPO_ROOT.to_s
118
+ )
119
+ fail!("export failed:\n#{stderr}\n#{stdout}") unless status.success?
120
+
121
+ stdout
122
+ end
123
+
124
+ first = run_export.call([])
125
+ check!(first.include?("changed="), "first export missing write summary")
126
+
127
+ a_path = export.join("collections/articles/2026-08-22-article-a.json")
128
+ b_path = export.join("collections/articles/2026-08-22-article-b.json")
129
+ check!(a_path.file?, "missing exported A")
130
+ check!(b_path.file?, "missing exported B")
131
+
132
+ remote = {}
133
+ apply_plan = lambda do |planned|
134
+ planned["upload"].each do |key|
135
+ local = export.join(key)
136
+ body = local.binread
137
+ remote[key] = { "body" => body, "etag" => md5(body) }
138
+ end
139
+ planned["delete"].each { |key| remote.delete(key) }
140
+ end
141
+
142
+ local_files = lambda do
143
+ export.find.select(&:file?).filter_map do |path|
144
+ rel = path.relative_path_from(export).to_s
145
+ next if rel == "manifest.json"
146
+
147
+ { "key" => rel, "md5" => md5(path.binread) }
148
+ end
149
+ end
150
+
151
+ first_plan = R2JsonSync.plan(local_files.call, {})
152
+ check!(first_plan["skip"].empty?, "empty remote must upload everything")
153
+ apply_plan.call(first_plan)
154
+ remote_b_before = remote["collections/articles/2026-08-22-article-b.json"]["body"]
155
+
156
+ write_article.call("article-a", "Article A", "Body A version two — only this file changed.")
157
+ sleep 1
158
+ second = run_export.call(["content/collections/articles/article-a.md"])
159
+ check!(second.match?(/unchanged=[1-9]/), "incremental re-export should reuse B (#{second})")
160
+
161
+ check!(b_path.binread == remote_b_before,
162
+ "incremental export must leave B bytes unchanged")
163
+
164
+ second_plan = R2JsonSync.plan(
165
+ local_files.call,
166
+ remote.transform_values { |item| item["etag"] }
167
+ )
168
+ uploaded = second_plan["upload"]
169
+ skipped = second_plan["skip"]
170
+ check!(uploaded.length < skipped.length,
171
+ "second sync should upload fewer than it skips (upload=#{uploaded.length} skip=#{skipped.length} upload=#{uploaded.inspect})")
172
+ check!(uploaded.include?("collections/articles/2026-08-22-article-a.json"),
173
+ "changed A must upload")
174
+ check!(uploaded.include?("data/writing_index.json"),
175
+ "writing_index must upload after A changes")
176
+ check!(!uploaded.include?("collections/articles/2026-08-22-article-b.json"),
177
+ "unchanged B must not upload")
178
+ check!(skipped.include?("collections/articles/2026-08-22-article-b.json"),
179
+ "unchanged B must be skipped")
180
+
181
+ apply_plan.call(second_plan)
182
+ check!(remote["collections/articles/2026-08-22-article-b.json"]["body"] == remote_b_before,
183
+ "remote B body must stay unchanged")
184
+
185
+ WritingIndexContract.assert_slugs_match_export!(export)
186
+ index = JSON.parse(export.join("data/writing_index.json").read)
187
+ a_entry = index.find { |entry| entry["key"] == "article-a" }
188
+ check!(!a_entry.nil?, "writing_index must include article-a")
189
+ check!(export.join("collections/articles/#{a_entry['slug']}.json").file?,
190
+ "Freshy-like dated slug must match an exported JSON file")
191
+ end
192
+
193
+ puts "R2 JSON sync skip logic OK"
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Refs: PLT-AC-3 — homepage bands derive from the registry.
4
+ require "pathname"
5
+ require "yaml"
6
+
7
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
8
+ $LOAD_PATH.unshift(ROOT.join("lib").to_s)
9
+ require "pandorga/registry"
10
+
11
+ config = YAML.safe_load(ROOT.join("examples/full/_config.yml").read, aliases: true)
12
+ pages = PandorgaRegistry.pages(config)
13
+ bands = PandorgaRegistry.home_bands(pages)
14
+
15
+ kinds = bands.map { |b| b["kind"] }
16
+ unless kinds == %w[portfolio articles blog index]
17
+ warn "FAIL test-registry-home: unexpected band kinds #{kinds.inspect}"
18
+ exit 1
19
+ end
20
+
21
+ index = bands.find { |b| b["kind"] == "index" }
22
+ panel_keys = index["pages"].map { |p| p["key"] }
23
+ unless panel_keys == %w[resources network bibliography]
24
+ warn "FAIL test-registry-home: index panels #{panel_keys.inspect}"
25
+ exit 1
26
+ end
27
+
28
+ layout = PandorgaRegistry.layout_for({ "template" => "portfolio" })
29
+ unless layout == "projects"
30
+ warn "FAIL test-registry-home: portfolio layout expected projects, got #{layout}"
31
+ exit 1
32
+ end
33
+
34
+ puts "PASS test-registry-home"
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The content runtime is assembled from a dozen Liquid partials into one
5
+ # inline <script>. A syntax error in any partial takes down every listing and
6
+ # detail view at once, and no Ruby gate would notice: the build succeeds and
7
+ # the page ships broken. Parse the assembled script the way a browser would.
8
+
9
+ require "pathname"
10
+ require "open3"
11
+ require "tmpdir"
12
+
13
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
14
+ SITE_ROOT = REPO_ROOT.join("_site")
15
+
16
+ # One page per distinct runtime assembly. They all include the same partials
17
+ # today, but a layout could include a subset tomorrow.
18
+ CANDIDATES = [
19
+ "pages/articles/index.html",
20
+ "pages/blog/index.html",
21
+ "pages/projects/index.html",
22
+ "index.html"
23
+ ].freeze
24
+
25
+ RUNTIME_PATTERN = /window\.ContentRuntime = \(function \(\) \{.*?\n \}\)\(\);/m
26
+
27
+ def fail!(message)
28
+ warn message
29
+ exit 1
30
+ end
31
+
32
+ puts "Testing content runtime JS syntax..."
33
+
34
+ unless SITE_ROOT.directory?
35
+ puts " skipped: no _site build to inspect"
36
+ exit 0
37
+ end
38
+
39
+ checked = 0
40
+
41
+ Dir.mktmpdir do |tmp|
42
+ CANDIDATES.each do |relative|
43
+ page = SITE_ROOT.join(relative)
44
+ next unless page.file?
45
+
46
+ match = page.read.match(RUNTIME_PATTERN)
47
+ next if match.nil?
48
+
49
+ script = Pathname.new(tmp).join("#{relative.tr('/', '-')}.js")
50
+ script.write(match[0])
51
+
52
+ _out, err, status = Open3.capture3("node", "--check", script.to_s)
53
+ unless status.success?
54
+ fail!("#{relative}: content runtime is not valid JavaScript\n#{err}")
55
+ end
56
+
57
+ checked += 1
58
+ puts " #{relative}: OK (#{match[0].bytesize} bytes)"
59
+ end
60
+ end
61
+
62
+ fail!("no page contained a ContentRuntime block — did the runtime stop inlining?") if checked.zero?
63
+
64
+ puts "All content runtime JS syntax tests passed (#{checked} page(s))."
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Gate: xcite resolution must use the slim writing_index, not bulk loadCollection
5
+ # of articles/posts/products/projects. Spec: GitHub issue #364.
6
+
7
+ require "json"
8
+ require "open3"
9
+ require "pathname"
10
+ require "tmpdir"
11
+
12
+ require_relative "../lib/writing_index_contract"
13
+
14
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
15
+ EXPORT_SCRIPT = REPO_ROOT.join("scripts/content/export-content-json.rb")
16
+ XCITE_INCLUDE = REPO_ROOT.join("_includes/content-runtime/30-includes-xcite.html")
17
+ FRAGMENTS_INCLUDE = REPO_ROOT.join("_includes/content-runtime/60-fragments.html")
18
+ LISTING_INCLUDE = REPO_ROOT.join("_includes/content-runtime/40-listing.html")
19
+ BLOG_LAYOUT = REPO_ROOT.join("_layouts/blog.html")
20
+ ARTICLES_LAYOUT = REPO_ROOT.join("_layouts/articles.html")
21
+
22
+ def fail!(message)
23
+ warn message
24
+ exit 1
25
+ end
26
+
27
+ def check!(condition, message)
28
+ fail!(message) unless condition
29
+ end
30
+
31
+ puts "Testing slim writing index / xcite fetch contract..."
32
+
33
+ xcite_src = XCITE_INCLUDE.read
34
+ check!(xcite_src.include?("data/writing_index.json"),
35
+ "ensureXciteIndex must load data/writing_index.json")
36
+ check!(!xcite_src.match?(/loadCollection\(\s*['"]collections\/(articles|posts)/),
37
+ "ensureXciteIndex must not call loadCollection for articles/posts")
38
+ check!(!xcite_src.match?(/loadCollection\(\s*['"]collections\/(products|projects)/),
39
+ "ensureXciteIndex must not call loadCollection for products/projects")
40
+
41
+ fragments_src = FRAGMENTS_INCLUDE.read
42
+ check!(fragments_src.include?("jsonCache"),
43
+ "loadJson must memoize in-memory")
44
+ check!(fragments_src.include?("function loadWritingIndex"),
45
+ "runtime must expose loadWritingIndex for listing shells")
46
+ check!(fragments_src.include?("excerpt: entry.excerpt"),
47
+ "writingIndexAsListingItem must pass excerpt through to listing cards")
48
+
49
+ listing_src = LISTING_INCLUDE.read
50
+ check!(listing_src.include?("function listingPreview"),
51
+ "runtime must expose listingPreview so cards can fall back to excerpts")
52
+
53
+ [BLOG_LAYOUT, ARTICLES_LAYOUT].each do |layout|
54
+ check!(layout.read.include?("listingPreview"),
55
+ "#{layout.relative_path_from(REPO_ROOT)} cards must use listingPreview")
56
+ end
57
+
58
+ Dir.mktmpdir("writing-index-") do |tmp|
59
+ out_dir = Pathname.new(tmp)
60
+ stdout, stderr, status = Open3.capture3(
61
+ { "CONTENT_API_BASE_URL" => "" },
62
+ "ruby", EXPORT_SCRIPT.to_s, "content", out_dir.to_s,
63
+ chdir: REPO_ROOT.to_s
64
+ )
65
+ fail!("export-content-json.rb failed:\n#{stderr}\n#{stdout}") unless status.success?
66
+
67
+ index_path = out_dir.join("data/writing_index.json")
68
+ check!(index_path.file?, "export must write data/writing_index.json")
69
+
70
+ entries = JSON.parse(index_path.read)
71
+ check!(entries.is_a?(Array) && !entries.empty?, "writing_index must be a non-empty array")
72
+
73
+ required = %w[key slug title url type collection date xcites category description excerpt thumbnail aliases]
74
+ sample = entries.first
75
+ required.each do |field|
76
+ check!(sample.key?(field), "writing_index entries must include #{field}")
77
+ end
78
+
79
+ types = entries.map { |e| e["collection"] || e["type"] }.uniq
80
+ %w[articles posts products projects].each do |collection|
81
+ check!(types.include?(collection),
82
+ "writing_index must cover #{collection} (got #{types.inspect})")
83
+ end
84
+
85
+ posts = entries.select { |entry| (entry["collection"] || entry["type"]) == "posts" }
86
+ check!(!posts.empty?, "writing_index must include posts")
87
+ post_without_description = posts.find { |entry| entry["description"].to_s.strip.empty? }
88
+ check!(!post_without_description.nil?, "expected at least one post with an empty editorial description")
89
+ excerpt = post_without_description["excerpt"].to_s.strip
90
+ check!(!excerpt.empty?, "posts without description must still export a body excerpt")
91
+ check!(excerpt.length <= 220, "excerpt must stay within the listing preview limit")
92
+
93
+ article = entries.find { |entry| (entry["collection"] || entry["type"]) == "articles" && !entry["description"].to_s.strip.empty? }
94
+ check!(!article.nil?, "expected an article with an editorial description")
95
+ check!(!article["excerpt"].to_s.strip.empty?, "articles must still export a body excerpt alongside description")
96
+
97
+ WritingIndexContract.assert_slugs_match_export!(out_dir, entries)
98
+ freshy = entries.find { |entry| entry["key"] == "freshy-cooling-map" }
99
+ if freshy
100
+ check!(out_dir.join("collections/articles/#{freshy['slug']}.json").file?,
101
+ "Freshy writing_index slug must match collections/articles/#{freshy['slug']}.json")
102
+ end
103
+
104
+ expected_aliases = {
105
+ "ai-agents-sdd" => "ai-agents-sdd",
106
+ "vibe-coding" => "2026-03-24-where-to-do-vibe-coding",
107
+ "wsl" => "20260224-wsl",
108
+ "stitch-and-claude" => "stitch-and-claude"
109
+ }
110
+ expected_aliases.each do |key, alias_slug|
111
+ entry = entries.find { |item| item["key"] == key }
112
+ check!(!entry.nil?, "writing_index must include #{key}")
113
+ aliases = Array(entry["aliases"])
114
+ check!(aliases.include?(alias_slug),
115
+ "#{key} writing_index aliases must include #{alias_slug} (got #{aliases.inspect})")
116
+ end
117
+ end
118
+
119
+ check!(FRAGMENTS_INCLUDE.read.include?("function resolveWritingAlias"),
120
+ "runtime must resolve old slugs from writing_index aliases")
121
+ check!(ARTICLES_LAYOUT.read.include?("resolveWritingAlias"),
122
+ "articles shell must resolve writing_index aliases before loading JSON")
123
+ check!(ARTICLES_LAYOUT.read.include?("adoptCanonicalDetailUrl"),
124
+ "articles shell must replaceState to the canonical URL when an alias matches")
125
+
126
+ puts "All slim writing index / xcite fetch checks passed."