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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +77 -0
- data/DESIGN.md +1434 -0
- data/LICENSE +21 -0
- data/README.md +81 -0
- data/_data/themes/dark.yml +97 -0
- data/_data/themes/light.yml +120 -0
- data/_data/themes/shared.yml +37 -0
- data/_data/translations.yml +85 -0
- data/_includes/chord-tab.html +38 -0
- data/_includes/content-runtime/00-config.html +22 -0
- data/_includes/content-runtime/10-utils.html +176 -0
- data/_includes/content-runtime/20-markdown.html +264 -0
- data/_includes/content-runtime/25-code-blocks.html +315 -0
- data/_includes/content-runtime/30-includes-bib.html +190 -0
- data/_includes/content-runtime/30-includes-xcite.html +122 -0
- data/_includes/content-runtime/30-includes.html +365 -0
- data/_includes/content-runtime/40-listing.html +225 -0
- data/_includes/content-runtime/41-listing-helpers.html +71 -0
- data/_includes/content-runtime/42-listing-filter.html +208 -0
- data/_includes/content-runtime/43-blocks.html +379 -0
- data/_includes/content-runtime/50-detail.html +861 -0
- data/_includes/content-runtime/55-toc.html +176 -0
- data/_includes/content-runtime/60-fragments.html +567 -0
- data/_includes/content-runtime/90-bootstrap.html +76 -0
- data/_includes/cut-in.html +10 -0
- data/_includes/cv/cv-box.html +18 -0
- data/_includes/dict-card.html +24 -0
- data/_includes/figure.html +36 -0
- data/_includes/home/band-index.html +43 -0
- data/_includes/home/band-listing.html +49 -0
- data/_includes/mathjax.html +10 -0
- data/_includes/ongoing-product.html +1 -0
- data/_includes/page/content-runtime.html +20 -0
- data/_includes/page/date_evolution.html +19 -0
- data/_includes/page/figure-lightbox.html +102 -0
- data/_includes/page/formatted_date.html +15 -0
- data/_includes/page/hero-default.html +18 -0
- data/_includes/page/listing-runtime-attrs.html +8 -0
- data/_includes/page/mermaid.html +101 -0
- data/_includes/page/network-graph.html +1253 -0
- data/_includes/page/page-header.html +41 -0
- data/_includes/page/professional-connections.html +2 -0
- data/_includes/page/site-footer.html +101 -0
- data/_includes/page/timeline-section.html +868 -0
- data/_includes/plotly.html +22 -0
- data/_includes/theme/dev-reexport.html +100 -0
- data/_includes/theme/favicons.html +4 -0
- data/_includes/theme/font-loader.html +55 -0
- data/_includes/theme/shell-theme-boot.html +115 -0
- data/_includes/theme/site-logo.html +6 -0
- data/_includes/theme/tailwind-theme-colors.json.liquid +65 -0
- data/_includes/theme/tailwind-theme-fonts.json.liquid +6 -0
- data/_includes/theme/theme-context.liquid +20 -0
- data/_includes/theme/theme-toggle.html +94 -0
- data/_includes/theme/theme-vars-block.liquid +161 -0
- data/_includes/theme/theme-vars.html +16 -0
- data/_includes/transcription.html +80 -0
- data/_includes/tweet.html +22 -0
- data/_includes/video.html +11 -0
- data/_includes/xcite.html +3 -0
- data/_includes/youtube-short.html +26 -0
- data/_includes/youtube.html +12 -0
- data/_layouts/article.html +27 -0
- data/_layouts/articles.html +534 -0
- data/_layouts/bibliography.html +417 -0
- data/_layouts/blog.html +544 -0
- data/_layouts/cv.html +393 -0
- data/_layouts/home.html +319 -0
- data/_layouts/network.html +9 -0
- data/_layouts/pandorga/articles.html +4 -0
- data/_layouts/pandorga/bibliography.html +4 -0
- data/_layouts/pandorga/blog.html +4 -0
- data/_layouts/pandorga/cv.html +4 -0
- data/_layouts/pandorga/media.html +4 -0
- data/_layouts/pandorga/network.html +4 -0
- data/_layouts/pandorga/portfolio.html +4 -0
- data/_layouts/post.html +7 -0
- data/_layouts/product.html +5 -0
- data/_layouts/project.html +27 -0
- data/_layouts/projects.html +534 -0
- data/_layouts/resources.html +387 -0
- data/_layouts/shell.html +203 -0
- data/_layouts/text.html +246 -0
- data/_plugins/bibliography_artifact.rb +57 -0
- data/_plugins/content_backend.rb +37 -0
- data/_plugins/content_collections.rb +51 -0
- data/_plugins/content_detail_paths.rb +58 -0
- data/_plugins/content_headers.rb +73 -0
- data/_plugins/content_keys.rb +310 -0
- data/_plugins/content_local_serve.rb +168 -0
- data/_plugins/content_site_adapter.rb +159 -0
- data/_plugins/content_validators_hook.rb +20 -0
- data/_plugins/content_writing_index.rb +52 -0
- data/_plugins/content_writing_paths.rb +55 -0
- data/_plugins/force_utf8_encoding.rb +13 -0
- data/_plugins/git_chronology.rb +211 -0
- data/_plugins/git_metadata.rb +43 -0
- data/_plugins/job_sections.rb +25 -0
- data/_plugins/lib/content_validators.rb +27 -0
- data/_plugins/lib/resource_validator.rb +56 -0
- data/_plugins/lib/tag_validator.rb +153 -0
- data/_plugins/lib/text_excerpt.rb +56 -0
- data/_plugins/lib/writing_entry.rb +107 -0
- data/_plugins/local_detail_rewrites.rb +194 -0
- data/_plugins/timeline.rb +507 -0
- data/_plugins/writing_slug_limits.rb +70 -0
- data/assets/css/article.css +1330 -0
- data/assets/css/base.css +1432 -0
- data/assets/css/cv.css +13 -0
- data/assets/css/global-link.css +94 -0
- data/assets/css/ledger/bibliography.css +307 -0
- data/assets/css/ledger/chrome.css +118 -0
- data/assets/css/ledger/cv.css +836 -0
- data/assets/css/ledger/detail.css +1270 -0
- data/assets/css/ledger/footer.css +298 -0
- data/assets/css/ledger/home.css +1490 -0
- data/assets/css/ledger/network.css +106 -0
- data/assets/css/ledger/projects.css +716 -0
- data/assets/css/ledger/rascunhos.css +379 -0
- data/assets/css/ledger/sources.css +327 -0
- data/assets/css/ledger/writing.css +754 -0
- data/assets/tailwind-play.js +15 -0
- data/examples/full/Gemfile +7 -0
- data/examples/full/_config.yml +92 -0
- data/examples/full/_redirects +3 -0
- data/examples/full/content/collections/articles/2026-01-01-welcome.md +9 -0
- data/examples/full/content/collections/data/contacts.yml +3 -0
- data/examples/full/content/collections/data/education.yml +4 -0
- data/examples/full/content/collections/jobs/research-fellow.md +12 -0
- data/examples/full/content/collections/posts/2026-01-02-note.md +9 -0
- data/examples/full/content/collections/products/ledger-tool.md +6 -0
- data/examples/full/content/collections/projects/demo-kit.md +10 -0
- data/examples/full/content/collections/resources/style-guide.md +7 -0
- data/examples/full/content/pages/cv/summary.md +1 -0
- data/examples/full/content/pages/headers.yml +20 -0
- data/examples/full/index.html +4 -0
- data/examples/minimal/Gemfile +7 -0
- data/examples/minimal/_config.yml +47 -0
- data/examples/minimal/_redirects +3 -0
- data/examples/minimal/content/collections/articles/2026-01-01-hello.md +10 -0
- data/examples/minimal/content/collections/articles/2026-01-02-second.md +9 -0
- data/examples/minimal/content/pages/headers.yml +6 -0
- data/examples/minimal/index.html +4 -0
- data/exe/pandorga +7 -0
- data/functions/api/studio/[[path]].js +562 -0
- data/functions/api/studio/_lib/auth.js +131 -0
- data/functions/api/studio/_lib/blocks.js +156 -0
- data/functions/api/studio/_lib/gemini.js +62 -0
- data/functions/api/studio/_lib/github.js +278 -0
- data/functions/api/studio/_lib/guidelines.js +143 -0
- data/functions/api/studio/_lib/paths.js +76 -0
- data/functions/api/studio/_lib/prompts.js +165 -0
- data/functions/api/studio/_lib/rate-limit.js +40 -0
- data/functions/api/studio/_lib/rewrite.js +136 -0
- data/functions/api/studio/_lib/sanitize.js +143 -0
- data/lib/pandorga/cli.rb +66 -0
- data/lib/pandorga/commands/doctor.rb +66 -0
- data/lib/pandorga/commands/export.rb +30 -0
- data/lib/pandorga/commands/install_functions.rb +41 -0
- data/lib/pandorga/commands/new.rb +113 -0
- data/lib/pandorga/commands/publish.rb +53 -0
- data/lib/pandorga/commands/serve.rb +34 -0
- data/lib/pandorga/commands/studio_schema.rb +309 -0
- data/lib/pandorga/jekyll/generator.rb +74 -0
- data/lib/pandorga/jekyll/plugins/bibliography_artifact.rb +57 -0
- data/lib/pandorga/jekyll/plugins/content_backend.rb +37 -0
- data/lib/pandorga/jekyll/plugins/content_collections.rb +51 -0
- data/lib/pandorga/jekyll/plugins/content_detail_paths.rb +58 -0
- data/lib/pandorga/jekyll/plugins/content_headers.rb +73 -0
- data/lib/pandorga/jekyll/plugins/content_keys.rb +310 -0
- data/lib/pandorga/jekyll/plugins/content_local_serve.rb +168 -0
- data/lib/pandorga/jekyll/plugins/content_site_adapter.rb +159 -0
- data/lib/pandorga/jekyll/plugins/content_validators_hook.rb +20 -0
- data/lib/pandorga/jekyll/plugins/content_writing_index.rb +52 -0
- data/lib/pandorga/jekyll/plugins/content_writing_paths.rb +55 -0
- data/lib/pandorga/jekyll/plugins/force_utf8_encoding.rb +13 -0
- data/lib/pandorga/jekyll/plugins/git_chronology.rb +211 -0
- data/lib/pandorga/jekyll/plugins/git_metadata.rb +43 -0
- data/lib/pandorga/jekyll/plugins/job_sections.rb +25 -0
- data/lib/pandorga/jekyll/plugins/lib/content_validators.rb +27 -0
- data/lib/pandorga/jekyll/plugins/lib/resource_validator.rb +56 -0
- data/lib/pandorga/jekyll/plugins/lib/tag_validator.rb +153 -0
- data/lib/pandorga/jekyll/plugins/lib/text_excerpt.rb +56 -0
- data/lib/pandorga/jekyll/plugins/lib/writing_entry.rb +107 -0
- data/lib/pandorga/jekyll/plugins/local_detail_rewrites.rb +194 -0
- data/lib/pandorga/jekyll/plugins/timeline.rb +507 -0
- data/lib/pandorga/jekyll/plugins/writing_slug_limits.rb +70 -0
- data/lib/pandorga/jekyll.rb +15 -0
- data/lib/pandorga/object_store_env.rb +51 -0
- data/lib/pandorga/registry/schema.yml +115 -0
- data/lib/pandorga/registry.rb +279 -0
- data/lib/pandorga/version.rb +5 -0
- data/lib/pandorga.rb +19 -0
- data/scripts/cdn-allowlist.json +19 -0
- data/scripts/content/backfill-content-keys.rb +99 -0
- data/scripts/content/band_art.rb +362 -0
- data/scripts/content/export-content-json.rb +741 -0
- data/scripts/content/flatten-ansi-capture.mjs +153 -0
- data/scripts/content/hero_portraits.rb +28 -0
- data/scripts/content/publish-content-to-object-store.sh +78 -0
- data/scripts/content/publish-content-to-r2.sh +5 -0
- data/scripts/content/sync-content-json-to-r2.rb +167 -0
- data/scripts/content/sync-content-redirects.rb +12 -0
- data/scripts/generate-configuration-doc.rb +127 -0
- data/scripts/lib/content_license.rb +50 -0
- data/scripts/lib/content_storage_config.rb +70 -0
- data/scripts/lib/normalize-display-math.mjs +52 -0
- data/scripts/lib/r2_json_sync.rb +76 -0
- data/scripts/lib/withdrawn_public_objects.rb +23 -0
- data/scripts/lib/writing_index_contract.rb +61 -0
- data/scripts/pdf/md-to-pdf.mjs +1400 -0
- data/scripts/pdf/md-to-pdf.sh +27 -0
- data/scripts/test/fixtures/dual-template/_config.yml +32 -0
- data/scripts/test/fixtures/dual-template/index.html +4 -0
- data/scripts/test/render-parity/article-note.json +5 -0
- data/scripts/test/render-parity/blockquote.json +5 -0
- data/scripts/test/render-parity/blocks/ansi-basic.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-bold-is-bright.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-drops-cursor-moves.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-escapes-markup.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-extended-colour.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-real-control-byte.json +7 -0
- data/scripts/test/render-parity/blocks/archive-card-date-once.json +19 -0
- data/scripts/test/render-parity/blocks/archive-card-empty-foot.json +7 -0
- data/scripts/test/render-parity/blocks/archive-card-footer-dates.json +22 -0
- data/scripts/test/render-parity/blocks/archive-card-meta-parts.json +26 -0
- data/scripts/test/render-parity/blocks/archive-card.json +32 -0
- data/scripts/test/render-parity/blocks/cta-link.json +13 -0
- data/scripts/test/render-parity/blocks/lang-pill-pt.json +8 -0
- data/scripts/test/render-parity/blocks/lang-pill.json +8 -0
- data/scripts/test/render-parity/blocks/ledger-row-meta-parts.json +29 -0
- data/scripts/test/render-parity/blocks/ledger-row.json +35 -0
- data/scripts/test/render-parity/blocks/meta-strip.json +27 -0
- data/scripts/test/render-parity/blocks/poster-media-image.json +8 -0
- data/scripts/test/render-parity/blocks/poster-media-video.json +9 -0
- data/scripts/test/render-parity/blocks/section-banner.json +13 -0
- data/scripts/test/render-parity/blocks/system-card-custom-meta.json +22 -0
- data/scripts/test/render-parity/blocks/system-card.json +48 -0
- data/scripts/test/render-parity/blocks/tag-chips-links.json +12 -0
- data/scripts/test/render-parity/blocks/tag-chips.json +9 -0
- data/scripts/test/render-parity/cut-in-blank-line.json +9 -0
- data/scripts/test/render-parity/cut-in-right.json +9 -0
- data/scripts/test/render-parity/cut-in.json +8 -0
- data/scripts/test/render-parity/figure.json +5 -0
- data/scripts/test/render-parity/load-runtime.mjs +337 -0
- data/scripts/test/render-parity/ongoing-product.json +5 -0
- data/scripts/test/render-parity/plotly.json +5 -0
- data/scripts/test/render-parity/poem.json +5 -0
- data/scripts/test/render-parity/run-absent-field-checks.mjs +232 -0
- data/scripts/test/render-parity/run-block-fixture.mjs +86 -0
- data/scripts/test/render-parity/run-detail-parity-checks.mjs +123 -0
- data/scripts/test/render-parity/run-field-flow-checks.mjs +227 -0
- data/scripts/test/render-parity/run-fixture.mjs +159 -0
- data/scripts/test/render-parity/selectors.mjs +43 -0
- data/scripts/test/render-parity/video.json +5 -0
- data/scripts/test/render-parity/youtube-short.json +10 -0
- data/scripts/test/render-parity/youtube.json +8 -0
- data/scripts/test/test-absent-field-absent-element.rb +43 -0
- data/scripts/test/test-cdn-pins.rb +44 -0
- data/scripts/test/test-cloudflare-watch-paths-doc.rb +31 -0
- data/scripts/test/test-configuration-doc.rb +36 -0
- data/scripts/test/test-content-detail-urls.rb +346 -0
- data/scripts/test/test-content-keys.rb +110 -0
- data/scripts/test/test-content-license.rb +45 -0
- data/scripts/test/test-content-render-parity.rb +38 -0
- data/scripts/test/test-content-validators.rb +12 -0
- data/scripts/test/test-css-structure.rb +72 -0
- data/scripts/test/test-cv-export-contract.rb +158 -0
- data/scripts/test/test-detail-render-parity.rb +40 -0
- data/scripts/test/test-dev-reexport.rb +122 -0
- data/scripts/test/test-doctor.rb +39 -0
- data/scripts/test/test-dual-template-instances.rb +87 -0
- data/scripts/test/test-dual-template-runtime.rb +167 -0
- data/scripts/test/test-examples-build.rb +60 -0
- data/scripts/test/test-export-content-json-incremental.rb +138 -0
- data/scripts/test/test-export.rb +53 -0
- data/scripts/test/test-exported-fields-reach-the-view.rb +60 -0
- data/scripts/test/test-form-control-theming.rb +143 -0
- data/scripts/test/test-git-chronology.rb +414 -0
- data/scripts/test/test-hero-portraits.mjs +97 -0
- data/scripts/test/test-hero-portraits.rb +162 -0
- data/scripts/test/test-home-band-art.rb +135 -0
- data/scripts/test/test-install-functions.rb +39 -0
- data/scripts/test/test-language-export.rb +79 -0
- data/scripts/test/test-listing-filter-contract.rb +119 -0
- data/scripts/test/test-math-row-breaks.mjs +79 -0
- data/scripts/test/test-math-row-breaks.rb +18 -0
- data/scripts/test/test-new.rb +113 -0
- data/scripts/test/test-no-personal-data.rb +85 -0
- data/scripts/test/test-public-cv-export.rb +164 -0
- data/scripts/test/test-r2-config-consistency.rb +57 -0
- data/scripts/test/test-r2-json-sync.rb +193 -0
- data/scripts/test/test-registry-home.rb +34 -0
- data/scripts/test/test-runtime-js-syntax.rb +64 -0
- data/scripts/test/test-slim-writing-index.rb +126 -0
- data/scripts/test/test-studio-api-contract.rb +99 -0
- data/scripts/test/test-studio-content-files.rb +79 -0
- data/scripts/test/test-studio-liquid-roundtrip.mjs +179 -0
- data/scripts/test/test-studio-rate-limit.mjs +70 -0
- data/scripts/test/test-studio-rewrite-api-contract.rb +59 -0
- data/scripts/test/test-studio-rewrite-sanitize.mjs +210 -0
- data/scripts/test/test-studio-schema-composition.rb +126 -0
- data/scripts/test/test-studio-slug-config.rb +167 -0
- data/scripts/test/test-template-packages.rb +51 -0
- data/scripts/test/test-text-excerpt.rb +51 -0
- data/scripts/test/test-unwrap-media-src.mjs +39 -0
- data/scripts/test/test-unwrap-media-src.rb +19 -0
- data/scripts/test/test-withdrawn-content-export.rb +69 -0
- data/scripts/test/test-writing-index-export-slugs.rb +84 -0
- data/scripts/test/test-writing-slug-limits.rb +61 -0
- data/scripts/test/test-xcite-chronology.rb +106 -0
- data/scripts/validate.sh +74 -0
- data/scripts/visual/build-cv-pages-json.js +33 -0
- data/scripts/visual/build-site-pages-json.js +94 -0
- data/scripts/visual/publish-visual-inspection-to-r2.sh +42 -0
- data/scripts/visual/take-screenshots.js +88 -0
- data/scripts/visual/visual-inspection-pages.json +113 -0
- data/studio/assets/index-B_j0pMD5.css +1 -0
- data/studio/assets/index-Ch4YEv9g.js +285 -0
- data/studio/index.html +27 -0
- data/studio/schema.yml +869 -0
- data/studio-app/README.md +27 -0
- data/studio-app/index.html +26 -0
- data/studio-app/package-lock.json +2499 -0
- data/studio-app/package.json +26 -0
- data/studio-app/skills-lock.json +47 -0
- data/studio-app/src/api/client.js +71 -0
- data/studio-app/src/editor/body-editor.js +70 -0
- data/studio-app/src/editor/liquid-core.js +382 -0
- data/studio-app/src/editor/liquid.js +77 -0
- data/studio-app/src/lib/document.js +207 -0
- data/studio-app/src/lib/list-controls.js +317 -0
- data/studio-app/src/lib/schema.js +66 -0
- data/studio-app/src/lib/yaml-doc.js +100 -0
- data/studio-app/src/main.js +3693 -0
- data/studio-app/src/media/browser.js +578 -0
- data/studio-app/src/preview/render.js +430 -0
- data/studio-app/src/styles/studio.css +2826 -0
- data/studio-app/vite.config.js +25 -0
- data/templates/articles/README.md +40 -0
- data/templates/articles/fixtures/article.md +9 -0
- data/templates/articles/screenshots/README.md +6 -0
- data/templates/articles/studio.yml +73 -0
- data/templates/articles/template.yml +21 -0
- data/templates/bibliography/README.md +41 -0
- data/templates/bibliography/fixtures/references.json +9 -0
- data/templates/bibliography/screenshots/README.md +6 -0
- data/templates/bibliography/studio.yml +66 -0
- data/templates/bibliography/template.yml +18 -0
- data/templates/blog/README.md +38 -0
- data/templates/blog/fixtures/post.md +9 -0
- data/templates/blog/screenshots/README.md +6 -0
- data/templates/blog/studio.yml +69 -0
- data/templates/blog/template.yml +20 -0
- data/templates/cv/README.md +34 -0
- data/templates/cv/fixtures/job.md +12 -0
- data/templates/cv/screenshots/README.md +6 -0
- data/templates/cv/studio.yml +200 -0
- data/templates/cv/template.yml +25 -0
- data/templates/media/README.md +38 -0
- data/templates/media/fixtures/resource.md +7 -0
- data/templates/media/screenshots/README.md +6 -0
- data/templates/media/studio.yml +58 -0
- data/templates/media/template.yml +17 -0
- data/templates/network/README.md +34 -0
- data/templates/network/fixtures/sources.json +3 -0
- data/templates/network/screenshots/README.md +6 -0
- data/templates/network/studio.yml +28 -0
- data/templates/network/template.yml +10 -0
- data/templates/portfolio/README.md +39 -0
- data/templates/portfolio/fixtures/project.md +10 -0
- data/templates/portfolio/screenshots/README.md +6 -0
- data/templates/portfolio/studio.yml +89 -0
- data/templates/portfolio/template.yml +18 -0
- 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("&", "&")
|
|
358
|
+
.gsub("<", "<")
|
|
359
|
+
.gsub(">", ">")
|
|
360
|
+
.gsub('"', """)
|
|
361
|
+
end
|
|
362
|
+
end
|