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,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Refs: PLT-AC-6 — examples build with the static backend and fictional persona.
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
+
11
+ def run!(*cmd, chdir:)
12
+ env = {
13
+ "RUBYOPT" => "-I#{ROOT.join('lib')}",
14
+ "PANDORGA_SITE_ROOT" => chdir.to_s,
15
+ "CONTENT_API_BASE_URL" => "",
16
+ "CONTENT_EXPORT_SKIP_VALIDATE" => "1",
17
+ "JEKYLL_ENV" => "production"
18
+ }
19
+ stdout, stderr, status = Open3.capture3(env, *cmd, chdir: chdir.to_s)
20
+ return [stdout, stderr] if status.success?
21
+
22
+ warn "FAIL test-examples-build: #{cmd.join(' ')} (cwd=#{chdir})\n#{stdout}\n#{stderr}"
23
+ exit 1
24
+ end
25
+
26
+ %w[minimal full].each do |name|
27
+ example = ROOT.join("examples", name)
28
+ unless example.join("_config.yml").file?
29
+ warn "FAIL test-examples-build: missing #{example}"
30
+ exit 1
31
+ end
32
+
33
+ FileUtils.rm_rf(example.join("_site"))
34
+ FileUtils.rm_rf(example.join("_content_json"))
35
+
36
+ run!("bundle", "install", "--quiet", chdir: example) if example.join("Gemfile").file?
37
+ run!("ruby", EXE.to_s, "export", "content", "_content_json", chdir: example)
38
+ run!("bundle", "exec", "jekyll", "build", "--quiet", chdir: example)
39
+
40
+ index = example.join("_site/index.html")
41
+ unless index.file?
42
+ warn "FAIL test-examples-build: #{name} missing _site/index.html"
43
+ exit 1
44
+ end
45
+ html = index.read
46
+ if html.match?(/Alexandre L\. Heinen|Loeblein Heinen/i)
47
+ warn "FAIL test-examples-build: #{name} home HTML leaked owner identity"
48
+ exit 1
49
+ end
50
+ if html.include?("Ada Example") == false && name == "full"
51
+ warn "FAIL test-examples-build: full example missing fictional persona"
52
+ exit 1
53
+ end
54
+ unless html.include?("home-hero") || html.include?("home-ledger")
55
+ warn "FAIL test-examples-build: #{name} home layout missing"
56
+ exit 1
57
+ end
58
+ end
59
+
60
+ puts "PASS test-examples-build"
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Ensures export reuses identical JSON bytes instead of wiping OUTPUT_ROOT.
5
+
6
+ $stdout.sync = true
7
+ $stderr.sync = true
8
+
9
+ require "fileutils"
10
+ require "open3"
11
+ require "pathname"
12
+ require "tmpdir"
13
+
14
+ require_relative "../lib/writing_index_contract"
15
+
16
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
17
+ EXPORT_SCRIPT = REPO_ROOT.join("scripts/content/export-content-json.rb")
18
+
19
+ def fail!(message)
20
+ warn message
21
+ exit 1
22
+ end
23
+
24
+ def run_export!(out_dir)
25
+ stdout, stderr, status = Open3.capture3(
26
+ { "CONTENT_API_BASE_URL" => "" },
27
+ "ruby", EXPORT_SCRIPT.to_s, "content", out_dir.to_s,
28
+ chdir: REPO_ROOT.to_s
29
+ )
30
+ fail!("export failed:\n#{stderr}\n#{stdout}") unless status.success?
31
+
32
+ stdout
33
+ end
34
+
35
+ Dir.mktmpdir("content-json-incremental-") do |dir|
36
+ out = Pathname.new(dir).join("export")
37
+
38
+ first = run_export!(out)
39
+ fail!("first export missing write summary") unless first.include?("changed")
40
+
41
+ sample = out.join("manifest.json")
42
+ fail!("missing manifest") unless sample.file?
43
+
44
+ mtime_before = sample.mtime
45
+ content_before = sample.read
46
+ sleep 1
47
+
48
+ second = run_export!(out)
49
+ fail!("second export should report unchanged files") unless second.match?(/unchanged=[1-9]/)
50
+
51
+ # manifest embeds generated_at, so it should rewrite; pick a leaf that is stable.
52
+ leaf = out.glob("collections/articles/*.json").min_by(&:to_s)
53
+ fail!("no article JSON exported") unless leaf
54
+
55
+ leaf_mtime = leaf.mtime
56
+ leaf_body = leaf.read
57
+ sleep 1
58
+ run_export!(out)
59
+
60
+ fail!("stable article JSON was rewritten") unless leaf.mtime == leaf_mtime
61
+ fail!("stable article JSON bytes changed") unless leaf.read == leaf_body
62
+
63
+ # Touch a published post body via chronology-insensitive check: rewriting the
64
+ # same export dir must keep pruning functional (no crash) and keep manifest.
65
+ fail!("manifest disappeared") unless sample.file?
66
+ fail!("manifest became empty") if sample.read.strip.empty?
67
+ fail!("first manifest snapshot unexpectedly empty") if content_before.strip.empty?
68
+
69
+ puts "Export incremental write OK (sample #{leaf.relative_path_from(out)}, mtime preserved)"
70
+ puts "Note: manifest may still rewrite because generated_at changes (#{mtime_before} -> #{sample.mtime})."
71
+ end
72
+
73
+ # Path filter: rebuild only the changed article plus indexes that embed dates/excerpts.
74
+ Dir.mktmpdir("content-json-path-filter-") do |dir|
75
+ root = Pathname.new(dir)
76
+ content = root.join("content")
77
+ out = root.join("export")
78
+ FileUtils.mkdir_p(content.join("collections/articles"))
79
+
80
+ write_article = lambda do |stem, body|
81
+ content.join("collections/articles/#{stem}.md").write(<<~MARKDOWN)
82
+ ---
83
+ title: #{stem}
84
+ key: #{stem}
85
+ date: 2026-08-22
86
+ published: true
87
+ ---
88
+ #{body}
89
+ MARKDOWN
90
+ end
91
+
92
+ write_article.call("filter-a", "First A")
93
+ write_article.call("filter-b", "First B")
94
+
95
+ run = lambda do |changed|
96
+ env = {
97
+ "CONTENT_API_BASE_URL" => "",
98
+ "CONTENT_EXPORT_SKIP_VALIDATE" => "1"
99
+ }
100
+ env["CONTENT_EXPORT_CHANGED_PATHS"] = changed unless changed.to_s.empty?
101
+ stdout, stderr, status = Open3.capture3(
102
+ env,
103
+ "ruby", EXPORT_SCRIPT.to_s, content.to_s, out.to_s,
104
+ chdir: REPO_ROOT.to_s
105
+ )
106
+ fail!("path-filter export failed:\n#{stderr}\n#{stdout}") unless status.success?
107
+
108
+ stdout
109
+ end
110
+
111
+ run.call("")
112
+ article_a = out.join("collections/articles/2026-08-22-filter-a.json")
113
+ article_b = out.join("collections/articles/2026-08-22-filter-b.json")
114
+ fail!("missing filter-a") unless article_a.file?
115
+ fail!("missing filter-b") unless article_b.file?
116
+
117
+ b_mtime = article_b.mtime
118
+ b_body = article_b.read
119
+ index_before = out.join("data/writing_index.json").read
120
+ sleep 1
121
+
122
+ write_article.call("filter-a", "Second A — path filter should rebuild this one")
123
+ filtered = run.call("content/collections/articles/filter-a.md")
124
+ fail!("path-filter export should reuse unchanged files") unless filtered.match?(/unchanged=[1-9]/)
125
+
126
+ fail!("unchanged B was rewritten") unless article_b.mtime == b_mtime
127
+ fail!("unchanged B bytes changed") unless article_b.read == b_body
128
+ fail!("changed A was not rewritten") if article_a.read == "First A"
129
+ fail!("changed A body missing") unless article_a.read.include?("Second A")
130
+
131
+ index_after = out.join("data/writing_index.json").read
132
+ fail!("writing_index must rebuild when an article changes") if index_after == index_before
133
+ fail!("manifest missing after path-filter export") unless out.join("manifest.json").file?
134
+
135
+ WritingIndexContract.assert_slugs_match_export!(out)
136
+
137
+ puts "Export path-filter OK (B mtime preserved, A + writing_index rebuilt)"
138
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Refs: PLT-AC-6, PLT-AC-7, PLT-AC-12
4
+ require "pathname"
5
+ require "json"
6
+ require "open3"
7
+ require "fileutils"
8
+
9
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
10
+ EXAMPLE = ROOT.join("examples/minimal")
11
+ EXE = ROOT.join("exe/pandorga")
12
+ DEST = ROOT.join("tmp/export-test")
13
+
14
+ FileUtils.rm_rf(DEST)
15
+ env = {
16
+ "RUBYOPT" => "-I#{ROOT.join('lib')}",
17
+ "PANDORGA_SITE_ROOT" => EXAMPLE.to_s,
18
+ "CONTENT_API_BASE_URL" => "",
19
+ "CONTENT_EXPORT_SKIP_VALIDATE" => "1"
20
+ }
21
+ stdout, stderr, status = Open3.capture3(
22
+ env, "ruby", EXE.to_s, "export",
23
+ "content", DEST.to_s,
24
+ chdir: EXAMPLE.to_s
25
+ )
26
+ unless status.success?
27
+ warn "FAIL test-export: #{stdout}\n#{stderr}"
28
+ exit 1
29
+ end
30
+
31
+ manifest = JSON.parse(DEST.join("manifest.json").read)
32
+ unless manifest["schema_version"] == 1
33
+ warn "FAIL test-export: schema_version missing or wrong: #{manifest['schema_version'].inspect}"
34
+ exit 1
35
+ end
36
+
37
+ hello = DEST.join("collections/articles/2026-01-01-hello.json")
38
+ unless hello.file?
39
+ warn "FAIL test-export: missing #{hello}"
40
+ exit 1
41
+ end
42
+ payload = JSON.parse(hello.read)
43
+ title = payload["title"] || payload.dig("front_matter", "title")
44
+ unless title.to_s.include?("Hello")
45
+ warn "FAIL test-export: title not exported (#{title.inspect})"
46
+ exit 1
47
+ end
48
+ if payload.key?("body_extended") || payload.dig("front_matter", "body_extended")
49
+ warn "FAIL test-export: private field leaked"
50
+ exit 1
51
+ end
52
+
53
+ puts "PASS test-export"
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Gate: every field the exporter writes must survive the trip to the page.
5
+ #
6
+ # Between scripts/content/export-content-json.rb and a rendered card sit two
7
+ # copy steps that drop fields silently:
8
+ #
9
+ # writing_index.json --writingIndexAsListingItem--> { front_matter }
10
+ # collections/*.json --hydrateFrontMatter--------> { front_matter }
11
+ #
12
+ # The first is an explicit field whitelist, so an exported field stays
13
+ # invisible until someone remembers to add a line to it; that is how
14
+ # `language` was exported for weeks and never appeared on a card. The second
15
+ # used to return early before promoting the payload date, so no detail page
16
+ # showed a date at all. Neither failure changes the build's exit code.
17
+ #
18
+ # So: export for real, then run both copy steps through the real runtime and
19
+ # compare key by key. A new exporter field is covered the day it is added —
20
+ # either it reaches the view, or it goes on the documented exclusion list in
21
+ # scripts/test/render-parity/run-field-flow-checks.mjs.
22
+
23
+ require "open3"
24
+ require "pathname"
25
+ require "tmpdir"
26
+
27
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
28
+ EXPORT_SCRIPT = ROOT.join("scripts/content/export-content-json.rb")
29
+ CHECKS = Pathname.new(__dir__).join("render-parity", "run-field-flow-checks.mjs")
30
+
31
+ def fail!(message)
32
+ warn message
33
+ exit 1
34
+ end
35
+
36
+ puts "Testing that exported fields reach the view..."
37
+
38
+ fail!("missing #{CHECKS}") unless CHECKS.file?
39
+
40
+ Dir.mktmpdir("field-flow-") do |tmp|
41
+ out_dir = Pathname.new(tmp).join("_content_json")
42
+
43
+ stdout, stderr, status = Open3.capture3(
44
+ { "CONTENT_API_BASE_URL" => "" },
45
+ "ruby", EXPORT_SCRIPT.to_s, "content", out_dir.to_s,
46
+ chdir: ROOT.to_s
47
+ )
48
+ fail!("export-content-json.rb failed:\n#{stderr}\n#{stdout}") unless status.success?
49
+
50
+ stdout, stderr, status = Open3.capture3(
51
+ "node", CHECKS.to_s, out_dir.to_s, chdir: ROOT.join("scripts").to_s
52
+ )
53
+ print stdout
54
+ unless status.success?
55
+ warn stderr unless stderr.strip.empty?
56
+ fail!("An exported field never reaches the page.")
57
+ end
58
+ end
59
+
60
+ puts "All exported-field flow checks passed."
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Gate: text-entry controls must be painted in theme tokens, by a selector
5
+ # that outranks the Tailwind forms plugin.
6
+ #
7
+ # The forms plugin ships its own base layer for `[type='text']`,
8
+ # `[type='search']`, `textarea` and `select`, setting a white background and a
9
+ # near-black colour. That layer is injected after assets/css/base.css, and an
10
+ # attribute selector scores the same as a single class, so a one-class rule
11
+ # loses the tie on source order. In dark mode the keyword box then renders as
12
+ # a white hole — which is exactly what happened, on every listing page at
13
+ # once, while the build stayed green and no screenshot was taken in dark mode.
14
+ #
15
+ # Two defences, both checked here:
16
+ # 1. the rule that paints the control uses at least two classes (or an
17
+ # equivalent compound), so it wins the cascade outright;
18
+ # 2. it sets background and colour from custom properties, so both palettes
19
+ # follow. A literal colour is how one theme gets broken silently.
20
+ #
21
+ # Checkboxes and radios are out of scope: the plugin styles those through
22
+ # `accent-color`/`currentColor`, which already tracks the theme.
23
+
24
+ require "pathname"
25
+ require "set"
26
+
27
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
28
+ MARKUP_DIRS = [ROOT.join("_layouts"), ROOT.join("_includes")].freeze
29
+ CSS_DIR = ROOT.join("assets/css")
30
+
31
+ # Input types the forms plugin paints as a text field. `<input>` with no type
32
+ # defaults to text, so a missing type counts too.
33
+ TEXT_ENTRY_TYPES = %w[text search email url tel number password date datetime-local month week time].freeze
34
+
35
+ # The printed CV is hard-coded to the light palette by design, and carries no
36
+ # interactive controls; studio/ is the self-hosted SPA.
37
+ SKIP = /cv-print|print-/.freeze
38
+
39
+ def fail!(message)
40
+ warn message
41
+ exit 1
42
+ end
43
+
44
+ def check!(condition, message)
45
+ fail!(message) unless condition
46
+ end
47
+
48
+ # Specificity that matters against `[type='search']` (0,1,0): count classes,
49
+ # attributes and ids in one selector of the group.
50
+ def weight(selector)
51
+ selector.scan(/\.[\w-]+/).length +
52
+ selector.scan(/\[[^\]]+\]/).length +
53
+ selector.scan(/#[\w-]+/).length * 100
54
+ end
55
+
56
+ def css_rules
57
+ Dir.glob(CSS_DIR.join("**/*.css")).sort.flat_map do |path|
58
+ next [] if File.basename(path).match?(SKIP)
59
+
60
+ text = File.read(path).gsub(%r{/\*.*?\*/}m, "")
61
+ text.scan(/([^{}]+)\{([^{}]*)\}/m).map do |selector_group, body|
62
+ { file: Pathname.new(path).relative_path_from(ROOT).to_s,
63
+ selectors: selector_group.split(",").map(&:strip).reject(&:empty?),
64
+ body: body }
65
+ end
66
+ end
67
+ end
68
+
69
+ def text_entry_controls
70
+ found = []
71
+ MARKUP_DIRS.each do |dir|
72
+ Dir.glob(dir.join("**/*.{html,liquid}")).sort.each do |path|
73
+ next if File.basename(path).match?(SKIP)
74
+
75
+ File.read(path).scan(/<(input|textarea|select)\b([^>]*)>/m) do |tag, attrs|
76
+ type = attrs[/\btype\s*=\s*["']([\w-]+)["']/, 1]
77
+ next if tag == "input" && !type.nil? && !TEXT_ENTRY_TYPES.include?(type)
78
+
79
+ classes = attrs[/\bclass\s*=\s*["']([^"']*)["']/, 1].to_s.split(/\s+/)
80
+ found << {
81
+ file: Pathname.new(path).relative_path_from(ROOT).to_s,
82
+ tag: tag,
83
+ type: type || (tag == "input" ? "text" : tag),
84
+ classes: classes
85
+ }
86
+ end
87
+ end
88
+ end
89
+ found
90
+ end
91
+
92
+ puts "Testing form control theming..."
93
+
94
+ rules = css_rules
95
+ check!(rules.length > 100, "only parsed #{rules.length} CSS rules; the parser has drifted")
96
+
97
+ controls = text_entry_controls
98
+ check!(
99
+ controls.any?,
100
+ "found no text-entry controls in #{MARKUP_DIRS.map { |d| d.basename.to_s }.join(', ')}; " \
101
+ "the listing pages each carry a keyword box, so the scan has drifted"
102
+ )
103
+
104
+ controls.each do |control|
105
+ label = "#{control[:file]} <#{control[:tag]} type=#{control[:type]}>"
106
+ check!(
107
+ control[:classes].any?,
108
+ "#{label} carries no class, so nothing can outrank the forms plugin base layer"
109
+ )
110
+
111
+ painting = rules.select do |rule|
112
+ next false unless rule[:body].match?(/(?<![-\w])background(?:-color)?\s*:/)
113
+ next false unless rule[:body].match?(/(?<![-\w])color\s*:/)
114
+
115
+ rule[:selectors].any? do |selector|
116
+ control[:classes].any? { |cls| selector.include?(".#{cls}") } && weight(selector) >= 2
117
+ end
118
+ end
119
+
120
+ check!(
121
+ painting.any?,
122
+ "#{label} (class=#{control[:classes].join(' ')}) has no CSS rule that sets both " \
123
+ "background and color with at least two classes of specificity. The Tailwind " \
124
+ "forms plugin paints it white from a later stylesheet, so dark mode shows a white hole."
125
+ )
126
+
127
+ painting.each do |rule|
128
+ %w[background color].each do |property|
129
+ value = rule[:body][/(?<![-\w])#{property}(?:-color)?\s*:([^;]+);/, 1].to_s.strip
130
+ next if value.empty?
131
+
132
+ check!(
133
+ value.include?("var(--"),
134
+ "#{rule[:file]}: #{rule[:selectors].join(', ')} sets #{property}: #{value}. " \
135
+ "Use a theme custom property — a literal colour is correct in one palette and wrong in the other."
136
+ )
137
+ end
138
+ end
139
+
140
+ puts " #{label}: painted by #{painting.map { |r| r[:selectors].first }.uniq.join(' / ')}"
141
+ end
142
+
143
+ puts "All form control theming checks passed (#{controls.length} control(s))."