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,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
@@ -0,0 +1,43 @@
1
+ require 'date'
2
+ require 'time'
3
+
4
+ # This plugin automatically derives 'date' and 'last_updated' from Git history
5
+ # for articles and posts, ensuring Git is the single source of truth for chronology.
6
+ module Jekyll
7
+ class GitMetadataGenerator < Generator
8
+ safe true
9
+ priority :high
10
+
11
+ def generate(site)
12
+ return unless GitChronology.git_repo?
13
+
14
+ article_docs = site.collections["articles"]&.docs || []
15
+ post_docs = site.collections["posts"]&.docs || []
16
+ return if article_docs.empty? && post_docs.empty?
17
+
18
+ (article_docs + post_docs).each do |doc|
19
+ next unless doc.path.match?(/\.(md|markdown)$/)
20
+
21
+ doc.data['language'] = doc.collection.label == 'articles' ? 'enus' : 'ptbr'
22
+ GitChronology.apply!(doc.data, doc.path, doc.collection.label == 'articles' ? 'article' : 'post')
23
+
24
+ if doc.data['date']
25
+ begin
26
+ doc.data['date'] = Time.parse(doc.data['date'].to_s)
27
+ rescue ArgumentError
28
+ end
29
+ end
30
+
31
+ if doc.data['last_updated']
32
+ begin
33
+ doc.data['last_updated'] = Time.parse(doc.data['last_updated'].to_s)
34
+ rescue ArgumentError
35
+ end
36
+ end
37
+ end
38
+
39
+ article_docs.sort_by! { |d| d.data['last_updated'] || d.data['date'] || Time.now }.reverse!
40
+ post_docs.sort_by! { |d| d.data['last_updated'] || d.data['date'] || Time.now }.reverse!
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Renders the private CV job body fields to HTML for the print layouts,
4
+ # storing the result in doc.data["extended"/"single_page"].
5
+ # body_public is not rendered here: the public CV page reads it straight
6
+ # from the exported JSON through the client-side runtime.
7
+ module Jekyll
8
+ class JobSectionsGenerator < Generator
9
+ safe true
10
+ priority :high
11
+
12
+ def generate(site)
13
+ return if site.config["shell_content_agnostic"]
14
+
15
+ converter = site.find_converter_instance(Jekyll::Converters::Markdown)
16
+
17
+ (site.collections["jobs"]&.docs || []).each do |doc|
18
+ %w[extended single_page].each do |key|
19
+ markdown = doc.data["body_#{key}"].to_s.strip
20
+ doc.data[key] = converter.convert(markdown) unless markdown.empty?
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require_relative "resource_validator"
6
+ require_relative "tag_validator"
7
+ require_relative "../writing_slug_limits"
8
+ require_relative "../content_keys"
9
+
10
+ # Single validation entry point for editorial content under content/.
11
+ module ContentValidators
12
+ module_function
13
+
14
+ def validate_all!(repo_root = Pathname.pwd)
15
+ root = Pathname.new(repo_root)
16
+ errors = []
17
+ errors.concat(ResourceValidator.collect_errors(root))
18
+ errors.concat(TagValidator.collect_errors(root))
19
+ errors.concat(WritingSlugLimits.collect_errors(root))
20
+
21
+ unless errors.empty?
22
+ raise ArgumentError, "Content validation failed:\n - #{errors.join("\n - ")}"
23
+ end
24
+
25
+ ContentKeys.validate!(root)
26
+ end
27
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "yaml"
5
+
6
+ # Shared resource collection validation (content/ source, not Jekyll collections).
7
+ module ResourceValidator
8
+ module_function
9
+
10
+ def split_front_matter(text)
11
+ text = text.to_s.sub(/\A\xEF\xBB\xBF/, "")
12
+ return [{}, text] unless text.start_with?("---\n")
13
+
14
+ parts = text.split(/^---\s*$\n?/, 3)
15
+ return [{}, text] unless parts.length == 3
16
+
17
+ data = YAML.safe_load(parts[1], permitted_classes: [Date, Time], aliases: true) || {}
18
+ [data, parts[2]]
19
+ end
20
+
21
+ def collect_errors(repo_root)
22
+ root = Pathname.new(repo_root)
23
+ resources_dir = root.join("content/collections/resources")
24
+ return [] unless resources_dir.directory?
25
+
26
+ errors = []
27
+ resources_dir.glob("*.{md,markdown}").sort.each do |source|
28
+ front_matter, = split_front_matter(source.read)
29
+ name = front_matter["name"] || source.basename(source.extname).to_s
30
+ type = front_matter["type"].to_s.downcase
31
+ link = front_matter["link"].to_s
32
+
33
+ if link.empty?
34
+ errors << "#{name}: missing required 'link' field"
35
+ next
36
+ end
37
+
38
+ case type
39
+ when "youtube"
40
+ unless link.match?(%r{\Ahttps?://(www\.)?(youtube\.com|youtu\.be)/}i)
41
+ errors << "#{name}: type 'youtube' but link does not point to youtube.com or youtu.be (got: #{link})"
42
+ end
43
+ when "spotify"
44
+ unless link.match?(%r{\Ahttps?://(open\.)?spotify\.com/}i)
45
+ errors << "#{name}: type 'spotify' but link does not point to spotify.com (got: #{link})"
46
+ end
47
+ when "pdf"
48
+ unless link.end_with?(".pdf")
49
+ errors << "#{name}: type 'pdf' but link does not end with .pdf (got: #{link})"
50
+ end
51
+ end
52
+ end
53
+
54
+ errors
55
+ end
56
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "yaml"
5
+ require "date"
6
+
7
+ # Tags replaced category as the single classification axis for articles and
8
+ # rascunhos. Vocabulary is closed per collection and loaded from
9
+ # `_config.yml` → `pandorga.taxonomies` (PLT Phase 1).
10
+ module TagValidator
11
+ DEFAULT_MAX_TAGS = 3
12
+
13
+ COLLECTIONS_WITHOUT_REQUIRED_TAGS = %w[posts].freeze
14
+
15
+ module_function
16
+
17
+ def load_vocabularies(repo_root = Pathname.pwd)
18
+ root = Pathname.new(repo_root)
19
+ config_path = root.join("_config.yml")
20
+ return {} unless config_path.file?
21
+
22
+ config = YAML.safe_load(config_path.read, permitted_classes: [Date, Time], aliases: true) || {}
23
+ vocab = {}
24
+
25
+ taxonomies = config.dig("pandorga", "taxonomies")
26
+ if taxonomies.is_a?(Hash)
27
+ taxonomies.each do |collection, spec|
28
+ next unless spec.is_a?(Hash)
29
+
30
+ tags = spec["tags"]
31
+ next unless tags.is_a?(Array)
32
+
33
+ vocab[collection.to_s] = tags.map(&:to_s).freeze
34
+ end
35
+ end
36
+
37
+ # Page-local taxonomy (pandorga.pages[].taxonomy) fills gaps.
38
+ pages = config.dig("pandorga", "pages")
39
+ if pages.is_a?(Array)
40
+ pages.each do |page|
41
+ next unless page.is_a?(Hash)
42
+
43
+ collection = page["collection"].to_s
44
+ next if collection.empty? || vocab.key?(collection)
45
+
46
+ spec = page["taxonomy"]
47
+ next unless spec.is_a?(Hash)
48
+
49
+ tags = spec["tags"]
50
+ next unless tags.is_a?(Array)
51
+
52
+ vocab[collection] = tags.map(&:to_s).freeze
53
+ end
54
+ end
55
+
56
+ vocab
57
+ end
58
+
59
+ def max_tags_for(collection, repo_root = Pathname.pwd)
60
+ root = Pathname.new(repo_root)
61
+ config_path = root.join("_config.yml")
62
+ return DEFAULT_MAX_TAGS unless config_path.file?
63
+
64
+ config = YAML.safe_load(config_path.read, permitted_classes: [Date, Time], aliases: true) || {}
65
+ max = config.dig("pandorga", "taxonomies", collection.to_s, "max")
66
+ if max.nil?
67
+ pages = config.dig("pandorga", "pages")
68
+ if pages.is_a?(Array)
69
+ page = pages.find { |p| p.is_a?(Hash) && p["collection"].to_s == collection.to_s }
70
+ max = page.dig("taxonomy", "max") if page.is_a?(Hash)
71
+ end
72
+ end
73
+ max.nil? ? DEFAULT_MAX_TAGS : Integer(max)
74
+ rescue ArgumentError, TypeError
75
+ DEFAULT_MAX_TAGS
76
+ end
77
+
78
+ def collect_errors(repo_root = Pathname.pwd)
79
+ root = Pathname.new(repo_root)
80
+ errors = []
81
+ vocabulary = load_vocabularies(root)
82
+
83
+ if vocabulary.empty?
84
+ errors << "_config.yml: pandorga.taxonomies is missing or empty"
85
+ return errors
86
+ end
87
+
88
+ vocabulary.each do |collection, allowed|
89
+ dir = root.join("content", "collections", collection)
90
+ next unless dir.directory?
91
+
92
+ tags_required = !COLLECTIONS_WITHOUT_REQUIRED_TAGS.include?(collection)
93
+ max_tags = max_tags_for(collection, root)
94
+
95
+ Dir.glob(dir.join("*.md")).sort.each do |path|
96
+ relative = Pathname.new(path).relative_path_from(root)
97
+ front_matter = read_front_matter(path)
98
+ next if front_matter.nil?
99
+
100
+ if front_matter.key?("category")
101
+ errors << "#{relative}: uses `category`; tags replaced it as the " \
102
+ "single classification axis"
103
+ end
104
+
105
+ tags = front_matter["tags"]
106
+ if tags.nil?
107
+ errors << "#{relative}: missing `tags`" if tags_required
108
+ next
109
+ end
110
+
111
+ unless tags.is_a?(Array)
112
+ errors << "#{relative}: `tags` must be a list, got #{tags.class}"
113
+ next
114
+ end
115
+
116
+ if tags.empty?
117
+ errors << "#{relative}: `tags` is empty; every entry needs at least one" if tags_required
118
+ next
119
+ end
120
+
121
+ if tags.size > max_tags
122
+ errors << "#{relative}: #{tags.size} tags exceeds the #{max_tags} " \
123
+ "allowed — pick the ones that would actually be filtered on"
124
+ end
125
+
126
+ if tags.uniq.size != tags.size
127
+ errors << "#{relative}: duplicate tags #{(tags - tags.uniq).uniq.inspect}"
128
+ end
129
+
130
+ unknown = tags.reject { |tag| allowed.include?(tag.to_s) }
131
+ next if unknown.empty?
132
+
133
+ errors << "#{relative}: unknown #{collection} tag(s) #{unknown.inspect}. " \
134
+ "Allowed: #{allowed.join(', ')}"
135
+ end
136
+ end
137
+
138
+ errors
139
+ end
140
+
141
+ def read_front_matter(path)
142
+ source = File.read(path)
143
+ return nil unless source.start_with?("---")
144
+
145
+ raw = source.split(/^---\s*$/, 3)[1]
146
+ return nil if raw.nil?
147
+
148
+ parsed = YAML.safe_load(raw, permitted_classes: [Date, Time], aliases: true)
149
+ parsed.is_a?(Hash) ? parsed : nil
150
+ rescue Psych::SyntaxError
151
+ nil
152
+ end
153
+ end