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,69 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Objects taken down for copyright / unpublished drafts must not re-enter the
5
+ # public export. The R2 sync deletes remote keys that are absent locally, so
6
+ # this list is also the prune set for a clean publish.
7
+
8
+ $stdout.sync = true
9
+ $stderr.sync = true
10
+
11
+ require "json"
12
+ require "open3"
13
+ require "pathname"
14
+ require "tmpdir"
15
+
16
+ require_relative "../lib/withdrawn_public_objects"
17
+
18
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
19
+ EXPORT_SCRIPT = REPO_ROOT.join("scripts/content/export-content-json.rb")
20
+
21
+ WITHDRAWN_EXPORT_PATHS = WithdrawnPublicObjects::EXPORT_PATHS
22
+ STALE_PUBLIC_PHRASES = WithdrawnPublicObjects::STALE_PHRASES
23
+
24
+ def fail!(message)
25
+ warn message
26
+ exit 1
27
+ end
28
+
29
+ def check!(condition, message)
30
+ fail!(message) unless condition
31
+ end
32
+
33
+ def check_export!(out)
34
+ manifest = JSON.parse(out.join("manifest.json").read)
35
+ listed = Array(manifest["files"]).flat_map { |_key, paths| Array(paths) }
36
+
37
+ WITHDRAWN_EXPORT_PATHS.each do |relative|
38
+ check!(!out.join(relative).exist?, "export wrote withdrawn object #{relative}")
39
+ check!(!listed.include?(relative), "manifest lists withdrawn object #{relative}")
40
+ end
41
+
42
+ export_text = out.glob("**/*.json").map(&:read).join("\n")
43
+ STALE_PUBLIC_PHRASES.each do |phrase|
44
+ check!(!export_text.include?(phrase), "export still contains withdrawn phrase #{phrase.inspect}")
45
+ end
46
+ end
47
+
48
+ puts "Testing withdrawn objects stay out of the public export..."
49
+
50
+ existing = ARGV[0]
51
+ if existing
52
+ out = Pathname.new(existing).expand_path
53
+ check!(out.directory?, "export dir missing: #{out}")
54
+ check_export!(out)
55
+ else
56
+ Dir.mktmpdir("withdrawn-content-export-") do |dir|
57
+ out = Pathname.new(dir).join("export")
58
+ stdout, stderr, status = Open3.capture3(
59
+ { "CONTENT_API_BASE_URL" => "" },
60
+ "ruby", EXPORT_SCRIPT.to_s, "content", out.to_s,
61
+ chdir: REPO_ROOT.to_s
62
+ )
63
+ fail!("export failed:\n#{stderr}\n#{stdout}") unless status.success?
64
+
65
+ check_export!(out)
66
+ end
67
+ end
68
+
69
+ puts "Withdrawn content export OK."
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # writing_index slugs must point at exported JSON (08-23 vs 08-22 class of bug).
5
+
6
+ $stdout.sync = true
7
+ $stderr.sync = true
8
+
9
+ require "fileutils"
10
+ require "json"
11
+ require "pathname"
12
+ require "tmpdir"
13
+
14
+ require_relative "../lib/writing_index_contract"
15
+
16
+ def fail!(message)
17
+ warn message
18
+ exit 1
19
+ end
20
+
21
+ def check!(condition, message)
22
+ fail!(message) unless condition
23
+ end
24
+
25
+ puts "Testing writing_index ↔ export slug contract..."
26
+
27
+ Dir.mktmpdir("writing-index-slugs-") do |dir|
28
+ root = Pathname.new(dir)
29
+ FileUtils.mkdir_p(root.join("collections/articles"))
30
+ FileUtils.mkdir_p(root.join("data"))
31
+
32
+ root.join("collections/articles/2026-08-22-freshy-cooling-map.json").write(<<~JSON)
33
+ {"type":"article","slug":"2026-08-22-freshy-cooling-map","key":"freshy-cooling-map"}
34
+ JSON
35
+
36
+ matching = [
37
+ {
38
+ "slug" => "2026-08-22-freshy-cooling-map",
39
+ "key" => "freshy-cooling-map",
40
+ "type" => "articles",
41
+ "collection" => "articles"
42
+ }
43
+ ]
44
+ check!(WritingIndexContract.mismatches(root, matching).empty?,
45
+ "matching 08-22 slug must pass")
46
+
47
+ stale = [
48
+ {
49
+ "slug" => "2026-08-23-freshy-cooling-map",
50
+ "key" => "freshy-cooling-map",
51
+ "type" => "articles",
52
+ "collection" => "articles"
53
+ }
54
+ ]
55
+ errors = WritingIndexContract.mismatches(root, stale)
56
+ check!(!errors.empty?, "08-23 index vs 08-22 file must fail")
57
+ check!(errors.any? { |line| line.include?("2026-08-23-freshy-cooling-map") },
58
+ "mismatch should name the bad slug (#{errors.inspect})")
59
+
60
+ root.join("data/writing_index.json").write(JSON.pretty_generate(stale))
61
+ begin
62
+ WritingIndexContract.assert_slugs_match_export!(root)
63
+ fail!("assert_slugs_match_export! should raise on 08-23 vs 08-22")
64
+ rescue RuntimeError => e
65
+ check!(e.message.include?("2026-08-23-freshy-cooling-map"),
66
+ "assertion message should include the stale slug")
67
+ end
68
+ end
69
+
70
+ puts "writing_index export slug contract OK"
71
+
72
+ existing = ARGV[0]
73
+ if existing
74
+ out = Pathname.new(existing).expand_path
75
+ check!(out.directory?, "export dir missing: #{out}")
76
+ WritingIndexContract.assert_slugs_match_export!(out)
77
+ index = JSON.parse(out.join("data/writing_index.json").read)
78
+ freshy = index.find { |entry| entry["key"] == "freshy-cooling-map" }
79
+ if freshy
80
+ check!(out.join("collections/articles/#{freshy['slug']}.json").file?,
81
+ "Freshy writing_index slug must match collections/articles/#{freshy['slug']}.json")
82
+ end
83
+ puts "writing_index slugs match #{out}"
84
+ end
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require "pathname"
7
+
8
+ require_relative "../../_plugins/writing_slug_limits"
9
+
10
+ def check!(condition, message = "condition was false")
11
+ raise "Assertion failed: #{message}" unless condition
12
+ end
13
+
14
+ def assert_equal(expected, actual, message = nil)
15
+ return if expected == actual
16
+
17
+ label = message || "expected #{expected.inspect}, got #{actual.inspect}"
18
+ raise "Assertion failed: #{label}"
19
+ end
20
+
21
+ def with_temp_writing_tree
22
+ Dir.mktmpdir("writing-slug-limits") do |tmpdir|
23
+ root = Pathname.new(tmpdir)
24
+ yield root
25
+ end
26
+ end
27
+
28
+ puts "Testing WritingSlugLimits..."
29
+
30
+ check! WritingSlugLimits.ascii_slug?("2026-06-26-escarnio")
31
+ check! !WritingSlugLimits.ascii_slug?("2026-06-26-escárnio"), "accented slug should not be ASCII"
32
+
33
+ assert_equal "2026-06-26-escarnio", WritingSlugLimits.transliterate_slug("2026-06-26-escárnio")
34
+ assert_equal "2026-04-16-otimismo-na-ciencia", WritingSlugLimits.transliterate_slug("2026-04-16-otimismo-na-ciência")
35
+ assert_equal "2026-06-24-por-que-eu-sai-do-mundo-do-drone", WritingSlugLimits.transliterate_slug("2026-06-24-por-que-eu-saí-do-mundo-do-drone")
36
+
37
+ with_temp_writing_tree do |root|
38
+ posts_dir = root.join("content/collections/posts")
39
+ FileUtils.mkdir_p(posts_dir)
40
+ File.write(posts_dir.join("2026-06-26-escárnio.md"), "---\ntitle: Test\n---\n")
41
+
42
+ errors = WritingSlugLimits.collect_errors(root)
43
+ check! errors.any? { |error| error.include?("non-ASCII") }, "expected non-ASCII slug error"
44
+ check! errors.any? { |error| error.include?("2026-06-26-escarnio") }, "expected transliterated suggestion"
45
+ end
46
+
47
+ with_temp_writing_tree do |root|
48
+ posts_dir = root.join("content/collections/posts")
49
+ FileUtils.mkdir_p(posts_dir)
50
+ long_slug = "#{'a' * 45}"
51
+ File.write(posts_dir.join("#{long_slug}.md"), "---\ntitle: Test\n---\n")
52
+
53
+ errors = WritingSlugLimits.collect_errors(root)
54
+ check! errors.any? { |error| error.include?("chars (max #{WritingSlugLimits::MAX_LENGTH})") },
55
+ "expected length error"
56
+ end
57
+
58
+ repo_errors = WritingSlugLimits.collect_errors(Pathname.new(__dir__).join("..", ".."))
59
+ check! repo_errors.empty?, "repository slug validation failed:\n - #{repo_errors.join("\n - ")}"
60
+
61
+ puts "All WritingSlugLimits tests passed."
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Gate: article/post xcite targets must not be editorially newer than the source.
5
+ # Projects and products may be cited from any dated piece. Same-day companions
6
+ # (EN/PT pairs) may cite each other.
7
+
8
+ require "date"
9
+ require "pathname"
10
+ require "yaml"
11
+
12
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
13
+ XCITE_RE = /\{%-?\s*include\s+xcite\.html\s+([^%]+?)-?%\}/i
14
+ KEY_ATTR_RE = /key\s*=\s*["']([^"']+)["']/i
15
+ DATED_KINDS = %w[articles posts].freeze
16
+ FREE_KINDS = %w[projects products].freeze
17
+
18
+ def check!(condition, message)
19
+ raise message unless condition
20
+ end
21
+
22
+ def load_front_matter(path)
23
+ text = path.read
24
+ return [{}, ""] unless text.start_with?("---")
25
+
26
+ parts = text.split("---", 3)
27
+ return [{}, text] if parts.length < 3
28
+
29
+ [YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}, parts[2]]
30
+ end
31
+
32
+ def editorial_date(path, front_matter)
33
+ raw = front_matter["date"]
34
+ case raw
35
+ when Date then return raw.iso8601
36
+ when Time then return raw.to_date.iso8601
37
+ when String
38
+ day = raw.strip[0, 10]
39
+ return day if day.match?(/\A\d{4}-\d{2}-\d{2}\z/)
40
+ end
41
+
42
+ stem = path.basename.to_s
43
+ return Regexp.last_match(1) if stem =~ /\A(\d{4}-\d{2}-\d{2})-/
44
+
45
+ nil
46
+ end
47
+
48
+ def infer_key(path, front_matter, kind)
49
+ key = front_matter["key"].to_s.strip
50
+ return key unless key.empty?
51
+
52
+ stem = path.basename(".md").to_s
53
+ return stem.sub(/\A\d{4}-\d{2}-\d{2}-/, "") if DATED_KINDS.include?(kind)
54
+
55
+ stem
56
+ end
57
+
58
+ puts "Testing xcite chronology (newer texts cite older texts; projects/products free)..."
59
+
60
+ entries = {}
61
+ (DATED_KINDS + FREE_KINDS).each do |kind|
62
+ dir = REPO_ROOT.join("content/collections", kind)
63
+ next unless dir.directory?
64
+
65
+ dir.glob("*.md").each do |path|
66
+ front_matter, body = load_front_matter(path)
67
+ key = infer_key(path, front_matter, kind)
68
+ check!(!key.empty?, "missing key for #{path.relative_path_from(REPO_ROOT)}")
69
+
70
+ entries[key] = {
71
+ "kind" => kind,
72
+ "path" => path.relative_path_from(REPO_ROOT).to_s,
73
+ "date" => editorial_date(path, front_matter),
74
+ "body" => body
75
+ }
76
+ end
77
+ end
78
+
79
+ violations = []
80
+ DATED_KINDS.each do |kind|
81
+ entries.each_value do |source|
82
+ next unless source["kind"] == kind
83
+
84
+ source["body"].scan(XCITE_RE).flatten.each do |attrs|
85
+ match = KEY_ATTR_RE.match(attrs)
86
+ next unless match
87
+
88
+ target_key = match[1]
89
+ target = entries[target_key]
90
+ check!(!target.nil?, "unknown xcite key #{target_key.inspect} in #{source['path']}")
91
+ next if FREE_KINDS.include?(target["kind"])
92
+ next unless DATED_KINDS.include?(target["kind"])
93
+
94
+ src_date = source["date"]
95
+ tgt_date = target["date"]
96
+ check!(!src_date.to_s.empty?, "missing editorial date for #{source['path']}")
97
+ check!(!tgt_date.to_s.empty?, "missing editorial date for #{target['path']}")
98
+ next if src_date >= tgt_date
99
+
100
+ violations << "#{source['path']} (#{src_date}) cites newer #{target['path']} (#{tgt_date}) via key=#{target_key}"
101
+ end
102
+ end
103
+ end
104
+
105
+ check!(violations.empty?, "xcite chronology violations:\n #{violations.join("\n ")}")
106
+ puts "All xcite chronology checks passed (#{entries.size} keyed entries)."
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # Platform gates that run against the gem / fixtures / examples.
3
+ # Content-corpus gates stay on the consuming site (website).
4
+ set -euo pipefail
5
+
6
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
7
+ cd "$ROOT"
8
+
9
+ echo "== pandorga validate =="
10
+
11
+ # Prefer Bundler so gemspec deps (jekyll, …) resolve the same way in CI and
12
+ # local checkouts. Plain `ruby` only sees the system/mise gem path.
13
+ if [[ -f "${ROOT}/Gemfile.lock" ]] && command -v bundle >/dev/null 2>&1; then
14
+ if command -v mise >/dev/null 2>&1; then
15
+ RUBY=(mise exec -- bundle exec ruby)
16
+ else
17
+ RUBY=(bundle exec ruby)
18
+ fi
19
+ elif command -v mise >/dev/null 2>&1; then
20
+ RUBY=(mise exec -- ruby)
21
+ else
22
+ RUBY=(ruby)
23
+ fi
24
+
25
+ export RUBYOPT="-I${ROOT}/lib${RUBYOPT:+ $RUBYOPT}"
26
+
27
+ # Explicit allowlist — do not glob site-corpus tests copied during extraction.
28
+ TESTS=(
29
+ scripts/test/test-doctor.rb
30
+ scripts/test/test-new.rb
31
+ scripts/test/test-export.rb
32
+ scripts/test/test-registry-home.rb
33
+ scripts/test/test-template-packages.rb
34
+ scripts/test/test-dual-template-instances.rb
35
+ scripts/test/test-dual-template-runtime.rb
36
+ scripts/test/test-install-functions.rb
37
+ scripts/test/test-no-personal-data.rb
38
+ scripts/test/test-configuration-doc.rb
39
+ scripts/test/test-cv-export-contract.rb
40
+ scripts/test/test-css-structure.rb
41
+ scripts/test/test-form-control-theming.rb
42
+ scripts/test/test-listing-filter-contract.rb
43
+ scripts/test/test-studio-api-contract.rb
44
+ scripts/test/test-studio-rewrite-api-contract.rb
45
+ scripts/test/test-studio-schema-composition.rb
46
+ scripts/test/test-text-excerpt.rb
47
+ scripts/test/test-git-chronology.rb
48
+ scripts/test/test-math-row-breaks.rb
49
+ scripts/test/test-detail-render-parity.rb
50
+ scripts/test/test-absent-field-absent-element.rb
51
+ scripts/test/test-home-band-art.rb
52
+ scripts/test/test-r2-json-sync.rb
53
+ scripts/test/test-writing-slug-limits.rb
54
+ scripts/test/test-examples-build.rb
55
+ )
56
+
57
+ failed=0
58
+ for test in "${TESTS[@]}"; do
59
+ if [[ ! -f "$test" ]]; then
60
+ echo "-- ${test} (missing, skip)"
61
+ continue
62
+ fi
63
+ echo "-- ${test}"
64
+ if ! "${RUBY[@]}" "$test"; then
65
+ failed=1
66
+ fi
67
+ done
68
+
69
+ if [[ "$failed" -ne 0 ]]; then
70
+ echo "VALIDATE FAILED"
71
+ exit 1
72
+ fi
73
+
74
+ echo "VALIDATE OK"
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ // build-cv-pages-json.js — Write the cv-pages.json file used by take-screenshots.js.
3
+ //
4
+ // Extracts the 'cv' array from visual-inspection-pages.json and writes it as a flat
5
+ // array to cv-pages.json in the current working directory.
6
+ //
7
+ // Usage (run from repo root):
8
+ // node scripts/visual/build-cv-pages-json.js [outputFile]
9
+ //
10
+ // Arguments:
11
+ // outputFile — Output path for the JSON file (default: cv-pages.json)
12
+
13
+ 'use strict';
14
+
15
+ const path = require('path');
16
+ const fs = require('fs');
17
+
18
+ const REPO_ROOT = path.resolve(path.join(__dirname, '..', '..'));
19
+
20
+ function main() {
21
+ const outputFile = path.resolve(process.argv[2] || path.join(REPO_ROOT, 'cv-pages.json'));
22
+
23
+ const cfg = JSON.parse(
24
+ fs.readFileSync(path.join(__dirname, 'visual-inspection-pages.json'), 'utf8')
25
+ );
26
+
27
+ fs.writeFileSync(outputFile, JSON.stringify(cfg.cv, null, 2), 'utf8');
28
+
29
+ console.log(`Wrote ${cfg.cv.length} page(s) to ${outputFile}`);
30
+ cfg.cv.forEach((p) => console.log(` ${p.name} → ${p.url}`));
31
+ }
32
+
33
+ main();
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ // build-site-pages-json.js — Build the site-pages.json file used by take-screenshots.js.
3
+ //
4
+ // Takes the static page list from visual-inspection-pages.json, appends the most
5
+ // recent article and post (from content/), and writes site-pages.json.
6
+ //
7
+ // Usage (run from repo root):
8
+ // node scripts/visual/build-site-pages-json.js [outputFile]
9
+ //
10
+ // Arguments:
11
+ // outputFile — Output path for the JSON file (default: site-pages.json)
12
+
13
+ 'use strict';
14
+
15
+ const path = require('path');
16
+ const fs = require('fs');
17
+ const { execSync } = require('child_process');
18
+
19
+ const REPO_ROOT = path.resolve(path.join(__dirname, '..', '..'));
20
+ const CONTENT_ROOT = path.join(REPO_ROOT, 'content');
21
+ const ARTICLES_DIR = path.join(CONTENT_ROOT, 'collections', 'articles');
22
+ const POSTS_DIR = path.join(CONTENT_ROOT, 'collections', 'posts');
23
+
24
+ function gitChronology(repoRelativePath, type) {
25
+ const quotedPath = repoRelativePath.replace(/'/g, "'\\''");
26
+ const quotedType = type.replace(/'/g, "'\\''");
27
+ const raw = execSync(
28
+ `bundle exec ruby -rjson -e "require_relative '_plugins/git_chronology'; puts JSON.generate(GitChronology.chronology_for(ARGV[0], type: ARGV[1]))" -- '${quotedPath}' '${quotedType}'`,
29
+ { cwd: REPO_ROOT, encoding: 'utf8' }
30
+ ).trim();
31
+ const chronology = JSON.parse(raw || '{}');
32
+ return {
33
+ date: chronology.date || '',
34
+ last_updated: chronology.last_updated || chronology.date || '',
35
+ };
36
+ }
37
+
38
+ function latestMarkdownSlug(dir, repoRelativeDir) {
39
+ if (!fs.existsSync(dir)) {
40
+ throw new Error(`Directory not found: ${dir}`);
41
+ }
42
+
43
+ const files = fs
44
+ .readdirSync(dir)
45
+ .filter((f) => f.endsWith('.md'));
46
+
47
+ if (files.length === 0) {
48
+ throw new Error(`No markdown files found in ${dir}`);
49
+ }
50
+
51
+ return files
52
+ .map((file) => {
53
+ const slug = file.replace(/\.md$/, '');
54
+ const writingType = repoRelativeDir.includes('posts') ? 'post' : 'article';
55
+ const chronology = gitChronology(path.posix.join(repoRelativeDir, file), writingType);
56
+ return {
57
+ slug,
58
+ sortKey: chronology.last_updated || chronology.date || slug,
59
+ };
60
+ })
61
+ .sort((a, b) => String(b.sortKey).localeCompare(String(a.sortKey)))[0].slug;
62
+ }
63
+
64
+ function main() {
65
+ const outputFile = path.resolve(process.argv[2] || path.join(REPO_ROOT, 'site-pages.json'));
66
+
67
+ const cfg = JSON.parse(
68
+ fs.readFileSync(path.join(__dirname, 'visual-inspection-pages.json'), 'utf8')
69
+ );
70
+
71
+ const latestArticleSlug = latestMarkdownSlug(ARTICLES_DIR, 'content/collections/articles');
72
+ const latestPostSlug = latestMarkdownSlug(POSTS_DIR, 'content/collections/posts');
73
+
74
+ const pages = [
75
+ ...cfg.site,
76
+ {
77
+ id: 'latest-article',
78
+ name: 'Latest Article',
79
+ url: `/articles/${encodeURIComponent(latestArticleSlug)}/`,
80
+ },
81
+ {
82
+ id: 'latest-post',
83
+ name: 'Latest Post',
84
+ url: `/posts/${encodeURIComponent(latestPostSlug)}/`,
85
+ },
86
+ ];
87
+
88
+ fs.writeFileSync(outputFile, JSON.stringify(pages, null, 2), 'utf8');
89
+
90
+ console.log(`Wrote ${pages.length} page(s) to ${outputFile}`);
91
+ pages.forEach((p) => console.log(` ${p.name} → ${p.url}`));
92
+ }
93
+
94
+ main();
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bash
2
+ # Upload visual-inspection PNG screenshots to Cloudflare R2.
3
+ # Used by .github/workflows/visual-inspection.yml (manual dispatch only).
4
+ set -euo pipefail
5
+
6
+ LOCAL_DIR="${1:?Usage: publish-visual-inspection-to-r2.sh <local-dir> <run-prefix> [subdir]}"
7
+ RUN_PREFIX="${2:?Usage: publish-visual-inspection-to-r2.sh <local-dir> <run-prefix> [subdir]}"
8
+ SUBDIR="${3:-}"
9
+
10
+ : "${R2_ENDPOINT:?Set R2_ENDPOINT}"
11
+ : "${R2_BUCKET:?Set R2_BUCKET}"
12
+ : "${R2_ACCESS_KEY_ID:?Set R2_ACCESS_KEY_ID}"
13
+ : "${R2_SECRET_ACCESS_KEY:?Set R2_SECRET_ACCESS_KEY}"
14
+
15
+ export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
16
+ export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
17
+ export AWS_DEFAULT_REGION="${R2_REGION:-auto}"
18
+
19
+ if ! command -v aws >/dev/null 2>&1; then
20
+ echo "aws CLI is required." >&2
21
+ exit 1
22
+ fi
23
+
24
+ if [ ! -d "$LOCAL_DIR" ]; then
25
+ echo "Local directory not found: $LOCAL_DIR" >&2
26
+ exit 1
27
+ fi
28
+
29
+ DEST="visual-inspection/${RUN_PREFIX}"
30
+ if [ -n "$SUBDIR" ]; then
31
+ DEST="${DEST}/${SUBDIR}"
32
+ fi
33
+
34
+ echo "==> R2 sync ${LOCAL_DIR}/ → s3://${R2_BUCKET}/${DEST}/"
35
+ aws s3 sync "$LOCAL_DIR" "s3://${R2_BUCKET}/${DEST}/" \
36
+ --endpoint-url "$R2_ENDPOINT" \
37
+ --exclude "*" \
38
+ --include "*.png" \
39
+ --cache-control "public,max-age=31536000,immutable" \
40
+ --content-type "image/png"
41
+
42
+ echo "==> Visual inspection upload complete: ${DEST}/"
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // take-screenshots.js — Screenshot site pages using Puppeteer for visual inspection.
3
+ //
4
+ // Usage:
5
+ // node scripts/visual/take-screenshots.js <outputDir> <baseUrl> <pagesJsonFile>
6
+ //
7
+ // Arguments:
8
+ // outputDir — Directory to save screenshots (created if missing)
9
+ // baseUrl — Base URL of the local server (e.g. http://localhost:4000 for the
10
+ // public site; the workflow uses 4001 for the private CV build)
11
+ // pagesJsonFile — Path to a flat JSON array: [{ id, name, url }, ...]
12
+ //
13
+ // Output: saves <id>.png for each page in outputDir.
14
+ //
15
+ // Exit codes:
16
+ // 0 — all screenshots successful
17
+ // 1 — one or more screenshots failed (partial output may exist)
18
+
19
+ 'use strict';
20
+
21
+ const puppeteer = require('puppeteer');
22
+ const path = require('path');
23
+ const fs = require('fs');
24
+
25
+ async function main() {
26
+ const outputDir = path.resolve(process.argv[2] || 'screenshots');
27
+ const baseUrl = (process.argv[3] || 'http://localhost:4000').replace(/\/$/, '');
28
+ const pagesFile = path.resolve(process.argv[4] || 'pages.json');
29
+
30
+ const pages = JSON.parse(fs.readFileSync(pagesFile, 'utf8'));
31
+
32
+ fs.mkdirSync(outputDir, { recursive: true });
33
+
34
+ const browser = await puppeteer.launch({
35
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
36
+ });
37
+
38
+ let failures = 0;
39
+
40
+ try {
41
+ const executing = new Set();
42
+ const CONCURRENCY_LIMIT = 5;
43
+
44
+ for (const pageConfig of pages) {
45
+ const task = async () => {
46
+ const url = `${baseUrl}${pageConfig.url}`;
47
+ console.log(`Screenshotting: ${pageConfig.name} → ${url}`);
48
+
49
+ const page = await browser.newPage();
50
+ await page.setViewport({ width: 1440, height: 1200 });
51
+
52
+ try {
53
+ await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });
54
+ const outputPath = path.join(outputDir, `${pageConfig.id}.png`);
55
+ await page.screenshot({ path: outputPath, fullPage: true });
56
+ console.log(` ✓ ${path.basename(outputPath)}`);
57
+ } catch (err) {
58
+ console.error(` ✗ ${pageConfig.name}: ${err.message}`);
59
+ failures += 1;
60
+ } finally {
61
+ await page.close();
62
+ }
63
+ };
64
+
65
+ const promise = task();
66
+ executing.add(promise);
67
+ promise.finally(() => executing.delete(promise));
68
+
69
+ if (executing.size >= CONCURRENCY_LIMIT) {
70
+ await Promise.race(executing);
71
+ }
72
+ }
73
+
74
+ await Promise.all(executing);
75
+ } finally {
76
+ await browser.close();
77
+ }
78
+
79
+ if (failures > 0) {
80
+ console.error(`\n${failures} screenshot(s) failed.`);
81
+ process.exit(1);
82
+ }
83
+ }
84
+
85
+ main().catch((err) => {
86
+ console.error(err);
87
+ process.exit(1);
88
+ });