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,164 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Public content-store contract: print-only CV copy stays in Git for private
|
|
5
|
+
# PDF builds and must not appear in the R2 JSON export.
|
|
6
|
+
# Spec: docs/content/architecture.md
|
|
7
|
+
|
|
8
|
+
$stdout.sync = true
|
|
9
|
+
$stderr.sync = true
|
|
10
|
+
|
|
11
|
+
require "date"
|
|
12
|
+
require "json"
|
|
13
|
+
require "open3"
|
|
14
|
+
require "pathname"
|
|
15
|
+
require "tmpdir"
|
|
16
|
+
require "yaml"
|
|
17
|
+
|
|
18
|
+
REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
|
|
19
|
+
EXPORT_SCRIPT = REPO_ROOT.join("scripts/content/export-content-json.rb")
|
|
20
|
+
JOBS_DIR = REPO_ROOT.join("content/collections/jobs")
|
|
21
|
+
|
|
22
|
+
PRIVATE_JOB_FIELDS = %w[body_extended body_single_page].freeze
|
|
23
|
+
PRINT_ONLY_PAGE_FRAGMENTS = %w[
|
|
24
|
+
pages/cv/summary-extended.json
|
|
25
|
+
pages/cv/summary-single-page.json
|
|
26
|
+
pages/cv/ai-practice.json
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
def fail!(message)
|
|
30
|
+
warn message
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def check!(condition, message)
|
|
35
|
+
fail!(message) unless condition
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run_export!(out_dir)
|
|
39
|
+
stdout, stderr, status = Open3.capture3(
|
|
40
|
+
{ "CONTENT_API_BASE_URL" => "" },
|
|
41
|
+
"ruby", EXPORT_SCRIPT.to_s, "content", out_dir.to_s,
|
|
42
|
+
chdir: REPO_ROOT.to_s
|
|
43
|
+
)
|
|
44
|
+
fail!("export failed:\n#{stderr}\n#{stdout}") unless status.success?
|
|
45
|
+
|
|
46
|
+
stdout
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def split_front_matter(text)
|
|
50
|
+
text = text.sub(/\A\xEF\xBB\xBF/, "")
|
|
51
|
+
return [{}, text] unless text.start_with?("---\n")
|
|
52
|
+
|
|
53
|
+
parts = text.split(/^---\s*$\n?/, 3)
|
|
54
|
+
return [{}, text] unless parts.length == 3
|
|
55
|
+
|
|
56
|
+
data = YAML.safe_load(parts[1], permitted_classes: [Date, Time], aliases: true) || {}
|
|
57
|
+
[data, parts[2]]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
puts "Testing public CV export redaction..."
|
|
61
|
+
|
|
62
|
+
source_jobs = JOBS_DIR.glob("*.md")
|
|
63
|
+
check!(!source_jobs.empty?, "expected job Markdown under content/collections/jobs")
|
|
64
|
+
|
|
65
|
+
jobs_with_private = source_jobs.select do |path|
|
|
66
|
+
front_matter, = split_front_matter(path.read)
|
|
67
|
+
PRIVATE_JOB_FIELDS.any? { |field| !front_matter[field].to_s.strip.empty? }
|
|
68
|
+
end
|
|
69
|
+
check!(!jobs_with_private.empty?, "expected at least one job with body_extended or body_single_page in Git")
|
|
70
|
+
|
|
71
|
+
extended_summary = REPO_ROOT.join("content/pages/cv/summary-extended.md")
|
|
72
|
+
single_page_summary = REPO_ROOT.join("content/pages/cv/summary-single-page.md")
|
|
73
|
+
check!(extended_summary.file?, "missing print-only #{extended_summary}")
|
|
74
|
+
check!(single_page_summary.file?, "missing print-only #{single_page_summary}")
|
|
75
|
+
|
|
76
|
+
def check_export!(out, jobs_with_private)
|
|
77
|
+
job_json = out.glob("collections/jobs/*.json")
|
|
78
|
+
check!(!job_json.empty?, "export produced no job JSON")
|
|
79
|
+
|
|
80
|
+
job_json.each do |path|
|
|
81
|
+
payload = JSON.parse(path.read)
|
|
82
|
+
front_matter = payload["front_matter"] || {}
|
|
83
|
+
PRIVATE_JOB_FIELDS.each do |field|
|
|
84
|
+
check!(
|
|
85
|
+
!front_matter.key?(field),
|
|
86
|
+
"#{path.basename} public JSON still contains front_matter.#{field}"
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
source = JOBS_DIR.join("#{path.basename('.json')}.md")
|
|
91
|
+
next unless source.file?
|
|
92
|
+
|
|
93
|
+
source_fm, = split_front_matter(source.read)
|
|
94
|
+
next if source_fm["body_public"].to_s.strip.empty?
|
|
95
|
+
|
|
96
|
+
check!(
|
|
97
|
+
!front_matter["body_public"].to_s.strip.empty?,
|
|
98
|
+
"#{path.basename} dropped body_public while redacting print-only fields"
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
PRINT_ONLY_PAGE_FRAGMENTS.each do |relative|
|
|
103
|
+
leaked = out.join(relative)
|
|
104
|
+
check!(!leaked.file?, "print-only fragment exported to public JSON: #{relative}")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
public_summary = out.join("pages/cv/summary.json")
|
|
108
|
+
check!(public_summary.file?, "public CV summary fragment missing from export")
|
|
109
|
+
|
|
110
|
+
skills_json = out.join("data/skills.json")
|
|
111
|
+
check!(!skills_json.file?, "skills.json should not be exported publicly (private CV PDF sidebar only)")
|
|
112
|
+
|
|
113
|
+
contacts_json = out.join("data/contacts.json")
|
|
114
|
+
check!(contacts_json.file?, "public contacts JSON missing from export")
|
|
115
|
+
contacts = JSON.parse(contacts_json.read)
|
|
116
|
+
check!(contacts.is_a?(Array) && !contacts.empty?, "public contacts JSON should be a non-empty array")
|
|
117
|
+
contacts.each do |contact|
|
|
118
|
+
href = contact.is_a?(Hash) ? contact["href"].to_s : ""
|
|
119
|
+
check!(
|
|
120
|
+
!href.start_with?("mailto:"),
|
|
121
|
+
"data/contacts.json still exports a mailto contact (#{href})"
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
check!(
|
|
125
|
+
!contacts_json.read.match?(/mailto:|@gmail\.com/i),
|
|
126
|
+
"data/contacts.json still contains a public email address"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
print_header = REPO_ROOT.join("_includes/cv/cv-print-header.html")
|
|
130
|
+
check!(print_header.file?, "missing private CV print header")
|
|
131
|
+
check!(
|
|
132
|
+
print_header.read.include?("mailto:"),
|
|
133
|
+
"private CV print header must keep mailto for recruiter PDFs"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
leaked_blob = out.glob("**/*.json").map(&:read).join("\n")
|
|
137
|
+
jobs_with_private.each do |source|
|
|
138
|
+
front_matter, = split_front_matter(source.read)
|
|
139
|
+
PRIVATE_JOB_FIELDS.each do |field|
|
|
140
|
+
snippet = front_matter[field].to_s.strip.lines.map(&:strip).reject(&:empty?).find { |line| line.length >= 40 }
|
|
141
|
+
next if snippet.nil?
|
|
142
|
+
|
|
143
|
+
check!(
|
|
144
|
+
!leaked_blob.include?(snippet),
|
|
145
|
+
"#{source.basename} #{field} still appears in public JSON (#{snippet[0, 80]}…)"
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
existing = ARGV[0]
|
|
152
|
+
if existing
|
|
153
|
+
out = Pathname.new(existing).expand_path
|
|
154
|
+
check!(out.directory?, "export dir missing: #{out}")
|
|
155
|
+
check_export!(out, jobs_with_private)
|
|
156
|
+
else
|
|
157
|
+
Dir.mktmpdir("public-cv-export-") do |dir|
|
|
158
|
+
out = Pathname.new(dir).join("export")
|
|
159
|
+
run_export!(out)
|
|
160
|
+
check_export!(out, jobs_with_private)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
puts "Public CV export redaction OK"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
$stdout.sync = true
|
|
5
|
+
$stderr.sync = true
|
|
6
|
+
|
|
7
|
+
require "pathname"
|
|
8
|
+
require_relative "../lib/content_storage_config"
|
|
9
|
+
|
|
10
|
+
REPO_ROOT = ContentStorageConfig.repo_root
|
|
11
|
+
REDIRECTS_FILE = REPO_ROOT.join("_redirects")
|
|
12
|
+
STUDIO_FILE = REPO_ROOT.join("studio/index.html")
|
|
13
|
+
EXPORT_SCRIPT = REPO_ROOT.join("scripts/content/export-content-json.rb")
|
|
14
|
+
|
|
15
|
+
def fail!(message)
|
|
16
|
+
raise message
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def check!(condition, message)
|
|
20
|
+
fail!(message) unless condition
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
base = ContentStorageConfig.base_url
|
|
24
|
+
check!(base && !base.empty?, "_config.yml content_api_base_url must be set")
|
|
25
|
+
|
|
26
|
+
legacy_host = ContentStorageConfig::LEGACY_GCS_HOST
|
|
27
|
+
[
|
|
28
|
+
["_config.yml", ContentStorageConfig.config_path.read],
|
|
29
|
+
["_redirects", REDIRECTS_FILE.read],
|
|
30
|
+
["studio/index.html", STUDIO_FILE.read],
|
|
31
|
+
["scripts/content/export-content-json.rb", EXPORT_SCRIPT.read]
|
|
32
|
+
].each do |label, text|
|
|
33
|
+
check!(!text.include?(legacy_host),
|
|
34
|
+
"#{label} still references GCS (#{legacy_host})")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if ContentStorageConfig.placeholder?(base)
|
|
38
|
+
puts "SKIP: R2 URL alignment checks (content_api_base_url is still placeholder: #{base})"
|
|
39
|
+
puts " Fill the URL per docs/ops/cloudflare.md before production cutover."
|
|
40
|
+
exit 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
redirects = REDIRECTS_FILE.read
|
|
44
|
+
media_line = redirects.lines.find { |line| line.start_with?("/media/*") }
|
|
45
|
+
check!(media_line, "_redirects must define /media/* → R2")
|
|
46
|
+
check!(media_line.include?(base),
|
|
47
|
+
"/media/* redirect must use content_api_base_url (#{base}); got: #{media_line.strip}")
|
|
48
|
+
|
|
49
|
+
studio = STUDIO_FILE.read
|
|
50
|
+
# Self-hosted Studio SPA: media is HTTPS or /media/* (redirected via _redirects).
|
|
51
|
+
# Do not require a Decap-era CONTENT_MEDIA_BASE constant in index.html.
|
|
52
|
+
check!(!studio.match?(%r{storage\.googleapis\.com|googleapis\.com/storage}),
|
|
53
|
+
"studio/index.html must not hardcode a GCS media host")
|
|
54
|
+
check!(!studio.include?(legacy_host),
|
|
55
|
+
"studio/index.html must not reference legacy GCS host")
|
|
56
|
+
|
|
57
|
+
puts "R2 config consistency OK (#{base})"
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Checksum skip planner for R2 JSON publish. No live credentials.
|
|
5
|
+
|
|
6
|
+
$stdout.sync = true
|
|
7
|
+
$stderr.sync = true
|
|
8
|
+
|
|
9
|
+
require "digest"
|
|
10
|
+
require "fileutils"
|
|
11
|
+
require "json"
|
|
12
|
+
require "open3"
|
|
13
|
+
require "pathname"
|
|
14
|
+
require "tmpdir"
|
|
15
|
+
|
|
16
|
+
require_relative "../lib/r2_json_sync"
|
|
17
|
+
require_relative "../lib/writing_index_contract"
|
|
18
|
+
|
|
19
|
+
REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
|
|
20
|
+
EXPORT_SCRIPT = REPO_ROOT.join("scripts/content/export-content-json.rb")
|
|
21
|
+
|
|
22
|
+
def fail!(message)
|
|
23
|
+
warn message
|
|
24
|
+
exit 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def check!(condition, message)
|
|
28
|
+
fail!(message) unless condition
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def md5(text)
|
|
32
|
+
Digest::MD5.hexdigest(text)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
puts "Testing R2 JSON checksum skip planner..."
|
|
36
|
+
|
|
37
|
+
check!(!R2JsonSync.skip_upload?(nil, "abc"), "missing etag must upload")
|
|
38
|
+
check!(!R2JsonSync.skip_upload?("", "abc"), "blank etag must upload")
|
|
39
|
+
check!(!R2JsonSync.skip_upload?("abc-2", "abc"), "multipart etag must upload")
|
|
40
|
+
check!(!R2JsonSync.skip_upload?('"abc-2"', "abc"), "quoted multipart etag must upload")
|
|
41
|
+
check!(R2JsonSync.skip_upload?("abc", "abc"), "matching md5 must skip")
|
|
42
|
+
check!(R2JsonSync.skip_upload?("ABC", "abc"), "etag compare is case-insensitive")
|
|
43
|
+
check!(R2JsonSync.skip_upload?('"abc"', "abc"), "quoted etag must still skip")
|
|
44
|
+
check!(!R2JsonSync.skip_upload?("abc", "def"), "hash mismatch must upload")
|
|
45
|
+
check!(
|
|
46
|
+
!R2JsonSync.skip_upload?("abc", "abc", key: "data/writing_index.json"),
|
|
47
|
+
"writing_index.json must always upload"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
listed = R2JsonSync.contents_to_etags([
|
|
51
|
+
{ "Key" => "collections/articles/a.json", "ETag" => '"aaa"' },
|
|
52
|
+
{ "Key" => "collections/", "ETag" => '"dir"' },
|
|
53
|
+
{ "Key" => "", "ETag" => '"empty"' }
|
|
54
|
+
])
|
|
55
|
+
check!(listed == { "collections/articles/a.json" => "aaa" }, "list contents keep file keys only")
|
|
56
|
+
|
|
57
|
+
plan = R2JsonSync.plan(
|
|
58
|
+
[
|
|
59
|
+
{ "key" => "collections/articles/a.json", "md5" => "aaa" },
|
|
60
|
+
{ "key" => "collections/articles/b.json", "md5" => "bbb" },
|
|
61
|
+
{ "key" => "data/writing_index.json", "md5" => "idx" }
|
|
62
|
+
],
|
|
63
|
+
{
|
|
64
|
+
"collections/articles/a.json" => "aaa",
|
|
65
|
+
"collections/articles/b.json" => "old-b",
|
|
66
|
+
"collections/articles/gone.json" => "zzz",
|
|
67
|
+
"data/writing_index.json" => "idx"
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
check!(plan["upload"] == ["collections/articles/b.json", "data/writing_index.json"],
|
|
71
|
+
"mismatch + writing_index upload (got #{plan['upload'].inspect})")
|
|
72
|
+
check!(plan["skip"] == ["collections/articles/a.json"], "unchanged A must skip (got #{plan['skip'].inspect})")
|
|
73
|
+
check!(plan["delete"] == ["collections/articles/gone.json"], "extra remote key must delete")
|
|
74
|
+
|
|
75
|
+
summary = R2JsonSync.summary_line("collections", plan)
|
|
76
|
+
check!(summary.include?("uploaded=2"), "summary uploaded count (#{summary})")
|
|
77
|
+
check!(summary.include?("skipped=1"), "summary skipped count (#{summary})")
|
|
78
|
+
check!(summary.include?("deleted=1"), "summary deleted count (#{summary})")
|
|
79
|
+
|
|
80
|
+
puts " planner OK"
|
|
81
|
+
|
|
82
|
+
puts "Testing export A → sync → change A → incremental export → small upload..."
|
|
83
|
+
|
|
84
|
+
Dir.mktmpdir("r2-json-sync-") do |dir|
|
|
85
|
+
root = Pathname.new(dir)
|
|
86
|
+
content = root.join("content")
|
|
87
|
+
export = root.join("export")
|
|
88
|
+
FileUtils.mkdir_p(content.join("collections/articles"))
|
|
89
|
+
|
|
90
|
+
write_article = lambda do |stem, title, body|
|
|
91
|
+
content.join("collections/articles/#{stem}.md").write(<<~MARKDOWN)
|
|
92
|
+
---
|
|
93
|
+
title: #{title}
|
|
94
|
+
key: #{stem}
|
|
95
|
+
date: 2026-08-22
|
|
96
|
+
published: true
|
|
97
|
+
---
|
|
98
|
+
#{body}
|
|
99
|
+
MARKDOWN
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
write_article.call("article-a", "Article A", "Body A version one.")
|
|
103
|
+
write_article.call("article-b", "Article B", "Body B stays put.")
|
|
104
|
+
%w[article-c article-d article-e article-f].each do |stem|
|
|
105
|
+
write_article.call(stem, stem, "Unchanged body for #{stem}.")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
run_export = lambda do |changed_paths|
|
|
109
|
+
env = {
|
|
110
|
+
"CONTENT_API_BASE_URL" => "",
|
|
111
|
+
"CONTENT_EXPORT_SKIP_VALIDATE" => "1"
|
|
112
|
+
}
|
|
113
|
+
env["CONTENT_EXPORT_CHANGED_PATHS"] = changed_paths.join("\n") unless changed_paths.empty?
|
|
114
|
+
stdout, stderr, status = Open3.capture3(
|
|
115
|
+
env,
|
|
116
|
+
"ruby", EXPORT_SCRIPT.to_s, content.to_s, export.to_s,
|
|
117
|
+
chdir: REPO_ROOT.to_s
|
|
118
|
+
)
|
|
119
|
+
fail!("export failed:\n#{stderr}\n#{stdout}") unless status.success?
|
|
120
|
+
|
|
121
|
+
stdout
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
first = run_export.call([])
|
|
125
|
+
check!(first.include?("changed="), "first export missing write summary")
|
|
126
|
+
|
|
127
|
+
a_path = export.join("collections/articles/2026-08-22-article-a.json")
|
|
128
|
+
b_path = export.join("collections/articles/2026-08-22-article-b.json")
|
|
129
|
+
check!(a_path.file?, "missing exported A")
|
|
130
|
+
check!(b_path.file?, "missing exported B")
|
|
131
|
+
|
|
132
|
+
remote = {}
|
|
133
|
+
apply_plan = lambda do |planned|
|
|
134
|
+
planned["upload"].each do |key|
|
|
135
|
+
local = export.join(key)
|
|
136
|
+
body = local.binread
|
|
137
|
+
remote[key] = { "body" => body, "etag" => md5(body) }
|
|
138
|
+
end
|
|
139
|
+
planned["delete"].each { |key| remote.delete(key) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
local_files = lambda do
|
|
143
|
+
export.find.select(&:file?).filter_map do |path|
|
|
144
|
+
rel = path.relative_path_from(export).to_s
|
|
145
|
+
next if rel == "manifest.json"
|
|
146
|
+
|
|
147
|
+
{ "key" => rel, "md5" => md5(path.binread) }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
first_plan = R2JsonSync.plan(local_files.call, {})
|
|
152
|
+
check!(first_plan["skip"].empty?, "empty remote must upload everything")
|
|
153
|
+
apply_plan.call(first_plan)
|
|
154
|
+
remote_b_before = remote["collections/articles/2026-08-22-article-b.json"]["body"]
|
|
155
|
+
|
|
156
|
+
write_article.call("article-a", "Article A", "Body A version two — only this file changed.")
|
|
157
|
+
sleep 1
|
|
158
|
+
second = run_export.call(["content/collections/articles/article-a.md"])
|
|
159
|
+
check!(second.match?(/unchanged=[1-9]/), "incremental re-export should reuse B (#{second})")
|
|
160
|
+
|
|
161
|
+
check!(b_path.binread == remote_b_before,
|
|
162
|
+
"incremental export must leave B bytes unchanged")
|
|
163
|
+
|
|
164
|
+
second_plan = R2JsonSync.plan(
|
|
165
|
+
local_files.call,
|
|
166
|
+
remote.transform_values { |item| item["etag"] }
|
|
167
|
+
)
|
|
168
|
+
uploaded = second_plan["upload"]
|
|
169
|
+
skipped = second_plan["skip"]
|
|
170
|
+
check!(uploaded.length < skipped.length,
|
|
171
|
+
"second sync should upload fewer than it skips (upload=#{uploaded.length} skip=#{skipped.length} upload=#{uploaded.inspect})")
|
|
172
|
+
check!(uploaded.include?("collections/articles/2026-08-22-article-a.json"),
|
|
173
|
+
"changed A must upload")
|
|
174
|
+
check!(uploaded.include?("data/writing_index.json"),
|
|
175
|
+
"writing_index must upload after A changes")
|
|
176
|
+
check!(!uploaded.include?("collections/articles/2026-08-22-article-b.json"),
|
|
177
|
+
"unchanged B must not upload")
|
|
178
|
+
check!(skipped.include?("collections/articles/2026-08-22-article-b.json"),
|
|
179
|
+
"unchanged B must be skipped")
|
|
180
|
+
|
|
181
|
+
apply_plan.call(second_plan)
|
|
182
|
+
check!(remote["collections/articles/2026-08-22-article-b.json"]["body"] == remote_b_before,
|
|
183
|
+
"remote B body must stay unchanged")
|
|
184
|
+
|
|
185
|
+
WritingIndexContract.assert_slugs_match_export!(export)
|
|
186
|
+
index = JSON.parse(export.join("data/writing_index.json").read)
|
|
187
|
+
a_entry = index.find { |entry| entry["key"] == "article-a" }
|
|
188
|
+
check!(!a_entry.nil?, "writing_index must include article-a")
|
|
189
|
+
check!(export.join("collections/articles/#{a_entry['slug']}.json").file?,
|
|
190
|
+
"Freshy-like dated slug must match an exported JSON file")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
puts "R2 JSON sync skip logic OK"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Refs: PLT-AC-3 — homepage bands derive from the registry.
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
ROOT = Pathname.new(__dir__).join("../..").expand_path
|
|
8
|
+
$LOAD_PATH.unshift(ROOT.join("lib").to_s)
|
|
9
|
+
require "pandorga/registry"
|
|
10
|
+
|
|
11
|
+
config = YAML.safe_load(ROOT.join("examples/full/_config.yml").read, aliases: true)
|
|
12
|
+
pages = PandorgaRegistry.pages(config)
|
|
13
|
+
bands = PandorgaRegistry.home_bands(pages)
|
|
14
|
+
|
|
15
|
+
kinds = bands.map { |b| b["kind"] }
|
|
16
|
+
unless kinds == %w[portfolio articles blog index]
|
|
17
|
+
warn "FAIL test-registry-home: unexpected band kinds #{kinds.inspect}"
|
|
18
|
+
exit 1
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
index = bands.find { |b| b["kind"] == "index" }
|
|
22
|
+
panel_keys = index["pages"].map { |p| p["key"] }
|
|
23
|
+
unless panel_keys == %w[resources network bibliography]
|
|
24
|
+
warn "FAIL test-registry-home: index panels #{panel_keys.inspect}"
|
|
25
|
+
exit 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
layout = PandorgaRegistry.layout_for({ "template" => "portfolio" })
|
|
29
|
+
unless layout == "projects"
|
|
30
|
+
warn "FAIL test-registry-home: portfolio layout expected projects, got #{layout}"
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
puts "PASS test-registry-home"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# The content runtime is assembled from a dozen Liquid partials into one
|
|
5
|
+
# inline <script>. A syntax error in any partial takes down every listing and
|
|
6
|
+
# detail view at once, and no Ruby gate would notice: the build succeeds and
|
|
7
|
+
# the page ships broken. Parse the assembled script the way a browser would.
|
|
8
|
+
|
|
9
|
+
require "pathname"
|
|
10
|
+
require "open3"
|
|
11
|
+
require "tmpdir"
|
|
12
|
+
|
|
13
|
+
REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
|
|
14
|
+
SITE_ROOT = REPO_ROOT.join("_site")
|
|
15
|
+
|
|
16
|
+
# One page per distinct runtime assembly. They all include the same partials
|
|
17
|
+
# today, but a layout could include a subset tomorrow.
|
|
18
|
+
CANDIDATES = [
|
|
19
|
+
"pages/articles/index.html",
|
|
20
|
+
"pages/blog/index.html",
|
|
21
|
+
"pages/projects/index.html",
|
|
22
|
+
"index.html"
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
RUNTIME_PATTERN = /window\.ContentRuntime = \(function \(\) \{.*?\n \}\)\(\);/m
|
|
26
|
+
|
|
27
|
+
def fail!(message)
|
|
28
|
+
warn message
|
|
29
|
+
exit 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
puts "Testing content runtime JS syntax..."
|
|
33
|
+
|
|
34
|
+
unless SITE_ROOT.directory?
|
|
35
|
+
puts " skipped: no _site build to inspect"
|
|
36
|
+
exit 0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
checked = 0
|
|
40
|
+
|
|
41
|
+
Dir.mktmpdir do |tmp|
|
|
42
|
+
CANDIDATES.each do |relative|
|
|
43
|
+
page = SITE_ROOT.join(relative)
|
|
44
|
+
next unless page.file?
|
|
45
|
+
|
|
46
|
+
match = page.read.match(RUNTIME_PATTERN)
|
|
47
|
+
next if match.nil?
|
|
48
|
+
|
|
49
|
+
script = Pathname.new(tmp).join("#{relative.tr('/', '-')}.js")
|
|
50
|
+
script.write(match[0])
|
|
51
|
+
|
|
52
|
+
_out, err, status = Open3.capture3("node", "--check", script.to_s)
|
|
53
|
+
unless status.success?
|
|
54
|
+
fail!("#{relative}: content runtime is not valid JavaScript\n#{err}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
checked += 1
|
|
58
|
+
puts " #{relative}: OK (#{match[0].bytesize} bytes)"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
fail!("no page contained a ContentRuntime block — did the runtime stop inlining?") if checked.zero?
|
|
63
|
+
|
|
64
|
+
puts "All content runtime JS syntax tests passed (#{checked} page(s))."
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Gate: xcite resolution must use the slim writing_index, not bulk loadCollection
|
|
5
|
+
# of articles/posts/products/projects. Spec: GitHub issue #364.
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
require "open3"
|
|
9
|
+
require "pathname"
|
|
10
|
+
require "tmpdir"
|
|
11
|
+
|
|
12
|
+
require_relative "../lib/writing_index_contract"
|
|
13
|
+
|
|
14
|
+
REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
|
|
15
|
+
EXPORT_SCRIPT = REPO_ROOT.join("scripts/content/export-content-json.rb")
|
|
16
|
+
XCITE_INCLUDE = REPO_ROOT.join("_includes/content-runtime/30-includes-xcite.html")
|
|
17
|
+
FRAGMENTS_INCLUDE = REPO_ROOT.join("_includes/content-runtime/60-fragments.html")
|
|
18
|
+
LISTING_INCLUDE = REPO_ROOT.join("_includes/content-runtime/40-listing.html")
|
|
19
|
+
BLOG_LAYOUT = REPO_ROOT.join("_layouts/blog.html")
|
|
20
|
+
ARTICLES_LAYOUT = REPO_ROOT.join("_layouts/articles.html")
|
|
21
|
+
|
|
22
|
+
def fail!(message)
|
|
23
|
+
warn message
|
|
24
|
+
exit 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def check!(condition, message)
|
|
28
|
+
fail!(message) unless condition
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
puts "Testing slim writing index / xcite fetch contract..."
|
|
32
|
+
|
|
33
|
+
xcite_src = XCITE_INCLUDE.read
|
|
34
|
+
check!(xcite_src.include?("data/writing_index.json"),
|
|
35
|
+
"ensureXciteIndex must load data/writing_index.json")
|
|
36
|
+
check!(!xcite_src.match?(/loadCollection\(\s*['"]collections\/(articles|posts)/),
|
|
37
|
+
"ensureXciteIndex must not call loadCollection for articles/posts")
|
|
38
|
+
check!(!xcite_src.match?(/loadCollection\(\s*['"]collections\/(products|projects)/),
|
|
39
|
+
"ensureXciteIndex must not call loadCollection for products/projects")
|
|
40
|
+
|
|
41
|
+
fragments_src = FRAGMENTS_INCLUDE.read
|
|
42
|
+
check!(fragments_src.include?("jsonCache"),
|
|
43
|
+
"loadJson must memoize in-memory")
|
|
44
|
+
check!(fragments_src.include?("function loadWritingIndex"),
|
|
45
|
+
"runtime must expose loadWritingIndex for listing shells")
|
|
46
|
+
check!(fragments_src.include?("excerpt: entry.excerpt"),
|
|
47
|
+
"writingIndexAsListingItem must pass excerpt through to listing cards")
|
|
48
|
+
|
|
49
|
+
listing_src = LISTING_INCLUDE.read
|
|
50
|
+
check!(listing_src.include?("function listingPreview"),
|
|
51
|
+
"runtime must expose listingPreview so cards can fall back to excerpts")
|
|
52
|
+
|
|
53
|
+
[BLOG_LAYOUT, ARTICLES_LAYOUT].each do |layout|
|
|
54
|
+
check!(layout.read.include?("listingPreview"),
|
|
55
|
+
"#{layout.relative_path_from(REPO_ROOT)} cards must use listingPreview")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
Dir.mktmpdir("writing-index-") do |tmp|
|
|
59
|
+
out_dir = Pathname.new(tmp)
|
|
60
|
+
stdout, stderr, status = Open3.capture3(
|
|
61
|
+
{ "CONTENT_API_BASE_URL" => "" },
|
|
62
|
+
"ruby", EXPORT_SCRIPT.to_s, "content", out_dir.to_s,
|
|
63
|
+
chdir: REPO_ROOT.to_s
|
|
64
|
+
)
|
|
65
|
+
fail!("export-content-json.rb failed:\n#{stderr}\n#{stdout}") unless status.success?
|
|
66
|
+
|
|
67
|
+
index_path = out_dir.join("data/writing_index.json")
|
|
68
|
+
check!(index_path.file?, "export must write data/writing_index.json")
|
|
69
|
+
|
|
70
|
+
entries = JSON.parse(index_path.read)
|
|
71
|
+
check!(entries.is_a?(Array) && !entries.empty?, "writing_index must be a non-empty array")
|
|
72
|
+
|
|
73
|
+
required = %w[key slug title url type collection date xcites category description excerpt thumbnail aliases]
|
|
74
|
+
sample = entries.first
|
|
75
|
+
required.each do |field|
|
|
76
|
+
check!(sample.key?(field), "writing_index entries must include #{field}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
types = entries.map { |e| e["collection"] || e["type"] }.uniq
|
|
80
|
+
%w[articles posts products projects].each do |collection|
|
|
81
|
+
check!(types.include?(collection),
|
|
82
|
+
"writing_index must cover #{collection} (got #{types.inspect})")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
posts = entries.select { |entry| (entry["collection"] || entry["type"]) == "posts" }
|
|
86
|
+
check!(!posts.empty?, "writing_index must include posts")
|
|
87
|
+
post_without_description = posts.find { |entry| entry["description"].to_s.strip.empty? }
|
|
88
|
+
check!(!post_without_description.nil?, "expected at least one post with an empty editorial description")
|
|
89
|
+
excerpt = post_without_description["excerpt"].to_s.strip
|
|
90
|
+
check!(!excerpt.empty?, "posts without description must still export a body excerpt")
|
|
91
|
+
check!(excerpt.length <= 220, "excerpt must stay within the listing preview limit")
|
|
92
|
+
|
|
93
|
+
article = entries.find { |entry| (entry["collection"] || entry["type"]) == "articles" && !entry["description"].to_s.strip.empty? }
|
|
94
|
+
check!(!article.nil?, "expected an article with an editorial description")
|
|
95
|
+
check!(!article["excerpt"].to_s.strip.empty?, "articles must still export a body excerpt alongside description")
|
|
96
|
+
|
|
97
|
+
WritingIndexContract.assert_slugs_match_export!(out_dir, entries)
|
|
98
|
+
freshy = entries.find { |entry| entry["key"] == "freshy-cooling-map" }
|
|
99
|
+
if freshy
|
|
100
|
+
check!(out_dir.join("collections/articles/#{freshy['slug']}.json").file?,
|
|
101
|
+
"Freshy writing_index slug must match collections/articles/#{freshy['slug']}.json")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
expected_aliases = {
|
|
105
|
+
"ai-agents-sdd" => "ai-agents-sdd",
|
|
106
|
+
"vibe-coding" => "2026-03-24-where-to-do-vibe-coding",
|
|
107
|
+
"wsl" => "20260224-wsl",
|
|
108
|
+
"stitch-and-claude" => "stitch-and-claude"
|
|
109
|
+
}
|
|
110
|
+
expected_aliases.each do |key, alias_slug|
|
|
111
|
+
entry = entries.find { |item| item["key"] == key }
|
|
112
|
+
check!(!entry.nil?, "writing_index must include #{key}")
|
|
113
|
+
aliases = Array(entry["aliases"])
|
|
114
|
+
check!(aliases.include?(alias_slug),
|
|
115
|
+
"#{key} writing_index aliases must include #{alias_slug} (got #{aliases.inspect})")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
check!(FRAGMENTS_INCLUDE.read.include?("function resolveWritingAlias"),
|
|
120
|
+
"runtime must resolve old slugs from writing_index aliases")
|
|
121
|
+
check!(ARTICLES_LAYOUT.read.include?("resolveWritingAlias"),
|
|
122
|
+
"articles shell must resolve writing_index aliases before loading JSON")
|
|
123
|
+
check!(ARTICLES_LAYOUT.read.include?("adoptCanonicalDetailUrl"),
|
|
124
|
+
"articles shell must replaceState to the canonical URL when an alias matches")
|
|
125
|
+
|
|
126
|
+
puts "All slim writing index / xcite fetch checks passed."
|