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,507 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "content_keys"
4
+
5
+ require "date"
6
+ require "set"
7
+
8
+ # Builds site.data["timeline"] at :post_read with normalized events and writing buckets.
9
+ # Spec: docs/features/timeline.md
10
+ module TimelineBuilder
11
+ module_function
12
+
13
+ def build(site)
14
+ today = Date.today
15
+ warnings = []
16
+ errors = []
17
+ events = []
18
+
19
+ products_by_slug = index_products(site)
20
+ referenced_product_slugs = Set.new
21
+
22
+ build_jobs(site, today, events, warnings, errors, products_by_slug, referenced_product_slugs)
23
+ build_education(site, events, errors)
24
+ build_projects(site, today, events, errors)
25
+ build_writing(site, events)
26
+
27
+ products_by_slug.each_key do |slug|
28
+ next if referenced_product_slugs.include?(slug)
29
+
30
+ warnings << "Product '#{slug}' is not linked from any job; omitted from timeline"
31
+ end
32
+
33
+ writing_buckets = aggregate_writing(events)
34
+
35
+ unless errors.empty?
36
+ errors.each { |e| warn "Timeline validation error: #{e}" }
37
+ raise "Timeline has #{errors.size} validation error(s). See docs/features/timeline.md."
38
+ end
39
+
40
+ warnings.each { |w| warn "Timeline: #{w}" }
41
+
42
+ {
43
+ "meta" => {
44
+ "generated_at" => today.iso8601,
45
+ "today" => today.iso8601,
46
+ "warnings" => warnings
47
+ },
48
+ "events" => events,
49
+ "writing_buckets" => writing_buckets
50
+ }
51
+ end
52
+
53
+ def index_products(site)
54
+ (site.collections["products"]&.docs || []).each_with_object({}) do |doc, hash|
55
+ hash[doc_slug(doc)] = doc
56
+ end
57
+ end
58
+
59
+ def build_jobs(site, today, events, warnings, errors, products_by_slug, referenced_product_slugs)
60
+ # One product bar per slug. Jobs may list the same product across consecutive
61
+ # roles; emitting once per job created duplicate clipped fragments (Anafi Ai / UKR).
62
+ product_refs = {}
63
+
64
+ (site.collections["jobs"]&.docs || []).each do |doc|
65
+ slug = doc_slug(doc)
66
+ raw_start = doc.data["start"]
67
+ raw_end = doc.data["end"]
68
+
69
+ if blank?(raw_start)
70
+ errors << "Job '#{slug}': missing required 'start' field"
71
+ next
72
+ end
73
+
74
+ ongoing = blank?(raw_end)
75
+ start_info = normalize_start(raw_start)
76
+ end_info = if ongoing
77
+ { date: today, precision: "day", raw: nil }
78
+ else
79
+ normalize_end(raw_end, start_info[:precision])
80
+ end
81
+
82
+ job_id = "job:#{slug}"
83
+ events << event_hash(
84
+ id: job_id,
85
+ source: "jobs",
86
+ source_id: slug,
87
+ type: "employment",
88
+ title: doc.data["position"].to_s,
89
+ subtitle: doc.data["company"].to_s,
90
+ start_on: start_info[:date],
91
+ end_on: end_info[:date],
92
+ ongoing: ongoing,
93
+ precision: start_info[:precision],
94
+ url: doc.url,
95
+ meta: {
96
+ "raw_start" => start_info[:raw],
97
+ "raw_end" => ongoing ? nil : end_info[:raw],
98
+ "location" => doc.data["location"]
99
+ }
100
+ )
101
+
102
+ job_start = start_info[:date]
103
+ job_end = end_info[:date]
104
+
105
+ Array(doc.data["products"]).each do |product_slug|
106
+ product_slug = product_slug.to_s.strip
107
+ next if product_slug.empty?
108
+
109
+ product_doc = products_by_slug[product_slug]
110
+ unless product_doc
111
+ warnings << "Job '#{slug}': product slug '#{product_slug}' not found in products content"
112
+ next
113
+ end
114
+
115
+ referenced_product_slugs << product_slug
116
+ ref = product_refs[product_slug] ||= {
117
+ "doc" => product_doc,
118
+ "job_ids" => [],
119
+ "job_starts" => [],
120
+ "job_ends" => []
121
+ }
122
+ ref["job_ids"] << job_id
123
+ ref["job_starts"] << job_start
124
+ ref["job_ends"] << job_end
125
+ end
126
+ end
127
+
128
+ product_refs.each do |product_slug, ref|
129
+ add_product_event(
130
+ ref["doc"],
131
+ product_slug,
132
+ ref["job_ids"],
133
+ ref["job_starts"].min,
134
+ ref["job_ends"].max,
135
+ today,
136
+ events,
137
+ warnings
138
+ )
139
+ end
140
+ end
141
+
142
+ def add_product_event(doc, slug, job_ids, jobs_start, jobs_end, today, events, warnings)
143
+ raw_start = doc.data["start_date"]
144
+ raw_end = doc.data["end_date"]
145
+
146
+ if blank?(raw_start)
147
+ warnings << "Product '#{slug}': missing start_date; skipped"
148
+ return
149
+ end
150
+
151
+ start_info = normalize_start(raw_start)
152
+ ongoing = blank?(raw_end)
153
+ end_info = if ongoing
154
+ { date: today, precision: "day", raw: nil }
155
+ else
156
+ normalize_end(raw_end, start_info[:precision])
157
+ end
158
+
159
+ prod_start = start_info[:date]
160
+ prod_end = end_info[:date]
161
+ parent_ids = Array(job_ids)
162
+
163
+ if prod_start < jobs_start || prod_end > jobs_end
164
+ warnings << "Product '#{slug}': dates (#{prod_start}..#{prod_end}) outside linked jobs span (#{jobs_start}..#{jobs_end}); clipped"
165
+ prod_start = [prod_start, jobs_start].max
166
+ prod_end = [prod_end, jobs_end].min
167
+ end
168
+
169
+ if prod_end < prod_start
170
+ warnings << "Product '#{slug}': no overlap with linked jobs; skipped"
171
+ return
172
+ end
173
+
174
+ title = present(doc.data["product"]) || doc.data["title"].to_s
175
+ events << event_hash(
176
+ id: "product:#{slug}",
177
+ source: "products",
178
+ source_id: slug,
179
+ type: "product",
180
+ title: title,
181
+ subtitle: doc.data["company"].to_s,
182
+ start_on: prod_start,
183
+ end_on: prod_end,
184
+ ongoing: ongoing,
185
+ precision: start_info[:precision],
186
+ url: doc.url,
187
+ parent_id: parent_ids.first,
188
+ meta: {
189
+ "raw_start" => start_info[:raw],
190
+ "raw_end" => ongoing ? nil : end_info[:raw],
191
+ "parent_ids" => parent_ids
192
+ }
193
+ )
194
+ end
195
+
196
+ def build_education(site, events, errors)
197
+ (site.data["education"] || []).each_with_index do |entry, index|
198
+ label = entry["institution"] || "entry #{index}"
199
+ raw_start = entry["start"]
200
+ raw_end = entry["end"]
201
+
202
+ if blank?(raw_start) || blank?(raw_end)
203
+ errors << "Education '#{label}': missing required 'start' or 'end' field"
204
+ next
205
+ end
206
+
207
+ start_info = normalize_start(raw_start)
208
+ end_info = normalize_end(raw_end, start_info[:precision])
209
+
210
+ events << event_hash(
211
+ id: "education:#{index}",
212
+ source: "education",
213
+ source_id: index.to_s,
214
+ type: "education",
215
+ title: entry["degree"].to_s,
216
+ subtitle: entry["institution"].to_s,
217
+ start_on: start_info[:date],
218
+ end_on: end_info[:date],
219
+ ongoing: false,
220
+ precision: start_info[:precision],
221
+ url: nil,
222
+ meta: {
223
+ "raw_start" => start_info[:raw],
224
+ "raw_end" => end_info[:raw],
225
+ "location" => entry["location"]
226
+ }
227
+ )
228
+ end
229
+ end
230
+
231
+ def build_projects(site, today, events, errors)
232
+ (site.collections["projects"]&.docs || []).each do |doc|
233
+ slug = doc_slug(doc)
234
+ raw_start = doc.data["start_date"]
235
+ status = doc.data["status"].to_s.downcase
236
+ raw_end = doc.data["end_date"]
237
+
238
+ if blank?(raw_start)
239
+ errors << "Project '#{slug}': missing required 'start_date' field"
240
+ next
241
+ end
242
+
243
+ ongoing = status == "ongoing"
244
+ start_info = normalize_start(raw_start)
245
+ end_info = if ongoing
246
+ { date: today, precision: "day", raw: nil }
247
+ else
248
+ if blank?(raw_end)
249
+ errors << "Project '#{slug}': completed project requires 'end_date'"
250
+ next
251
+ end
252
+ normalize_end(raw_end, start_info[:precision])
253
+ end
254
+
255
+ title = present(doc.data["project"]) || doc.data["title"].to_s
256
+ events << event_hash(
257
+ id: "project:#{slug}",
258
+ source: "projects",
259
+ source_id: slug,
260
+ type: "project",
261
+ title: title,
262
+ subtitle: doc.data["label"].to_s,
263
+ start_on: start_info[:date],
264
+ end_on: end_info[:date],
265
+ ongoing: ongoing,
266
+ precision: start_info[:precision],
267
+ url: doc.url,
268
+ meta: {
269
+ "raw_start" => start_info[:raw],
270
+ "raw_end" => ongoing ? nil : end_info[:raw],
271
+ "status" => status
272
+ }
273
+ )
274
+ end
275
+ end
276
+
277
+ def build_writing(site, events)
278
+ index = site.data["writing_index"]
279
+ if index.is_a?(Array) && !index.empty?
280
+ build_writing_from_index(index, events)
281
+ return
282
+ end
283
+
284
+ article_docs = published_docs(site.collections["articles"]&.docs || [])
285
+
286
+ article_docs.each do |doc|
287
+ add_writing_event(doc, "articles", "article", events)
288
+ end
289
+
290
+ published_docs(site.collections["posts"]&.docs || []).each do |doc|
291
+ add_writing_event(doc, "posts", "post", events)
292
+ end
293
+ end
294
+
295
+ def build_writing_from_index(entries, events)
296
+ entries.select { |entry| entry["type"] == "articles" }.each do |entry|
297
+ add_writing_event_from_index(entry, "articles", "article", events)
298
+ end
299
+
300
+ entries.select { |entry| entry["type"] == "posts" }.each do |entry|
301
+ add_writing_event_from_index(entry, "posts", "post", events)
302
+ end
303
+ end
304
+
305
+ def add_writing_event_from_index(entry, source, type, events)
306
+ raw_date = entry["date"]
307
+ return if blank?(raw_date)
308
+
309
+ day = parse_day(raw_date)
310
+ slug = entry["slug"].to_s
311
+
312
+ meta = { "raw_start" => day.iso8601, "raw_end" => day.iso8601 }
313
+ if type == "article" && entry["last_updated"]
314
+ meta["last_updated"] = entry["last_updated"].to_s
315
+ end
316
+
317
+ events << event_hash(
318
+ id: "#{type}:#{slug}",
319
+ source: source,
320
+ source_id: slug,
321
+ type: type,
322
+ title: entry["title"].to_s,
323
+ subtitle: type == "article" ? "Article" : "Post",
324
+ start_on: day,
325
+ end_on: day,
326
+ ongoing: false,
327
+ precision: "day",
328
+ url: entry["url"],
329
+ meta: meta
330
+ )
331
+ end
332
+
333
+ def published_docs(docs)
334
+ docs.reject { |doc| doc.data["published"] == false }
335
+ end
336
+
337
+ def add_writing_event(doc, source, type, events)
338
+ raw_date = doc.data["date"]
339
+ return if blank?(raw_date)
340
+
341
+ day = parse_day(raw_date)
342
+ slug = doc_slug(doc)
343
+
344
+ meta = { "raw_start" => day.iso8601, "raw_end" => day.iso8601 }
345
+ if type == "article" && doc.data["last_updated"]
346
+ meta["last_updated"] = doc.data["last_updated"].to_s
347
+ end
348
+
349
+ events << event_hash(
350
+ id: "#{type}:#{slug}",
351
+ source: source,
352
+ source_id: slug,
353
+ type: type,
354
+ title: doc.data["title"].to_s,
355
+ subtitle: type == "article" ? "Article" : "Post",
356
+ start_on: day,
357
+ end_on: day,
358
+ ongoing: false,
359
+ precision: "day",
360
+ url: doc.url,
361
+ meta: meta
362
+ )
363
+ end
364
+
365
+ def aggregate_writing(events)
366
+ writing = events.select { |e| %w[post article].include?(e["type"]) }
367
+ buckets = {}
368
+
369
+ writing.each do |ev|
370
+ month = ev["start"][0, 7]
371
+ buckets[month] ||= { "month" => month, "count" => 0, "posts" => [], "articles" => [] }
372
+ buckets[month]["count"] += 1
373
+ item = {
374
+ "id" => ev["id"],
375
+ "title" => ev["title"],
376
+ "url" => ev["url"],
377
+ "date" => ev["start"]
378
+ }
379
+ if ev["type"] == "article"
380
+ buckets[month]["articles"] << item
381
+ else
382
+ buckets[month]["posts"] << item
383
+ end
384
+ end
385
+
386
+ buckets.values.sort_by { |b| b["month"] }
387
+ end
388
+
389
+ def event_hash(id:, source:, source_id:, type:, title:, subtitle:, start_on:, end_on:, ongoing:, precision:, url:, parent_id: nil, meta: {})
390
+ {
391
+ "id" => id,
392
+ "source" => source,
393
+ "source_id" => source_id,
394
+ "type" => type,
395
+ "title" => title,
396
+ "subtitle" => subtitle,
397
+ "start" => iso_date(start_on),
398
+ "end" => iso_date(end_on),
399
+ "ongoing" => ongoing,
400
+ "precision" => precision,
401
+ "url" => url,
402
+ "parent_id" => parent_id,
403
+ "meta" => meta
404
+ }.compact
405
+ end
406
+
407
+ def normalize_start(raw)
408
+ raw_s = raw.to_s.strip
409
+ if raw_s.match?(/\A\d{4}-\d{2}-\d{2}\z/)
410
+ day = Date.parse(raw_s)
411
+ { date: day, precision: "day", raw: raw_s }
412
+ elsif raw_s.match?(/\A\d{4}-\d{2}\z/)
413
+ y, m = raw_s.split("-").map(&:to_i)
414
+ { date: Date.new(y, m, 1), precision: "month", raw: raw_s }
415
+ elsif raw_s.match?(/\A\d{4}\z/)
416
+ y = raw_s.to_i
417
+ { date: Date.new(y, 1, 1), precision: "year", raw: raw_s }
418
+ else
419
+ raise ArgumentError, "Unrecognized date format: #{raw_s}"
420
+ end
421
+ end
422
+
423
+ def normalize_end(raw, start_precision)
424
+ raw_s = raw.to_s.strip
425
+ if raw_s.match?(/\A\d{4}-\d{2}-\d{2}\z/)
426
+ day = Date.parse(raw_s)
427
+ { date: day, precision: "day", raw: raw_s }
428
+ elsif raw_s.match?(/\A\d{4}-\d{2}\z/)
429
+ y, m = raw_s.split("-").map(&:to_i)
430
+ { date: Date.new(y, m, -1), precision: "month", raw: raw_s }
431
+ elsif raw_s.match?(/\A\d{4}\z/)
432
+ y = raw_s.to_i
433
+ { date: Date.new(y, 12, 31), precision: "year", raw: raw_s }
434
+ else
435
+ precision = start_precision || "day"
436
+ { date: normalize_start(raw_s)[:date], precision: precision, raw: raw_s }
437
+ end
438
+ end
439
+
440
+ def parse_day(raw)
441
+ case raw
442
+ when Date then raw
443
+ when Time then raw.to_date
444
+ else Date.parse(raw.to_s)
445
+ end
446
+ end
447
+
448
+ def iso_date(value)
449
+ case value
450
+ when Date then value.iso8601
451
+ when String then value
452
+ else value.to_s
453
+ end
454
+ end
455
+
456
+ def blank?(value)
457
+ value.nil? || value.to_s.strip.empty?
458
+ end
459
+
460
+ def present(value)
461
+ s = value.to_s.strip
462
+ s.empty? ? nil : s
463
+ end
464
+
465
+ def doc_slug(doc)
466
+ data = doc.data || {}
467
+ type = infer_doc_type(doc)
468
+ key = ContentKeys.normalize_key(data["key"])
469
+ if ContentKeys::KEYED_TYPES.include?(type) && !key.empty?
470
+ return ContentKeys.path_segment(type, key, date: data["date"])
471
+ end
472
+
473
+ slug = data["slug"].to_s.strip
474
+ return slug unless slug.empty?
475
+
476
+ File.basename(doc.path, File.extname(doc.path))
477
+ end
478
+
479
+ def infer_doc_type(doc)
480
+ path = doc.path.to_s
481
+ return "article" if path.include?("/articles/")
482
+ return "post" if path.include?("/posts/") || path.include?("/collections/posts/")
483
+ return "product" if path.include?("/products/") || path.include?("/collections/products/")
484
+ return "project" if path.include?("/projects/") || path.include?("/collections/projects/")
485
+
486
+ collection = doc.respond_to?(:collection) ? doc.collection&.label.to_s : ""
487
+ return "article" if collection == "articles"
488
+ return "post" if collection == "posts"
489
+ return "product" if collection == "products"
490
+ return "project" if collection == "projects"
491
+
492
+ "job"
493
+ end
494
+ end
495
+
496
+ module Jekyll
497
+ class TimelineGenerator < Generator
498
+ safe true
499
+ priority :low
500
+
501
+ def generate(site)
502
+ return if site.config["shell_content_agnostic"]
503
+
504
+ site.data["timeline"] = TimelineBuilder.build(site)
505
+ end
506
+ end
507
+ end if defined?(Jekyll::Generator)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require_relative "content_writing_paths"
6
+
7
+ # Slug rules for articles and posts:
8
+ # - ASCII only (accents break Cloudflare Pages path matching and R2 JSON keys)
9
+ # - Length cap derived from the longest existing article filename slug (34 chars) plus 10
10
+ module WritingSlugLimits
11
+ MAX_LENGTH = 44
12
+ NON_ASCII_PATTERN = /[^\x00-\x7F]/
13
+
14
+ TRANSLITERATION_MAP = {
15
+ "á" => "a", "à" => "a", "â" => "a", "ã" => "a", "ä" => "a", "å" => "a",
16
+ "é" => "e", "è" => "e", "ê" => "e", "ë" => "e",
17
+ "í" => "i", "ì" => "i", "î" => "i", "ï" => "i",
18
+ "ó" => "o", "ò" => "o", "ô" => "o", "õ" => "o", "ö" => "o",
19
+ "ú" => "u", "ù" => "u", "û" => "u", "ü" => "u",
20
+ "ç" => "c", "ñ" => "n", "ý" => "y", "ÿ" => "y",
21
+ "Á" => "A", "À" => "A", "Â" => "A", "Ã" => "A", "Ä" => "A", "Å" => "A",
22
+ "É" => "E", "È" => "E", "Ê" => "E", "Ë" => "E",
23
+ "Í" => "I", "Ì" => "I", "Î" => "I", "Ï" => "I",
24
+ "Ó" => "O", "Ò" => "O", "Ô" => "O", "Õ" => "O", "Ö" => "O",
25
+ "Ú" => "U", "Ù" => "U", "Û" => "U", "Ü" => "U",
26
+ "Ç" => "C", "Ñ" => "N", "Ý" => "Y"
27
+ }.freeze
28
+
29
+ module_function
30
+
31
+ def ascii_slug?(slug)
32
+ !slug.match?(NON_ASCII_PATTERN)
33
+ end
34
+
35
+ def transliterate_slug(slug)
36
+ slug.chars.map { |char| TRANSLITERATION_MAP.fetch(char, char) }.join
37
+ end
38
+
39
+ def collect_errors(repo_root = Pathname.pwd)
40
+ errors = []
41
+ root = Pathname.new(repo_root)
42
+
43
+ ContentWritingPaths::BY_TYPE.each do |kind, relative_dir|
44
+ directory = root.join(ContentWritingPaths::CONTENT_ROOT_SEGMENT, relative_dir)
45
+ next unless directory.directory?
46
+
47
+ directory.glob("*.{md,markdown}").sort.each do |file|
48
+ slug = file.basename(file.extname).to_s
49
+
50
+ unless ascii_slug?(slug)
51
+ suggested = transliterate_slug(slug)
52
+ errors << "#{kind} #{file}: slug '#{slug}' contains non-ASCII characters; rename to '#{suggested}'"
53
+ end
54
+
55
+ next if slug.length <= MAX_LENGTH
56
+
57
+ errors << "#{kind} #{file}: slug '#{slug}' is #{slug.length} chars (max #{MAX_LENGTH})"
58
+ end
59
+ end
60
+
61
+ errors
62
+ end
63
+
64
+ def validate!(repo_root = Pathname.pwd)
65
+ errors = collect_errors(repo_root)
66
+ return if errors.empty?
67
+
68
+ raise ArgumentError, "Writing slug validation failed:\n - #{errors.join("\n - ")}"
69
+ end
70
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "jekyll/generator"
4
+
5
+ # Load migrated site plugins (generators, hooks) shipped inside the gem.
6
+ plugins_root = File.expand_path("jekyll/plugins", __dir__)
7
+ if File.directory?(plugins_root)
8
+ # lib/ helpers first
9
+ lib_dir = File.join(plugins_root, "lib")
10
+ Dir[File.join(lib_dir, "*.rb")].sort.each { |path| require path } if File.directory?(lib_dir)
11
+ Dir[File.join(plugins_root, "*.rb")].sort.each { |path| require path }
12
+ end
13
+
14
+ # Auto-loaded when listed under `plugins: [pandorga]` in _config.yml.
15
+ Liquid::Template.error_mode = :strict if defined?(Liquid)
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Resolve S3-compatible object-store credentials from S3_* or legacy R2_* env.
4
+ module Pandorga
5
+ module ObjectStoreEnv
6
+ PAIRS = {
7
+ "endpoint" => %w[S3_ENDPOINT R2_ENDPOINT],
8
+ "bucket" => %w[S3_BUCKET R2_BUCKET],
9
+ "access_key_id" => %w[S3_ACCESS_KEY_ID R2_ACCESS_KEY_ID AWS_ACCESS_KEY_ID],
10
+ "secret_access_key" => %w[S3_SECRET_ACCESS_KEY R2_SECRET_ACCESS_KEY AWS_SECRET_ACCESS_KEY],
11
+ "region" => %w[S3_REGION R2_REGION AWS_DEFAULT_REGION],
12
+ "account_id" => %w[S3_ACCOUNT_ID R2_ACCOUNT_ID]
13
+ }.freeze
14
+
15
+ module_function
16
+
17
+ def fetch(key, default = nil)
18
+ names = PAIRS[key.to_s] || [key.to_s]
19
+ names.each do |name|
20
+ value = ENV[name].to_s
21
+ return value unless value.empty?
22
+ end
23
+ default
24
+ end
25
+
26
+ def require!(*keys)
27
+ missing = keys.select { |key| fetch(key).to_s.empty? }
28
+ return if missing.empty?
29
+
30
+ hints = missing.map do |key|
31
+ names = PAIRS[key.to_s] || [key.to_s]
32
+ "#{key} (#{names.join(' / ')})"
33
+ end
34
+ raise ArgumentError, "missing object-store env: #{hints.join(', ')}"
35
+ end
36
+
37
+ def apply_aws_env!
38
+ ENV["AWS_ACCESS_KEY_ID"] = fetch("access_key_id") if ENV["AWS_ACCESS_KEY_ID"].to_s.empty?
39
+ ENV["AWS_SECRET_ACCESS_KEY"] = fetch("secret_access_key") if ENV["AWS_SECRET_ACCESS_KEY"].to_s.empty?
40
+ region = fetch("region", "auto")
41
+ ENV["AWS_DEFAULT_REGION"] = region if ENV["AWS_DEFAULT_REGION"].to_s.empty?
42
+
43
+ # Keep legacy R2_* populated so existing sync scripts keep working.
44
+ ENV["R2_ENDPOINT"] = fetch("endpoint") if ENV["R2_ENDPOINT"].to_s.empty?
45
+ ENV["R2_BUCKET"] = fetch("bucket") if ENV["R2_BUCKET"].to_s.empty?
46
+ ENV["R2_ACCESS_KEY_ID"] = fetch("access_key_id") if ENV["R2_ACCESS_KEY_ID"].to_s.empty?
47
+ ENV["R2_SECRET_ACCESS_KEY"] = fetch("secret_access_key") if ENV["R2_SECRET_ACCESS_KEY"].to_s.empty?
48
+ ENV["R2_REGION"] = region if ENV["R2_REGION"].to_s.empty?
49
+ end
50
+ end
51
+ end