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,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
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require "json"
6
+ require "rbconfig"
7
+ require "jekyll"
8
+
9
+ # When local_content_serve is enabled (_config_dev.yml), copies the content
10
+ # pipeline export and editorial media into the Jekyll output so the browser
11
+ # runtime reads the same JSON layout as Cloudflare R2.
12
+ module Jekyll
13
+ module ContentLocalServe
14
+ EXPORT_MUTEX = Mutex.new
15
+
16
+ class << self
17
+ attr_accessor :serve_options, :site_config
18
+
19
+ def enabled?(site)
20
+ site.config["local_content_serve"] == true
21
+ end
22
+
23
+ def export_root(site)
24
+ File.join(site.source, site.config["local_content_export_dir"] || "_content_json")
25
+ end
26
+
27
+ def content_root(site)
28
+ File.join(site.source, site.config["content_root"] || "content")
29
+ end
30
+
31
+ # Cold start only. The export stamps `generated_at` into manifest.json, and
32
+ # it writes into a directory Jekyll watches, so exporting on every build
33
+ # makes `jekyll serve --watch` regenerate itself forever. dev-serve.sh owns
34
+ # the re-export when content changes; this covers the case where somebody
35
+ # runs Jekyll against the dev config with no export on disk at all.
36
+ def export_script(site)
37
+ site_script = File.join(site.source, "scripts", "content", "export-content-json.rb")
38
+ return site_script if File.file?(site_script)
39
+
40
+ begin
41
+ require "pandorga"
42
+ gem_script = Pandorga.gem_path("scripts/content/export-content-json.rb")
43
+ return gem_script if File.file?(gem_script)
44
+ rescue LoadError
45
+ nil
46
+ end
47
+ end
48
+
49
+ def export_content!(site)
50
+ return if File.directory?(export_root(site))
51
+
52
+ script = export_script(site)
53
+ return unless script
54
+
55
+ content_dir = site.config["content_root"] || "content"
56
+ output_dir = site.config["local_content_export_dir"] || "_content_json"
57
+ env = {
58
+ "CONTENT_API_BASE_URL" => site.config["content_api_base_url"].to_s.strip,
59
+ "CONTENT_EXPORT_INCLUDE_UNPUBLISHED" => site.config["content_include_unpublished"] == true ? "1" : "0",
60
+ "PANDORGA_SITE_ROOT" => site.source.to_s,
61
+ "CONTENT_EXPORT_SKIP_VALIDATE" => "1"
62
+ }
63
+
64
+ ruby_bin = RbConfig.ruby
65
+ success = system(env, ruby_bin, script, content_dir, output_dir, chdir: site.source)
66
+ return if success
67
+
68
+ Jekyll.logger.error "ContentLocalServe:", "export-content-json.rb failed"
69
+ raise "Content export failed; run bundle exec pandorga export content _content_json"
70
+ end
71
+
72
+ def copy_tree(source, destination)
73
+ return unless File.directory?(source)
74
+
75
+ Pathname.glob(File.join(source, "**", "*"), File::FNM_DOTMATCH).each do |path|
76
+ next if path.directory?
77
+
78
+ relative = path.relative_path_from(Pathname.new(source)).to_s
79
+ target = File.join(destination, relative)
80
+ FileUtils.mkdir_p(File.dirname(target))
81
+ FileUtils.cp(path.to_s, target)
82
+ end
83
+ end
84
+
85
+ def publish!(site)
86
+ export_root = export_root(site)
87
+ export_content!(site)
88
+
89
+ copy_tree(export_root, site.dest)
90
+ copy_tree(File.join(content_root(site), "media"), File.join(site.dest, "media"))
91
+ end
92
+
93
+ def store_serve_options(options)
94
+ @serve_options = Jekyll.configuration(options)
95
+ rescue StandardError
96
+ @serve_options = options
97
+ end
98
+
99
+ def reexport!(options = nil)
100
+ opts = options || serve_options || site_config || {}
101
+ source = opts["source"] || Dir.pwd
102
+ destination = opts["destination"] || File.join(source, "_site")
103
+ content_dir = opts["content_root"] || "content"
104
+ output_dir = opts["local_content_export_dir"] || "_content_json"
105
+ script = File.join(source, "scripts", "content", "export-content-json.rb")
106
+
107
+ return [false, "Missing export script at #{script}"] unless File.file?(script)
108
+
109
+ env = {
110
+ "CONTENT_API_BASE_URL" => opts.fetch("content_api_base_url", "").to_s.strip,
111
+ "CONTENT_EXPORT_INCLUDE_UNPUBLISHED" => (opts["content_include_unpublished"] != false) ? "1" : "0"
112
+ }
113
+
114
+ ruby_bin = RbConfig.ruby
115
+ Jekyll.logger.info "ContentLocalServe:", "Re-exporting content JSON..."
116
+ success = system(env, ruby_bin, script, content_dir, output_dir, chdir: source)
117
+ return [false, "export-content-json.rb execution failed"] unless success
118
+
119
+ export_path = File.join(source, output_dir)
120
+ copy_tree(export_path, destination)
121
+ copy_tree(File.join(source, content_dir, "media"), File.join(destination, "media"))
122
+
123
+ shell_trigger = File.join(source, "pages", "articles", "index.html")
124
+ FileUtils.touch(shell_trigger) if File.file?(shell_trigger)
125
+
126
+ Jekyll.logger.info "ContentLocalServe:", "Re-export complete and mirrored to #{destination}"
127
+ [true, "Content JSON re-exported successfully"]
128
+ rescue StandardError => e
129
+ Jekyll.logger.error "ContentLocalServe:", "Re-export error: #{e.message}"
130
+ [false, "Re-export error: #{e.message}"]
131
+ end
132
+
133
+ def handle_reexport_request(_req, res)
134
+ res["Content-Type"] = "application/json; charset=utf-8"
135
+ res["Cache-Control"] = "no-store, no-cache, must-revalidate"
136
+
137
+ opts = serve_options || site_config || {}
138
+ unless opts["content_include_unpublished"] || opts["unpublished"] || opts["local_content_serve"]
139
+ res.status = 403
140
+ res.body = JSON.generate({ "ok" => false, "error" => "Dev re-export is disabled in production mode" })
141
+ return
142
+ end
143
+
144
+ EXPORT_MUTEX.synchronize do
145
+ success, message = reexport!(opts)
146
+ if success
147
+ res.status = 200
148
+ res.body = JSON.generate({ "ok" => true, "message" => message })
149
+ else
150
+ res.status = 500
151
+ res.body = JSON.generate({ "ok" => false, "error" => message })
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ Jekyll::Hooks.register :site, :after_init do |site|
158
+ ContentLocalServe.site_config = site.config
159
+ end
160
+
161
+ Jekyll::Hooks.register :site, :post_write do |site|
162
+ next unless ContentLocalServe.enabled?(site)
163
+
164
+ ContentLocalServe.publish!(site)
165
+ Jekyll.logger.info "ContentLocalServe:", "Published local content JSON and media to #{site.dest}"
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "pathname"
5
+ require "ostruct"
6
+ require "yaml"
7
+
8
+ require_relative "git_chronology"
9
+ require_relative "content_keys"
10
+ require_relative "content_writing_paths"
11
+ require_relative "lib/text_excerpt"
12
+ require_relative "lib/writing_entry"
13
+
14
+ begin
15
+ require "pandorga/registry"
16
+ rescue LoadError
17
+ # Exporter may load this file before the gem lib path is on $LOAD_PATH.
18
+ end
19
+
20
+ # Minimal site-like object built from /content for export-time derived data
21
+ # (timeline, writing_index) without running a Jekyll build.
22
+ class ContentSiteAdapter
23
+ Doc = Struct.new(:data, :url, :path, keyword_init: true)
24
+
25
+ COLLECTION_DIRS = {
26
+ "products" => "collections/products",
27
+ "projects" => "collections/projects",
28
+ "jobs" => "collections/jobs"
29
+ }.freeze
30
+
31
+ attr_reader :collections, :data
32
+
33
+ def initialize(content_root, include_writing_index: true, config: nil)
34
+ @content_root = Pathname.new(content_root)
35
+ @config = config.is_a?(Hash) ? config : load_site_config
36
+ @collections = build_collections
37
+ @data = {
38
+ "education" => load_yaml_array("collections/data/education.yml"),
39
+ "writing_index" => include_writing_index ? build_writing_index : []
40
+ }
41
+ end
42
+
43
+ private
44
+
45
+ def load_site_config
46
+ root = Pathname.new(ENV.fetch("PANDORGA_SITE_ROOT", Dir.pwd)).expand_path
47
+ path = root.join("_config.yml")
48
+ return {} unless path.file?
49
+
50
+ YAML.safe_load(path.read, permitted_classes: [Date, Time], aliases: true) || {}
51
+ rescue StandardError
52
+ {}
53
+ end
54
+
55
+ def collection_dirs
56
+ return COLLECTION_DIRS unless defined?(PandorgaRegistry)
57
+
58
+ pages = PandorgaRegistry.pages(@config)
59
+ return COLLECTION_DIRS if pages.empty?
60
+
61
+ dirs = PandorgaRegistry.adapter_collection_dirs(pages)
62
+ dirs.empty? ? COLLECTION_DIRS : dirs
63
+ end
64
+
65
+ def build_collections
66
+ collection_dirs.each_with_object({}) do |(name, relative), hash|
67
+ hash[name] = OpenStruct.new(docs: load_collection_docs(relative, name))
68
+ end
69
+ end
70
+
71
+ def load_collection_docs(relative_dir, collection_name)
72
+ dir = @content_root.join(relative_dir)
73
+ return [] unless dir.directory?
74
+
75
+ type = case collection_name
76
+ when "products" then "product"
77
+ when "projects" then "project"
78
+ when "jobs" then "job"
79
+ else collection_name
80
+ end
81
+
82
+ Dir.glob(dir.join("*.{md,markdown}")).sort.filter_map do |absolute_path|
83
+ text = File.read(absolute_path).sub(/\A\xEF\xBB\xBF/, "")
84
+ front_matter, = split_front_matter(text)
85
+ next if front_matter["published"] == false
86
+
87
+ if ContentKeys::KEYED_TYPES.include?(type)
88
+ key = ContentKeys.normalize_key(front_matter["key"])
89
+ path_segment = ContentKeys.path_segment(type, key, date: front_matter["date"])
90
+ url = ContentKeys.detail_path(type, key, date: front_matter["date"])
91
+ else
92
+ key = ContentKeys.filename_stem(absolute_path)
93
+ path_segment = key
94
+ url = "/#{collection_name}/#{path_segment}/"
95
+ end
96
+
97
+ Doc.new(
98
+ data: stringify_keys(front_matter.merge("key" => key, "slug" => path_segment)),
99
+ url: url,
100
+ path: absolute_path
101
+ )
102
+ end
103
+ end
104
+
105
+ def build_writing_index
106
+ entries = []
107
+ index_collections.each do |collection, relative_path|
108
+ directory = @content_root.join(relative_path)
109
+ next unless directory.directory?
110
+
111
+ Dir.glob(directory.join("*.{md,markdown}")).sort.each do |absolute_path|
112
+ entry = build_writing_entry(absolute_path, collection)
113
+ entries << entry if entry
114
+ end
115
+ end
116
+ entries
117
+ end
118
+
119
+ def index_collections
120
+ if defined?(PandorgaRegistry)
121
+ pages = PandorgaRegistry.pages(@config)
122
+ unless pages.empty?
123
+ map = PandorgaRegistry.writing_collections(pages)
124
+ return map.keys.each_with_object({}) do |name, hash|
125
+ hash[name] = "collections/#{name}"
126
+ end
127
+ end
128
+ end
129
+ ContentWritingPaths::INDEX_BY_COLLECTION
130
+ end
131
+
132
+ def build_writing_entry(absolute_path, collection)
133
+ relative_source = Pathname.new(absolute_path).relative_path_from(@content_root).to_s
134
+ repo_relative_path = Pathname.new(ContentWritingPaths::CONTENT_ROOT_SEGMENT)
135
+ .join(relative_source).to_s
136
+ WritingEntry.build(absolute_path, collection, repo_relative_path)
137
+ end
138
+
139
+ def load_yaml_array(relative_path)
140
+ path = @content_root.join(relative_path)
141
+ return [] unless path.file?
142
+
143
+ YAML.safe_load_file(path, permitted_classes: [Date, Time], aliases: true) || []
144
+ end
145
+
146
+ def split_front_matter(text)
147
+ return [{}, text] unless text.start_with?("---\n")
148
+
149
+ parts = text.split(/^---\s*$\n?/, 3)
150
+ return [{}, text] unless parts.length == 3
151
+
152
+ data = YAML.safe_load(parts[1], permitted_classes: [Date, Time], aliases: true) || {}
153
+ [data, parts[2]]
154
+ end
155
+
156
+ def stringify_keys(hash)
157
+ hash.transform_keys(&:to_s)
158
+ end
159
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require_relative "lib/content_validators"
6
+
7
+ # Editorial validation for content-aware builds only. Public Pages shells set
8
+ # shell_content_agnostic and skip this — gates live in validate.sh / export.
9
+ module Jekyll
10
+ class ContentValidatorsHook < Generator
11
+ safe true
12
+ priority :highest
13
+
14
+ def generate(site)
15
+ return if site.config["shell_content_agnostic"]
16
+
17
+ ContentValidators.validate_all!(Pathname.new(site.source))
18
+ end
19
+ end
20
+ end