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,578 @@
1
+ /**
2
+ * Studio media library — browse content/media, upload, create folders.
3
+ * Cite paths as /media/… in Liquid includes after upload.
4
+ */
5
+
6
+ const MEDIA_ROOT = "content/media";
7
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
8
+ const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)$/i;
9
+ const VIDEO_EXT = /\.(mp4|webm|mov|m4v)$/i;
10
+ const HTML_EXT = /\.html?$/i;
11
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
12
+
13
+ export function mediaRootFromSchema(schema) {
14
+ const input = String(schema?.media?.input || MEDIA_ROOT).replace(/\/+$/, "");
15
+ if (input === MEDIA_ROOT || input.startsWith(`${MEDIA_ROOT}/`)) return MEDIA_ROOT;
16
+ return MEDIA_ROOT;
17
+ }
18
+
19
+ export function toPublicPath(repoPath) {
20
+ const path = String(repoPath || "");
21
+ if (path.startsWith("content/media")) {
22
+ return path.replace(/^content/, "") || "/media";
23
+ }
24
+ return "";
25
+ }
26
+
27
+ export function isImageName(name) {
28
+ return IMAGE_EXT.test(String(name || ""));
29
+ }
30
+
31
+ export function isVideoName(name) {
32
+ return VIDEO_EXT.test(String(name || ""));
33
+ }
34
+
35
+ export function isHtmlName(name) {
36
+ return HTML_EXT.test(String(name || ""));
37
+ }
38
+
39
+ /** image | video | html — kinds the media library can open in the figure lightbox. */
40
+ export function previewKind(name) {
41
+ if (isImageName(name)) return "image";
42
+ if (isVideoName(name)) return "video";
43
+ if (isHtmlName(name)) return "html";
44
+ return null;
45
+ }
46
+
47
+ export function formatBytes(n) {
48
+ const bytes = Number(n) || 0;
49
+ if (bytes < 1024) return `${bytes} B`;
50
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
51
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
52
+ }
53
+
54
+ export function sanitizeUploadName(raw) {
55
+ const base = String(raw || "")
56
+ .split(/[/\\]/)
57
+ .pop()
58
+ .trim();
59
+ if (!base) return null;
60
+ const cleaned = base.replace(/\s+/g, "-").replace(/[^A-Za-z0-9._-]/g, "");
61
+ if (!SAFE_NAME.test(cleaned)) return null;
62
+ return cleaned;
63
+ }
64
+
65
+ export function sanitizeFolderName(raw) {
66
+ const name = String(raw || "")
67
+ .trim()
68
+ .replace(/\s+/g, "-")
69
+ .replace(/[^A-Za-z0-9._-]/g, "");
70
+ if (!SAFE_NAME.test(name) || name === ".gitkeep") return null;
71
+ return name;
72
+ }
73
+
74
+ export function fileToBase64(file) {
75
+ return new Promise((resolve, reject) => {
76
+ const reader = new FileReader();
77
+ reader.onload = () => {
78
+ const result = String(reader.result || "");
79
+ const comma = result.indexOf(",");
80
+ resolve(comma >= 0 ? result.slice(comma + 1) : result);
81
+ };
82
+ reader.onerror = () => reject(reader.error || new Error("read_failed"));
83
+ reader.readAsDataURL(file);
84
+ });
85
+ }
86
+
87
+ export function buildBreadcrumb(dirPath, root = MEDIA_ROOT) {
88
+ const path = String(dirPath || root);
89
+ const parts = path.split("/").filter(Boolean);
90
+ const rootParts = root.split("/").filter(Boolean);
91
+ const crumbs = [];
92
+ let acc = "";
93
+ for (let i = 0; i < parts.length; i++) {
94
+ acc = acc ? `${acc}/${parts[i]}` : parts[i];
95
+ const isRoot = i === rootParts.length - 1 && acc === root;
96
+ const belowRoot = i >= rootParts.length - 1;
97
+ if (!belowRoot && !isRoot) continue;
98
+ crumbs.push({
99
+ label: isRoot || acc === root ? "media" : parts[i],
100
+ path: acc,
101
+ });
102
+ }
103
+ if (!crumbs.length) crumbs.push({ label: "media", path: root });
104
+ return crumbs;
105
+ }
106
+
107
+ /**
108
+ * @param {object} opts
109
+ * @param {typeof import("../api/client.js").studioApi} opts.api
110
+ * @param {object} opts.state
111
+ * @param {(tag: string, attrs?: object, children?: any[]) => HTMLElement} opts.el
112
+ * @param {(name: string, extraClass?: string) => HTMLElement} opts.materialIcon
113
+ * @param {(raw: string) => string} opts.resolveMediaUrl
114
+ * @param {() => void} opts.render
115
+ * @param {(err: any) => void} opts.captureError
116
+ * @param {() => void} opts.clearError
117
+ * @param {() => void} opts.goHome
118
+ */
119
+ export function renderMediaPane(opts) {
120
+ const {
121
+ api,
122
+ state,
123
+ el,
124
+ materialIcon,
125
+ resolveMediaUrl,
126
+ render,
127
+ captureError,
128
+ clearError,
129
+ goHome,
130
+ } = opts;
131
+
132
+ const dirPath = state.mediaPath || MEDIA_ROOT;
133
+ const items = state.mediaItems || [];
134
+ const crumbs = buildBreadcrumb(dirPath);
135
+
136
+ const breadcrumb = el("nav", {
137
+ className: "media-breadcrumb",
138
+ "aria-label": "Media path",
139
+ });
140
+ crumbs.forEach((crumb, i) => {
141
+ if (i > 0) {
142
+ breadcrumb.append(el("span", { className: "media-breadcrumb-sep", text: "/" }));
143
+ }
144
+ const isLast = i === crumbs.length - 1;
145
+ if (isLast) {
146
+ breadcrumb.append(
147
+ el("span", {
148
+ className: "media-breadcrumb-current",
149
+ text: crumb.label,
150
+ })
151
+ );
152
+ } else {
153
+ breadcrumb.append(
154
+ el("button", {
155
+ className: "media-breadcrumb-link",
156
+ type: "button",
157
+ text: crumb.label,
158
+ onClick: () => void openMediaDir(crumb.path, opts),
159
+ })
160
+ );
161
+ }
162
+ });
163
+
164
+ const fileInput = el("input", {
165
+ type: "file",
166
+ className: "media-file-input",
167
+ multiple: "multiple",
168
+ accept: "image/*,video/*,.html,.svg,.pdf",
169
+ hidden: true,
170
+ onChange: async (ev) => {
171
+ const files = Array.from(ev.target.files || []);
172
+ ev.target.value = "";
173
+ if (!files.length) return;
174
+ await uploadFiles(files, opts);
175
+ },
176
+ });
177
+
178
+ const toolbar = el("div", { className: "media-toolbar" }, [
179
+ el("button", {
180
+ className: "btn btn-nav",
181
+ type: "button",
182
+ title: "Back to Studio map",
183
+ onClick: () => goHome(),
184
+ }, [
185
+ materialIcon("chevron_left", "btn-nav-icon"),
186
+ el("span", { text: "Studio map" }),
187
+ ]),
188
+ el("span", { className: "spacer" }),
189
+ el("button", {
190
+ className: "btn",
191
+ type: "button",
192
+ text: "New folder",
193
+ disabled: !!state.mediaBusy,
194
+ onClick: () => void createFolder(opts),
195
+ }),
196
+ el("button", {
197
+ className: "btn btn-primary",
198
+ type: "button",
199
+ text: state.mediaBusy ? "Working…" : "Upload",
200
+ disabled: !!state.mediaBusy,
201
+ onClick: () => fileInput.click(),
202
+ }),
203
+ fileInput,
204
+ ]);
205
+
206
+ const hint = el("p", {
207
+ className: "media-hint",
208
+ text: "Click an image, video, or HTML file to preview it. Copy the public path into includes (e.g. /media/images/…). Deletes stay in GitHub/IDE.",
209
+ });
210
+
211
+ let body;
212
+ if (state.mediaLoading) {
213
+ body = el("p", { className: "loading-msg", text: "Loading media…" });
214
+ } else if (!items.length) {
215
+ body = el("p", {
216
+ className: "empty-msg",
217
+ text: "This folder is empty. Upload a file or create a subfolder.",
218
+ });
219
+ } else {
220
+ const rows = items.map((item) => renderMediaRow(item, opts));
221
+ body = el("div", { className: "media-grid", role: "list" }, rows);
222
+ }
223
+
224
+ return el("div", { className: "pane media-pane" }, [
225
+ el("header", { className: "media-header" }, [
226
+ el("h1", { className: "media-title", text: "Media" }),
227
+ breadcrumb,
228
+ hint,
229
+ ]),
230
+ toolbar,
231
+ body,
232
+ ]);
233
+ }
234
+
235
+ function renderMediaRow(item, opts) {
236
+ const { el, materialIcon, resolveMediaUrl, state } = opts;
237
+ const isDir = item.type === "dir";
238
+ const kind = isDir ? null : previewKind(item.name);
239
+ const publicPath = item.public_path || (isDir ? null : toPublicPath(item.path));
240
+ const thumbSrc =
241
+ !isDir && isImageName(item.name) && publicPath
242
+ ? resolveMediaUrl(publicPath)
243
+ : "";
244
+
245
+ let preview;
246
+ if (isDir) {
247
+ preview = el("span", { className: "media-thumb media-thumb--folder" }, [
248
+ materialIcon("folder", "media-thumb-icon"),
249
+ ]);
250
+ } else if (thumbSrc) {
251
+ const img = el("img", {
252
+ className: "media-thumb-img",
253
+ src: thumbSrc,
254
+ alt: "",
255
+ loading: "lazy",
256
+ referrerpolicy: "no-referrer",
257
+ });
258
+ img.addEventListener("error", () => {
259
+ img.replaceWith(
260
+ el("span", { className: "media-thumb media-thumb--file" }, [
261
+ materialIcon("image", "media-thumb-icon"),
262
+ ])
263
+ );
264
+ });
265
+ preview = el("span", { className: "media-thumb media-thumb--image" }, [img]);
266
+ } else if (isVideoName(item.name)) {
267
+ preview = el("span", { className: "media-thumb media-thumb--file" }, [
268
+ materialIcon("movie", "media-thumb-icon"),
269
+ ]);
270
+ } else if (isHtmlName(item.name)) {
271
+ preview = el("span", { className: "media-thumb media-thumb--file" }, [
272
+ materialIcon("html", "media-thumb-icon"),
273
+ ]);
274
+ } else {
275
+ preview = el("span", { className: "media-thumb media-thumb--file" }, [
276
+ materialIcon("draft", "media-thumb-icon"),
277
+ ]);
278
+ }
279
+
280
+ const metaParts = [];
281
+ if (!isDir && item.size) metaParts.push(formatBytes(item.size));
282
+ if (publicPath) metaParts.push(publicPath);
283
+
284
+ const openOrCopy = isDir
285
+ ? () => void openMediaDir(item.path, opts)
286
+ : kind
287
+ ? (event) => openMediaPreview(item, event.currentTarget, opts)
288
+ : () => void copyPath(publicPath, opts);
289
+
290
+ const rowTitle = isDir
291
+ ? `Open ${item.name}`
292
+ : kind
293
+ ? `Preview ${item.name}`
294
+ : `Copy ${publicPath}`;
295
+
296
+ return el("div", {
297
+ className: "media-row" + (isDir ? " media-row--dir" : ""),
298
+ role: "listitem",
299
+ }, [
300
+ el("button", {
301
+ className: "media-row-main" + (kind ? " media-row-main--preview" : ""),
302
+ type: "button",
303
+ title: rowTitle,
304
+ disabled: !!state.mediaBusy,
305
+ onClick: openOrCopy,
306
+ }, [
307
+ preview,
308
+ el("span", { className: "media-row-body" }, [
309
+ el("span", { className: "media-row-name", text: item.name }),
310
+ el("span", {
311
+ className: "media-row-meta",
312
+ text: metaParts.join(" · ") || (isDir ? "Folder" : ""),
313
+ }),
314
+ ]),
315
+ ]),
316
+ !isDir && publicPath
317
+ ? el("button", {
318
+ className: "btn media-copy-btn",
319
+ type: "button",
320
+ text: "Copy path",
321
+ title: publicPath,
322
+ disabled: !!state.mediaBusy,
323
+ onClick: () => void copyPath(publicPath, opts),
324
+ })
325
+ : null,
326
+ ].filter(Boolean));
327
+ }
328
+
329
+ /** One overlay for the page, same chrome as the public figure lightbox. */
330
+ let mediaLightbox = null;
331
+ let mediaLightboxTrigger = null;
332
+
333
+ function ensureMediaLightbox(el, materialIcon) {
334
+ if (mediaLightbox) return mediaLightbox;
335
+ const img = el("img", { className: "figure-lightbox-img", alt: "" });
336
+ const video = el("video", {
337
+ className: "figure-lightbox-video",
338
+ controls: true,
339
+ playsinline: true,
340
+ });
341
+ const frame = el("iframe", {
342
+ className: "figure-lightbox-frame",
343
+ title: "Media preview",
344
+ });
345
+ const caption = el("p", { className: "figure-lightbox-caption" });
346
+ img.hidden = true;
347
+ video.hidden = true;
348
+ frame.hidden = true;
349
+ caption.hidden = true;
350
+ video.controls = true;
351
+ const closeBtn = el(
352
+ "button",
353
+ {
354
+ type: "button",
355
+ className: "figure-lightbox-close",
356
+ "data-lightbox-close": "true",
357
+ "aria-label": "Close",
358
+ onClick: (event) => {
359
+ event.preventDefault();
360
+ closeMediaPreview();
361
+ },
362
+ },
363
+ [materialIcon("close")]
364
+ );
365
+ const root = el(
366
+ "div",
367
+ {
368
+ id: "figure-lightbox",
369
+ className: "figure-lightbox",
370
+ role: "dialog",
371
+ "aria-modal": "true",
372
+ "aria-label": "Media preview",
373
+ "aria-hidden": "true",
374
+ },
375
+ [
376
+ el("div", {
377
+ className: "figure-lightbox-backdrop",
378
+ "data-lightbox-close": "true",
379
+ tabindex: "-1",
380
+ onClick: () => closeMediaPreview(),
381
+ }),
382
+ el("div", { className: "figure-lightbox-panel" }, [
383
+ closeBtn,
384
+ img,
385
+ video,
386
+ frame,
387
+ caption,
388
+ ]),
389
+ ]
390
+ );
391
+ root.hidden = true;
392
+ document.body.append(root);
393
+ document.addEventListener("keydown", (event) => {
394
+ if (!mediaLightbox || mediaLightbox.root.hidden) return;
395
+ if (event.key === "Escape") {
396
+ event.preventDefault();
397
+ closeMediaPreview();
398
+ }
399
+ });
400
+ mediaLightbox = { root, img, video, frame, caption, closeBtn };
401
+ return mediaLightbox;
402
+ }
403
+
404
+ function clearLightboxMedia(box) {
405
+ box.img.removeAttribute("src");
406
+ box.img.alt = "";
407
+ box.img.hidden = true;
408
+ box.video.pause();
409
+ box.video.removeAttribute("src");
410
+ box.video.load();
411
+ box.video.hidden = true;
412
+ box.frame.removeAttribute("src");
413
+ box.frame.hidden = true;
414
+ box.caption.textContent = "";
415
+ box.caption.hidden = true;
416
+ }
417
+
418
+ function setLightboxOpen(box, isOpen) {
419
+ box.root.hidden = !isOpen;
420
+ box.root.setAttribute("aria-hidden", String(!isOpen));
421
+ document.documentElement.classList.toggle("figure-lightbox-open", isOpen);
422
+ document.body.classList.toggle("figure-lightbox-open", isOpen);
423
+ }
424
+
425
+ function closeMediaPreview() {
426
+ if (!mediaLightbox || mediaLightbox.root.hidden) return;
427
+ clearLightboxMedia(mediaLightbox);
428
+ setLightboxOpen(mediaLightbox, false);
429
+ const trigger = mediaLightboxTrigger;
430
+ mediaLightboxTrigger = null;
431
+ if (trigger && typeof trigger.focus === "function") {
432
+ try {
433
+ trigger.focus();
434
+ } catch {
435
+ /* row may have been re-rendered */
436
+ }
437
+ }
438
+ }
439
+
440
+ function openMediaPreview(item, trigger, opts) {
441
+ const { el, materialIcon, resolveMediaUrl } = opts;
442
+ const kind = previewKind(item.name);
443
+ const publicPath = item.public_path || toPublicPath(item.path);
444
+ const url = publicPath ? resolveMediaUrl(publicPath) : "";
445
+ if (!kind || !url) return;
446
+ const box = ensureMediaLightbox(el, materialIcon);
447
+ mediaLightboxTrigger = trigger || null;
448
+ clearLightboxMedia(box);
449
+ if (kind === "image") {
450
+ box.img.hidden = false;
451
+ box.img.referrerPolicy = "no-referrer";
452
+ box.img.alt = item.name || "";
453
+ box.img.src = url;
454
+ } else if (kind === "video") {
455
+ box.video.hidden = false;
456
+ box.video.referrerPolicy = "no-referrer";
457
+ box.video.src = url;
458
+ } else {
459
+ box.frame.hidden = false;
460
+ box.frame.title = item.name || "HTML preview";
461
+ box.frame.src = url;
462
+ }
463
+ box.caption.textContent = item.name || "";
464
+ box.caption.hidden = !item.name;
465
+ setLightboxOpen(box, true);
466
+ box.closeBtn.focus();
467
+ }
468
+
469
+ async function copyPath(publicPath, opts) {
470
+ const { state, render, captureError, clearError } = opts;
471
+ if (!publicPath) return;
472
+ clearError();
473
+ try {
474
+ await navigator.clipboard.writeText(publicPath);
475
+ state.status = `Copied ${publicPath}`;
476
+ } catch (err) {
477
+ captureError(err);
478
+ state.status = publicPath;
479
+ }
480
+ render();
481
+ }
482
+
483
+ export async function openMediaDir(dirPath, opts) {
484
+ const { api, state, render, captureError, clearError } = opts;
485
+ clearError();
486
+ state.mediaPath = dirPath || MEDIA_ROOT;
487
+ state.mediaLoading = true;
488
+ state.mediaBusy = false;
489
+ state.status = `Loading ${state.mediaPath}…`;
490
+ render();
491
+ try {
492
+ const data = await api.listMedia(state.mediaPath);
493
+ state.mediaItems = data.items || [];
494
+ state.mediaPath = data.path || state.mediaPath;
495
+ state.mediaLoading = false;
496
+ const dirs = state.mediaItems.filter((i) => i.type === "dir").length;
497
+ const files = state.mediaItems.filter((i) => i.type === "file").length;
498
+ state.status = `${dirs} folders · ${files} files`;
499
+ } catch (err) {
500
+ state.mediaItems = [];
501
+ state.mediaLoading = false;
502
+ captureError(err);
503
+ }
504
+ render();
505
+ }
506
+
507
+ async function createFolder(opts) {
508
+ const { api, state, render, captureError, clearError } = opts;
509
+ const raw = window.prompt("New folder name (letters, numbers, .-_)");
510
+ if (raw == null) return;
511
+ const name = sanitizeFolderName(raw);
512
+ if (!name) {
513
+ state.error = "Invalid folder name";
514
+ state.status = "";
515
+ render();
516
+ return;
517
+ }
518
+ const path = `${state.mediaPath || MEDIA_ROOT}/${name}`;
519
+ clearError();
520
+ state.mediaBusy = true;
521
+ state.status = `Creating ${name}…`;
522
+ render();
523
+ try {
524
+ await api.createMediaFolder({
525
+ path,
526
+ message: `studio: mkdir ${path}`,
527
+ });
528
+ state.mediaBusy = false;
529
+ state.status = `Created ${toPublicPath(path) || path}`;
530
+ await openMediaDir(state.mediaPath, opts);
531
+ } catch (err) {
532
+ state.mediaBusy = false;
533
+ captureError(err);
534
+ render();
535
+ }
536
+ }
537
+
538
+ async function uploadFiles(files, opts) {
539
+ const { api, state, render, captureError, clearError } = opts;
540
+ clearError();
541
+ const dir = state.mediaPath || MEDIA_ROOT;
542
+ let ok = 0;
543
+ state.mediaBusy = true;
544
+
545
+ for (const file of files) {
546
+ const name = sanitizeUploadName(file.name);
547
+ if (!name) {
548
+ state.error = `Skipped invalid name: ${file.name}`;
549
+ continue;
550
+ }
551
+ if (file.size > MAX_UPLOAD_BYTES) {
552
+ state.error = `${name} exceeds ${formatBytes(MAX_UPLOAD_BYTES)} (GitHub Contents API soft limit in Studio)`;
553
+ continue;
554
+ }
555
+ state.status = `Uploading ${name}…`;
556
+ render();
557
+ try {
558
+ const content_base64 = await fileToBase64(file);
559
+ const path = `${dir}/${name}`;
560
+ const result = await api.uploadMedia({
561
+ path,
562
+ content_base64,
563
+ message: `studio: upload ${path}`,
564
+ });
565
+ ok += 1;
566
+ state.status = `Uploaded ${result.public_path || toPublicPath(path)}`;
567
+ } catch (err) {
568
+ captureError(err);
569
+ state.mediaBusy = false;
570
+ render();
571
+ return;
572
+ }
573
+ }
574
+
575
+ state.mediaBusy = false;
576
+ if (ok) state.status = ok === 1 ? state.status : `Uploaded ${ok} files`;
577
+ await openMediaDir(dir, opts);
578
+ }