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,135 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Home band art (docs/features/home-band-art.md).
5
+ #
6
+ # AC-BAND-01 the export writes both plates under data/
7
+ # AC-BAND-03 a second export with no content change reports them unchanged
8
+ # AC-BAND-07 the layout carries no generated SVG markup
9
+ # AC-BAND-08 the network plate prints the counts computed from the same corpus
10
+ # AC-BAND-09 an entry joined to nothing leaves the plate's bar short of full width
11
+
12
+ require "fileutils"
13
+ require "json"
14
+ require "open3"
15
+ require "pathname"
16
+ require "tmpdir"
17
+
18
+ require_relative "../content/band_art"
19
+
20
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
21
+ EXPORT = ROOT.join("scripts/content/export-content-json.rb")
22
+ LAYOUT = ROOT.join("_layouts/home.html")
23
+ INDEX_BAND = ROOT.join("_includes/home/band-index.html")
24
+ EXAMPLE_CONTENT = ROOT.join("examples/full/content")
25
+
26
+ def check!(condition, message = "condition was false")
27
+ raise "Assertion failed: #{message}" unless condition
28
+ end
29
+
30
+ puts "Testing home band art..."
31
+
32
+ # ── The drawing, against a corpus small enough to count by hand ────────────
33
+
34
+ index = [
35
+ { "key" => "a", "url" => "/articles/a/", "collection" => "articles", "date" => "2026-01-01",
36
+ "tags" => %w[Engineering Writing], "xcites" => [] },
37
+ { "key" => "b", "url" => "/articles/b/", "collection" => "articles", "date" => "2026-02-01",
38
+ "tags" => ["Engineering"], "xcites" => ["a"] },
39
+ { "key" => "c", "url" => "/posts/c/", "collection" => "posts", "date" => "2026-03-01",
40
+ "tags" => ["Writing"], "xcites" => %w[a a b] },
41
+ # Joined to nothing, so it is counted in the corpus and drawn nowhere.
42
+ { "key" => "d", "url" => "/posts/d/", "collection" => "posts", "date" => "2026-04-01",
43
+ "tags" => ["Solitude"], "xcites" => [] },
44
+ # Not writing: the plate's legend never names projects.
45
+ { "key" => "e", "url" => "/projects/e/", "collection" => "projects", "date" => "2026-05-01",
46
+ "tags" => ["Engineering"], "xcites" => ["a"] }
47
+ ]
48
+
49
+ graph = BandArt.idea_graph(index)
50
+ check! graph[:nodes].map { |node| node["key"] } == %w[a b c],
51
+ "unjoined and non-writing entries must stay out of the graph, got #{graph[:nodes].map { |n| n['key'] }}"
52
+ check! graph[:corpus] == 4, "corpus counts every keyed article and post, got #{graph[:corpus]}"
53
+ check! graph[:edges].length == 3, "c cites a twice and it counts once, got #{graph[:edges].length}"
54
+ check! graph[:themes].map { |theme| theme[:name] } .sort == %w[Engineering Writing],
55
+ "a tag used once is not a theme, got #{graph[:themes].map { |t| t[:name] }}"
56
+
57
+ resources = [
58
+ { "slug" => "one", "category" => "Academic", "type" => "youtube" },
59
+ { "slug" => "two", "category" => "Academic", "type" => "podcast" },
60
+ { "slug" => "three", "category" => "Music", "type" => "website" }
61
+ ]
62
+
63
+ plates = BandArt.build(writing_index: index, resources: resources)
64
+ check! plates.keys.sort == %w[network sources], "build draws both plates, got #{plates.keys}"
65
+
66
+ # AC-BAND-08: the numbers are counted, not typed. The three numerals are the
67
+ # drawing, so they are read out of the markup rather than out of a caption.
68
+ network = plates["network"]
69
+ numerals = network.scan(%r{class="home-index-metric"[^>]*>(\d+)<}).flatten
70
+ check! numerals == %w[3 3 2], "the metric card must print the computed graph, got #{numerals.inspect}"
71
+ check! network.include?(%(data-band-tally="3 of 4 pieces joined")),
72
+ "the caption must say how much of the corpus is joined, got #{network[%r{data-band-tally="[^"]*"}]}"
73
+ check! network.include?(%(home-index-spine--articles" x="10" y="96" width="150")) &&
74
+ network.include?(%(home-index-spine--posts" x="160" y="96" width="75")),
75
+ "the spine scales to the corpus and stops short of the margin when a piece is unjoined, " "got #{network.scan(%r{home-index-spine--\w+" x="[^"]*" y="[^"]*" width="[^"]*"})}"
76
+ check! plates["sources"].include?('data-band-tally="3 sources · 2 collections"'),
77
+ "sources tally must restate the shelf, got #{plates['sources'][/data-band-tally="[^"]*"/]}"
78
+ check! plates["sources"].include?(">Acad.<") && plates["sources"].include?(">Music<"),
79
+ "shelf labels must use the short forms, got #{plates['sources'].scan(%r{home-index-collection[^>]*>([^<]+)})}"
80
+
81
+ # Nothing may be drawn in a colour the palette cannot repaint.
82
+ plates.each do |name, svg|
83
+ check! !svg.match?(/#[0-9a-fA-F]{3,8}\b/), "#{name} plate carries a literal colour"
84
+ check! svg.start_with?("<svg "), "#{name} plate must be a standalone SVG document"
85
+ check! svg.include?('xmlns="http://www.w3.org/2000/svg"'), "#{name} plate needs the SVG namespace"
86
+ end
87
+
88
+ # AC-BAND-07: the layout may not hold generated markup any more.
89
+ layout = LAYOUT.read
90
+ check! !layout.include?("<svg"), "_layouts/home.html must not carry generated SVG"
91
+ index_band = INDEX_BAND.read
92
+ check! index_band.include?('data-content-band-art="sources"') ||
93
+ index_band.include?('data-content-band-art="{{ _art_key }}"'),
94
+ "the sources panel needs its mount point"
95
+ check! index_band.include?("data-content-band-art"),
96
+ "the network panel needs its mount point"
97
+
98
+ # ── The export, end to end ────────────────────────────────────────────────
99
+
100
+ Dir.mktmpdir("band-art") do |dir|
101
+ out = Pathname.new(dir).join("export")
102
+ content_root = EXAMPLE_CONTENT.directory? ? EXAMPLE_CONTENT : ROOT.join("content")
103
+ env = {
104
+ "CONTENT_API_BASE_URL" => "",
105
+ "CONTENT_EXPORT_SKIP_VALIDATE" => "1",
106
+ "PANDORGA_SITE_ROOT" => (EXAMPLE_CONTENT.directory? ? ROOT.join("examples/full") : ROOT).to_s,
107
+ "RUBYOPT" => "-I#{ROOT.join('lib')}"
108
+ }
109
+ command = ["ruby", EXPORT.to_s, content_root.to_s, out.to_s]
110
+
111
+ first, status = Open3.capture2e(env, *command, chdir: env["PANDORGA_SITE_ROOT"])
112
+ check! status.success?, "first export failed:\n#{first}"
113
+
114
+ # AC-BAND-01
115
+ %w[band-art-sources.svg band-art-network.svg].each do |name|
116
+ path = out.join("data", name)
117
+ check! path.file?, "the export must write data/#{name}"
118
+ check! path.read.start_with?("<svg "), "data/#{name} must be an SVG document"
119
+ end
120
+
121
+ manifest = JSON.parse(out.join("manifest.json").read)
122
+ check! manifest["files"]["band_art"].sort == ["data/band-art-network.svg", "data/band-art-sources.svg"],
123
+ "the manifest must list both plates, got #{manifest['files']['band_art'].inspect}"
124
+
125
+ before = out.join("data", "band-art-sources.svg").read
126
+
127
+ # AC-BAND-03: a re-export must reuse the bytes, or every run churns R2.
128
+ second, status = Open3.capture2e(env, *command, chdir: env["PANDORGA_SITE_ROOT"])
129
+ check! status.success?, "second export failed:\n#{second}"
130
+ check! out.join("data", "band-art-sources.svg").read == before,
131
+ "a re-export with no content change rewrote the sources plate"
132
+ check! second.include?("band_art: 2"), "the export must report both plates:\n#{second}"
133
+ end
134
+
135
+ puts "Home band art OK."
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Refs: PLT-0.2
4
+ require "pathname"
5
+ require "open3"
6
+ require "fileutils"
7
+
8
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
9
+ EXE = ROOT.join("exe/pandorga")
10
+ DEST = ROOT.join("tmp/install-functions-test")
11
+
12
+ FileUtils.rm_rf(DEST)
13
+ DEST.mkpath
14
+ env = { "RUBYOPT" => "-I#{ROOT.join('lib')}" }
15
+ stdout, stderr, status = Open3.capture3(env, "ruby", EXE.to_s, "install-functions", DEST.to_s)
16
+ unless status.success?
17
+ warn "FAIL test-install-functions: #{stdout}\n#{stderr}"
18
+ exit 1
19
+ end
20
+
21
+ api = DEST.join("functions/api/studio/[[path]].js")
22
+ unless api.file?
23
+ warn "FAIL test-install-functions: functions not copied"
24
+ exit 1
25
+ end
26
+ stamp = DEST.join("functions/.pandorga-version")
27
+ unless stamp.file? && stamp.read.match?(/\d+\.\d+\.\d+/)
28
+ warn "FAIL test-install-functions: version stamp missing"
29
+ exit 1
30
+ end
31
+ # Must not embed a default owner repo in the stub.
32
+ body = api.read
33
+ owner_slug = %w[alexandre lheinen].join
34
+ if body.include?(owner_slug)
35
+ warn "FAIL test-install-functions: personal data in functions stub"
36
+ exit 1
37
+ end
38
+
39
+ puts "PASS test-install-functions"
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The [EN]/[PT] pills read `language` off writing_index entries. The field has
5
+ # always been in front matter, but was never exported, so listings had no way
6
+ # to reach it. Guard both halves: the key is always present, and any value it
7
+ # carries is one the UI knows how to render.
8
+
9
+ require "json"
10
+ require "pathname"
11
+ require "yaml"
12
+ require "date"
13
+
14
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
15
+ EXPORT_ROOT = REPO_ROOT.join("_content_json")
16
+ CONTENT_ROOT = REPO_ROOT.join("content/collections")
17
+
18
+ # Empty is allowed: an unset language renders no pill at all, which is the
19
+ # correct behaviour for an item whose language was never declared.
20
+ ALLOWED = ["", "enus", "ptbr"].freeze
21
+ TAGGED_COLLECTIONS = %w[articles posts].freeze
22
+
23
+ def fail!(message)
24
+ warn message
25
+ exit 1
26
+ end
27
+
28
+ def check!(condition, message)
29
+ fail!(message) unless condition
30
+ end
31
+
32
+ puts "Testing language export..."
33
+
34
+ index_path = EXPORT_ROOT.join("data/writing_index.json")
35
+ unless index_path.file?
36
+ puts " skipped: no export at #{index_path.relative_path_from(REPO_ROOT)}"
37
+ exit 0
38
+ end
39
+
40
+ entries = JSON.parse(index_path.read)
41
+ check!(entries.any?, "writing_index.json is empty")
42
+
43
+ missing = entries.reject { |e| e.key?("language") }
44
+ check!(missing.empty?,
45
+ "writing_index entries missing the language key: " \
46
+ "#{missing.take(3).map { |e| e['slug'] }.join(', ')}")
47
+
48
+ bad = entries.reject { |e| ALLOWED.include?(e["language"].to_s) }
49
+ check!(bad.empty?,
50
+ "unknown language values: " \
51
+ "#{bad.take(5).map { |e| "#{e['slug']}=#{e['language'].inspect}" }.join(', ')}")
52
+
53
+ # Front matter is the source of truth; catch a value that exists on disk but
54
+ # is dropped somewhere between parsing and the index.
55
+ TAGGED_COLLECTIONS.each do |collection|
56
+ Dir.glob(CONTENT_ROOT.join(collection, "*.md")).sort.each do |path|
57
+ source = File.read(path)
58
+ next unless source.start_with?("---")
59
+
60
+ front_matter = YAML.safe_load(source.split(/^---\s*$/, 3)[1],
61
+ permitted_classes: [Date, Time]) || {}
62
+ next if front_matter["published"] == false
63
+
64
+ declared = front_matter["language"].to_s.strip
65
+ next if declared.empty?
66
+
67
+ slug = File.basename(path, ".md")
68
+ entry = entries.find { |e| e["slug"] == slug && e["collection"] == collection }
69
+ next if entry.nil?
70
+
71
+ check!(entry["language"].to_s == declared,
72
+ "#{collection}/#{slug}: front matter language #{declared.inspect} " \
73
+ "but index has #{entry['language'].inspect}")
74
+ end
75
+ end
76
+
77
+ declared_count = entries.count { |e| !e["language"].to_s.empty? }
78
+ puts " #{entries.size} entries, #{declared_count} with a declared language."
79
+ puts "All language export tests passed."
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Gate: every filter facet a page offers must be a facet the filter reads.
5
+ #
6
+ # ListingFilter keeps one state object. A chip group writes `state[group.key]`
7
+ # on click; `filterItems` reads a fixed set of names. Nothing connects the
8
+ # two, so renaming a facet in a layout — `category` to `tag`, say — leaves the
9
+ # chips rendering, highlighting and pushing a query string while the list
10
+ # beNewsreader them never changes. That shipped on the Sources page: every click set
11
+ # a key nothing read.
12
+ #
13
+ # There is no build error and no console error to notice, so the contract is
14
+ # checked here: chip-group keys against the state ListingFilter initialises
15
+ # and the names filterItems actually consumes, plus the URL parameters a
16
+ # layout can set against the ones syncUrl clears.
17
+
18
+ require "pathname"
19
+
20
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
21
+ FILTER = ROOT.join("_includes/content-runtime/42-listing-filter.html")
22
+ LAYOUT_DIR = ROOT.join("_layouts")
23
+
24
+ # A chip group is the only place a layout writes `key: '<literal>'` followed by
25
+ # a label. Matching the pair keeps unrelated object literals out.
26
+ CHIP_GROUP_KEY = /key:\s*(['"])(\w+)\1\s*,\s*(?:\/\*.*?\*\/\s*|\/\/[^\n]*\n\s*)*label:/m
27
+ PARAM_OPTION = /(?:tagParam|categoryParam):\s*(['"])([\w-]+)\1/
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
+ puts "Testing the listing filter facet contract..."
39
+
40
+ source = FILTER.read
41
+
42
+ state_block = source[/var state = \{(.*?)\n \};/m, 1]
43
+ check!(!state_block.nil?, "could not find the ListingFilter state object in #{FILTER}")
44
+ state_keys = state_block.scan(/^\s*(\w+):/).flatten.uniq
45
+ check!(state_keys.length >= 3, "only found state keys #{state_keys.inspect}; the regex has drifted")
46
+
47
+ filter_items = source[/filterItems: function \(items, state, options\) \{(.*?)\n \}\n \};/m, 1]
48
+ check!(!filter_items.nil?, "could not find ListingFilter.filterItems in #{FILTER}")
49
+ read_keys = filter_items.scan(/\bstate\.(\w+)/).flatten.uniq
50
+ check!(read_keys.length >= 3, "only found read keys #{read_keys.inspect}; the regex has drifted")
51
+
52
+ sync_url = source[/function syncUrl\(\) \{(.*?)\n \}/m, 1]
53
+ check!(!sync_url.nil?, "could not find syncUrl in #{FILTER}")
54
+ cleared_params = sync_url[/\[(.*?)\]\.forEach/m, 1].to_s.scan(/['"]([\w-]+)['"]/).flatten
55
+ check!(cleared_params.any?, "syncUrl clears no query parameters; the regex has drifted")
56
+
57
+ # Dead state is a facet waiting to happen: something can set it and nothing
58
+ # will ever act on it.
59
+ dead = state_keys - read_keys
60
+ check!(
61
+ dead.empty?,
62
+ "ListingFilter initialises #{dead.inspect} but filterItems never reads " \
63
+ "#{dead.length == 1 ? 'it' : 'them'}. A chip group keyed on a dead name " \
64
+ "filters nothing."
65
+ )
66
+
67
+ groups = 0
68
+ layouts_with_groups = []
69
+
70
+ Dir.glob(LAYOUT_DIR.join("*.html")).sort.each do |path|
71
+ layout = Pathname.new(path)
72
+ body = layout.read
73
+ keys = body.scan(CHIP_GROUP_KEY).map { |_, key| key }
74
+ next if keys.empty?
75
+
76
+ layouts_with_groups << layout.basename.to_s
77
+ groups += keys.length
78
+
79
+ keys.uniq.each do |key|
80
+ check!(
81
+ state_keys.include?(key),
82
+ "#{layout.basename}: chip group keyed on '#{key}', which ListingFilter " \
83
+ "never initialises (it knows #{state_keys.sort.inspect}). Clicking a " \
84
+ "chip would set a key nothing reads."
85
+ )
86
+ check!(
87
+ read_keys.include?(key),
88
+ "#{layout.basename}: chip group keyed on '#{key}', which filterItems " \
89
+ "never reads (it consumes #{read_keys.sort.inspect}). The chips would " \
90
+ "highlight and the list would not move."
91
+ )
92
+ end
93
+
94
+ check!(
95
+ body.include?("ListingFilter.filterItems"),
96
+ "#{layout.basename} builds chip groups but never calls " \
97
+ "ListingFilter.filterItems, so no click can reach the list"
98
+ )
99
+
100
+ body.scan(PARAM_OPTION).each do |_, param|
101
+ check!(
102
+ cleared_params.include?(param),
103
+ "#{layout.basename} writes ?#{param}= but syncUrl never clears it " \
104
+ "(clears #{cleared_params.inspect}), so switching facets leaves a stale filter in the URL"
105
+ )
106
+ end
107
+ end
108
+
109
+ check!(
110
+ layouts_with_groups.length >= 3,
111
+ "only #{layouts_with_groups.inspect} declare chip groups; the redesign put a " \
112
+ "filter deck on every listing page, so the scan has probably stopped matching"
113
+ )
114
+ check!(groups >= 5, "only found #{groups} chip groups; the scan has probably stopped matching")
115
+
116
+ puts " state: #{state_keys.sort.join(', ')}"
117
+ puts " read by filterItems: #{read_keys.sort.join(', ')}"
118
+ puts " #{groups} chip group(s) across #{layouts_with_groups.join(', ')}"
119
+ puts "All listing filter facet checks passed."
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Unit checks for display-math row-break normalization.
4
+ * See scripts/lib/normalize-display-math.mjs and docs/content/elements.md.
5
+ */
6
+ import { normalizeDisplayMathTex, normalizeMathRegion } from '../lib/normalize-display-math.mjs';
7
+
8
+ function assertEqual(actual, expected, label) {
9
+ if (actual !== expected) {
10
+ console.error(`FAIL: ${label}`);
11
+ console.error(' expected:', JSON.stringify(expected));
12
+ console.error(' actual: ', JSON.stringify(actual));
13
+ process.exit(1);
14
+ }
15
+ console.log(` ok: ${label}`);
16
+ }
17
+
18
+ console.log('Testing display-math row-break normalization...');
19
+
20
+ const alignBroken = [
21
+ '\\begin{align}',
22
+ 'p(x) &= \\int p(x,y)\\mathrm{d}y \\',
23
+ 'p(y|x) &= \\frac{p(x,y)}{p(x)} \\',
24
+ 'p(x|y) &= \\frac{p(y|x)p(x)}{p(y)}',
25
+ '\\end{align}',
26
+ ].join('\n');
27
+
28
+ const alignFixed = [
29
+ '\\begin{align}',
30
+ 'p(x) &= \\int p(x,y)\\mathrm{d}y \\\\',
31
+ 'p(y|x) &= \\frac{p(x,y)}{p(x)} \\\\',
32
+ 'p(x|y) &= \\frac{p(y|x)p(x)}{p(y)}',
33
+ '\\end{align}',
34
+ ].join('\n');
35
+
36
+ assertEqual(
37
+ normalizeDisplayMathTex(alignBroken),
38
+ alignFixed,
39
+ 'align trailing single backslash → \\\\'
40
+ );
41
+
42
+ assertEqual(
43
+ normalizeDisplayMathTex('\\begin{bmatrix}x-x_r\\ y-y_r\\end{bmatrix}'),
44
+ '\\begin{bmatrix}x-x_r\\\\ y-y_r\\end{bmatrix}',
45
+ 'bmatrix mid-line \\ + space → \\\\'
46
+ );
47
+
48
+ const alignedOk = [
49
+ '\\begin{aligned}',
50
+ 'a &= 1 \\\\',
51
+ 'b &= 2',
52
+ '\\end{aligned}',
53
+ ].join('\n');
54
+
55
+ assertEqual(
56
+ normalizeDisplayMathTex(alignedOk),
57
+ alignedOk,
58
+ 'already-correct \\\\ left unchanged'
59
+ );
60
+
61
+ assertEqual(
62
+ normalizeDisplayMathTex('a = b\\,c\\;d'),
63
+ 'a = b\\,c\\;d',
64
+ 'spacing commands \\, and \\; untouched outside row envs'
65
+ );
66
+
67
+ assertEqual(
68
+ normalizeMathRegion('$$\\begin{align}a &= 1 \\ b &= 2\\end{align}$$'),
69
+ '$$\\begin{align}a &= 1 \\\\ b &= 2\\end{align}$$',
70
+ '$$ region wrapper preserved'
71
+ );
72
+
73
+ assertEqual(
74
+ normalizeMathRegion('$a\\\\b$'),
75
+ '$a\\\\b$',
76
+ 'inline math not normalized'
77
+ );
78
+
79
+ console.log('All display-math row-break tests passed.');
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "open3"
5
+ require "pathname"
6
+
7
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
8
+ SCRIPT = Pathname.new(__dir__).join("test-math-row-breaks.mjs")
9
+
10
+ puts "Testing math row-break normalization..."
11
+ stdout, stderr, status = Open3.capture3("node", SCRIPT.to_s, chdir: ROOT.join("scripts").to_s)
12
+ unless status.success?
13
+ warn stderr
14
+ warn stdout
15
+ raise "math row-break tests failed"
16
+ end
17
+ puts stdout
18
+ puts "All math row-break tests passed."
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Refs: PLT Phase 6 — pandorga new
4
+ require "pathname"
5
+ require "open3"
6
+ require "fileutils"
7
+ require "yaml"
8
+ require "date"
9
+
10
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
11
+ EXE = ROOT.join("exe/pandorga")
12
+ DEST = ROOT.join("tmp/new-site-test")
13
+
14
+ def run_new(*args)
15
+ env = { "RUBYOPT" => "-I#{ROOT.join('lib')}" }
16
+ Open3.capture3(env, "ruby", EXE.to_s, "new", *args)
17
+ end
18
+
19
+ def run_doctor(dir)
20
+ env = { "RUBYOPT" => "-I#{ROOT.join('lib')}" }
21
+ Open3.capture3(env, "ruby", EXE.to_s, "doctor", dir.to_s)
22
+ end
23
+
24
+ FileUtils.rm_rf(DEST)
25
+
26
+ stdout, stderr, status = run_new(DEST.to_s)
27
+ unless status.success?
28
+ warn "FAIL test-new: expected exit 0\n#{stdout}\n#{stderr}"
29
+ exit 1
30
+ end
31
+ unless stdout.include?("Created site at") && stdout.include?("bundle install")
32
+ warn "FAIL test-new: missing next-steps output\n#{stdout}"
33
+ exit 1
34
+ end
35
+
36
+ required = %w[
37
+ _config.yml
38
+ Gemfile
39
+ index.html
40
+ _redirects
41
+ content/pages/headers.yml
42
+ content/collections/articles/2026-01-01-hello.md
43
+ content/collections/articles/2026-01-02-second.md
44
+ ]
45
+ required.each do |rel|
46
+ unless DEST.join(rel).file?
47
+ warn "FAIL test-new: missing #{rel}"
48
+ exit 1
49
+ end
50
+ end
51
+
52
+ %w[Gemfile.lock _site vendor .bundle _content_json .jekyll-cache].each do |name|
53
+ if DEST.join(name).exist?
54
+ warn "FAIL test-new: should exclude #{name}"
55
+ exit 1
56
+ end
57
+ end
58
+
59
+ gemfile = DEST.join("Gemfile").read
60
+ unless gemfile.include?('gem "pandorga", "~> 1.2"')
61
+ warn "FAIL test-new: Gemfile must pin pandorga ~> 1.2\n#{gemfile}"
62
+ exit 1
63
+ end
64
+ unless gemfile.include?("path:") && gemfile.include?("github:")
65
+ warn "FAIL test-new: Gemfile should comment path/github fallbacks"
66
+ exit 1
67
+ end
68
+
69
+ config = YAML.safe_load(DEST.join("_config.yml").read, permitted_classes: [Date]) || {}
70
+ pandorga = config["pandorga"]
71
+ unless pandorga.is_a?(Hash)
72
+ warn "FAIL test-new: missing pandorga: in _config.yml"
73
+ exit 1
74
+ end
75
+ identity = pandorga["identity"]
76
+ unless identity.is_a?(Hash) && identity["name"].to_s == "Ada Example"
77
+ warn "FAIL test-new: expected Ada Example identity"
78
+ exit 1
79
+ end
80
+ backend = pandorga.dig("content", "backend")
81
+ unless backend == "static"
82
+ warn "FAIL test-new: expected content.backend: static, got #{backend.inspect}"
83
+ exit 1
84
+ end
85
+ pages = pandorga["pages"]
86
+ unless pages.is_a?(Array) && pages.any?
87
+ warn "FAIL test-new: expected pandorga.pages entries"
88
+ exit 1
89
+ end
90
+
91
+ dout, derr, dstatus = run_doctor(DEST)
92
+ unless dstatus.success? && dout.include?("pandorga doctor: ok")
93
+ warn "FAIL test-new: doctor failed on scaffold\n#{dout}\n#{derr}"
94
+ exit 1
95
+ end
96
+
97
+ _out, err, st = run_new(DEST.to_s)
98
+ if st.success?
99
+ warn "FAIL test-new: expected failure without --force on nonempty dir"
100
+ exit 1
101
+ end
102
+ unless err.include?("--force")
103
+ warn "FAIL test-new: expected --force hint\n#{err}"
104
+ exit 1
105
+ end
106
+
107
+ stdout2, stderr2, status2 = run_new(DEST.to_s, "--force")
108
+ unless status2.success?
109
+ warn "FAIL test-new: --force should succeed\n#{stdout2}\n#{stderr2}"
110
+ exit 1
111
+ end
112
+
113
+ puts "PASS test-new"
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Refs: PLT-AC-1
4
+ require "pathname"
5
+ require "find"
6
+
7
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
8
+
9
+ # Public repo home (ADR-001) may appear as a URL or Bundler github: source.
10
+ REPO_URL_OK = %r{(?:github\.com/)?alexandrelheinen/pandorga}
11
+
12
+ PATTERNS = [
13
+ [/pub-[0-9a-f]{32}\.r2\.dev/i, "r2 bucket URL"],
14
+ [/pk_live_[A-Za-z0-9_-]{16,}/, "live publishable key"],
15
+ [/sk_live_[A-Za-z0-9_-]{16,}/, "live secret key"],
16
+ [/github_pat_[A-Za-z0-9_]+/, "github pat"],
17
+ [/Alexandre L\. Heinen/, "owner short name"],
18
+ [/Alexandre Loeblein Heinen/, "owner full name"],
19
+ [/alexandrelheinen\.pages\.dev/i, "pages domain"],
20
+ [/dev\.alexandrelheinen\.studio/i, "android applicationId"]
21
+ ].freeze
22
+
23
+ SKIP_DIRS = %w[
24
+ .git
25
+ tmp
26
+ _site
27
+ _content_json
28
+ vendor
29
+ .bundle
30
+ node_modules
31
+ .guidelines
32
+ .agents
33
+ ].freeze
34
+
35
+ # Migration spec documents the extraction source; .gitmodules points at the
36
+ # owner's private guidelines repo (not shipped as platform code).
37
+ ALLOW_REL = %w[
38
+ docs/spec.md
39
+ docs/decisions.md
40
+ .gitmodules
41
+ ].freeze
42
+
43
+ failures = []
44
+ Find.find(ROOT) do |path|
45
+ path = Pathname.new(path)
46
+ if path.directory?
47
+ Find.prune if SKIP_DIRS.include?(path.basename.to_s)
48
+ next
49
+ end
50
+ next unless path.file?
51
+ next if path.basename.to_s == "test-no-personal-data.rb"
52
+ next if path.extname.match?(/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|eot|pdf|zip|gz|wasm)$/i)
53
+
54
+ rel = path.relative_path_from(ROOT).to_s
55
+ next if ALLOW_REL.include?(rel)
56
+
57
+ raw = begin
58
+ path.read(mode: "rb")
59
+ rescue StandardError
60
+ next
61
+ end
62
+ next if raw.include?("\x00")
63
+
64
+ text = raw.force_encoding("UTF-8")
65
+ next unless text.valid_encoding?
66
+
67
+ # Strip allowed repo URLs before scanning for the owner slug.
68
+ scrubbed = text.encode("UTF-8", invalid: :replace, undef: :replace).gsub(REPO_URL_OK, "")
69
+ if scrubbed.match?(/alexandrelheinen/i)
70
+ failures << "#{rel}: owner slug outside allowed repo URL"
71
+ end
72
+
73
+ PATTERNS.each do |re, label|
74
+ next unless text.match?(re)
75
+
76
+ failures << "#{rel}: #{label}"
77
+ end
78
+ end
79
+
80
+ if failures.any?
81
+ warn "FAIL test-no-personal-data:\n#{failures.uniq.join("\n")}"
82
+ exit 1
83
+ end
84
+
85
+ puts "PASS test-no-personal-data"