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,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// run-detail-parity-checks.mjs — the two detail paths must agree.
|
|
3
|
+
//
|
|
4
|
+
// A detail page is built twice. The public site renders it in the browser
|
|
5
|
+
// from _includes/content-runtime/50-detail.html, which delegates to the
|
|
6
|
+
// shared blocks; the private/studio build renders the same page at build time
|
|
7
|
+
// from _layouts/text.html, which reimplements the tag chips and the language
|
|
8
|
+
// pill in Liquid because Liquid cannot call the JS. That layout's own comment
|
|
9
|
+
// promises it emits "exactly the class contract" the runtime emits, and
|
|
10
|
+
// assets/css/ledger/detail.css styles both from one set of rules — so the day
|
|
11
|
+
// the two disagree, one of them loses its styling and the build says nothing.
|
|
12
|
+
//
|
|
13
|
+
// Prints the contract it found, for the Ruby gate to relay.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { loadRuntime, toHtml, REPO_ROOT } from './load-runtime.mjs';
|
|
18
|
+
|
|
19
|
+
const LIQUID_DETAIL = path.join(REPO_ROOT, '_layouts', 'text.html');
|
|
20
|
+
const RUNTIME_DETAIL = path.join(REPO_ROOT, '_includes', 'content-runtime', '50-detail.html');
|
|
21
|
+
const BLOCKS = path.join(REPO_ROOT, '_includes', 'content-runtime', '43-blocks.html');
|
|
22
|
+
|
|
23
|
+
const { runtime } = loadRuntime();
|
|
24
|
+
const failures = [];
|
|
25
|
+
|
|
26
|
+
const liquid = fs.readFileSync(LIQUID_DETAIL, 'utf8');
|
|
27
|
+
const sidebar = fs.readFileSync(RUNTIME_DETAIL, 'utf8');
|
|
28
|
+
|
|
29
|
+
// The runtime detail view must delegate, not grow its own copy.
|
|
30
|
+
['renderTagChips'].forEach((block) => {
|
|
31
|
+
if (!new RegExp(`${block}\\s*\\(`).test(sidebar)) {
|
|
32
|
+
failures.push(
|
|
33
|
+
`50-detail.html no longer calls ${block}; the detail sidebar has forked from the block library`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Class contract: whatever the shared block emits, the Liquid copy must emit.
|
|
39
|
+
function classesIn(html) {
|
|
40
|
+
const found = new Set();
|
|
41
|
+
const attr = /class="([^"]*)"/g;
|
|
42
|
+
let match;
|
|
43
|
+
while ((match = attr.exec(html)) !== null) {
|
|
44
|
+
match[1].split(/\s+/).filter(Boolean).forEach((name) => found.add(name));
|
|
45
|
+
}
|
|
46
|
+
return [...found];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const contract = [
|
|
50
|
+
{ label: 'tag chips (linked)', html: toHtml(runtime.renderTagChips(['Engineering'], { hrefFor: () => '/pages/articles/?tag=Engineering' })) },
|
|
51
|
+
{ label: 'tag chips (plain)', html: toHtml(runtime.renderTagChips(['Engineering'])) }
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// A BEM-style modifier is interpolated on the Liquid side
|
|
55
|
+
// (`lang-pill--{{ page.language }}`), so the stem is what can be compared.
|
|
56
|
+
function emittedByLiquid(name) {
|
|
57
|
+
if (liquid.includes(name)) return true;
|
|
58
|
+
const stem = name.split('--')[0];
|
|
59
|
+
return stem !== name && liquid.includes(`${stem}--`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const reported = new Set();
|
|
63
|
+
contract.forEach(({ label, html }) => {
|
|
64
|
+
if (!html) {
|
|
65
|
+
failures.push(`${label}: the block rendered nothing for a populated field`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
classesIn(html).forEach((name) => {
|
|
69
|
+
reported.add(name);
|
|
70
|
+
if (!emittedByLiquid(name)) {
|
|
71
|
+
failures.push(
|
|
72
|
+
`_layouts/text.html never emits "${name}", which the runtime ${label} does. ` +
|
|
73
|
+
'The private detail build would lose that styling.'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Language vocabulary: one table in JS, one chain of Liquid conditionals.
|
|
80
|
+
const blocks = fs.readFileSync(BLOCKS, 'utf8');
|
|
81
|
+
const table = blocks.match(/var LANGUAGE_LABELS = \{([\s\S]*?)\};/);
|
|
82
|
+
if (!table) {
|
|
83
|
+
failures.push('could not find LANGUAGE_LABELS in 43-blocks.html');
|
|
84
|
+
} else {
|
|
85
|
+
const runtimeLabels = {};
|
|
86
|
+
table[1].replace(/(\w+)\s*:\s*'([^']*)'/g, (_, code, label) => {
|
|
87
|
+
runtimeLabels[code] = label;
|
|
88
|
+
return '';
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const liquidLabels = {};
|
|
92
|
+
liquid.replace(
|
|
93
|
+
/page\.language\s*==\s*"(\w+)"\s*%\}\{%\s*assign\s+lang_label\s*=\s*"([^"]*)"/g,
|
|
94
|
+
(_, code, label) => {
|
|
95
|
+
liquidLabels[code] = label;
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (Object.keys(liquidLabels).length === 0) {
|
|
101
|
+
failures.push('found no lang_label assignments in _layouts/text.html; the scan has drifted');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const codes = new Set([...Object.keys(runtimeLabels), ...Object.keys(liquidLabels)]);
|
|
105
|
+
codes.forEach((code) => {
|
|
106
|
+
if (runtimeLabels[code] !== liquidLabels[code]) {
|
|
107
|
+
failures.push(
|
|
108
|
+
`language "${code}" reads ${JSON.stringify(runtimeLabels[code])} in 43-blocks.html but ` +
|
|
109
|
+
`${JSON.stringify(liquidLabels[code])} in _layouts/text.html`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
console.log(` language vocabulary agrees on ${[...codes].sort().join(', ')}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (failures.length) {
|
|
118
|
+
console.error(`The two detail paths disagree (${failures.length} problem(s)):`);
|
|
119
|
+
failures.forEach((line) => console.error(` - ${line}`));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(` shared class contract: ${[...reported].sort().join(', ')}`);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// run-field-flow-checks.mjs — every exported field must reach the view.
|
|
3
|
+
//
|
|
4
|
+
// Two silent-drop traps sit between the exporter and the page, and neither
|
|
5
|
+
// shows up in a build:
|
|
6
|
+
//
|
|
7
|
+
// 1. writingIndexAsListingItem() copies writing_index entries into the
|
|
8
|
+
// { front_matter } shape the blocks read, through an explicit field
|
|
9
|
+
// whitelist. A newly exported field is invisible until someone adds a
|
|
10
|
+
// line there, and nothing complains — that is how `language` reached
|
|
11
|
+
// writing_index.json and still never made it onto a card.
|
|
12
|
+
//
|
|
13
|
+
// 2. hydrateFrontMatter() merges the detail payload's top-level date and
|
|
14
|
+
// last_updated into front_matter. It used to do that after an early
|
|
15
|
+
// return that always fired for exported content, so no detail page ever
|
|
16
|
+
// showed a date.
|
|
17
|
+
//
|
|
18
|
+
// This walks the real export rather than a fixture, so a field added to
|
|
19
|
+
// scripts/content/export-content-json.rb is covered the day it lands.
|
|
20
|
+
//
|
|
21
|
+
// Usage: node run-field-flow-checks.mjs <exportDir>
|
|
22
|
+
// Driven by scripts/test/test-exported-fields-reach-the-view.rb.
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { loadRuntime } from './load-runtime.mjs';
|
|
27
|
+
|
|
28
|
+
// Keys the exporter writes into a writing_index entry that a listing item is
|
|
29
|
+
// allowed to drop. Every entry needs a reason; an exclusion that turns out to
|
|
30
|
+
// survive is reported too, so the list cannot rot into a rubber stamp.
|
|
31
|
+
const LISTING_EXCLUSIONS = {
|
|
32
|
+
xcites:
|
|
33
|
+
'Cross-reference resolution reads writing_index.json directly ' +
|
|
34
|
+
'(30-includes-xcite.html). A listing card never renders a citation list.'
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const exportDir = process.argv[2];
|
|
38
|
+
if (!exportDir) {
|
|
39
|
+
console.error('usage: run-field-flow-checks.mjs <exportDir>');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// hydrateFrontMatter is internal to the runtime; 90-bootstrap.html does not
|
|
44
|
+
// export it, but every detail page depends on what it does.
|
|
45
|
+
const { runtime, internals } = loadRuntime({ expose: ['hydrateFrontMatter'] });
|
|
46
|
+
const hydrateFrontMatter = internals.hydrateFrontMatter;
|
|
47
|
+
const failures = [];
|
|
48
|
+
let checked = 0;
|
|
49
|
+
|
|
50
|
+
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
51
|
+
|
|
52
|
+
// ── 1. writing_index entry → listing item ─────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const entries = JSON.parse(
|
|
55
|
+
fs.readFileSync(path.join(exportDir, 'data', 'writing_index.json'), 'utf8')
|
|
56
|
+
);
|
|
57
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
58
|
+
console.error('writing_index.json is empty; nothing to check');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const exportedKeys = new Set();
|
|
63
|
+
entries.forEach((entry) => Object.keys(entry).forEach((key) => exportedKeys.add(key)));
|
|
64
|
+
|
|
65
|
+
Object.keys(LISTING_EXCLUSIONS).forEach((key) => {
|
|
66
|
+
if (!exportedKeys.has(key)) {
|
|
67
|
+
failures.push(
|
|
68
|
+
`exclusion list names "${key}", which the exporter no longer writes — remove it`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// One entry per key is enough to prove the whitelist covers it, but which
|
|
74
|
+
// entry matters: an entry whose value is empty would pass a whitelist that
|
|
75
|
+
// dropped the key. Prefer an entry with a populated value.
|
|
76
|
+
function witnessFor(key) {
|
|
77
|
+
return (
|
|
78
|
+
entries.find((entry) => {
|
|
79
|
+
const value = entry[key];
|
|
80
|
+
if (value == null || value === '') return false;
|
|
81
|
+
return !(Array.isArray(value) && value.length === 0);
|
|
82
|
+
}) || entries[0]
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
[...exportedKeys].sort().forEach((key) => {
|
|
87
|
+
checked += 1;
|
|
88
|
+
const entry = witnessFor(key);
|
|
89
|
+
const item = runtime.writingIndexAsListingItem(entry);
|
|
90
|
+
const fm = item.front_matter || {};
|
|
91
|
+
const survives = same(item[key], entry[key]) || same(fm[key], entry[key]);
|
|
92
|
+
|
|
93
|
+
if (Object.prototype.hasOwnProperty.call(LISTING_EXCLUSIONS, key)) {
|
|
94
|
+
if (survives && !same(entry[key], undefined)) {
|
|
95
|
+
failures.push(
|
|
96
|
+
`"${key}" is on the exclusion list but now survives into the listing item — ` +
|
|
97
|
+
'delete the exclusion rather than leaving it stale'
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!survives) {
|
|
104
|
+
failures.push(
|
|
105
|
+
`writingIndexAsListingItem drops "${key}" (exporter value ` +
|
|
106
|
+
`${JSON.stringify(entry[key])} for ${entry.key || entry.slug}). ` +
|
|
107
|
+
'Add it to the whitelist in _includes/content-runtime/60-fragments.html, ' +
|
|
108
|
+
'or to LISTING_EXCLUSIONS here with a reason.'
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// The whitelist must not invent values either: a key the exporter never wrote
|
|
114
|
+
// has no business appearing on a listing item.
|
|
115
|
+
const listingKeys = new Set();
|
|
116
|
+
{
|
|
117
|
+
const item = runtime.writingIndexAsListingItem(entries[0]);
|
|
118
|
+
Object.keys(item).forEach((key) => key !== 'front_matter' && listingKeys.add(key));
|
|
119
|
+
Object.keys(item.front_matter || {}).forEach((key) => listingKeys.add(key));
|
|
120
|
+
}
|
|
121
|
+
[...listingKeys].sort().forEach((key) => {
|
|
122
|
+
checked += 1;
|
|
123
|
+
if (!exportedKeys.has(key)) {
|
|
124
|
+
failures.push(
|
|
125
|
+
`writingIndexAsListingItem reads "${key}", which no writing_index entry carries — ` +
|
|
126
|
+
'the field is always undefined on a card'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ── 2. collection payload → hydrated detail front matter ──────────────────
|
|
132
|
+
|
|
133
|
+
function collectionPayloads() {
|
|
134
|
+
const root = path.join(exportDir, 'collections');
|
|
135
|
+
return fs
|
|
136
|
+
.readdirSync(root, { withFileTypes: true })
|
|
137
|
+
.filter((dirent) => dirent.isDirectory())
|
|
138
|
+
.flatMap((dirent) => {
|
|
139
|
+
const dir = path.join(root, dirent.name);
|
|
140
|
+
return fs
|
|
141
|
+
.readdirSync(dir)
|
|
142
|
+
.filter((name) => name.endsWith('.json'))
|
|
143
|
+
.map((name) => ({
|
|
144
|
+
label: `${dirent.name}/${name}`,
|
|
145
|
+
payload: JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))
|
|
146
|
+
}));
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const payloads = collectionPayloads();
|
|
151
|
+
if (payloads.length === 0) {
|
|
152
|
+
console.error('no exported collection objects; nothing to hydrate');
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let datedPayloads = 0;
|
|
157
|
+
payloads.forEach(({ label, payload }) => {
|
|
158
|
+
const before = JSON.parse(JSON.stringify(payload.front_matter || {}));
|
|
159
|
+
const hydrated = hydrateFrontMatter(JSON.parse(JSON.stringify(payload)));
|
|
160
|
+
const fm = hydrated.front_matter || {};
|
|
161
|
+
checked += 1;
|
|
162
|
+
|
|
163
|
+
if (payload.date) {
|
|
164
|
+
datedPayloads += 1;
|
|
165
|
+
if (fm.date !== payload.date) {
|
|
166
|
+
failures.push(
|
|
167
|
+
`${label}: hydrateFrontMatter left front_matter.date as ${JSON.stringify(fm.date)} ` +
|
|
168
|
+
`for a payload dated ${payload.date} — the detail view shows no date`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (payload.last_updated && fm.last_updated !== payload.last_updated) {
|
|
173
|
+
failures.push(
|
|
174
|
+
`${label}: hydrateFrontMatter dropped last_updated ` +
|
|
175
|
+
`(${payload.last_updated} → ${JSON.stringify(fm.last_updated)})`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
Object.entries(before).forEach(([key, value]) => {
|
|
179
|
+
if (key === 'date' || key === 'last_updated') return;
|
|
180
|
+
if (!same(fm[key], value)) {
|
|
181
|
+
failures.push(
|
|
182
|
+
`${label}: hydrateFrontMatter changed front_matter.${key} ` +
|
|
183
|
+
`from ${JSON.stringify(value)} to ${JSON.stringify(fm[key])}`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (datedPayloads === 0) {
|
|
190
|
+
failures.push('no exported payload carried a date; the date-promotion check proved nothing');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Raw Markdown that has not been through the exporter still has its block
|
|
194
|
+
// parsed — that path is why the early return existed in the first place.
|
|
195
|
+
{
|
|
196
|
+
checked += 1;
|
|
197
|
+
const raw = hydrateFrontMatter({
|
|
198
|
+
body_markdown: '---\ntitle: Raw\nlanguage: enus\n---\nBody text.\n',
|
|
199
|
+
date: '2026-03-01'
|
|
200
|
+
});
|
|
201
|
+
const fm = raw.front_matter || {};
|
|
202
|
+
if (fm.title !== 'Raw' || fm.language !== 'enus') {
|
|
203
|
+
failures.push(
|
|
204
|
+
`hydrateFrontMatter stopped parsing inline front matter (got ${JSON.stringify(fm)})`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (fm.date !== '2026-03-01') {
|
|
208
|
+
failures.push('hydrateFrontMatter dropped the payload date when a block was parsed');
|
|
209
|
+
}
|
|
210
|
+
if (raw.body_markdown.trim() !== 'Body text.') {
|
|
211
|
+
failures.push(`hydrateFrontMatter left the block in body_markdown: ${JSON.stringify(raw.body_markdown)}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (failures.length) {
|
|
216
|
+
console.error(`Exported fields did not reach the view (${failures.length} problem(s)):`);
|
|
217
|
+
failures.forEach((line) => console.error(` - ${line}`));
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const excluded = Object.keys(LISTING_EXCLUSIONS);
|
|
222
|
+
console.log(
|
|
223
|
+
` ${exportedKeys.size} writing_index keys reach a listing item ` +
|
|
224
|
+
`(${excluded.length} documented exclusion: ${excluded.join(', ')}).`
|
|
225
|
+
);
|
|
226
|
+
console.log(` ${payloads.length} detail payloads hydrate with their dates and front matter intact.`);
|
|
227
|
+
console.log(` ${checked} field-flow checks.`);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { marked } from 'marked';
|
|
6
|
+
import { missingSelectors } from './selectors.mjs';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
function parseIncludeAttrs(raw) {
|
|
11
|
+
const attrs = {};
|
|
12
|
+
let text = String(raw || '');
|
|
13
|
+
text = text.replace(/([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g, (_, key, dbl, sgl) => {
|
|
14
|
+
attrs[key] = dbl != null ? dbl : sgl;
|
|
15
|
+
return '';
|
|
16
|
+
});
|
|
17
|
+
text.replace(/([\w-]+)\s*=\s*([^\s]+)/g, (_, key, val) => {
|
|
18
|
+
if (!(key in attrs)) attrs[key] = val;
|
|
19
|
+
return '';
|
|
20
|
+
});
|
|
21
|
+
return attrs;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function escapeAttr(value) {
|
|
25
|
+
return String(value ?? '')
|
|
26
|
+
.replace(/&/g, '&')
|
|
27
|
+
.replace(/</g, '<')
|
|
28
|
+
.replace(/>/g, '>')
|
|
29
|
+
.replace(/"/g, '"');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function unwrapMediaSrc(path) {
|
|
33
|
+
const value = String(path == null ? '' : path).trim();
|
|
34
|
+
if (!value) return '';
|
|
35
|
+
const md = value.match(/^\[([^\]]*)\]\(([^)\s]+)\)\s*$/);
|
|
36
|
+
if (md) return String(md[2] || '').trim();
|
|
37
|
+
const angle = value.match(/^<([^>\s]+)>\s*$/);
|
|
38
|
+
if (angle) return String(angle[1] || '').trim();
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cutInHtml(attrs) {
|
|
43
|
+
const src = unwrapMediaSrc(attrs.src);
|
|
44
|
+
if (!src) return '';
|
|
45
|
+
const sideClass = attrs.side === 'right' ? ' article-cut-in--right' : '';
|
|
46
|
+
return `<figure class="article-cut-in-plate${sideClass}"><img class="article-cut-in${sideClass}" src="${escapeAttr(src)}" alt="${escapeAttr(attrs.alt || '')}" width="120" loading="lazy"></figure>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function figureHtml(attrs) {
|
|
50
|
+
const src = unwrapMediaSrc(attrs.src);
|
|
51
|
+
if (!src) return '';
|
|
52
|
+
const width = attrs.width ? `max-width:${escapeAttr(attrs.width)}%;` : 'max-width:100%;';
|
|
53
|
+
const caption = attrs.caption || '';
|
|
54
|
+
const lbAttr = caption ? ` data-lightbox-caption="${escapeAttr(caption)}"` : '';
|
|
55
|
+
return [
|
|
56
|
+
`<figure class="my-8 mx-auto text-center w-full" style="display:block;${width}">`,
|
|
57
|
+
`<img class="figure-lightbox-trigger" src="${escapeAttr(src)}" alt="${escapeAttr(attrs.alt || caption)}" loading="lazy" tabindex="0" role="button" aria-label="Enlarge image"${lbAttr}>`,
|
|
58
|
+
caption ? `<figcaption class="mt-3">${escapeAttr(caption)}</figcaption>` : '',
|
|
59
|
+
'</figure>'
|
|
60
|
+
].join('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function plotlyHtml(attrs) {
|
|
64
|
+
const src = unwrapMediaSrc(attrs.src);
|
|
65
|
+
if (!src) return '';
|
|
66
|
+
const width = attrs.width ? String(attrs.width).trim() : '100';
|
|
67
|
+
const height = attrs.height && /^\d+$/.test(String(attrs.height).trim())
|
|
68
|
+
? String(attrs.height).trim()
|
|
69
|
+
: '480';
|
|
70
|
+
const caption = attrs.caption || '';
|
|
71
|
+
const title = attrs.alt || caption || 'Interactive chart';
|
|
72
|
+
return [
|
|
73
|
+
`<figure class="plotly-figure my-8 mx-auto text-center w-full" style="display:block;max-width:${escapeAttr(width)}%;">`,
|
|
74
|
+
`<iframe class="plotly-embed-frame" src="${escapeAttr(src)}" title="${escapeAttr(title)}" loading="lazy" scrolling="no" style="width:100%;height:${escapeAttr(height)}px;border:0;display:block;"></iframe>`,
|
|
75
|
+
caption ? `<figcaption class="mt-3">${escapeAttr(caption)}</figcaption>` : '',
|
|
76
|
+
'</figure>'
|
|
77
|
+
].join('');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function youtubeHtml(attrs, isShort) {
|
|
81
|
+
const id = attrs.id || attrs.youtube_id || (isShort ? attrs.src : '');
|
|
82
|
+
if (!id) return '';
|
|
83
|
+
const aspect = isShort ? 'aspect-ratio: 9 / 16;' : 'aspect-ratio: 16 / 9;';
|
|
84
|
+
return `<figure class="my-8 mx-auto text-center w-full"><div style="${aspect}"><iframe src="https://www.youtube.com/embed/${escapeAttr(id)}"></iframe></div></figure>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function youtubeShortHtml(attrs) {
|
|
88
|
+
const src = attrs.src || attrs.id || attrs.id1;
|
|
89
|
+
const src2 = attrs.src2 || attrs.id2;
|
|
90
|
+
if (!src) return '';
|
|
91
|
+
const frame = (id) =>
|
|
92
|
+
`<iframe width="315" height="560" src="https://www.youtube.com/embed/${escapeAttr(id)}" title="YouTube short"></iframe>`;
|
|
93
|
+
return `<figure class="my-8 mx-auto text-center w-full"><div class="article-youtube-short${src2 ? ' article-youtube-short--pair' : ''}">${frame(src)}${src2 ? frame(src2) : ''}</div></figure>`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function videoHtml(attrs) {
|
|
97
|
+
const src = unwrapMediaSrc(attrs.src);
|
|
98
|
+
if (!src) return '';
|
|
99
|
+
return `<figure><video controls src="${escapeAttr(src)}"></video>${attrs.caption ? `<figcaption>${escapeAttr(attrs.caption)}</figcaption>` : ''}</figure>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ongoingProductHtml() {
|
|
103
|
+
return '<span class="ongoing-product-badge" style="display: inline-block; padding: 0.15rem 0.5rem; background: var(--color-surface-container-low); border-radius: 0.25rem; font-size: 0.8rem; color: var(--color-on-surface-variant);">Currently active</span>';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function preprocessLiquid(markdown) {
|
|
107
|
+
return String(markdown)
|
|
108
|
+
.replace(/\{%-?\s*include\s+cut-in\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => cutInHtml(parseIncludeAttrs(raw)))
|
|
109
|
+
.replace(/\{%-?\s*include\s+figure\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => figureHtml(parseIncludeAttrs(raw)))
|
|
110
|
+
.replace(/\{%-?\s*include\s+plotly\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => plotlyHtml(parseIncludeAttrs(raw)))
|
|
111
|
+
.replace(/\{%-?\s*include\s+youtube-short\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => youtubeShortHtml(parseIncludeAttrs(raw)))
|
|
112
|
+
.replace(/\{%-?\s*include\s+youtube\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => youtubeHtml(parseIncludeAttrs(raw), false))
|
|
113
|
+
.replace(/\{%-?\s*include\s+video\.html\s+([\s\S]*?)-?%\}/g, (_, raw) => videoHtml(parseIncludeAttrs(raw)))
|
|
114
|
+
.replace(/\{%-?\s*include\s+ongoing-product\.html\s*-?%\}/g, () => ongoingProductHtml());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function hasMarkdownAttr(openTag) {
|
|
118
|
+
return /\bmarkdown\s*=\s*["']1["']/i.test(String(openTag || ''));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function preprocessMarkdownDivs(markdown) {
|
|
122
|
+
let text = String(markdown || '');
|
|
123
|
+
text = text.replace(/<div\s+([^>]*class="[^"]*\barticle-note\b[^"]*"[^>]*)>([\s\S]*?)<\/div>/gi, (match, openTag, inner) => {
|
|
124
|
+
if (!hasMarkdownAttr(openTag)) return match;
|
|
125
|
+
return `<div class="article-note">${marked.parse(inner.trim())}</div>`;
|
|
126
|
+
});
|
|
127
|
+
text = text.replace(/<div\s+([^>]*class="[^"]*\bpoem\b[^"]*"[^>]*)>([\s\S]*?)<\/div>/gi, (match, openTag, inner) => {
|
|
128
|
+
if (!hasMarkdownAttr(openTag)) return match;
|
|
129
|
+
return `<div class="poem"><p>${inner.trim()}</p></div>`;
|
|
130
|
+
});
|
|
131
|
+
return text;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function render(markdown) {
|
|
135
|
+
const prepared = preprocessMarkdownDivs(preprocessLiquid(markdown));
|
|
136
|
+
return marked.parse(prepared, { gfm: true, breaks: false });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const fixturePath = process.argv[2];
|
|
140
|
+
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
|
141
|
+
const html = render(fixture.markdown);
|
|
142
|
+
const missing = missingSelectors(html, fixture.selectors);
|
|
143
|
+
|
|
144
|
+
if (missing.length) {
|
|
145
|
+
console.error(`Fixture ${fixture.name}: missing selectors ${missing.join(', ')}`);
|
|
146
|
+
console.error(html);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (fixture.paragraphCount != null) {
|
|
151
|
+
const count = (html.match(/<p\b/gi) || []).length;
|
|
152
|
+
if (count !== fixture.paragraphCount) {
|
|
153
|
+
console.error(`Fixture ${fixture.name}: expected ${fixture.paragraphCount} <p>, got ${count}`);
|
|
154
|
+
console.error(html);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log(`Fixture ${fixture.name}: OK`);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// selectors.mjs — the fixture selector vocabulary, shared by both runners.
|
|
2
|
+
//
|
|
3
|
+
// Fixtures describe expectations as selector-ish strings rather than parsing
|
|
4
|
+
// the HTML, so a fixture stays readable and the runners stay dependency-free.
|
|
5
|
+
// Supported forms:
|
|
6
|
+
// "iframe" / "img" / "video" / "blockquote" bare tag
|
|
7
|
+
// "span.lang-pill" tag with class
|
|
8
|
+
// "ledger-chip-row" class name, or a tag name
|
|
9
|
+
//
|
|
10
|
+
// Class matching is whole-token, not \b-delimited: a hyphen is a word
|
|
11
|
+
// boundary, so `\bledger-chip\b` also matches class="ledger-chip-row" and
|
|
12
|
+
// every count comes out one too high.
|
|
13
|
+
|
|
14
|
+
function classPattern(name, flags = 'i') {
|
|
15
|
+
return new RegExp(`class="(?:[^"]*\\s)?${name}(?:\\s[^"]*)?"`, flags);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function matchesSelector(html, selector) {
|
|
19
|
+
if (selector === 'iframe') return /<iframe\b/i.test(html);
|
|
20
|
+
if (selector === 'img') return /<img\b/i.test(html);
|
|
21
|
+
if (selector === 'video') return /<video\b/i.test(html);
|
|
22
|
+
if (selector === 'blockquote') return /<blockquote\b/i.test(html);
|
|
23
|
+
const tagClass = String(selector).match(/^([a-z][\w-]*)\.([a-z][\w-]*)$/i);
|
|
24
|
+
if (tagClass) {
|
|
25
|
+
const [, tag, cls] = tagClass;
|
|
26
|
+
return new RegExp(`<${tag}\\b[^>]*class="(?:[^"]*\\s)?${cls}(?:\\s[^"]*)?"`, 'i').test(html);
|
|
27
|
+
}
|
|
28
|
+
return classPattern(selector).test(html) || new RegExp(`<${selector}\\b`, 'i').test(html);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function missingSelectors(html, selectors) {
|
|
32
|
+
return (selectors || []).filter((selector) => !matchesSelector(html, selector));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function presentSelectors(html, selectors) {
|
|
36
|
+
return (selectors || []).filter((selector) => matchesSelector(html, selector));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How many elements carry the class, for "exactly one separator" assertions. */
|
|
40
|
+
export function countClass(html, className) {
|
|
41
|
+
const matches = html.match(classPattern(className, 'gi'));
|
|
42
|
+
return matches ? matches.length : 0;
|
|
43
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Gate: an element renders only when the field backing it is present.
|
|
5
|
+
#
|
|
6
|
+
# This is the site's central editorial invariant, and the one the build cannot
|
|
7
|
+
# see. The shared ledger blocks run in the browser, so `jekyll build` succeeds
|
|
8
|
+
# whether a card shows a language pill for a piece that never declared one, an
|
|
9
|
+
# empty poster plate where there is no thumbnail, or a meta strip that ends in
|
|
10
|
+
# a dangling slash. Every failure of this kind has to be caught by executing
|
|
11
|
+
# the blocks, which is what scripts/test/render-parity/run-absent-field-checks.mjs
|
|
12
|
+
# does against the real _includes/content-runtime/43-blocks.html.
|
|
13
|
+
#
|
|
14
|
+
# The positive half of the contract — present field, present element — lives in
|
|
15
|
+
# scripts/test/render-parity/blocks/*.json, so a block cannot pass this gate by
|
|
16
|
+
# rendering nothing at all.
|
|
17
|
+
|
|
18
|
+
require "open3"
|
|
19
|
+
require "pathname"
|
|
20
|
+
|
|
21
|
+
ROOT = Pathname.new(__dir__).join("..", "..").expand_path
|
|
22
|
+
CHECKS = Pathname.new(__dir__).join("render-parity", "run-absent-field-checks.mjs")
|
|
23
|
+
|
|
24
|
+
def fail!(message)
|
|
25
|
+
warn message
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
puts "Testing the absent-field / absent-element rule..."
|
|
30
|
+
|
|
31
|
+
fail!("missing #{CHECKS}") unless CHECKS.file?
|
|
32
|
+
|
|
33
|
+
stdout, stderr, status = Open3.capture3(
|
|
34
|
+
"node", CHECKS.to_s, chdir: ROOT.join("scripts").to_s
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
print stdout
|
|
38
|
+
unless status.success?
|
|
39
|
+
warn stderr unless stderr.strip.empty?
|
|
40
|
+
fail!("Absent fields rendered elements. An absent field means an absent element.")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
puts "All absent-field / absent-element checks passed."
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "set"
|
|
7
|
+
|
|
8
|
+
ROOT = Pathname.new(__dir__).join("..", "..").expand_path
|
|
9
|
+
ALLOWLIST = JSON.parse(File.read(ROOT.join("scripts/cdn-allowlist.json")))
|
|
10
|
+
ALLOWED = ALLOWLIST.fetch("allowed_urls").to_set
|
|
11
|
+
SCAN_DIRS = ALLOWLIST.fetch("scan_globs")
|
|
12
|
+
|
|
13
|
+
CDN_ATTR_RE = /
|
|
14
|
+
(?:src|href)\s*=\s*["']
|
|
15
|
+
(https?:\/\/[^"']+)
|
|
16
|
+
["']
|
|
17
|
+
/ix
|
|
18
|
+
|
|
19
|
+
errors = []
|
|
20
|
+
|
|
21
|
+
SCAN_DIRS.each do |relative|
|
|
22
|
+
dir = ROOT.join(relative)
|
|
23
|
+
next unless dir.directory?
|
|
24
|
+
|
|
25
|
+
Dir.glob(dir.join("**/*.{html,js,liquid}")).sort.each do |file|
|
|
26
|
+
path = Pathname.new(file)
|
|
27
|
+
text = path.read
|
|
28
|
+
text.scan(CDN_ATTR_RE).flatten.each do |url|
|
|
29
|
+
next unless url.include?("cdn.jsdelivr.net") || url.include?("cdn.tailwindcss.com")
|
|
30
|
+
|
|
31
|
+
next if ALLOWED.include?(url)
|
|
32
|
+
|
|
33
|
+
errors << "#{path.relative_path_from(ROOT)}: unlisted CDN URL #{url}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if errors.any?
|
|
39
|
+
warn "CDN pin validation failed:"
|
|
40
|
+
errors.each { |line| warn " - #{line}" }
|
|
41
|
+
exit 1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
puts "CDN pin validation OK (#{ALLOWED.size} allowed URLs)."
|