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,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paragraph/block chunking for Studio Refine.
|
|
3
|
+
* Large selections get local patches per block; absolute offsets are remapped after.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TARGET = 1_800;
|
|
7
|
+
const DEFAULT_MAX = 2_400;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Split markdown into contiguous blocks with absolute offsets into `text`.
|
|
11
|
+
* Breaks on blank lines (separators stay with the preceding block); merges small
|
|
12
|
+
* siblings up to targetChars without exceeding maxChars unless one segment is larger.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} text
|
|
15
|
+
* @param {{ targetChars?: number, maxChars?: number }} [opts]
|
|
16
|
+
* @returns {{ index: number, start: number, end: number, text: string }[]}
|
|
17
|
+
*/
|
|
18
|
+
export function splitIntoBlocks(text, opts = {}) {
|
|
19
|
+
const targetChars = opts.targetChars ?? DEFAULT_TARGET;
|
|
20
|
+
const maxChars = opts.maxChars ?? DEFAULT_MAX;
|
|
21
|
+
const src = typeof text === "string" ? text : "";
|
|
22
|
+
if (!src) return [];
|
|
23
|
+
|
|
24
|
+
const chunks = src.split(/(\n{2,})/);
|
|
25
|
+
const segments = [];
|
|
26
|
+
let pos = 0;
|
|
27
|
+
for (const piece of chunks) {
|
|
28
|
+
if (!piece) continue;
|
|
29
|
+
const next = pos + piece.length;
|
|
30
|
+
if (/^\n{2,}$/.test(piece)) {
|
|
31
|
+
if (segments.length) {
|
|
32
|
+
const prev = segments[segments.length - 1];
|
|
33
|
+
prev.end = next;
|
|
34
|
+
prev.text = src.slice(prev.start, prev.end);
|
|
35
|
+
} else {
|
|
36
|
+
segments.push({ start: pos, end: next, text: piece });
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
segments.push({ start: pos, end: next, text: piece });
|
|
40
|
+
}
|
|
41
|
+
pos = next;
|
|
42
|
+
}
|
|
43
|
+
if (!segments.length) {
|
|
44
|
+
return [{ index: 0, start: 0, end: src.length, text: src }];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const merged = [];
|
|
48
|
+
let buf = null;
|
|
49
|
+
for (const seg of segments) {
|
|
50
|
+
if (!buf) {
|
|
51
|
+
buf = { start: seg.start, end: seg.end, text: seg.text };
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const combinedLen = buf.end - buf.start + (seg.end - seg.start);
|
|
55
|
+
if (buf.text.trim().length < targetChars && combinedLen <= maxChars) {
|
|
56
|
+
buf.end = seg.end;
|
|
57
|
+
buf.text = src.slice(buf.start, buf.end);
|
|
58
|
+
} else {
|
|
59
|
+
merged.push(buf);
|
|
60
|
+
buf = { start: seg.start, end: seg.end, text: seg.text };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (buf) merged.push(buf);
|
|
64
|
+
|
|
65
|
+
return merged.map((b, index) => ({
|
|
66
|
+
index,
|
|
67
|
+
start: b.start,
|
|
68
|
+
end: b.end,
|
|
69
|
+
text: b.text,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Find a unique (or hint-disambiguated) occurrence of `needle` in `haystack`.
|
|
75
|
+
* @param {string} haystack
|
|
76
|
+
* @param {string} needle
|
|
77
|
+
* @param {number} [hint]
|
|
78
|
+
* @returns {{ start: number, end: number } | null}
|
|
79
|
+
*/
|
|
80
|
+
export function locateUnique(haystack, needle, hint = 0) {
|
|
81
|
+
if (!needle || typeof haystack !== "string") return null;
|
|
82
|
+
const h = Number.isInteger(hint) ? hint : 0;
|
|
83
|
+
if (h >= 0 && haystack.slice(h, h + needle.length) === needle) {
|
|
84
|
+
return { start: h, end: h + needle.length };
|
|
85
|
+
}
|
|
86
|
+
const hits = [];
|
|
87
|
+
let from = 0;
|
|
88
|
+
while (from <= haystack.length - needle.length) {
|
|
89
|
+
const i = haystack.indexOf(needle, from);
|
|
90
|
+
if (i === -1) break;
|
|
91
|
+
hits.push(i);
|
|
92
|
+
from = i + 1;
|
|
93
|
+
}
|
|
94
|
+
if (!hits.length) return null;
|
|
95
|
+
if (hits.length === 1) {
|
|
96
|
+
return { start: hits[0], end: hits[0] + needle.length };
|
|
97
|
+
}
|
|
98
|
+
let best = hits[0];
|
|
99
|
+
let bestDist = Math.abs(best - h);
|
|
100
|
+
for (const i of hits) {
|
|
101
|
+
const d = Math.abs(i - h);
|
|
102
|
+
if (d < bestDist) {
|
|
103
|
+
best = i;
|
|
104
|
+
bestDist = d;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { start: best, end: best + needle.length };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Map block-scoped patches onto absolute offsets in the full work text.
|
|
112
|
+
* @param {{ index: number, start: number, end: number, text: string }[]} blocks
|
|
113
|
+
* @param {unknown[]} patches
|
|
114
|
+
* @returns {unknown[]}
|
|
115
|
+
*/
|
|
116
|
+
export function flattenBlockPatches(blocks, patches) {
|
|
117
|
+
const list = Array.isArray(patches) ? patches : [];
|
|
118
|
+
const byIndex = new Map(
|
|
119
|
+
(Array.isArray(blocks) ? blocks : []).map((b) => [b.index, b])
|
|
120
|
+
);
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const raw of list) {
|
|
123
|
+
if (!raw || typeof raw !== "object") continue;
|
|
124
|
+
const blockIndex = Number(raw.block);
|
|
125
|
+
if (!Number.isInteger(blockIndex)) continue;
|
|
126
|
+
const block = byIndex.get(blockIndex);
|
|
127
|
+
if (!block) continue;
|
|
128
|
+
const original = String(raw.original ?? "");
|
|
129
|
+
const replacement = String(raw.replacement ?? "");
|
|
130
|
+
if (!original || replacement === original) continue;
|
|
131
|
+
const hint = Number.isInteger(Number(raw.start)) ? Number(raw.start) : 0;
|
|
132
|
+
const loc = locateUnique(block.text, original, hint);
|
|
133
|
+
if (!loc) continue;
|
|
134
|
+
out.push({
|
|
135
|
+
start: block.start + loc.start,
|
|
136
|
+
end: block.start + loc.end,
|
|
137
|
+
original,
|
|
138
|
+
replacement,
|
|
139
|
+
category: raw.category,
|
|
140
|
+
reason: raw.reason,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Format blocks for the Gemini user message.
|
|
148
|
+
* @param {{ index: number, text: string }[]} blocks
|
|
149
|
+
*/
|
|
150
|
+
export function formatBlocksForPrompt(blocks) {
|
|
151
|
+
const lines = ["blocks:"];
|
|
152
|
+
for (const b of blocks) {
|
|
153
|
+
lines.push(`[${b.index}]`, "<<<", b.text, ">>>");
|
|
154
|
+
}
|
|
155
|
+
return lines.join("\n");
|
|
156
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini generateContent client (Google AI Studio / Generative Language API).
|
|
3
|
+
* Spec: docs/features/studio-proof.md §5.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {object} env
|
|
8
|
+
* @param {object} body — generateContent request payload
|
|
9
|
+
* @returns {Promise<{ patches: unknown[], model: string, rawText: string }>}
|
|
10
|
+
*/
|
|
11
|
+
export async function callGemini(env, body) {
|
|
12
|
+
const key = String(env.GEMINI_API_KEY || "").trim();
|
|
13
|
+
if (!key) {
|
|
14
|
+
const err = new Error("gemini_unconfigured");
|
|
15
|
+
err.code = "gemini_unconfigured";
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
const model = String(env.GEMINI_MODEL || "gemini-2.5-flash").trim();
|
|
19
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(key)}`;
|
|
20
|
+
|
|
21
|
+
const res = await fetch(url, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "Content-Type": "application/json" },
|
|
24
|
+
body: JSON.stringify(body),
|
|
25
|
+
});
|
|
26
|
+
const raw = await res.text();
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
const err = new Error(`gemini_http_${res.status}`);
|
|
29
|
+
err.code = "gemini_upstream";
|
|
30
|
+
err.status = res.status;
|
|
31
|
+
err.detail = raw.slice(0, 400);
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(raw);
|
|
38
|
+
} catch {
|
|
39
|
+
const err = new Error("gemini_bad_json");
|
|
40
|
+
err.code = "gemini_upstream";
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const text =
|
|
45
|
+
parsed?.candidates?.[0]?.content?.parts?.map((p) => p.text || "").join("") ||
|
|
46
|
+
"";
|
|
47
|
+
let payload;
|
|
48
|
+
try {
|
|
49
|
+
payload = JSON.parse(text);
|
|
50
|
+
} catch {
|
|
51
|
+
const err = new Error("gemini_schema_json");
|
|
52
|
+
err.code = "gemini_upstream";
|
|
53
|
+
err.detail = text.slice(0, 400);
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
patches: Array.isArray(payload.patches) ? payload.patches : [],
|
|
59
|
+
model,
|
|
60
|
+
rawText: text,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Contents API helpers. PAT stays server-side (AC-STU-09).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function repoParts(env) {
|
|
6
|
+
const full = String(env.GITHUB_REPO || "").trim();
|
|
7
|
+
if (!full) throw new Error("GITHUB_REPO is required (owner/name)");
|
|
8
|
+
const [owner, repo] = full.split("/");
|
|
9
|
+
if (!owner || !repo) throw new Error("GITHUB_REPO must be owner/name");
|
|
10
|
+
return { owner, repo };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* GitHub Contents failures used to surface as `{ error: "server_error" }` with
|
|
15
|
+
* the status buried after a colon. A phone toolbar ellipsizes that to
|
|
16
|
+
* "server_err…", so the code and the sentence have to travel separately.
|
|
17
|
+
*
|
|
18
|
+
* Primary and secondary rate limits are 403 or 429. A bare 403 is also how
|
|
19
|
+
* GitHub reports a bad token, so only a rate-limit sentence counts.
|
|
20
|
+
*/
|
|
21
|
+
export function isGithubRateLimit(status, body) {
|
|
22
|
+
if (status === 429) return true;
|
|
23
|
+
if (status !== 403) return false;
|
|
24
|
+
const lower = String(body || "").toLowerCase();
|
|
25
|
+
return (
|
|
26
|
+
lower.includes("rate limit") ||
|
|
27
|
+
lower.includes("secondary rate") ||
|
|
28
|
+
lower.includes("abuse detection")
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} op
|
|
34
|
+
* @param {number} status
|
|
35
|
+
* @param {string} text
|
|
36
|
+
* @returns {{ message: string, code: string, status: number }}
|
|
37
|
+
*/
|
|
38
|
+
export function classifyGithubFailure(op, status, text) {
|
|
39
|
+
const conflict = status === 409;
|
|
40
|
+
if (conflict) {
|
|
41
|
+
return {
|
|
42
|
+
message:
|
|
43
|
+
"This file changed on GitHub since Studio opened it. Reload it, then save again.",
|
|
44
|
+
code: "conflict",
|
|
45
|
+
status,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (isGithubRateLimit(status, text)) {
|
|
49
|
+
return {
|
|
50
|
+
message: "GitHub is rate-limiting Studio. Wait a minute, then save again.",
|
|
51
|
+
code: "github_rate_limited",
|
|
52
|
+
status: 429,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
message: `GitHub ${op} failed (${status}).`,
|
|
57
|
+
code: "github_error",
|
|
58
|
+
status: status >= 400 && status < 600 ? status : 502,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function githubFail(op, res, text) {
|
|
63
|
+
const classified = classifyGithubFailure(op, res.status, text);
|
|
64
|
+
const err = new Error(classified.message);
|
|
65
|
+
err.status = classified.status;
|
|
66
|
+
err.code = classified.code;
|
|
67
|
+
err.detail = String(text || "").slice(0, 400);
|
|
68
|
+
return err;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function gh(env, path, init = {}) {
|
|
72
|
+
const token = env.GITHUB_TOKEN;
|
|
73
|
+
if (!token) {
|
|
74
|
+
const err = new Error("GITHUB_TOKEN not configured");
|
|
75
|
+
err.status = 503;
|
|
76
|
+
err.code = "github_unconfigured";
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
const { owner, repo } = repoParts(env);
|
|
80
|
+
const url = `https://api.github.com/repos/${owner}/${repo}${path}`;
|
|
81
|
+
const res = await fetch(url, {
|
|
82
|
+
...init,
|
|
83
|
+
headers: {
|
|
84
|
+
Accept: "application/vnd.github+json",
|
|
85
|
+
Authorization: `Bearer ${token}`,
|
|
86
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
87
|
+
"User-Agent": "website-studio",
|
|
88
|
+
...(init.headers || {}),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
return res;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function getFile(env, path) {
|
|
95
|
+
const res = await gh(env, `/contents/${path}?ref=main`);
|
|
96
|
+
if (res.status === 404) return null;
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
throw githubFail("get", res, await res.text());
|
|
99
|
+
}
|
|
100
|
+
const json = await res.json();
|
|
101
|
+
if (json.type !== "file" || !json.content) {
|
|
102
|
+
const err = new Error("not a file");
|
|
103
|
+
err.status = 400;
|
|
104
|
+
err.code = "not_a_file";
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
const content = new TextDecoder("utf-8").decode(
|
|
108
|
+
Uint8Array.from(atob(String(json.content).replace(/\n/g, "")), (c) =>
|
|
109
|
+
c.charCodeAt(0)
|
|
110
|
+
)
|
|
111
|
+
);
|
|
112
|
+
return { path, content, sha: json.sha, size: json.size };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function putFile(env, { path, content, message, sha }) {
|
|
116
|
+
const body = {
|
|
117
|
+
message: message || `studio: update ${path}`,
|
|
118
|
+
content: btoa(unescape(encodeURIComponent(content))),
|
|
119
|
+
branch: "main",
|
|
120
|
+
};
|
|
121
|
+
if (sha) body.sha = sha;
|
|
122
|
+
const res = await gh(env, `/contents/${path}`, {
|
|
123
|
+
method: "PUT",
|
|
124
|
+
headers: { "Content-Type": "application/json" },
|
|
125
|
+
body: JSON.stringify(body),
|
|
126
|
+
});
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
throw githubFail("put", res, await res.text());
|
|
129
|
+
}
|
|
130
|
+
return res.json();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Put a binary file. `contentBase64` is already base64 of raw bytes
|
|
135
|
+
* (GitHub Contents API create/update limit: 100 MB; Studio caps lower).
|
|
136
|
+
*/
|
|
137
|
+
export async function putBinaryFile(env, { path, contentBase64, message, sha }) {
|
|
138
|
+
const body = {
|
|
139
|
+
message: message || `studio: upload ${path}`,
|
|
140
|
+
content: contentBase64,
|
|
141
|
+
branch: "main",
|
|
142
|
+
};
|
|
143
|
+
if (sha) body.sha = sha;
|
|
144
|
+
const res = await gh(env, `/contents/${path}`, {
|
|
145
|
+
method: "PUT",
|
|
146
|
+
headers: { "Content-Type": "application/json" },
|
|
147
|
+
body: JSON.stringify(body),
|
|
148
|
+
});
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
throw githubFail("put", res, await res.text());
|
|
151
|
+
}
|
|
152
|
+
return res.json();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Metadata only — safe for binary paths (no UTF-8 decode). */
|
|
156
|
+
export async function getFileMeta(env, path) {
|
|
157
|
+
const res = await gh(env, `/contents/${path}?ref=main`);
|
|
158
|
+
if (res.status === 404) return null;
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
throw githubFail("get", res, await res.text());
|
|
161
|
+
}
|
|
162
|
+
const json = await res.json();
|
|
163
|
+
return {
|
|
164
|
+
path,
|
|
165
|
+
sha: json.sha || null,
|
|
166
|
+
size: json.size || 0,
|
|
167
|
+
type: json.type || null,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** List directory entries (files and dirs). */
|
|
172
|
+
export async function listEntries(env, dirPath) {
|
|
173
|
+
const res = await gh(env, `/contents/${dirPath}?ref=main`);
|
|
174
|
+
if (res.status === 404) return [];
|
|
175
|
+
if (!res.ok) {
|
|
176
|
+
throw githubFail("list", res, await res.text());
|
|
177
|
+
}
|
|
178
|
+
const json = await res.json();
|
|
179
|
+
if (!Array.isArray(json)) return [];
|
|
180
|
+
return json
|
|
181
|
+
.filter((e) => e.type === "file" || e.type === "dir")
|
|
182
|
+
.map((e) => ({
|
|
183
|
+
name: e.name,
|
|
184
|
+
path: e.path,
|
|
185
|
+
sha: e.sha,
|
|
186
|
+
size: e.size || 0,
|
|
187
|
+
type: e.type === "dir" ? "dir" : "file",
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function listDir(env, dirPath) {
|
|
192
|
+
const entries = await listEntries(env, dirPath);
|
|
193
|
+
return entries.filter((e) => e.type === "file");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Very small front-matter peek for tree listings (title/date/thumbnail + list meta). */
|
|
197
|
+
export function peekMeta(raw) {
|
|
198
|
+
const empty = {
|
|
199
|
+
title: null,
|
|
200
|
+
date: null,
|
|
201
|
+
start_date: null,
|
|
202
|
+
end_date: null,
|
|
203
|
+
start: null,
|
|
204
|
+
end: null,
|
|
205
|
+
thumbnail: null,
|
|
206
|
+
key: null,
|
|
207
|
+
published: null,
|
|
208
|
+
language: null,
|
|
209
|
+
category: null,
|
|
210
|
+
type: null,
|
|
211
|
+
company: null,
|
|
212
|
+
status: null,
|
|
213
|
+
engagement: null,
|
|
214
|
+
tags: null,
|
|
215
|
+
};
|
|
216
|
+
const text = String(raw || "");
|
|
217
|
+
if (!text.startsWith("---")) return empty;
|
|
218
|
+
const fmEnd = text.indexOf("\n---", 3);
|
|
219
|
+
if (fmEnd < 0) return empty;
|
|
220
|
+
const fm = text.slice(3, fmEnd + 1);
|
|
221
|
+
const grab = (name) => {
|
|
222
|
+
const re = new RegExp(`^${name}:\\s*(.+)$`, "m");
|
|
223
|
+
const m = fm.match(re);
|
|
224
|
+
if (!m) return null;
|
|
225
|
+
let v = m[1].trim();
|
|
226
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
227
|
+
v = v.slice(1, -1);
|
|
228
|
+
}
|
|
229
|
+
return v;
|
|
230
|
+
};
|
|
231
|
+
/** Full tag list from inline `[a, b]` or a YAML block list. */
|
|
232
|
+
const grabTags = () => {
|
|
233
|
+
const clean = (s) => String(s || "").trim().replace(/^["']|["']$/g, "");
|
|
234
|
+
const inline = fm.match(/^tags:\s*\[([^\]]*)\]/m);
|
|
235
|
+
if (inline) {
|
|
236
|
+
const tags = inline[1]
|
|
237
|
+
.split(",")
|
|
238
|
+
.map(clean)
|
|
239
|
+
.filter(Boolean);
|
|
240
|
+
return tags.length ? tags : null;
|
|
241
|
+
}
|
|
242
|
+
const block = fm.match(/^tags:\s*\n((?:[ \t]*-[ \t]*.+\n?)+)/m);
|
|
243
|
+
if (block) {
|
|
244
|
+
const tags = block[1]
|
|
245
|
+
.split("\n")
|
|
246
|
+
.map((line) => {
|
|
247
|
+
const m = line.match(/^[ \t]*-[ \t]*(.+)$/);
|
|
248
|
+
return m ? clean(m[1]) : "";
|
|
249
|
+
})
|
|
250
|
+
.filter(Boolean);
|
|
251
|
+
return tags.length ? tags : null;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
};
|
|
255
|
+
const startDate = grab("start_date");
|
|
256
|
+
const endDate = grab("end_date");
|
|
257
|
+
const start = grab("start");
|
|
258
|
+
const end = grab("end");
|
|
259
|
+
return {
|
|
260
|
+
title: grab("title") || grab("product") || grab("project") || grab("name") || grab("position"),
|
|
261
|
+
// Collapsed start for sort; list meta uses start_date/end_date or start/end.
|
|
262
|
+
date: grab("date") || startDate || start,
|
|
263
|
+
start_date: startDate,
|
|
264
|
+
end_date: endDate,
|
|
265
|
+
start,
|
|
266
|
+
end,
|
|
267
|
+
thumbnail: grab("thumbnail"),
|
|
268
|
+
key: grab("key"),
|
|
269
|
+
published: grab("published"),
|
|
270
|
+
language: grab("language"),
|
|
271
|
+
category: grab("category"),
|
|
272
|
+
type: grab("type"),
|
|
273
|
+
company: grab("company"),
|
|
274
|
+
status: grab("status"),
|
|
275
|
+
engagement: grab("engagement"),
|
|
276
|
+
tags: grabTags(),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load guidelines/studio-proof/pack.json from R2 (S3-compatible).
|
|
3
|
+
* Spec: docs/features/studio-proof.md §6 (AC-PRF-08).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const cache = {
|
|
7
|
+
bodySha: null,
|
|
8
|
+
pack: null,
|
|
9
|
+
loadedAt: 0,
|
|
10
|
+
};
|
|
11
|
+
const TTL_MS = 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Minimal AWS SigV4 for S3 GetObject (R2).
|
|
15
|
+
* @param {object} env
|
|
16
|
+
* @param {string} key — object key without leading slash
|
|
17
|
+
*/
|
|
18
|
+
async function signedGet(env, key) {
|
|
19
|
+
const accessKey = String(env.R2_ACCESS_KEY_ID || "").trim();
|
|
20
|
+
const secretKey = String(env.R2_SECRET_ACCESS_KEY || "").trim();
|
|
21
|
+
const endpoint = String(env.R2_ENDPOINT || "").replace(/\/$/, "");
|
|
22
|
+
const bucket = String(env.R2_BUCKET || "").trim();
|
|
23
|
+
if (!accessKey || !secretKey || !endpoint || !bucket) {
|
|
24
|
+
const err = new Error("r2_unconfigured");
|
|
25
|
+
err.code = "guidelines_unavailable";
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const url = new URL(endpoint);
|
|
30
|
+
const host = url.host;
|
|
31
|
+
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
32
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
33
|
+
const region = String(env.R2_REGION || "auto");
|
|
34
|
+
const service = "s3";
|
|
35
|
+
const canonicalUri = `/${bucket}/${key.split("/").map(encodeURIComponent).join("/")}`;
|
|
36
|
+
const payloadHash = await sha256Hex("");
|
|
37
|
+
const canonicalHeaders = `host:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`;
|
|
38
|
+
const signedHeaders = "host;x-amz-content-sha256;x-amz-date";
|
|
39
|
+
const canonicalRequest = [
|
|
40
|
+
"GET",
|
|
41
|
+
canonicalUri,
|
|
42
|
+
"",
|
|
43
|
+
canonicalHeaders,
|
|
44
|
+
signedHeaders,
|
|
45
|
+
payloadHash,
|
|
46
|
+
].join("\n");
|
|
47
|
+
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
|
48
|
+
const stringToSign = [
|
|
49
|
+
"AWS4-HMAC-SHA256",
|
|
50
|
+
amzDate,
|
|
51
|
+
credentialScope,
|
|
52
|
+
await sha256Hex(canonicalRequest),
|
|
53
|
+
].join("\n");
|
|
54
|
+
const signingKey = await getSignatureKey(secretKey, dateStamp, region, service);
|
|
55
|
+
const signature = await hmacHex(signingKey, stringToSign);
|
|
56
|
+
const authorization = `AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
57
|
+
|
|
58
|
+
const res = await fetch(`${endpoint}${canonicalUri}`, {
|
|
59
|
+
method: "GET",
|
|
60
|
+
headers: {
|
|
61
|
+
Host: host,
|
|
62
|
+
"x-amz-date": amzDate,
|
|
63
|
+
"x-amz-content-sha256": payloadHash,
|
|
64
|
+
Authorization: authorization,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
if (res.status === 404) {
|
|
68
|
+
const err = new Error("pack_missing");
|
|
69
|
+
err.code = "guidelines_unavailable";
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
const err = new Error(`r2_http_${res.status}`);
|
|
74
|
+
err.code = "guidelines_unavailable";
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
return res.json();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function sha256Hex(message) {
|
|
81
|
+
const data = new TextEncoder().encode(message);
|
|
82
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
83
|
+
return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function hmac(key, message) {
|
|
87
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
88
|
+
"raw",
|
|
89
|
+
typeof key === "string" ? new TextEncoder().encode(key) : key,
|
|
90
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
91
|
+
false,
|
|
92
|
+
["sign"]
|
|
93
|
+
);
|
|
94
|
+
const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
|
|
95
|
+
return new Uint8Array(sig);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function hmacHex(key, message) {
|
|
99
|
+
const sig = await hmac(key, message);
|
|
100
|
+
return [...sig].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function getSignatureKey(secretKey, dateStamp, region, service) {
|
|
104
|
+
const kDate = await hmac(`AWS4${secretKey}`, dateStamp);
|
|
105
|
+
const kRegion = await hmac(kDate, region);
|
|
106
|
+
const kService = await hmac(kRegion, service);
|
|
107
|
+
return hmac(kService, "aws4_request");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {object} env
|
|
112
|
+
* @returns {Promise<{ body_markdown: string, body_sha256: string }>}
|
|
113
|
+
*/
|
|
114
|
+
export async function loadGuidelinesPack(env) {
|
|
115
|
+
// Test / local override without R2.
|
|
116
|
+
if (env.GUIDELINES_PACK_JSON) {
|
|
117
|
+
const pack = JSON.parse(String(env.GUIDELINES_PACK_JSON));
|
|
118
|
+
return pack;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
if (cache.pack && now - cache.loadedAt < TTL_MS) {
|
|
123
|
+
return cache.pack;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const pack = await signedGet(env, "guidelines/studio-proof/pack.json");
|
|
127
|
+
if (!pack?.body_markdown || !pack?.body_sha256) {
|
|
128
|
+
const err = new Error("pack_invalid");
|
|
129
|
+
err.code = "guidelines_unavailable";
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
cache.pack = pack;
|
|
133
|
+
cache.bodySha = pack.body_sha256;
|
|
134
|
+
cache.loadedAt = now;
|
|
135
|
+
return pack;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @visibleForTesting */
|
|
139
|
+
export function clearGuidelinesCache() {
|
|
140
|
+
cache.pack = null;
|
|
141
|
+
cache.bodySha = null;
|
|
142
|
+
cache.loadedAt = 0;
|
|
143
|
+
}
|