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,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Draws the two plates in the home page's reference band from the real content.
4
+ #
5
+ # The band sends readers to Sources and to the Network of Ideas, and the brief
6
+ # for it asked for abstract illustration. Abstract art that is also *true* is
7
+ # better than abstract art that is only decorative, so both plates are
8
+ # projections of what the site already publishes:
9
+ #
10
+ # sources - one square per entry in the resources collection, packed into the
11
+ # block for its `category` and coloured by `type` from the medium
12
+ # palette the Sources page itself uses. A block's size is its real
13
+ # count.
14
+ #
15
+ # network - the same graph `_includes/page/network-graph.html` draws, counted
16
+ # rather than drawn. Same nodes, same two relations: a citation, and
17
+ # a theme two or more entries share, under the same rule that an
18
+ # entry appears only once it is joined to something. The page can
19
+ # afford a force layout and the room to read it; the plate reports
20
+ # the three totals and how the corpus splits between articles and
21
+ # rascunhos.
22
+ #
23
+ # Nothing here may invent a quantity. Every number a plate prints is counted from
24
+ # the content at export time.
25
+ #
26
+ # This runs inside `export-content-json.rb` rather than as a script of its own
27
+ # because the plates went stale otherwise. Cloudflare Pages excludes `content/*`
28
+ # from its build watch paths, so a Studio save never rebuilds the shell, and art
29
+ # committed into a layout is art that stops counting the day it is written. See
30
+ # docs/features/home-band-art.md.
31
+ #
32
+ # The output is SVG whose every stroke and fill is a custom property, because the
33
+ # site has two complete palettes and switches between them on a `data-theme`
34
+ # attribute. The browser injects the markup inline for the same reason: an
35
+ # external document cannot see `--color-primary`, so an `<img>` would be right in
36
+ # one theme and wrong in the other.
37
+ module BandArt
38
+ # Plate geometry, shared so the two drawings sit on the same baseline and read
39
+ # as a pair rather than as two unrelated diagrams.
40
+ VIEW_W = 640
41
+ VIEW_H = 240
42
+ PAD_X = 14
43
+ BASELINE = 208
44
+ SPAN = VIEW_W - PAD_X * 2
45
+
46
+ # A tag carried by one entry connects that entry to nothing. The graph page
47
+ # sets the same floor, so the two drawings count themes the same way.
48
+ MIN_THEME_USES = 2
49
+
50
+ WRITING_COLLECTIONS = %w[articles posts].freeze
51
+
52
+ module_function
53
+
54
+ # Public entry point. `writing_index` is the export's own index; `resources`
55
+ # is the exported resource payload list. Returns a hash of plate name to SVG.
56
+ def build(writing_index:, resources:)
57
+ graph = idea_graph(writing_index)
58
+ field = source_field(resources)
59
+
60
+ {
61
+ "sources" => sources_plate(resources, field),
62
+ "network" => network_plate(graph)
63
+ }
64
+ end
65
+
66
+ # ── Reading the content ──────────────────────────────────────────────────
67
+
68
+ # The same two edge rules as the network page: an `xcites` key resolves to
69
+ # another keyed entry, self-citations are dropped and a pair counts once; and a
70
+ # theme is a tag two or more entries share. Writing only, since the index also
71
+ # carries projects and products, which the page's legend never names.
72
+ def idea_graph(entries)
73
+ keyed = entries.select do |entry|
74
+ key = entry["key"].to_s
75
+ !key.empty? && WRITING_COLLECTIONS.include?(entry["collection"].to_s)
76
+ end
77
+ by_key = keyed.each_with_object({}) { |entry, acc| acc[entry["key"].to_s] = entry }
78
+
79
+ seen = {}
80
+ edges = []
81
+ keyed.each do |entry|
82
+ Array(entry["xcites"]).each do |cite|
83
+ target = by_key[cite.to_s]
84
+ next if target.nil? || target["url"] == entry["url"]
85
+
86
+ pair = "#{entry['url']} #{target['url']}"
87
+ next if seen.key?(pair)
88
+
89
+ seen[pair] = true
90
+ edges << { source: entry["url"], target: target["url"] }
91
+ end
92
+ end
93
+
94
+ uses = Hash.new { |hash, key| hash[key] = [] }
95
+ keyed.each do |entry|
96
+ Array(entry["tags"]).each do |tag|
97
+ name = tag.to_s.strip
98
+ uses[name] << entry["url"] unless name.empty?
99
+ end
100
+ end
101
+
102
+ themes = []
103
+ connected = {}
104
+ edges.each do |edge|
105
+ connected[edge[:source]] = true
106
+ connected[edge[:target]] = true
107
+ end
108
+ uses.each do |name, urls|
109
+ next if urls.length < MIN_THEME_USES
110
+
111
+ themes << { name: name, uses: urls.length }
112
+ urls.each { |url| connected[url] = true }
113
+ end
114
+
115
+ nodes = keyed
116
+ .select { |entry| connected[entry["url"]] }
117
+ .sort_by { |entry| [entry["date"].to_s, entry["url"].to_s] }
118
+
119
+ # Themes are ordered by how often the writing returns to them, so the
120
+ # description that lists them opens on the ones that actually hold the
121
+ # corpus together.
122
+ { nodes: nodes,
123
+ edges: edges,
124
+ themes: themes.sort_by { |theme| [-theme[:uses], theme[:name]] },
125
+ corpus: keyed.length,
126
+ articles: nodes.count { |node| node["collection"] == "articles" },
127
+ posts: nodes.count { |node| node["collection"] == "posts" } }
128
+ end
129
+
130
+ # ── The idea map ─────────────────────────────────────────────────────────
131
+
132
+ # The plate used to draw the whole citation graph: an arc per citation over a
133
+ # rail of entries, a thread per shared theme under it. At the size a home panel
134
+ # gives it, ninety threads and forty arcs resolve into a smudge, and the reader
135
+ # learns nothing the caption above it had not already said. So the graph is
136
+ # reported rather than drawn. The three counts are the art, set large, over one
137
+ # spine that splits the corpus by what kind of writing it is, which is the
138
+ # graph page's own two-colour key and the only quantity here that needs a
139
+ # colour to be read.
140
+ METRIC_VIEW_W = 320
141
+ METRIC_VIEW_H = 120
142
+ METRIC_PAD = 10
143
+ METRIC_COLUMNS = 3
144
+ SPINE_Y = 96
145
+ SPINE_H = 6
146
+
147
+ def draw_metric_card(graph)
148
+ return "" if graph[:nodes].empty?
149
+
150
+ cells = [
151
+ [graph[:nodes].length, "entries"],
152
+ [graph[:edges].length, "citations"],
153
+ [graph[:themes].length, "themes"]
154
+ ]
155
+ width = (METRIC_VIEW_W - METRIC_PAD * 2).to_f / METRIC_COLUMNS
156
+
157
+ parts = cells.each_with_index.map do |(count, label), index|
158
+ x = METRIC_PAD + width * (index + 0.5)
159
+ divider = if index.zero?
160
+ ""
161
+ else
162
+ %(<line class="home-index-divider" x1="#{round(METRIC_PAD + width * index)}" y1="12" ) +
163
+ %(x2="#{round(METRIC_PAD + width * index)}" y2="82" />)
164
+ end
165
+ divider +
166
+ %(<text class="home-index-metric" x="#{round(x)}" y="58" text-anchor="middle">#{count}</text>) +
167
+ %(<text class="home-index-metric-label" x="#{round(x)}" y="78" text-anchor="middle">#{label}</text>)
168
+ end
169
+
170
+ parts << draw_spine(graph)
171
+ parts.join
172
+ end
173
+
174
+ # One bar, two segments, drawn to scale. A corpus that is mostly rascunhos
175
+ # looks like one, and the day an entry falls out of the graph the bar stops
176
+ # short of the right margin rather than quietly restating a full width.
177
+ def draw_spine(graph)
178
+ span = METRIC_VIEW_W - METRIC_PAD * 2
179
+ scale = span.to_f / [graph[:corpus], 1].max
180
+ articles = graph[:articles] * scale
181
+ posts = graph[:posts] * scale
182
+
183
+ %(<line class="home-index-spine-rule" x1="#{METRIC_PAD}" y1="#{SPINE_Y + SPINE_H}" ) +
184
+ %(x2="#{METRIC_VIEW_W - METRIC_PAD}" y2="#{SPINE_Y + SPINE_H}" />) +
185
+ %(<rect class="home-index-spine home-index-spine--articles" x="#{METRIC_PAD}" y="#{SPINE_Y}" ) +
186
+ %(width="#{round(articles)}" height="#{SPINE_H}" />) +
187
+ %(<rect class="home-index-spine home-index-spine--posts" x="#{round(METRIC_PAD + articles)}" ) +
188
+ %(y="#{SPINE_Y}" width="#{round(posts)}" height="#{SPINE_H}" />)
189
+ end
190
+
191
+ # ── The source field ─────────────────────────────────────────────────────
192
+
193
+ # Medium is a name, not a magnitude, so it gets a colour and nothing else. One
194
+ # square is one source, and the only quantity is how many of them a collection
195
+ # holds, which is a real property of the shelf.
196
+ MEDIA = [
197
+ { type: "youtube", label: "video", legend: "Video" },
198
+ { type: "podcast", label: "podcast", legend: "Podcast" },
199
+ { type: "website", label: "site", legend: "Site" }
200
+ ].freeze
201
+
202
+ # A tile and the air around it. Three to a row leaves the largest collection
203
+ # four rows tall, which is the height the plate has once the shelf rules and
204
+ # their labels are paid for.
205
+ UNIT = 32
206
+ UNIT_GAP = 5
207
+ UNITS_PER_ROW = 3
208
+ CELL_W = UNITS_PER_ROW * UNIT + (UNITS_PER_ROW - 1) * UNIT_GAP
209
+
210
+ # Shelf labels sit under a ~106px column. At the plate's mono size the long
211
+ # category names overflow; short forms keep the full vocabulary in captions
212
+ # and descriptions while the drawing stays readable.
213
+ COLLECTION_SHORT = {
214
+ "Academic" => "Acad.",
215
+ "Communication" => "Comm.",
216
+ "Humor" => "Humor",
217
+ "Music" => "Music",
218
+ "Professional" => "Prof."
219
+ }.freeze
220
+
221
+ def collection_shelf_label(category)
222
+ name = category.to_s
223
+ COLLECTION_SHORT.fetch(name) { name.length <= 6 ? name : "#{name[0, 4]}." }
224
+ end
225
+
226
+ # Collections are ranked by how many sources they hold, which the content does
227
+ # say. Inside one, the sources are sorted by medium so the colours block up
228
+ # instead of speckling; nothing in the front matter orders them, so that part is
229
+ # composition rather than a claim.
230
+ def source_field(resources)
231
+ order = MEDIA.each_with_index.to_h { |medium, index| [medium[:type], index] }
232
+ resources
233
+ .group_by { |resource| resource["category"].to_s }
234
+ .map do |category, items|
235
+ { category: category,
236
+ items: items.sort_by { |item| [order.fetch(item["type"].to_s, 9), item["slug"].to_s] } }
237
+ end
238
+ .sort_by { |cluster| [-cluster[:items].length, cluster[:category]] }
239
+ end
240
+
241
+ # An empty shelf draws nothing rather than an empty frame, which is the same
242
+ # rule the cards follow: an absent field produces no element. A fixture corpus
243
+ # with no resources reaches here, and so would a freshly emptied collection.
244
+ def draw_source_field(clusters)
245
+ return "" if clusters.empty?
246
+
247
+ order = MEDIA.each_with_index.to_h { |medium, index| [medium[:type], index] }
248
+ gutter = clusters.length > 1 ? (SPAN - clusters.length * CELL_W).to_f / (clusters.length - 1) : 0
249
+ tallest = (clusters.first[:items].length / UNITS_PER_ROW.to_f).ceil
250
+ ceiling = BASELINE - UNIT - (tallest - 1) * (UNIT + UNIT_GAP)
251
+
252
+ # The plate holds five collections of up to fifteen sources. Growing past
253
+ # either is an editorial event worth noticing, not a rounding error, so say so
254
+ # here rather than silently drawing a clipped or overlapping picture.
255
+ if ceiling.negative?
256
+ raise "band art: the largest collection (#{clusters.first[:category]}, " \
257
+ "#{clusters.first[:items].length}) no longer fits the plate; raise VIEW_H or UNITS_PER_ROW"
258
+ end
259
+ if clusters.length > 1 && gutter < UNIT_GAP
260
+ raise "band art: #{clusters.length} collections no longer fit side by side; lower UNIT or CELL_W"
261
+ end
262
+
263
+ parts = []
264
+ clusters.each_with_index do |cluster, cluster_index|
265
+ x0 = PAD_X + cluster_index * (CELL_W + gutter)
266
+ cluster[:items].each_with_index do |resource, index|
267
+ kind = order.key?(resource["type"].to_s) ? resource["type"] : "other"
268
+ x = x0 + (index % UNITS_PER_ROW) * (UNIT + UNIT_GAP)
269
+ y = BASELINE - UNIT - (index / UNITS_PER_ROW) * (UNIT + UNIT_GAP)
270
+ parts << %(<rect class="home-index-unit home-index-unit--#{kind}" ) +
271
+ %(x="#{round(x)}" y="#{round(y)}" width="#{UNIT}" height="#{UNIT}" />)
272
+ end
273
+ # Every block stands on its own rule and says its own name. Five unlabelled
274
+ # runs of identical squares tell the reader nothing.
275
+ parts << %(<line class="home-index-shelf" x1="#{round(x0)}" y1="#{BASELINE + 7}" ) +
276
+ %(x2="#{round(x0 + CELL_W)}" y2="#{BASELINE + 7}" />)
277
+ parts << %(<text class="home-index-collection" x="#{round(x0 + CELL_W / 2.0)}" ) +
278
+ %(y="#{BASELINE + 24}" text-anchor="middle">#{escape(collection_shelf_label(cluster[:category]))}</text>)
279
+ end
280
+ parts.join
281
+ end
282
+
283
+ # ── Assembling the plates ────────────────────────────────────────────────
284
+
285
+ def sources_plate(resources, clusters)
286
+ media_tally = MEDIA.map do |medium|
287
+ count = resources.count { |resource| resource["type"].to_s == medium[:type] }
288
+ plural(count, medium[:label], "#{medium[:label]}s")
289
+ end.join(", ")
290
+ blocks = clusters.map { |cluster| "#{cluster[:category]}, #{cluster[:items].length}" }.join("; ")
291
+
292
+ plate(
293
+ "sources",
294
+ legend: MEDIA.map { |medium| [medium[:type], medium[:legend]] },
295
+ tally: "#{plural(resources.length, 'source', 'sources')} · " \
296
+ "#{plural(clusters.length, 'collection', 'collections')}",
297
+ title: "The shelf, by collection and medium",
298
+ description: "One square per source, packed into a block for the collection it belongs to and " \
299
+ "coloured by medium: #{media_tally}. The #{clusters.length} blocks, largest first: #{blocks}.",
300
+ art: draw_source_field(clusters)
301
+ )
302
+ end
303
+
304
+ # A tally in the caption would only repeat the three numerals, so the caption
305
+ # carries the one thing the drawing does not: how much of the writing the
306
+ # graph actually holds.
307
+ def network_plate(graph)
308
+ themes = graph[:themes].map { |theme| "#{theme[:name]}, #{theme[:uses]}" }.join("; ")
309
+
310
+ plate(
311
+ "network",
312
+ width: METRIC_VIEW_W,
313
+ height: METRIC_VIEW_H,
314
+ legend: [%w[articles Articles], %w[posts Rascunhos]],
315
+ tally: "#{graph[:nodes].length} of #{plural(graph[:corpus], 'piece', 'pieces')} joined",
316
+ title: "What the idea graph holds",
317
+ description: "#{plural(graph[:nodes].length, 'entry', 'entries')} of #{graph[:corpus]}, joined by " \
318
+ "#{plural(graph[:edges].length, 'citation', 'citations')} and by " \
319
+ "#{plural(graph[:themes].length, 'shared theme', 'shared themes')}, most returned to " \
320
+ "first: #{themes}. The bar beneath splits them into " \
321
+ "#{plural(graph[:articles], 'article', 'articles')} and " \
322
+ "#{plural(graph[:posts], 'rascunho', 'rascunhos')}.",
323
+ art: draw_metric_card(graph)
324
+ )
325
+ end
326
+
327
+ # The legend and the tally are the plate's own caption, and both are counted
328
+ # rather than written, so they travel with the drawing. They ride as attributes
329
+ # instead of as SVG text because text inside a viewBox scales with the box: the
330
+ # browser lifts them back out into the plate's HTML caption, where they keep the
331
+ # label register at whatever size the panel happens to be.
332
+ def plate(name, legend:, tally:, title:, description:, art:, width: VIEW_W, height: VIEW_H)
333
+ id = "home-index-#{name}"
334
+ <<~SVG
335
+ <svg xmlns="http://www.w3.org/2000/svg" class="home-index-art home-index-art--#{name}" viewBox="0 0 #{width} #{height}"
336
+ preserveAspectRatio="xMidYMid meet" role="img" aria-labelledby="#{id}-title #{id}-desc"
337
+ data-band-tally="#{escape(tally)}"
338
+ data-band-legend="#{escape(legend.map { |kind, label| "#{kind}:#{label}" }.join(','))}"><title id="#{id}-title">#{escape(title)}</title><desc id="#{id}-desc">#{escape(description)}</desc>#{art}</svg>
339
+ SVG
340
+ end
341
+
342
+ # ── Small helpers ────────────────────────────────────────────────────────
343
+
344
+ # Integers print without a trailing ".0" so the path data reads like the
345
+ # geometry constants above it.
346
+ def round(value)
347
+ rounded = value.to_f.round(2)
348
+ rounded == rounded.to_i ? rounded.to_i.to_s : rounded.to_s
349
+ end
350
+
351
+ def plural(count, singular, plural_form)
352
+ "#{count} #{count == 1 ? singular : plural_form}"
353
+ end
354
+
355
+ def escape(value)
356
+ value.to_s
357
+ .gsub("&", "&amp;")
358
+ .gsub("<", "&lt;")
359
+ .gsub(">", "&gt;")
360
+ .gsub('"', "&quot;")
361
+ end
362
+ end