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,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "yaml"
5
+
6
+ # Generates a build-time references.bib artifact in a temporary build location
7
+ # for jekyll-scholar if needed, compiled from YAML entries in content/collections/bibliography/.
8
+ # Editors edit YAML in content/collections/bibliography/ (one file per citation key);
9
+ # BibTeX is never edited by hand in content/.
10
+ module Jekyll
11
+ class BibliographyArtifactGenerator < Generator
12
+ safe true
13
+ priority :highest
14
+
15
+ def generate(site)
16
+ source_dir = Pathname.new(site.source).join("content/collections/bibliography")
17
+ return unless source_dir.directory?
18
+
19
+ artifact_dir = Pathname.new(site.source).join(".jekyll-cache")
20
+ artifact_dir.mkpath
21
+ artifact_file = artifact_dir.join("generated_references.bib")
22
+
23
+ # Decouple blocking I/O from CPU-intensive YAML parsing
24
+ # Pre-read all file contents into memory sequentially
25
+ files = source_dir.glob("*.{yml,yaml}").sort
26
+ file_contents = files.map do |file|
27
+ [file.basename(".*").to_s, file, file.read]
28
+ end
29
+
30
+ bib_entries = []
31
+ file_contents.each do |key, file, content|
32
+ begin
33
+ data = YAML.safe_load(content, permitted_classes: [Date, Time], aliases: true) || {}
34
+ rescue StandardError => e
35
+ Jekyll.logger.warn "BibliographyArtifactGenerator:", "Failed to load #{file}: #{e.message}"
36
+ next
37
+ end
38
+
39
+ type = data.delete("type") || "misc"
40
+ fields = []
41
+ data.each do |k, v|
42
+ next if v.nil? || v.to_s.empty?
43
+ fields << " #{k} = {#{v}}"
44
+ end
45
+
46
+ bib_entries << "@#{type}{#{key},\n#{fields.join(",\n")}\n}"
47
+ end
48
+
49
+ artifact_file.write(bib_entries.join("\n\n"))
50
+
51
+ if site.config["scholar"]
52
+ site.config["scholar"]["source"] = ".jekyll-cache"
53
+ site.config["scholar"]["bibliography"] = "generated_references.bib"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+
5
+ # Maps pandorga.content.backend / base_url onto the legacy content_api_base_url
6
+ # and local_content_serve flags the runtime and exporters already understand.
7
+ # Refs: PLT §5.2, PLT-AC-6
8
+ module Jekyll
9
+ module PandorgaContentBackend
10
+ module_function
11
+
12
+ def apply!(site)
13
+ require "pandorga/registry"
14
+
15
+ config = site.config
16
+ backend = PandorgaRegistry.content_backend(config)
17
+ base = PandorgaRegistry.content_base_url(config)
18
+
19
+ config["pandorga"] ||= {}
20
+ config["pandorga"]["content"] ||= {}
21
+ config["pandorga"]["content"]["backend"] = backend
22
+
23
+ if PandorgaRegistry.static_backend?(config)
24
+ config["content_api_base_url"] = ""
25
+ # Serve exported JSON from _site unless the site opts out explicitly.
26
+ config["local_content_serve"] = true unless config.key?("local_content_serve")
27
+ else
28
+ config["content_api_base_url"] = base
29
+ config["pandorga"]["content"]["base_url"] = base
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ Jekyll::Hooks.register :site, :after_init do |site|
36
+ Jekyll::PandorgaContentBackend.apply!(site)
37
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "set"
5
+ require "pandorga/registry"
6
+
7
+ # Loads editorial Markdown from /content into Jekyll collections at build time.
8
+ # This keeps /content excluded from the default Jekyll read path while still
9
+ # providing collection docs for product/project pages, timeline, network graph,
10
+ # CV jobs, and related plugins. Articles and posts stay API-backed; their metadata
11
+ # is indexed separately by ContentWritingIndex.
12
+ module Jekyll
13
+ class ContentCollections < Generator
14
+ safe true
15
+ priority :highest
16
+
17
+ COLLECTION_PATHS = PandorgaRegistry::DEFAULT_SHELL_COLLECTIONS
18
+
19
+ def generate(site)
20
+ return if site.config["shell_content_agnostic"]
21
+
22
+ content_root = File.join(site.source, site.config["content_root"] || "content")
23
+ return unless File.directory?(content_root)
24
+
25
+ pages = PandorgaRegistry.pages(site.config)
26
+ paths = pages.empty? ? COLLECTION_PATHS : PandorgaRegistry.shell_collection_dirs(pages)
27
+
28
+ paths.each do |collection_name, relative_path|
29
+ inject_documents(site, collection_name, File.join(content_root, relative_path))
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def inject_documents(site, collection_name, directory)
36
+ collection = site.collections[collection_name]
37
+ return unless collection && File.directory?(directory)
38
+
39
+ existing_paths = collection.docs.map(&:path).to_set
40
+
41
+ Dir.glob(File.join(directory, "**", "*.{md,markdown}")).sort.each do |absolute_path|
42
+ relative_path = Pathname.new(absolute_path).relative_path_from(Pathname.new(site.source)).to_s
43
+ next if existing_paths.include?(relative_path)
44
+
45
+ doc = Jekyll::Document.new(relative_path, site: site, collection: collection)
46
+ doc.read
47
+ collection.docs << doc
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared detail URL rewrite rules for Cloudflare _redirects and local jekyll serve.
4
+ module ContentDetailPaths
5
+ POST_SHELL = "/pages/blog/"
6
+ ARTICLE_SHELL = "/pages/articles/"
7
+ PRODUCT_SHELL = "/pages/cv/"
8
+ PROJECT_SHELL = "/pages/projects/"
9
+
10
+ POST_DETAIL_RE = %r{\A/posts/[^./][^/]*/?\z}
11
+ BLOG_DETAIL_RE = %r{\A/blog/[^./][^/]*/?\z}
12
+ ARTICLE_DETAIL_RE = %r{\A/articles/[^./][^/]*/?\z}
13
+ PRODUCT_DETAIL_RE = %r{\A/products/[^./][^/]*/?\z}
14
+ PROJECT_DETAIL_RE = %r{\A/projects/[^./][^/]*/?\z}
15
+
16
+ REWRITES = [
17
+ [POST_DETAIL_RE, POST_SHELL],
18
+ [BLOG_DETAIL_RE, POST_SHELL],
19
+ [ARTICLE_DETAIL_RE, ARTICLE_SHELL],
20
+ [PRODUCT_DETAIL_RE, PRODUCT_SHELL],
21
+ [PROJECT_DETAIL_RE, PROJECT_SHELL]
22
+ ].freeze
23
+
24
+ RESERVED = [
25
+ POST_SHELL,
26
+ ARTICLE_SHELL,
27
+ PRODUCT_SHELL,
28
+ PROJECT_SHELL
29
+ ].freeze
30
+
31
+ module_function
32
+
33
+ def rewrite_path(path)
34
+ normalized = path.end_with?("/") ? path : "#{path}/"
35
+ return nil if RESERVED.include?(normalized)
36
+
37
+ REWRITES.each do |pattern, target|
38
+ return target if pattern.match?(path) || pattern.match?(normalized)
39
+ end
40
+ nil
41
+ end
42
+
43
+ def post_detail_path(slug)
44
+ "/posts/#{slug}/"
45
+ end
46
+
47
+ def article_detail_path(slug)
48
+ "/articles/#{slug}/"
49
+ end
50
+
51
+ def product_detail_path(slug)
52
+ "/products/#{slug}/"
53
+ end
54
+
55
+ def project_detail_path(slug)
56
+ "/projects/#{slug}/"
57
+ end
58
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loads editorial files from /content into site.data without making /content part
4
+ # of the Jekyll render graph. The site must be able to build even when the
5
+ # content directory is absent; external storage becomes the publishing path.
6
+ #
7
+ # Example: content/pages/blog/sidebar-intro.md becomes
8
+ # site.data["content_fragments"]["blog/sidebar-intro"]
9
+ # Example: content/collections/data/education.yml becomes
10
+ # site.data["education"]
11
+
12
+ require "yaml"
13
+ require "pathname"
14
+ require "date"
15
+
16
+ module Jekyll
17
+ class ContentLayerGenerator < Generator
18
+ safe true
19
+ priority :highest
20
+
21
+ def generate(site)
22
+ return if site.config["shell_content_agnostic"]
23
+
24
+ content_path = File.join(site.source, site.config["content_root"] || "content")
25
+ site.data["content_fragments"] ||= {}
26
+
27
+ load_page_content(site, content_path)
28
+ load_structured_data(site, content_path)
29
+ end
30
+
31
+ private
32
+
33
+ def load_page_content(site, content_path)
34
+ pages_path = File.join(content_path, "pages")
35
+ return unless Dir.exist?(pages_path)
36
+
37
+ Dir.glob(File.join(pages_path, "**", "*.md")).sort.each do |file|
38
+ key = relative_key(file, pages_path)
39
+ site.data["content_fragments"][key] = File.read(file)
40
+ end
41
+ end
42
+
43
+ def load_structured_data(site, content_path)
44
+ data_path = File.join(content_path, "collections", "data")
45
+ return unless Dir.exist?(data_path)
46
+
47
+ Dir.glob(File.join(data_path, "**", "*.{yml,yaml}")).sort.each do |file|
48
+ parts = relative_key(file, data_path).split("/")
49
+ write_nested_data(site.data, parts, load_yaml(file))
50
+ end
51
+ end
52
+
53
+ def write_nested_data(target, parts, value)
54
+ key = parts.first
55
+ if parts.length == 1
56
+ target[key] = value
57
+ return
58
+ end
59
+
60
+ target[key] ||= {}
61
+ write_nested_data(target[key], parts.drop(1), value)
62
+ end
63
+
64
+ def relative_key(file, base)
65
+ pathname = Pathname.new(file).relative_path_from(Pathname.new(base)).to_s
66
+ pathname.sub(/\.(md|ya?ml)\z/, "")
67
+ end
68
+
69
+ def load_yaml(file)
70
+ YAML.safe_load_file(file, permitted_classes: [Date, Time], aliases: true) || {}
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "pathname"
5
+ require "yaml"
6
+
7
+ require_relative "git_chronology"
8
+ require_relative "content_detail_paths"
9
+ require_relative "content_writing_paths"
10
+
11
+ # Canonical content identity and public detail URLs for articles, posts,
12
+ # projects, and products. The front-matter `key` is the stable cross-reference
13
+ # identifier; public path segments are derived from `key` (and Git `date` for
14
+ # dated writing).
15
+ module ContentKeys
16
+ KEYED_TYPES = %w[article post project product].freeze
17
+
18
+ TYPE_CONFIG = {
19
+ "article" => { dated: true, detail: :article_detail_path },
20
+ "post" => { dated: true, detail: :post_detail_path },
21
+ "project" => { dated: false, detail: :project_detail_path },
22
+ "product" => { dated: false, detail: :product_detail_path }
23
+ }.freeze
24
+
25
+ CONTENT_PATHS = ContentWritingPaths::BY_TYPE.transform_values { |rel| File.join(ContentWritingPaths::CONTENT_ROOT_SEGMENT, rel) }.merge(
26
+ "project" => "content/collections/projects",
27
+ "product" => "content/collections/products"
28
+ ).freeze
29
+
30
+ REDIRECTS_BEGIN = "# BEGIN content-key redirects (generated — do not edit)"
31
+ REDIRECTS_END = "# END content-key redirects (generated — do not edit)"
32
+
33
+ # Cloudflare Pages first-match wins. Specific 301s must sit above these
34
+ # SPA catch-alls or old slugs are rewritten with HTTP 200 and never redirect.
35
+ CATCHALL_200_PREFIXES = %w[/articles/:slug /posts/:slug /blog/:slug].freeze
36
+
37
+ class ValidationError < StandardError; end
38
+
39
+ module_function
40
+
41
+ def normalize_key(value)
42
+ value.to_s.strip
43
+ end
44
+
45
+ def normalize_date(value)
46
+ case value
47
+ when Date then value.iso8601
48
+ when Time then value.to_date.iso8601
49
+ when String
50
+ value.strip[0, 10]
51
+ else
52
+ value&.to_s&.strip&.[](0, 10)
53
+ end
54
+ end
55
+
56
+ def filename_stem(path)
57
+ File.basename(path.to_s, File.extname(path.to_s))
58
+ end
59
+
60
+ def infer_key_from_filename(path, type)
61
+ stem = filename_stem(path)
62
+ config = TYPE_CONFIG.fetch(type)
63
+ config[:dated] ? stem.sub(/\A\d{4}-\d{2}-\d{2}-/, "") : stem
64
+ end
65
+
66
+ def path_segment(type, key, date: nil)
67
+ normalized_key = normalize_key(key)
68
+ config = TYPE_CONFIG.fetch(type)
69
+ return normalized_key unless config[:dated]
70
+
71
+ day = normalize_date(date)
72
+ raise ValidationError, "dated content requires a publication date (key=#{normalized_key})" if day.nil? || day.empty?
73
+
74
+ "#{day}-#{normalized_key}"
75
+ end
76
+
77
+ def detail_path(type, key, date: nil)
78
+ segment = path_segment(type, key, date: date)
79
+ ContentDetailPaths.public_send(TYPE_CONFIG.fetch(type).fetch(:detail), segment)
80
+ end
81
+
82
+ def split_front_matter(text)
83
+ text = text.to_s.sub(/\A\xEF\xBB\xBF/, "")
84
+ return [{}, text] unless text.start_with?("---\n")
85
+
86
+ parts = text.split(/^---\s*$\n?/, 3)
87
+ return [{}, text] unless parts.length == 3
88
+
89
+ data = YAML.safe_load(parts[1], permitted_classes: [Date, Time], aliases: true) || {}
90
+ [data, parts[2]]
91
+ end
92
+
93
+ def load_entry(absolute_path, type, repo_root: Pathname.pwd)
94
+ text = File.read(absolute_path).sub(/\A\xEF\xBB\xBF/, "")
95
+ front_matter, = split_front_matter(text)
96
+ relative_source = Pathname.new(absolute_path).relative_path_from(repo_root.join("content")).to_s
97
+ repo_relative_path = Pathname.new("content").join(relative_source).to_s
98
+
99
+ if KEYED_TYPES.include?(type)
100
+ GitChronology.apply!(front_matter, repo_relative_path, type) if %w[article post].include?(type)
101
+ end
102
+
103
+ key = normalize_key(front_matter["key"])
104
+ date = front_matter["date"]
105
+ legacy_segment = filename_stem(absolute_path)
106
+
107
+ {
108
+ "type" => type,
109
+ "path" => absolute_path.to_s,
110
+ "repo_relative_path" => repo_relative_path,
111
+ "key" => key,
112
+ "date" => date,
113
+ "legacy_segment" => legacy_segment,
114
+ "path_segment" => key.empty? ? nil : path_segment(type, key, date: date),
115
+ "detail_path" => key.empty? ? nil : detail_path(type, key, date: date),
116
+ "redirects" => normalize_redirects(front_matter),
117
+ "published" => front_matter["published"] != false
118
+ }
119
+ end
120
+
121
+ def collect_entries(repo_root = Pathname.pwd)
122
+ entries = []
123
+ CONTENT_PATHS.each do |type, relative_dir|
124
+ directory = repo_root.join(relative_dir)
125
+ next unless directory.directory?
126
+
127
+ Dir.glob(directory.join("*.{md,markdown}")).sort.each do |absolute_path|
128
+ entries << load_entry(absolute_path, type, repo_root: repo_root)
129
+ end
130
+ end
131
+ entries
132
+ end
133
+
134
+ def published_entries(repo_root = Pathname.pwd)
135
+ collect_entries(repo_root).select { |entry| entry["published"] }
136
+ end
137
+
138
+ def validate!(repo_root = Pathname.pwd)
139
+ errors = []
140
+ published = published_entries(repo_root)
141
+ by_key = Hash.new { |hash, key| hash[key] = [] }
142
+
143
+ published.each do |entry|
144
+ key = entry["key"]
145
+ label = "#{entry["type"]} #{entry["repo_relative_path"]}"
146
+
147
+ if key.empty?
148
+ errors << "#{label}: missing required non-empty `key`"
149
+ next
150
+ end
151
+
152
+ begin
153
+ entry["path_segment"] = path_segment(entry["type"], key, date: entry["date"])
154
+ entry["detail_path"] = detail_path(entry["type"], key, date: entry["date"])
155
+ rescue ValidationError => e
156
+ errors << "#{label}: #{e.message}"
157
+ next
158
+ end
159
+
160
+ by_key[key] << label
161
+ end
162
+
163
+ by_key.each do |key, labels|
164
+ next if labels.length < 2
165
+
166
+ errors << "duplicate key #{key.inspect} used by: #{labels.join(', ')}"
167
+ end
168
+
169
+ return if errors.empty?
170
+
171
+ errors.each { |message| warn "ContentKeys: #{message}" }
172
+ raise ValidationError, "Content key validation failed with #{errors.length} error(s)"
173
+ end
174
+
175
+ def normalize_redirects(front_matter)
176
+ values = []
177
+ %w[redirects redirect_from].each do |field|
178
+ raw = front_matter[field]
179
+ case raw
180
+ when Array
181
+ values.concat(raw)
182
+ when String
183
+ values << raw unless raw.strip.empty?
184
+ end
185
+ end
186
+ values.map { |value| normalize_redirect_path(value) }.compact.uniq
187
+ end
188
+
189
+ def normalize_redirect_path(path)
190
+ value = path.to_s.strip
191
+ return nil if value.empty?
192
+
193
+ value = "/#{value}" unless value.start_with?("/")
194
+ value = value.chomp("/")
195
+ value
196
+ end
197
+
198
+ def redirect_rules(entries = nil)
199
+ entries ||= published_entries
200
+ rules = []
201
+
202
+ entries.each do |entry|
203
+ next if entry["detail_path"].nil?
204
+
205
+ canonical = entry["detail_path"].chomp("/")
206
+ entry["redirects"].each do |source|
207
+ next if source.nil? || source.empty?
208
+ next if source == canonical
209
+
210
+ rules << [source, "#{canonical}/", 301]
211
+ rules << ["#{source}/", "#{canonical}/", 301] unless source.end_with?("/")
212
+ end
213
+ end
214
+
215
+ rules.uniq
216
+ end
217
+
218
+ def alias_slugs(entry)
219
+ slugs = []
220
+ slugs << entry["legacy_segment"].to_s
221
+ slugs << entry["key"].to_s
222
+ slugs.concat(redirect_path_slugs(entry["redirects"]))
223
+ slugs.concat(compact_dated_slugs(slugs))
224
+
225
+ canonical = entry["path_segment"].to_s
226
+ slugs.map { |slug| slug.to_s.strip }.reject(&:empty?).uniq.reject { |slug| slug == canonical }
227
+ end
228
+
229
+ def catchall_200_line?(line)
230
+ stripped = line.to_s.strip
231
+ CATCHALL_200_PREFIXES.any? do |prefix|
232
+ stripped.start_with?("#{prefix} ") || stripped.start_with?("#{prefix}/ ")
233
+ end
234
+ end
235
+
236
+ def generated_redirects_precede_catchalls?(text)
237
+ begin_at = text.index(REDIRECTS_BEGIN)
238
+ catchall_at = text.lines.find_index { |line| catchall_200_line?(line) }
239
+ return false if begin_at.nil? || catchall_at.nil?
240
+
241
+ begin_line = text.lines.find_index { |line| line.include?(REDIRECTS_BEGIN) }
242
+ !begin_line.nil? && begin_line < catchall_at
243
+ end
244
+
245
+ def sync_redirects_file!(repo_root = Pathname.pwd, redirects_path: repo_root.join("_redirects"))
246
+ rules = redirect_rules(published_entries(repo_root))
247
+ body = rules.map { |source, target, code| "#{source} #{target} #{code}" }.join("\n")
248
+ block = [REDIRECTS_BEGIN, body, REDIRECTS_END].join("\n").strip
249
+
250
+ original = redirects_path.file? ? redirects_path.read : ""
251
+ stripped = if original.include?(REDIRECTS_BEGIN) && original.include?(REDIRECTS_END)
252
+ original.sub(/#{Regexp.escape(REDIRECTS_BEGIN)}.*?#{Regexp.escape(REDIRECTS_END)}\n*/m, "")
253
+ else
254
+ original
255
+ end
256
+
257
+ updated = insert_generated_redirects(stripped, block)
258
+ redirects_path.write(updated)
259
+ end
260
+
261
+ def insert_generated_redirects(text, block)
262
+ lines = text.to_s.lines
263
+ insert_at = catchall_section_start(lines)
264
+ block_text = "#{block.to_s.rstrip}\n"
265
+
266
+ if insert_at
267
+ before = lines[0...insert_at]
268
+ after = lines[insert_at..]
269
+ before.pop while before.last && before.last.strip.empty?
270
+ before_text = before.join
271
+ before_text += "\n" if !before_text.empty? && !before_text.end_with?("\n")
272
+ before_text += "\n" unless before_text.empty?
273
+ remainder = after.join.sub(/\A\n+/, "")
274
+ "#{before_text}#{block_text}\n#{remainder}"
275
+ else
276
+ updated = text.to_s.rstrip
277
+ updated.empty? ? block_text : "#{updated}\n\n#{block_text}"
278
+ end
279
+ end
280
+
281
+ def catchall_section_start(lines)
282
+ insert_at = lines.find_index { |line| catchall_200_line?(line) }
283
+ return nil unless insert_at
284
+
285
+ idx = insert_at
286
+ while idx > 0
287
+ previous = lines[idx - 1].to_s.strip
288
+ break unless previous.empty? || previous.start_with?("#")
289
+
290
+ idx -= 1
291
+ end
292
+ idx
293
+ end
294
+
295
+ def redirect_path_slugs(paths)
296
+ Array(paths).filter_map do |path|
297
+ segment = path.to_s.split("/").reject(&:empty?).last
298
+ segment unless segment.nil? || segment.empty?
299
+ end
300
+ end
301
+
302
+ def compact_dated_slugs(slugs)
303
+ Array(slugs).filter_map do |slug|
304
+ match = slug.to_s.match(/\A(\d{4})-(\d{2})-(\d{2})-(.+)\z/)
305
+ next unless match
306
+
307
+ "#{match[1]}#{match[2]}#{match[3]}-#{match[4]}"
308
+ end
309
+ end
310
+ end