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,72 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # An unbalanced brace in CSS does not fail a build and does not error in a
5
+ # browser. The parser simply swallows everything after it into the open block,
6
+ # so a stylesheet keeps loading with a 200 while an arbitrary number of its
7
+ # rules silently stop applying. That is exactly what a bad merge resolution
8
+ # produced here: the page rendered, the console was clean, and a third of one
9
+ # sheet had quietly stopped existing.
10
+ #
11
+ # Also catches conflict markers, which are valid-enough text to survive a
12
+ # build and are far easier to spot here than on a rendered page.
13
+
14
+ require "pathname"
15
+
16
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
17
+ CSS_ROOT = REPO_ROOT.join("assets/css")
18
+
19
+ CONFLICT_MARKERS = /^(?:<{7}|={7}|>{7})(?:\s|$)/.freeze
20
+
21
+ def fail!(message)
22
+ warn message
23
+ exit 1
24
+ end
25
+
26
+ # Strip comments and strings so braces inside them are not counted.
27
+ def strip_noise(source)
28
+ out = source.gsub(%r{/\*.*?\*/}m, "")
29
+ out.gsub(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, '""')
30
+ end
31
+
32
+ puts "Testing CSS structure..."
33
+
34
+ sheets = Dir.glob(CSS_ROOT.join("**", "*.css")).sort
35
+ fail!("no stylesheets found under #{CSS_ROOT}") if sheets.empty?
36
+
37
+ errors = []
38
+
39
+ sheets.each do |path|
40
+ relative = Pathname.new(path).relative_path_from(REPO_ROOT)
41
+ source = File.read(path)
42
+
43
+ source.each_line.with_index(1) do |line, number|
44
+ next unless line.match?(CONFLICT_MARKERS)
45
+
46
+ errors << "#{relative}:#{number}: unresolved conflict marker"
47
+ end
48
+
49
+ cleaned = strip_noise(source)
50
+ depth = 0
51
+ negative_at = nil
52
+
53
+ cleaned.each_line.with_index(1) do |line, number|
54
+ depth += line.count("{") - line.count("}")
55
+ if depth.negative? && negative_at.nil?
56
+ negative_at = number
57
+ break
58
+ end
59
+ end
60
+
61
+ if negative_at
62
+ errors << "#{relative}:#{negative_at}: closing brace with no matching opener"
63
+ elsif depth.positive?
64
+ errors << "#{relative}: #{depth} unclosed brace(s) — every rule after the " \
65
+ "offending block is swallowed and silently stops applying"
66
+ end
67
+ end
68
+
69
+ fail!("CSS structure problems:\n - #{errors.join("\n - ")}") unless errors.empty?
70
+
71
+ puts " #{sheets.size} stylesheet(s) balanced, no conflict markers."
72
+ puts "All CSS structure tests passed."
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Refs: PLT-AC-7
5
+ # CV collection fields outside the public allowlist must not enter export JSON.
6
+ # Uses a temp Ada Example fixture — no personal site data.
7
+
8
+ $stdout.sync = true
9
+ $stderr.sync = true
10
+
11
+ require "json"
12
+ require "open3"
13
+ require "pathname"
14
+ require "tmpdir"
15
+ require "fileutils"
16
+
17
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
18
+ EXPORT_SCRIPT = ROOT.join("scripts/content/export-content-json.rb")
19
+
20
+ PRIVATE_SNIPPETS = [
21
+ "PRIVATE_EXTENDED_JOB_BODY_MARKER_ada_example",
22
+ "PRIVATE_SINGLE_PAGE_JOB_BODY_MARKER_ada_example",
23
+ "PRIVATE_CONTACT_SECRET_ada@secret.example",
24
+ "PRIVATE_EXTENDED_PRODUCT_BODY_MARKER_ada_example",
25
+ "PRIVATE_PRODUCT_NOTES_ada_example"
26
+ ].freeze
27
+
28
+ def fail!(message)
29
+ warn "FAIL test-cv-export-contract: #{message}"
30
+ exit 1
31
+ end
32
+
33
+ def check!(condition, message)
34
+ fail!(message) unless condition
35
+ end
36
+
37
+ def write_site!(site)
38
+ site.join("_config.yml").write(<<~YAML)
39
+ title: "CV Export Contract Fixture"
40
+ pandorga:
41
+ identity:
42
+ name: "Ada Example"
43
+ short_name: "A. Example"
44
+ url: "https://example.org"
45
+ locale: en
46
+ content:
47
+ backend: static
48
+ base_url: ""
49
+ pages:
50
+ - key: cv
51
+ template: cv
52
+ title: Curriculum Vitæ
53
+ path: /pages/cv/
54
+ collections: { jobs: jobs, products: products }
55
+ home: { band: hero }
56
+ YAML
57
+
58
+ jobs = site.join("content/collections/jobs")
59
+ products = site.join("content/collections/products")
60
+ data = site.join("content/collections/data")
61
+ FileUtils.mkdir_p([jobs, products, data])
62
+
63
+ jobs.join("research-fellow.md").write(<<~MD)
64
+ ---
65
+ title: Research Fellow
66
+ key: research-fellow
67
+ company: Example Labs
68
+ role: Research Fellow
69
+ start: 2024-01
70
+ end: 2026-06
71
+ location: Example City
72
+ summary: Fictional public CV entry for Ada Example.
73
+ body_public: Public paragraph that may appear in the export.
74
+ body_extended: |
75
+ PRIVATE_EXTENDED_JOB_BODY_MARKER_ada_example
76
+ Extended copy for private PDF builds only.
77
+ body_single_page: |
78
+ PRIVATE_SINGLE_PAGE_JOB_BODY_MARKER_ada_example
79
+ Single-page private PDF copy.
80
+ contact_email: PRIVATE_CONTACT_SECRET_ada@secret.example
81
+ published: true
82
+ ---
83
+
84
+ Markdown body for Ada Example research role.
85
+ MD
86
+
87
+ products.join("ledger-tool.md").write(<<~MD)
88
+ ---
89
+ title: Ledger Tool
90
+ key: ledger-tool
91
+ product: Ledger Tool
92
+ company: Example Labs
93
+ body_extended: PRIVATE_EXTENDED_PRODUCT_BODY_MARKER_ada_example
94
+ private_notes: PRIVATE_PRODUCT_NOTES_ada_example
95
+ published: true
96
+ ---
97
+
98
+ Public product summary for Ada Example.
99
+ MD
100
+
101
+ # Public contacts only. Secrets belong on collection fields outside the
102
+ # allowlist (contact_email above), not in committed public data files.
103
+ data.join("contacts.yml").write(<<~YAML)
104
+ - label: Example Org
105
+ href: https://example.org
106
+ YAML
107
+ end
108
+
109
+ def run_export!(site, out)
110
+ env = {
111
+ "PANDORGA_SITE_ROOT" => site.to_s,
112
+ "CONTENT_API_BASE_URL" => "",
113
+ "CONTENT_EXPORT_SKIP_VALIDATE" => "1",
114
+ "RUBYOPT" => "-I#{ROOT.join('lib')}"
115
+ }
116
+ stdout, stderr, status = Open3.capture3(
117
+ env,
118
+ "ruby", EXPORT_SCRIPT.to_s, "content", out.to_s,
119
+ chdir: site.to_s
120
+ )
121
+ fail!("export failed:\n#{stderr}\n#{stdout}") unless status.success?
122
+ end
123
+
124
+ puts "Testing CV export contract (PLT-AC-7)..."
125
+
126
+ Dir.mktmpdir("cv-export-contract-") do |dir|
127
+ site = Pathname.new(dir).join("site")
128
+ out = Pathname.new(dir).join("export")
129
+ site.mkpath
130
+ write_site!(site)
131
+ run_export!(site, out)
132
+
133
+ job_json = out.join("collections/jobs/research-fellow.json")
134
+ product_json = out.join("collections/products/ledger-tool.json")
135
+ check!(job_json.file?, "missing exported job JSON")
136
+ check!(product_json.file?, "missing exported product JSON")
137
+
138
+ job = JSON.parse(job_json.read)
139
+ product = JSON.parse(product_json.read)
140
+ job_fm = job["front_matter"] || {}
141
+ product_fm = product["front_matter"] || {}
142
+
143
+ %w[body_extended body_single_page contact_email].each do |field|
144
+ check!(!job_fm.key?(field), "job front_matter still has #{field}")
145
+ end
146
+ check!(!job_fm["body_public"].to_s.strip.empty?, "job dropped body_public")
147
+
148
+ %w[body_extended private_notes].each do |field|
149
+ check!(!product_fm.key?(field), "product front_matter still has #{field}")
150
+ end
151
+
152
+ blob = out.glob("**/*").select(&:file?).map(&:read).join("\n")
153
+ PRIVATE_SNIPPETS.each do |snippet|
154
+ check!(!blob.include?(snippet), "private snippet leaked: #{snippet}")
155
+ end
156
+ end
157
+
158
+ puts "PASS test-cv-export-contract"
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Gate: the build-time and browser detail views must emit the same contract.
5
+ #
6
+ # Detail pages exist twice. The public site assembles them in the browser from
7
+ # _includes/content-runtime/50-detail.html, which calls the shared ledger
8
+ # blocks. The private/studio build renders them at build time from
9
+ # _layouts/text.html, which reimplements the tag chips and the language pill
10
+ # in Liquid — it has no way to call the JS. One stylesheet,
11
+ # assets/css/ledger/detail.css, serves both, so a class renamed on one side
12
+ # silently unstyles the other, and the language table can drift into two
13
+ # different vocabularies without anything failing.
14
+ #
15
+ # The layout already states this contract in a comment. This makes it a gate.
16
+
17
+ require "open3"
18
+ require "pathname"
19
+
20
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
21
+ CHECKS = Pathname.new(__dir__).join("render-parity", "run-detail-parity-checks.mjs")
22
+
23
+ def fail!(message)
24
+ warn message
25
+ exit 1
26
+ end
27
+
28
+ puts "Testing detail view render parity (Liquid build vs browser runtime)..."
29
+
30
+ fail!("missing #{CHECKS}") unless CHECKS.file?
31
+
32
+ stdout, stderr, status = Open3.capture3("node", CHECKS.to_s, chdir: ROOT.join("scripts").to_s)
33
+
34
+ print stdout
35
+ unless status.success?
36
+ warn stderr unless stderr.strip.empty?
37
+ fail!("The two detail render paths have drifted apart.")
38
+ end
39
+
40
+ puts "All detail render parity checks passed."
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "pathname"
5
+ require "json"
6
+ require "ostruct"
7
+
8
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
9
+ require_relative "../../_plugins/content_local_serve"
10
+ require_relative "../../_plugins/local_detail_rewrites"
11
+
12
+ def fail!(msg)
13
+ warn "FAIL: #{msg}"
14
+ exit 1
15
+ end
16
+
17
+ def check!(condition, msg)
18
+ fail!(msg) unless condition
19
+ end
20
+
21
+ puts "Testing dev re-export feature..."
22
+
23
+ # 1. Check template file presence and key identifiers
24
+ include_file = REPO_ROOT.join("_includes/theme/dev-reexport.html")
25
+ check!(include_file.file?, "Missing _includes/theme/dev-reexport.html")
26
+ include_content = include_file.read
27
+ check!(include_content.include?('id="dev-reexport"'), "Missing id='dev-reexport' in include")
28
+ check!(include_content.include?('id="dev-reexport-btn"'), "Missing id='dev-reexport-btn' in include")
29
+ check!(include_content.include?('id="dev-reexport-label"'), "Missing id='dev-reexport-label' in include")
30
+ check!(include_content.include?('id="dev-reexport-icon"'), "Missing id='dev-reexport-icon' in include")
31
+ check!(include_content.include?("/__dev/reexport"), "Missing /__dev/reexport fetch in include")
32
+ check!(include_content.include?("text-tertiary"), "Missing text-tertiary token styling for dev button")
33
+ puts " ✓ Template structure and styling tokens verified"
34
+
35
+ # 2. Check shell layout includes it with guard
36
+ shell_file = REPO_ROOT.join("_layouts/shell.html")
37
+ shell_content = shell_file.read
38
+ check!(shell_content.include?("theme/dev-reexport.html"), "Missing dev-reexport.html inclusion in _layouts/shell.html")
39
+ check!(shell_content.match?(/site\.content_include_unpublished.*theme\/dev-reexport\.html/m),
40
+ "dev-reexport.html must be guarded by site.content_include_unpublished")
41
+ puts " ✓ Shell layout conditional guard verified"
42
+
43
+ # 3. Check that public build evaluation does not render dev-reexport button
44
+ require "liquid"
45
+ guard_snippet = "{% if site.content_include_unpublished or site.unpublished %}dev_present{% else %}dev_absent{% endif %}"
46
+ parsed_guard = Liquid::Template.parse(guard_snippet)
47
+ check!(parsed_guard.render("site" => {}) == "dev_absent",
48
+ "Public build (no dev flags) must NOT render dev-reexport button")
49
+ check!(parsed_guard.render("site" => { "content_include_unpublished" => true }) == "dev_present",
50
+ "Dev build with content_include_unpublished must render dev-reexport button")
51
+ check!(parsed_guard.render("site" => { "unpublished" => true }) == "dev_present",
52
+ "Dev build with unpublished must render dev-reexport button")
53
+ puts " ✓ Liquid conditional guard correctly excludes button in public builds and includes it in dev"
54
+
55
+
56
+ # 4. Check REEXPORT_PATHS definition in LocalDetailRewrites
57
+ check!(Jekyll::ContentDetailRewrites::REEXPORT_PATHS.include?("/__dev/reexport"),
58
+ "REEXPORT_PATHS must include /__dev/reexport")
59
+ check!(Jekyll::ContentDetailRewrites::REEXPORT_PATHS.include?("/api/dev/reexport"),
60
+ "REEXPORT_PATHS must include /api/dev/reexport")
61
+ puts " ✓ REEXPORT_PATHS verified"
62
+
63
+ # 5. Check handle_reexport_request permission guard
64
+ fake_res = {}
65
+ def fake_res.[]=(key, val)
66
+ @headers ||= {}
67
+ @headers[key] = val
68
+ end
69
+ def fake_res.status=(val)
70
+ @status = val
71
+ end
72
+ def fake_res.status
73
+ @status
74
+ end
75
+ def fake_res.body=(val)
76
+ @body = val
77
+ end
78
+ def fake_res.body
79
+ @body
80
+ end
81
+
82
+ # When dev options are nil/empty: must be 403 Forbidden
83
+ Jekyll::ContentLocalServe.serve_options = nil
84
+ Jekyll::ContentLocalServe.site_config = nil
85
+ Jekyll::ContentLocalServe.handle_reexport_request(nil, fake_res)
86
+ check!(fake_res.status == 403, "handle_reexport_request must return 403 when dev flags are absent")
87
+ puts " ✓ Production permission guard returns 403"
88
+
89
+ # When dev option is present:
90
+ fake_res2 = {}
91
+ def fake_res2.[]=(key, val)
92
+ @headers ||= {}
93
+ @headers[key] = val
94
+ end
95
+ def fake_res2.status=(val)
96
+ @status = val
97
+ end
98
+ def fake_res2.status
99
+ @status
100
+ end
101
+ def fake_res2.body=(val)
102
+ @body = val
103
+ end
104
+ def fake_res2.body
105
+ @body
106
+ end
107
+
108
+ dev_options = {
109
+ "source" => REPO_ROOT.to_s,
110
+ "destination" => REPO_ROOT.join("_site").to_s,
111
+ "content_include_unpublished" => true,
112
+ "local_content_serve" => true,
113
+ "content_api_base_url" => ""
114
+ }
115
+ Jekyll::ContentLocalServe.serve_options = dev_options
116
+ Jekyll::ContentLocalServe.handle_reexport_request(nil, fake_res2)
117
+ check!(fake_res2.status == 200, "handle_reexport_request should return 200 in dev mode, got #{fake_res2.status}: #{fake_res2.body}")
118
+ parsed = JSON.parse(fake_res2.body)
119
+ check!(parsed["ok"] == true, "Response payload must have ok: true")
120
+ puts " ✓ Dev mode re-export execution returned 200 OK"
121
+
122
+ puts "All dev re-export tests passed!"
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Refs: PLT-0.3, PLT-AC-5
4
+ require "pathname"
5
+ require "open3"
6
+
7
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
8
+ EXAMPLE = ROOT.join("examples/minimal")
9
+ EXE = ROOT.join("exe/pandorga")
10
+
11
+ def run_doctor(dir)
12
+ env = { "RUBYOPT" => "-I#{ROOT.join('lib')}" }
13
+ Open3.capture3(env, "ruby", EXE.to_s, "doctor", dir.to_s)
14
+ end
15
+
16
+ stdout, stderr, status = run_doctor(EXAMPLE)
17
+ unless status.success?
18
+ warn "FAIL test-doctor: expected exit 0\n#{stdout}\n#{stderr}"
19
+ exit 1
20
+ end
21
+ unless stdout.include?("pandorga doctor: ok")
22
+ warn "FAIL test-doctor: unexpected stdout:\n#{stdout}"
23
+ exit 1
24
+ end
25
+
26
+ bad = ROOT.join("tmp/doctor-bad-fixture")
27
+ bad.mkpath
28
+ bad.join("_config.yml").write("title: x\n")
29
+ _out, err, st = run_doctor(bad)
30
+ if st.success?
31
+ warn "FAIL test-doctor: expected failure without pandorga: key"
32
+ exit 1
33
+ end
34
+ unless err.include?("pandorga:")
35
+ warn "FAIL test-doctor: expected error about pandorga: key\n#{err}"
36
+ exit 1
37
+ end
38
+
39
+ puts "PASS test-doctor"
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Refs: PLT-AC-4 — same template twice with different collections yields two pages.
4
+ require "pathname"
5
+ require "yaml"
6
+ require "tmpdir"
7
+ require "jekyll"
8
+
9
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
10
+ $LOAD_PATH.unshift(ROOT.join("lib").to_s)
11
+ require "pandorga/registry"
12
+ require "pandorga/jekyll/generator"
13
+
14
+ FIXTURE = ROOT.join("scripts/test/fixtures/dual-template")
15
+ config_path = FIXTURE.join("_config.yml")
16
+ unless config_path.file?
17
+ warn "FAIL test-dual-template-instances: missing #{config_path}"
18
+ exit 1
19
+ end
20
+
21
+ site_yaml = YAML.safe_load(config_path.read, aliases: true)
22
+ pages = PandorgaRegistry.pages(site_yaml)
23
+
24
+ begin
25
+ PandorgaRegistry.validate!(pages)
26
+ rescue ArgumentError => e
27
+ warn "FAIL test-dual-template-instances: registry rejected dual articles: #{e.message}"
28
+ exit 1
29
+ end
30
+
31
+ article_pages = pages.select { |p| p["template"].to_s == "articles" }
32
+ unless article_pages.length == 2
33
+ warn "FAIL test-dual-template-instances: expected 2 articles pages, got #{article_pages.length}"
34
+ exit 1
35
+ end
36
+
37
+ collections = article_pages.map { |p| p["collection"].to_s }.uniq
38
+ paths = article_pages.map { |p| p["path"].to_s }.uniq
39
+ keys = article_pages.map { |p| p["key"].to_s }.uniq
40
+ unless collections.length == 2 && paths.length == 2 && keys.length == 2
41
+ warn "FAIL test-dual-template-instances: dual pages must differ in key, collection, and path"
42
+ exit 1
43
+ end
44
+
45
+ writing = PandorgaRegistry.writing_collections(pages)
46
+ collections.each do |name|
47
+ unless writing.key?(name)
48
+ warn "FAIL test-dual-template-instances: writing_collections missing #{name.inspect}"
49
+ exit 1
50
+ end
51
+ end
52
+
53
+ Dir.mktmpdir("pandorga-dual-template") do |dest|
54
+ jekyll_config = Jekyll.configuration(
55
+ "source" => FIXTURE.to_s,
56
+ "destination" => dest,
57
+ "quiet" => true,
58
+ "safe" => false
59
+ )
60
+ # Overlay pandorga registry from the fixture without requiring a theme build.
61
+ jekyll_config["pandorga"] = site_yaml["pandorga"]
62
+
63
+ site = Jekyll::Site.new(jekyll_config)
64
+ Pandorga::Jekyll::Generator.new.generate(site)
65
+
66
+ generated = site.pages.select { |p| keys.include?(p.data["key"].to_s) }
67
+ got_keys = generated.map { |p| p.data["key"].to_s }.sort
68
+ unless got_keys == keys.sort
69
+ warn "FAIL test-dual-template-instances: generator pages #{got_keys.inspect}, expected #{keys.sort.inspect}"
70
+ exit 1
71
+ end
72
+
73
+ got_paths = generated.map { |p| p.data["permalink"].to_s }.sort
74
+ expected_paths = paths.map { |p| p.end_with?("/") ? p : "#{p}/" }.sort
75
+ unless got_paths == expected_paths
76
+ warn "FAIL test-dual-template-instances: permalinks #{got_paths.inspect}, expected #{expected_paths.inspect}"
77
+ exit 1
78
+ end
79
+
80
+ layouts = generated.map { |p| p.data["layout"].to_s }.uniq
81
+ unless layouts == ["articles"]
82
+ warn "FAIL test-dual-template-instances: expected articles layout for both, got #{layouts.inspect}"
83
+ exit 1
84
+ end
85
+ end
86
+
87
+ puts "PASS test-dual-template-instances"
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Refs: PLT-AC-4 — dual template instances emit distinct listing runtime config in HTML.
4
+ require "pathname"
5
+ require "yaml"
6
+ require "tmpdir"
7
+ require "jekyll"
8
+ require "liquid"
9
+
10
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
11
+ $LOAD_PATH.unshift(ROOT.join("lib").to_s)
12
+ require "pandorga/registry"
13
+ require "pandorga/jekyll/generator"
14
+
15
+ FIXTURE = ROOT.join("scripts/test/fixtures/dual-template")
16
+ ATTRS_INCLUDE = ROOT.join("_includes/page/listing-runtime-attrs.html")
17
+ ARTICLES_LAYOUT = ROOT.join("_layouts/articles.html")
18
+
19
+ config_path = FIXTURE.join("_config.yml")
20
+ unless config_path.file?
21
+ warn "FAIL test-dual-template-runtime: missing #{config_path}"
22
+ exit 1
23
+ end
24
+
25
+ unless ATTRS_INCLUDE.file?
26
+ warn "FAIL test-dual-template-runtime: missing #{ATTRS_INCLUDE}"
27
+ exit 1
28
+ end
29
+
30
+ unless ARTICLES_LAYOUT.file?
31
+ warn "FAIL test-dual-template-runtime: missing #{ARTICLES_LAYOUT}"
32
+ exit 1
33
+ end
34
+
35
+ site_yaml = YAML.safe_load(config_path.read, aliases: true)
36
+ pages = PandorgaRegistry.pages(site_yaml)
37
+ PandorgaRegistry.validate!(pages)
38
+
39
+ article_pages = pages.select { |p| p["template"].to_s == "articles" }
40
+ unless article_pages.length == 2
41
+ warn "FAIL test-dual-template-runtime: expected 2 articles pages, got #{article_pages.length}"
42
+ exit 1
43
+ end
44
+
45
+ layout_src = ARTICLES_LAYOUT.read
46
+ %w[listing-runtime-attrs data-listing-collection].each do |token|
47
+ next if layout_src.include?(token)
48
+
49
+ warn "FAIL test-dual-template-runtime: articles layout missing #{token.inspect}"
50
+ exit 1
51
+ end
52
+
53
+ attrs_src = ATTRS_INCLUDE.read
54
+ %w[listing_collection listing_path listing_detail data-listing-collection data-listing-path data-listing-detail].each do |token|
55
+ next if attrs_src.include?(token)
56
+
57
+ warn "FAIL test-dual-template-runtime: listing-runtime-attrs missing #{token.inspect}"
58
+ exit 1
59
+ end
60
+
61
+ if layout_src.match?(/loadWritingIndex\(\s*['"]articles['"]\s*\)/)
62
+ warn "FAIL test-dual-template-runtime: articles layout still hardcodes loadWritingIndex('articles')"
63
+ exit 1
64
+ end
65
+
66
+ if layout_src.include?("'/pages/articles/?tag='") || layout_src.include?('"/pages/articles/?tag="')
67
+ warn "FAIL test-dual-template-runtime: articles layout still hardcodes /pages/articles/ tag href"
68
+ exit 1
69
+ end
70
+
71
+ attrs_template = Liquid::Template.parse(ATTRS_INCLUDE.read)
72
+
73
+ Dir.mktmpdir("pandorga-dual-template-runtime") do |dest|
74
+ jekyll_config = Jekyll.configuration(
75
+ "source" => FIXTURE.to_s,
76
+ "destination" => dest,
77
+ "quiet" => true,
78
+ "safe" => false
79
+ )
80
+ jekyll_config["pandorga"] = site_yaml["pandorga"]
81
+
82
+ site = Jekyll::Site.new(jekyll_config)
83
+ Pandorga::Jekyll::Generator.new.generate(site)
84
+
85
+ keys = article_pages.map { |p| p["key"].to_s }
86
+ generated = site.pages.select { |p| keys.include?(p.data["key"].to_s) }
87
+ unless generated.length == 2
88
+ warn "FAIL test-dual-template-runtime: generator emitted #{generated.length} pages, expected 2"
89
+ exit 1
90
+ end
91
+
92
+ html_by_key = {}
93
+ generated.each do |page|
94
+ %w[collection listing_path detail].each do |field|
95
+ value = page.data[field].to_s
96
+ if value.empty?
97
+ warn "FAIL test-dual-template-runtime: page #{page.data['key']} missing #{field}"
98
+ exit 1
99
+ end
100
+ end
101
+
102
+ html = attrs_template.render(
103
+ "page" => {
104
+ "collection" => page.data["collection"],
105
+ "listing_path" => page.data["listing_path"],
106
+ "permalink" => page.data["permalink"],
107
+ "detail" => page.data["detail"],
108
+ "key" => page.data["key"]
109
+ },
110
+ "include" => {
111
+ "default_collection" => "articles",
112
+ "default_path" => "/pages/articles/",
113
+ "default_detail" => "/articles/:slug/"
114
+ }
115
+ )
116
+ html_by_key[page.data["key"].to_s] = html
117
+ end
118
+
119
+ collections = generated.map { |p| p.data["collection"].to_s }.uniq
120
+ paths = generated.map { |p| p.data["listing_path"].to_s }.uniq
121
+ details = generated.map { |p| p.data["detail"].to_s }.uniq
122
+ unless collections.length == 2 && paths.length == 2 && details.length == 2
123
+ warn "FAIL test-dual-template-runtime: page data must differ in collection, listing_path, and detail " \
124
+ "(collections=#{collections.inspect} paths=#{paths.inspect} details=#{details.inspect})"
125
+ exit 1
126
+ end
127
+
128
+ htmls = html_by_key.values
129
+ unless htmls.length == 2 && htmls.uniq.length == 2
130
+ warn "FAIL test-dual-template-runtime: rendered listing attrs must differ per instance: #{html_by_key.inspect}"
131
+ exit 1
132
+ end
133
+
134
+ article_pages.each do |entry|
135
+ key = entry["key"].to_s
136
+ html = html_by_key.fetch(key)
137
+ expected_collection = entry["collection"].to_s
138
+ expected_path = entry["path"].to_s
139
+ expected_path = "#{expected_path}/" unless expected_path.end_with?("/")
140
+ expected_detail = entry["detail"].to_s
141
+
142
+ unless html.include?("data-listing-collection=\"#{expected_collection}\"")
143
+ warn "FAIL test-dual-template-runtime: #{key} HTML missing collection #{expected_collection.inspect}: #{html}"
144
+ exit 1
145
+ end
146
+ unless html.include?("data-listing-path=\"#{expected_path}\"")
147
+ warn "FAIL test-dual-template-runtime: #{key} HTML missing path #{expected_path.inspect}: #{html}"
148
+ exit 1
149
+ end
150
+ unless html.include?("data-listing-detail=\"#{expected_detail}\"")
151
+ warn "FAIL test-dual-template-runtime: #{key} HTML missing detail #{expected_detail.inspect}: #{html}"
152
+ exit 1
153
+ end
154
+ end
155
+
156
+ # Both instances must not collapse onto the English articles defaults.
157
+ if htmls.all? { |html| html.include?('data-listing-collection="articles"') }
158
+ warn "FAIL test-dual-template-runtime: both instances still point at collection articles"
159
+ exit 1
160
+ end
161
+ if htmls.all? { |html| html.include?('data-listing-path="/pages/articles/"') }
162
+ warn "FAIL test-dual-template-runtime: both instances still point at /pages/articles/"
163
+ exit 1
164
+ end
165
+ end
166
+
167
+ puts "PASS test-dual-template-runtime"