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,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
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "pathname"
5
+ require "set"
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
+ # Indexes articles and posts from /content for timeline and network features
15
+ # without emitting Jekyll collection pages (those load from the content API).
16
+ module Jekyll
17
+ class ContentWritingIndex < Generator
18
+ safe true
19
+ priority :high
20
+
21
+
22
+ def generate(site)
23
+ return if site.config["shell_content_agnostic"]
24
+
25
+ content_root = File.join(site.source, site.config["content_root"] || "content")
26
+ entries = []
27
+
28
+ if File.directory?(content_root)
29
+ ContentWritingPaths::INDEX_BY_COLLECTION.each do |collection, relative_path|
30
+ directory = File.join(content_root, relative_path)
31
+ next unless File.directory?(directory)
32
+
33
+ Dir.glob(File.join(directory, "*.{md,markdown}")).sort.each do |absolute_path|
34
+ entry = build_entry(absolute_path, collection)
35
+ entries << entry if entry
36
+ end
37
+ end
38
+ end
39
+
40
+ site.data["writing_index"] = entries
41
+ end
42
+
43
+ private
44
+
45
+ def build_entry(absolute_path, collection)
46
+ repo_relative_path = ContentWritingPaths.repo_relative_for_index_collection(
47
+ collection, File.basename(absolute_path)
48
+ )
49
+ WritingEntry.build(absolute_path, collection, repo_relative_path)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Canonical filesystem locations for articles and posts under content/.
4
+ # Shared by writing indexers, slug validation, and content-key resolution.
5
+ module ContentWritingPaths
6
+ CONTENT_ROOT_SEGMENT = "content"
7
+
8
+ BY_COLLECTION = {
9
+ "articles" => "collections/articles",
10
+ "posts" => "collections/posts"
11
+ }.freeze
12
+
13
+ BY_TYPE = {
14
+ "article" => "collections/articles",
15
+ "post" => "collections/posts"
16
+ }.freeze
17
+
18
+ TYPE_FOR_COLLECTION = {
19
+ "articles" => "article",
20
+ "posts" => "post"
21
+ }.freeze
22
+
23
+ COLLECTION_FOR_TYPE = TYPE_FOR_COLLECTION.invert.freeze
24
+
25
+ # Slim runtime index covers every keyed collection xcite can resolve.
26
+ INDEX_BY_COLLECTION = BY_COLLECTION.merge(
27
+ "products" => "collections/products",
28
+ "projects" => "collections/projects"
29
+ ).freeze
30
+
31
+ INDEX_TYPE_FOR_COLLECTION = TYPE_FOR_COLLECTION.merge(
32
+ "products" => "product",
33
+ "projects" => "project"
34
+ ).freeze
35
+
36
+ module_function
37
+
38
+ def content_relative(type_or_collection)
39
+ BY_COLLECTION[type_or_collection] || BY_TYPE.fetch(type_or_collection)
40
+ end
41
+
42
+ def repo_relative(type)
43
+ File.join(CONTENT_ROOT_SEGMENT, BY_TYPE.fetch(type))
44
+ end
45
+
46
+ def repo_relative_for_collection(collection, filename = nil)
47
+ base = File.join(CONTENT_ROOT_SEGMENT, BY_COLLECTION.fetch(collection))
48
+ filename ? File.join(base, filename) : base
49
+ end
50
+
51
+ def repo_relative_for_index_collection(collection, filename = nil)
52
+ base = File.join(CONTENT_ROOT_SEGMENT, INDEX_BY_COLLECTION.fetch(collection))
53
+ filename ? File.join(base, filename) : base
54
+ end
55
+ end
@@ -0,0 +1,13 @@
1
+ # Force UTF-8 as the process-wide default encoding.
2
+ #
3
+ # Some build environments (e.g. the Cloudflare build image) run under a non-UTF-8
4
+ # locale such as `C`/`POSIX`, which makes Ruby's `Encoding.default_external` be
5
+ # US-ASCII. Jekyll/kramdown then raise "The source text contains invalid
6
+ # characters for the used encoding US-ASCII" when rendering non-ASCII content
7
+ # (accents, em-dashes, etc.). Netlify defaulted to a UTF-8 locale, which hid this.
8
+ #
9
+ # Plugins are loaded before content is read and rendered, so setting the defaults
10
+ # here makes the build locale-independent regardless of how `jekyll build` is
11
+ # invoked. This is a no-op where the locale is already UTF-8 (local dev, CI).
12
+ Encoding.default_external = Encoding::UTF_8 unless Encoding.default_external == Encoding::UTF_8
13
+ Encoding.default_internal = Encoding::UTF_8 unless Encoding.default_internal == Encoding::UTF_8
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "shellwords"
5
+ require "open3"
6
+
7
+ # Derives publication and last-modification dates for articles and posts.
8
+ # Shared by the content JSON export script and Jekyll build-time indexers.
9
+ #
10
+ # `date` — editorial creation date: front matter `date`, else the YYYY-MM-DD filename
11
+ # prefix, else the first Git add (legacy files without a dated name).
12
+ # `last_updated` — latest commit where the markdown body (after the closing ---) changed;
13
+ # front-matter-only edits do not advance it.
14
+ #
15
+ # Pitfall (see docs/content/architecture.md): history often crosses renames. git log --follow
16
+ # reaches pre-migration commits, but git show <sha>:<current-path> does not — the path did
17
+ # not exist yet. A failed show must never be treated as an empty body; resolve the path per
18
+ # commit via git_commit_entries (--name-only) before comparing bodies.
19
+ module GitChronology
20
+ WRITING_TYPES = %w[article post].freeze
21
+ FILENAME_DATE_RE = /\A(\d{4}-\d{2}-\d{2})-/.freeze
22
+
23
+ module_function
24
+
25
+ def reset_cache!
26
+ @chronology_memo = {}
27
+ end
28
+
29
+ def git_repo?
30
+ system("git rev-parse --is-inside-work-tree > /dev/null 2>&1")
31
+ end
32
+
33
+ def git_date(repo_relative_path, mode)
34
+ return nil unless git_repo?
35
+
36
+ path = repo_relative_path.to_s
37
+ case mode
38
+ when :creation
39
+ raw_out, _status = Open3.capture2("git", "log", "--follow", "--diff-filter=A", "--format=%ad", "--date=short", "--", path)
40
+ raw = raw_out.strip.split("\n").last
41
+ when :updated
42
+ # AM skips pure rename/copy commits (e.g. content migration) while still
43
+ # returning the add date for files that have only been created once.
44
+ raw_out, _status = Open3.capture2("git", "log", "--follow", "--diff-filter=AM", "-1", "--format=%ad", "--date=short", "--", path)
45
+ raw = raw_out.strip
46
+ else
47
+ return nil
48
+ end
49
+
50
+ raw unless raw.nil? || raw.empty?
51
+ end
52
+
53
+ def split_front_matter(text)
54
+ text = text.to_s.sub(/\A\xEF\xBB\xBF/, "")
55
+ return ["", text] unless text.start_with?("---\n")
56
+
57
+ parts = text.split(/^---\s*$\n?/, 3)
58
+ return ["", text] unless parts.length == 3
59
+
60
+ [parts[1], parts[2]]
61
+ end
62
+
63
+ def markdown_body(text)
64
+ _front_matter, body = split_front_matter(text)
65
+ body
66
+ end
67
+
68
+ # Chronology compares semantic body content, not formatting noise from CMS saves
69
+ # (line endings, trailing spaces, or blank lines added/removed without text edits).
70
+ def normalize_body_for_chronology(body)
71
+ body.to_s
72
+ .gsub(/\r\n?/, "\n")
73
+ .each_line
74
+ .map(&:strip)
75
+ .reject(&:empty?)
76
+ .join("\n")
77
+ end
78
+
79
+ def bodies_changed_for_chronology?(body_a, body_b)
80
+ normalize_body_for_chronology(body_a) != normalize_body_for_chronology(body_b)
81
+ end
82
+
83
+ SHA_RE = /\A[0-9a-f]{40}\z/i
84
+
85
+ # Newest-first [commit_sha, repo_relative_path_at_that_commit] pairs.
86
+ #
87
+ # --follow alone is insufficient for body chronology: each revision must be read at the
88
+ # path that existed in that commit (_posts/… before the 2026-06-19 content/ migration,
89
+ # content/collections/posts/… after). Without --name-only, git show sha:current_path
90
+ # fails on older commits and last_updated incorrectly snaps to the rename date.
91
+ def git_commit_entries(repo_relative_path)
92
+ return [] unless git_repo?
93
+
94
+ path = repo_relative_path.to_s
95
+ out, _status = Open3.capture2("git", "log", "--follow", "--name-only", "--format=%H", "--", path)
96
+ lines = out.each_line.map(&:strip).reject(&:empty?)
97
+ return [] if lines.empty?
98
+
99
+ entries = []
100
+ index = 0
101
+ while index < lines.length
102
+ sha = lines[index]
103
+ break unless sha.match?(SHA_RE)
104
+
105
+ index += 1
106
+ paths = []
107
+ while index < lines.length && !lines[index].match?(SHA_RE)
108
+ paths << lines[index]
109
+ index += 1
110
+ end
111
+
112
+ path_at_commit = paths.empty? ? repo_relative_path.to_s : pick_path_at_commit(paths, repo_relative_path)
113
+ entries << [sha, path_at_commit]
114
+ end
115
+ entries
116
+ end
117
+
118
+ def pick_path_at_commit(paths, repo_relative_path)
119
+ basename = File.basename(repo_relative_path.to_s)
120
+ paths.find { |candidate| candidate.end_with?(basename) } || paths.last
121
+ end
122
+
123
+ def commit_date(sha)
124
+ out, _status = Open3.capture2("git", "log", "-1", "--format=%ad", "--date=short", sha)
125
+ out.strip
126
+ end
127
+
128
+ def file_content_at_commit(path_at_commit, sha)
129
+ # path_at_commit comes from git_commit_entries, not the caller's present-day path.
130
+ path = path_at_commit.to_s
131
+ content, status = Open3.capture2("git", "show", "#{sha}:#{path}", err: File::NULL)
132
+ return nil unless status.success?
133
+ return nil if content.nil? || content.empty?
134
+
135
+ content
136
+ end
137
+
138
+ def git_body_updated_date(repo_relative_path)
139
+ entries = git_commit_entries(repo_relative_path)
140
+ return nil if entries.empty?
141
+
142
+ # Walk newest → oldest; stop at the first pair where bodies differ.
143
+ # Do not substitute "" when file_content_at_commit returns nil — that was the
144
+ # migration bug (missing historical path read as an empty body edit).
145
+ entries.each_with_index do |(sha, path_at_commit), idx|
146
+ body = markdown_body(file_content_at_commit(path_at_commit, sha) || "")
147
+ if idx == entries.length - 1
148
+ return nil
149
+ end
150
+
151
+ older_sha, older_path = entries[idx + 1]
152
+ older_body = markdown_body(file_content_at_commit(older_path, older_sha) || "")
153
+ return commit_date(sha) if bodies_changed_for_chronology?(body, older_body)
154
+ end
155
+
156
+ nil
157
+ end
158
+
159
+ def filename_date(repo_relative_path)
160
+ stem = File.basename(repo_relative_path.to_s, File.extname(repo_relative_path.to_s))
161
+ match = stem.match(FILENAME_DATE_RE)
162
+ normalize_date_value(match[1]) if match
163
+ end
164
+
165
+ def editorial_date(front_matter, repo_relative_path)
166
+ normalize_date_value(front_matter["date"]) ||
167
+ filename_date(repo_relative_path) ||
168
+ git_date(repo_relative_path, :creation)
169
+ end
170
+
171
+ def chronology_for(repo_relative_path, type: nil, front_matter: nil)
172
+ return {} unless type.nil? || WRITING_TYPES.include?(type)
173
+
174
+ @chronology_memo ||= {}
175
+ memo_key = [repo_relative_path.to_s, type, normalize_date_value(front_matter&.dig("date"))]
176
+ return @chronology_memo[memo_key] if @chronology_memo.key?(memo_key)
177
+
178
+ fm = front_matter || {}
179
+ creation = editorial_date(fm, repo_relative_path)
180
+ updated = git_body_updated_date(repo_relative_path)
181
+ last_updated = updated || creation
182
+
183
+ result = {}
184
+ result["date"] = creation if creation
185
+ result["last_updated"] = last_updated if last_updated
186
+ @chronology_memo[memo_key] = result
187
+ result
188
+ end
189
+
190
+ def normalize_date_value(value)
191
+ case value
192
+ when Date then value.iso8601
193
+ when Time then value.to_date.iso8601
194
+ when String
195
+ stripped = value.strip
196
+ stripped.empty? ? nil : stripped
197
+ else
198
+ raw = value&.to_s&.strip
199
+ raw.nil? || raw.empty? ? nil : raw
200
+ end
201
+ end
202
+
203
+ def apply!(front_matter, repo_relative_path, type)
204
+ return front_matter unless WRITING_TYPES.include?(type)
205
+
206
+ chronology = chronology_for(repo_relative_path, type: type, front_matter: front_matter)
207
+ front_matter["date"] = chronology["date"] if chronology["date"]
208
+ front_matter["last_updated"] = chronology["last_updated"] if chronology["last_updated"]
209
+ front_matter
210
+ end
211
+ end