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,153 @@
1
+ #!/usr/bin/env node
2
+ // flatten-ansi-capture.mjs — turn a terminal capture into an ```ansi fence.
3
+ //
4
+ // An `ansi` fence is rendered by _includes/content-runtime/25-code-blocks.html,
5
+ // which honours SGR colour and nothing else. Real captures are not that clean:
6
+ // anything that paints columns does it by moving the cursor, and neofetch in
7
+ // particular draws its whole info block by jumping up twenty rows and right
8
+ // forty-three columns for every line. Pasted as-is, the result is one column of
9
+ // overstruck text.
10
+ //
11
+ // So the moves are played out here, onto a character grid, and the result is
12
+ // re-emitted as ordinary lines that carry colour and nothing else. What the
13
+ // fence stores is then what a reader sees.
14
+ //
15
+ // Usage:
16
+ // script -qec "neofetch" /dev/null > capture.txt
17
+ // node scripts/content/flatten-ansi-capture.mjs capture.txt > block.txt
18
+ //
19
+ // `script` is what makes the capture worth taking: neofetch, like most tools,
20
+ // drops its colour when stdout is not a terminal, and `script` gives it a pty
21
+ // to write to. Paste the output between ```ansi fences.
22
+ //
23
+ // --raw emits the 0x1b control byte instead of the printable `\e[` spelling.
24
+ // The default is the printable form, because that is what survives an editor,
25
+ // a JSON export and a diff; the renderer reads both.
26
+
27
+ import fs from 'node:fs';
28
+
29
+ const ESC = String.fromCharCode(27);
30
+
31
+ /* SGR is accumulated rather than replaced. Tools write the attributes of one
32
+ run as several sequences in a row — neofetch's logo is `\e[0m\e[31m\e[1m` —
33
+ so treating each `m` as the whole state throws away every colour. */
34
+ function makeAttr() {
35
+ return { fg: null, bg: null, bold: false };
36
+ }
37
+
38
+ function applySgr(attr, params) {
39
+ const codes = params === '' ? ['0'] : params.split(';');
40
+ for (let i = 0; i < codes.length; i++) {
41
+ const n = parseInt(codes[i], 10);
42
+ if (Number.isNaN(n) || n === 0) { attr.fg = null; attr.bg = null; attr.bold = false; }
43
+ else if (n === 1) attr.bold = true;
44
+ else if (n === 22) attr.bold = false;
45
+ else if (n === 39) attr.fg = null;
46
+ else if (n === 49) attr.bg = null;
47
+ else if ((n >= 30 && n <= 37) || (n >= 90 && n <= 97)) attr.fg = String(n);
48
+ else if ((n >= 40 && n <= 47) || (n >= 100 && n <= 107)) attr.bg = String(n);
49
+ else if (n === 38 || n === 48) {
50
+ const target = n === 38 ? 'fg' : 'bg';
51
+ if (codes[i + 1] === '5') { attr[target] = `${n};5;${codes[i + 2]}`; i += 2; }
52
+ else if (codes[i + 1] === '2') {
53
+ attr[target] = `${n};2;${codes[i + 2]};${codes[i + 3]};${codes[i + 4]}`;
54
+ i += 4;
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ function sgrOf(attr) {
61
+ const parts = [];
62
+ if (attr.bold) parts.push('1');
63
+ if (attr.fg) parts.push(attr.fg);
64
+ if (attr.bg) parts.push(attr.bg);
65
+ return parts.join(';');
66
+ }
67
+
68
+ export function flattenAnsi(input) {
69
+ const grid = [];
70
+ let row = 0;
71
+ let col = 0;
72
+ const attr = makeAttr();
73
+
74
+ const cell = (r, c) => {
75
+ while (grid.length <= r) grid.push([]);
76
+ const line = grid[r];
77
+ while (line.length <= c) line.push({ ch: ' ', sgr: '', bg: false });
78
+ return line[c];
79
+ };
80
+
81
+ for (let i = 0; i < input.length; i++) {
82
+ const ch = input[i];
83
+ if (ch === ESC && input[i + 1] === '[') {
84
+ const m = /^\x1b\[([0-9;?]*)([A-Za-z])/.exec(input.slice(i));
85
+ if (m) {
86
+ const [, params, final] = m;
87
+ const n = parseInt(params, 10) || 1;
88
+ // `?` params belong to the private mode sequences (cursor hide, wrap
89
+ // off) that every capture opens and closes with. Nothing to draw.
90
+ if (final === 'm') applySgr(attr, params.replace(/\?/g, ''));
91
+ else if (final === 'A') row = Math.max(0, row - n);
92
+ else if (final === 'B') row += n;
93
+ else if (final === 'C') col += n;
94
+ else if (final === 'D') col = Math.max(0, col - n);
95
+ else if (final === 'G') col = Math.max(0, n - 1);
96
+ i += m[0].length - 1;
97
+ continue;
98
+ }
99
+ }
100
+ if (ch === '\n') { row++; col = 0; continue; }
101
+ if (ch === '\r') { col = 0; continue; }
102
+ if (ch === ESC || ch < ' ') continue;
103
+ const target = cell(row, col);
104
+ target.ch = ch;
105
+ target.sgr = sgrOf(attr);
106
+ target.bg = attr.bg != null;
107
+ col++;
108
+ }
109
+
110
+ const lines = grid.map((line) => {
111
+ // Trailing blanks are padding unless they carry a background colour, which
112
+ // is how the palette strips at the foot of a neofetch block are drawn.
113
+ let last = -1;
114
+ for (let i = 0; i < line.length; i++) if (line[i].ch !== ' ' || line[i].bg) last = i;
115
+ let text = '';
116
+ let active = '';
117
+ for (let i = 0; i <= last; i++) {
118
+ const c = line[i];
119
+ // A plain space shows no attribute of its own, so it stays inside the
120
+ // run it sits in rather than forcing a reset mid-line.
121
+ const want = c.ch === ' ' && !c.bg ? active : c.sgr;
122
+ // Every run states its attributes absolutely, leading with the reset.
123
+ // SGR is additive, so `\e[1m` after a red run is *bold red*, not bold
124
+ // default — and that is exactly the pair neofetch's logo alternates
125
+ // between. Emitting the difference would silently repaint the white
126
+ // letters of the Ubuntu mark in red.
127
+ if (want !== active) { text += `${ESC}[${want ? `0;${want}` : '0'}m`; active = want; }
128
+ text += c.ch;
129
+ }
130
+ if (active) text += `${ESC}[0m`;
131
+ return text;
132
+ });
133
+
134
+ while (lines.length && lines[lines.length - 1].replace(/\x1b\[[0-9;]*m/g, '').trim() === '') {
135
+ lines.pop();
136
+ }
137
+ return lines.join('\n');
138
+ }
139
+
140
+ const args = process.argv.slice(2);
141
+ const raw = args.includes('--raw');
142
+ const source = args.filter((a) => !a.startsWith('--'))[0];
143
+
144
+ if (!source) {
145
+ console.error('usage: flatten-ansi-capture.mjs <capture.txt> [--raw]');
146
+ process.exit(2);
147
+ }
148
+
149
+ // latin1 throughout: a capture is a byte stream, and decoding it as UTF-8
150
+ // would fold the box-drawing bytes some logos use into replacement characters.
151
+ const flattened = flattenAnsi(fs.readFileSync(source, 'latin1'));
152
+ const out = raw ? flattened : flattened.split(ESC + '[').join('\\e[');
153
+ process.stdout.write(Buffer.from(out + '\n', 'latin1'));
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The home hero pool is the folder. The content export writes the public
4
+ # paths to pages/home/portraits.json. The author uploads a photograph under
5
+ # content/media/images/hero/ and does not keep a second list.
6
+
7
+ require "pathname"
8
+
9
+ module HeroPortraits
10
+ IMAGE_EXTENSIONS = %w[.jpg .jpeg .png .webp .gif].freeze
11
+
12
+ module_function
13
+
14
+ def entries(content_root)
15
+ image_files(Pathname.new(content_root)).map do |file|
16
+ { "src" => "/media/images/hero/#{file.basename}" }
17
+ end
18
+ end
19
+
20
+ def image_files(root)
21
+ dir = root.join("media/images/hero")
22
+ return [] unless dir.directory?
23
+
24
+ dir.children.select do |path|
25
+ path.file? && IMAGE_EXTENSIONS.include?(path.extname.downcase)
26
+ end.sort_by { |path| path.basename.to_s }
27
+ end
28
+ end
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bash
2
+ # Publish exported editorial JSON and media to an S3-compatible bucket.
3
+ # Accepts S3_* or legacy R2_* environment variables.
4
+ # Used by `pandorga publish` and CI content pipelines.
5
+ set -euo pipefail
6
+
7
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
8
+ SITE_ROOT="${PANDORGA_SITE_ROOT:-$ROOT}"
9
+ EXPORT_ROOT="${1:-$SITE_ROOT/_content_json}"
10
+
11
+ # Prefer S3_* names; fall back to R2_* for Cloudflare R2 callers.
12
+ S3_ENDPOINT="${S3_ENDPOINT:-${R2_ENDPOINT:-}}"
13
+ S3_BUCKET="${S3_BUCKET:-${R2_BUCKET:-}}"
14
+ S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-${R2_ACCESS_KEY_ID:-}}"
15
+ S3_SECRET_ACCESS_KEY="${S3_SECRET_ACCESS_KEY:-${R2_SECRET_ACCESS_KEY:-}}"
16
+ S3_REGION="${S3_REGION:-${R2_REGION:-auto}}"
17
+
18
+ : "${S3_ENDPOINT:?Set S3_ENDPOINT or R2_ENDPOINT (S3-compatible API URL)}"
19
+ : "${S3_BUCKET:?Set S3_BUCKET or R2_BUCKET}"
20
+ : "${S3_ACCESS_KEY_ID:?Set S3_ACCESS_KEY_ID or R2_ACCESS_KEY_ID}"
21
+ : "${S3_SECRET_ACCESS_KEY:?Set S3_SECRET_ACCESS_KEY or R2_SECRET_ACCESS_KEY}"
22
+
23
+ export AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY_ID"
24
+ export AWS_SECRET_ACCESS_KEY="$S3_SECRET_ACCESS_KEY"
25
+ export AWS_DEFAULT_REGION="$S3_REGION"
26
+
27
+ # Keep sync helper (still reads R2_* names) working.
28
+ export R2_ENDPOINT="$S3_ENDPOINT"
29
+ export R2_BUCKET="$S3_BUCKET"
30
+ export R2_ACCESS_KEY_ID="$S3_ACCESS_KEY_ID"
31
+ export R2_SECRET_ACCESS_KEY="$S3_SECRET_ACCESS_KEY"
32
+ export R2_REGION="$S3_REGION"
33
+
34
+ if ! command -v aws >/dev/null 2>&1; then
35
+ echo "aws CLI is required for object-store publish." >&2
36
+ exit 1
37
+ fi
38
+
39
+ run_ruby() {
40
+ if command -v mise >/dev/null 2>&1; then
41
+ mise exec -- ruby "$@"
42
+ else
43
+ ruby "$@"
44
+ fi
45
+ }
46
+
47
+ sync_json_prefix() {
48
+ local prefix="$1"
49
+ local src="$EXPORT_ROOT/$prefix"
50
+ if [ -d "$src" ]; then
51
+ echo "==> Object store replace sync $prefix/"
52
+ run_ruby "$ROOT/scripts/content/sync-content-json-to-r2.rb" "$EXPORT_ROOT" "$prefix"
53
+ fi
54
+ }
55
+
56
+ echo "==> Publishing content JSON to bucket: ${S3_BUCKET}"
57
+ echo "==> Endpoint: ${S3_ENDPOINT}"
58
+ sync_json_prefix collections
59
+ sync_json_prefix data
60
+ sync_json_prefix pages
61
+
62
+ echo "==> Upload manifest.json"
63
+ aws s3 cp "$EXPORT_ROOT/manifest.json" "s3://${S3_BUCKET}/manifest.json" \
64
+ --endpoint-url "$S3_ENDPOINT" \
65
+ --cache-control "public,max-age=60" \
66
+ --content-type "application/json"
67
+
68
+ MEDIA_ROOT="$SITE_ROOT/content/media"
69
+ if [ -d "$MEDIA_ROOT" ]; then
70
+ echo "==> Sync media/"
71
+ aws s3 sync "$MEDIA_ROOT" "s3://${S3_BUCKET}/media" \
72
+ --endpoint-url "$S3_ENDPOINT" \
73
+ --delete \
74
+ --size-only \
75
+ --cache-control "public,max-age=31536000,immutable"
76
+ fi
77
+
78
+ echo "==> Object-store publish complete"
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ # Compatibility wrapper — prefer publish-content-to-object-store.sh (S3_* or R2_*).
3
+ set -euo pipefail
4
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ exec bash "$ROOT/publish-content-to-object-store.sh" "$@"
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Incremental JSON prefix sync to Cloudflare R2.
5
+ # Skip aws s3 cp when list-objects-v2 ETag is a single-part MD5 equal to the
6
+ # local file (case-insensitive). Upload if missing, ETag blank, multipart
7
+ # ("-" in ETag), or hash mismatch. Delete remote keys absent locally.
8
+ # Stale bodies cannot survive: mismatch still uploads and extras still delete.
9
+ # writing_index.json is always PUT so listings match the live object set.
10
+ #
11
+ # Usage:
12
+ # sync-content-json-to-r2.rb <export_root> <prefix>
13
+ # Example:
14
+ # sync-content-json-to-r2.rb _content_json collections
15
+
16
+ require "digest"
17
+ require "json"
18
+ require "open3"
19
+ require "pathname"
20
+ require "set"
21
+
22
+ require_relative "../lib/r2_json_sync"
23
+ require_relative "../lib/withdrawn_public_objects"
24
+
25
+ $stdout.sync = true
26
+ $stderr.sync = true
27
+
28
+ EXPORT_ROOT = Pathname.new(ARGV[0] || "_content_json").expand_path
29
+ PREFIX = (ARGV[1] || "").sub(%r{\A/+}, "").sub(%r{/+\z}, "")
30
+
31
+ def fail!(message)
32
+ warn message
33
+ exit 1
34
+ end
35
+
36
+ fail!("Usage: #{$PROGRAM_NAME} <export_root> <prefix>") if PREFIX.empty?
37
+
38
+ endpoint = ENV["R2_ENDPOINT"].to_s
39
+ bucket = ENV["R2_BUCKET"].to_s
40
+ fail!("R2_ENDPOINT is required") if endpoint.empty?
41
+ fail!("R2_BUCKET is required") if bucket.empty?
42
+ fail!("aws CLI is required") if Open3.capture3("bash", "-lc", "command -v aws").last.exitstatus != 0
43
+
44
+ SRC = EXPORT_ROOT.join(PREFIX)
45
+ fail!("Missing local prefix directory: #{SRC}") unless SRC.directory?
46
+
47
+ CACHE_CONTROL = "public,max-age=60"
48
+
49
+ # The export is mostly JSON, and was entirely JSON until the home band plates
50
+ # started shipping as drawings. Serving an SVG as application/json costs nothing
51
+ # for a fetch that reads it as text, and everything for anything that does not.
52
+ CONTENT_TYPES = {
53
+ ".json" => "application/json",
54
+ ".svg" => "image/svg+xml"
55
+ }.freeze
56
+
57
+ def content_type_for(path)
58
+ CONTENT_TYPES.fetch(path.extname.downcase) do
59
+ fail!("no content type registered for #{path.extname} (#{path}); add one to CONTENT_TYPES")
60
+ end
61
+ end
62
+
63
+ def run_aws(*args)
64
+ cmd = ["aws", *args, "--endpoint-url", ENV.fetch("R2_ENDPOINT")]
65
+ stdout, stderr, status = Open3.capture3(
66
+ {
67
+ "AWS_ACCESS_KEY_ID" => ENV.fetch("R2_ACCESS_KEY_ID"),
68
+ "AWS_SECRET_ACCESS_KEY" => ENV.fetch("R2_SECRET_ACCESS_KEY"),
69
+ "AWS_DEFAULT_REGION" => ENV.fetch("R2_REGION", "auto")
70
+ },
71
+ *cmd
72
+ )
73
+ [stdout, stderr, status]
74
+ end
75
+
76
+ def list_remote_objects(prefix)
77
+ etags = {}
78
+ token = nil
79
+ loop do
80
+ args = [
81
+ "s3api", "list-objects-v2",
82
+ "--bucket", ENV.fetch("R2_BUCKET"),
83
+ "--prefix", "#{prefix}/",
84
+ "--output", "json"
85
+ ]
86
+ args += ["--continuation-token", token] if token
87
+ stdout, stderr, status = run_aws(*args)
88
+ fail!("list-objects-v2 failed for #{prefix}/: #{stderr}") unless status.success?
89
+
90
+ payload = stdout.strip.empty? ? {} : JSON.parse(stdout)
91
+ etags.merge!(R2JsonSync.contents_to_etags(payload["Contents"]))
92
+ break unless payload["IsTruncated"]
93
+
94
+ token = payload["NextContinuationToken"]
95
+ fail!("list-objects-v2 truncated without continuation token") if token.to_s.empty?
96
+ end
97
+ etags
98
+ end
99
+
100
+ def local_md5(path)
101
+ Digest::MD5.hexdigest(path.binread)
102
+ end
103
+
104
+ local_files = SRC.find.select(&:file?).map do |path|
105
+ rel = path.relative_path_from(SRC).to_s
106
+ key = "#{PREFIX}/#{rel}"
107
+ [path, key, local_md5(path)]
108
+ end
109
+
110
+ remote_etags = list_remote_objects(PREFIX)
111
+ plan = R2JsonSync.plan(
112
+ local_files.map { |(_path, key, md5)| { "key" => key, "md5" => md5 } },
113
+ remote_etags
114
+ )
115
+ upload_keys = plan["upload"].to_set
116
+
117
+ uploaded = 0
118
+ local_files.each do |path, key, _md5|
119
+ next unless upload_keys.include?(key)
120
+
121
+ _stdout, stderr, status = run_aws(
122
+ "s3", "cp", path.to_s, "s3://#{ENV.fetch('R2_BUCKET')}/#{key}",
123
+ "--cache-control", CACHE_CONTROL,
124
+ "--content-type", content_type_for(path)
125
+ )
126
+ fail!("upload failed for #{key}: #{stderr}") unless status.success?
127
+
128
+ uploaded += 1
129
+ end
130
+
131
+ deleted = 0
132
+ plan["delete"].each do |key|
133
+ _stdout, stderr, status = run_aws(
134
+ "s3", "rm", "s3://#{ENV.fetch('R2_BUCKET')}/#{key}"
135
+ )
136
+ fail!("delete failed for #{key}: #{stderr}") unless status.success?
137
+
138
+ deleted += 1
139
+ end
140
+
141
+ skipped = plan["skip"].length
142
+ local_keys = local_files.map { |(_, key, _)| key }.to_set
143
+
144
+ remote_after = list_remote_objects(PREFIX)
145
+ extra = (remote_after.keys.to_set - local_keys).to_a.sort
146
+ missing = (local_keys - remote_after.keys.to_set).to_a.sort
147
+ unless extra.empty? && missing.empty?
148
+ fail!("R2 keyset drift for #{PREFIX}/ extra=#{extra.inspect} missing=#{missing.inspect}")
149
+ end
150
+
151
+ local_files.each do |_path, key, md5|
152
+ etag = remote_after[key].to_s
153
+ next if R2JsonSync.multipart_etag?(etag)
154
+
155
+ unless etag.casecmp?(md5)
156
+ fail!("R2 origin mismatch for #{key}: etag=#{etag.inspect} md5=#{md5.inspect}")
157
+ end
158
+ end
159
+
160
+ WithdrawnPublicObjects::EXPORT_PATHS.each do |relative|
161
+ next unless relative.start_with?("#{PREFIX}/")
162
+ next unless remote_after.key?(relative)
163
+
164
+ fail!("withdrawn object still present on R2: #{relative}")
165
+ end
166
+
167
+ puts "R2 replace sync #{PREFIX}/: uploaded=#{uploaded} skipped=#{skipped} deleted=#{deleted}"
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "pathname"
5
+
6
+ require_relative "../../_plugins/content_keys"
7
+
8
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
9
+
10
+ ContentKeys.validate!(REPO_ROOT)
11
+ ContentKeys.sync_redirects_file!(REPO_ROOT)
12
+ puts "Content redirects synced to #{REPO_ROOT.join('_redirects')}"
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Regenerate docs/configuration.md from lib/pandorga/registry/schema.yml.
5
+ # Refs: PLT-AC-11
6
+
7
+ require "pathname"
8
+ require "yaml"
9
+
10
+ ROOT = Pathname.new(__dir__).join("..").expand_path
11
+ SCHEMA_PATH = ROOT.join("lib/pandorga/registry/schema.yml")
12
+ DOC_PATH = ROOT.join("docs/configuration.md")
13
+
14
+ def fail!(message)
15
+ warn message
16
+ exit 1
17
+ end
18
+
19
+ def required_cell(value)
20
+ case value
21
+ when true then "yes"
22
+ when false then "no"
23
+ when nil then ""
24
+ else value.to_s
25
+ end
26
+ end
27
+
28
+ def render_keys_table(keys, include_required:)
29
+ lines = []
30
+ if include_required
31
+ lines << "| Key | Type | Required | Notes |"
32
+ lines << "|---|---|---|---|"
33
+ else
34
+ lines << "| Key | Type | Notes |"
35
+ lines << "|---|---|---|"
36
+ end
37
+
38
+ keys.each do |key, meta|
39
+ meta = {} unless meta.is_a?(Hash)
40
+ label = meta["key_label"] || key
41
+ type = meta["type"].to_s
42
+ notes = meta["notes"].to_s
43
+ if include_required
44
+ lines << "| `#{label}` | #{type} | #{required_cell(meta['required'])} | #{notes} |"
45
+ else
46
+ lines << "| `#{label}` | #{type} | #{notes} |"
47
+ end
48
+ end
49
+ lines.join("\n")
50
+ end
51
+
52
+ def render_home_attributes(entries)
53
+ lines = ["| Key | Notes |", "|---|---|"]
54
+ entries.each do |entry|
55
+ entry = {} unless entry.is_a?(Hash)
56
+ lines << "| #{entry['key']} | #{entry['notes']} |"
57
+ end
58
+ lines.join("\n")
59
+ end
60
+
61
+ def generate(schema)
62
+ out = []
63
+ out << "# Configuration reference"
64
+ out << ""
65
+ out << "Generated from `lib/pandorga/registry/schema.yml`. Gate: keep in sync"
66
+ out << "via `scripts/generate-configuration-doc.rb` (`PLT-AC-11`)."
67
+ out << ""
68
+
69
+ %w[identity content home].each do |section|
70
+ data = schema.fetch(section)
71
+ out << "## #{data.fetch('title')}"
72
+ out << ""
73
+ include_required = section == "identity"
74
+ out << render_keys_table(data.fetch("keys"), include_required: include_required)
75
+ out << ""
76
+ intro = data["intro"].to_s.strip
77
+ unless intro.empty?
78
+ out << intro
79
+ out << ""
80
+ end
81
+ end
82
+
83
+ tax = schema.fetch("taxonomies")
84
+ out << "## #{tax.fetch('title')}"
85
+ out << ""
86
+ out << tax.fetch("prose").to_s.strip
87
+ out << ""
88
+
89
+ pages = schema.fetch("pages")
90
+ out << "## #{pages.fetch('title')}"
91
+ out << ""
92
+ out << render_keys_table(pages.fetch("keys"), include_required: true)
93
+ out << ""
94
+ footer = pages["footer"].to_s.strip
95
+ out << footer unless footer.empty?
96
+ out << ""
97
+
98
+ home_attrs = pages.fetch("home_attributes")
99
+ out << "### #{home_attrs.fetch('title')}"
100
+ out << ""
101
+ out << render_home_attributes(home_attrs.fetch("entries"))
102
+ out << ""
103
+
104
+ "#{out.join("\n").rstrip}\n"
105
+ end
106
+
107
+ schema = YAML.safe_load(SCHEMA_PATH.read)
108
+ fail!("schema missing or empty: #{SCHEMA_PATH}") unless schema.is_a?(Hash)
109
+
110
+ text = generate(schema)
111
+
112
+ if ARGV.include?("--check")
113
+ unless DOC_PATH.file? && DOC_PATH.read == text
114
+ warn "FAIL: docs/configuration.md is stale; run scripts/generate-configuration-doc.rb"
115
+ exit 1
116
+ end
117
+ puts "OK docs/configuration.md matches schema"
118
+ exit 0
119
+ end
120
+
121
+ if ARGV.include?("--stdout")
122
+ print text
123
+ exit 0
124
+ end
125
+
126
+ DOC_PATH.write(text)
127
+ puts "Wrote #{DOC_PATH.relative_path_from(ROOT)}"
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+
6
+ # Shared editorial license metadata (_config.yml → content_license).
7
+ module ContentLicense
8
+ # No default rights_holder — sites must set content_license.rights_holder.
9
+ DEFAULTS = {
10
+ "id" => "CC-BY-4.0",
11
+ "name" => "Creative Commons Attribution 4.0 International",
12
+ "url" => "https://creativecommons.org/licenses/by/4.0/",
13
+ "page_path" => "/pages/license/"
14
+ }.freeze
15
+
16
+ SOFTWARE = {
17
+ "id" => "MIT",
18
+ "name" => "MIT License",
19
+ "url" => "https://opensource.org/licenses/MIT",
20
+ "file" => "LICENSE"
21
+ }.freeze
22
+
23
+ EDITORIAL_TYPES = %w[article post product project job resource].freeze
24
+
25
+ module_function
26
+
27
+ def load(repo_root = Pathname.new(__dir__).join("..", "..").expand_path)
28
+ config_path = repo_root.join("_config.yml")
29
+ return DEFAULTS.dup unless config_path.file?
30
+
31
+ config = YAML.safe_load(config_path.read) || {}
32
+ raw = config["content_license"]
33
+ return DEFAULTS.dup unless raw.is_a?(Hash)
34
+
35
+ DEFAULTS.merge(raw.transform_keys(&:to_s))
36
+ end
37
+
38
+ def payload(license = load)
39
+ license.slice("id", "name", "url", "rights_holder", "page_path")
40
+ end
41
+
42
+ def copyright_notice(license = load, year: Time.now.utc.year)
43
+ holder = license["rights_holder"] || DEFAULTS["rights_holder"]
44
+ "© #{year} #{holder}"
45
+ end
46
+
47
+ def editorial?(type)
48
+ EDITORIAL_TYPES.include?(type)
49
+ end
50
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "yaml"
5
+
6
+ # Shared reader for the public content store base URL.
7
+ # Prefers pandorga.content.base_url; falls back to content_api_base_url.
8
+ module ContentStorageConfig
9
+ PLACEHOLDER_MARKERS = [
10
+ "__R2_PUBLIC_BASE_URL__",
11
+ "REPLACE_WITH_R2_PUBLIC_BASE_URL",
12
+ "__S3_PUBLIC_BASE_URL__",
13
+ "REPLACE_WITH_S3_PUBLIC_BASE_URL"
14
+ ].freeze
15
+ LEGACY_GCS_HOST = "storage.googleapis.com"
16
+
17
+ module_function
18
+
19
+ def repo_root
20
+ Pathname.new(__dir__).join("..", "..").expand_path
21
+ end
22
+
23
+ def config_path
24
+ repo_root.join("_config.yml")
25
+ end
26
+
27
+ def load_config
28
+ YAML.safe_load(config_path.read, aliases: true) || {}
29
+ rescue Errno::ENOENT
30
+ {}
31
+ end
32
+
33
+ def base_url
34
+ env = ENV["S3_PUBLIC_BASE_URL"].to_s.strip
35
+ env = ENV["R2_PUBLIC_BASE_URL"].to_s.strip if env.empty?
36
+ env = ENV["CONTENT_API_BASE_URL"].to_s.strip if env.empty?
37
+ unless env.empty?
38
+ return env.chomp("/")
39
+ end
40
+
41
+ config = load_config
42
+ begin
43
+ require "pandorga/registry"
44
+ url = PandorgaRegistry.content_base_url(config)
45
+ return url unless url.empty?
46
+ rescue LoadError
47
+ # CLI gates may run without the gem on LOAD_PATH.
48
+ end
49
+
50
+ url = config["content_api_base_url"].to_s.strip
51
+ url.empty? ? nil : url.chomp("/")
52
+ end
53
+
54
+ def placeholder?(url = base_url)
55
+ return true if url.nil? || url.empty?
56
+
57
+ PLACEHOLDER_MARKERS.any? { |marker| url.include?(marker) }
58
+ end
59
+
60
+ def legacy_gcs?(url = base_url)
61
+ return false if url.nil? || url.empty?
62
+
63
+ url.include?(LEGACY_GCS_HOST)
64
+ end
65
+
66
+ def configured_for_live_checks?
67
+ url = base_url
68
+ !url.nil? && !url.empty? && !placeholder?(url) && !legacy_gcs?(url)
69
+ end
70
+ end