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,208 @@
1
+ {% raw %}
2
+
3
+ function frontMatterHaystack(item) {
4
+ var fm = (item && item.front_matter) || item || {};
5
+ var parts = [];
6
+ Object.keys(fm).forEach(function (key) {
7
+ var val = fm[key];
8
+ if (val == null || val === '') return;
9
+ if (Array.isArray(val)) {
10
+ parts.push(val.join(' '));
11
+ return;
12
+ }
13
+ if (typeof val === 'object') return;
14
+ parts.push(String(val));
15
+ });
16
+ if (item && item.slug) parts.push(String(item.slug));
17
+ if (item && item.key) parts.push(String(item.key));
18
+ return parts.join(' ').toLowerCase();
19
+ }
20
+
21
+ function matchesFrontMatterQuery(item, query) {
22
+ var q = String(query || '').trim().toLowerCase();
23
+ if (!q) return true;
24
+ return frontMatterHaystack(item).indexOf(q) !== -1;
25
+ }
26
+
27
+ var ListingFilter = {
28
+ frontMatterHaystack: frontMatterHaystack,
29
+ matchesFrontMatterQuery: matchesFrontMatterQuery,
30
+
31
+ create: function (options) {
32
+ options = options || {};
33
+ var root = options.root;
34
+ if (!root) return null;
35
+
36
+ var trigger = root.querySelector('[data-listing-filter-trigger]');
37
+ var panel = root.querySelector('[data-listing-filter-panel]');
38
+ var input = root.querySelector('[data-listing-filter-q]');
39
+ var chipsRoot = root.querySelector('[data-listing-filter-chips]');
40
+ var state = {
41
+ q: String(options.initialQuery || '').trim(),
42
+ tag: options.initialTag || options.initialCategory || null,
43
+ medium: options.initialMedium || null,
44
+ sort: options.initialSort || options.defaultSort || 'updated',
45
+ type: options.initialType || null
46
+ };
47
+ var onChange = typeof options.onChange === 'function' ? options.onChange : function () {};
48
+ var chipGroups = options.chipGroups || [];
49
+
50
+ function setExpanded(open) {
51
+ if (!trigger || !panel) return;
52
+ trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
53
+ panel.hidden = !open;
54
+ }
55
+
56
+ function renderChips() {
57
+ if (!chipsRoot) return;
58
+ chipsRoot.innerHTML = '';
59
+ chipGroups.forEach(function (group) {
60
+ if (!group.options || !group.options.length) return;
61
+ var section = document.createElement('div');
62
+ section.className = 'listing-filter-group';
63
+ if (group.label) {
64
+ var heading = document.createElement('p');
65
+ heading.className = 'listing-filter-group-label font-label text-on-surface-variant';
66
+ heading.textContent = group.label;
67
+ section.appendChild(heading);
68
+ }
69
+ var row = document.createElement('div');
70
+ row.className = 'listing-filter-chips';
71
+ row.setAttribute('role', 'group');
72
+ if (group.label) row.setAttribute('aria-label', group.label);
73
+ group.options.forEach(function (option) {
74
+ var btn = document.createElement('button');
75
+ btn.type = 'button';
76
+ btn.className = 'listing-filter-chip font-body';
77
+ btn.dataset.filterKey = group.key;
78
+ btn.dataset.filterValue = option.value == null ? '' : String(option.value);
79
+ if (option.swatch) {
80
+ btn.innerHTML = '<span class="listing-filter-swatch ' + option.swatch + '" aria-hidden="true"></span>' +
81
+ escapeHtml(option.label);
82
+ } else {
83
+ btn.textContent = option.label;
84
+ }
85
+ var active = state[group.key] === option.value ||
86
+ (option.value === null && (state[group.key] == null || state[group.key] === ''));
87
+ if (active) btn.classList.add('is-active');
88
+ // Without this the active filter is conveyed by colour alone.
89
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
90
+ btn.addEventListener('click', function () {
91
+ state[group.key] = option.value;
92
+ if (group.key !== 'q') state.q = input ? input.value.trim() : state.q;
93
+ renderChips();
94
+ syncUrl();
95
+ onChange(state);
96
+ });
97
+ row.appendChild(btn);
98
+ });
99
+ section.appendChild(row);
100
+ chipsRoot.appendChild(section);
101
+ });
102
+ }
103
+
104
+ function syncUrl() {
105
+ if (options.syncUrl === false) return;
106
+ var p = new URLSearchParams(window.location.search);
107
+ ['q', 'tag', 'category', 'medium', 'sort', 'type', 'categoria', 'language', 'ano', 'page'].forEach(function (key) {
108
+ p.delete(key);
109
+ });
110
+ if (state.q) p.set('q', state.q);
111
+ if (state.tag) p.set(options.tagParam || options.categoryParam || 'tag', state.tag);
112
+ if (state.medium) p.set('medium', state.medium);
113
+ if (state.type) p.set('type', state.type);
114
+ if (state.sort && state.sort !== (options.defaultSort || 'updated')) p.set('sort', state.sort);
115
+ var qs = p.toString();
116
+ var next = window.location.pathname + (qs ? '?' + qs : '');
117
+ var current = window.location.pathname + window.location.search;
118
+ if (next !== current) history.replaceState(null, '', next);
119
+ }
120
+
121
+ function applyQueryFromInput() {
122
+ state.q = input ? input.value.trim() : '';
123
+ renderChips();
124
+ syncUrl();
125
+ onChange(state);
126
+ }
127
+
128
+ if (input) {
129
+ input.value = state.q;
130
+ var debounceMs = typeof options.searchDebounceMs === 'number' ? options.searchDebounceMs : 300;
131
+ var debounceTimer = null;
132
+ input.addEventListener('input', function () {
133
+ if (debounceTimer) clearTimeout(debounceTimer);
134
+ debounceTimer = setTimeout(applyQueryFromInput, debounceMs);
135
+ });
136
+ input.addEventListener('keydown', function (ev) {
137
+ if (ev.key === 'Enter') {
138
+ ev.preventDefault();
139
+ if (debounceTimer) clearTimeout(debounceTimer);
140
+ applyQueryFromInput();
141
+ }
142
+ });
143
+ }
144
+
145
+ if (trigger && panel) {
146
+ trigger.addEventListener('click', function () {
147
+ setExpanded(panel.hidden);
148
+ });
149
+ }
150
+
151
+ document.addEventListener('click', function (ev) {
152
+ if (!root || root.hidden) return;
153
+ if (root.contains(ev.target)) return;
154
+ setExpanded(false);
155
+ });
156
+
157
+ renderChips();
158
+
159
+ return {
160
+ root: root,
161
+ getState: function () { return Object.assign({}, state); },
162
+ setVisible: function (visible) {
163
+ root.hidden = !visible;
164
+ if (!visible) setExpanded(false);
165
+ },
166
+ applyQueryFromInput: applyQueryFromInput,
167
+ refreshChipGroups: function (nextGroups) {
168
+ chipGroups = nextGroups || [];
169
+ renderChips();
170
+ }
171
+ };
172
+ },
173
+
174
+ filterItems: function (items, state, options) {
175
+ options = options || {};
176
+ state = state || {};
177
+ var list = (items || []).slice();
178
+ list = list.filter(function (item) {
179
+ if (!matchesFrontMatterQuery(item, state.q)) return false;
180
+ if (state.tag) {
181
+ var fm = item.front_matter || {};
182
+ var itemTags = fm.tags || item.tags;
183
+ if (Array.isArray(itemTags) && itemTags.length) {
184
+ // An entry carries several tags; matching any one of them keeps it.
185
+ if (itemTags.indexOf(state.tag) === -1) return false;
186
+ } else {
187
+ // Resources still classify on a single category axis.
188
+ var cat = fm.category || item.category || '';
189
+ if (cat !== state.tag) return false;
190
+ }
191
+ }
192
+ if (state.medium && typeof options.mediumKey === 'function') {
193
+ if (options.mediumKey(item) !== state.medium) return false;
194
+ }
195
+ if (state.type) {
196
+ var nodeType = (item.collection || item.type || '').toString();
197
+ if (nodeType !== state.type) return false;
198
+ }
199
+ return true;
200
+ });
201
+ if (typeof options.sortItems === 'function') {
202
+ list.sort(function (a, b) { return options.sortItems(a, b, state.sort); });
203
+ }
204
+ return list;
205
+ }
206
+ };
207
+
208
+ {% endraw %}
@@ -0,0 +1,379 @@
1
+ {% raw %}
2
+
3
+ /* Architectural Ledger blocks.
4
+ *
5
+ * Shared renderers for the pieces every listing repeats: meta strips,
6
+ * language pills, tag chips, directional CTAs, and the three card shapes.
7
+ * Each layout used to grow its own copy of these, which is why the same
8
+ * card drifted between the writing index and the home page.
9
+ *
10
+ * House rule: a block renders only when the field backing it is present
11
+ * and non-empty. Nothing here derives a value, counts anything, or
12
+ * substitutes a placeholder — an absent field means an absent element.
13
+ */
14
+
15
+ var LANGUAGE_LABELS = {
16
+ enus: 'EN',
17
+ ptbr: 'PT'
18
+ };
19
+
20
+ /* [EN] / [PT]. Empty language renders nothing rather than a neutral pill,
21
+ * since roughly half the corpus has never declared one.
22
+ *
23
+ * Writing declares `enus` / `ptbr`; resources use the underscored form
24
+ * (`en_us`, `pt_br`) and include a handful of third languages. Separators
25
+ * are dropped before the lookup so both spellings resolve, and a language
26
+ * outside the pair still renders under its primary subtag rather than
27
+ * being discarded — the value is really there.
28
+ */
29
+ function renderLangPill(language, options) {
30
+ var raw = String(language || '').trim().toLowerCase();
31
+ if (!raw) return '';
32
+ var code = raw.replace(/[_-]/g, '');
33
+ var label = LANGUAGE_LABELS[code];
34
+ if (!label) {
35
+ // A regional variant is still the same language: en_gb is English, so
36
+ // it takes the English pill rather than the neutral one. Resolve the
37
+ // primary subtag against the same vocabulary before giving up.
38
+ var primary = code.slice(0, 2);
39
+ var canonical = Object.keys(LANGUAGE_LABELS).filter(function (key) {
40
+ return key.slice(0, 2) === primary;
41
+ })[0];
42
+ if (canonical) {
43
+ label = LANGUAGE_LABELS[canonical];
44
+ code = canonical;
45
+ } else if (options && options.allowUnknown) {
46
+ // Only where a caller genuinely holds languages beyond the two the
47
+ // site publishes in. Sources has one French entry; dropping its pill
48
+ // would discard a value that is really there. Everywhere else an
49
+ // unrecognised code is a typo, and showing it would hide the error.
50
+ label = primary.toUpperCase();
51
+ code = 'other';
52
+ } else {
53
+ return '';
54
+ }
55
+ }
56
+ return [
57
+ '<span class="lang-pill lang-pill--', escapeAttr(code), '" data-language="',
58
+ escapeAttr(raw.replace(/[_-]/g, '')), '">', escapeHtml(label), '</span>'
59
+ ].join('');
60
+ }
61
+
62
+ /* Tag chips. Optional href turns each chip into a filter link. */
63
+ function renderTagChips(tags, options) {
64
+ options = options || {};
65
+ var list = (Array.isArray(tags) ? tags : [])
66
+ .map(function (t) { return String(t || '').trim(); })
67
+ .filter(function (t) { return t !== ''; });
68
+ if (!list.length) return '';
69
+ if (options.limit) list = list.slice(0, options.limit);
70
+
71
+ var chips = list.map(function (tag) {
72
+ var inner = escapeHtml(tag);
73
+ if (typeof options.hrefFor === 'function') {
74
+ return '<a class="ledger-chip" href="' + escapeAttr(options.hrefFor(tag)) +
75
+ '">' + inner + '</a>';
76
+ }
77
+ return '<span class="ledger-chip">' + inner + '</span>';
78
+ });
79
+ return '<div class="ledger-chip-row">' + chips.join('') + '</div>';
80
+ }
81
+
82
+ /* Mono meta strip: the ledger line above a title. Callers pass whatever
83
+ * they have; separators appear only between surviving parts, so a missing
84
+ * category never leaves a dangling slash. */
85
+ function renderMetaStrip(parts, options) {
86
+ options = options || {};
87
+ var kept = (parts || []).filter(function (p) {
88
+ return p != null && String(p).trim() !== '';
89
+ });
90
+ if (!kept.length) return '';
91
+ var sep = '<span class="ledger-meta-sep" aria-hidden="true">/</span>';
92
+ var cls = 'ledger-meta' + (options.className ? ' ' + options.className : '');
93
+ return '<div class="' + cls + '">' + kept.join(sep) + '</div>';
94
+ }
95
+
96
+ /* Directional CTA. Plain calls keep the trailing arrow (label, then a
97
+ * `.ledger-cta-arrow` glyph that slides right on hover) — that motion is
98
+ * a "go to" cue and reads correctly after the label. A destination-typed
99
+ * `icon` (repository/article/external/details) is a leading marker
100
+ * instead, like a small label icon, not a directional cue: it renders
101
+ * before the label in its own `.ledger-cta-icon` span, which does not
102
+ * take part in the hover slide. */
103
+ function renderCtaLink(href, label, options) {
104
+ options = options || {};
105
+ if (!href || !label) return '';
106
+ var cls = 'ledger-cta' + (options.className ? ' ' + options.className : '');
107
+ if (options.icon) {
108
+ return [
109
+ '<a class="', cls, '" href="', escapeAttr(href), '">',
110
+ '<span class="material-symbols-outlined ledger-cta-icon" aria-hidden="true">', escapeHtml(options.icon), '</span>',
111
+ '<span>', escapeHtml(label), '</span>',
112
+ '</a>'
113
+ ].join('');
114
+ }
115
+ return [
116
+ '<a class="', cls, '" href="', escapeAttr(href), '">',
117
+ '<span>', escapeHtml(label), '</span>',
118
+ '<span class="material-symbols-outlined ledger-cta-arrow" aria-hidden="true">chevron_right</span>',
119
+ '</a>'
120
+ ].join('');
121
+ }
122
+
123
+ /* Section banner: kicker, title, and an optional right-aligned note. */
124
+ function renderSectionBanner(options) {
125
+ options = options || {};
126
+ if (!options.title) return '';
127
+ var html = '<div class="ledger-section-banner">';
128
+ html += '<div class="ledger-section-lead">';
129
+ if (options.kicker) {
130
+ html += '<span class="ledger-kicker">' +
131
+ escapeHtml(options.kicker) + '</span>';
132
+ }
133
+ html += '<h2 class="section-title">' + escapeHtml(options.title) + '</h2>';
134
+ html += '</div>';
135
+ if (options.note) {
136
+ html += '<span class="ledger-section-note">' +
137
+ escapeHtml(options.note) + '</span>';
138
+ }
139
+ html += '</div>';
140
+ return html;
141
+ }
142
+
143
+ /* The featured entry.
144
+ *
145
+ * The pick is random, so a listing does not always lead with the same
146
+ * piece — but it is held for the session, so paging through the archive
147
+ * does not reshuffle the lead, and the home page and the writing index
148
+ * agree on which article is featured. Closing the tab draws again.
149
+ *
150
+ * Callers that lead on something other than an article pass their own
151
+ * `storageKey`, so the two picks are independent, and their own
152
+ * `eligible` when the default candidate rule does not apply to them. The
153
+ * default is an article's: only entries carrying a thumbnail, because the
154
+ * panel has an image column and one that reserves space for a picture it
155
+ * does not have is worse than no panel.
156
+ */
157
+ var FEATURED_STORAGE_KEY = 'featured-article-slug';
158
+
159
+ function hasThumbnail(item) {
160
+ var fm = (item && item.front_matter) || {};
161
+ return String(fm.thumbnail || '').trim() !== '';
162
+ }
163
+
164
+ function readSessionSlug(key) {
165
+ try {
166
+ return window.sessionStorage.getItem(key);
167
+ } catch (error) {
168
+ return null; // private mode, or storage disabled
169
+ }
170
+ }
171
+
172
+ function writeSessionSlug(key, slug) {
173
+ try {
174
+ window.sessionStorage.setItem(key, slug);
175
+ } catch (error) {
176
+ /* Not being able to remember the pick is survivable: the page still
177
+ renders, it just may lead with a different entry next navigation. */
178
+ }
179
+ }
180
+
181
+ function pickFeatured(items, options) {
182
+ var opts = options || {};
183
+ var key = opts.storageKey || FEATURED_STORAGE_KEY;
184
+ var candidates = (items || []).filter(opts.eligible || hasThumbnail);
185
+ if (!candidates.length) return null;
186
+
187
+ var held = readSessionSlug(key);
188
+ if (held) {
189
+ var match = candidates.filter(function (item) { return item.slug === held; })[0];
190
+ if (match) return match;
191
+ }
192
+
193
+ var chosen = candidates[Math.floor(Math.random() * candidates.length)];
194
+ if (chosen && chosen.slug) writeSessionSlug(key, chosen.slug);
195
+ return chosen;
196
+ }
197
+
198
+ /* Shared card internals, so the three shapes below cannot drift apart. */
199
+ function blockFields(item) {
200
+ var fm = (item && item.front_matter) || item || {};
201
+ return {
202
+ fm: fm,
203
+ title: fm.title || item.title || item.slug || '',
204
+ date: fm.date || item.date || '',
205
+ lastUpdated: fm.last_updated || item.last_updated || '',
206
+ language: fm.language || item.language || '',
207
+ tags: fm.tags || item.tags || [],
208
+ preview: typeof listingPreview === 'function' ? listingPreview(item) : ''
209
+ };
210
+ }
211
+
212
+ /* Ledger row — the dense horizontal entry used by the rascunhos folio and
213
+ * the writing archive. */
214
+ function renderLedgerRow(item, options) {
215
+ options = options || {};
216
+ var f = blockFields(item);
217
+ var href = options.href || '#';
218
+ var locale = options.locale || 'en';
219
+
220
+ // Callers may supply their own strip parts, the way renderSystemCard
221
+ // does; the default is the date and the language pill. A listing whose
222
+ // entries are all one language gets nothing from the pill and has its
223
+ // own facts to put in that slot, and passing them in beats patching the
224
+ // strip after construction.
225
+ var meta = renderMetaStrip(options.metaParts || [
226
+ formatDate(f.date, locale),
227
+ renderLangPill(f.language)
228
+ ]);
229
+
230
+ var row = document.createElement('article');
231
+ row.className = 'ledger-row group';
232
+
233
+ var html = '';
234
+ if (meta) html += '<div class="ledger-row-head">' + meta + '</div>';
235
+ html += '<h3 class="ledger-row-title card-title"><a href="' +
236
+ escapeAttr(href) + '">' + escapeHtml(f.title) + '</a></h3>';
237
+ if (f.preview) {
238
+ html += '<p class="ledger-row-copy font-body">' +
239
+ escapeHtml(f.preview) + '</p>';
240
+ }
241
+
242
+ var chips = renderTagChips(f.tags, { hrefFor: options.tagHref });
243
+ var cta = renderCtaLink(href, options.ctaLabel);
244
+ if (chips || cta) {
245
+ html += '<div class="ledger-row-foot">' + chips + cta + '</div>';
246
+ }
247
+
248
+ row.innerHTML = html;
249
+ return row;
250
+ }
251
+
252
+ /* Archive card — the vertical card used on grids. */
253
+ function renderArchiveCard(item, options) {
254
+ options = options || {};
255
+ var f = blockFields(item);
256
+ var href = options.href || '#';
257
+ var locale = options.locale || 'en';
258
+
259
+ var card = document.createElement('article');
260
+ card.className = 'ledger-card group';
261
+ card.setAttribute('data-dynamic-article', 'true');
262
+
263
+ var html = '<div class="ledger-card-body">';
264
+ // Callers may supply their own strip parts, as renderLedgerRow lets
265
+ // them; the default is the date and the language pill. A grid whose
266
+ // entries are all one language gets nothing from the pill.
267
+ var meta = renderMetaStrip(options.metaParts || [
268
+ formatDate(f.date, locale),
269
+ renderLangPill(f.language)
270
+ ]);
271
+ if (meta) html += meta;
272
+ html += '<h3 class="card-title ledger-card-title"><a href="' +
273
+ escapeAttr(href) + '">' + escapeHtml(f.title) + '</a></h3>';
274
+ if (f.preview) {
275
+ html += '<p class="ledger-card-copy font-body">' +
276
+ escapeHtml(f.preview) + '</p>';
277
+ }
278
+ html += renderTagChips(f.tags, { hrefFor: options.tagHref, limit: options.tagLimit });
279
+ // The meta strip above already carries the date, so the footer copy is
280
+ // opt-in rather than the default — printing it twice is the common bug.
281
+ var foot = '';
282
+ if (options.showFooterDates) {
283
+ foot += renderCardFooterDates(f.date, f.lastUpdated, locale);
284
+ }
285
+ foot += renderCtaLink(href, options.ctaLabel);
286
+ // .ledger-card-foot draws a rule above itself, so an empty one rules off
287
+ // nothing. renderLedgerRow guards this the same way.
288
+ if (foot) html += '<div class="ledger-card-foot">' + foot + '</div>';
289
+ html += '</div>';
290
+
291
+ card.innerHTML = html;
292
+ return card;
293
+ }
294
+
295
+ /* Card media. Stills, not clips: the simulation captures these projects
296
+ * publish run to several megabytes each (ARCO's city race is 5.9 MB,
297
+ * FRET's 3.5 MB), and autoplaying a grid of them on an index page is not
298
+ * defensible on a mobile connection. The videos stay in the project
299
+ * bodies, where a reader has chosen to be.
300
+ *
301
+ * A video URL still renders rather than vanishing, but it downloads
302
+ * nothing until the reader asks for it. */
303
+ function renderPosterMedia(src, alt) {
304
+ var url = String(src || '').trim();
305
+ if (!url) return '';
306
+ var isVideo = /\.(mp4|webm|mov)(\?|#|$)/i.test(url);
307
+ if (isVideo) {
308
+ return [
309
+ '<div class="ledger-poster">',
310
+ '<video src="', escapeAttr(url), '" controls preload="none" ',
311
+ 'playsinline aria-label="', escapeAttr(alt || ''), '"></video>',
312
+ '</div>'
313
+ ].join('');
314
+ }
315
+ return [
316
+ '<div class="ledger-poster">',
317
+ '<img src="', escapeAttr(url), '" alt="', escapeAttr(alt || ''),
318
+ '" loading="lazy" decoding="async"',
319
+ // Thumbnails are frequently hotlinked to third-party hosts. When one
320
+ // dies, remove the frame rather than leaving an empty plate beNewsreader.
321
+ ' onerror="var p=this.closest(\'.ledger-poster\'); if (p) p.remove();"',
322
+ '>',
323
+ '</div>'
324
+ ].join('');
325
+ }
326
+
327
+ /* System card — the project shape. Carries the fields projects actually
328
+ * have: label, status, and a repository link. */
329
+ function renderSystemCard(item, options) {
330
+ options = options || {};
331
+ var f = blockFields(item);
332
+ var fm = f.fm;
333
+ var href = options.href || '#';
334
+ var name = fm.project || f.title;
335
+
336
+ var card = document.createElement('article');
337
+ card.className = 'ledger-system-card group';
338
+
339
+ var html = renderPosterMedia(fm.thumbnail, name);
340
+ // Callers may supply their own strip parts; the default is whatever the
341
+ // project declares about itself.
342
+ var parts = options.metaParts || [
343
+ fm.label ? escapeHtml(fm.label) : '',
344
+ fm.status ? '<span class="ledger-status">' + escapeHtml(fm.status) + '</span>' : ''
345
+ ];
346
+ var head = renderMetaStrip(parts);
347
+ if (head) html += head;
348
+
349
+ // The title's anchor is the card's only link to the project, and CSS
350
+ // stretches it over the whole card (see .ledger-system-card in
351
+ // base.css), so the poster and the tagline navigate as well. That is
352
+ // what replaced the Details CTA this footer used to carry.
353
+ html += '<h3 class="card-title ledger-card-title"><a class="ledger-card-stretch" href="' +
354
+ escapeAttr(href) + '">' + escapeHtml(name) + '</a></h3>';
355
+
356
+ var summary = fm.tagline || f.preview;
357
+ if (summary) {
358
+ html += '<p class="ledger-card-copy font-body">' +
359
+ escapeHtml(summary) + '</p>';
360
+ }
361
+
362
+ html += renderTagChips(f.tags, { hrefFor: options.tagHref });
363
+
364
+ // Outbound links only. `.ledger-card-foot` draws a rule above itself, so
365
+ // an empty one rules off nothing; renderLedgerRow and renderArchiveCard
366
+ // guard it the same way.
367
+ var links = [];
368
+ if (fm.github) links.push(renderCtaLink(fm.github, options.repoLabel || 'Repository', { icon: 'code' }));
369
+ if (fm.article_url) links.push(renderCtaLink(fm.article_url, options.articleLabel || 'Article', { icon: 'article' }));
370
+ if (fm.external_url) links.push(renderCtaLink(fm.external_url, options.externalLabel || 'Visit', { icon: 'public' }));
371
+ if (links.length) {
372
+ html += '<div class="ledger-card-foot ledger-card-links">' + links.join('') + '</div>';
373
+ }
374
+
375
+ card.innerHTML = html;
376
+ return card;
377
+ }
378
+
379
+ {% endraw %}