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,31 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "pathname"
5
+
6
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
7
+ DOC = ROOT.join("docs/ops/cloudflare.md")
8
+
9
+ def check!(condition, message = "condition was false")
10
+ raise "Assertion failed: #{message}" unless condition
11
+ end
12
+
13
+ text = DOC.read
14
+
15
+ puts "Testing Cloudflare watch paths documentation..."
16
+
17
+ check! text.include?("`*`"), "missing include pattern `*`"
18
+ check! text.include?("`content/*`"), "missing exclude pattern content/*"
19
+ check! text.include?("`docs/*`"), "missing exclude pattern docs/*"
20
+ check! text.include?("Settings → Build → Build watch paths"),
21
+ "missing dashboard navigation path"
22
+ check! text.include?("SKIP_DEPENDENCY_INSTALL"),
23
+ "missing SKIP_DEPENDENCY_INSTALL (avoids Pages pip install of study deps)"
24
+ check! text.include?("bundle install") && text.include?("jekyll build"),
25
+ "missing lean Pages build command with explicit bundle install"
26
+ check! text.include?("pandorga install-functions"),
27
+ "missing pandorga install-functions in Pages build command (PLT-0.2)"
28
+ check! text.include?("3.4.4"),
29
+ "missing Pages v3 Ruby 3.4.4 pin (avoids compiling Ruby from source)"
30
+
31
+ puts "Cloudflare watch paths documentation OK."
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Refs: PLT-AC-11
5
+ require "open3"
6
+ require "pathname"
7
+
8
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
9
+ GENERATOR = ROOT.join("scripts/generate-configuration-doc.rb")
10
+ SCHEMA = ROOT.join("lib/pandorga/registry/schema.yml")
11
+ DOC = ROOT.join("docs/configuration.md")
12
+
13
+ unless SCHEMA.file?
14
+ warn "FAIL test-configuration-doc: missing #{SCHEMA}"
15
+ exit 1
16
+ end
17
+
18
+ unless DOC.file?
19
+ warn "FAIL test-configuration-doc: missing #{DOC}"
20
+ exit 1
21
+ end
22
+
23
+ stdout, stderr, status = Open3.capture3("ruby", GENERATOR.to_s, "--check")
24
+ unless status.success?
25
+ warn "FAIL test-configuration-doc: #{stderr}\n#{stdout}"
26
+ exit 1
27
+ end
28
+
29
+ %w[cv articles blog media network bibliography portfolio].each do |name|
30
+ next if DOC.read.include?(name) && SCHEMA.read.include?(name)
31
+
32
+ warn "FAIL test-configuration-doc: template #{name} missing from doc or schema"
33
+ exit 1
34
+ end
35
+
36
+ puts "PASS test-configuration-doc"
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $stdout.sync = true
5
+ $stderr.sync = true
6
+
7
+ # End-to-end gate: editorial JSON export (storage contract) + shell detail URLs.
8
+ #
9
+ # Simulates CMS publish → R2 JSON → shell poster (HTML) → runtime JSON fetch.
10
+ # Fails if any published post/article would 404 at /posts/:slug/ or /articles/:slug/
11
+ # without a per-slug shell rebuild.
12
+ #
13
+ # Run via ./scripts/validate.sh and content-detail-url-gate.yml.
14
+
15
+ require "fileutils"
16
+ require "json"
17
+ require "net/http"
18
+ require "open3"
19
+ require "pathname"
20
+ require "socket"
21
+ require "tempfile"
22
+ require "timeout"
23
+
24
+ require_relative "../../_plugins/content_detail_paths"
25
+
26
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
27
+ CONTENT_ROOT = REPO_ROOT.join("content")
28
+ EXPORT_ROOT = REPO_ROOT.join("_content_json")
29
+ SITE_ROOT = REPO_ROOT.join("_site")
30
+ REDIRECTS_FILE = REPO_ROOT.join("_redirects")
31
+
32
+ SYNTHETIC_SLUG = "2099-01-01-ci-detail-url-gate"
33
+ SYNTHETIC_MARKER = "Synthetic post proving detail URLs work without a shell rebuild."
34
+
35
+ DETAIL_RUNTIME_MARKERS = [
36
+ "ContentRuntime",
37
+ "contentSlug",
38
+ "loadCollection",
39
+ "Skipping missing content object"
40
+ ].freeze
41
+
42
+ def fail!(message)
43
+ raise message
44
+ end
45
+
46
+ def check!(condition, message)
47
+ fail!(message) unless condition
48
+ end
49
+
50
+ def run!(command, chdir: REPO_ROOT, env: {})
51
+ stdout, stderr, status = Open3.capture3(env, *command, chdir: chdir.to_s)
52
+ return [stdout, stderr] if status.success?
53
+
54
+ fail!("Command failed (#{command.join(' ')}):\n#{stderr}\n#{stdout}")
55
+ end
56
+
57
+ def free_port
58
+ server = TCPServer.new("127.0.0.1", 0)
59
+ port = server.addr[1]
60
+ server.close
61
+ port
62
+ end
63
+
64
+ def validate_redirects!
65
+ check!(REDIRECTS_FILE.file?, "Missing #{REDIRECTS_FILE}")
66
+
67
+ text = REDIRECTS_FILE.read
68
+ check!(text.include?("/posts/:slug #{ContentDetailPaths::POST_SHELL.rstrip} 200"),
69
+ "_redirects must rewrite /posts/:slug/ to #{ContentDetailPaths::POST_SHELL}")
70
+ check!(text.include?("/articles/:slug #{ContentDetailPaths::ARTICLE_SHELL.rstrip} 200"),
71
+ "_redirects must rewrite /articles/:slug/ to #{ContentDetailPaths::ARTICLE_SHELL}")
72
+ check!(text.include?("/products/:slug #{ContentDetailPaths::PRODUCT_SHELL.rstrip} 200"),
73
+ "_redirects must rewrite /products/:slug/ to #{ContentDetailPaths::PRODUCT_SHELL}")
74
+ check!(text.include?("/projects/:slug #{ContentDetailPaths::PROJECT_SHELL.rstrip} 200"),
75
+ "_redirects must rewrite /projects/:slug/ to #{ContentDetailPaths::PROJECT_SHELL}")
76
+ require_relative "../../_plugins/content_keys"
77
+ check!(ContentKeys.generated_redirects_precede_catchalls?(text),
78
+ "_redirects must place content-key 301s above /articles/:slug, /posts/:slug, and /blog/:slug 200 rewrites")
79
+
80
+ if text.match?(%r{/social/(posts|articles)/|/social/(posts|articles)/detail})
81
+ fail!("_redirects still targets /social/ detail shells (regression): use #{ContentDetailPaths::POST_SHELL} and #{ContentDetailPaths::ARTICLE_SHELL}")
82
+ end
83
+ end
84
+
85
+ def prepare_synthetic_post!
86
+ posts_dir = CONTENT_ROOT.join("collections/posts")
87
+ synthetic = posts_dir.join("#{SYNTHETIC_SLUG}.md")
88
+ return if synthetic.file?
89
+
90
+ File.write(
91
+ synthetic,
92
+ <<~MARKDOWN
93
+ ---
94
+ layout: post
95
+ title: CI detail URL gate
96
+ date: 2099-01-01
97
+ tags:
98
+ - devaneio
99
+ language: ptbr
100
+ key: ci-detail-url-gate
101
+ description: Synthetic fixture for detail URL regression tests.
102
+ published: true
103
+ ---
104
+
105
+ #{SYNTHETIC_MARKER}
106
+ MARKDOWN
107
+ )
108
+ @synthetic_created = true
109
+ end
110
+
111
+ def cleanup_synthetic_post!
112
+ return unless @synthetic_created
113
+
114
+ path = CONTENT_ROOT.join("collections/posts/#{SYNTHETIC_SLUG}.md")
115
+ FileUtils.rm_f(path)
116
+
117
+ # Re-export so a local _content_json left from this gate never retains the
118
+ # synthetic slug (the content publish workflow no longer runs this gate).
119
+ export_content!
120
+ end
121
+
122
+ def export_content!
123
+ run!(%w[bundle exec ruby scripts/content/export-content-json.rb content _content_json],
124
+ env: { "CONTENT_API_BASE_URL" => "" })
125
+ check!(EXPORT_ROOT.join("manifest.json").file?, "Export did not write manifest.json")
126
+ end
127
+
128
+ def load_manifest
129
+ JSON.parse(EXPORT_ROOT.join("manifest.json").read)
130
+ end
131
+
132
+ def verify_export_contract!(manifest)
133
+ %w[posts articles].each do |collection|
134
+ files = manifest.dig("files", collection)
135
+ check!(files.is_a?(Array) && !files.empty?, "manifest.files.#{collection} is empty")
136
+
137
+ files.each do |relative_path|
138
+ json_path = EXPORT_ROOT.join(relative_path)
139
+ check!(json_path.file?, "Missing exported JSON: #{relative_path}")
140
+
141
+ payload = JSON.parse(json_path.read)
142
+ slug = payload["slug"].to_s
143
+ check!(!slug.empty?, "JSON missing slug: #{relative_path}")
144
+ check!(payload["body_markdown"].to_s.strip != "", "JSON missing body_markdown: #{relative_path}")
145
+ end
146
+ end
147
+
148
+ synth = EXPORT_ROOT.join("collections/posts/#{SYNTHETIC_SLUG}.json")
149
+ check!(synth.file?, "Synthetic post was not exported to storage layout")
150
+ check!(JSON.parse(synth.read)["body_markdown"].include?(SYNTHETIC_MARKER),
151
+ "Synthetic post body missing marker text")
152
+ end
153
+
154
+ def build_shell!
155
+ run!(%w[bundle exec jekyll build --baseurl "" --config _config.yml,_config_dev.yml],
156
+ env: { "CONTENT_API_BASE_URL" => "" })
157
+ end
158
+
159
+ def verify_shell_structure!
160
+ check!(SITE_ROOT.join("pages/blog/index.html").file?,
161
+ "Missing blog listing shell at /pages/blog/")
162
+ check!(SITE_ROOT.join("pages/articles/index.html").file?,
163
+ "Missing articles listing shell at /pages/articles/")
164
+ check!(SITE_ROOT.join(ContentDetailPaths::POST_SHELL.delete_prefix("/"), "index.html").file?,
165
+ "Missing rewrite target #{ContentDetailPaths::POST_SHELL}")
166
+ check!(SITE_ROOT.join(ContentDetailPaths::ARTICLE_SHELL.delete_prefix("/"), "index.html").file?,
167
+ "Missing rewrite target #{ContentDetailPaths::ARTICLE_SHELL}")
168
+ check!(SITE_ROOT.join(ContentDetailPaths::PRODUCT_SHELL.delete_prefix("/"), "index.html").file?,
169
+ "Missing rewrite target #{ContentDetailPaths::PRODUCT_SHELL}")
170
+ check!(SITE_ROOT.join(ContentDetailPaths::PROJECT_SHELL.delete_prefix("/"), "index.html").file?,
171
+ "Missing rewrite target #{ContentDetailPaths::PROJECT_SHELL}")
172
+ check!(!SITE_ROOT.join("products").directory?,
173
+ "Static /products/ pages must not be generated; detail URLs use runtime fetch")
174
+ check!(!SITE_ROOT.join("projects").directory?,
175
+ "Static /projects/ pages must not be generated; detail URLs use runtime fetch")
176
+ check!(!SITE_ROOT.join("social").directory?,
177
+ "Obsolete /social/ shells still generated; detail URLs must reuse listing layouts")
178
+ check!(SITE_ROOT.join("_redirects").file?, "_redirects was not copied into _site")
179
+ end
180
+
181
+ def slug_from_manifest_path(relative_path)
182
+ File.basename(relative_path, ".json")
183
+ end
184
+
185
+ def detail_path(type, slug)
186
+ case type
187
+ when "post" then ContentDetailPaths.post_detail_path(slug)
188
+ when "article" then ContentDetailPaths.article_detail_path(slug)
189
+ when "product" then ContentDetailPaths.product_detail_path(slug)
190
+ when "project" then ContentDetailPaths.project_detail_path(slug)
191
+ else
192
+ fail!("Unknown detail type: #{type}")
193
+ end
194
+ end
195
+
196
+ def json_path(type, slug)
197
+ collection = case type
198
+ when "post" then "posts"
199
+ when "article" then "articles"
200
+ when "product" then "products"
201
+ when "project" then "projects"
202
+ else fail!("Unknown detail type: #{type}")
203
+ end
204
+ "/collections/#{collection}/#{slug}.json"
205
+ end
206
+
207
+ def http_get(port, path)
208
+ uri = URI("http://127.0.0.1:#{port}#{path}")
209
+ Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 10) do |http|
210
+ http.get(uri.request_uri)
211
+ end
212
+ end
213
+
214
+ def verify_runtime_detail!(port, type, slug)
215
+ url = detail_path(type, slug)
216
+ rewrite_target = ContentDetailPaths.rewrite_path(url)
217
+ check!(rewrite_target, "No local rewrite registered for #{url}")
218
+
219
+ shell_path = SITE_ROOT.join(rewrite_target.delete_prefix("/").chomp("/"), "index.html")
220
+ check!(shell_path.file?, "Rewrite target missing on disk: #{rewrite_target}")
221
+
222
+ response = http_get(port, url)
223
+ check!(response.code.to_i == 200,
224
+ "Detail URL #{url} returned HTTP #{response.code} (expected 200). Published content would 404 in production.")
225
+
226
+ body = response.body.to_s
227
+ DETAIL_RUNTIME_MARKERS.each do |marker|
228
+ check!(body.include?(marker),
229
+ "Detail URL #{url} is missing runtime marker #{marker.inspect}; shell cannot load storage JSON")
230
+ end
231
+
232
+ json_url = json_path(type, slug)
233
+ json_response = http_get(port, json_url)
234
+ check!(json_response.code.to_i == 200,
235
+ "Storage JSON #{json_url} returned HTTP #{json_response.code} for slug #{slug}")
236
+
237
+ payload = JSON.parse(json_response.body)
238
+ check!(payload["slug"] == slug, "JSON slug mismatch for #{json_url}")
239
+ check!(payload["body_markdown"].to_s.strip != "", "JSON body empty for #{json_url}")
240
+ end
241
+
242
+ def start_server!(port)
243
+ log = Tempfile.new("jekyll-serve-log")
244
+ log.close
245
+
246
+ wrapper_pid = spawn(
247
+ { "CONTENT_API_BASE_URL" => "" },
248
+ "bundle", "exec", "jekyll", "serve",
249
+ "--host", "127.0.0.1",
250
+ "--port", port.to_s,
251
+ "--config", "_config.yml,_config_dev.yml",
252
+ "--skip-initial-build",
253
+ "--no-watch",
254
+ "--detach",
255
+ [:out, :err] => log.path,
256
+ chdir: REPO_ROOT.to_s
257
+ )
258
+ _pid, status = Process.wait2(wrapper_pid)
259
+ output = File.read(log.path)
260
+ fail!("jekyll serve --detach failed:\n#{output}") unless status.success?
261
+
262
+ if output =~ /pid '(\d+)'/
263
+ @server_pid = Regexp.last_match(1).to_i
264
+ else
265
+ fail!("Could not detect jekyll serve pid from output: #{output}")
266
+ end
267
+
268
+ Timeout.timeout(30) do
269
+ loop do
270
+ response = http_get(port, ContentDetailPaths::POST_SHELL)
271
+ break if response.code.to_i == 200
272
+
273
+ sleep 0.25
274
+ end
275
+ end
276
+ rescue Timeout::Error
277
+ fail!("Timed out waiting for jekyll serve on port #{port}")
278
+ ensure
279
+ FileUtils.rm_f(log.path)
280
+ end
281
+
282
+ def stop_server!
283
+ return unless @server_pid
284
+
285
+ Process.kill("TERM", @server_pid)
286
+ rescue Errno::ESRCH
287
+ nil
288
+ ensure
289
+ @server_pid = nil
290
+ end
291
+
292
+ def verify_all_detail_urls!(manifest, port)
293
+ failures = []
294
+
295
+ {
296
+ "post" => manifest.dig("files", "posts") || [],
297
+ "article" => manifest.dig("files", "articles") || [],
298
+ "product" => manifest.dig("files", "products") || [],
299
+ "project" => manifest.dig("files", "projects") || []
300
+ }.each do |type, files|
301
+ files.each do |relative_path|
302
+ slug = slug_from_manifest_path(relative_path)
303
+ begin
304
+ verify_runtime_detail!(port, type, slug)
305
+ puts " OK #{detail_path(type, slug)}"
306
+ rescue StandardError => e
307
+ failures << "#{detail_path(type, slug)}: #{e.message}"
308
+ end
309
+ end
310
+ end
311
+
312
+ return if failures.empty?
313
+
314
+ fail!(<<~MSG.strip)
315
+ Detail URL gate failed for #{failures.length} published item(s):
316
+ - #{failures.join("\n - ")}
317
+ MSG
318
+ end
319
+
320
+ puts "Testing content detail URLs (storage export + shell poster)..."
321
+
322
+ @synthetic_created = false
323
+ port = free_port
324
+ @server_pid = nil
325
+ manifest = nil
326
+
327
+ begin
328
+ validate_redirects!
329
+ prepare_synthetic_post!
330
+ export_content!
331
+ manifest = load_manifest
332
+ verify_export_contract!(manifest)
333
+ build_shell!
334
+ verify_shell_structure!
335
+ start_server!(port)
336
+ verify_all_detail_urls!(manifest, port)
337
+ ensure
338
+ stop_server!
339
+ cleanup_synthetic_post!
340
+ end
341
+
342
+ post_count = manifest&.dig("counts", "posts") || 0
343
+ article_count = manifest&.dig("counts", "articles") || 0
344
+ product_count = manifest&.dig("counts", "products") || 0
345
+ project_count = manifest&.dig("counts", "projects") || 0
346
+ puts "All content detail URL tests passed (#{post_count} posts, #{article_count} articles, #{product_count} products, #{project_count} projects, synthetic gate included)."
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "pathname"
5
+ require "set"
6
+ require "tmpdir"
7
+
8
+ require_relative "../../_plugins/content_keys"
9
+
10
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
11
+
12
+ def check!(condition, message)
13
+ raise message unless condition
14
+ end
15
+
16
+ puts "Testing ContentKeys..."
17
+
18
+ begin
19
+ ContentKeys.validate!(REPO_ROOT)
20
+ puts " key uniqueness: OK"
21
+ rescue ContentKeys::ValidationError => e
22
+ raise "ContentKeys validation failed: #{e.message}"
23
+ end
24
+
25
+ entries = ContentKeys.published_entries(REPO_ROOT)
26
+ check! !entries.empty?, "expected published keyed content entries"
27
+
28
+ entries.each do |entry|
29
+ check! !entry["key"].empty?, "missing key on #{entry['repo_relative_path']}"
30
+ check! !entry["path_segment"].to_s.empty?, "missing path segment on #{entry['repo_relative_path']}"
31
+ check! entry["detail_path"].start_with?("/"), "invalid detail path for #{entry['repo_relative_path']}"
32
+ end
33
+
34
+ rules = ContentKeys.redirect_rules(entries)
35
+ rules.each do |source, target, code|
36
+ check! source.start_with?("/"), "redirect source must be absolute: #{source}"
37
+ check! target.start_with?("/"), "redirect target must be absolute: #{target}"
38
+ check! code == 301, "redirects must be permanent"
39
+ end
40
+
41
+ expected_aliases = {
42
+ "ai-agents-sdd" => %w[ai-agents-sdd],
43
+ "vibe-coding" => %w[2026-03-24-where-to-do-vibe-coding 2026-03-24-when-to-vibe-code],
44
+ "wsl" => %w[20260224-wsl wsl],
45
+ "stitch-and-claude" => %w[stitch-and-claude]
46
+ }
47
+ expected_aliases.each do |key, slugs|
48
+ entry = entries.find { |item| item["key"] == key }
49
+ check! !entry.nil?, "missing published entry for #{key}"
50
+ aliases = ContentKeys.alias_slugs(entry)
51
+ slugs.each do |slug|
52
+ check! aliases.include?(slug), "#{key} aliases must include #{slug} (got #{aliases.inspect})"
53
+ end
54
+ end
55
+
56
+ # Studio once rewrote includes as key="" when fromBlock destructured the RegExp
57
+ # match wrong. Reject empty xcite targets in published markdown.
58
+ xcite_empty = /\{%-?\s*include\s+xcite\.html\s+key=["']\s*["'][^%]*-?%\}/
59
+ known_keys = entries.map { |item| item["key"] }.to_set
60
+ xcite_re = /\{%-?\s*include\s+xcite\.html\s+key=["']([^"']+)["'][^%]*-?%\}/
61
+ Pathname.glob(REPO_ROOT.join("content/collections/*/*.md")).each do |path|
62
+ path.read.each_line.with_index(1) do |line, lineno|
63
+ if line.match?(xcite_empty)
64
+ raise "empty xcite key in #{path.relative_path_from(REPO_ROOT)}:#{lineno}"
65
+ end
66
+ line.scan(xcite_re).flatten.each do |target|
67
+ next if known_keys.include?(target)
68
+
69
+ raise "unknown xcite key #{target.inspect} in #{path.relative_path_from(REPO_ROOT)}:#{lineno}"
70
+ end
71
+ end
72
+ end
73
+ puts " xcite includes: OK"
74
+
75
+ redirects_path = REPO_ROOT.join("_redirects")
76
+ text = redirects_path.read
77
+ check! ContentKeys.generated_redirects_precede_catchalls?(text),
78
+ "generated content-key 301s must sit above /articles/:slug, /posts/:slug, and /blog/:slug 200 rewrites"
79
+
80
+ %w[
81
+ /articles/20260224-wsl
82
+ /articles/2026-03-20-stitch-e-claude
83
+ /articles/2026-03-24-where-to-do-vibe-coding
84
+ ].each do |source|
85
+ check! text.include?("#{source} "), "expected 301 source #{source} in _redirects"
86
+ end
87
+
88
+ Dir.mktmpdir("content-keys-redirects-") do |tmp|
89
+ stale = Pathname.new(tmp).join("_redirects")
90
+ stale.write(<<~REDIRECTS)
91
+ /media/* https://example.test/media/:splat 302
92
+
93
+ /articles/:slug /pages/articles/ 200
94
+ /posts/:slug /pages/blog/ 200
95
+ /blog/:slug /pages/blog/ 200
96
+
97
+ #{ContentKeys::REDIRECTS_BEGIN}
98
+ /articles/stale-alias /articles/canonical/ 301
99
+ #{ContentKeys::REDIRECTS_END}
100
+ REDIRECTS
101
+
102
+ ContentKeys.sync_redirects_file!(REPO_ROOT, redirects_path: stale)
103
+ synced = stale.read
104
+ check! ContentKeys.generated_redirects_precede_catchalls?(synced),
105
+ "sync_redirects_file! must move generated 301s above SPA catch-all 200s"
106
+ check! synced.include?("/articles/20260224-wsl /articles/2026-02-24-wsl/ 301"),
107
+ "sync must emit content key 301 redirects"
108
+ end
109
+
110
+ puts "All ContentKeys tests passed (#{entries.length} entries, #{rules.length} redirect rule(s))."
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "pathname"
6
+
7
+ require_relative "../lib/content_license"
8
+
9
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
10
+ EXPORT_ROOT = REPO_ROOT.join("_content_json")
11
+
12
+ def check!(condition, message)
13
+ raise message unless condition
14
+ end
15
+
16
+ puts "Testing content license export..."
17
+
18
+ config_license = ContentLicense.load(REPO_ROOT)
19
+ check!(config_license["id"] == "CC-BY-4.0", "content_license.id must be CC-BY-4.0 in _config.yml")
20
+
21
+ run_export = !EXPORT_ROOT.join("manifest.json").file?
22
+ if run_export
23
+ system("ruby", REPO_ROOT.join("scripts/content/export-content-json.rb").to_s, "content", "_content_json") || exit(1)
24
+ end
25
+
26
+ manifest_path = EXPORT_ROOT.join("manifest.json")
27
+ check!(manifest_path.file?, "manifest.json missing after export")
28
+
29
+ manifest = JSON.parse(manifest_path.read)
30
+ check!(manifest["content_license"].is_a?(Hash), "manifest.json missing content_license")
31
+ check!(manifest["content_license"]["id"] == "CC-BY-4.0", "manifest content_license.id must be CC-BY-4.0")
32
+ check!(manifest["content_license"]["url"].to_s.include?("creativecommons.org"), "manifest content_license.url must point to Creative Commons")
33
+
34
+ %w[articles posts].each do |collection|
35
+ sample = manifest.dig("files", collection, 0)
36
+ check!(sample, "manifest.files.#{collection} is empty")
37
+
38
+ json_path = EXPORT_ROOT.join(sample)
39
+ payload = JSON.parse(json_path.read)
40
+ check!(payload["license"].is_a?(Hash), "#{sample} missing license object")
41
+ check!(payload["license"]["id"] == "CC-BY-4.0", "#{sample} license.id must be CC-BY-4.0")
42
+ check!(payload["copyright"].to_s.include?("©"), "#{sample} missing copyright notice")
43
+ end
44
+
45
+ puts "All content license tests passed."
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "open3"
6
+ require "pathname"
7
+
8
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
9
+ FIXTURE_DIR = Pathname.new(__dir__).join("render-parity")
10
+ RUNNER = FIXTURE_DIR.join("run-fixture.mjs")
11
+
12
+ # The shared ledger blocks (43-blocks.html) render listings in the browser, so
13
+ # a Jekyll build never executes them and no Ruby gate can see their output.
14
+ # Their fixtures call the real runtime through load-runtime.mjs.
15
+ BLOCK_DIR = FIXTURE_DIR.join("blocks")
16
+ BLOCK_RUNNER = FIXTURE_DIR.join("run-block-fixture.mjs")
17
+
18
+ def check!(condition, message = "condition was false")
19
+ raise "Assertion failed: #{message}" unless condition
20
+ end
21
+
22
+ def run_fixtures!(runner, paths, label)
23
+ check! paths.any?, "no #{label} fixtures found"
24
+ paths.each do |fixture_path|
25
+ stdout, stderr, status = Open3.capture3(
26
+ "node", runner.to_s, fixture_path, chdir: ROOT.join("scripts").to_s
27
+ )
28
+ check! status.success?, "#{File.basename(fixture_path)} failed:\n#{stderr}\n#{stdout}"
29
+ puts " #{stdout.strip}"
30
+ end
31
+ end
32
+
33
+ puts "Testing content render parity..."
34
+
35
+ run_fixtures!(RUNNER, Dir.glob(FIXTURE_DIR.join("*.json")).sort, "markdown parity")
36
+ run_fixtures!(BLOCK_RUNNER, Dir.glob(BLOCK_DIR.join("*.json")).sort, "ledger block")
37
+
38
+ puts "All content render parity tests passed."
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "pathname"
5
+
6
+ require_relative "../../_plugins/lib/content_validators"
7
+
8
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
9
+
10
+ puts "Testing ContentValidators..."
11
+ ContentValidators.validate_all!(ROOT)
12
+ puts "All ContentValidators checks passed."