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,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Studio API path guards. Spec: docs/features/studio.md (AC-STU path allowlist).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const ALLOWED_PREFIXES = ["content/"];
|
|
6
|
+
|
|
7
|
+
const BLOCKED_SEGMENTS = new Set([
|
|
8
|
+
".git",
|
|
9
|
+
".github",
|
|
10
|
+
"_plugins",
|
|
11
|
+
"node_modules",
|
|
12
|
+
"studio-app",
|
|
13
|
+
"functions",
|
|
14
|
+
".env",
|
|
15
|
+
"secrets",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export function normalizeRepoPath(raw) {
|
|
19
|
+
const path = String(raw || "")
|
|
20
|
+
.replace(/\\/g, "/")
|
|
21
|
+
.replace(/^\/+/, "")
|
|
22
|
+
.replace(/\/+/g, "/");
|
|
23
|
+
if (!path || path.includes("\0")) return null;
|
|
24
|
+
if (path.split("/").some((seg) => seg === ".." || BLOCKED_SEGMENTS.has(seg))) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (!ALLOWED_PREFIXES.some((prefix) => path === prefix.slice(0, -1) || path.startsWith(prefix))) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return path;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function collectionDir(name, schema) {
|
|
34
|
+
const found = findEntry(schema?.content, name);
|
|
35
|
+
return found?.path || null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findEntry(nodes, name) {
|
|
39
|
+
for (const entry of nodes || []) {
|
|
40
|
+
if (!entry || typeof entry !== "object") continue;
|
|
41
|
+
if (entry.type === "group") {
|
|
42
|
+
const nested = findEntry(entry.items, name);
|
|
43
|
+
if (nested) return nested;
|
|
44
|
+
} else if (entry.name === name) {
|
|
45
|
+
return entry;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function listSchemaCollections(schema) {
|
|
52
|
+
const out = [];
|
|
53
|
+
walk(schema?.content, out);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function walk(nodes, out) {
|
|
58
|
+
for (const entry of nodes || []) {
|
|
59
|
+
if (!entry || typeof entry !== "object") continue;
|
|
60
|
+
if (entry.type === "group") {
|
|
61
|
+
walk(entry.items, out);
|
|
62
|
+
} else if (entry.type === "collection" || entry.type === "file") {
|
|
63
|
+
out.push({
|
|
64
|
+
name: entry.name,
|
|
65
|
+
label: entry.label || entry.name,
|
|
66
|
+
type: entry.type,
|
|
67
|
+
path: entry.path,
|
|
68
|
+
format: entry.format || "yaml-frontmatter",
|
|
69
|
+
filename: entry.filename,
|
|
70
|
+
fields: entry.fields || [],
|
|
71
|
+
view: entry.view || {},
|
|
72
|
+
operations: entry.operations || {},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini prompt builders for Studio Correct / Refine.
|
|
3
|
+
* Spec: docs/features/studio-proof.md §8 (AC-PRF-11, AC-PRF-15, AC-PRF-17).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { formatBlocksForPrompt } from "./blocks.js";
|
|
7
|
+
|
|
8
|
+
const CORRECT_SCHEMA = {
|
|
9
|
+
type: "object",
|
|
10
|
+
properties: {
|
|
11
|
+
patches: {
|
|
12
|
+
type: "array",
|
|
13
|
+
items: {
|
|
14
|
+
type: "object",
|
|
15
|
+
required: ["start", "end", "original", "replacement", "category", "reason"],
|
|
16
|
+
properties: {
|
|
17
|
+
start: { type: "integer" },
|
|
18
|
+
end: { type: "integer" },
|
|
19
|
+
original: { type: "string" },
|
|
20
|
+
replacement: { type: "string" },
|
|
21
|
+
category: { type: "string" },
|
|
22
|
+
reason: { type: "string" },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
required: ["patches"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const REFINE_SCHEMA = {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
patches: {
|
|
34
|
+
type: "array",
|
|
35
|
+
items: {
|
|
36
|
+
type: "object",
|
|
37
|
+
required: ["block", "original", "replacement", "category", "reason"],
|
|
38
|
+
properties: {
|
|
39
|
+
block: { type: "integer" },
|
|
40
|
+
original: { type: "string" },
|
|
41
|
+
replacement: { type: "string" },
|
|
42
|
+
category: { type: "string" },
|
|
43
|
+
reason: { type: "string" },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
required: ["patches"],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const CORRECT_SYSTEM = `You are a mechanical corrector for a personal site CMS. You are not a co-author and not a stylist.
|
|
52
|
+
|
|
53
|
+
Language: all judgments use the language code from the user message (from the entry front matter). Spelling and idiom follow that language's norms.
|
|
54
|
+
|
|
55
|
+
Allowed patch categories only: spelling, typo, grammar, punctuation, idiom. If unsure whether a change is required for correctness, omit it.
|
|
56
|
+
|
|
57
|
+
Hard bans:
|
|
58
|
+
- Do not improve rhythm, voice, imagery, or “style”.
|
|
59
|
+
- Do not change meaning, add or remove ideas, or reorder paragraphs.
|
|
60
|
+
- Do not edit Liquid {% … %} tags (include, cite, or any other) or their attributes.
|
|
61
|
+
- Do not touch fenced code blocks or inline \`code\`.
|
|
62
|
+
- Do not translate into another language.
|
|
63
|
+
- Do not invent citations, links, or facts.
|
|
64
|
+
|
|
65
|
+
Offsets start/end are JavaScript string indices (UTF-16 code units) into the exact text field. original must equal text.slice(start, end). Prefer the smallest edit that makes the sentence correct.
|
|
66
|
+
|
|
67
|
+
Emit JSON only matching the schema. No prose outside JSON.`;
|
|
68
|
+
|
|
69
|
+
function refineSystem(guidelinesBody) {
|
|
70
|
+
const pack = String(guidelinesBody || "").trim();
|
|
71
|
+
return `You are an editorial refiner for a personal site CMS under the owner's writing law. The user invoked Refine on purpose: improve fluidity, clarity, correctness, and prose quality inside the submitted blocks. You are not a co-author: do not change stance, invent structure, headings hierarchy, or claims.
|
|
72
|
+
|
|
73
|
+
Preserve tone consistency with the submitted text and with any provided article context (before/after). Stay in the language code from the user message.
|
|
74
|
+
|
|
75
|
+
Allowed patch categories: clarity, flow, prose, grammar, punctuation, spelling, typo, idiom.
|
|
76
|
+
|
|
77
|
+
When to patch:
|
|
78
|
+
- For each prose block, look for sentences that can be tightened, clarified, or made to flow better without changing meaning.
|
|
79
|
+
- Prefer one local edit per issue (a clause or a sentence). original must be an exact substring of that block.
|
|
80
|
+
- Skip heading-only blocks, blocks that are only Liquid/HTML, and figure/include-only blocks.
|
|
81
|
+
- Empty patches only when every prose block is already tight under the guidelines. Do not return an empty list just because the prose is already good if a clearer local wording exists.
|
|
82
|
+
|
|
83
|
+
Hard bans:
|
|
84
|
+
- Do not edit Liquid {% … %} tags (include, cite, or any other) or code fences / inline code. Leave those substrings untouched inside original/replacement.
|
|
85
|
+
- Do not translate.
|
|
86
|
+
- Do not homogenize into generic AI-essay voice, mic-drop closers, or banned patterns from the guidelines.
|
|
87
|
+
- Do not leak private repository paths or internal tooling into the prose.
|
|
88
|
+
- Do not replace an entire long paragraph in one patch; keep each replacement short and local.
|
|
89
|
+
|
|
90
|
+
Each patch must set block to the integer index shown as [n] in the user message.
|
|
91
|
+
|
|
92
|
+
Writing guidelines (published pack):
|
|
93
|
+
<<<
|
|
94
|
+
${pack}
|
|
95
|
+
>>>
|
|
96
|
+
|
|
97
|
+
Emit JSON only matching the schema. No prose outside JSON.`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Prefer Fundamental + Cadence/Typography + site overlay if over budget.
|
|
102
|
+
* @param {string} body
|
|
103
|
+
* @param {number} maxChars
|
|
104
|
+
*/
|
|
105
|
+
export function truncateGuidelines(body, maxChars = 48_000) {
|
|
106
|
+
const text = String(body || "");
|
|
107
|
+
if (text.length <= maxChars) return text;
|
|
108
|
+
return `${text.slice(0, maxChars)}\n\n[guidelines truncated for context budget]\n`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {object} opts
|
|
113
|
+
* @param {"correct"|"refine"} opts.mode
|
|
114
|
+
* @param {string} opts.language
|
|
115
|
+
* @param {string} opts.path
|
|
116
|
+
* @param {string} opts.text
|
|
117
|
+
* @param {{ before?: string, after?: string } | null} [opts.context]
|
|
118
|
+
* @param {string} [opts.guidelinesBody]
|
|
119
|
+
* @param {{ index: number, text: string }[] | null} [opts.blocks]
|
|
120
|
+
*/
|
|
121
|
+
export function buildGeminiRequest({
|
|
122
|
+
mode,
|
|
123
|
+
language,
|
|
124
|
+
path,
|
|
125
|
+
text,
|
|
126
|
+
context = null,
|
|
127
|
+
guidelinesBody = "",
|
|
128
|
+
blocks = null,
|
|
129
|
+
}) {
|
|
130
|
+
const system =
|
|
131
|
+
mode === "refine"
|
|
132
|
+
? refineSystem(truncateGuidelines(guidelinesBody))
|
|
133
|
+
: CORRECT_SYSTEM;
|
|
134
|
+
|
|
135
|
+
const parts = [
|
|
136
|
+
`mode: ${mode}`,
|
|
137
|
+
`language: ${language}`,
|
|
138
|
+
`path: ${path || ""}`,
|
|
139
|
+
];
|
|
140
|
+
if (mode === "refine" && context?.before) {
|
|
141
|
+
parts.push("context.before:", "<<<", String(context.before), ">>>");
|
|
142
|
+
}
|
|
143
|
+
if (mode === "refine" && Array.isArray(blocks) && blocks.length) {
|
|
144
|
+
parts.push(formatBlocksForPrompt(blocks));
|
|
145
|
+
} else {
|
|
146
|
+
parts.push("text:", "<<<", String(text ?? ""), ">>>");
|
|
147
|
+
}
|
|
148
|
+
if (mode === "refine" && context?.after) {
|
|
149
|
+
parts.push("context.after:", "<<<", String(context.after), ">>>");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
systemInstruction: { parts: [{ text: system }] },
|
|
154
|
+
contents: [{ role: "user", parts: [{ text: parts.join("\n") }] }],
|
|
155
|
+
generationConfig: {
|
|
156
|
+
temperature: mode === "refine" ? 0.35 : 0,
|
|
157
|
+
responseMimeType: "application/json",
|
|
158
|
+
responseSchema: mode === "refine" ? REFINE_SCHEMA : CORRECT_SCHEMA,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export { CORRECT_SCHEMA, REFINE_SCHEMA, CORRECT_SYSTEM };
|
|
164
|
+
/** @deprecated use CORRECT_SCHEMA — kept for static contract greps */
|
|
165
|
+
export const RESPONSE_SCHEMA = CORRECT_SCHEMA;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-window request budget for Studio mutating routes.
|
|
3
|
+
* Spec: docs/features/studio.md §4 — throttle writes, not reads.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {Map<string, {start: number, count: number}>} store
|
|
8
|
+
* @param {string} key
|
|
9
|
+
* @param {number} now
|
|
10
|
+
* @param {{ windowMs: number, max: number }} limits
|
|
11
|
+
* @returns {{ allowed: boolean, retryAfterSec: number }}
|
|
12
|
+
*/
|
|
13
|
+
export function takeToken(store, key, now, limits) {
|
|
14
|
+
const windowMs = limits.windowMs;
|
|
15
|
+
const max = limits.max;
|
|
16
|
+
let bucket = store.get(key);
|
|
17
|
+
if (!bucket || now < bucket.start || now - bucket.start >= windowMs) {
|
|
18
|
+
bucket = { start: now, count: 0 };
|
|
19
|
+
store.set(key, bucket);
|
|
20
|
+
}
|
|
21
|
+
bucket.count += 1;
|
|
22
|
+
if (bucket.count <= max) {
|
|
23
|
+
return { allowed: true, retryAfterSec: 0 };
|
|
24
|
+
}
|
|
25
|
+
const retryAfterSec = Math.max(
|
|
26
|
+
1,
|
|
27
|
+
Math.ceil((bucket.start + windowMs - now) / 1000)
|
|
28
|
+
);
|
|
29
|
+
return { allowed: false, retryAfterSec };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** PUT/POST/PATCH/DELETE. GET (tree, file, search, session) must not spend this. */
|
|
33
|
+
export function isMutatingMethod(method) {
|
|
34
|
+
const verb = String(method || "").toUpperCase();
|
|
35
|
+
return verb === "POST" || verb === "PUT" || verb === "PATCH" || verb === "DELETE";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function clientAddress(request) {
|
|
39
|
+
return request.headers.get("CF-Connecting-IP") || "unknown";
|
|
40
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Studio rewrite orchestration (Correct / Refine).
|
|
3
|
+
* Spec: docs/features/studio-proof.md §7.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { flattenBlockPatches, splitIntoBlocks } from "./blocks.js";
|
|
7
|
+
import { buildGeminiRequest } from "./prompts.js";
|
|
8
|
+
import { callGemini } from "./gemini.js";
|
|
9
|
+
import { loadGuidelinesPack } from "./guidelines.js";
|
|
10
|
+
import { clientAddress, takeToken } from "./rate-limit.js";
|
|
11
|
+
import { sanitizePatches } from "./sanitize.js";
|
|
12
|
+
|
|
13
|
+
const MAX_TEXT = 100_000;
|
|
14
|
+
const MAX_CONTEXT = 2_000;
|
|
15
|
+
const REWRITE_RATE = new Map();
|
|
16
|
+
const REWRITE_WINDOW_MS = 60_000;
|
|
17
|
+
const REWRITE_MAX = 5;
|
|
18
|
+
/** Allow more local patches when refining a long multi-block selection. */
|
|
19
|
+
const REFINE_MAX_PATCHES = 60;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {Request} request
|
|
23
|
+
* @param {string} userId
|
|
24
|
+
* @returns {{ allowed: boolean, retryAfterSec: number }}
|
|
25
|
+
*/
|
|
26
|
+
export function rewriteRateLimit(request, userId) {
|
|
27
|
+
return takeToken(
|
|
28
|
+
REWRITE_RATE,
|
|
29
|
+
`rewrite:${userId}:${clientAddress(request)}`,
|
|
30
|
+
Date.now(),
|
|
31
|
+
{ windowMs: REWRITE_WINDOW_MS, max: REWRITE_MAX }
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} env
|
|
37
|
+
* @param {object} body — parsed JSON body
|
|
38
|
+
*/
|
|
39
|
+
export async function runRewrite(env, body) {
|
|
40
|
+
const mode = String(body?.mode || "").toLowerCase();
|
|
41
|
+
if (mode !== "correct" && mode !== "refine") {
|
|
42
|
+
const err = new Error("invalid_body");
|
|
43
|
+
err.code = "invalid_body";
|
|
44
|
+
err.status = 400;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const language = String(body?.language || "").trim();
|
|
49
|
+
if (!language) {
|
|
50
|
+
const err = new Error("language_required");
|
|
51
|
+
err.code = "language_required";
|
|
52
|
+
err.status = 400;
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const text = String(body?.text ?? "");
|
|
57
|
+
if (!text) {
|
|
58
|
+
const err = new Error("invalid_body");
|
|
59
|
+
err.code = "invalid_body";
|
|
60
|
+
err.status = 400;
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
if (text.length > MAX_TEXT) {
|
|
64
|
+
const err = new Error("invalid_body");
|
|
65
|
+
err.code = "invalid_body";
|
|
66
|
+
err.status = 400;
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const path = String(body?.path || "");
|
|
71
|
+
// Spec: when selection is present, `text` is already the slice; offsets are
|
|
72
|
+
// relative to that slice. SPA applies against the same `text` it sent.
|
|
73
|
+
const workText = text;
|
|
74
|
+
|
|
75
|
+
let context = null;
|
|
76
|
+
if (mode === "refine" && body?.context && typeof body.context === "object") {
|
|
77
|
+
context = {
|
|
78
|
+
before: String(body.context.before || "").slice(-MAX_CONTEXT),
|
|
79
|
+
after: String(body.context.after || "").slice(0, MAX_CONTEXT),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let guidelinesVersion = null;
|
|
84
|
+
let guidelinesBody = "";
|
|
85
|
+
if (mode === "refine") {
|
|
86
|
+
const pack = await loadGuidelinesPack(env);
|
|
87
|
+
guidelinesBody = pack.body_markdown;
|
|
88
|
+
guidelinesVersion = pack.body_sha256;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const blocks = mode === "refine" ? splitIntoBlocks(workText) : null;
|
|
92
|
+
|
|
93
|
+
const geminiBody = buildGeminiRequest({
|
|
94
|
+
mode,
|
|
95
|
+
language,
|
|
96
|
+
path,
|
|
97
|
+
text: workText,
|
|
98
|
+
context,
|
|
99
|
+
guidelinesBody,
|
|
100
|
+
blocks,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
let gemini;
|
|
104
|
+
try {
|
|
105
|
+
gemini = await callGemini(env, geminiBody);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (err.code === "gemini_unconfigured") {
|
|
108
|
+
err.status = 503;
|
|
109
|
+
err.code = "gemini_upstream";
|
|
110
|
+
} else if (!err.status) {
|
|
111
|
+
err.status = 502;
|
|
112
|
+
err.code = err.code || "gemini_upstream";
|
|
113
|
+
}
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const rawPatches =
|
|
118
|
+
mode === "refine"
|
|
119
|
+
? flattenBlockPatches(blocks, gemini.patches)
|
|
120
|
+
: gemini.patches;
|
|
121
|
+
|
|
122
|
+
const { patches, stats } = sanitizePatches({
|
|
123
|
+
mode,
|
|
124
|
+
text: workText,
|
|
125
|
+
patches: rawPatches,
|
|
126
|
+
maxPatches: mode === "refine" ? REFINE_MAX_PATCHES : 40,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
mode,
|
|
131
|
+
guidelines_version: guidelinesVersion,
|
|
132
|
+
model: gemini.model,
|
|
133
|
+
patches,
|
|
134
|
+
stats,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch sanitizer for Studio Correct / Refine.
|
|
3
|
+
* Spec: docs/features/studio-proof.md §3, §8 (AC-PRF-05–07).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { locateUnique } from "./blocks.js";
|
|
7
|
+
|
|
8
|
+
export const CORRECT_CATEGORIES = new Set([
|
|
9
|
+
"spelling",
|
|
10
|
+
"typo",
|
|
11
|
+
"grammar",
|
|
12
|
+
"punctuation",
|
|
13
|
+
"idiom",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export const REFINE_CATEGORIES = new Set([
|
|
17
|
+
"spelling",
|
|
18
|
+
"typo",
|
|
19
|
+
"grammar",
|
|
20
|
+
"punctuation",
|
|
21
|
+
"idiom",
|
|
22
|
+
"clarity",
|
|
23
|
+
"flow",
|
|
24
|
+
"prose",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** Any Liquid tag — include, cite, etc. */
|
|
28
|
+
const LIQUID_RE = /\{%[\s\S]*?%\}/g;
|
|
29
|
+
const FENCE_RE = /```[\s\S]*?```/g;
|
|
30
|
+
const INLINE_CODE_RE = /`[^`\n]+`/g;
|
|
31
|
+
|
|
32
|
+
/** @param {string} text */
|
|
33
|
+
export function protectedRanges(text) {
|
|
34
|
+
const ranges = [];
|
|
35
|
+
const pushAll = (re) => {
|
|
36
|
+
re.lastIndex = 0;
|
|
37
|
+
let m;
|
|
38
|
+
while ((m = re.exec(text)) !== null) {
|
|
39
|
+
ranges.push({ start: m.index, end: m.index + m[0].length });
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
pushAll(LIQUID_RE);
|
|
43
|
+
pushAll(FENCE_RE);
|
|
44
|
+
pushAll(INLINE_CODE_RE);
|
|
45
|
+
return ranges;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function overlapsProtected(start, end, ranges) {
|
|
49
|
+
return ranges.some((r) => start < r.end && end > r.start);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {object} opts
|
|
54
|
+
* @param {"correct"|"refine"} opts.mode
|
|
55
|
+
* @param {string} opts.text
|
|
56
|
+
* @param {unknown[]} opts.patches
|
|
57
|
+
* @param {number} [opts.maxPatches]
|
|
58
|
+
* @param {number} [opts.maxReplacement]
|
|
59
|
+
* @returns {{ patches: object[], stats: { proposed: number, kept: number, dropped: number } }}
|
|
60
|
+
*/
|
|
61
|
+
export function sanitizePatches({
|
|
62
|
+
mode,
|
|
63
|
+
text,
|
|
64
|
+
patches,
|
|
65
|
+
maxPatches = 40,
|
|
66
|
+
maxReplacement = mode === "refine" ? 500 : 200,
|
|
67
|
+
}) {
|
|
68
|
+
const allowed = mode === "refine" ? REFINE_CATEGORIES : CORRECT_CATEGORIES;
|
|
69
|
+
const protected_ = protectedRanges(text || "");
|
|
70
|
+
const src = typeof text === "string" ? text : "";
|
|
71
|
+
const list = Array.isArray(patches) ? patches : [];
|
|
72
|
+
const out = [];
|
|
73
|
+
const seen = [];
|
|
74
|
+
|
|
75
|
+
for (const raw of list) {
|
|
76
|
+
if (!raw || typeof raw !== "object") continue;
|
|
77
|
+
|
|
78
|
+
const original = String(raw.original ?? "");
|
|
79
|
+
const replacement = String(raw.replacement ?? "");
|
|
80
|
+
const category = String(raw.category || "").toLowerCase();
|
|
81
|
+
const reason = String(raw.reason || "").slice(0, 160);
|
|
82
|
+
|
|
83
|
+
if (!allowed.has(category)) continue;
|
|
84
|
+
if (!original || replacement === original) continue;
|
|
85
|
+
if (replacement.length > maxReplacement) continue;
|
|
86
|
+
if (replacement.length === 0 && original.length > 8) continue;
|
|
87
|
+
|
|
88
|
+
let start = Number(raw.start);
|
|
89
|
+
let end = Number(raw.end);
|
|
90
|
+
if (
|
|
91
|
+
!Number.isInteger(start) ||
|
|
92
|
+
!Number.isInteger(end) ||
|
|
93
|
+
start < 0 ||
|
|
94
|
+
end < start ||
|
|
95
|
+
end > src.length ||
|
|
96
|
+
src.slice(start, end) !== original
|
|
97
|
+
) {
|
|
98
|
+
const hint = Number.isInteger(start) ? start : 0;
|
|
99
|
+
const loc = locateUnique(src, original, hint);
|
|
100
|
+
if (!loc) continue;
|
|
101
|
+
start = loc.start;
|
|
102
|
+
end = loc.end;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (overlapsProtected(start, end, protected_)) continue;
|
|
106
|
+
if (seen.some((r) => start < r.end && end > r.start)) continue;
|
|
107
|
+
|
|
108
|
+
seen.push({ start, end });
|
|
109
|
+
out.push({
|
|
110
|
+
start,
|
|
111
|
+
end,
|
|
112
|
+
original,
|
|
113
|
+
replacement,
|
|
114
|
+
category,
|
|
115
|
+
reason,
|
|
116
|
+
});
|
|
117
|
+
if (out.length >= maxPatches) break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
out.sort((a, b) => a.start - b.start);
|
|
121
|
+
return {
|
|
122
|
+
patches: out,
|
|
123
|
+
stats: {
|
|
124
|
+
proposed: list.length,
|
|
125
|
+
kept: out.length,
|
|
126
|
+
dropped: Math.max(0, list.length - out.length),
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Remap selection-relative patches onto full-body offsets.
|
|
133
|
+
* @param {unknown[]} patches
|
|
134
|
+
* @param {number} base
|
|
135
|
+
*/
|
|
136
|
+
export function remapPatchOffsets(patches, base) {
|
|
137
|
+
const b = Number(base) || 0;
|
|
138
|
+
return (Array.isArray(patches) ? patches : []).map((p) => ({
|
|
139
|
+
...p,
|
|
140
|
+
start: Number(p.start) + b,
|
|
141
|
+
end: Number(p.end) + b,
|
|
142
|
+
}));
|
|
143
|
+
}
|
data/lib/pandorga/cli.rb
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "commands/doctor"
|
|
4
|
+
require_relative "commands/export"
|
|
5
|
+
require_relative "commands/serve"
|
|
6
|
+
require_relative "commands/publish"
|
|
7
|
+
require_relative "commands/install_functions"
|
|
8
|
+
require_relative "commands/studio_schema"
|
|
9
|
+
require_relative "commands/new"
|
|
10
|
+
|
|
11
|
+
module Pandorga
|
|
12
|
+
# Dispatch for `exe/pandorga`.
|
|
13
|
+
module CLI
|
|
14
|
+
COMMANDS = {
|
|
15
|
+
"new" => Commands::New,
|
|
16
|
+
"doctor" => Commands::Doctor,
|
|
17
|
+
"export" => Commands::Export,
|
|
18
|
+
"serve" => Commands::Serve,
|
|
19
|
+
"publish" => Commands::Publish,
|
|
20
|
+
"install-functions" => Commands::InstallFunctions,
|
|
21
|
+
"studio-schema" => Commands::StudioSchema
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def run(argv)
|
|
27
|
+
command = argv.shift
|
|
28
|
+
if command.nil? || %w[-h --help help].include?(command)
|
|
29
|
+
print_help
|
|
30
|
+
return 0
|
|
31
|
+
end
|
|
32
|
+
if %w[-v --version].include?(command)
|
|
33
|
+
puts "pandorga #{Pandorga::VERSION}"
|
|
34
|
+
return 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
klass = COMMANDS[command]
|
|
38
|
+
unless klass
|
|
39
|
+
warn "Unknown command: #{command}"
|
|
40
|
+
print_help
|
|
41
|
+
return 1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
klass.new.run(argv)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def print_help
|
|
48
|
+
puts <<~HELP
|
|
49
|
+
pandorga #{Pandorga::VERSION} — static shell + JSON content platform
|
|
50
|
+
|
|
51
|
+
Usage:
|
|
52
|
+
pandorga new PATH Scaffold a site from the minimal example
|
|
53
|
+
pandorga doctor Check site config and environment
|
|
54
|
+
pandorga export [src] [dest] Export content collections to JSON
|
|
55
|
+
pandorga serve Local Jekyll serve with JSON re-export
|
|
56
|
+
pandorga publish Sync content JSON to R2
|
|
57
|
+
pandorga install-functions Copy functions/ and studio/ into site root
|
|
58
|
+
pandorga studio-schema Compose studio/schema.yml from templates
|
|
59
|
+
|
|
60
|
+
Options:
|
|
61
|
+
-h, --help Show this help
|
|
62
|
+
-v, --version Show version
|
|
63
|
+
HELP
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
module Pandorga
|
|
8
|
+
module Commands
|
|
9
|
+
# Sanity-check a site directory for pandorga configuration.
|
|
10
|
+
class Doctor
|
|
11
|
+
def run(argv)
|
|
12
|
+
root = Pathname.new(argv[0] || Dir.pwd).expand_path
|
|
13
|
+
errors = []
|
|
14
|
+
warnings = []
|
|
15
|
+
|
|
16
|
+
config_path = root.join("_config.yml")
|
|
17
|
+
unless config_path.file?
|
|
18
|
+
warn "error: no _config.yml in #{root}"
|
|
19
|
+
return 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
config = YAML.safe_load(config_path.read, permitted_classes: [Date]) || {}
|
|
23
|
+
pandorga = config["pandorga"]
|
|
24
|
+
unless pandorga.is_a?(Hash)
|
|
25
|
+
errors << "missing top-level `pandorga:` key (identity, content, pages)"
|
|
26
|
+
else
|
|
27
|
+
identity = pandorga["identity"]
|
|
28
|
+
if !identity.is_a?(Hash) || identity["name"].to_s.strip.empty?
|
|
29
|
+
errors << "pandorga.identity.name is required"
|
|
30
|
+
end
|
|
31
|
+
pages = pandorga["pages"]
|
|
32
|
+
if pages.nil?
|
|
33
|
+
warnings << "pandorga.pages is empty — no pages will be generated"
|
|
34
|
+
elsif !pages.is_a?(Array)
|
|
35
|
+
errors << "pandorga.pages must be an array"
|
|
36
|
+
else
|
|
37
|
+
keys = pages.map { |p| p.is_a?(Hash) ? p["key"] : nil }
|
|
38
|
+
dup = keys.compact.group_by(&:itself).select { |_, v| v.size > 1 }.keys
|
|
39
|
+
errors << "duplicate page keys: #{dup.join(', ')}" if dup.any?
|
|
40
|
+
pages.each_with_index do |page, i|
|
|
41
|
+
next unless page.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
errors << "pages[#{i}]: missing key" if page["key"].to_s.empty?
|
|
44
|
+
errors << "pages[#{i}] (#{page['key']}): missing template" if page["template"].to_s.empty?
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
gemfile = root.join("Gemfile")
|
|
50
|
+
warnings << "Gemfile does not mention pandorga" if gemfile.file? && !gemfile.read.include?("pandorga")
|
|
51
|
+
|
|
52
|
+
if errors.empty? && warnings.empty?
|
|
53
|
+
puts "pandorga doctor: ok (#{root})"
|
|
54
|
+
return 0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
warnings.each { |w| warn "warning: #{w}" }
|
|
58
|
+
errors.each { |e| warn "error: #{e}" }
|
|
59
|
+
return 1 if errors.any?
|
|
60
|
+
|
|
61
|
+
puts "pandorga doctor: ok with warnings (#{root})"
|
|
62
|
+
0
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|