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,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "rbconfig"
5
+
6
+ module Pandorga
7
+ module Commands
8
+ # Delegate to the full content export pipeline shipped with the gem.
9
+ class Export
10
+ def run(argv)
11
+ site_root = Pathname.new(Dir.pwd).expand_path
12
+ script = Pathname.new(Pandorga.gem_path("scripts/content/export-content-json.rb"))
13
+ unless script.file?
14
+ warn "error: export script missing at #{script}"
15
+ return 1
16
+ end
17
+
18
+ content = argv[0] || "content"
19
+ dest = argv[1] || "_content_json"
20
+ env = ENV.to_h.merge(
21
+ "PANDORGA_SITE_ROOT" => site_root.to_s,
22
+ "RUBYOPT" => ["-I#{Pandorga.gem_path('lib')}", ENV["RUBYOPT"]].compact.join(" ")
23
+ )
24
+ cmd = [RbConfig.ruby, script.to_s, content, dest]
25
+ puts "pandorga export: #{content} → #{dest} (site #{site_root})"
26
+ system(env, *cmd, chdir: site_root.to_s) ? 0 : 1
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Pandorga
7
+ module Commands
8
+ # Option A (PLT-0.2): copy functions/ and studio/ from the gem into the site root
9
+ # so Cloudflare Pages can compile Functions from the project root.
10
+ class InstallFunctions
11
+ STAMP = ".pandorga-version"
12
+
13
+ def run(argv)
14
+ dest_root = Pathname.new(argv[0] || Dir.pwd).expand_path
15
+ gem_functions = Pathname.new(Pandorga.gem_path("functions"))
16
+ gem_studio = Pathname.new(Pandorga.gem_path("studio"))
17
+
18
+ unless gem_functions.directory?
19
+ warn "error: gem has no functions/ directory at #{gem_functions}"
20
+ return 1
21
+ end
22
+
23
+ copy_tree(gem_functions, dest_root.join("functions"))
24
+ copy_tree(gem_studio, dest_root.join("studio")) if gem_studio.directory?
25
+
26
+ stamp = dest_root.join("functions", STAMP)
27
+ stamp.write("#{Pandorga::VERSION}\n")
28
+ puts "pandorga install-functions: copied to #{dest_root} (v#{Pandorga::VERSION})"
29
+ 0
30
+ end
31
+
32
+ private
33
+
34
+ def copy_tree(src, dest)
35
+ FileUtils.rm_rf(dest) if dest.exist?
36
+ FileUtils.mkdir_p(dest.dirname)
37
+ FileUtils.cp_r(src, dest)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "optparse"
5
+ require "pathname"
6
+
7
+ module Pandorga
8
+ module Commands
9
+ # Scaffold a new site from examples/minimal.
10
+ class New
11
+ EXCLUDE = %w[
12
+ Gemfile.lock
13
+ _site
14
+ vendor
15
+ .bundle
16
+ _content_json
17
+ .jekyll-cache
18
+ ].freeze
19
+
20
+ GEMFILE = <<~RUBY
21
+ # frozen_string_literal: true
22
+
23
+ source "https://rubygems.org"
24
+
25
+ gem "pandorga", "~> 1.2"
26
+ # Local checkout: gem "pandorga", path: "../pandorga"
27
+ # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.2.1"
28
+ gem "csv"
29
+ gem "base64"
30
+ RUBY
31
+
32
+ def run(argv)
33
+ force = false
34
+ parser = OptionParser.new do |opts|
35
+ opts.banner = "Usage: pandorga new PATH [--force]"
36
+ opts.on("--force", "Overwrite an existing directory") { force = true }
37
+ opts.on("-h", "--help", "Show this help") do
38
+ puts opts
39
+ return 0
40
+ end
41
+ end
42
+ parser.parse!(argv)
43
+
44
+ raw = argv[0]
45
+ if raw.nil? || raw.strip.empty?
46
+ warn "error: PATH is required"
47
+ warn parser.banner
48
+ return 1
49
+ end
50
+
51
+ dest = Pathname.new(raw).expand_path
52
+ src = Pathname.new(Pandorga.gem_path("examples/minimal"))
53
+ unless src.directory?
54
+ warn "error: scaffold source missing at #{src}"
55
+ return 1
56
+ end
57
+
58
+ if dest.exist?
59
+ nonempty = dest.directory? && dest.children.any?
60
+ if nonempty && !force
61
+ warn "error: #{dest} is not empty (pass --force to overwrite)"
62
+ return 1
63
+ end
64
+ if force
65
+ FileUtils.rm_rf(dest)
66
+ elsif dest.file?
67
+ warn "error: #{dest} exists and is a file"
68
+ return 1
69
+ end
70
+ end
71
+
72
+ FileUtils.mkdir_p(dest)
73
+ copy_scaffold(src, dest)
74
+ dest.join("Gemfile").write(GEMFILE)
75
+
76
+ puts "Created site at #{dest}"
77
+ puts
78
+ puts "Next steps:"
79
+ puts " cd #{shell_path(dest)}"
80
+ puts " bundle install"
81
+ puts " bundle exec pandorga doctor"
82
+ puts " bundle exec pandorga serve"
83
+ 0
84
+ end
85
+
86
+ private
87
+
88
+ def copy_scaffold(src, dest)
89
+ src.each_child do |child|
90
+ name = child.basename.to_s
91
+ next if EXCLUDE.include?(name)
92
+ next if name == "Gemfile" # rewritten after copy
93
+
94
+ target = dest.join(name)
95
+ if child.directory?
96
+ FileUtils.mkdir_p(target)
97
+ copy_scaffold(child, target)
98
+ else
99
+ FileUtils.cp(child, target)
100
+ end
101
+ end
102
+ end
103
+
104
+ def shell_path(pathname)
105
+ begin
106
+ pathname.relative_path_from(Pathname.pwd).to_s
107
+ rescue ArgumentError
108
+ pathname.to_s
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "rbconfig"
5
+ require_relative "../object_store_env"
6
+
7
+ module Pandorga
8
+ module Commands
9
+ # Publish content JSON to an S3-compatible object store (R2, MinIO, AWS S3, …).
10
+ class Publish
11
+ def run(argv)
12
+ root = Pathname.new(argv[0] || Dir.pwd).expand_path
13
+ site_script = root.join("scripts", "content", "publish-content-to-r2.sh")
14
+ if site_script.file? && site_script.expand_path != gem_publish_script
15
+ Dir.chdir(root) do
16
+ exec("bash", site_script.to_s, *argv[1..])
17
+ end
18
+ end
19
+
20
+ begin
21
+ ObjectStoreEnv.require!(:endpoint, :bucket, :access_key_id, :secret_access_key)
22
+ rescue ArgumentError => e
23
+ warn "error: #{e.message}"
24
+ warn "hint: set S3_* (or legacy R2_*) variables for any S3-compatible bucket"
25
+ return 1
26
+ end
27
+
28
+ ObjectStoreEnv.apply_aws_env!
29
+
30
+ export_status = Export.new.run(
31
+ [root.join("content").to_s, root.join("_content_json").to_s]
32
+ )
33
+ return export_status if export_status != 0
34
+
35
+ script = gem_publish_script
36
+ unless script.file?
37
+ warn "error: publish script missing at #{script}"
38
+ return 1
39
+ end
40
+
41
+ env = ENV.to_h.merge("PANDORGA_SITE_ROOT" => root.to_s)
42
+ puts "pandorga publish: syncing #{root.join('_content_json')} → object store"
43
+ system(env, "bash", script.to_s, root.join("_content_json").to_s, chdir: root.to_s) ? 0 : 1
44
+ end
45
+
46
+ private
47
+
48
+ def gem_publish_script
49
+ Pathname.new(Pandorga.gem_path("scripts/content/publish-content-to-object-store.sh"))
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "rbconfig"
5
+
6
+ module Pandorga
7
+ module Commands
8
+ # Local development: export JSON, then jekyll serve with unpublished content.
9
+ class Serve
10
+ def run(argv)
11
+ root = Pathname.new(argv[0] || Dir.pwd).expand_path
12
+ Dir.chdir(root) do
13
+ export_status = Export.new.run(%w[content _content_json])
14
+ return export_status if export_status != 0
15
+
16
+ config = %w[_config.yml]
17
+ config << "_config_dev.yml" if root.join("_config_dev.yml").file?
18
+ env = ENV.to_h.merge(
19
+ "CONTENT_API_BASE_URL" => "",
20
+ "JEKYLL_ENV" => "development",
21
+ "PANDORGA_SITE_ROOT" => root.to_s
22
+ )
23
+ cmd = [
24
+ "bundle", "exec", "jekyll", "serve",
25
+ "--unpublished",
26
+ "--config", config.join(",")
27
+ ]
28
+ puts "pandorga serve: #{cmd.join(' ')}"
29
+ exec(env, *cmd)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "yaml"
5
+ require "fileutils"
6
+ require "date"
7
+
8
+ module Pandorga
9
+ module Commands
10
+ # Compose studio/schema.yml from template studio.yml files + registry.
11
+ # Refs: PLT-AC-3, docs/spec.md §5.5
12
+ class StudioSchema
13
+ DEFAULT_MEDIA = {
14
+ "name" => "media",
15
+ "label" => "Media",
16
+ "input" => "content/media",
17
+ "output" => "/media",
18
+ "categories" => %w[image video plot],
19
+ "rename" => "safe"
20
+ }.freeze
21
+
22
+ DEFAULT_MARKDOWN_BODY = {
23
+ "type" => "code",
24
+ "label" => "Body",
25
+ "description" =>
26
+ "Markdown source (code editor). Paste Liquid {% include %} markers " \
27
+ "as-is — figures, citations, embeds.",
28
+ "options" => { "format" => "markdown" }
29
+ }.freeze
30
+
31
+ def run(argv)
32
+ root = Pathname.new(argv[0] || Dir.pwd).expand_path
33
+ templates_root = Pathname.new(Pandorga.gem_path("templates"))
34
+ config_path = root.join("_config.yml")
35
+ unless config_path.file?
36
+ warn "error: no _config.yml in #{root}"
37
+ return 1
38
+ end
39
+
40
+ config = YAML.safe_load(config_path.read, permitted_classes: [Date]) || {}
41
+ schema = compose(config, templates_root)
42
+
43
+ overrides_path = root.join("studio.overrides.yml")
44
+ if overrides_path.file?
45
+ extra = YAML.safe_load(overrides_path.read, permitted_classes: [Date]) || {}
46
+ apply_overrides!(schema, extra) if extra.is_a?(Hash)
47
+ end
48
+
49
+ out = root.join("studio", "schema.yml")
50
+ FileUtils.mkdir_p(out.dirname)
51
+ out.write(YAML.dump(schema))
52
+ puts "pandorga studio-schema: wrote #{out}"
53
+ 0
54
+ end
55
+
56
+ # Public for gates: build the schema hash without writing.
57
+ def compose(config, templates_root)
58
+ pages = config.dig("pandorga", "pages") || []
59
+ taxonomies = config.dig("pandorga", "taxonomies") || {}
60
+ groups = {}
61
+ group_order = []
62
+ needs_media = false
63
+ needs_markdown = false
64
+ collection_index = {}
65
+
66
+ pages.each do |page|
67
+ next unless page.is_a?(Hash)
68
+
69
+ template = page["template"].to_s
70
+ next if template.empty?
71
+
72
+ studio_yml = Pathname.new(templates_root).join(template, "studio.yml")
73
+ next unless studio_yml.file?
74
+
75
+ fragment = YAML.safe_load(studio_yml.read, permitted_classes: [Date]) || {}
76
+ next unless fragment.is_a?(Hash)
77
+
78
+ needs_media ||= truthy?(fragment["needs_media"])
79
+ contributions = expand_fragment(fragment, page, taxonomies)
80
+ contributions.each do |contrib|
81
+ group_name = contrib[:group]
82
+ unless groups.key?(group_name)
83
+ groups[group_name] = {
84
+ "name" => group_name,
85
+ "label" => contrib[:group_label],
86
+ "type" => "group",
87
+ "items" => []
88
+ }
89
+ group_order << group_name
90
+ end
91
+
92
+ contrib[:items].each do |item|
93
+ needs_markdown ||= uses_markdown_body?(item)
94
+ groups[group_name]["items"] << item
95
+ if item["type"].to_s == "collection"
96
+ # Deep copy so YAML.dump does not emit aliases shared with content.
97
+ collection_index[item["name"].to_s] = deep_dup(item)
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ schema = {
104
+ "version" => 1,
105
+ "generated_by" => "pandorga studio-schema #{Pandorga::VERSION}",
106
+ "content" => group_order.map { |name| groups[name] },
107
+ "collections" => collection_index
108
+ }
109
+ schema["media"] = deep_dup(DEFAULT_MEDIA) if needs_media
110
+ if needs_markdown
111
+ schema["components"] = { "markdown_body" => deep_dup(DEFAULT_MARKDOWN_BODY) }
112
+ end
113
+ schema
114
+ end
115
+
116
+ private
117
+
118
+ def expand_fragment(fragment, page, taxonomies)
119
+ contributions = []
120
+ default_group = fragment["group"].to_s
121
+ default_group = page["template"].to_s if default_group.empty?
122
+ default_label = fragment["group_label"].to_s
123
+ default_label = default_group.capitalize if default_label.empty?
124
+
125
+ items = []
126
+
127
+ if fragment["collection"].is_a?(Hash)
128
+ name = page["collection"].to_s
129
+ unless name.empty?
130
+ items << instantiate_collection(
131
+ fragment["collection"],
132
+ name,
133
+ page,
134
+ taxonomies
135
+ )
136
+ end
137
+ end
138
+
139
+ if fragment["collections"].is_a?(Hash)
140
+ slots = page["collections"].is_a?(Hash) ? page["collections"] : {}
141
+ fragment["collections"].each do |slot, spec|
142
+ next unless spec.is_a?(Hash)
143
+
144
+ name = slots[slot].to_s
145
+ name = slot.to_s if name.empty?
146
+ next if name.empty? || name == "data" || name == "profile"
147
+
148
+ items << instantiate_collection(spec, name, page, taxonomies)
149
+ end
150
+ end
151
+
152
+ Array(fragment["files"]).each do |file_spec|
153
+ next unless file_spec.is_a?(Hash)
154
+
155
+ items << deep_dup(file_spec)
156
+ end
157
+
158
+ unless items.empty?
159
+ contributions << {
160
+ group: default_group,
161
+ group_label: default_label,
162
+ items: items
163
+ }
164
+ end
165
+
166
+ profile = fragment["profile"]
167
+ if profile.is_a?(Hash)
168
+ profile_items = Array(profile["files"]).select { |f| f.is_a?(Hash) }.map { |f| deep_dup(f) }
169
+ unless profile_items.empty?
170
+ contributions << {
171
+ group: profile["group"].to_s.empty? ? "profile" : profile["group"].to_s,
172
+ group_label: profile["group_label"].to_s.empty? ? "Profile" : profile["group_label"].to_s,
173
+ items: profile_items
174
+ }
175
+ end
176
+ end
177
+
178
+ contributions
179
+ end
180
+
181
+ def instantiate_collection(spec, name, page, taxonomies)
182
+ entry = deep_dup(spec)
183
+ entry["name"] = name
184
+ entry["type"] ||= "collection"
185
+ if page["collection"].to_s == name && !page["title"].to_s.empty?
186
+ entry["label"] = page["title"].to_s
187
+ elsif entry["label"].to_s.empty?
188
+ entry["label"] = name.capitalize
189
+ end
190
+ entry["path"] ||= "content/collections/#{name}"
191
+ entry["path"] = entry["path"].to_s.gsub("{collection}", name)
192
+ apply_taxonomy!(entry, name, taxonomies)
193
+ entry
194
+ end
195
+
196
+ def apply_taxonomy!(entry, collection_name, taxonomies)
197
+ tax = taxonomies[collection_name]
198
+ return unless tax.is_a?(Hash)
199
+
200
+ Array(entry["fields"]).each do |field|
201
+ next unless field.is_a?(Hash)
202
+ next unless field["name"].to_s == "tags"
203
+
204
+ field["options"] ||= {}
205
+ next unless field["options"].is_a?(Hash)
206
+
207
+ tags = tax["tags"]
208
+ field["options"]["values"] = tags if tags.is_a?(Array) && !tags.empty?
209
+ max = tax["max"]
210
+ field["options"]["max"] = max if max
211
+ end
212
+ end
213
+
214
+ def apply_overrides!(schema, extra)
215
+ if extra["media"].is_a?(Hash)
216
+ schema["media"] = deep_merge(schema["media"] || {}, extra["media"])
217
+ end
218
+
219
+ if extra["components"].is_a?(Hash)
220
+ schema["components"] = deep_merge(schema["components"] || {}, extra["components"])
221
+ end
222
+
223
+ if extra["collections"].is_a?(Hash)
224
+ extra["collections"].each do |name, patch|
225
+ next unless patch.is_a?(Hash)
226
+
227
+ item = find_content_item(schema, name.to_s)
228
+ if item
229
+ merged = deep_merge(item, patch)
230
+ item.replace(merged)
231
+ end
232
+ if schema["collections"].is_a?(Hash) && schema["collections"][name.to_s]
233
+ schema["collections"][name.to_s] =
234
+ deep_merge(schema["collections"][name.to_s], patch)
235
+ end
236
+ end
237
+ end
238
+
239
+ return unless extra["content"].is_a?(Array)
240
+
241
+ schema["content"] ||= []
242
+ extra["content"].each do |group|
243
+ next unless group.is_a?(Hash)
244
+
245
+ existing = schema["content"].find { |g| g["name"].to_s == group["name"].to_s }
246
+ if existing
247
+ existing.replace(deep_merge(existing, group))
248
+ else
249
+ schema["content"] << deep_dup(group)
250
+ end
251
+ end
252
+ end
253
+
254
+ def find_content_item(schema, name)
255
+ Array(schema["content"]).each do |group|
256
+ next unless group.is_a?(Hash)
257
+
258
+ Array(group["items"]).each do |item|
259
+ return item if item.is_a?(Hash) && item["name"].to_s == name
260
+ end
261
+ end
262
+ nil
263
+ end
264
+
265
+ def uses_markdown_body?(node)
266
+ case node
267
+ when Hash
268
+ return true if node["component"].to_s == "markdown_body"
269
+
270
+ node.any? { |_, v| uses_markdown_body?(v) }
271
+ when Array
272
+ node.any? { |v| uses_markdown_body?(v) }
273
+ else
274
+ false
275
+ end
276
+ end
277
+
278
+ def truthy?(value)
279
+ value == true || value.to_s == "true"
280
+ end
281
+
282
+ def deep_dup(obj)
283
+ case obj
284
+ when Hash
285
+ obj.transform_values { |v| deep_dup(v) }
286
+ when Array
287
+ obj.map { |v| deep_dup(v) }
288
+ else
289
+ obj
290
+ end
291
+ end
292
+
293
+ def deep_merge(left, right)
294
+ return deep_dup(right) unless left.is_a?(Hash) && right.is_a?(Hash)
295
+
296
+ result = deep_dup(left)
297
+ right.each do |key, value|
298
+ result[key] =
299
+ if result.key?(key) && result[key].is_a?(Hash) && value.is_a?(Hash)
300
+ deep_merge(result[key], value)
301
+ else
302
+ deep_dup(value)
303
+ end
304
+ end
305
+ result
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+ require_relative "../registry"
5
+
6
+ module Pandorga
7
+ module Jekyll
8
+ # Generates listing pages and nav data from pandorga.pages (PLT-AC-3).
9
+ class Generator < ::Jekyll::Generator
10
+ safe true
11
+ priority :high
12
+
13
+ # Template defaults when registry omits `detail` (English platform paths).
14
+ DEFAULT_DETAILS = {
15
+ "articles" => "/articles/:slug/",
16
+ "blog" => "/posts/:slug/",
17
+ "portfolio" => "/projects/:slug/"
18
+ }.freeze
19
+
20
+ def generate(site)
21
+ pages = PandorgaRegistry.pages(site.config)
22
+ return if pages.empty?
23
+
24
+ begin
25
+ PandorgaRegistry.validate!(pages)
26
+ rescue ArgumentError => e
27
+ raise ::Jekyll::Errors::FatalException, e.message
28
+ end
29
+
30
+ # Website shell reads site.data.nav (label/pages/key shape).
31
+ site.data["nav"] = PandorgaRegistry.build_nav(pages)
32
+ site.data["pandorga_pages"] = pages
33
+ site.data["pandorga_nav"] = site.data["nav"]
34
+ site.data["pandorga_home_bands"] = PandorgaRegistry.home_bands(pages)
35
+
36
+ existing_keys = site.pages.map { |p| p.data["key"] }.compact
37
+ pages.each do |entry|
38
+ next unless entry.is_a?(Hash)
39
+ next if existing_keys.include?(entry["key"])
40
+
41
+ emit_page(site, entry)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def emit_page(site, entry)
48
+ path = entry["path"].to_s
49
+ return if path.empty?
50
+
51
+ listing_path = path.end_with?("/") ? path : "#{path}/"
52
+ rel_dir = path.sub(%r{\A/}, "").sub(%r{/\z}, "")
53
+ page = ::Jekyll::PageWithoutAFile.new(site, site.source, rel_dir, "index.html")
54
+ page.data["layout"] = PandorgaRegistry.layout_for(entry)
55
+ page.data["key"] = entry["key"]
56
+ page.data["template"] = entry["template"] if entry["template"]
57
+ page.data["title"] = entry["title"] if entry["title"]
58
+ page.data["description"] = entry["description"] if entry["description"]
59
+ page.data["accent"] = entry["accent"] if entry["accent"]
60
+ page.data["badge"] = entry["badge"] if entry["badge"]
61
+ page.data["lang"] = entry["language"] if entry["language"]
62
+ page.data["permalink"] = listing_path
63
+ # listing_path is explicit: Jekyll reserves page.path for the source file.
64
+ page.data["listing_path"] = listing_path
65
+ page.data["collection"] = entry["collection"] if entry["collection"]
66
+ detail = entry["detail"].to_s
67
+ detail = DEFAULT_DETAILS[entry["template"].to_s].to_s if detail.empty?
68
+ page.data["detail"] = detail unless detail.empty?
69
+ page.content = ""
70
+ site.pages << page
71
+ end
72
+ end
73
+ end
74
+ end