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,115 @@
1
+ # Registry configuration schema for `pandorga:` in `_config.yml`.
2
+ # Source of truth for docs/configuration.md (PLT-AC-11).
3
+ # Validated at build time by PandorgaRegistry.validate! and related helpers.
4
+
5
+ identity:
6
+ title: "`pandorga.identity`"
7
+ keys:
8
+ name:
9
+ type: string
10
+ required: true
11
+ notes: Title suffix, default author, BibTeX
12
+ short_name:
13
+ type: string
14
+ required: false
15
+ notes: Shorter `<title>` suffix
16
+ url:
17
+ type: string
18
+ required: false
19
+ notes: Site canonical URL
20
+ locale:
21
+ type: string
22
+ required: false
23
+ notes: Default `en`
24
+
25
+ content:
26
+ title: "`pandorga.content`"
27
+ intro: |
28
+ Legacy `content_api_base_url` still works as a fallback for `base_url`.
29
+ keys:
30
+ backend:
31
+ type: "`static` \\| `r2` \\| `object_store` \\| `s3`"
32
+ notes: "`static` serves JSON from `_site`. The others use `base_url` as the public object-store origin"
33
+ base_url:
34
+ type: string
35
+ notes: Public content origin when backend is not `static`
36
+
37
+ home:
38
+ title: "`pandorga.home`"
39
+ keys:
40
+ hero_art:
41
+ type: string
42
+ notes: Include path for hero decoration (default `page/hero-default.html`)
43
+
44
+ taxonomies:
45
+ title: "`pandorga.taxonomies`"
46
+ prose: |
47
+ Closed tag vocabularies per collection (`tags`, `max`, `min`). Page-local
48
+ `pandorga.pages[].taxonomy` is also accepted when a collection has no top-level
49
+ entry.
50
+
51
+ pages:
52
+ title: "`pandorga.pages[]`"
53
+ footer: |
54
+ Duplicate `key`, unknown `template`, or conflicting `path` fails the build.
55
+ keys:
56
+ key:
57
+ type: string
58
+ required: true
59
+ notes: Unique page id
60
+ template:
61
+ type: string
62
+ required: true
63
+ notes: One of `cv`, `articles`, `blog`, `media`, `network`, `bibliography`, `portfolio`
64
+ layout:
65
+ type: string
66
+ required: false
67
+ notes: Jekyll layout override (default from template)
68
+ collection:
69
+ key_label: "collection` / `collections"
70
+ type: string / map
71
+ required: "template-dependent"
72
+ notes: Content folders
73
+ path:
74
+ type: string
75
+ required: true
76
+ notes: Listing URL
77
+ detail:
78
+ type: string
79
+ required: false
80
+ notes: Detail route pattern
81
+ nav:
82
+ type: "map \\| `false`"
83
+ required: false
84
+ notes: "`{ group, icon }` or hide"
85
+ home:
86
+ type: "map \\| `false`"
87
+ required: false
88
+ notes: Home band options; omit for template defaults
89
+ taxonomy:
90
+ type: map
91
+ required: false
92
+ notes: Tag vocabulary for the page
93
+ accent:
94
+ type: string
95
+ required: false
96
+ notes: Page accent token
97
+ language:
98
+ type: string
99
+ required: false
100
+ notes: Listing / home band language
101
+ home_attributes:
102
+ title: "`home` attributes"
103
+ entries:
104
+ - key: "`band: hero`"
105
+ notes: Page feeds the hero (CV); not a separate band
106
+ - key: "`band: index` + `group`"
107
+ notes: Joins a shared index panel group
108
+ - key: "`featured`"
109
+ notes: Lead card for writing bands
110
+ - key: "`limit`"
111
+ notes: Max items in the band
112
+ - key: "`cta`"
113
+ notes: Listing CTA label (default `View all`)
114
+ - key: "`false`"
115
+ notes: Omit from the homepage
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Page registry helpers (no Jekyll dependency — safe for the exporter CLI).
4
+ # Refs: PLT-AC-3, PLT-AC-5
5
+
6
+ module PandorgaRegistry
7
+ KNOWN_TEMPLATES = %w[
8
+ cv articles blog media network bibliography portfolio
9
+ ].freeze
10
+
11
+ # Template key → Jekyll layout name (existing top-level layouts).
12
+ TEMPLATE_LAYOUTS = {
13
+ "cv" => "cv",
14
+ "articles" => "articles",
15
+ "blog" => "blog",
16
+ "media" => "resources",
17
+ "network" => "network",
18
+ "bibliography" => "bibliography",
19
+ "portfolio" => "projects"
20
+ }.freeze
21
+
22
+ # Shell collections loaded from content/ for timeline / CV / media.
23
+ DEFAULT_SHELL_COLLECTIONS = {
24
+ "products" => "collections/products",
25
+ "projects" => "collections/projects",
26
+ "jobs" => "collections/jobs",
27
+ "resources" => "collections/resources"
28
+ }.freeze
29
+
30
+ OBJECT_STORE_BACKENDS = %w[r2 object_store s3].freeze
31
+
32
+ module_function
33
+
34
+ def pages(config)
35
+ raw = config.dig("pandorga", "pages")
36
+ return [] unless raw.is_a?(Array)
37
+
38
+ raw
39
+ end
40
+
41
+ def validate!(pages)
42
+ keys = []
43
+ paths = []
44
+ pages.each_with_index do |page, i|
45
+ raise ArgumentError, "pandorga.pages[#{i}] must be a mapping" unless page.is_a?(Hash)
46
+
47
+ key = page["key"].to_s
48
+ raise ArgumentError, "pandorga.pages[#{i}]: missing key" if key.empty?
49
+ raise ArgumentError, "pandorga.pages: duplicate key #{key.inspect}" if keys.include?(key)
50
+
51
+ keys << key
52
+
53
+ template = page["template"].to_s
54
+ raise ArgumentError, "pandorga.pages[#{key}]: missing template" if template.empty?
55
+ unless KNOWN_TEMPLATES.include?(template)
56
+ raise ArgumentError,
57
+ "pandorga.pages[#{key}]: unknown template #{template.inspect} " \
58
+ "(known: #{KNOWN_TEMPLATES.join(', ')})"
59
+ end
60
+
61
+ path = page["path"].to_s
62
+ next if path.empty?
63
+ raise ArgumentError, "pandorga.pages: conflicting path #{path.inspect}" if paths.include?(path)
64
+
65
+ paths << path
66
+ end
67
+ end
68
+
69
+ def build_nav(pages)
70
+ groups = {}
71
+ order = []
72
+ pages.each do |page|
73
+ next unless page.is_a?(Hash)
74
+ next if page["nav"] == false
75
+
76
+ nav = page["nav"].is_a?(Hash) ? page["nav"] : {}
77
+ group = nav["group"] || "Pages"
78
+ unless groups.key?(group)
79
+ groups[group] = []
80
+ order << group
81
+ end
82
+ item = { "key" => page["key"] }
83
+ item["icon"] = nav["icon"] if nav["icon"]
84
+ groups[group] << item
85
+ end
86
+ order.map { |label| { "label" => label, "pages" => groups[label] } }
87
+ end
88
+
89
+ def layout_for(page)
90
+ return page["layout"].to_s unless page["layout"].to_s.empty?
91
+
92
+ template = page["template"].to_s
93
+ TEMPLATE_LAYOUTS.fetch(template, "pandorga/#{template}")
94
+ end
95
+
96
+ def writing_collections(pages)
97
+ map = {}
98
+ pages.each do |page|
99
+ next unless page.is_a?(Hash)
100
+ next unless %w[articles blog].include?(page["template"].to_s)
101
+
102
+ collection = page["collection"].to_s
103
+ next if collection.empty?
104
+
105
+ type = case collection
106
+ when "articles" then "article"
107
+ when "posts" then "post"
108
+ else collection.sub(/s\z/, "")
109
+ end
110
+ map[collection] = type
111
+ end
112
+ # Portfolio / CV keyed entries also feed the slim writing index.
113
+ pages.each do |page|
114
+ next unless page.is_a?(Hash)
115
+
116
+ case page["template"].to_s
117
+ when "portfolio"
118
+ c = page["collection"].to_s
119
+ map[c] = "project" unless c.empty?
120
+ when "cv"
121
+ cols = page["collections"]
122
+ next unless cols.is_a?(Hash)
123
+
124
+ products = cols["products"].to_s
125
+ map[products] = "product" unless products.empty?
126
+ end
127
+ end
128
+ map["products"] ||= "product"
129
+ map["projects"] ||= "project"
130
+ map
131
+ end
132
+
133
+ # Collection folders the shell injects at build time (jobs, projects, …).
134
+ def shell_collection_dirs(pages)
135
+ dirs = {}
136
+ pages.each do |page|
137
+ next unless page.is_a?(Hash)
138
+
139
+ template = page["template"].to_s
140
+ case template
141
+ when "portfolio"
142
+ name = page["collection"].to_s
143
+ dirs[name] = "collections/#{name}" unless name.empty?
144
+ when "media"
145
+ name = page["collection"].to_s
146
+ dirs[name] = "collections/#{name}" unless name.empty?
147
+ when "cv"
148
+ cols = page["collections"]
149
+ next unless cols.is_a?(Hash)
150
+
151
+ cols.each_value do |name|
152
+ n = name.to_s
153
+ next if n.empty? || n == "data" || n == "profile"
154
+
155
+ dirs[n] = "collections/#{n}"
156
+ end
157
+ end
158
+ end
159
+ dirs.empty? ? DEFAULT_SHELL_COLLECTIONS.dup : DEFAULT_SHELL_COLLECTIONS.merge(dirs)
160
+ end
161
+
162
+ # Adapter collections used for timeline derived export.
163
+ def adapter_collection_dirs(pages)
164
+ dirs = shell_collection_dirs(pages)
165
+ dirs.select { |name, _| %w[products projects jobs].include?(name) }
166
+ end
167
+
168
+ # Default home behaviour when `home` is omitted (PLT §5.2 / §5.4).
169
+ TEMPLATE_HOME_DEFAULTS = {
170
+ "cv" => { "band" => "hero" },
171
+ "articles" => { "featured" => true, "limit" => 3 },
172
+ "blog" => { "limit" => 4 },
173
+ "portfolio" => { "limit" => 4 },
174
+ "media" => { "band" => "index", "group" => "references" },
175
+ "network" => { "band" => "index", "group" => "references" },
176
+ "bibliography" => { "band" => "index", "group" => "references" }
177
+ }.freeze
178
+
179
+ # Homepage band plan in registry order (PLT §5.4).
180
+ # Hero-only pages (`home.band: hero`) are skipped — the hero is separate.
181
+ def home_bands(pages)
182
+ bands = []
183
+ pending_index = nil
184
+
185
+ flush_index = lambda do
186
+ return unless pending_index
187
+
188
+ bands << pending_index
189
+ pending_index = nil
190
+ end
191
+
192
+ pages.each do |page|
193
+ next unless page.is_a?(Hash)
194
+ next if page["home"] == false
195
+
196
+ home = resolve_home(page)
197
+ next if home["band"].to_s == "hero"
198
+
199
+ if home["band"].to_s == "index" || !home["group"].to_s.empty?
200
+ group = home["group"].to_s
201
+ group = "index" if group.empty?
202
+ if pending_index.nil? || pending_index["group"] != group
203
+ flush_index.call
204
+ pending_index = {
205
+ "kind" => "index",
206
+ "group" => group,
207
+ "section_key" => group == "references" ? "index" : group,
208
+ "pages" => []
209
+ }
210
+ end
211
+ pending_index["pages"] << page_band_meta(page, home)
212
+ next
213
+ end
214
+
215
+ flush_index.call
216
+ template = page["template"].to_s
217
+ bands << page_band_meta(page, home).merge(
218
+ "kind" => template,
219
+ "section_key" => page["key"].to_s
220
+ )
221
+ end
222
+ flush_index.call
223
+ apply_contrast!(bands)
224
+ end
225
+
226
+ def resolve_home(page)
227
+ defaults = TEMPLATE_HOME_DEFAULTS[page["template"].to_s] || {}
228
+ return defaults.dup unless page["home"].is_a?(Hash)
229
+
230
+ defaults.merge(page["home"])
231
+ end
232
+ private_class_method :resolve_home
233
+
234
+ def page_band_meta(page, home)
235
+ {
236
+ "key" => page["key"].to_s,
237
+ "template" => page["template"].to_s,
238
+ "path" => page["path"].to_s,
239
+ "title" => page["title"].to_s,
240
+ "collection" => page["collection"].to_s,
241
+ "language" => page["language"].to_s,
242
+ "accent" => page["accent"].to_s,
243
+ "home" => home
244
+ }
245
+ end
246
+ private_class_method :page_band_meta
247
+
248
+ def apply_contrast!(bands)
249
+ bands.each_with_index do |band, i|
250
+ # Odd listing bands get contrast; index bands always contrast.
251
+ band["contrast"] = band["kind"] == "index" || i.odd?
252
+ end
253
+ bands
254
+ end
255
+ private_class_method :apply_contrast!
256
+
257
+ def content_backend(config)
258
+ raw = config.dig("pandorga", "content", "backend").to_s.strip
259
+ return "static" if raw.empty?
260
+
261
+ raw
262
+ end
263
+
264
+ def content_base_url(config)
265
+ pandorga_url = config.dig("pandorga", "content", "base_url").to_s.strip
266
+ return pandorga_url.chomp("/") unless pandorga_url.empty?
267
+
268
+ legacy = config["content_api_base_url"].to_s.strip
269
+ legacy.empty? ? "" : legacy.chomp("/")
270
+ end
271
+
272
+ def object_store_backend?(config)
273
+ OBJECT_STORE_BACKENDS.include?(content_backend(config))
274
+ end
275
+
276
+ def static_backend?(config)
277
+ content_backend(config) == "static"
278
+ end
279
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pandorga
4
+ VERSION = "1.2.1"
5
+ end
data/lib/pandorga.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pandorga/version"
4
+
5
+ # Pandorga — Jekyll theme, plugin, and CLI for a static shell with JSON content.
6
+ module Pandorga
7
+ class Error < StandardError; end
8
+
9
+ ROOT = File.expand_path("..", __dir__)
10
+
11
+ def self.gem_path(*parts)
12
+ File.join(ROOT, *parts)
13
+ end
14
+ end
15
+
16
+ require_relative "pandorga/registry"
17
+
18
+ # Jekyll loads this file as a plugin when `plugins: [pandorga]`.
19
+ require_relative "pandorga/jekyll" if defined?(Jekyll)
@@ -0,0 +1,19 @@
1
+ {
2
+ "allowed_urls": [
3
+ "https://cdn.jsdelivr.net/npm/marked@15.0.7/marked.min.js",
4
+ "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs",
5
+ "https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-mml-chtml.js",
6
+ "https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js",
7
+ "https://cdn.jsdelivr.net/npm/plotly.js-dist-min@3.1.0/plotly.min.js",
8
+ "https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.2.3/css/flag-icons.min.css",
9
+ "https://cdn.tailwindcss.com?plugins=forms,container-queries"
10
+ ],
11
+ "scan_globs": [
12
+ "_layouts",
13
+ "_includes",
14
+ "content/media/plots"
15
+ ],
16
+ "exceptions": {
17
+ "tailwind_play_cdn": "Tailwind play CDN cannot be SRI-pinned; URL must match exactly."
18
+ }
19
+ }
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "pathname"
5
+ require "yaml"
6
+
7
+ require_relative "../../_plugins/content_keys"
8
+ require_relative "../../_plugins/content_detail_paths"
9
+ require_relative "../../_plugins/git_chronology"
10
+
11
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
12
+ CONTENT_ROOT = REPO_ROOT.join("content")
13
+
14
+ KEY_OVERRIDES = {
15
+ "content/collections/posts/2026-03-06-horse-auto-follow.md" => "horse-auto-follow-pt",
16
+ "content/collections/posts/2026-03-02-mcmc.md" => "mcmc-pt"
17
+ }.freeze
18
+
19
+ def split_front_matter(text)
20
+ ContentKeys.split_front_matter(text)
21
+ end
22
+
23
+ def write_front_matter(path, front_matter, body)
24
+ yaml = front_matter.to_yaml.sub(/\A---\n/, "").strip
25
+ path.write("---\n#{yaml}\n---\n\n#{body}")
26
+ end
27
+
28
+ def infer_key(entry)
29
+ override = KEY_OVERRIDES[entry["repo_relative_path"]]
30
+ return override if override
31
+
32
+ key = ContentKeys.normalize_key(entry["key"])
33
+ return key unless key.empty?
34
+
35
+ ContentKeys.infer_key_from_filename(entry["path"], entry["type"])
36
+ end
37
+
38
+ def legacy_detail_path(type, legacy_segment)
39
+ case type
40
+ when "article" then ContentDetailPaths.article_detail_path(legacy_segment)
41
+ when "post" then ContentDetailPaths.post_detail_path(legacy_segment)
42
+ when "project" then ContentDetailPaths.project_detail_path(legacy_segment)
43
+ when "product" then ContentDetailPaths.product_detail_path(legacy_segment)
44
+ else
45
+ raise "unknown type #{type}"
46
+ end
47
+ end
48
+
49
+ def migrate_redirect_fields!(front_matter)
50
+ redirects = ContentKeys.normalize_redirects(front_matter)
51
+ front_matter.delete("redirect_from")
52
+ front_matter["redirects"] = redirects unless redirects.empty?
53
+ front_matter
54
+ end
55
+
56
+ def ensure_legacy_redirect!(front_matter, type, legacy_segment, canonical_path)
57
+ redirects = ContentKeys.normalize_redirects(front_matter)
58
+ legacy = legacy_detail_path(type, legacy_segment).chomp("/")
59
+ canonical = canonical_path.chomp("/")
60
+ redirects << legacy unless legacy == canonical
61
+ redirects = redirects.map { |path| ContentKeys.normalize_redirect_path(path) }.compact.uniq
62
+ front_matter["redirects"] = redirects unless redirects.empty?
63
+ front_matter.delete("redirects") if redirects.empty?
64
+ front_matter
65
+ end
66
+
67
+ updated = 0
68
+ ContentKeys::CONTENT_PATHS.each do |type, relative_dir|
69
+ directory = CONTENT_ROOT.join(relative_dir.sub(%r{\Acontent/}, ""))
70
+ next unless directory.directory?
71
+
72
+ Dir.glob(directory.join("*.{md,markdown}")).sort.each do |absolute_path|
73
+ entry = ContentKeys.load_entry(absolute_path, type, repo_root: REPO_ROOT)
74
+ text = File.read(absolute_path).sub(/\A\xEF\xBB\xBF/, "")
75
+ front_matter, body = split_front_matter(text)
76
+ original_yaml = front_matter.to_yaml
77
+
78
+ key = infer_key(entry)
79
+ front_matter["key"] = key
80
+ migrate_redirect_fields!(front_matter)
81
+
82
+ if %w[article post].include?(type)
83
+ GitChronology.apply!(front_matter, entry["repo_relative_path"], type)
84
+ end
85
+
86
+ canonical = ContentKeys.detail_path(type, key, date: front_matter["date"])
87
+ ensure_legacy_redirect!(front_matter, type, entry["legacy_segment"], canonical)
88
+
89
+ next if front_matter.to_yaml == original_yaml
90
+
91
+ write_front_matter(Pathname(absolute_path), front_matter, body.sub(/\A\n+/, ""))
92
+ updated += 1
93
+ puts "updated #{entry['repo_relative_path']} (key=#{key})"
94
+ end
95
+ end
96
+
97
+ ContentKeys.validate!(REPO_ROOT)
98
+ ContentKeys.sync_redirects_file!(REPO_ROOT)
99
+ puts "Backfill complete (#{updated} file(s) updated). Redirects synced to _redirects."