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,741 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "json"
6
+ require "pathname"
7
+ require "date"
8
+ require "time"
9
+ require "shellwords"
10
+ require "yaml"
11
+ require "set"
12
+
13
+ require_relative "../../_plugins/git_chronology"
14
+
15
+ require_relative "../../_plugins/content_keys"
16
+ require_relative "../../_plugins/writing_slug_limits"
17
+ require_relative "../../_plugins/lib/content_validators"
18
+ require_relative "../../_plugins/content_site_adapter"
19
+ require_relative "../../_plugins/timeline"
20
+ require_relative "../lib/content_license"
21
+ require_relative "../lib/writing_index_contract"
22
+ require_relative "band_art"
23
+ require_relative "hero_portraits"
24
+
25
+ REPO_ROOT = Pathname.new(ENV.fetch("PANDORGA_SITE_ROOT", Dir.pwd)).expand_path
26
+ CONTENT_ROOT = Pathname.new(ARGV[0] || "content").expand_path
27
+ OUTPUT_ROOT = Pathname.new(ARGV[1] || "_content_json").expand_path
28
+
29
+ def load_site_config
30
+ config_path = REPO_ROOT.join("_config.yml")
31
+ return {} unless config_path.file?
32
+
33
+ YAML.safe_load(config_path.read, aliases: true) || {}
34
+ rescue StandardError
35
+ {}
36
+ end
37
+
38
+ SITE_CONFIG = load_site_config.freeze
39
+
40
+ def load_content_api_base_url
41
+ if ENV.key?("CONTENT_API_BASE_URL")
42
+ return ENV["CONTENT_API_BASE_URL"].to_s.chomp("/")
43
+ end
44
+
45
+ require "pandorga/registry"
46
+ backend = PandorgaRegistry.content_backend(SITE_CONFIG)
47
+ url = PandorgaRegistry.content_base_url(SITE_CONFIG)
48
+
49
+ # Static backend serves JSON from the same origin — empty base is correct.
50
+ return "" if backend == "static"
51
+
52
+ if url.empty?
53
+ raise "pandorga.content.base_url (or content_api_base_url) is required when " \
54
+ "pandorga.content.backend is #{backend.inspect}; set CONTENT_API_BASE_URL to override"
55
+ end
56
+
57
+ url
58
+ end
59
+
60
+ CONTENT_API_BASE_URL = load_content_api_base_url
61
+ CONTENT_LICENSE = ContentLicense.load(REPO_ROOT).freeze
62
+ SHELL_ASSET_PREFIXES = [
63
+ "/assets/images/og-default",
64
+ "/assets/images/profile"
65
+ ].freeze
66
+ MEDIA_FRONT_MATTER_KEYS = %w[thumbnail og_image].freeze
67
+
68
+ def shell_asset_path?(path)
69
+ normalized = path.to_s.strip
70
+ SHELL_ASSET_PREFIXES.any? { |prefix| normalized.start_with?(prefix) }
71
+ end
72
+
73
+ def normalize_media_path(path)
74
+ value = path.to_s.strip
75
+ return nil if value.empty?
76
+ return nil if value.match?(%r{\Ahttps?://}i)
77
+
78
+ if value.start_with?("/content/media/")
79
+ "/media/#{value.delete_prefix("/content/media/")}"
80
+ elsif value.start_with?("content/media/")
81
+ "/media/#{value.delete_prefix("content/media/")}"
82
+ elsif value.match?(%r{\A/assets/images/}) && !shell_asset_path?(value)
83
+ value.sub(%r{\A/assets/images/}, "/media/images/")
84
+ elsif value.start_with?("/media/") || value.start_with?("media/")
85
+ value.start_with?("/") ? value : "/#{value}"
86
+ end
87
+ end
88
+
89
+ def resolve_media_url(path)
90
+ value = path.to_s.strip
91
+ return value if value.empty? || value.match?(%r{\Ahttps?://}i)
92
+ return value if shell_asset_path?(value)
93
+
94
+ media_path = normalize_media_path(value)
95
+ return value unless media_path
96
+
97
+ "#{CONTENT_API_BASE_URL}#{media_path}"
98
+ end
99
+
100
+ def rewrite_media_attribute_paths(text)
101
+ text.gsub(/((?:src|poster|href)\s*=\s*["'])([^"']+)(["'])/i) do
102
+ prefix = Regexp.last_match(1)
103
+ path = Regexp.last_match(2)
104
+ suffix = Regexp.last_match(3)
105
+ "#{prefix}#{resolve_media_url(path)}#{suffix}"
106
+ end
107
+ end
108
+
109
+ def rewrite_markdown_media_paths(text)
110
+ text.gsub(/(\!\[[^\]]*\]\()([^)]+)(\))/) do
111
+ prefix = Regexp.last_match(1)
112
+ path = Regexp.last_match(2)
113
+ suffix = Regexp.last_match(3)
114
+ "#{prefix}#{resolve_media_url(path)}#{suffix}"
115
+ end
116
+ end
117
+
118
+ def rewrite_media_references(text)
119
+ return text unless text.is_a?(String) && !text.empty?
120
+
121
+ rewrite_media_attribute_paths(rewrite_markdown_media_paths(text))
122
+ end
123
+
124
+ def rewrite_front_matter_media!(front_matter)
125
+ return front_matter unless front_matter.is_a?(Hash)
126
+
127
+ MEDIA_FRONT_MATTER_KEYS.each do |key|
128
+ next unless front_matter[key].is_a?(String)
129
+
130
+ front_matter[key] = resolve_media_url(front_matter[key])
131
+ end
132
+ front_matter
133
+ end
134
+
135
+ def split_front_matter(text)
136
+ text = text.sub(/\A\xEF\xBB\xBF/, "")
137
+ return [{}, text] unless text.start_with?("---\n")
138
+
139
+ parts = text.split(/^---\s*$\n?/, 3)
140
+ return [{}, text] unless parts.length == 3
141
+
142
+ data = YAML.safe_load(parts[1], permitted_classes: [Date, Time], aliases: true) || {}
143
+ [data, parts[2]]
144
+ end
145
+
146
+ def apply_git_chronology!(front_matter, repo_relative_path, type)
147
+ GitChronology.apply!(front_matter, repo_relative_path, type)
148
+ end
149
+
150
+ def json_ready(value)
151
+ case value
152
+ when Hash
153
+ value.transform_values { |v| json_ready(v) }
154
+ when Array
155
+ value.map { |v| json_ready(v) }
156
+ when Date, Time
157
+ value.iso8601
158
+ else
159
+ value
160
+ end
161
+ end
162
+
163
+ WRITTEN_OUTPUT_FILES = []
164
+ OUTPUT_WRITE_STATS = { written: 0, unchanged: 0 }
165
+
166
+ def load_changed_paths
167
+ paths = []
168
+ paths.concat(ENV["CONTENT_EXPORT_CHANGED_PATHS"].to_s.split(/[\n,]+/))
169
+ changed_file = ENV["CONTENT_EXPORT_CHANGED_PATHS_FILE"].to_s
170
+ if !changed_file.empty? && File.file?(changed_file)
171
+ paths.concat(File.readlines(changed_file, chomp: true))
172
+ end
173
+ paths.map { |path| path.to_s.strip.sub(%r{\A\./}, "") }.reject(&:empty?).uniq
174
+ end
175
+
176
+ CHANGED_PATHS = load_changed_paths.freeze
177
+ INCREMENTAL_EXPORT = !CHANGED_PATHS.empty?
178
+
179
+ def path_affected?(*repo_relatives)
180
+ return true unless INCREMENTAL_EXPORT
181
+
182
+ CHANGED_PATHS.any? do |changed|
183
+ repo_relatives.any? do |rel|
184
+ rel = rel.to_s.sub(%r{\A\./}, "")
185
+ next false if rel.empty?
186
+
187
+ changed == rel ||
188
+ changed.start_with?("#{rel.chomp('/')}/") ||
189
+ rel.start_with?("#{changed.chomp('/')}/")
190
+ end
191
+ end
192
+ end
193
+
194
+ def existing_output_for_source(relative_source)
195
+ @existing_by_source ||= index_existing_export_by_source
196
+ @existing_by_source[relative_source]
197
+ end
198
+
199
+ def index_existing_export_by_source
200
+ index = {}
201
+ return index unless OUTPUT_ROOT.directory?
202
+
203
+ OUTPUT_ROOT.glob("**/*.json").each do |path|
204
+ next unless path.file?
205
+ next if %w[manifest.json writing_index.json timeline.json].include?(path.basename.to_s)
206
+
207
+ begin
208
+ payload = JSON.parse(path.read)
209
+ rescue JSON::ParserError
210
+ next
211
+ end
212
+ next unless payload.is_a?(Hash)
213
+
214
+ src = payload["source_path"].to_s
215
+ next if src.empty?
216
+
217
+ index[src] = path
218
+ end
219
+ index
220
+ end
221
+
222
+ def keep_existing_output(output_path)
223
+ WRITTEN_OUTPUT_FILES << output_path.expand_path
224
+ OUTPUT_WRITE_STATS[:unchanged] += 1
225
+ output_path.relative_path_from(OUTPUT_ROOT).to_s
226
+ end
227
+
228
+ def write_json(path, payload)
229
+ content = "#{JSON.pretty_generate(json_ready(payload))}\n"
230
+ FileUtils.mkdir_p(path.dirname)
231
+ WRITTEN_OUTPUT_FILES << path.expand_path
232
+
233
+ if path.file? && path.read == content
234
+ OUTPUT_WRITE_STATS[:unchanged] += 1
235
+ return :unchanged
236
+ end
237
+
238
+ path.write(content)
239
+ OUTPUT_WRITE_STATS[:written] += 1
240
+ :written
241
+ end
242
+
243
+ # Same contract as write_json for anything that is not JSON: register the path
244
+ # so the pruner keeps it, and reuse the bytes when nothing changed so the R2
245
+ # sync can skip the object.
246
+ def write_text(path, content)
247
+ FileUtils.mkdir_p(path.dirname)
248
+ WRITTEN_OUTPUT_FILES << path.expand_path
249
+
250
+ if path.file? && path.read == content
251
+ OUTPUT_WRITE_STATS[:unchanged] += 1
252
+ return :unchanged
253
+ end
254
+
255
+ path.write(content)
256
+ OUTPUT_WRITE_STATS[:written] += 1
257
+ :written
258
+ end
259
+
260
+ def prune_stale_output_files!
261
+ return unless OUTPUT_ROOT.directory?
262
+
263
+ keep = WRITTEN_OUTPUT_FILES.map(&:to_s).to_set
264
+ stale = []
265
+ OUTPUT_ROOT.find do |path|
266
+ next unless path.file?
267
+
268
+ stale << path unless keep.include?(path.expand_path.to_s)
269
+ end
270
+
271
+ stale.each(&:delete)
272
+ # Remove empty directories left beNewsreader by unpublish/rename.
273
+ OUTPUT_ROOT.find.sort.reverse_each do |path|
274
+ next unless path.directory?
275
+ next if path == OUTPUT_ROOT
276
+
277
+ path.rmdir if path.children.empty?
278
+ rescue Errno::ENOTEMPTY, Errno::ENOENT
279
+ # Concurrent or non-empty; ignore.
280
+ end
281
+
282
+ stale.length
283
+ end
284
+
285
+ def published_for_export?(front_matter)
286
+ return true if ENV["CONTENT_EXPORT_INCLUDE_UNPUBLISHED"] == "1"
287
+
288
+ front_matter["published"] != false
289
+ end
290
+
291
+ # Print-only CV copy. Private PDF builds read these from Git via Jekyll;
292
+ # they must not land in the public R2 JSON. Spec: docs/content/architecture.md.
293
+ PRINT_ONLY_PAGE_FRAGMENTS = %w[
294
+ cv/summary-extended
295
+ cv/summary-single-page
296
+ cv/ai-practice
297
+ ].freeze
298
+
299
+ # Private CV PDF sidebar only; the public CV page has no skills section.
300
+ PRINT_ONLY_DATA_FILES = %w[skills.yml].freeze
301
+
302
+ # Allowlist for public CV collection JSON (PLT-AC-7). Anything else
303
+ # (body_extended, body_single_page, contact secrets, …) never leaves the
304
+ # repository.
305
+ PUBLIC_JOB_EXPORT_FIELDS = %w[
306
+ title key company role position start end location summary skills links
307
+ department engagement products body_public published thumbnail description
308
+ author
309
+ ].freeze
310
+
311
+ PUBLIC_PRODUCT_EXPORT_FIELDS = %w[
312
+ title key product company start_date end_date external_url redirects
313
+ drop_cap layout published thumbnail description author
314
+ ].freeze
315
+
316
+ def load_writing_index_collections
317
+ require "pandorga/registry"
318
+ pages = PandorgaRegistry.pages(SITE_CONFIG)
319
+ return nil if pages.empty?
320
+
321
+ PandorgaRegistry.writing_collections(pages)
322
+ rescue StandardError
323
+ nil
324
+ end
325
+
326
+ WRITING_INDEX_COLLECTIONS_FALLBACK = {
327
+ "articles" => "article",
328
+ "posts" => "post",
329
+ "products" => "product",
330
+ "projects" => "project"
331
+ }.freeze
332
+
333
+ def public_front_matter(front_matter, type)
334
+ case type
335
+ when "job"
336
+ return front_matter.select { |key, _| PUBLIC_JOB_EXPORT_FIELDS.include?(key.to_s) }
337
+ when "product"
338
+ return front_matter.select { |key, _| PUBLIC_PRODUCT_EXPORT_FIELDS.include?(key.to_s) }
339
+ when "article", "post"
340
+ return front_matter.reject { |key, _| %w[date last_updated].include?(key.to_s) }
341
+ end
342
+
343
+ front_matter
344
+ end
345
+
346
+ def export_markdown_tree(source_dir, output_dir, type)
347
+ return [] unless source_dir.directory?
348
+
349
+ files = []
350
+ source_dir.glob("**/*.{md,markdown}").sort.each do |source|
351
+ front_matter, body = split_front_matter(source.read)
352
+ next unless published_for_export?(front_matter)
353
+
354
+ relative_source = source.relative_path_from(CONTENT_ROOT).to_s
355
+ repo_relative_path = Pathname.new("content").join(relative_source).to_s
356
+ existing = existing_output_for_source(relative_source)
357
+ if !path_affected?(repo_relative_path) && existing
358
+ files << keep_existing_output(existing)
359
+ next
360
+ end
361
+
362
+ apply_git_chronology!(front_matter, repo_relative_path, type)
363
+ rewrite_front_matter_media!(front_matter)
364
+
365
+ if ContentKeys::KEYED_TYPES.include?(type)
366
+ key = ContentKeys.normalize_key(front_matter["key"])
367
+ path_segment = ContentKeys.path_segment(type, key, date: front_matter["date"])
368
+ detail_url = ContentKeys.detail_path(type, key, date: front_matter["date"])
369
+ else
370
+ path_segment = source.basename(source.extname).to_s
371
+ detail_url = nil
372
+ end
373
+
374
+ output_path = output_dir.join("#{path_segment}.json")
375
+
376
+ payload = {
377
+ "type" => type,
378
+ "slug" => path_segment,
379
+ "key" => ContentKeys.normalize_key(front_matter["key"]),
380
+ "source_path" => relative_source,
381
+ "front_matter" => public_front_matter(front_matter, type),
382
+ "body_markdown" => rewrite_media_references(body.strip)
383
+ }
384
+ payload["url"] = detail_url if detail_url
385
+ if %w[article post].include?(type)
386
+ payload["date"] = front_matter["date"]
387
+ payload["last_updated"] = front_matter["last_updated"]
388
+ end
389
+ if ContentLicense.editorial?(type)
390
+ payload["license"] = ContentLicense.payload(CONTENT_LICENSE)
391
+ payload["copyright"] = ContentLicense.copyright_notice(CONTENT_LICENSE)
392
+ end
393
+
394
+ write_json(output_path, payload)
395
+ files << output_path.relative_path_from(OUTPUT_ROOT).to_s
396
+ end
397
+ files
398
+ end
399
+
400
+ def export_bibliography(source_dir, output_dir)
401
+ bib_dir = source_dir.join("collections/bibliography")
402
+ return [] unless bib_dir.directory?
403
+
404
+ output_path = output_dir.join("collections/bibliography/references.json")
405
+ if !path_affected?("content/collections/bibliography") && output_path.file?
406
+ return [keep_existing_output(output_path)]
407
+ end
408
+
409
+ entries = {}
410
+ bib_dir.glob("*.{yml,yaml}").sort.each do |source|
411
+ key = source.basename(".*").to_s
412
+ data = YAML.safe_load(source.read, permitted_classes: [Date, Time], aliases: true) || {}
413
+ type = data.delete("type").to_s
414
+ fields = {}
415
+ data.each do |name, value|
416
+ fields[name.to_s] = value.to_s
417
+ end
418
+
419
+ entries[key] = {
420
+ "type" => type,
421
+ "fields" => fields
422
+ }
423
+ end
424
+
425
+ write_json(output_path, entries)
426
+ [output_path.relative_path_from(OUTPUT_ROOT).to_s]
427
+ rescue StandardError => e
428
+ warn "Bibliography export failed: #{e.message}"
429
+ []
430
+ end
431
+
432
+ def export_data_tree(source_dir, output_dir)
433
+ return [] unless source_dir.directory?
434
+
435
+ files = []
436
+ source_dir.glob("**/*.{yml,yaml}").sort.each do |source|
437
+ next if PRINT_ONLY_DATA_FILES.include?(source.basename.to_s)
438
+
439
+ relative = source.relative_path_from(source_dir).to_s.sub(/\.(ya?ml)\z/, ".json")
440
+ output_path = output_dir.join(relative)
441
+ repo_relative = Pathname.new("content/collections/data").join(source.relative_path_from(source_dir)).to_s
442
+ if !path_affected?(repo_relative) && output_path.file?
443
+ files << keep_existing_output(output_path)
444
+ next
445
+ end
446
+
447
+ data = YAML.safe_load(source.read, permitted_classes: [Date, Time], aliases: true) || {}
448
+ write_json(output_path, data)
449
+ files << output_path.relative_path_from(OUTPUT_ROOT).to_s
450
+ end
451
+ files
452
+ end
453
+
454
+ def export_page_fragments(source_dir, output_dir)
455
+ return [] unless source_dir.directory?
456
+
457
+ files = []
458
+ source_dir.glob("**/*.{md,markdown}").sort.each do |source|
459
+ front_matter, body = split_front_matter(source.read)
460
+ next unless published_for_export?(front_matter)
461
+
462
+ relative_output = source.relative_path_from(source_dir).to_s.sub(/\.(md|markdown)\z/, ".json")
463
+ fragment_key = relative_output.sub(/\.json\z/, "")
464
+ next if PRINT_ONLY_PAGE_FRAGMENTS.include?(fragment_key)
465
+
466
+ relative_source = source.relative_path_from(CONTENT_ROOT).to_s
467
+ repo_relative_path = Pathname.new("content").join(relative_source).to_s
468
+ existing = existing_output_for_source(relative_source)
469
+ if !path_affected?(repo_relative_path) && existing
470
+ files << keep_existing_output(existing)
471
+ next
472
+ end
473
+
474
+ apply_git_chronology!(front_matter, repo_relative_path, "page_fragment")
475
+ rewrite_front_matter_media!(front_matter)
476
+
477
+ slug = source.basename(source.extname).to_s
478
+ output_path = output_dir.join(relative_output)
479
+
480
+ payload = {
481
+ "type" => "page_fragment",
482
+ "slug" => slug,
483
+ "source_path" => relative_source,
484
+ "front_matter" => front_matter,
485
+ "body_markdown" => rewrite_media_references(body.strip)
486
+ }
487
+
488
+ write_json(output_path, payload)
489
+ files << output_path.relative_path_from(OUTPUT_ROOT).to_s
490
+ end
491
+ files
492
+ end
493
+
494
+ # Structured YAML for the pages, exported as plain JSON objects the browser
495
+ # runtime fetches directly. `headers.yml` sits at the top of content/pages/ and
496
+ # carries every page title, tag line and home band introduction in one object
497
+ # at pages/headers.json. A page directory may also keep its own YAML beside its
498
+ # markdown fragments; that is exported to pages/<page>/<name>.json. Both shapes
499
+ # are handled so a page can add structured copy later without touching this.
500
+ def export_page_headers(source_dir, output_dir)
501
+ pages_path = source_dir.join("pages")
502
+ return [] unless pages_path.directory?
503
+
504
+ files = []
505
+ sources = pages_path.glob("*.{yml,yaml}").sort + pages_path.glob("*/*.{yml,yaml}").sort
506
+ sources.each do |source|
507
+ relative = source.relative_path_from(pages_path)
508
+ output_path = output_dir.join("pages", relative.to_s.sub(/\.(ya?ml)\z/, ".json"))
509
+ repo_relative = Pathname.new("content/pages").join(relative).to_s
510
+ if !path_affected?(repo_relative) && output_path.file?
511
+ files << keep_existing_output(output_path)
512
+ next
513
+ end
514
+
515
+ data = YAML.safe_load(source.read, permitted_classes: [Date, Time], aliases: true) || {}
516
+ write_json(output_path, data)
517
+ files << output_path.relative_path_from(OUTPUT_ROOT).to_s
518
+ end
519
+ files
520
+ end
521
+
522
+ # One object per image in content/media/images/hero/. The browser reads this
523
+ # file; there is no author-maintained list. Runs on a full export, and on an
524
+ # incremental export when the folder changed.
525
+ def export_hero_portraits(content_root, output_root)
526
+ output_path = output_root.join("pages/home/portraits.json")
527
+ affected = path_affected?("content/media/images/hero")
528
+ if !affected && output_path.file?
529
+ return keep_existing_output(output_path)
530
+ end
531
+
532
+ write_json(output_path, HeroPortraits.entries(content_root))
533
+ output_path.relative_path_from(output_root).to_s
534
+ end
535
+
536
+ def index_date(value)
537
+ case value
538
+ when Date then value.iso8601
539
+ when Time then value.to_date.iso8601
540
+ else
541
+ value.to_s
542
+ end
543
+ end
544
+
545
+ WRITING_INDEX_XCITE_RE = /\{%-?\s*include\s+xcite\.html\s+key=["']([^"']+)["'][^%]*-?%\}/
546
+ WRITING_INDEX_COLLECTIONS = load_writing_index_collections || WRITING_INDEX_COLLECTIONS_FALLBACK
547
+
548
+ def writing_index_from_export(output_root, content_root)
549
+ entries = []
550
+ WRITING_INDEX_COLLECTIONS.each do |collection, type|
551
+ output_root.glob("collections/#{collection}/*.json").sort.each do |json_path|
552
+ payload = JSON.parse(json_path.read)
553
+ next unless payload["type"].to_s == type
554
+
555
+ entry = writing_index_entry_from_export(payload, content_root, collection)
556
+ entries << entry if entry
557
+ end
558
+ end
559
+ entries
560
+ end
561
+
562
+ def writing_index_entry_from_export(payload, content_root, collection)
563
+ source_path = payload["source_path"].to_s
564
+ source = Pathname.new(content_root).join(source_path)
565
+ if source.file?
566
+ front_matter, body = split_front_matter(source.read.sub(/\A\xEF\xBB\xBF/, ""))
567
+ else
568
+ front_matter = payload["front_matter"].is_a?(Hash) ? payload["front_matter"] : {}
569
+ body = payload["body_markdown"].to_s
570
+ end
571
+ return nil if front_matter["published"] == false
572
+
573
+ key = payload["key"].to_s
574
+ key = ContentKeys.normalize_key(front_matter["key"]) if key.empty?
575
+ return nil if key.empty?
576
+
577
+ slug = payload["slug"].to_s
578
+ return nil if slug.empty?
579
+
580
+ xcites = Array(front_matter["xcites"]).map(&:to_s).reject(&:empty?)
581
+ xcites = body.scan(WRITING_INDEX_XCITE_RE).flatten.uniq if xcites.empty?
582
+ listing = TextExcerpt.listing_fields(front_matter, body)
583
+
584
+ title = front_matter["title"].to_s
585
+ title = front_matter["product"].to_s if title.empty? && front_matter["product"]
586
+ title = front_matter["project"].to_s if title.empty? && front_matter["project"]
587
+
588
+ publication_date = payload["date"] || front_matter["date"] || front_matter["start_date"]
589
+ last_updated = payload["last_updated"] || front_matter["last_updated"]
590
+ writing_type = payload["type"]
591
+ url = payload["url"].to_s
592
+ if url.empty? && ContentKeys::TYPE_CONFIG.key?(writing_type)
593
+ url = ContentKeys.detail_path(writing_type, key, date: publication_date)
594
+ end
595
+
596
+ legacy = File.basename(source_path, File.extname(source_path))
597
+ {
598
+ "slug" => slug,
599
+ "key" => key,
600
+ "title" => title,
601
+ "date" => index_date(publication_date),
602
+ "last_updated" => index_date(last_updated),
603
+ "url" => url,
604
+ "type" => collection,
605
+ "collection" => collection,
606
+ "category" => front_matter["category"].to_s,
607
+ "language" => front_matter["language"].to_s,
608
+ "description" => listing["description"],
609
+ "excerpt" => listing["excerpt"],
610
+ "thumbnail" => front_matter["thumbnail"].to_s,
611
+ "xcites" => xcites,
612
+ "company" => front_matter["company"].to_s,
613
+ "product" => front_matter["product"].to_s,
614
+ "project" => front_matter["project"].to_s,
615
+ "tagline" => front_matter["tagline"].to_s,
616
+ "start_date" => front_matter["start_date"].to_s,
617
+ "end_date" => front_matter["end_date"].to_s,
618
+ "sort_order" => front_matter["sort_order"],
619
+ "status" => front_matter["status"].to_s,
620
+ "label" => front_matter["label"].to_s,
621
+ "icon" => front_matter["icon"].to_s,
622
+ "tags" => Array(front_matter["tags"]),
623
+ "external_url" => front_matter["external_url"].to_s,
624
+ "article_url" => front_matter["article_url"].to_s,
625
+ "github" => front_matter["github"].to_s,
626
+ "aliases" => ContentKeys.alias_slugs(
627
+ "legacy_segment" => legacy,
628
+ "key" => key,
629
+ "redirects" => ContentKeys.normalize_redirects(front_matter),
630
+ "path_segment" => slug
631
+ )
632
+ }
633
+ end
634
+
635
+ def export_derived_data(content_root, output_root)
636
+ adapter = ContentSiteAdapter.new(content_root, include_writing_index: false)
637
+ timeline_path = output_root.join("data/timeline.json")
638
+ writing_index_path = output_root.join("data/writing_index.json")
639
+
640
+ # Slug/date/url come from the just-exported JSON so listings cannot point at
641
+ # a dated path that is not in the object set (08-23 index vs 08-22 file).
642
+ index = writing_index_from_export(output_root, content_root)
643
+ adapter.data["writing_index"] = index
644
+ write_json(writing_index_path, index)
645
+ WritingIndexContract.assert_slugs_match_export!(output_root, index)
646
+ write_json(timeline_path, TimelineBuilder.build(adapter))
647
+
648
+ {
649
+ "timeline" => [timeline_path.relative_path_from(output_root).to_s],
650
+ "writing_index" => [writing_index_path.relative_path_from(output_root).to_s],
651
+ "band_art" => export_band_art(output_root, index)
652
+ }
653
+ end
654
+
655
+ # The home page's reference band, drawn from the corpus this run just exported
656
+ # rather than from committed markup. See docs/features/home-band-art.md for why
657
+ # it lives here: a Studio save never rebuilds the shell, so art baked into a
658
+ # layout stops counting the day it is written.
659
+ def export_band_art(output_root, writing_index)
660
+ resources = Pathname.glob(output_root.join("collections", "resources", "*.json"))
661
+ .sort
662
+ .map do |path|
663
+ payload = JSON.parse(path.read)
664
+ (payload["front_matter"] || {}).merge("slug" => payload["slug"])
665
+ end
666
+
667
+ BandArt.build(writing_index: writing_index, resources: resources).map do |name, svg|
668
+ path = output_root.join("data", "band-art-#{name}.svg")
669
+ write_text(path, svg)
670
+ path.relative_path_from(output_root).to_s
671
+ end.sort
672
+ end
673
+
674
+ ContentValidators.validate_all!(REPO_ROOT) unless ENV["CONTENT_EXPORT_SKIP_VALIDATE"] == "1"
675
+
676
+ # Output mirrors a flat collections/ hierarchy in the bucket (articles, posts,
677
+ # products, ...) aligned with source paths under content/collections/.
678
+ # Reuse an existing OUTPUT_ROOT when present: identical payloads keep their
679
+ # bytes/mtimes. CONTENT_EXPORT_CHANGED_PATHS(_FILE) skips chronology/rebuild
680
+ # for untouched sources (still exports missing files; always rebuilds
681
+ # writing_index, timeline, and manifest). Stale files are pruned afterward.
682
+ def export_collection_trees
683
+ require "pandorga/registry"
684
+ pages = PandorgaRegistry.pages(SITE_CONFIG)
685
+ writing = pages.empty? ? WRITING_INDEX_COLLECTIONS : PandorgaRegistry.writing_collections(pages)
686
+ shell = pages.empty? ? PandorgaRegistry::DEFAULT_SHELL_COLLECTIONS : PandorgaRegistry.shell_collection_dirs(pages)
687
+
688
+ trees = {}
689
+ writing.each do |collection, type|
690
+ trees[collection] = export_markdown_tree(
691
+ CONTENT_ROOT.join("collections", collection),
692
+ OUTPUT_ROOT.join("collections", collection),
693
+ type
694
+ )
695
+ end
696
+ shell.each do |collection, relative|
697
+ next if trees.key?(collection)
698
+
699
+ type = case collection
700
+ when "jobs" then "job"
701
+ when "resources" then "resource"
702
+ else collection.sub(/s\z/, "")
703
+ end
704
+ trees[collection] = export_markdown_tree(
705
+ CONTENT_ROOT.join(relative),
706
+ OUTPUT_ROOT.join(relative),
707
+ type
708
+ )
709
+ end
710
+ trees
711
+ end
712
+
713
+ files = export_collection_trees
714
+ files["page_fragments"] = export_page_fragments(CONTENT_ROOT.join("pages"), OUTPUT_ROOT.join("pages"))
715
+ files["page_headers"] = export_page_headers(CONTENT_ROOT, OUTPUT_ROOT) << export_hero_portraits(CONTENT_ROOT, OUTPUT_ROOT)
716
+ files["data"] = export_data_tree(CONTENT_ROOT.join("collections", "data"), OUTPUT_ROOT.join("data"))
717
+ files["bibliography"] = export_bibliography(CONTENT_ROOT, OUTPUT_ROOT)
718
+ files.merge!(export_derived_data(CONTENT_ROOT, OUTPUT_ROOT))
719
+ counts = files.transform_values(&:length)
720
+
721
+ write_json(OUTPUT_ROOT.join("manifest.json"), {
722
+ "schema_version" => 1,
723
+ "generated_at" => Time.now.utc.iso8601,
724
+ "content_root" => CONTENT_ROOT.to_s,
725
+ "content_api_base_url" => CONTENT_API_BASE_URL,
726
+ "content_license" => ContentLicense.payload(CONTENT_LICENSE),
727
+ "software_license" => ContentLicense::SOFTWARE,
728
+ "counts" => counts,
729
+ "files" => files
730
+ })
731
+
732
+ pruned = prune_stale_output_files!
733
+
734
+ ContentKeys.sync_redirects_file!(REPO_ROOT) unless INCREMENTAL_EXPORT
735
+
736
+ puts "Exported content JSON to #{OUTPUT_ROOT}"
737
+ counts.each { |name, count| puts " #{name}: #{count}" }
738
+ puts " write: changed=#{OUTPUT_WRITE_STATS[:written]} unchanged=#{OUTPUT_WRITE_STATS[:unchanged]} pruned=#{pruned}"
739
+ if INCREMENTAL_EXPORT
740
+ puts " incremental paths: #{CHANGED_PATHS.length}"
741
+ end