From a1c56db04572463ff6e8d461c81bf64129a916c3 Mon Sep 17 00:00:00 2001 From: Diogo Carneiro Date: Fri, 28 Aug 2026 09:31:49 +0100 Subject: [PATCH 01/13] chore: add packages directory to pnpm workspace configuration --- agent-work/polystella-monorepo-baseline.md | 272 +++++ agent-work/polystella-monorepo-extraction.md | 992 +++++++++++++++++++ pnpm-workspace.yaml | 1 + tsconfig.base.json | 22 + 4 files changed, 1287 insertions(+) create mode 100644 agent-work/polystella-monorepo-baseline.md create mode 100644 agent-work/polystella-monorepo-extraction.md create mode 100644 tsconfig.base.json diff --git a/agent-work/polystella-monorepo-baseline.md b/agent-work/polystella-monorepo-baseline.md new file mode 100644 index 0000000..ce9d2ae --- /dev/null +++ b/agent-work/polystella-monorepo-baseline.md @@ -0,0 +1,272 @@ +# PolyStella Monorepo Extraction Baseline + +Status: Recorded +Recorded: 2026-08-28 +Source commit: `c7a8e0e0ea7638ce2e98a771255f809d1265abb7` + +## Purpose + +This is the before-extraction reference for +`agent-work/polystella-monorepo-extraction.md`. Step 8 must reproduce the +stable values below before Astro moves to `packages/astro`. + +Timestamps, durations, temporary server ports, and concurrent log order are +not parity fields. + +## Existing Package Freeze + +The existing package remains unchanged through Steps 1-5. No baseline tests +were added because the current suite already covers the critical invariants. + +Protected until Step 6: + +```text +package.json +src/** +tests/** +client.d.ts +types-internal/** +``` + +## Environment + +| Item | Baseline | +| ---------------------------- | ------------------------------------------ | +| Node | `v22.22.3` | +| pnpm | `11.5.2` | +| Vitest | `4.1.9` | +| PolyStella | `0.4.0` | +| Git commit | `c7a8e0e0ea7638ce2e98a771255f809d1265abb7` | +| Local branch selected by CLI | `main` | + +CI currently runs Node 24. The local Node 22 baseline satisfies the current +`p-retry` engine requirement. + +## Automated Baseline + +| Command | Result | +| ----------------------------------------------------------- | -------------------------------------------------------------------------- | +| `pnpm test` | Pass: 62 files, 1,159 tests, 1.28 seconds | +| `pnpm exec tsc --noEmit` | Pass | +| `pnpm build` | Pass | +| `node dist/cli.js --version` | Pass: `0.4.0` | +| `pnpm playground:mdx-jsx:build` | Pass: 6 pages; dry-run reports 10 keys across 5 files and 2 target locales | +| `LOG_LEVEL=debug pnpm playground:mdx-jsx:translate:dry-run` | Pass: exact keys recorded below | +| `pnpm playground:mdx-jsx:translate:local` | Pass: 10 misses, 0 hits, 0 overrides, 0 failures | +| `pnpm pack --dry-run` | Pass: `@cloudflare/polystella@0.4.0` | + +`pnpm pack --dry-run` runs the existing `prepare` build. Its expected tarball +name is `cloudflare-polystella-0.4.0.tgz`. The dry run includes `src`, `dist`, +all current export targets, the executable CLI, package docs, `llms` files, +and both shipped skills. + +## Dry-Run R2 Keys + +The dry run has no configured provider, so each source has the same hash for +both target locales. The locale still appears in the key. + +| Source | Hash | +| ------------------------ | ------------------------------------------------------------------ | +| `docs/block-jsx.mdx` | `1bd2533d354aac689865d610b9ef2c660a940eba7df2d02b3b01c4dee9a8081c` | +| `docs/expressions.mdx` | `a45472cf8b6fafe58710b6f810cf8116a9213a02039b9209c686f339d28c5fc3` | +| `docs/inline-jsx.mdx` | `db88cc682b22c78b6e7cdcfba1d16d906e49ffd93a52c8504808d961565c1de2` | +| `docs/plain-markdown.md` | `8ccebd5b5dd1254b71c67d3b0e58c1e567219d67fd68437347893d30526337ea` | +| `docs/static-data.mdx` | `ea3f9e1ca502f7714e794dd273e1dae8ccab2fc49fd3809478dd76f3f640b99e` | + +Key formula used by the CLI: + +```text +i18n/{locale}/{source}#{hash}.md +``` + +Target locales are `pt-BR` and `fr-FR`, producing 10 keys total. + +## Fake-Provider Translation Baseline + +The local playground uses one concrete model per locale: + +```text +pt-BR = playground/fake-workers-ai/pt-BR +fr-FR = playground/fake-workers-ai/fr-FR +``` + +| Source | Segments | `pt-BR` source hash | `fr-FR` source hash | +| ------------------------ | -------: | ------------------------------------------------------------------ | ------------------------------------------------------------------ | +| `docs/block-jsx.mdx` | 11 | `d603785e2ee730cd61c299fffed971882438f20abd59a1776f52432a6844b95c` | `6e0fa4eeda9f2d8be76e38f6583fba8c7877e4ef50380f2375813ce268af5aa2` | +| `docs/expressions.mdx` | 5 | `b3aeadf4eda27ef6f83faf521b7c382271c603601a8eba91578bf8e12cdf33ca` | `9ec3feed4aeae36d06a2fecb049752fdefa962814a7760164222d497f0b73562` | +| `docs/inline-jsx.mdx` | 8 | `d0fd6e76bd4fe60319b2427ddaac6f13f1a6f49e0c9ca52a66e2f4e7bad50adf` | `9126c18f9bc2059a260e002d9ee1dec3aca7462175afb870c9be20ef7bb5f191` | +| `docs/plain-markdown.md` | 5 | `da48b6282dd5fb8ef174eca1cc6bde469d0f1dc6516445e0cab45180196d8f3c` | `f302200778242da8d6574216e40a2bb2328493e235c78d5324c923986c5574a6` | +| `docs/static-data.mdx` | 14 | `66841e3dcdc8e1a4926fb34b244cf42da073b5b3215946ad0770b98f71524ebb` | `4c1c9a1ac749596481fbfac4e4bc33d205356f1e880f57410d3406464a0fad23` | + +Stable report totals: + +```json +{ + "cacheHits": 0, + "aiTranslated": 10, + "overrides": 0, + "skipped": 0, + "localSkipped": 0, + "errors": 0 +} +``` + +## Normalized Output Digests + +For comparison, replace the complete `aiTranslatedAt:` line with +`aiTranslatedAt: ` and compute SHA-256 over the resulting UTF-8 +bytes. + +| Output | SHA-256 | +| ------------------------------ | ------------------------------------------------------------------ | +| `pt-BR/docs/block-jsx.mdx` | `0dd866e0845d17b368399c067368c5e9ea5ebcd04e2c76f057851f2dd1bcb407` | +| `pt-BR/docs/expressions.mdx` | `5b26369e1494cc12d929f10343aa5da90ef6edbfc91b87aa8dcae8c3eb58bc24` | +| `pt-BR/docs/inline-jsx.mdx` | `5a18c49dad816aaa4927b6895a314868137650453d471236a4f8ee5f99c3f87c` | +| `pt-BR/docs/plain-markdown.md` | `63d19914e7932d5911b7a2e2db502f63d05ad1ceb52c3ec2ab0e5e183dea1bff` | +| `pt-BR/docs/static-data.mdx` | `6587cc19c5b1aa37729e6ad8c2cc412ae0b73138b920fba04c7974b14d7e9661` | +| `fr-FR/docs/block-jsx.mdx` | `0580cc3c2e0aaea0dfb1905ce689a971bf850f56cd8451c2ddbb912800b65271` | +| `fr-FR/docs/expressions.mdx` | `7fe3552758a30774a699932fa2036ce10811d6c25ece4d4a51d24c2ef1dc707b` | +| `fr-FR/docs/inline-jsx.mdx` | `094b341575f2189ab1a51bb9bde4e0076aa35cbcd5f9879f5d5eafdbd45d0f2e` | +| `fr-FR/docs/plain-markdown.md` | `eec245f660e9574679ae1f7dd4956867f4f7b7742e904b45199dbf58a147d22d` | +| `fr-FR/docs/static-data.mdx` | `1cabe32c0a9ef9764e83b7ed3952ec9d567ab6ef3e4b621651f105695e9415f6` | + +Normalized report digest: + +```text +7c5a3be43011144d9f173ccc118207e9a17222c75da1385eb40dbe129df05b26 +``` + +Report normalization removes `build.startedAt`, `build.durationMs`, and every +entry `durationMs`; entries are sorted by `{locale}/{sourcePath}` before +hashing. + +## Manual Output Observations + +Plain Markdown: + +- Frontmatter translations are quoted by YAML serialization. +- `canonicalUrl` changes from `/docs/plain-markdown` to + `/pt-BR/docs/plain-markdown` for the `pt-BR` output. +- `aiTranslated`, `aiTranslationModel`, and `aiTranslatedAt` appear in + frontmatter before the closing delimiter. +- Body links are locale-prefixed. +- The fake translator prefixes each translated segment with `[locale]`. + +MDX: + +- ESM imports remain executable and retain their relative target after staging + rewriting. +- Static exported arrays, annotation-selected arrays, function-return arrays, + and inline JSX prop arrays preserve syntax while selected string values are + prefixed. +- JSX expressions and configured visible props preserve their surrounding + syntax. +- Markdown and MDX continue through separate parser paths. + +## Core Hash And Prompt Fixtures + +Source-hash fixture: + +```json +{ + "body": "# Hello\n\nA paragraph.\n", + "frontmatter": { "title": "Hello", "year": 2025 }, + "glossaryHash": "g0", + "modelId": "@cf/meta/llama-3.1-8b-instruct" +} +``` + +Expected source hash: + +```text +df40a08682e9df8e0643f5e95651478da8ff06922ad2f8aaec7d479db70bb7ee +``` + +Prompt fixture uses segments `fm:title = Hello` and +`body:0 = A paragraph.`, source locale `en-US`, target locale `pt-BR`, and +`EMPTY_GLOSSARY`. + +| Prompt field | Length | SHA-256 | +| ------------- | -----: | ------------------------------------------------------------------ | +| System prompt | 734 | `32b336fbdce5ab269488f3f5d77840d1f1a9fa7c7e544d6f4c70bca570e4ef34` | +| User prompt | 322 | `975ae31980e7f7a782ec257d7584e0ba689b01f006e86c34c82ba029a1363685` | + +## Structured Adapter Fixtures + +The current playground contains only Markdown and MDX. JSON, YAML, and TOML +are therefore characterized directly through their adapters and their existing +unit suites. + +Each fixture selects `title` and `nested.body` and must emit these segments in +order: + +```json +[ + { "id": "title", "text": "Hello" }, + { "id": "nested.body", "text": "World" } +] +``` + +Translations are `X:Hello` and `X:World`. The marker is intentionally injected +into the top-level object-valued entry (`nested`), matching Astro's collection +entry behavior. + +Expected JSON output: + +```json +{ + "title": "X:Hello", + "nested": { + "body": "X:World", + "aiTranslated": true + } +} +``` + +Expected YAML output: + +```yaml +title: X:Hello +nested: + body: X:World + aiTranslated: true +``` + +Expected TOML output: + +```toml +title = "X:Hello" + +[nested] +body = "X:World" +aiTranslated = true +``` + +## Existing Invariant Coverage + +| Requirement | Existing coverage | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Cache hash composition and boundaries | `tests/storage/hash.test.ts` | +| Apply before PUT and cache hit/miss behavior | `tests/storage/cache.test.ts` | +| Local cache index isolation | `tests/storage/local-cache.test.ts`, `tests/translation/run.test.ts` | +| Prompt bytes and response protocol | `tests/translation/prompt.test.ts` | +| Group flattening and batching | `tests/translation/batch.test.ts`, `tests/translation/translate-segments.test.ts`, adapter tests | +| Permanent versus retriable provider errors | `tests/translation/provider.test.ts` | +| Markdown versus MDX parser behavior | `tests/parsing/parse.test.ts`, `tests/parsing/mdx.test.ts`, `tests/parsing/round-trip.test.ts` | +| Every current format adapter | `tests/parsing/*-adapter.test.ts` | +| Astro setup timing and end-to-end flow | `tests/smoke.test.ts`, `tests/translation/run.test.ts` | +| UI token/retry behavior | `tests/i18n/ui-translate*.test.ts` | + +## Baseline Limitations + +- R2 is intentionally not configured in the playground baseline. Real cache + hit, fallback, prune, and write behavior is covered by storage and run tests. +- Override, `noTranslate`, and local-cache skip paths are covered by tests but + are not represented in the playground's generated report. +- Structured formats have direct adapter fixtures rather than a staged Astro + playground fixture. +- No real provider credentials or R2 credentials were used. + +These are not extraction exceptions. Their existing tests must remain green, +and Step 12 still requires real provider and safe test-R2 manual verification +before release. diff --git a/agent-work/polystella-monorepo-extraction.md b/agent-work/polystella-monorepo-extraction.md new file mode 100644 index 0000000..799ca21 --- /dev/null +++ b/agent-work/polystella-monorepo-extraction.md @@ -0,0 +1,992 @@ +# PolyStella Monorepo Extraction Plan + +Status: In progress (Steps 1-2 complete) +Last updated: 2026-08-28 + +## ELI5: What Will Happen + +PolyStella is currently one large box containing four different things: the +translation engine, file readers, AI provider connections, and Astro-specific +behavior. We will separate those things without changing what an Astro build +produces. + +| Step | ELI5 | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------- | +| 1. Record the baseline | Take a "before" picture so we can tell whether the move changes anything. | +| 2. Prepare the workspace | Mark where the new package shelves will go while leaving the current Astro package where it is. | +| 3. Extract core | Move the translation brain into a package that knows nothing about Astro, filesystems, or Cloudflare bindings. | +| 4. Extract adapters | Move the Markdown, MDX, JSON, YAML, and TOML readers/writers into one reusable package. | +| 5. Extract providers | Move Workers AI and Anthropic connections into one package, including both Workers AI HTTP and binding calls. | +| 6. Reconnect Astro | Make the existing Astro integration use the three new packages while it is still at the repository root. | +| 7. Prove portability | Run the reusable packages in Node and workerd and make sure they do not secretly need Node compatibility. | +| 8. Check the extraction | Compare the new behavior with the "before" picture before moving any remaining Astro files. | +| 9. Move Astro | Move the now-smaller Astro package into `packages/astro` and make the repository root private. | +| 10. Finish packaging | Make versions, tarballs, builds, CLI files, and package exports work as a real four-package release. | +| 11. Update automation and docs | Teach CI, pkg.pr.new, Changesets, documentation, and contributor guidance about all four packages. | +| 12. Run the release gate | Test the exact artifacts consumers will install, including one manual real-provider smoke test. | + +No package is published between these steps. The first release happens only +after Step 12 passes. + +## End State + +```text +polystella/ + package.json private workspace root + tsconfig.base.json + packages/ + core/ @cloudflare/polystella-core + adapters/ @cloudflare/polystella-adapters + providers/ @cloudflare/polystella-providers + astro/ @cloudflare/polystella + docs/ + playgrounds/ + agent-work/ +``` + +Dependency direction: + +```text +@cloudflare/polystella-core + ^ ^ + | | +adapters providers + ^ ^ + +--------+--------+ + | + Astro +``` + +Core never imports adapters, providers, Astro, React, Cloudflare bindings, +filesystem APIs, storage APIs, or environment globals. + +## Fixed Decisions + +- Public names use the existing `@cloudflare` scope. +- All four public packages use lockstep versions. +- The compatibility target is functionality, not preservation of every + existing low-level import or error string. +- Existing Astro configuration, CLI behavior, generated files, cache keys, + markers, routes, and runtime behavior should stay unchanged unless the + extraction itself requires a documented change. +- All current providers live in one package with `./workers-ai` and + `./anthropic` subpath exports. +- Workers AI supports both the current HTTP transport and a workerd binding + transport. +- All current file adapters live in one package. We will not create one + package per format. +- Astro stays at the root during extraction and moves only after the new + packages pass their gates. +- Until Step 6, the existing package is frozen: do not edit root + `package.json`, `src/**`, `tests/**`, `client.d.ts`, or `types-internal/**`. + Steps 2-5 build shadow packages by copying behavior into new files. Step 6 + is the first cutover and the first point where duplicated root + implementations are rewired or removed. +- The Translation Agent is not moved in this plan. The packages and a workerd + fixture prepare for that later migration. +- There is no hosted core service and no extra network hop. Consumers call + core directly in their own process or Worker. + +## Package Responsibilities + +### `@cloudflare/polystella-core` + +Owns: + +- `Segment`, `Glossary`, `StyleRule`, and the empty glossary value. +- `Translator`, `PermanentProviderError`, and permanent-error detection. +- Locale-aware model resolution. +- Prompt construction and strict response parsing. +- Translation batching and group invariants. +- Retry orchestration, logging contracts, and `AbortSignal` propagation. + +Does not own: + +- Files, paths, adapters, cache keys, R2, URL rewriting, output markers, + provider HTTP, or provider bindings. + +### `@cloudflare/polystella-adapters` + +Owns: + +- Shared adapter types and the current Markdown/MDX, JSON, YAML, and TOML + implementations. +- Parsing, extraction, translation application, grouping, key-path helpers, + and MDX placeholder handling. +- A portable Remark parser and an injectable Markdown parser contract. + +Does not own: + +- Satteri, Astro configuration, file walking, cache hashing, AI marker policy, + locale URL policy, or staging-path import rewriting. + +### `@cloudflare/polystella-providers` + +Owns: + +- Workers AI HTTP request construction and response normalization. +- Workers AI binding invocation and response normalization. +- Anthropic HTTP request construction and response normalization. +- Provider-specific HTTP error classification. + +Does not own: + +- Prompts, response protocol validation, retries, locale model maps, cache + behavior, or generated Cloudflare `Ai` types. + +Public exports: + +```text +@cloudflare/polystella-providers +@cloudflare/polystella-providers/workers-ai +@cloudflare/polystella-providers/anthropic +``` + +### `@cloudflare/polystella` + +Owns: + +- Astro integration hooks, configuration, CLI, filesystem access, source + walking, staging, R2 cache, reports, routing, runtime APIs, content + collections, React helpers, UI strings, and recipes. +- Satteri-backed Markdown/MDX parsing for current Astro behavior. +- Source, glossary, and MDX extraction-policy hashing. +- `noTranslate`, URL rewriting, document-context selection, and AI marker + policy around the shared adapters. + +## Progress + +| Step | Status | Completion evidence | +| ------------------------------ | ----------- | --------------------------------------------------------------------------------------------------------------------- | +| 1. Record the baseline | Complete | `agent-work/polystella-monorepo-baseline.md` records commands, keys, hashes, outputs, and known limitations | +| 2. Prepare the workspace | Complete | `packages/*` discovery and framework-neutral `tsconfig.base.json` added; existing package remains unchanged and green | +| 3. Extract core | Not started | Core tests pass in Node and has no forbidden imports | +| 4. Extract adapters | Not started | All format tests pass through the shared package | +| 5. Extract providers | Not started | HTTP and binding provider tests pass | +| 6. Reconnect Astro | Not started | Existing Astro suite and playground pass | +| 7. Prove portability | Not started | Node, no-compat workerd, and boundary checks pass | +| 8. Check the extraction | Not started | Before/after outputs and keys match | +| 9. Move Astro | Not started | Private root and moved Astro package build cleanly | +| 10. Finish packaging | Not started | Four tarballs install and import in a clean project | +| 11. Update automation and docs | Not started | CI/docs/pkg.pr.new configuration covers all packages | +| 12. Run the release gate | Not started | Every automated and manual release check passes | + +## Step 1: Record The Baseline + +Purpose: Establish known-good behavior before changing package boundaries. + +Changes: + +- Add characterization tests only where current tests do not already pin a + critical invariant. +- Record stable values from the MDX playground in + `agent-work/polystella-monorepo-baseline.md` rather than retaining noisy raw + logs. +- Record representative staged Markdown and MDX output from the local fake + translator, plus direct adapter fixtures for JSON, YAML, and TOML because the + playground does not stage those formats. +- Record representative R2 keys, source hashes, markers, model IDs, and build + report fields. +- Inspect the current `pnpm pack --dry-run` output for + `@cloudflare/polystella`. + +Critical invariants: + +- Cache key bytes remain unchanged. +- `flat(groups) === segments` by reference and order. +- Adapter application inserts the AI marker before cache PUT. +- Markdown uses Markdown syntax and MDX uses MDX syntax. +- Permanent provider errors skip retries; transient and parse failures retry. +- Translation still runs during `astro:config:setup`. + +Automated verification: + +```sh +pnpm test +pnpm exec tsc --noEmit +pnpm build +node dist/cli.js --version +pnpm playground:mdx-jsx:build +pnpm playground:mdx-jsx:translate:dry-run +``` + +Manual verification: + +- Read the dry-run log and save the representative R2 keys for comparison in + Step 8. +- Open generated Markdown and MDX files and record formatting, marker + placement, and rewritten links. Inspect direct reconstruction fixtures for + JSON, YAML, and TOML. +- Confirm `git status --short` contains only intentional baseline tests or + artifacts. + +Stop condition: Do not begin extraction while any baseline command fails or a +critical behavior lacks a reproducible check. + +Completion evidence (2026-08-28): All baseline commands passed: 62 test files +and 1,159 tests, strict typecheck, package build, CLI `0.4.0`, six-page +playground build, local fake-provider translation, and package dry-run. The +baseline file records 10 dry-run keys, 10 normalized output digests, source and +prompt hashes, report fields, structured adapter outputs, and existing tests +covering the non-playground paths. + +## Step 2: Prepare The Workspace + +Purpose: Create package/build structure without moving Astro first. + +Changes: + +- Add package workspace patterns for `packages/core`, `packages/adapters`, and + `packages/providers` to `pnpm-workspace.yaml`. +- Keep `.` as the published `@cloudflare/polystella` package during Steps 2-8. +- Add `tsconfig.base.json` with framework-neutral strict TypeScript options. +- Define the package-local `package.json`, `tsconfig.json`, and + `tsconfig.build.json` conventions. Create each real package and manifest with + its first production files in Steps 3-5 instead of adding empty placeholder + packages. +- Use plain `tsc` builds with declarations, declaration maps, and source maps. +- Use pnpm's dependency graph for build order. Do not add Turbo, Nx, tsup, or + another build orchestrator. +- Build each lower package directly during the shadow phase. Do not alter the + root package's build or `prepare` scripts until Step 6. +- Start all new public package manifests at `0.4.0` so they can join the fixed + version group before the first coordinated release. +- Treat root `package.json`, `src/**`, `tests/**`, `client.d.ts`, and + `types-internal/**` as read-only throughout Steps 2-5. + +Expected initial package dependencies: + +```text +core -> p-retry +adapters -> core + portable parsing dependencies +providers -> core +Astro -> core + adapters + providers + host-only dependencies +``` + +Automated verification: + +```sh +pnpm install +pnpm list -r --depth 0 +pnpm build +``` + +Manual verification: + +- Inspect `pnpm list -r --depth 0` and confirm workspace discovery still sees + the current root, docs, and playground packages without phantom package + entries. +- Review the shared TypeScript settings and confirm they do not extend Astro or + include Node types by default. +- Confirm `git diff -- package.json src tests client.d.ts types-internal` is + empty. + +Stop condition: Do not move production code until the shared configuration and +workspace patterns leave the existing root build green. + +Completion evidence (2026-08-28): `pnpm install --frozen-lockfile` retained the +lockfile and discovered only the root, docs, and MDX playground packages. +Tests, typecheck, build, CLI, playground build, and `pnpm pack --dry-run` all +passed. `git diff -- package.json src tests client.d.ts types-internal +pnpm-lock.yaml` is empty. The shared config has no Astro extension and sets +`types: []`, so Node ambient types are not included by default. + +## Step 3: Extract Core + +Purpose: Copy the reusable translation engine and its smallest shared data +contracts into a platform-neutral shadow package. The existing package remains +unchanged until Step 6. + +Target files in `packages/core/src`: + +```text +index.ts +segment.ts +glossary.ts +translator.ts +logger.ts +prompt.ts +batch.ts +translate-batch.ts +translate-segments.ts +``` + +Changes: + +- Copy `Segment` from `src/parsing/extract.ts`. +- Copy `Glossary`, `StyleRule`, and `EMPTY_GLOSSARY` from + `src/glossary/glossary.ts`. +- Copy `Translator` and `PermanentProviderError` from + `src/translation/provider.ts`. +- Add `isPermanentProviderError()` using the existing + `_tag: "PermanentProviderError"` discriminator so duplicate installs do not + accidentally re-enable retries. +- Copy locale-aware model ID resolution into core. Provider factories receive + only a concrete model ID. +- Copy `src/translation/logger.ts`, `prompt.ts`, `batch.ts`, and + `translate-segments.ts` into core. +- Copy `translateBatch` and its retry event/options, without provider transport + code, into `translate-batch.ts`. +- Keep prompt bytes, response parsing, retry counts, retry callbacks, jitter, + empty-input handling, batching order, and abort behavior unchanged. +- Keep `loadGlossaries`, Zod validation, `hashGlossary`, + `EMPTY_GLOSSARY_HASH`, `computeSourceHash`, and MDX policy hashing in Astro. + Those functions are host/cache concerns and currently rely on Node crypto or + filesystem APIs. +- Keep `p-retry` initially. Its implementation and `is-network-error` + dependency use Web primitives, although its package metadata declares Node + `>=22`. + +`nodejs_compat` policy: + +- Consumers may enable `nodejs_compat`; doing so is not a problem. +- Core must also run without it. This is a negative portability test that + prevents an accidental Node-only import from becoming an undocumented + requirement for every Worker consumer. +- Workerd does not enforce npm's Node `engines` metadata. Node installation and + CI continue on Node 24, which satisfies `p-retry`. +- If `p-retry` cannot execute in no-compat workerd, replace only the currently + used retry subset and pin it with the existing retry tests. Do not design a + general retry framework. + +Tests copied to `packages/core/tests` during the shadow phase: + +- Prompt construction and response parsing. +- Token-budget batching and oversize warnings. +- `translateBatch` success, parse failures, transient failures, permanent + failures, final-error behavior, retry callbacks, and cancellation. +- `translateSegments` grouping, sequential batches, merged results, and aborts. + +Automated verification: + +```sh +pnpm --filter @cloudflare/polystella-core test +pnpm --filter @cloudflare/polystella-core build +pnpm exec tsc --noEmit +``` + +Manual verification: + +- Compare one generated system/user prompt with the Step 1 baseline byte for + byte. +- Inspect `packages/core/src` imports and confirm there is no `node:`, Astro, + React, provider, adapter, filesystem, storage, or environment dependency. +- Inspect the emitted core declaration entry and confirm a downstream consumer + can implement `Translator` without importing Astro types. +- Confirm `git diff -- package.json src tests client.d.ts types-internal` is + empty. + +Stop condition: Core is not complete while any host concern or provider +transport is needed to run a translation with a fake `Translator`. + +## Step 4: Extract Adapters + +Purpose: Copy current file-format translation into a reusable shadow package +without carrying Astro or Satteri into workerd. Root parsing code remains +unchanged until Step 6. + +Portable code to copy or reimplement from `src/parsing`: + +```text +adapter.ts +adapters/json.ts +adapters/markdown.ts +adapters/toml.ts +adapters/yaml.ts +apply.ts +extract.ts +key-paths.ts +mdx-jsx-attributes.ts +mdx-placeholders.ts +mdx-static-data.ts +traverse.ts +portable Remark parser functions from parse.ts +normalized MDX rule data types +``` + +Changes: + +- Define the reusable `FileAdapter` around parsing, segment + extraction, translation application, and optional segment grouping. +- Import `Segment` from core rather than defining adapter-owned segment types. +- Accept already-resolved per-source extraction policy instead of Astro's + resolved configuration type. +- Define an injectable Markdown parser contract with separate Markdown and MDX + operations. +- Make the package's built-in parser use Remark and Web-compatible modules. +- Keep Satteri and its ESTree/position compatibility repair in Astro. Astro + maps its existing `markdown.parser` option to either the injected Satteri or + Remark parser. +- Keep cache-value selection, `noTranslate`, URL rewriting, + document-context selection, and AI marker policy out of the required shared + adapter contract. +- Allow pure format helpers for those operations to live in the adapters + package when Astro needs format-aware parsing. Astro decides when and why + they run. +- Keep the Astro policy registry in Astro and register wrappers around the + shared adapter objects. The adapters package exports all current adapters + from one root entry; it does not create per-format packages. +- Keep `mdx-audit.ts`, `rewrite-links.ts`, `rewrite-mdx-imports.ts`, MDX recipe + normalization, glob selection, and policy hashing in Astro. +- Preserve the group flattening runtime assertion. +- Do not add HTML, plain text, or any other new format in this extraction. + +Expected portable runtime dependencies: + +```text +@cloudflare/polystella-core +picomatch +remark-frontmatter +remark-gfm +remark-mdx +remark-parse +smol-toml +unified +yaml +``` + +`satteri` and `acorn` remain Astro dependencies for the current Satteri +compatibility path. + +Tests copied to `packages/adapters/tests` during the shadow phase: + +- Current JSON, YAML, TOML, Markdown, MDX, extraction, application, key-path, + grouping, placeholder, and round-trip tests. +- Parser-injection tests proving Astro can supply Satteri while a workerd + consumer can use Remark. +- One reconstruction fixture per format that checks untouched bytes or + formatting according to the current format contract. + +Automated verification: + +```sh +pnpm --filter @cloudflare/polystella-adapters test +pnpm --filter @cloudflare/polystella-adapters build +pnpm exec tsc --noEmit +``` + +Manual verification: + +- Open the dependency tree and confirm `satteri`, native bindings, Astro, and + Node built-ins are absent from the adapters package. +- Translate one `.md`, `.mdx`, `.json`, `.yaml`, and `.toml` fixture and compare + segment IDs and reconstructed output with Step 1. +- Confirm the Markdown and MDX paths use distinct syntax rules. +- Confirm applying the adapter twice does not duplicate generic top-level + additions used by the Astro marker wrapper. +- Confirm `git diff -- package.json src tests client.d.ts types-internal` is + empty. + +Stop condition: Do not proceed if Astro parity requires importing Satteri from +the shared adapters entry or if a format emits different segment IDs. + +## Step 5: Extract Providers + +Purpose: Copy provider transports behind the core `Translator` contract so +Node and workerd hosts can use the same inference behavior. Root provider code +remains unchanged until Step 6. + +Target files: + +```text +packages/providers/src/index.ts +packages/providers/src/workers-ai.ts +packages/providers/src/anthropic.ts +``` + +Public factories: + +```text +createWorkersAIHttpTranslator +createWorkersAIBindingTranslator +createAnthropicTranslator +``` + +Changes: + +- Make provider options accept a concrete `modelId` and `maxTokens`. +- Keep locale model maps and `batchInputTokenBudget` outside providers. Astro + resolves the model through core before constructing the translator. +- Preserve Workers AI HTTP endpoint construction, bearer authentication, + custom endpoint support, chat messages, and `max_tokens`. +- Preserve Workers AI HTTP response precedence: + `result.response`, `result.choices[0].message.content`, then + `choices[0].message.content`. +- Add binding normalization for top-level `response`, top-level `choices`, + direct strings, and parsed object responses. +- Accept a small binding invocation callback rather than depending on + `@cloudflare/workers-types` or generated `Ai` model overloads. +- Check the binding translator's `AbortSignal` before and after inference. + Cloudflare's documented binding API does not currently expose true in-flight + cancellation, so do not claim that it does. +- Preserve Anthropic's endpoint, headers, body, first text-block behavior, and + injected `fetch` support without adding an SDK. +- Import the one canonical `PermanentProviderError` from core. +- Preserve permanent HTTP statuses exactly: `400`, `401`, `403`, `404`, and + `422`. Keep `408`, `425`, `429`, and `5xx` retriable. +- Do not retry inside provider factories. Core owns the single retry loop so + provider, parse, and malformed-output failures share one attempt budget. +- Leave binding errors unchanged unless the caller explicitly throws core's + `PermanentProviderError`; do not classify errors by message text. + +Tests copied or added in `packages/providers/tests` during the shadow phase: + +- Current Workers AI and Anthropic request/response tests from + `tests/translation/provider.test.ts`. +- Every permanent status plus representative `429` and `503` failures. +- HTTP signal forwarding and pre-aborted binding signals. +- Binding invocation arguments and every supported response envelope. +- Unexpected response previews and object-to-JSON normalization. +- Core/provider permanent-error identity. + +Automated verification: + +```sh +pnpm --filter @cloudflare/polystella-providers test +pnpm --filter @cloudflare/polystella-providers build +pnpm exec tsc --noEmit +``` + +Manual verification: + +- Inspect captured fake HTTP requests and compare URL, headers, body, and + `max_tokens` with Step 1. +- Run the binding factory with a small fake `env.AI.run` wrapper and inspect the + exact model ID and input object it receives. +- Confirm a `401` makes one core attempt and a `503` can consume the configured + retry budget. +- Confirm package manifests contain no Anthropic SDK or Cloudflare Workers + types runtime dependency. +- Confirm `git diff -- package.json src tests client.d.ts types-internal` is + empty. + +Stop condition: Do not rewire Astro until model identity, permanent errors, +and both existing HTTP transports match the baseline. + +## Step 6: Reconnect Astro At The Root + +Purpose: Prove the extraction independently from the later filesystem move. + +Primary callers to update: + +```text +src/index.ts +src/translation/provider.ts +src/translation/run.ts +src/storage/cache.ts +src/runtime/custom-loader-runtime.ts +src/i18n/ui-translate.ts +src/cli/translate-ui.ts +src/cli/audit-mdx.ts +``` + +Changes: + +- Replace relative imports of extracted code with workspace package imports. +- This is the first step allowed to edit root `package.json`, `src/**`, + `tests/**`, `client.d.ts`, or `types-internal/**`. +- Remove duplicated root implementations only after their callers import the + tested shadow packages. +- Keep a small Astro-local `createTranslator()` facade that maps validated + provider configuration to the provider package's concrete factories. +- Resolve each locale's model once and use `translator.modelId` for inference, + cache keys, metadata, reports, and output markers. +- Wrap shared adapters with Astro-owned cache selection, `noTranslate`, URL, + document-context, marker, and parser policies. +- Keep marker insertion inside the `apply` closure passed to + `translateOrLoadFromCache`, before R2 PUT. +- Keep Satteri as Astro's default parser and Remark as its configured fallback. +- Keep source walking, local cache index isolation, R2, overrides, staging, + route shims, bridge timing, and all runtime APIs in Astro. +- Keep the existing Astro package export subpaths. Move low-level translation, + adapter, and provider imports to their new package homes rather than adding + compatibility-only modules. +- Do not mix unrelated behavior fixes into this step. +- Update the root build and `prepare` scripts here, not during workspace + preparation. The root build now builds internal dependencies first; + `prepare` installs the Git hook only. + +Automated verification: + +```sh +pnpm test +pnpm exec tsc --noEmit +pnpm build +node dist/cli.js --version +pnpm playground:mdx-jsx:build +pnpm playground:mdx-jsx:translate:dry-run +``` + +Manual verification: + +- Compare dry-run R2 keys with Step 1. +- Compare one cache miss, one cache hit, one override, one `noTranslate` file, + and one local-cache skip report entry. +- Inspect staged Markdown/MDX and structured-data files for marker placement, + links, MDX imports, and formatting. +- Confirm logs still identify the resolved provider/model for each locale. + +Stop condition: Any cache-key, staged-byte, routing, marker, retry, or report +drift blocks the move to `packages/astro`. + +## Step 7: Prove Node And Workerd Portability + +Purpose: Turn the Node/workerd support decision into executable checks. + +Changes: + +- Keep the existing Node Vitest configuration for the full suite. +- Add `@cloudflare/vitest-plugin` as a root development dependency and add a + separate workerd Vitest configuration. +- Run representative core, adapters, and providers tests inside workerd. +- Add a minimal Worker fixture importing all three reusable packages. +- Configure that fixture without `nodejs_compat`. +- Add a small source/package boundary test rejecting imports of `node:`, Astro, + React, Satteri, filesystem, and environment modules from reusable packages. +- Keep consumers free to enable `nodejs_compat`. Passing without it is a + stronger portability guarantee, not a prohibition. + +Workerd coverage: + +- Core prompt, parse, batch, retry, and cancellation behavior with a fake + translator. +- JSON and Remark-backed Markdown adapter round trips. +- Workers AI binding behavior with a fake invocation callback. +- Provider HTTP behavior with a fake global `fetch`. + +Automated verification: + +```sh +pnpm test:node +pnpm test:workerd +pnpm test:boundaries +pnpm build +``` + +Manual verification: + +- Inspect the Worker fixture's Wrangler configuration and confirm there is no + `nodejs_compat` compatibility flag. +- Inspect its generated bundle for Node built-in imports and Satteri/native + binding references. +- Optionally repeat the fixture with `nodejs_compat` enabled to confirm that a + downstream Worker using the flag remains compatible. + +Stop condition: A reusable package that only works when `nodejs_compat` is +enabled is not platform-neutral and must be corrected before proceeding. + +## Step 8: Check The Extraction Before Moving Astro + +Purpose: Isolate extraction defects from path-move defects. + +Changes: + +- Pack the root Astro package and all three lower packages from the intermediate + layout. +- Install them into a temporary clean project. +- Import every lower-package entry and representative Astro entrypoints. +- Run the Astro playground against workspace packages. +- Update pkg.pr.new for the intermediate layout in the same commit that creates + the lower package manifests. + +Intermediate preview command: + +```sh +pnpm exec pkg-pr-new publish --pnpm --commentWithSha \ + '.' \ + './packages/core' \ + './packages/adapters' \ + './packages/providers' +``` + +All directories must be published in one command. pkg.pr.new then rewrites +sibling workspace dependencies to the matching preview URLs. + +Automated verification: + +```sh +pnpm test +pnpm exec tsc --noEmit +pnpm build +pnpm check:packages +pnpm playground:mdx-jsx:build +pnpm playground:mdx-jsx:translate:dry-run +``` + +Manual verification: + +- Inspect the pkg.pr.new PR comment and confirm it shows four installable + package previews. +- Install the Astro preview URL in a temporary Astro project and confirm its + package manifest points at the core, adapters, and providers preview URLs, + not unresolved `workspace:` ranges. +- Compare all Step 1 baseline artifacts one final time. + +Stop condition: Do not move Astro or make the root private until the +intermediate packages work outside the repository. + +## Step 9: Move Astro And Privatize The Root + +Purpose: Finish the desired monorepo layout after extraction is proven. + +Moves: + +```text +src/** -> packages/astro/src/** +Astro-owned tests -> packages/astro/tests/** +client.d.ts -> packages/astro/client.d.ts +types-internal/** -> packages/astro/types-internal/** +CHANGELOG.md -> packages/astro/CHANGELOG.md +Astro package metadata -> packages/astro/package.json +``` + +Changes: + +- Move only the remaining Astro-owned tree. Core, adapter, and provider files + have already reached their final locations and do not move again. +- Put the current public name, description, repository, exports, binary, + peers, publish configuration, and Astro dependencies in + `packages/astro/package.json`. +- Keep `packages/astro/src/version.ts` importing `../package.json`, preserving + the package version used in reports, metadata, and the runtime bridge. +- Preserve every existing Astro subpath export and `./client` type entry. +- Keep `chmod +x dist/cli.js` in the Astro package build. +- Replace the root manifest with a private `polystella-workspace` manifest + containing workspace scripts and development/release dependencies only. +- Remove root `exports`, `bin`, `files`, `publishConfig`, peers, and runtime + dependencies. +- Keep canonical repository-wide `AGENTS.md`, `ARCHITECTURE.md`, plans, and + contributor documentation at the root. +- Add concise package-local READMEs and license files rather than copying the + full architecture into every package. +- Update path-sensitive tests, docs scripts, Prettier ignores, and workflow + filters. Do not globally replace `src/`, because docs contain intentional + downstream Astro paths. + +Automated verification: + +```sh +pnpm install +pnpm test +pnpm exec tsc --noEmit +pnpm build +node packages/astro/dist/cli.js --version +``` + +Manual verification: + +- Inspect the root manifest and confirm it is private and has no publishable + entrypoints. +- Inspect `packages/astro/package.json` and compare its exports with the old + root manifest. +- Run `packages/astro/dist/cli.js --version` and confirm it reports the Astro + package version, not the private root version. +- Inspect a declaration map and source map from every package and confirm its + referenced source is included in that package. + +Stop condition: The move is incomplete while any source/test assumes the root +is still `@cloudflare/polystella` or any package version reads the private root +manifest. + +## Step 10: Finish Packaging And Lockstep Versions + +Purpose: Make the four packages behave like released artifacts rather than +workspace-only source folders. + +Changes: + +- Use `workspace:*` for every internal runtime dependency so pnpm writes exact + versions into packed manifests. +- Add all four public names to one Changesets `fixed` group: + +```text +@cloudflare/polystella-core +@cloudflare/polystella-adapters +@cloudflare/polystella-providers +@cloudflare/polystella +``` + +- Keep all four source manifests at `0.4.0` during extraction. +- Add one minor changeset covering all four packages. The release PR should + produce the first coordinated `0.5.0` release. +- Keep `src` and `dist` in each package tarball so declaration/source maps can + resolve source files. +- Add one small root package-check script that packs all four packages, + validates their export targets and internal versions, and installs them in a + temporary consumer. +- In the temporary consumer, import core, adapters, providers root/subpaths, + and all Astro runtime exports; typecheck representative APIs; run the CLI. +- Ensure no tarball contains tests, credentials, root tooling, or unresolved + `workspace:` ranges. + +Automated verification: + +```sh +pnpm build +pnpm check:packages +pnpm changeset status +``` + +Manual verification: + +- Open each tarball file list and inspect package name, version, files, export + targets, README, license, changelog, and internal dependency versions. +- Confirm the providers tarball exposes both subpaths. +- Confirm the Astro tarball contains an executable CLI with its shebang. +- Inspect the generated Changesets release state and confirm all four packages + move to the same version. + +Stop condition: Workspace tests are insufficient; packaging is blocked until a +fresh project can install only the tarballs and use every public entrypoint. + +## Step 11: Update Preview, Release, CI, And Documentation + +Purpose: Make repository automation and guidance agree with the final layout. + +Preview releases: + +- Update `.github/workflows/preview-releases.yml` to build first and publish all + package directories once. +- Keep the existing `PKG_PR_NEW_ENABLED` opt-in unless repository policy + changes separately. + +Final preview command: + +```sh +pnpm exec pkg-pr-new publish --pnpm --commentWithSha './packages/*' +``` + +This generates separate pkg.pr.new previews for core, adapters, providers, and +Astro. Publishing them in one invocation lets pkg.pr.new replace sibling +workspace dependencies with their corresponding preview URLs. + +Release and CI changes: + +- Keep `.github/workflows/release.yml` as the single Changesets publisher. +- Remove `.github/workflows/publish.yml`; `npm publish` from the private root + would be wrong and duplicates the Changesets path. +- Update `.github/workflows/ci.yml` to run Node tests, workerd tests, typecheck, + topological builds, package checks, and the moved Astro CLI smoke test. +- Update `.github/workflows/docs.yml` path filters for + `packages/astro/src/config/options.ts` and all package manifests. +- Update `.github/workflows/pr-triage.yml` area mappings from root `src/**` and + `tests/**` paths to package paths. +- Update `.changeset/README.md` for four fixed public packages and remove the + obsolete tag-publish fallback instructions. + +Documentation changes: + +- Update root `README.md`, `ARCHITECTURE.md`, `AGENTS.md`, `llms.txt`, generated + `llms-full.txt`, and contributor/consumer skills. +- Update stable repository paths in `tests/docs.test.ts` and planning/reference + documents that describe current architecture. +- Update `docs/scripts/generate-config-ref.ts` to import the moved Astro schema. +- Update `docs/scripts/check-exports.ts` to inspect all four public manifests. +- Document direct in-process flow: + +```text +source/record -> adapter -> core -> provider -> core -> adapter -> output +``` + +- Document a Workers AI binding example without importing generated Cloudflare + types into the provider package. +- Document that `nodejs_compat` is allowed but not required by reusable + packages. +- Document migrated low-level imports instead of adding compatibility shims. + +Automated verification: + +```sh +pnpm test +pnpm --filter polystella-docs build +pnpm --filter polystella-docs check-exports +pnpm --filter polystella-docs check-examples +pnpm build:llms +pnpm format:check +``` + +Manual verification: + +- Open the pkg.pr.new PR comment and confirm there are four package links and + one updated comment rather than four separate comments. +- Install at least the Astro and providers preview URLs together in a temporary + consumer and confirm sibling previews resolve. +- Read every changed command in `AGENTS.md` and run it from the repository root. +- Browse the docs export page and verify each package/subpath has a clear owner + and import example. + +Stop condition: Do not release while automation still assumes the root is a +publishable package or docs show old source paths/imports. + +## Step 12: Run The Final Release Gate + +Purpose: Verify the same code paths and package artifacts real consumers will +use. + +Automated verification: + +```sh +pnpm install --frozen-lockfile +pnpm test +pnpm test:node +pnpm test:workerd +pnpm test:boundaries +pnpm exec tsc --noEmit +pnpm build +pnpm check:packages +pnpm format:check +pnpm --filter polystella-docs build +pnpm --filter polystella-docs check-exports +pnpm --filter polystella-docs check-examples +pnpm playground:mdx-jsx:build +pnpm playground:mdx-jsx:translate:dry-run +node packages/astro/dist/cli.js --version +``` + +Manual verification: + +- Run one real Workers AI translation through the Astro HTTP path. +- Run one real Workers AI translation through a minimal Worker using the + binding path. This may incur Workers AI usage charges and must remain outside + normal CI. +- Run one Anthropic HTTP smoke test if credentials are available; otherwise + rely on the unchanged request characterization and record the omission. +- Verify one cold cache miss and one warm cache hit against a safe test R2 + bucket. +- Compare resulting staged bytes, cache metadata, report fields, marker values, + model IDs, links, and R2 keys with the Step 1 expectations. +- Install the four pkg.pr.new previews in a clean consumer and run an Astro + build plus a direct core/adapters/providers example. +- Review `git diff`, package tarball contents, and the pending changeset for + secrets or accidental unrelated changes. + +Release condition: Every automated gate passes, required manual checks are +recorded, and no known behavior regression remains. Only then merge the +Changesets release PR and publish all four packages together. + +## Explicitly Deferred + +- Moving the Translation Agent into this repository. +- Designing partial-failure, provenance, token-usage, or model-fallback APIs + before the Translation Agent source is available. +- Creating separate packages per provider or adapter. +- Adding new adapters or providers. +- Moving R2/cache behavior into core. +- Adding a hosted service or any required server hop. +- Securing or migrating to a dedicated npm scope. +- Broad cleanup or unrelated bug fixes discovered during extraction. + +## Decision Log + +Update this table when implementation requires changing an agreed boundary. + +| Date | Decision | Reason | +| ---------- | --------------------------------------------------- | ---------------------------------------------------------------------------------- | +| 2026-08-28 | Use `@cloudflare/polystella-*` names | Dedicated scope is speculative and unnecessary for extraction. | +| 2026-08-28 | Use lockstep versions | The first releases are tightly coordinated and internal dependencies stay exact. | +| 2026-08-28 | One providers package with subpaths | Current providers have no heavy SDK dependencies that justify separate releases. | +| 2026-08-28 | Workers AI owns HTTP and binding transports | Astro and the future Translation Agent can share normalization and error behavior. | +| 2026-08-28 | One adapters package | Current formats share contracts and portable parsing dependencies. | +| 2026-08-28 | Support Node and no-compat workerd | Consumers may use `nodejs_compat`, but reusable packages should not require it. | +| 2026-08-28 | Extract before moving Astro | Each concern moves once and regressions remain attributable. | +| 2026-08-28 | Prepare for, but do not move, the Translation Agent | Its source and final result/provenance requirements are not part of this change. | diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 00493df..e994a42 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - "." + - "packages/*" - "docs" - "playgrounds/*" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..b3bec31 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": [], + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noEmit": true + } +} From 2da854cd1a87dca57cbed88135f294cf354c25a4 Mon Sep 17 00:00:00 2001 From: Diogo Carneiro Date: Fri, 28 Aug 2026 12:02:08 +0100 Subject: [PATCH 02/13] chore: update monorepo extraction plan with steps 3-5 completion evidence Update extraction plan status from "In progress (Steps 1-2 complete)" to "In progress (Steps 1-3 complete)". Mark steps 3-5 as complete in progress table with test counts and verification details. Add completion evidence sections for core (26 tests, p-retry dependency), adapters (40 tests, Remark parser), and providers (33 tests, Workers AI/Anthropic factories) packages. Add corresponding pnpm-lock.yaml entries for packages/core, packages/adapters, and packages --- agent-work/polystella-monorepo-extraction.md | 41 ++- packages/adapters/package.json | 60 ++++ packages/adapters/src/adapter.ts | 28 ++ packages/adapters/src/adapters/json.ts | 50 +++ packages/adapters/src/adapters/markdown.ts | 79 +++++ packages/adapters/src/adapters/toml.ts | 41 +++ packages/adapters/src/adapters/yaml.ts | 51 +++ packages/adapters/src/apply.ts | 201 ++++++++++++ packages/adapters/src/extract.ts | 105 +++++++ packages/adapters/src/index.ts | 11 + packages/adapters/src/key-paths.ts | 200 ++++++++++++ packages/adapters/src/mdx-jsx-attributes.ts | 102 ++++++ packages/adapters/src/mdx-placeholders.ts | 291 +++++++++++++++++ packages/adapters/src/mdx-rules.ts | 13 + packages/adapters/src/mdx-static-data.ts | 297 ++++++++++++++++++ packages/adapters/src/parser.ts | 26 ++ packages/adapters/src/traverse.ts | 48 +++ packages/adapters/tests/key-paths.test.ts | 56 ++++ packages/adapters/tests/markdown.test.ts | 130 ++++++++ packages/adapters/tests/mdx.test.ts | 152 +++++++++ .../tests/structured-adapters.test.ts | 98 ++++++ packages/adapters/tsconfig.build.json | 13 + packages/adapters/tsconfig.json | 4 + packages/core/package.json | 49 +++ packages/core/src/batch.ts | 68 ++++ packages/core/src/glossary.ts | 21 ++ packages/core/src/index.ts | 8 + packages/core/src/logger.ts | 6 + packages/core/src/prompt.ts | 168 ++++++++++ packages/core/src/segment.ts | 4 + packages/core/src/translate-batch.ts | 79 +++++ packages/core/src/translate-segments.ts | 46 +++ packages/core/src/translator.ts | 24 ++ packages/core/tests/batch.test.ts | 50 +++ packages/core/tests/prompt.test.ts | 123 ++++++++ packages/core/tests/translate-batch.test.ts | 132 ++++++++ .../core/tests/translate-segments.test.ts | 148 +++++++++ packages/core/tsconfig.build.json | 13 + packages/core/tsconfig.json | 4 + packages/providers/package.json | 57 ++++ packages/providers/src/anthropic.ts | 53 ++++ packages/providers/src/http-error.ts | 10 + packages/providers/src/index.ts | 2 + packages/providers/src/workers-ai.ts | 153 +++++++++ packages/providers/tests/providers.test.ts | 251 +++++++++++++++ packages/providers/tsconfig.build.json | 13 + packages/providers/tsconfig.json | 4 + pnpm-lock.yaml | 69 ++++ 48 files changed, 3648 insertions(+), 4 deletions(-) create mode 100644 packages/adapters/package.json create mode 100644 packages/adapters/src/adapter.ts create mode 100644 packages/adapters/src/adapters/json.ts create mode 100644 packages/adapters/src/adapters/markdown.ts create mode 100644 packages/adapters/src/adapters/toml.ts create mode 100644 packages/adapters/src/adapters/yaml.ts create mode 100644 packages/adapters/src/apply.ts create mode 100644 packages/adapters/src/extract.ts create mode 100644 packages/adapters/src/index.ts create mode 100644 packages/adapters/src/key-paths.ts create mode 100644 packages/adapters/src/mdx-jsx-attributes.ts create mode 100644 packages/adapters/src/mdx-placeholders.ts create mode 100644 packages/adapters/src/mdx-rules.ts create mode 100644 packages/adapters/src/mdx-static-data.ts create mode 100644 packages/adapters/src/parser.ts create mode 100644 packages/adapters/src/traverse.ts create mode 100644 packages/adapters/tests/key-paths.test.ts create mode 100644 packages/adapters/tests/markdown.test.ts create mode 100644 packages/adapters/tests/mdx.test.ts create mode 100644 packages/adapters/tests/structured-adapters.test.ts create mode 100644 packages/adapters/tsconfig.build.json create mode 100644 packages/adapters/tsconfig.json create mode 100644 packages/core/package.json create mode 100644 packages/core/src/batch.ts create mode 100644 packages/core/src/glossary.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/logger.ts create mode 100644 packages/core/src/prompt.ts create mode 100644 packages/core/src/segment.ts create mode 100644 packages/core/src/translate-batch.ts create mode 100644 packages/core/src/translate-segments.ts create mode 100644 packages/core/src/translator.ts create mode 100644 packages/core/tests/batch.test.ts create mode 100644 packages/core/tests/prompt.test.ts create mode 100644 packages/core/tests/translate-batch.test.ts create mode 100644 packages/core/tests/translate-segments.test.ts create mode 100644 packages/core/tsconfig.build.json create mode 100644 packages/core/tsconfig.json create mode 100644 packages/providers/package.json create mode 100644 packages/providers/src/anthropic.ts create mode 100644 packages/providers/src/http-error.ts create mode 100644 packages/providers/src/index.ts create mode 100644 packages/providers/src/workers-ai.ts create mode 100644 packages/providers/tests/providers.test.ts create mode 100644 packages/providers/tsconfig.build.json create mode 100644 packages/providers/tsconfig.json diff --git a/agent-work/polystella-monorepo-extraction.md b/agent-work/polystella-monorepo-extraction.md index 799ca21..deb1c78 100644 --- a/agent-work/polystella-monorepo-extraction.md +++ b/agent-work/polystella-monorepo-extraction.md @@ -1,6 +1,6 @@ # PolyStella Monorepo Extraction Plan -Status: In progress (Steps 1-2 complete) +Status: In progress (Steps 1-3 complete) Last updated: 2026-08-28 ## ELI5: What Will Happen @@ -160,9 +160,9 @@ Owns: | ------------------------------ | ----------- | --------------------------------------------------------------------------------------------------------------------- | | 1. Record the baseline | Complete | `agent-work/polystella-monorepo-baseline.md` records commands, keys, hashes, outputs, and known limitations | | 2. Prepare the workspace | Complete | `packages/*` discovery and framework-neutral `tsconfig.base.json` added; existing package remains unchanged and green | -| 3. Extract core | Not started | Core tests pass in Node and has no forbidden imports | -| 4. Extract adapters | Not started | All format tests pass through the shared package | -| 5. Extract providers | Not started | HTTP and binding provider tests pass | +| 3. Extract core | Complete | Core builds from one runtime dependency; 26 package tests and import-boundary inspection pass | +| 4. Extract adapters | Complete | Shared adapters build with 40 portable format, parser, grouping, and reconstruction tests passing | +| 5. Extract providers | Complete | Three portable factories build with 33 HTTP, binding, cancellation, and retry-integration tests passing | | 6. Reconnect Astro | Not started | Existing Astro suite and playground pass | | 7. Prove portability | Not started | Node, no-compat workerd, and boundary checks pass | | 8. Check the extraction | Not started | Before/after outputs and keys match | @@ -379,6 +379,16 @@ Manual verification: Stop condition: Core is not complete while any host concern or provider transport is needed to run a translation with a fake `Translator`. +Completion evidence (2026-08-28): `@cloudflare/polystella-core@0.4.0` +builds declarations and source maps with only `p-retry` at runtime. Its 26 +tests pin the recorded prompt hashes, response parsing, batching and reference +order, model resolution, retries, cross-install permanent errors, sequential +multi-batch translation, and cancellation. Package typecheck, dist import, +tarball dry-run, root tests/typecheck/build, and the six-page playground build +pass. Source import inspection finds no Node, Astro, React, adapter, provider, +filesystem, storage, R2, or environment dependency. Protected root package +paths remain unchanged. + ## Step 4: Extract Adapters Purpose: Copy current file-format translation into a reusable shadow package @@ -435,6 +445,7 @@ Expected portable runtime dependencies: ```text @cloudflare/polystella-core +@types/mdast picomatch remark-frontmatter remark-gfm @@ -480,6 +491,16 @@ Manual verification: Stop condition: Do not proceed if Astro parity requires importing Satteri from the shared adapters entry or if a format emits different segment IDs. +Completion evidence (2026-08-28): `@cloudflare/polystella-adapters@0.4.0` +builds declarations and source maps with a narrow parser-injection contract and +Remark as its default parser. Its 40 tests cover JSON, YAML, TOML, Markdown, +MDX, parser injection, extraction/application, key paths, placeholders, static +data, reconstruction, generic-addition idempotence, and group reference/order +identity. Package and root typechecks/builds, 1,159 root tests, dist imports, +tarball dry-run, formatting, and the six-page playground build pass. Runtime +dependency and source inspection find no Satteri, Astro, Node built-in, or +native binding dependency. Protected root package paths remain unchanged. + ## Step 5: Extract Providers Purpose: Copy provider transports behind the core `Translator` contract so @@ -563,6 +584,18 @@ Manual verification: Stop condition: Do not rewire Astro until model identity, permanent errors, and both existing HTTP transports match the baseline. +Completion evidence (2026-08-28): `@cloudflare/polystella-providers@0.4.0` +exports Workers AI HTTP/binding and Anthropic factories with concrete model +IDs and the canonical core error type. Its 33 tests pin request bytes, response +precedence and normalization, all five permanent statuses, representative +retriable statuses, signal forwarding, binding cancellation boundaries, and +core retry behavior. Package/root tests, typechecks, builds, dist/subpath +imports, tarball dry-run, and the six-page playground build pass. Runtime +dependency and source inspection find no provider SDK, Workers ambient type, +Astro, Node built-in, locale-resolution, or provider-owned retry dependency. +Protected root package paths remain unchanged, and final review found no +remaining issues. + ## Step 6: Reconnect Astro At The Root Purpose: Prove the extraction independently from the later filesystem move. diff --git a/packages/adapters/package.json b/packages/adapters/package.json new file mode 100644 index 0000000..b1f53e5 --- /dev/null +++ b/packages/adapters/package.json @@ -0,0 +1,60 @@ +{ + "name": "@cloudflare/polystella-adapters", + "version": "0.4.0", + "type": "module", + "description": "Portable content format adapters for PolyStella.", + "license": "MIT", + "homepage": "https://github.com/cloudflare/polystella", + "repository": { + "type": "git", + "url": "git+https://github.com/cloudflare/polystella.git", + "directory": "packages/adapters" + }, + "bugs": { + "url": "https://github.com/cloudflare/polystella/issues" + }, + "keywords": [ + "markdown", + "mdx", + "json", + "yaml", + "toml", + "translation" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "test": "vitest run", + "build": "tsc -p tsconfig.build.json" + }, + "dependencies": { + "@cloudflare/polystella-core": "workspace:0.4.0", + "@types/mdast": "^4.0.4", + "picomatch": "^4.0.4", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.1", + "remark-parse": "^11.0.0", + "smol-toml": "^1.6.1", + "unified": "^11.0.5", + "yaml": "^2.8.3" + }, + "devDependencies": { + "@types/picomatch": "^4.0.0", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/packages/adapters/src/adapter.ts b/packages/adapters/src/adapter.ts new file mode 100644 index 0000000..2771eee --- /dev/null +++ b/packages/adapters/src/adapter.ts @@ -0,0 +1,28 @@ +import type { Segment } from "@cloudflare/polystella-core"; + +import type { NormalizedMdxRules } from "./mdx-rules.js"; + +export interface AdapterExtractOptions { + sourcePath: string; + translatableKeys: Record; + mdxRules?: NormalizedMdxRules | undefined; +} + +export interface AdapterApplyOptions { + sourcePath?: string | undefined; + mdxRules?: NormalizedMdxRules | undefined; + topLevelAdditions?: Record | undefined; +} + +export interface FileAdapter { + readonly extensions: readonly string[]; + parse(source: string, sourcePath?: string | undefined): TParsed; + extractSegments(parsed: TParsed, source: string, options: AdapterExtractOptions): Segment[]; + applyTranslations( + parsed: TParsed, + source: string, + translations: ReadonlyMap, + options?: AdapterApplyOptions | undefined, + ): string; + groupSegments?: ((parsed: TParsed, segments: Segment[]) => Segment[][]) | undefined; +} diff --git a/packages/adapters/src/adapters/json.ts b/packages/adapters/src/adapters/json.ts new file mode 100644 index 0000000..115477f --- /dev/null +++ b/packages/adapters/src/adapters/json.ts @@ -0,0 +1,50 @@ +import type { Segment } from "@cloudflare/polystella-core"; + +import type { FileAdapter } from "../adapter.js"; +import { parsePath, readAtPath, resolveConcretePaths, writeAtPath, type PathSegment } from "../key-paths.js"; + +export type JsonData = unknown; + +export const jsonAdapter: FileAdapter = { + extensions: [".json"], + + parse(source) { + return JSON.parse(source) as JsonData; + }, + + extractSegments(parsed, _source, options) { + const segments: Segment[] = []; + for (const path of resolveConcretePaths({ + parsed, + sourcePath: options.sourcePath, + translatableKeys: options.translatableKeys, + })) { + const value = readAtPath(parsed, parsePath(path).segments as PathSegment[]); + if (typeof value === "string" && value.length > 0) segments.push({ id: path, text: value }); + } + return segments; + }, + + applyTranslations(parsed, _source, translations, options = {}) { + const output = structuredClone(parsed) as JsonData; + for (const [id, translation] of translations) { + writeAtPath(output, parsePath(id).segments as PathSegment[], translation); + } + if (options.topLevelAdditions !== undefined) injectTopLevelAdditions(output, options.topLevelAdditions); + return JSON.stringify(output, null, 2); + }, +}; + +function injectTopLevelAdditions(output: JsonData, additions: Record): void { + if (output === null || typeof output !== "object") return; + if (Array.isArray(output)) { + for (const entry of output) addToEntry(entry, additions); + return; + } + for (const entry of Object.values(output)) addToEntry(entry, additions); +} + +function addToEntry(entry: unknown, additions: Record): void { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return; + Object.assign(entry, additions); +} diff --git a/packages/adapters/src/adapters/markdown.ts b/packages/adapters/src/adapters/markdown.ts new file mode 100644 index 0000000..d560a80 --- /dev/null +++ b/packages/adapters/src/adapters/markdown.ts @@ -0,0 +1,79 @@ +import type { Segment } from "@cloudflare/polystella-core"; +import type { Root } from "mdast"; + +import type { FileAdapter } from "../adapter.js"; +import { applyTranslations } from "../apply.js"; +import { extractSegments } from "../extract.js"; +import type { MarkdownParser } from "../parser.js"; +import { remarkMarkdownParser } from "../parser.js"; +import { visitTranslatableBlocks } from "../traverse.js"; + +export function createMarkdownAdapter(parser: MarkdownParser = remarkMarkdownParser): FileAdapter { + return { + extensions: [".md", ".mdx"], + + parse(source, sourcePath) { + return sourcePath?.toLowerCase().endsWith(".mdx") === true ? parser.parseMdx(source) : parser.parseMarkdown(source); + }, + + extractSegments(parsed, source, options) { + return extractSegments( + parsed, + { + sourcePath: options.sourcePath, + frontmatter: options.translatableKeys, + ...(options.mdxRules !== undefined ? { mdxRules: options.mdxRules } : {}), + }, + source, + ); + }, + + applyTranslations(parsed, source, translations, options = {}) { + return applyTranslations(parsed, translations, source, { + ...(options.sourcePath !== undefined ? { sourcePath: options.sourcePath } : {}), + ...(options.mdxRules !== undefined ? { mdxRules: options.mdxRules } : {}), + ...(options.topLevelAdditions !== undefined ? { frontmatterAdditions: options.topLevelAdditions } : {}), + }); + }, + + groupSegments(parsed, segments) { + if (segments.length === 0) return []; + const segmentById = new Map(segments.map((segment) => [segment.id, segment])); + const bodyGroups: Segment[][] = []; + let currentGroup: Segment[] = []; + visitTranslatableBlocks(parsed, ({ block, id }) => { + const segment = segmentById.get(id); + if (segment === undefined) return; + if (block.type === "heading" && currentGroup.length > 0) { + bodyGroups.push(currentGroup); + currentGroup = []; + } + currentGroup.push(segment); + }); + if (currentGroup.length > 0) bodyGroups.push(currentGroup); + + const mdxDataGroup = segments.filter((segment) => !segment.id.startsWith("body:") && !segment.id.startsWith("fm:")); + const frontmatterGroup = segments.filter((segment) => segment.id.startsWith("fm:")); + const groups = [...bodyGroups]; + if (mdxDataGroup.length > 0) groups.push(mdxDataGroup); + if (frontmatterGroup.length > 0) groups.push(frontmatterGroup); + + const flattened = groups.flat(); + if (flattened.length !== segments.length) { + throw new Error( + `[polystella] markdownAdapter.groupSegments invariant violated: produced ${flattened.length} segments but received ${segments.length}`, + ); + } + for (let index = 0; index < flattened.length; index++) { + if (flattened[index] !== segments[index]) { + throw new Error( + `[polystella] markdownAdapter.groupSegments invariant violated: segment at position ${index} differs (expected "${segments[index]?.id}", got "${flattened[index]?.id}")`, + ); + } + } + return groups; + }, + }; +} + +export const markdownAdapter = createMarkdownAdapter(); diff --git a/packages/adapters/src/adapters/toml.ts b/packages/adapters/src/adapters/toml.ts new file mode 100644 index 0000000..3729ed7 --- /dev/null +++ b/packages/adapters/src/adapters/toml.ts @@ -0,0 +1,41 @@ +import type { Segment } from "@cloudflare/polystella-core"; +import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; + +import type { FileAdapter } from "../adapter.js"; +import { parsePath, readAtPath, resolveConcretePaths, writeAtPath, type PathSegment } from "../key-paths.js"; + +export type TomlData = Record; + +export const tomlAdapter: FileAdapter = { + extensions: [".toml"], + + parse(source) { + return parseToml(source) as TomlData; + }, + + extractSegments(parsed, _source, options) { + const segments: Segment[] = []; + for (const path of resolveConcretePaths({ + parsed, + sourcePath: options.sourcePath, + translatableKeys: options.translatableKeys, + })) { + const value = readAtPath(parsed, parsePath(path).segments as PathSegment[]); + if (typeof value === "string" && value.length > 0) segments.push({ id: path, text: value }); + } + return segments; + }, + + applyTranslations(parsed, _source, translations, options = {}) { + const output = structuredClone(parsed) as TomlData; + for (const [id, translation] of translations) { + writeAtPath(output, parsePath(id).segments as PathSegment[], translation); + } + if (options.topLevelAdditions !== undefined) { + for (const entry of Object.values(output)) { + if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) Object.assign(entry, options.topLevelAdditions); + } + } + return stringifyToml(output); + }, +}; diff --git a/packages/adapters/src/adapters/yaml.ts b/packages/adapters/src/adapters/yaml.ts new file mode 100644 index 0000000..a4f22a9 --- /dev/null +++ b/packages/adapters/src/adapters/yaml.ts @@ -0,0 +1,51 @@ +import type { Segment } from "@cloudflare/polystella-core"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; + +import type { FileAdapter } from "../adapter.js"; +import { parsePath, readAtPath, resolveConcretePaths, writeAtPath, type PathSegment } from "../key-paths.js"; + +export type YamlData = unknown; + +export const yamlAdapter: FileAdapter = { + extensions: [".yaml", ".yml"], + + parse(source) { + return parseYaml(source) as YamlData; + }, + + extractSegments(parsed, _source, options) { + const segments: Segment[] = []; + for (const path of resolveConcretePaths({ + parsed, + sourcePath: options.sourcePath, + translatableKeys: options.translatableKeys, + })) { + const value = readAtPath(parsed, parsePath(path).segments as PathSegment[]); + if (typeof value === "string" && value.length > 0) segments.push({ id: path, text: value }); + } + return segments; + }, + + applyTranslations(parsed, _source, translations, options = {}) { + const output = structuredClone(parsed) as YamlData; + for (const [id, translation] of translations) { + writeAtPath(output, parsePath(id).segments as PathSegment[], translation); + } + if (options.topLevelAdditions !== undefined) injectTopLevelAdditions(output, options.topLevelAdditions); + return stringifyYaml(output); + }, +}; + +function injectTopLevelAdditions(output: YamlData, additions: Record): void { + if (output === null || typeof output !== "object") return; + if (Array.isArray(output)) { + for (const entry of output) addToEntry(entry, additions); + return; + } + for (const entry of Object.values(output)) addToEntry(entry, additions); +} + +function addToEntry(entry: unknown, additions: Record): void { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return; + Object.assign(entry, additions); +} diff --git a/packages/adapters/src/apply.ts b/packages/adapters/src/apply.ts new file mode 100644 index 0000000..fb8fb2f --- /dev/null +++ b/packages/adapters/src/apply.ts @@ -0,0 +1,201 @@ +import type { Root, Yaml } from "mdast"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; + +import { collectMarkdownSegments } from "./extract.js"; +import { restoreInlineMdxPlaceholders } from "./mdx-placeholders.js"; + +interface TextEdit { + start: number; + end: number; + replacement: string; + label: string; +} + +export interface ApplyTranslationsOptions { + sourcePath?: string | undefined; + mdxRules?: Parameters[1]["mdxRules"] | undefined; + frontmatterAdditions?: Record | undefined; +} + +export function applyTranslations( + ast: Root, + translations: ReadonlyMap, + source: string, + options: ApplyTranslationsOptions = {}, +): string { + const additions = options.frontmatterAdditions ?? {}; + const hasAdditions = Object.keys(additions).length > 0; + if (translations.size === 0 && !hasAdditions) return source; + + const edits: TextEdit[] = []; + const collected = collectMarkdownSegments( + ast, + { sourcePath: options.sourcePath ?? "", frontmatter: {}, mdxRules: options.mdxRules }, + source, + ); + for (const entry of collected) { + if (entry.kind === "frontmatter") continue; + const translation = translations.get(entry.segment.id); + if (translation === undefined || entry.span === undefined) continue; + edits.push({ + ...entry.span, + replacement: formatSegmentReplacement(entry, translation, translations), + label: entry.segment.id, + }); + } + + const frontmatterNode = ast.children.find((child): child is Yaml => child.type === "yaml"); + if (frontmatterNode !== undefined) { + const frontmatterTranslations = collectFrontmatterTranslations(translations); + if (frontmatterTranslations.size > 0 || hasAdditions) { + const span = nodeSpan(frontmatterNode); + if (span !== undefined) { + const parsed = parseYaml(frontmatterNode.value); + const data = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; + for (const [path, translation] of frontmatterTranslations) applyFrontmatterTranslation(data, path, translation); + for (const [key, value] of Object.entries(additions)) data[key] = value; + const inner = stringifyYaml(data).replace(/\n+$/, ""); + edits.push({ ...span, replacement: `---\n${inner}\n---`, label: "frontmatter" }); + } + } + } else if (hasAdditions) { + const inner = stringifyYaml(additions).replace(/\n+$/, ""); + edits.push({ start: 0, end: 0, replacement: `---\n${inner}\n---\n\n`, label: "frontmatter:add" }); + } + + if (edits.length === 0) return source; + assertNonOverlappingEdits(edits, options.sourcePath); + edits.sort((a, b) => b.start - a.start); + let output = source; + for (const edit of edits) output = output.slice(0, edit.start) + edit.replacement + output.slice(edit.end); + return output; +} + +function assertNonOverlappingEdits(edits: readonly TextEdit[], sourcePath: string | undefined): void { + for (const edit of edits) { + if (edit.start < 0 || edit.end < edit.start) { + throw new Error( + `[polystella] invalid markdown replacement span${formatSourcePath(sourcePath)}: ${edit.label} [${edit.start}, ${edit.end})`, + ); + } + } + + const occupied = edits.filter((edit) => edit.start < edit.end).sort((a, b) => a.start - b.start || a.end - b.end); + let previous: TextEdit | undefined; + for (const edit of occupied) { + if (previous !== undefined && edit.start < previous.end) { + throw new Error( + `[polystella] overlapping markdown replacement spans${formatSourcePath(sourcePath)}: ${previous.label} [${previous.start}, ${previous.end}) overlaps ${edit.label} [${edit.start}, ${edit.end})`, + ); + } + previous = edit; + } +} + +function formatSourcePath(sourcePath: string | undefined): string { + return sourcePath && sourcePath.length > 0 ? ` in ${sourcePath}` : ""; +} + +function formatSegmentReplacement( + entry: { + placeholders?: Parameters[1] | undefined; + replacement?: { kind: "js-string" | "quoted-attribute"; quote: "'" | '"' } | undefined; + }, + value: string, + translations: ReadonlyMap, +): string { + const restored = entry.placeholders !== undefined ? restoreInlineMdxPlaceholders(value, entry.placeholders, translations) : value; + return formatCollectedReplacement(entry.replacement, restored); +} + +function formatCollectedReplacement( + replacement: { kind: "js-string" | "quoted-attribute"; quote: "'" | '"' } | undefined, + value: string, +): string { + if (replacement?.kind === "js-string") return escapeJsStringContent(value, replacement.quote); + if (replacement?.kind === "quoted-attribute") return escapeQuotedAttributeContent(value, replacement.quote); + return value; +} + +function escapeJsStringContent(value: string, quote: "'" | '"'): string { + let output = ""; + for (const char of value) { + switch (char) { + case "\\": + output += "\\\\"; + break; + case "\n": + output += "\\n"; + break; + case "\r": + output += "\\r"; + break; + case "\t": + output += "\\t"; + break; + case "'": + output += quote === "'" ? "\\'" : char; + break; + case '"': + output += quote === '"' ? '\\"' : char; + break; + default: + output += char; + } + } + return output; +} + +function escapeQuotedAttributeContent(value: string, quote: "'" | '"'): string { + let output = ""; + for (const char of value) { + switch (char) { + case "&": + output += "&"; + break; + case "'": + output += quote === "'" ? "'" : char; + break; + case '"': + output += quote === '"' ? """ : char; + break; + case "\n": + case "\r": + output += " "; + break; + default: + output += char; + } + } + return output; +} + +function nodeSpan(node: unknown): { start: number; end: number } | undefined { + if (typeof node !== "object" || node === null) return undefined; + const position = (node as { position?: { start?: { offset?: unknown }; end?: { offset?: unknown } } }).position; + const start = position?.start?.offset; + const end = position?.end?.offset; + return typeof start === "number" && typeof end === "number" ? { start, end } : undefined; +} + +function collectFrontmatterTranslations(translations: ReadonlyMap): Map { + const frontmatter = new Map(); + for (const [id, value] of translations) { + if (id.startsWith("fm:")) frontmatter.set(id.slice(3), value); + } + return frontmatter; +} + +function applyFrontmatterTranslation(data: Record, path: string, translation: string): void { + const arrayMatch = /^([^[]+)\[(\d+)\]$/.exec(path); + if (arrayMatch !== null) { + const key = arrayMatch[1]; + const indexText = arrayMatch[2]; + if (key === undefined || indexText === undefined) return; + const value = data[key]; + const index = Number(indexText); + if (Array.isArray(value) && index < value.length) value[index] = translation; + return; + } + data[path] = translation; +} diff --git a/packages/adapters/src/extract.ts b/packages/adapters/src/extract.ts new file mode 100644 index 0000000..fb5bb77 --- /dev/null +++ b/packages/adapters/src/extract.ts @@ -0,0 +1,105 @@ +import type { Segment } from "@cloudflare/polystella-core"; +import type { Root, Yaml } from "mdast"; +import picomatch from "picomatch"; +import { parse as parseYaml } from "yaml"; + +import { collectMdxJsxAttributeSegments } from "./mdx-jsx-attributes.js"; +import type { InlineMdxPlaceholder } from "./mdx-placeholders.js"; +import { protectInlineMdxJsx } from "./mdx-placeholders.js"; +import { collectMdxStaticDataSegments } from "./mdx-static-data.js"; +import type { NormalizedMdxRules } from "./mdx-rules.js"; +import { inlineSpan, visitTranslatableBlocks } from "./traverse.js"; + +const matcherCache = new Map boolean>(); + +export interface ExtractOptions { + sourcePath: string; + frontmatter: Record; + mdxRules?: NormalizedMdxRules | undefined; +} + +export type MarkdownSegmentKind = "body" | "frontmatter" | "mdx-static-data" | "jsx-attribute" | "placeholder-inline-jsx"; + +export interface MarkdownCollectedSegment { + segment: Segment; + kind: MarkdownSegmentKind; + span?: { start: number; end: number } | undefined; + replacement?: { kind: "js-string" | "quoted-attribute"; quote: "'" | '"' } | undefined; + placeholders?: InlineMdxPlaceholder[] | undefined; +} + +export function extractSegments(ast: Root, options: ExtractOptions, source: string): Segment[] { + return collectMarkdownSegments(ast, options, source).map((entry) => entry.segment); +} + +export function collectMarkdownSegments(ast: Root, options: ExtractOptions, source: string): MarkdownCollectedSegment[] { + const segments: MarkdownCollectedSegment[] = []; + const placeholderSegments: MarkdownCollectedSegment[] = []; + + visitTranslatableBlocks(ast, ({ block, id }) => { + const span = inlineSpan(block); + if (span === undefined) return; + const protectedText = protectInlineMdxJsx(block, source, span, options.mdxRules); + const text = protectedText?.text ?? source.slice(span.start, span.end); + if (text.length === 0) return; + segments.push({ + segment: { id, text }, + kind: "body", + span, + ...(protectedText ? { placeholders: protectedText.placeholders } : {}), + }); + if (protectedText !== undefined) { + for (const placeholder of protectedText.placeholders) { + for (const attribute of placeholder.attributes) { + placeholderSegments.push({ segment: { id: attribute.id, text: attribute.text }, kind: "placeholder-inline-jsx" }); + } + } + } + }); + + if (options.mdxRules !== undefined) { + segments.push(...placeholderSegments); + segments.push(...collectMdxStaticDataSegments(ast, source, { sourcePath: options.sourcePath, mdxRules: options.mdxRules })); + segments.push(...collectMdxJsxAttributeSegments(ast, source, { mdxRules: options.mdxRules })); + } + + const frontmatterNode = ast.children.find((child): child is Yaml => child.type === "yaml"); + if (frontmatterNode !== undefined) { + const keys = resolveFrontmatterKeys(options.sourcePath, options.frontmatter); + if (keys.length > 0) { + const parsed = parseYaml(frontmatterNode.value); + const data = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; + for (const key of keys) { + const value = data[key]; + if (typeof value === "string" && value.length > 0) { + segments.push({ segment: { id: `fm:${key}`, text: value }, kind: "frontmatter" }); + } else if (Array.isArray(value)) { + value.forEach((item, index) => { + if (typeof item === "string" && item.length > 0) { + segments.push({ segment: { id: `fm:${key}[${index}]`, text: item }, kind: "frontmatter" }); + } + }); + } + } + } + } + + return segments; +} + +export function resolveFrontmatterKeys(sourcePath: string, rules: Record): string[] { + const matched = new Set(); + for (const [pattern, keys] of Object.entries(rules)) { + if (!getMatcher(pattern)(sourcePath)) continue; + for (const key of keys) matched.add(key); + } + return [...matched]; +} + +function getMatcher(pattern: string): (path: string) => boolean { + const cached = matcherCache.get(pattern); + if (cached !== undefined) return cached; + const matcher = picomatch(pattern); + matcherCache.set(pattern, matcher); + return matcher; +} diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts new file mode 100644 index 0000000..2d5978c --- /dev/null +++ b/packages/adapters/src/index.ts @@ -0,0 +1,11 @@ +export * from "./adapter.js"; +export * from "./adapters/json.js"; +export * from "./adapters/markdown.js"; +export * from "./adapters/toml.js"; +export * from "./adapters/yaml.js"; +export * from "./apply.js"; +export * from "./extract.js"; +export * from "./key-paths.js"; +export * from "./mdx-placeholders.js"; +export * from "./mdx-rules.js"; +export * from "./parser.js"; diff --git a/packages/adapters/src/key-paths.ts b/packages/adapters/src/key-paths.ts new file mode 100644 index 0000000..867cada --- /dev/null +++ b/packages/adapters/src/key-paths.ts @@ -0,0 +1,200 @@ +import picomatch from "picomatch"; + +export type PathSegment = string | number; + +const FORBIDDEN_SEGMENT_NAMES = new Set(["__proto__", "prototype", "constructor"]); +const patternMatcherCache = new Map boolean>(); + +export function parsePath(path: string): { segments: (PathSegment | "*")[]; hasWildcard: boolean } { + if (path.length === 0) throw new Error(`[polystella] empty key path is invalid`); + const segments: (PathSegment | "*")[] = []; + let hasWildcard = false; + let index = 0; + + while (index < path.length) { + if (path[index] === ".") { + throw new Error(`[polystella] malformed key path "${path}": unexpected "." at index ${index}`); + } + if (path[index] === "[") { + const closeIndex = path.indexOf("]", index); + if (closeIndex === -1) { + throw new Error(`[polystella] malformed key path "${path}": unclosed "[" at index ${index}`); + } + const inner = path.slice(index + 1, closeIndex); + if (inner === "*") { + segments.push("*"); + hasWildcard = true; + } else if (/^\d+$/.test(inner)) { + segments.push(Number(inner)); + } else { + throw new Error(`[polystella] malformed key path "${path}": "[${inner}]" must be a non-negative integer or "*"`); + } + index = closeIndex + 1; + if (index < path.length && path[index] === ".") { + index++; + if (index === path.length) throw new Error(`[polystella] malformed key path "${path}": trailing "."`); + } + continue; + } + + let end = index; + while (end < path.length && path[end] !== "." && path[end] !== "[") end++; + const key = path.slice(index, end); + if (key.length === 0) { + throw new Error(`[polystella] malformed key path "${path}": empty segment near index ${index}`); + } + if (key === "*") { + segments.push("*"); + hasWildcard = true; + } else { + assertSafeSegment(key, path); + segments.push(key); + } + index = end; + if (index < path.length && path[index] === ".") { + index++; + if (index === path.length) throw new Error(`[polystella] malformed key path "${path}": trailing "."`); + } + } + + return { segments, hasWildcard }; +} + +export function formatPath(segments: readonly PathSegment[]): string { + let output = ""; + for (let index = 0; index < segments.length; index++) { + const segment = segments[index]; + if (segment === undefined) continue; + output += typeof segment === "number" ? `[${segment}]` : index === 0 ? segment : `.${segment}`; + } + return output; +} + +export function expandPath(path: string, data: unknown): string[] { + const { segments, hasWildcard } = parsePath(path); + return hasWildcard ? expandSegments(segments, data, []) : [path]; +} + +export function readAtPath(node: unknown, segments: readonly PathSegment[]): unknown { + let current = node; + for (const segment of segments) { + if (current === null || current === undefined) return undefined; + if (typeof segment === "number") { + if (!Array.isArray(current)) return undefined; + current = current[segment]; + } else { + if (typeof current !== "object" || !Object.hasOwn(current, segment)) return undefined; + current = (current as Record)[segment]; + } + } + return current; +} + +export function writeAtPath(node: unknown, segments: readonly PathSegment[], value: unknown): void { + if (segments.length === 0) throw new Error(`[polystella] cannot write at empty path`); + let current = node; + for (let index = 0; index < segments.length - 1; index++) { + const segment = segments[index]; + if (segment === undefined) continue; + if (current === null || current === undefined) { + throw new Error(`[polystella] cannot write at ${formatPath(segments)}: parent is null/undefined at segment ${index}`); + } + if (typeof segment === "number") { + if (!Array.isArray(current)) { + throw new Error(`[polystella] cannot write at ${formatPath(segments)}: expected array at segment ${index}, got ${typeof current}`); + } + current = current[segment]; + } else { + if (typeof current !== "object") { + throw new Error(`[polystella] cannot write at ${formatPath(segments)}: expected object at segment ${index}, got ${typeof current}`); + } + current = Object.hasOwn(current, segment) ? (current as Record)[segment] : undefined; + } + } + + const last = segments.at(-1); + if (last === undefined) throw new Error(`[polystella] cannot write at empty path`); + if (current === null || current === undefined) { + throw new Error(`[polystella] cannot write at ${formatPath(segments)}: terminal parent is null/undefined`); + } + if (typeof last === "number") { + if (!Array.isArray(current)) { + throw new Error(`[polystella] cannot write at ${formatPath(segments)}: expected array as terminal parent`); + } + current[last] = value; + } else { + if (typeof current !== "object") { + throw new Error(`[polystella] cannot write at ${formatPath(segments)}: expected object as terminal parent`); + } + if (FORBIDDEN_SEGMENT_NAMES.has(last)) { + throw new Error(`[polystella] cannot write at ${formatPath(segments)}: terminal segment "${last}" is reserved (prototype-chain).`); + } + (current as Record)[last] = value; + } +} + +export function resolveConcretePaths(options: { + parsed: unknown; + sourcePath: string; + translatableKeys: Record; +}): string[] { + const matchedRules: string[] = []; + for (const [pattern, paths] of Object.entries(options.translatableKeys)) { + if (!getMatcher(pattern)(options.sourcePath)) continue; + for (const path of paths) { + if (!matchedRules.includes(path)) matchedRules.push(path); + } + } + + const concrete: string[] = []; + const seen = new Set(); + for (const rule of matchedRules) { + for (const expanded of expandPath(rule, options.parsed)) { + if (seen.has(expanded)) continue; + seen.add(expanded); + concrete.push(expanded); + } + } + return concrete; +} + +function assertSafeSegment(segment: string, path: string): void { + if (FORBIDDEN_SEGMENT_NAMES.has(segment)) { + throw new Error( + `[polystella] key path "${path}" contains reserved segment "${segment}". ` + + `Segments named __proto__, prototype, or constructor are rejected because they traverse the JavaScript prototype chain.`, + ); + } +} + +function expandSegments(segments: readonly (PathSegment | "*")[], node: unknown, path: PathSegment[]): string[] { + if (segments.length === 0) return [formatPath(path)]; + const [head, ...rest] = segments; + if (head === "*") { + if (node === null || node === undefined) return []; + if (Array.isArray(node)) { + return node.flatMap((entry, index) => expandSegments(rest, entry, [...path, index])); + } + if (typeof node === "object") { + return Object.keys(node).flatMap((key) => expandSegments(rest, (node as Record)[key], [...path, key])); + } + return []; + } + if (head === undefined) return [formatPath(path)]; + if (node === null || node === undefined) { + return [formatPath([...path, head, ...rest.filter((segment): segment is PathSegment => segment !== "*")])]; + } + if (typeof head === "number") { + return Array.isArray(node) ? expandSegments(rest, node[head], [...path, head]) : []; + } + if (typeof node !== "object" || !Object.hasOwn(node, head)) return []; + return expandSegments(rest, (node as Record)[head], [...path, head]); +} + +function getMatcher(pattern: string): (path: string) => boolean { + const cached = patternMatcherCache.get(pattern); + if (cached !== undefined) return cached; + const matcher = picomatch(pattern); + patternMatcherCache.set(pattern, matcher); + return matcher; +} diff --git a/packages/adapters/src/mdx-jsx-attributes.ts b/packages/adapters/src/mdx-jsx-attributes.ts new file mode 100644 index 0000000..7b6219b --- /dev/null +++ b/packages/adapters/src/mdx-jsx-attributes.ts @@ -0,0 +1,102 @@ +import type { Root } from "mdast"; + +import type { MarkdownCollectedSegment } from "./extract.js"; +import type { NormalizedMdxRules } from "./mdx-rules.js"; + +export interface CollectMdxJsxAttributeOptions { + mdxRules: NormalizedMdxRules; +} + +export function collectMdxJsxAttributeSegments( + ast: Root, + source: string, + options: CollectMdxJsxAttributeOptions, +): MarkdownCollectedSegment[] { + const output: MarkdownCollectedSegment[] = []; + walkUnknown(ast, (node) => { + if (!isMdxJsxElement(node)) return; + const attributes = readArrayProperty(node, "attributes"); + if (attributes === undefined) return; + const allowed = allowedAttributesForElement(node.name, options.mdxRules); + for (const attribute of attributes) { + if (!isMdxJsxAttribute(attribute) || !allowed.has(attribute.name)) continue; + if (typeof attribute.value !== "string" || attribute.value.length === 0) continue; + const attributeSpan = readPositionSpan(attribute); + if (attributeSpan === undefined) continue; + const valueSpan = findQuotedAttributeValueSpan(source, attributeSpan); + if (valueSpan === undefined) continue; + output.push({ + segment: { id: `mdx:attr:${node.name}.${attribute.name}:${valueSpan.start}`, text: attribute.value }, + kind: "jsx-attribute", + span: { start: valueSpan.start, end: valueSpan.end }, + replacement: { kind: "quoted-attribute", quote: valueSpan.quote }, + }); + } + }); + return output; +} + +function allowedAttributesForElement(elementName: string, rules: NormalizedMdxRules): Set { + if (isLowercaseElementName(elementName)) { + return new Set([...(rules.htmlAttributes["*"] ?? []), ...(rules.htmlAttributes[elementName] ?? [])]); + } + return new Set(rules.components[elementName]?.props ?? []); +} + +function isLowercaseElementName(name: string): boolean { + const first = name[0]; + return first !== undefined && first.toLowerCase() === first; +} + +function findQuotedAttributeValueSpan( + source: string, + attributeSpan: { start: number; end: number }, +): { start: number; end: number; quote: "'" | '"' } | undefined { + const slice = source.slice(attributeSpan.start, attributeSpan.end); + const equalsIndex = slice.indexOf("="); + if (equalsIndex < 0) return undefined; + let quoteIndex = equalsIndex + 1; + while (quoteIndex < slice.length && /\s/.test(slice[quoteIndex] ?? "")) quoteIndex++; + const quote = slice[quoteIndex]; + if (quote !== "'" && quote !== '"') return undefined; + const valueStart = quoteIndex + 1; + const valueEnd = slice.indexOf(quote, valueStart); + if (valueEnd < 0) return undefined; + return { start: attributeSpan.start + valueStart, end: attributeSpan.start + valueEnd, quote }; +} + +function isMdxJsxElement(node: unknown): node is { type: string; name: string } { + if (typeof node !== "object" || node === null) return false; + const candidate = node as { type?: unknown; name?: unknown }; + return candidate.type === "mdxJsxFlowElement" && typeof candidate.name === "string"; +} + +function isMdxJsxAttribute(node: unknown): node is { type: string; name: string; value: unknown } { + if (typeof node !== "object" || node === null) return false; + const candidate = node as { type?: unknown; name?: unknown }; + return candidate.type === "mdxJsxAttribute" && typeof candidate.name === "string"; +} + +function readPositionSpan(node: unknown): { start: number; end: number } | undefined { + if (typeof node !== "object" || node === null) return undefined; + const position = (node as { position?: { start?: { offset?: unknown }; end?: { offset?: unknown } } }).position; + const start = position?.start?.offset; + const end = position?.end?.offset; + return typeof start === "number" && typeof end === "number" ? { start, end } : undefined; +} + +function walkUnknown(value: unknown, visitor: (node: unknown) => void): void { + if (typeof value !== "object" || value === null) return; + visitor(value); + if (Array.isArray(value)) { + for (const item of value) walkUnknown(item, visitor); + return; + } + for (const child of Object.values(value as Record)) walkUnknown(child, visitor); +} + +function readArrayProperty(node: unknown, property: string): unknown[] | undefined { + if (typeof node !== "object" || node === null) return undefined; + const value = (node as Record)[property]; + return Array.isArray(value) ? value : undefined; +} diff --git a/packages/adapters/src/mdx-placeholders.ts b/packages/adapters/src/mdx-placeholders.ts new file mode 100644 index 0000000..9ce2caf --- /dev/null +++ b/packages/adapters/src/mdx-placeholders.ts @@ -0,0 +1,291 @@ +import type { NormalizedMdxRules } from "./mdx-rules.js"; +import type { TranslatableBlock } from "./traverse.js"; + +export class MdxPlaceholderError extends Error { + readonly _tag = "MdxPlaceholderError" as const; + + constructor(message: string) { + super(message); + this.name = "MdxPlaceholderError"; + } +} + +export interface InlineMdxPlaceholderAttribute { + id: string; + text: string; + start: number; + end: number; + quote: "'" | '"'; +} + +export type InlineMdxPlaceholder = + | { + id: string; + kind: "wrapper"; + opening: string; + closing: string; + attributes: InlineMdxPlaceholderAttribute[]; + } + | { + id: string; + kind: "opaque"; + source: string; + attributes: InlineMdxPlaceholderAttribute[]; + }; + +export interface ProtectedInlineMdxText { + text: string; + placeholders: InlineMdxPlaceholder[]; +} + +export function protectInlineMdxJsx( + block: TranslatableBlock, + source: string, + span: { start: number; end: number }, + rules: NormalizedMdxRules | undefined, +): ProtectedInlineMdxText | undefined { + const inlineNodes = readInlineMdxJsxNodes(block) + .map((node) => ({ node, span: readPositionSpan(node) })) + .filter((entry): entry is { node: MdxJsxTextNode; span: { start: number; end: number } } => entry.span !== undefined) + .filter((entry) => entry.span.start >= span.start && entry.span.end <= span.end) + .sort((a, b) => a.span.start - b.span.start); + if (inlineNodes.length === 0) return undefined; + + const placeholders: InlineMdxPlaceholder[] = []; + let cursor = span.start; + let text = ""; + inlineNodes.forEach((entry, index) => { + text += source.slice(cursor, entry.span.start); + const placeholder = buildPlaceholder(String(index), entry.node, entry.span, source, rules); + placeholders.push(placeholder.placeholder); + text += placeholder.text; + cursor = entry.span.end; + }); + return { text: text + source.slice(cursor, span.end), placeholders }; +} + +export function restoreInlineMdxPlaceholders( + value: string, + placeholders: readonly InlineMdxPlaceholder[], + translations?: ReadonlyMap | undefined, +): string { + let output = value; + for (const placeholder of placeholders) { + if (placeholder.kind === "wrapper") { + const pattern = new RegExp(`([\\s\\S]*?)<\\/ph>`, "g"); + let count = 0; + output = output.replace(pattern, (_match, inner: string) => { + count++; + const opening = applyInlinePlaceholderAttributeTranslations(placeholder.opening, placeholder.attributes, translations); + return `${opening}${inner}${placeholder.closing}`; + }); + if (count !== 1) { + throw new MdxPlaceholderError(`[polystella] translated segment lost or duplicated inline MDX placeholder id=${placeholder.id}`); + } + } else { + const pattern = new RegExp(``, "g"); + let count = 0; + output = output.replace(pattern, () => { + count++; + return applyInlinePlaceholderAttributeTranslations(placeholder.source, placeholder.attributes, translations); + }); + if (count !== 1) { + throw new MdxPlaceholderError(`[polystella] translated segment lost or duplicated inline MDX placeholder id=${placeholder.id}`); + } + } + } + if (/`, placeholder: { id, kind: "opaque", source: nodeSource, attributes } }; + } + const childSpan = readChildrenSpan(node.children); + if (childSpan === undefined || childSpan.start < nodeSpan.start || childSpan.end > nodeSpan.end) { + return { text: ``, placeholder: { id, kind: "opaque", source: nodeSource, attributes } }; + } + return { + text: `${source.slice(childSpan.start, childSpan.end)}`, + placeholder: { + id, + kind: "wrapper", + opening: source.slice(nodeSpan.start, childSpan.start), + closing: source.slice(childSpan.end, nodeSpan.end), + attributes, + }, + }; +} + +function collectPlaceholderAttributes( + node: MdxJsxTextNode, + source: string, + nodeSpan: { start: number; end: number }, + rules: NormalizedMdxRules | undefined, +): InlineMdxPlaceholderAttribute[] { + const allowed = allowedAttributesForElement(node.name, rules); + const attributes = readArrayProperty(node, "attributes"); + if (allowed.size === 0 || attributes === undefined) return []; + const output: InlineMdxPlaceholderAttribute[] = []; + for (const attribute of attributes) { + if (!isMdxJsxAttribute(attribute) || !allowed.has(attribute.name)) continue; + if (typeof attribute.value !== "string" || attribute.value.length === 0) continue; + const attributeSpan = readPositionSpan(attribute); + if (attributeSpan === undefined) continue; + const valueSpan = findQuotedAttributeValueSpan(source, attributeSpan); + if (valueSpan === undefined) continue; + output.push({ + id: `mdx:inline-attr:${node.name}.${attribute.name}:${valueSpan.start}`, + text: attribute.value, + start: valueSpan.start - nodeSpan.start, + end: valueSpan.end - nodeSpan.start, + quote: valueSpan.quote, + }); + } + return output; +} + +function applyInlinePlaceholderAttributeTranslations( + source: string, + attributes: readonly InlineMdxPlaceholderAttribute[], + translations: ReadonlyMap | undefined, +): string { + if (translations === undefined || attributes.length === 0) return source; + const edits: Array<{ start: number; end: number; replacement: string }> = []; + for (const attribute of attributes) { + if (attribute.start < 0 || attribute.end > source.length || attribute.end < attribute.start) continue; + const translation = translations.get(attribute.id); + if (translation === undefined) continue; + edits.push({ + start: attribute.start, + end: attribute.end, + replacement: escapeQuotedAttributeContent(translation, attribute.quote), + }); + } + edits.sort((a, b) => b.start - a.start); + let output = source; + for (const edit of edits) output = output.slice(0, edit.start) + edit.replacement + output.slice(edit.end); + return output; +} + +function allowedAttributesForElement(elementName: string, rules: NormalizedMdxRules | undefined): Set { + if (rules === undefined) return new Set(); + if (isLowercaseElementName(elementName)) { + return new Set([...(rules.htmlAttributes["*"] ?? []), ...(rules.htmlAttributes[elementName] ?? [])]); + } + return new Set(rules.components[elementName]?.props ?? []); +} + +function isLowercaseElementName(name: string): boolean { + const first = name[0]; + return first !== undefined && first.toLowerCase() === first; +} + +function findQuotedAttributeValueSpan( + source: string, + attributeSpan: { start: number; end: number }, +): { start: number; end: number; quote: "'" | '"' } | undefined { + const slice = source.slice(attributeSpan.start, attributeSpan.end); + const equalsIndex = slice.indexOf("="); + if (equalsIndex < 0) return undefined; + let quoteIndex = equalsIndex + 1; + while (quoteIndex < slice.length && /\s/.test(slice[quoteIndex] ?? "")) quoteIndex++; + const quote = slice[quoteIndex]; + if (quote !== "'" && quote !== '"') return undefined; + const valueStart = quoteIndex + 1; + const valueEnd = slice.indexOf(quote, valueStart); + if (valueEnd < 0) return undefined; + return { start: attributeSpan.start + valueStart, end: attributeSpan.start + valueEnd, quote }; +} + +function isMdxJsxAttribute(node: unknown): node is { type: string; name: string; value: unknown } { + if (typeof node !== "object" || node === null) return false; + const candidate = node as { type?: unknown; name?: unknown }; + return candidate.type === "mdxJsxAttribute" && typeof candidate.name === "string"; +} + +function readArrayProperty(node: unknown, property: string): unknown[] | undefined { + if (typeof node !== "object" || node === null) return undefined; + const value = (node as Record)[property]; + return Array.isArray(value) ? value : undefined; +} + +function escapeQuotedAttributeContent(value: string, quote: "'" | '"'): string { + let output = ""; + for (const char of value) { + switch (char) { + case "&": + output += "&"; + break; + case "'": + output += quote === "'" ? "'" : char; + break; + case '"': + output += quote === '"' ? """ : char; + break; + case "\n": + case "\r": + output += " "; + break; + default: + output += char; + } + } + return output; +} + +function shouldTreatAsOpaque(node: MdxJsxTextNode, rules: NormalizedMdxRules | undefined): boolean { + if (!Array.isArray(node.children) || node.children.length === 0) return true; + return rules?.components[node.name]?.children === false; +} + +function readInlineMdxJsxNodes(block: TranslatableBlock): MdxJsxTextNode[] { + const output: MdxJsxTextNode[] = []; + for (const child of block.children) { + if (isMdxJsxTextNode(child)) output.push(child); + } + return output; +} + +function isMdxJsxTextNode(node: unknown): node is MdxJsxTextNode { + if (typeof node !== "object" || node === null) return false; + const candidate = node as { type?: unknown; name?: unknown }; + return candidate.type === "mdxJsxTextElement" && typeof candidate.name === "string"; +} + +function readChildrenSpan(children: unknown[] | undefined): { start: number; end: number } | undefined { + if (!Array.isArray(children) || children.length === 0) return undefined; + const firstSpan = readPositionSpan(children[0]); + const lastSpan = readPositionSpan(children[children.length - 1]); + return firstSpan !== undefined && lastSpan !== undefined ? { start: firstSpan.start, end: lastSpan.end } : undefined; +} + +function readPositionSpan(node: unknown): { start: number; end: number } | undefined { + if (typeof node !== "object" || node === null) return undefined; + const position = (node as { position?: { start?: { offset?: unknown }; end?: { offset?: unknown } } }).position; + const start = position?.start?.offset; + const end = position?.end?.offset; + return typeof start === "number" && typeof end === "number" ? { start, end } : undefined; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/adapters/src/mdx-rules.ts b/packages/adapters/src/mdx-rules.ts new file mode 100644 index 0000000..919a064 --- /dev/null +++ b/packages/adapters/src/mdx-rules.ts @@ -0,0 +1,13 @@ +export const MDX_RULES_VERSION = "mdx-rules-v1"; + +export interface NormalizedMdxComponentRule { + children?: boolean | undefined; + props: string[]; +} + +export interface NormalizedMdxRules { + version: typeof MDX_RULES_VERSION; + htmlAttributes: Record; + components: Record; + data: Record>; +} diff --git a/packages/adapters/src/mdx-static-data.ts b/packages/adapters/src/mdx-static-data.ts new file mode 100644 index 0000000..33077c0 --- /dev/null +++ b/packages/adapters/src/mdx-static-data.ts @@ -0,0 +1,297 @@ +import type { Root } from "mdast"; +import picomatch from "picomatch"; + +import type { MarkdownCollectedSegment } from "./extract.js"; +import type { NormalizedMdxRules } from "./mdx-rules.js"; + +export interface CollectMdxStaticDataOptions { + sourcePath: string; + mdxRules: NormalizedMdxRules; +} + +interface BindingRule { + bindingName: string; + pathSpecs: string[]; +} + +interface TranslateDirective { + rangeEnd: number; + pathSpecs: string[]; +} + +interface LiteralRoot { + node: unknown; + range: { start: number; end: number }; +} + +const matcherCache = new Map boolean>(); + +export function collectMdxStaticDataSegments(ast: Root, source: string, options: CollectMdxStaticDataOptions): MarkdownCollectedSegment[] { + const rulesByBinding = new Map( + resolveBindingRules(options.mdxRules.data, options.sourcePath).map((rule) => [rule.bindingName, rule.pathSpecs]), + ); + const output: MarkdownCollectedSegment[] = []; + const occupiedSpans = new Set(); + for (const program of readEstreePrograms(ast)) { + collectAnnotatedStaticData({ program, source, output, occupiedSpans }); + for (const declarator of readVariableDeclarators(program)) { + const bindingName = readIdentifierName(readProperty(declarator, "id")); + if (bindingName === undefined) continue; + const pathSpecs = rulesByBinding.get(bindingName); + if (pathSpecs === undefined) continue; + collectFromStaticLiteral({ + idPrefix: `mdx:data:${bindingName}`, + node: readProperty(declarator, "init"), + path: "", + pathSpecs, + source, + output, + occupiedSpans, + }); + } + } + return output; +} + +function resolveBindingRules(dataRules: NormalizedMdxRules["data"], sourcePath: string): BindingRule[] { + const merged = new Map(); + for (const [pattern, bindings] of Object.entries(dataRules)) { + if (!getMatcher(pattern)(sourcePath)) continue; + for (const [bindingName, paths] of Object.entries(bindings)) { + const existing = merged.get(bindingName) ?? []; + for (const path of paths) { + if (!existing.includes(path)) existing.push(path); + } + merged.set(bindingName, existing); + } + } + return [...merged].map(([bindingName, pathSpecs]) => ({ bindingName, pathSpecs })); +} + +function collectFromStaticLiteral(args: { + idPrefix: string; + node: unknown; + path: string; + pathSpecs: string[]; + source: string; + output: MarkdownCollectedSegment[]; + occupiedSpans: Set; +}): void { + if (!isNode(args.node)) return; + if (args.node.type === "ArrayExpression") { + const elements = readArrayProperty(args.node, "elements"); + if (elements === undefined) return; + elements.forEach((element, index) => { + if (element !== null) collectFromStaticLiteral({ ...args, node: element, path: `${args.path}[${index}]` }); + }); + return; + } + if (args.node.type === "ObjectExpression") { + const properties = readArrayProperty(args.node, "properties"); + if (properties === undefined) return; + for (const property of properties) { + if (!isNode(property) || property.type !== "Property" || readBooleanProperty(property, "computed") === true) continue; + const key = readPropertyKey(readProperty(property, "key")); + if (key === undefined) continue; + const path = args.path.length > 0 ? `${args.path}.${key}` : key; + collectFromStaticLiteral({ ...args, node: readProperty(property, "value"), path }); + } + return; + } + if (args.node.type !== "Literal") return; + const value = readProperty(args.node, "value"); + if (typeof value !== "string" || value.length === 0 || !pathMatches(args.path, args.pathSpecs)) return; + const range = readRange(args.node); + if (range === undefined) return; + const quote = readStringQuote(readStringProperty(args.node, "raw") ?? args.source.slice(range.start, range.end)); + if (quote === undefined) return; + const span = { start: range.start + 1, end: range.end - 1 }; + const spanKey = `${span.start}:${span.end}`; + if (args.occupiedSpans.has(spanKey)) return; + args.occupiedSpans.add(spanKey); + args.output.push({ + segment: { id: `${args.idPrefix}${args.path}`, text: value }, + kind: "mdx-static-data", + span, + replacement: { kind: "js-string", quote }, + }); +} + +function collectAnnotatedStaticData(args: { + program: unknown; + source: string; + output: MarkdownCollectedSegment[]; + occupiedSpans: Set; +}): void { + const roots = readLiteralRoots(args.program); + for (const directive of readTranslateDirectives(args.program)) { + const root = roots.filter((candidate) => candidate.range.start >= directive.rangeEnd).sort((a, b) => a.range.start - b.range.start)[0]; + if (root === undefined) continue; + collectFromStaticLiteral({ + idPrefix: `mdx:annotation:${root.range.start}`, + node: root.node, + path: "", + pathSpecs: expandAnnotationPathSpecs(directive.pathSpecs, root.node), + source: args.source, + output: args.output, + occupiedSpans: args.occupiedSpans, + }); + } +} + +function expandAnnotationPathSpecs(pathSpecs: string[], root: unknown): string[] { + if (!isNode(root) || root.type !== "ArrayExpression") return pathSpecs; + const expanded: string[] = []; + for (const spec of pathSpecs) { + expanded.push(spec); + if (!spec.startsWith("[") && !spec.includes("[]")) expanded.push(`[].${spec}`); + } + return expanded; +} + +function readTranslateDirectives(program: unknown): TranslateDirective[] { + const output: TranslateDirective[] = []; + for (const comment of readArrayProperty(program, "comments") ?? []) { + const value = readStringProperty(comment, "value"); + const range = readRange(comment); + if (value === undefined || range === undefined) continue; + const pathSpecs = parseTranslateDirective(value); + if (pathSpecs.length > 0) output.push({ rangeEnd: range.end, pathSpecs }); + } + return output.sort((a, b) => a.rangeEnd - b.rangeEnd); +} + +function parseTranslateDirective(value: string): string[] { + const cleaned = value + .split("\n") + .map((line) => line.replace(/^\s*\*\s?/, "").trim()) + .join("\n"); + const paths = /@polystella\s+translate\b([\s\S]*)/m.exec(cleaned)?.[1]?.trim(); + if (!paths) return []; + return paths + .split(/[\n,]/) + .map((part) => part.replace(/^\s*-\s*/, "").trim()) + .filter((part) => part.length > 0); +} + +function readLiteralRoots(program: unknown): LiteralRoot[] { + const roots: LiteralRoot[] = []; + walkUnknown(program, (node) => { + if (!isNode(node) || (node.type !== "ArrayExpression" && node.type !== "ObjectExpression")) return; + const range = readRange(node); + if (range !== undefined) roots.push({ node, range }); + }); + return roots; +} + +function walkUnknown(value: unknown, visitor: (node: unknown) => void): void { + if (typeof value !== "object" || value === null) return; + visitor(value); + if (Array.isArray(value)) { + for (const item of value) walkUnknown(item, visitor); + return; + } + for (const child of Object.values(value as Record)) walkUnknown(child, visitor); +} + +function pathMatches(actualPath: string, specs: readonly string[]): boolean { + return specs.some((spec) => pathSpecToRegExp(spec).test(actualPath)); +} + +function pathSpecToRegExp(spec: string): RegExp { + const escaped = spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^${escaped.replace(/\\\[\\\]/g, "\\[\\d+\\]")}$`); +} + +function readEstreePrograms(root: unknown): unknown[] { + const programs: unknown[] = []; + walkUnknown(root, (node) => { + const program = readEstreeProgram(node); + if (program !== undefined && !programs.includes(program)) programs.push(program); + }); + return programs; +} + +function readEstreeProgram(node: unknown): unknown | undefined { + if (typeof node !== "object" || node === null) return undefined; + const data = (node as { data?: unknown }).data; + if (typeof data !== "object" || data === null) return undefined; + const estree = (data as { estree?: unknown }).estree; + return isNode(estree) && estree.type === "Program" ? estree : undefined; +} + +function readVariableDeclarators(program: unknown): unknown[] { + if (!isNode(program)) return []; + const output: unknown[] = []; + for (const statement of readArrayProperty(program, "body") ?? []) { + const declaration = unwrapVariableDeclaration(statement); + if (declaration !== undefined) output.push(...(readArrayProperty(declaration, "declarations") ?? [])); + } + return output; +} + +function unwrapVariableDeclaration(statement: unknown): unknown | undefined { + if (!isNode(statement)) return undefined; + if (statement.type === "VariableDeclaration") return statement; + if (statement.type !== "ExportNamedDeclaration") return undefined; + const declaration = readProperty(statement, "declaration"); + return isNode(declaration) && declaration.type === "VariableDeclaration" ? declaration : undefined; +} + +function readPropertyKey(key: unknown): string | undefined { + const identifier = readIdentifierName(key); + if (identifier !== undefined) return identifier; + if (!isNode(key) || key.type !== "Literal") return undefined; + const value = readProperty(key, "value"); + return typeof value === "string" ? value : undefined; +} + +function readIdentifierName(node: unknown): string | undefined { + return isNode(node) && node.type === "Identifier" ? readStringProperty(node, "name") : undefined; +} + +function readStringQuote(raw: string): "'" | '"' | undefined { + const first = raw[0]; + const last = raw[raw.length - 1]; + return raw.length >= 2 && (first === "'" || first === '"') && first === last ? first : undefined; +} + +function readRange(node: unknown): { start: number; end: number } | undefined { + if (typeof node !== "object" || node === null) return undefined; + const range = (node as { range?: unknown }).range; + if (!Array.isArray(range) || range.length < 2) return undefined; + const start = range[0]; + const end = range[1]; + return typeof start === "number" && typeof end === "number" ? { start, end } : undefined; +} + +function isNode(node: unknown): node is { type: string } { + return typeof node === "object" && node !== null && typeof (node as { type?: unknown }).type === "string"; +} + +function readProperty(node: unknown, property: string): unknown { + return typeof node === "object" && node !== null ? (node as Record)[property] : undefined; +} + +function readArrayProperty(node: unknown, property: string): unknown[] | undefined { + const value = readProperty(node, property); + return Array.isArray(value) ? value : undefined; +} + +function readStringProperty(node: unknown, property: string): string | undefined { + const value = readProperty(node, property); + return typeof value === "string" ? value : undefined; +} + +function readBooleanProperty(node: unknown, property: string): boolean | undefined { + const value = readProperty(node, property); + return typeof value === "boolean" ? value : undefined; +} + +function getMatcher(pattern: string): (path: string) => boolean { + const cached = matcherCache.get(pattern); + if (cached !== undefined) return cached; + const matcher = picomatch(pattern); + matcherCache.set(pattern, matcher); + return matcher; +} diff --git a/packages/adapters/src/parser.ts b/packages/adapters/src/parser.ts new file mode 100644 index 0000000..7bb54a7 --- /dev/null +++ b/packages/adapters/src/parser.ts @@ -0,0 +1,26 @@ +import type { Root } from "mdast"; +import remarkFrontmatter from "remark-frontmatter"; +import remarkGfm from "remark-gfm"; +import remarkMdx from "remark-mdx"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + +export interface MarkdownParser { + parseMarkdown(source: string): Root; + parseMdx(source: string): Root; +} + +const createMarkdownProcessor = () => unified().use(remarkParse).use(remarkFrontmatter, ["yaml"]).use(remarkGfm); +const createMdxProcessor = () => createMarkdownProcessor().use(remarkMdx); + +export const remarkMarkdownParser: MarkdownParser = { + parseMarkdown(source) { + return createMarkdownProcessor().parse(source) as Root; + }, + parseMdx(source) { + return createMdxProcessor().parse(source) as Root; + }, +}; + +export const parseMarkdown = (source: string): Root => remarkMarkdownParser.parseMarkdown(source); +export const parseMdx = (source: string): Root => remarkMarkdownParser.parseMdx(source); diff --git a/packages/adapters/src/traverse.ts b/packages/adapters/src/traverse.ts new file mode 100644 index 0000000..d1edece --- /dev/null +++ b/packages/adapters/src/traverse.ts @@ -0,0 +1,48 @@ +import type { Heading, Paragraph, Root, TableCell } from "mdast"; + +const TRANSLATABLE_BLOCK_TYPES = new Set(["paragraph", "heading", "tableCell"]); +const RECURSE_INTO_TYPES = new Set([ + "root", + "blockquote", + "list", + "listItem", + "table", + "tableRow", + "footnoteDefinition", + "mdxJsxFlowElement", +]); + +export type TranslatableBlock = Paragraph | Heading | TableCell; + +export interface BlockVisit { + block: TranslatableBlock; + id: string; +} + +export function visitTranslatableBlocks(ast: Root, visitor: (visit: BlockVisit) => void): void { + let index = 0; + const walk = (node: unknown): void => { + if (!isMdastLikeNode(node)) return; + if (TRANSLATABLE_BLOCK_TYPES.has(node.type)) { + visitor({ block: node as TranslatableBlock, id: `body:${index}` }); + index++; + return; + } + if (RECURSE_INTO_TYPES.has(node.type) && Array.isArray(node.children)) { + for (const child of node.children) walk(child); + } + }; + walk(ast); +} + +export function inlineSpan(block: TranslatableBlock): { start: number; end: number } | undefined { + const first = block.children[0]; + const last = block.children.at(-1); + const start = first?.position?.start?.offset; + const end = last?.position?.end?.offset; + return typeof start === "number" && typeof end === "number" ? { start, end } : undefined; +} + +function isMdastLikeNode(node: unknown): node is { type: string; children?: unknown[] } { + return typeof node === "object" && node !== null && typeof (node as { type?: unknown }).type === "string"; +} diff --git a/packages/adapters/tests/key-paths.test.ts b/packages/adapters/tests/key-paths.test.ts new file mode 100644 index 0000000..b831e8a --- /dev/null +++ b/packages/adapters/tests/key-paths.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { expandPath, formatPath, parsePath, readAtPath, resolveConcretePaths, writeAtPath } from "../src/index.js"; + +describe("key paths", () => { + it("parses and formats dotted, indexed, and wildcard paths", () => { + expect(parsePath("items[3].title")).toEqual({ segments: ["items", 3, "title"], hasWildcard: false }); + expect(parsePath("paths.*.summary")).toEqual({ segments: ["paths", "*", "summary"], hasWildcard: true }); + expect(formatPath(["items", 3, "title"])).toBe("items[3].title"); + }); + + it("rejects malformed paths", () => { + for (const path of ["", ".title", "a..b", "a.", "a[nope]", "a[0"]) { + expect(() => parsePath(path)).toThrow(); + } + }); + + it("expands composed array and object wildcards in source order", () => { + const data = { + paths: { + first: [{ summary: "a" }, { summary: "b" }], + second: [{ summary: "c" }], + }, + }; + expect(expandPath("paths.*[*].summary", data)).toEqual(["paths.first[0].summary", "paths.first[1].summary", "paths.second[0].summary"]); + }); + + it("reads and writes concrete paths", () => { + const data = { nested: { items: [{ title: "before" }] } }; + const path = ["nested", "items", 0, "title"] as const; + expect(readAtPath(data, path)).toBe("before"); + writeAtPath(data, path, "after"); + expect(readAtPath(data, path)).toBe("after"); + }); + + it("unions matching glob rules and deduplicates concrete paths", () => { + const paths = resolveConcretePaths({ + parsed: { items: [{ title: "a" }, { title: "b" }] }, + sourcePath: "data/items.json", + translatableKeys: { + "data/**": ["items[*].title"], + "**/*.json": ["items[0].title"], + }, + }); + expect(paths).toEqual(["items[0].title", "items[1].title"]); + }); + + it("blocks prototype-chain traversal and writes", () => { + for (const reserved of ["__proto__", "prototype", "constructor"]) { + expect(() => parsePath(`a.${reserved}.value`)).toThrow(/reserved/); + } + expect(readAtPath({}, ["__proto__"])).toBeUndefined(); + expect(() => writeAtPath({}, ["__proto__"], { polluted: true })).toThrow(/reserved/); + expect(({} as Record).polluted).toBeUndefined(); + }); +}); diff --git a/packages/adapters/tests/markdown.test.ts b/packages/adapters/tests/markdown.test.ts new file mode 100644 index 0000000..948bf4c --- /dev/null +++ b/packages/adapters/tests/markdown.test.ts @@ -0,0 +1,130 @@ +import type { Root } from "mdast"; +import { describe, expect, it, vi } from "vitest"; + +import { + createMarkdownAdapter, + markdownAdapter, + parseMarkdown, + parseMdx, + remarkMarkdownParser, + type MarkdownParser, +} from "../src/index.js"; + +describe("portable parser", () => { + it("keeps Markdown and MDX syntax distinct", () => { + expect(parseMarkdown('import Component from "./component.js";\n').children[0]?.type).toBe("paragraph"); + expect(parseMdx('import Component from "./component.js";\n').children[0]?.type).toBe("mdxjsEsm"); + expect(parseMarkdown("\n").children[0]?.type).toBe("html"); + expect(parseMdx("\n").children[0]?.type).toBe("mdxJsxFlowElement"); + }); + + it("dispatches through an injected parser by extension", () => { + const markdownRoot = { type: "root", children: [] } as Root; + const mdxRoot = { type: "root", children: [] } as Root; + const parser: MarkdownParser = { + parseMarkdown: vi.fn(() => markdownRoot), + parseMdx: vi.fn(() => mdxRoot), + }; + const adapter = createMarkdownAdapter(parser); + expect(adapter.parse("source", "file.md")).toBe(markdownRoot); + expect(adapter.parse("source", "file.MDX")).toBe(mdxRoot); + expect(parser.parseMarkdown).toHaveBeenCalledOnce(); + expect(parser.parseMdx).toHaveBeenCalledOnce(); + }); + + it("uses Remark by default", () => { + expect(markdownAdapter.parse("# Heading", "file.md")).toEqual(remarkMarkdownParser.parseMarkdown("# Heading")); + }); +}); + +describe("Markdown reconstruction", () => { + const source = [ + "---", + "title: Hello", + "tags:", + " - First", + " - Second", + "---", + "", + "# Heading", + "", + "A **formatted** [paragraph](/docs).", + ].join("\n"); + const options = { + sourcePath: "docs/example.md", + translatableKeys: { "docs/**": ["title", "tags"] }, + }; + + it("extracts stable body and frontmatter IDs and applies only their spans", () => { + const parsed = markdownAdapter.parse(source, options.sourcePath); + const segments = markdownAdapter.extractSegments(parsed, source, options); + expect(segments).toEqual([ + { id: "body:0", text: "Heading" }, + { id: "body:1", text: "A **formatted** [paragraph](/docs)." }, + { id: "fm:title", text: "Hello" }, + { id: "fm:tags[0]", text: "First" }, + { id: "fm:tags[1]", text: "Second" }, + ]); + const output = markdownAdapter.applyTranslations( + parsed, + source, + new Map(segments.map((segment) => [segment.id, `X:${segment.text}`])), + { topLevelAdditions: { aiTranslated: true } }, + ); + expect(output).toContain("title: X:Hello"); + expect(output).toContain("- X:First"); + expect(output).toContain("aiTranslated: true"); + expect(output).toContain("# X:Heading"); + expect(output).toContain("X:A **formatted** [paragraph](/docs)."); + }); + + it("returns source bytes unchanged without translations or additions", () => { + const parsed = markdownAdapter.parse(source, options.sourcePath); + expect(markdownAdapter.applyTranslations(parsed, source, new Map())).toBe(source); + }); + + it("applies generic additions idempotently", () => { + const once = markdownAdapter.applyTranslations(markdownAdapter.parse(source, options.sourcePath), source, new Map(), { + topLevelAdditions: { aiTranslated: true }, + }); + const twice = markdownAdapter.applyTranslations(markdownAdapter.parse(once, options.sourcePath), once, new Map(), { + topLevelAdditions: { aiTranslated: true }, + }); + expect(twice).toBe(once); + }); + + it("adds generic frontmatter fields when existing frontmatter is empty", () => { + const source = "---\n---\n\nBody.\n"; + const output = markdownAdapter.applyTranslations(markdownAdapter.parse(source, options.sourcePath), source, new Map(), { + topLevelAdditions: { aiTranslated: true }, + }); + expect(output).toBe("---\naiTranslated: true\n---\n\nBody.\n"); + }); + + it("groups by heading while preserving reference identity and order", () => { + const groupedSource = "Lede.\n\n## First\n\nBody one.\n\n## Second\n\nBody two.\n"; + const parsed = markdownAdapter.parse(groupedSource, "docs/grouped.md"); + const segments = markdownAdapter.extractSegments(parsed, groupedSource, { + sourcePath: "docs/grouped.md", + translatableKeys: {}, + }); + const groups = markdownAdapter.groupSegments?.(parsed, segments) ?? []; + expect(groups.map((group) => group.map((segment) => segment.id))).toEqual([["body:0"], ["body:1", "body:2"], ["body:3", "body:4"]]); + expect(groups.flat().every((segment, index) => segment === segments[index])).toBe(true); + }); + + it.each([ + ["lists", "- First item\n- Second item with **bold** text.\n"], + ["blockquote", "> Quoted text.\n>\n> Second paragraph.\n"], + ["table", "| Name | Value |\n| --- | --- |\n| One | Two |\n"], + ["footnote", "Text with a footnote.[^1]\n\n[^1]: Footnote body.\n"], + ["code", "Paragraph.\n\n```ts\nconst value = 1;\n```\n"], + ["html", "\n"], + ["autolink", "See for details.\n"], + ["escapes", "Literal \\[brackets\\], S&P, and `inline code`.\n"], + ])("round-trips representative %s syntax byte-for-byte", (_name, fixture) => { + const parsed = markdownAdapter.parse(fixture, "docs/fixture.md"); + markdownAdapter.extractSegments(parsed, fixture, { sourcePath: "docs/fixture.md", translatableKeys: {} }); + expect(markdownAdapter.applyTranslations(parsed, fixture, new Map())).toBe(fixture); + }); +}); diff --git a/packages/adapters/tests/mdx.test.ts b/packages/adapters/tests/mdx.test.ts new file mode 100644 index 0000000..7c5ff5b --- /dev/null +++ b/packages/adapters/tests/mdx.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; + +import { markdownAdapter, MDX_RULES_VERSION, MdxPlaceholderError, type NormalizedMdxRules } from "../src/index.js"; + +const sourcePath = "docs/example.mdx"; +const rules: NormalizedMdxRules = { + version: MDX_RULES_VERSION, + htmlAttributes: { "*": ["alt", "title", "aria-label", "placeholder"] }, + components: { + Callout: { props: ["title"] }, + Icon: { children: false, props: ["label"] }, + }, + data: { + "docs/**": { features: ["[].title", "[].description"] }, + }, +}; + +const options = { sourcePath, translatableKeys: {}, mdxRules: rules }; + +describe("MDX reconstruction", () => { + it("preserves ESM and JSX while translating prose and configured static data", () => { + const source = [ + 'import Grid from "../Grid.astro";', + "", + "export const features = [", + ' { title: "Fast setup", description: "Start quickly", icon: "rocket" },', + "];", + "", + "", + "", + "# Feature list", + "", + "Body prose.", + "", + "", + "", + ].join("\n"); + const parsed = markdownAdapter.parse(source, sourcePath); + const segments = markdownAdapter.extractSegments(parsed, source, options); + expect(segments.map(({ id, text }) => `${id}=${text}`)).toEqual([ + "body:0=Feature list", + "body:1=Body prose.", + "mdx:data:features[0].title=Fast setup", + "mdx:data:features[0].description=Start quickly", + ]); + const output = markdownAdapter.applyTranslations( + parsed, + source, + new Map(segments.map((segment) => [segment.id, `X:${segment.text}`])), + { sourcePath, mdxRules: rules }, + ); + expect(output).toContain('import Grid from "../Grid.astro";'); + expect(output).toContain('title: "X:Fast setup"'); + expect(output).toContain('description: "X:Start quickly"'); + expect(output).toContain('icon: "rocket"'); + expect(output).toContain("# X:Feature list"); + }); + + it("extracts and safely applies configured flow-element attributes", () => { + const source = 'Diagram\n\n'; + const parsed = markdownAdapter.parse(source, sourcePath); + const segments = markdownAdapter.extractSegments(parsed, source, options); + expect(segments.map((segment) => segment.text)).toEqual(["Diagram", "Notice"]); + const output = markdownAdapter.applyTranslations( + parsed, + source, + new Map(segments.map((segment) => [segment.id, `${segment.text} & "translated"`])), + { sourcePath, mdxRules: rules }, + ); + expect(output).toContain('alt="Diagram & "translated""'); + expect(output).toContain("title='Notice & \"translated\"'"); + expect(output).toContain('src="/diagram.png"'); + }); + + it("protects inline wrappers and opaque components, including configured props", () => { + const source = 'This is new; click .\n'; + const parsed = markdownAdapter.parse(source, sourcePath); + const segments = markdownAdapter.extractSegments(parsed, source, options); + expect(segments[0]).toEqual({ id: "body:0", text: 'This is new; click .' }); + const label = segments.find((segment) => segment.id.startsWith("mdx:inline-attr:Icon.label:")); + expect(label?.text).toBe("Download"); + const output = markdownAdapter.applyTranslations( + parsed, + source, + new Map([ + ["body:0", 'Clique ; isto e novo.'], + [label?.id ?? "", "Baixar"], + ]), + { sourcePath, mdxRules: rules }, + ); + expect(output).toBe('Clique ; isto e novo.\n'); + }); + + it("rejects lost or duplicated inline placeholders", () => { + const source = "This is new.\n"; + const parsed = markdownAdapter.parse(source, sourcePath); + expect(() => + markdownAdapter.applyTranslations(parsed, source, new Map([["body:0", "No placeholder"]]), { + sourcePath, + mdxRules: rules, + }), + ).toThrow(MdxPlaceholderError); + }); + + it("extracts annotation-selected static literals", () => { + const source = + 'export const cards = /** @polystella translate title, description */ [{ title: "Local", description: "Body", icon: "box" }];\n'; + const parsed = markdownAdapter.parse(source, sourcePath); + const segments = markdownAdapter.extractSegments(parsed, source, options); + expect(segments.map((segment) => segment.text)).toEqual(["Local", "Body"]); + }); + + it("escapes static-data translations for their original quote style", () => { + const source = "export const features = [{ title: \"Fast\", description: 'Start' }];\n"; + const parsed = markdownAdapter.parse(source, sourcePath); + const segments = markdownAdapter.extractSegments(parsed, source, options); + const output = markdownAdapter.applyTranslations( + parsed, + source, + new Map([ + [segments[0]?.id ?? "", 'Use "fast"'], + [segments[1]?.id ?? "", "Don't wait"], + ]), + { sourcePath, mdxRules: rules }, + ); + expect(output).toContain('title: "Use \\"fast\\""'); + expect(output).toContain("description: 'Don\\'t wait'"); + }); + + it("groups MDX prose, static data, and frontmatter without cloning or reordering", () => { + const source = [ + "---", + "title: Example", + "---", + "", + 'export const features = [{ title: "Fast", description: "Start" }];', + "", + "# Heading", + "", + "Body.", + ].join("\n"); + const parsed = markdownAdapter.parse(source, sourcePath); + const segments = markdownAdapter.extractSegments(parsed, source, { + ...options, + translatableKeys: { "docs/**": ["title"] }, + }); + const groups = markdownAdapter.groupSegments?.(parsed, segments) ?? []; + expect(groups.flat().map((segment) => segment.id)).toEqual(segments.map((segment) => segment.id)); + expect(groups.flat().every((segment, index) => segment === segments[index])).toBe(true); + expect(groups.at(-1)?.map((segment) => segment.id)).toEqual(["fm:title"]); + }); +}); diff --git a/packages/adapters/tests/structured-adapters.test.ts b/packages/adapters/tests/structured-adapters.test.ts new file mode 100644 index 0000000..c8ff665 --- /dev/null +++ b/packages/adapters/tests/structured-adapters.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +import { jsonAdapter, tomlAdapter, yamlAdapter, type FileAdapter } from "../src/index.js"; + +interface FormatCase { + name: string; + adapter: FileAdapter; + source: string; + expected: string; +} + +const formatCases: FormatCase[] = [ + { + name: "JSON", + adapter: jsonAdapter, + source: '{"title":"Hello","nested":{"body":"World"}}', + expected: '{\n "title": "X:Hello",\n "nested": {\n "body": "X:World",\n "aiTranslated": true\n }\n}', + }, + { + name: "YAML", + adapter: yamlAdapter, + source: "title: Hello\nnested:\n body: World\n", + expected: "title: X:Hello\nnested:\n body: X:World\n aiTranslated: true\n", + }, + { + name: "TOML", + adapter: tomlAdapter as FileAdapter, + source: 'title = "Hello"\n\n[nested]\nbody = "World"\n', + expected: 'title = "X:Hello"\n\n[nested]\nbody = "X:World"\naiTranslated = true\n', + }, +]; + +describe.each(formatCases)("$name adapter", ({ adapter, source, expected }) => { + const options = { sourcePath: "content/entry.data", translatableKeys: { "content/**": ["title", "nested.body"] } }; + + it("matches the Step 1 reconstruction fixture", () => { + const parsed = adapter.parse(source); + const segments = adapter.extractSegments(parsed, source, options); + expect(segments).toEqual([ + { id: "title", text: "Hello" }, + { id: "nested.body", text: "World" }, + ]); + const translations = new Map(segments.map((segment) => [segment.id, `X:${segment.text}`])); + expect(adapter.applyTranslations(parsed, source, translations, { topLevelAdditions: { aiTranslated: true } })).toBe(expected); + }); + + it("does not mutate parsed input and generic additions are idempotent", () => { + const parsed = adapter.parse(source); + const snapshot = structuredClone(parsed); + const once = adapter.applyTranslations(parsed, source, new Map(), { topLevelAdditions: { aiTranslated: true } }); + expect(parsed).toEqual(snapshot); + const twice = adapter.applyTranslations(adapter.parse(once), once, new Map(), { topLevelAdditions: { aiTranslated: true } }); + expect(twice).toBe(once); + }); +}); + +describe("structured wildcard extraction", () => { + it.each([ + [jsonAdapter, '{"items":[{"title":"First"},{"title":"Second"}]}'], + [yamlAdapter, "items:\n - title: First\n - title: Second\n"], + [tomlAdapter, '[[items]]\ntitle = "First"\n\n[[items]]\ntitle = "Second"\n'], + ] as const)("expands wildcards and ignores non-string fields", (adapter, source) => { + const portableAdapter = adapter as FileAdapter; + const parsed = portableAdapter.parse(source); + const segments = portableAdapter.extractSegments(parsed, source, { + sourcePath: "data/items", + translatableKeys: { "data/**": ["items[*].title", "items[*].missing"] }, + }); + expect(segments).toEqual([ + { id: "items[0].title", text: "First" }, + { id: "items[1].title", text: "Second" }, + ]); + }); + + it("injects generic additions into top-level JSON and YAML sequence entries", () => { + for (const [adapter, source] of [ + [jsonAdapter, '[{"id":"a","title":"First"},{"id":"b","title":"Second"}]'], + [yamlAdapter, "- id: a\n title: First\n- id: b\n title: Second\n"], + ] as const) { + const portableAdapter = adapter as FileAdapter; + const output = portableAdapter.applyTranslations(portableAdapter.parse(source), source, new Map(), { + topLevelAdditions: { aiTranslated: true }, + }); + expect(output.match(/aiTranslated/g)).toHaveLength(2); + } + }); + + it("leaves scalar roots valid and rejects invalid translation paths", () => { + expect(jsonAdapter.applyTranslations(jsonAdapter.parse('"value"'), '"value"', new Map(), { topLevelAdditions: { marker: true } })).toBe( + '"value"', + ); + expect(yamlAdapter.applyTranslations(yamlAdapter.parse("value\n"), "value\n", new Map(), { topLevelAdditions: { marker: true } })).toBe( + "value\n", + ); + const parsed = jsonAdapter.parse('{"entry":{"title":"Hello"}}'); + expect(() => jsonAdapter.applyTranslations(parsed, "", new Map([["entry.missing.title", "X"]]))).toThrow(/null\/undefined/); + }); +}); diff --git a/packages/adapters/tsconfig.build.json b/packages/adapters/tsconfig.build.json new file mode 100644 index 0000000..2bf2ce3 --- /dev/null +++ b/packages/adapters/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["tests/**", "**/*.test.ts", "dist/**"] +} diff --git a/packages/adapters/tsconfig.json b/packages/adapters/tsconfig.json new file mode 100644 index 0000000..7588060 --- /dev/null +++ b/packages/adapters/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "tests/**/*"] +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..640c1a3 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,49 @@ +{ + "name": "@cloudflare/polystella-core", + "version": "0.4.0", + "type": "module", + "description": "Platform-neutral translation primitives for PolyStella.", + "license": "MIT", + "homepage": "https://github.com/cloudflare/polystella", + "repository": { + "type": "git", + "url": "git+https://github.com/cloudflare/polystella.git", + "directory": "packages/core" + }, + "bugs": { + "url": "https://github.com/cloudflare/polystella/issues" + }, + "keywords": [ + "i18n", + "localization", + "translation", + "ai", + "cloudflare" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "test": "vitest run", + "build": "tsc -p tsconfig.build.json" + }, + "dependencies": { + "p-retry": "^8.0.0" + }, + "devDependencies": { + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/packages/core/src/batch.ts b/packages/core/src/batch.ts new file mode 100644 index 0000000..b7a2eb0 --- /dev/null +++ b/packages/core/src/batch.ts @@ -0,0 +1,68 @@ +import type { Logger } from "./logger.js"; +import type { Segment } from "./segment.js"; + +export const DEFAULT_INPUT_TOKEN_BUDGET = 4000; + +const TOKEN_CHAR_RATIO = 4; +const SEGMENT_OVERHEAD_CHARS = 8; + +export function estimateInputTokens(segments: Segment[]): number { + if (segments.length === 0) return 0; + let chars = 0; + for (const segment of segments) { + chars += segment.id.length + segment.text.length + SEGMENT_OVERHEAD_CHARS; + } + return Math.ceil(chars / TOKEN_CHAR_RATIO); +} + +export interface PackGroupsIntoBatchesOptions { + inputTokenBudget?: number; + logger?: Logger; + sourcePath?: string; +} + +export function packGroupsIntoBatches(groups: Segment[][], options: PackGroupsIntoBatchesOptions = {}): Segment[][] { + const budget = options.inputTokenBudget ?? DEFAULT_INPUT_TOKEN_BUDGET; + const batches: Segment[][] = []; + let currentBatch: Segment[] = []; + let currentTokens = 0; + + const flushCurrent = (): void => { + if (currentBatch.length > 0) { + batches.push(currentBatch); + currentBatch = []; + currentTokens = 0; + } + }; + + for (const group of groups) { + if (group.length === 0) continue; + const groupTokens = estimateInputTokens(group); + + if (groupTokens > budget) { + flushCurrent(); + options.logger?.warn( + `[polystella] section in ${options.sourcePath ?? ""} exceeds batch input-token budget (${groupTokens} > ${budget}); splitting paragraph-by-paragraph — heading anchor is lost for sub-batches past the first`, + ); + for (const segment of group) { + const segmentTokens = estimateInputTokens([segment]); + if (currentTokens + segmentTokens <= budget) { + currentBatch.push(segment); + currentTokens += segmentTokens; + } else { + flushCurrent(); + currentBatch.push(segment); + currentTokens = segmentTokens; + } + } + continue; + } + + if (currentTokens + groupTokens > budget) flushCurrent(); + currentBatch.push(...group); + currentTokens += groupTokens; + } + + flushCurrent(); + return batches; +} diff --git a/packages/core/src/glossary.ts b/packages/core/src/glossary.ts new file mode 100644 index 0000000..df6f730 --- /dev/null +++ b/packages/core/src/glossary.ts @@ -0,0 +1,21 @@ +export interface StyleRule { + category: string; + instruction: string; + example?: string; +} + +export interface Glossary { + version: string; + doNotTranslate: string[]; + preferredTranslations: Record; + styleRules: StyleRule[]; + notes: string; +} + +export const EMPTY_GLOSSARY: Glossary = { + version: "", + doNotTranslate: [], + preferredTranslations: {}, + styleRules: [], + notes: "", +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..93939f2 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,8 @@ +export * from "./batch.js"; +export * from "./glossary.js"; +export * from "./logger.js"; +export * from "./prompt.js"; +export * from "./segment.js"; +export * from "./translate-batch.js"; +export * from "./translate-segments.js"; +export * from "./translator.js"; diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts new file mode 100644 index 0000000..4a9c8f8 --- /dev/null +++ b/packages/core/src/logger.ts @@ -0,0 +1,6 @@ +export interface Logger { + info(message: string): void; + warn(message: string): void; + error(message: string): void; + debug(message: string): void; +} diff --git a/packages/core/src/prompt.ts b/packages/core/src/prompt.ts new file mode 100644 index 0000000..9d41f5c --- /dev/null +++ b/packages/core/src/prompt.ts @@ -0,0 +1,168 @@ +import type { Glossary } from "./glossary.js"; +import type { Segment } from "./segment.js"; + +const MARKER = "@@"; +const MARKER_LINE_RE = /^@@([^@\n]+?)@@\s*$/gm; + +if (MARKER !== "@@") { + throw new Error( + `[polystella] internal invariant violated: MARKER_LINE_RE assumes MARKER === "@@", got ${JSON.stringify(MARKER)}. ` + + `Update both together.`, + ); +} + +export interface BuildPromptInput { + segments: Segment[]; + glossary: Glossary; + sourceLocale: string; + targetLocale: string; + context?: string | undefined; + documentContext?: string | undefined; +} + +export interface BuiltPrompt { + systemPrompt: string; + userPrompt: string; +} + +export function buildPrompt(input: BuildPromptInput): BuiltPrompt { + const { segments, glossary, sourceLocale, targetLocale, context, documentContext } = input; + const sourceName = localeName(sourceLocale); + const targetName = localeName(targetLocale); + + const systemLines: string[] = [`You are a professional translator.`]; + const trimmedContext = context?.trim(); + if (trimmedContext) { + systemLines.push(trimmedContext); + } + systemLines.push( + `Translate from ${sourceName} (${sourceLocale}) to ${targetName} (${targetLocale}).`, + ``, + `Preserve markdown formatting markers exactly: **bold**, *italic*, _italic_, \`code\`, [link text](url). Translate the visible text but never the URL or any code identifier.`, + ); + + const trimmedDocContext = documentContext?.trim(); + if (trimmedDocContext) { + systemLines.push(""); + systemLines.push("DOCUMENT CONTEXT (for terminology only; do not translate this block):"); + systemLines.push(trimmedDocContext); + } + + if (glossary.doNotTranslate.length > 0) { + systemLines.push(""); + systemLines.push("TERMS THAT MUST NOT BE TRANSLATED (preserve verbatim, including capitalisation):"); + for (const term of glossary.doNotTranslate) { + systemLines.push(`- ${term}`); + } + } + + const preferred = Object.entries(glossary.preferredTranslations); + if (preferred.length > 0) { + systemLines.push(""); + systemLines.push("PREFERRED TRANSLATIONS (use these renderings, case-insensitive, when the source term appears):"); + for (const [source, target] of preferred) { + systemLines.push(`- ${source} -> ${target}`); + } + } + + if (glossary.styleRules.length > 0) { + systemLines.push(""); + systemLines.push("STYLE RULES (apply these throughout):"); + for (const rule of glossary.styleRules) { + systemLines.push(`- [${rule.category}] ${rule.instruction}`); + if (rule.example !== undefined) { + systemLines.push(` Example: ${rule.example}`); + } + } + } + + const trimmedNotes = glossary.notes.trim(); + if (trimmedNotes.length > 0) { + systemLines.push(""); + systemLines.push("ADDITIONAL NOTES:"); + systemLines.push(trimmedNotes); + } + + systemLines.push(""); + systemLines.push("OUTPUT FORMAT:"); + systemLines.push( + `For each segment in the user message, output a marker line of the form ${MARKER}${MARKER} on its own line, followed by the translated text on subsequent lines. Repeat for every segment id; do not skip any. The set of segment ids in your response MUST equal the set in the user message — do not add, omit, or rename any. Do NOT wrap your output in JSON, code fences, or any other surrounding syntax. Output the markers and translations only.`, + ); + + const userPromptParts: string[] = [ + `Translate the following segments to ${targetName}. Each segment is preceded by a marker line ${MARKER}${MARKER}. Output translations in the SAME format with the SAME segment ids — one marker line per segment, then the translation, then a blank line before the next marker.`, + "", + ]; + for (const segment of segments) { + userPromptParts.push(`${MARKER}${segment.id}${MARKER}`); + userPromptParts.push(segment.text); + userPromptParts.push(""); + } + + return { + systemPrompt: systemLines.join("\n"), + userPrompt: userPromptParts.join("\n").trimEnd(), + }; +} + +export function parseResponse(rawText: string, expectedIds: string[]): Map { + const cleaned = stripCodeFences(rawText.trim()); + const parts = cleaned.split(MARKER_LINE_RE); + + if (parts.length < 3) { + throw new Error( + `[polystella] no segment markers in the model response. Expected ${expectedIds.length} markers of the form "${MARKER}${MARKER}". Total length: ${rawText.length} chars.\nRaw response was:\n${truncateRaw(rawText)}`, + ); + } + + const expected = new Set(expectedIds); + const result = new Map(); + for (let index = 1; index + 1 < parts.length; index += 2) { + const id = (parts[index] ?? "").trim(); + const value = (parts[index + 1] ?? "").trim(); + if (id.length === 0 || !expected.has(id)) continue; + if (value.length === 0) { + throw new Error(`[polystella] model returned an empty translation for segment "${id}"`); + } + result.set(id, value); + } + + for (const id of expectedIds) { + if (!result.has(id)) { + const lastEmitted = [...result.keys()].at(-1); + const totalCharsInResult = [...result.values()].reduce((total, value) => total + value.length, 0); + const looksTruncated = + lastEmitted !== undefined && rawText.length > totalCharsInResult && !rawText.includes(`${MARKER}${id}${MARKER}`); + const hint = looksTruncated + ? ` Response appears truncated after segment "${lastEmitted}" — the model likely hit its output-token limit. Raise \`provider.maxTokens\` or split the source into smaller files.` + : ""; + throw new Error(`[polystella] model omitted segment "${id}" from response.${hint}\nRaw response was:\n${truncateRaw(rawText)}`); + } + } + + return result; +} + +function stripCodeFences(text: string): string { + if (!text.startsWith("```") || !text.endsWith("```") || text.length < 6) return text; + const firstNewline = text.indexOf("\n"); + if (firstNewline === -1) return text; + const closeIndex = text.length - 3; + if (text.charCodeAt(closeIndex - 1) !== 10 || closeIndex - 1 <= firstNewline) return text; + return text.slice(firstNewline + 1, closeIndex - 1).trim(); +} + +function truncateRaw(text: string, max = 2000): string { + if (text.length <= max) return text; + const headChars = Math.floor(max / 2); + const tailChars = max - headChars; + return `${text.slice(0, headChars)}\n... [truncated middle, total length ${text.length}] ...\n${text.slice(-tailChars)}`; +} + +function localeName(code: string): string { + try { + return new Intl.DisplayNames(["en-US"], { type: "language" }).of(code) ?? code; + } catch { + return code; + } +} diff --git a/packages/core/src/segment.ts b/packages/core/src/segment.ts new file mode 100644 index 0000000..9a69c17 --- /dev/null +++ b/packages/core/src/segment.ts @@ -0,0 +1,4 @@ +export interface Segment { + id: string; + text: string; +} diff --git a/packages/core/src/translate-batch.ts b/packages/core/src/translate-batch.ts new file mode 100644 index 0000000..7d01be5 --- /dev/null +++ b/packages/core/src/translate-batch.ts @@ -0,0 +1,79 @@ +import pRetry from "p-retry"; + +import type { Glossary } from "./glossary.js"; +import { buildPrompt, parseResponse } from "./prompt.js"; +import type { Segment } from "./segment.js"; +import { isPermanentProviderError, type Translator } from "./translator.js"; + +export interface TranslateBatchOptions { + translator: Translator; + segments: Segment[]; + glossary: Glossary; + sourceLocale: string; + targetLocale: string; + context?: string | undefined; + documentContext?: string | undefined; + maxRetries?: number; + onRetry?: ((event: TranslateBatchRetryEvent) => void) | undefined; + retryMinTimeoutMs?: number; + retryFactor?: number; + retryRandomize?: boolean; + signal?: AbortSignal | undefined; +} + +export interface TranslateBatchRetryEvent { + attempt: number; + totalAttempts: number; + error: Error; +} + +export async function translateBatch(options: TranslateBatchOptions): Promise> { + const { + translator, + segments, + glossary, + sourceLocale, + targetLocale, + context, + documentContext, + maxRetries = 0, + onRetry, + retryMinTimeoutMs = 0, + retryFactor = 2, + retryRandomize = false, + signal, + } = options; + if (segments.length === 0) return new Map(); + + const { systemPrompt, userPrompt } = buildPrompt({ + segments, + glossary, + sourceLocale, + targetLocale, + context, + documentContext, + }); + const expectedIds = segments.map((segment) => segment.id); + const totalAttempts = Math.max(1, maxRetries + 1); + + return pRetry( + async () => { + signal?.throwIfAborted(); + const rawText = await translator.translate(systemPrompt, userPrompt, signal); + return parseResponse(rawText, expectedIds); + }, + { + retries: maxRetries, + minTimeout: retryMinTimeoutMs, + factor: retryFactor, + randomize: retryRandomize, + ...(signal !== undefined ? { signal } : {}), + shouldRetry: ({ error }) => !isPermanentProviderError(error), + onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { + if (retriesLeft > 0 && !isPermanentProviderError(error)) { + onRetry?.({ attempt: attemptNumber, totalAttempts, error }); + } + }, + }, + ); +} diff --git a/packages/core/src/translate-segments.ts b/packages/core/src/translate-segments.ts new file mode 100644 index 0000000..dc4b0f8 --- /dev/null +++ b/packages/core/src/translate-segments.ts @@ -0,0 +1,46 @@ +import { packGroupsIntoBatches } from "./batch.js"; +import type { Logger } from "./logger.js"; +import type { Segment } from "./segment.js"; +import { translateBatch, type TranslateBatchOptions } from "./translate-batch.js"; + +export interface TranslateSegmentsOptions extends TranslateBatchOptions { + groups?: Segment[][]; + documentContext?: string | undefined; + inputTokenBudget?: number; + logger?: Logger; + sourcePath?: string; +} + +export interface TranslateSegmentsResult { + translations: Map; + batchCount: number; +} + +export async function translateSegments(options: TranslateSegmentsOptions): Promise { + const { segments, groups, documentContext, inputTokenBudget, logger, sourcePath, signal, ...rest } = options; + + signal?.throwIfAborted(); + if (segments.length === 0) return { translations: new Map(), batchCount: 0 }; + + const groupsToUse = groups ?? [segments]; + const batches = packGroupsIntoBatches(groupsToUse, { + ...(inputTokenBudget !== undefined ? { inputTokenBudget } : {}), + ...(logger !== undefined ? { logger } : {}), + ...(sourcePath !== undefined ? { sourcePath } : {}), + }); + if (batches.length === 0) return { translations: new Map(), batchCount: 0 }; + + const translations = new Map(); + for (const batch of batches) { + signal?.throwIfAborted(); + const batchResult = await translateBatch({ + ...rest, + segments: batch, + ...(documentContext !== undefined ? { documentContext } : {}), + ...(signal !== undefined ? { signal } : {}), + }); + for (const [id, text] of batchResult) translations.set(id, text); + } + + return { translations, batchCount: batches.length }; +} diff --git a/packages/core/src/translator.ts b/packages/core/src/translator.ts new file mode 100644 index 0000000..a4ed07c --- /dev/null +++ b/packages/core/src/translator.ts @@ -0,0 +1,24 @@ +export interface Translator { + readonly modelId: string; + translate(systemPrompt: string, userPrompt: string, signal?: AbortSignal | undefined): Promise; +} + +export class PermanentProviderError extends Error { + readonly _tag = "PermanentProviderError" as const; + + constructor(message: string) { + super(message); + this.name = "PermanentProviderError"; + } +} + +export function isPermanentProviderError(error: unknown): error is PermanentProviderError { + return typeof error === "object" && error !== null && "_tag" in error && error._tag === "PermanentProviderError"; +} + +export type ModelSpec = string | ({ default: string } & Record); + +export function resolveModelId(spec: ModelSpec, locale: string): string { + if (typeof spec === "string") return spec; + return spec[locale] ?? spec.default; +} diff --git a/packages/core/tests/batch.test.ts b/packages/core/tests/batch.test.ts new file mode 100644 index 0000000..b69dc4b --- /dev/null +++ b/packages/core/tests/batch.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; + +import { DEFAULT_INPUT_TOKEN_BUDGET, estimateInputTokens, packGroupsIntoBatches, type Segment } from "../src/index.js"; + +const segment = (id: string, text: string): Segment => ({ id, text }); + +describe("estimateInputTokens", () => { + it("pins the current chars-per-token formula", () => { + expect(estimateInputTokens([])).toBe(0); + expect(estimateInputTokens([segment("a", "hello")])).toBe(4); + expect(estimateInputTokens([segment("a", "hello"), segment("b", "world")])).toBe(7); + expect(DEFAULT_INPUT_TOKEN_BUDGET).toBe(4000); + }); +}); + +describe("packGroupsIntoBatches", () => { + it("ignores empty groups and packs fitting groups together", () => { + const first = [segment("a", "hello")]; + const second = [segment("b", "world")]; + expect(packGroupsIntoBatches([[], first, second, []])).toEqual([[...first, ...second]]); + }); + + it("preserves segment references and order across batches", () => { + const groups = [ + [segment("a", "hello"), segment("b", "world")], + [segment("c", "hello"), segment("d", "world")], + ]; + const batches = packGroupsIntoBatches(groups, { inputTokenBudget: 7 }); + expect(batches).toHaveLength(2); + expect(batches.flat()).toEqual(groups.flat()); + expect(batches.flat().every((entry, index) => entry === groups.flat()[index])).toBe(true); + }); + + it("warns and splits an oversize group without dropping segments", () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const group = [segment("a", "this is much too long"), segment("b", "also too long")]; + const batches = packGroupsIntoBatches([group], { inputTokenBudget: 3, logger, sourcePath: "docs/large.md" }); + expect(logger.warn).toHaveBeenCalledOnce(); + expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/docs\/large\.md.*splitting paragraph-by-paragraph/)); + expect(batches.flat()).toEqual(group); + }); + + it("flushes a fitting group before splitting the next oversize group", () => { + const first = [segment("a", "x")]; + const oversize = [segment("b", "this is the long one"), segment("c", "and another long bit")]; + const batches = packGroupsIntoBatches([first, oversize], { inputTokenBudget: 5 }); + expect(batches[0]).toEqual(first); + expect(batches.slice(1).flat()).toEqual(oversize); + }); +}); diff --git a/packages/core/tests/prompt.test.ts b/packages/core/tests/prompt.test.ts new file mode 100644 index 0000000..399dc82 --- /dev/null +++ b/packages/core/tests/prompt.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { buildPrompt, EMPTY_GLOSSARY, parseResponse, type Glossary, type Segment } from "../src/index.js"; + +const segments: Segment[] = [ + { id: "fm:title", text: "Hello" }, + { id: "body:0", text: "A paragraph." }, +]; + +const glossary: Glossary = { + version: "2026-04", + doNotTranslate: ["Cloudflare", "TLS"], + preferredTranslations: { edge: "borda" }, + styleRules: [ + { category: "tone", instruction: "Use formal academic register." }, + { category: "numbers", instruction: "Use comma as decimal separator.", example: "21.3 -> 21,3" }, + ], + notes: "Use Brazilian Portuguese spelling.", +}; + +async function sha256(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +describe("buildPrompt", () => { + it("matches the recorded baseline bytes", async () => { + const prompt = buildPrompt({ + segments, + glossary: EMPTY_GLOSSARY, + sourceLocale: "en-US", + targetLocale: "pt-BR", + }); + + expect(prompt.systemPrompt).toContain("American English (en-US)"); + expect(prompt.systemPrompt).toContain("Brazilian Portuguese (pt-BR)"); + expect(await sha256(prompt.systemPrompt)).toBe("32b336fbdce5ab269488f3f5d77840d1f1a9fa7c7e544d6f4c70bca570e4ef34"); + expect(await sha256(prompt.userPrompt)).toBe("975ae31980e7f7a782ec257d7584e0ba689b01f006e86c34c82ba029a1363685"); + }); + + it("renders glossary sections in order", () => { + const { systemPrompt } = buildPrompt({ + segments, + glossary, + sourceLocale: "en-US", + targetLocale: "pt-BR", + }); + const headings = ["MUST NOT BE TRANSLATED", "PREFERRED TRANSLATIONS", "STYLE RULES", "ADDITIONAL NOTES", "OUTPUT FORMAT"]; + expect(headings.map((heading) => systemPrompt.indexOf(heading))).toEqual( + [...headings.map((heading) => systemPrompt.indexOf(heading))].sort((left, right) => left - right), + ); + expect(systemPrompt).toContain("- Cloudflare"); + expect(systemPrompt).toContain("- edge -> borda"); + expect(systemPrompt).toContain(" Example: 21.3 -> 21,3"); + }); + + it("threads trimmed site and document context without changing absent-context bytes", () => { + const baseline = buildPrompt({ segments, glossary, sourceLocale: "en-US", targetLocale: "pt-BR" }); + const absent = buildPrompt({ + segments, + glossary, + sourceLocale: "en-US", + targetLocale: "pt-BR", + context: " ", + documentContext: "\n", + }); + expect(absent).toEqual(baseline); + + const framed = buildPrompt({ + segments, + glossary, + sourceLocale: "en-US", + targetLocale: "pt-BR", + context: " Specialise in research. ", + documentContext: " Title: Echo State Networks ", + }); + expect(framed.systemPrompt.split("\n")[1]).toBe("Specialise in research."); + expect(framed.systemPrompt).toContain( + "DOCUMENT CONTEXT (for terminology only; do not translate this block):\nTitle: Echo State Networks", + ); + }); +}); + +describe("parseResponse", () => { + const expectedIds = segments.map((segment) => segment.id); + + it("parses marker blocks and preserves multiline content", () => { + const response = ["@@fm:title@@", "Ola", "", "@@body:0@@", "First line", "Second line", "", "Third line"].join("\n"); + const parsed = parseResponse(response, expectedIds); + expect(parsed.get("fm:title")).toBe("Ola"); + expect(parsed.get("body:0")).toBe("First line\nSecond line\n\nThird line"); + }); + + it("unwraps code fences and ignores preambles and unknown ids", () => { + const response = [ + "```text", + "Here are the translations:", + "@@unknown@@", + "ignored", + "@@fm:title@@", + "Title", + "@@body:0@@", + "Body", + "```", + ].join("\n"); + const parsed = parseResponse(response, expectedIds); + expect([...parsed]).toEqual([ + ["fm:title", "Title"], + ["body:0", "Body"], + ]); + }); + + it("rejects missing markers, empty translations, and omitted ids", () => { + expect(() => parseResponse("plain text", expectedIds)).toThrow(/no segment markers/); + expect(() => parseResponse("@@fm:title@@\n\n@@body:0@@\nBody", expectedIds)).toThrow(/empty translation/); + expect(() => parseResponse("@@fm:title@@\nTitle", expectedIds)).toThrow(/omitted segment "body:0"/); + }); + + it("reports likely truncation without dumping an unbounded response", () => { + const longResponse = `@@fm:title@@\n${"x".repeat(3000)}`; + expect(() => parseResponse(longResponse, expectedIds)).toThrow(/Response appears truncated.*truncated middle/s); + }); +}); diff --git a/packages/core/tests/translate-batch.test.ts b/packages/core/tests/translate-batch.test.ts new file mode 100644 index 0000000..e252d03 --- /dev/null +++ b/packages/core/tests/translate-batch.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + EMPTY_GLOSSARY, + isPermanentProviderError, + PermanentProviderError, + resolveModelId, + translateBatch, + type Segment, + type Translator, +} from "../src/index.js"; + +const segments: Segment[] = [ + { id: "fm:title", text: "An apology" }, + { id: "body:0", text: "We regret any inconvenience." }, +]; + +const goodResponse = ["@@fm:title@@", "Um pedido de desculpas", "", "@@body:0@@", "Lamentamos o inconveniente."].join("\n"); + +const options = (translator: Translator) => ({ + translator, + segments, + glossary: EMPTY_GLOSSARY, + sourceLocale: "en-US", + targetLocale: "pt-BR", +}); + +describe("resolveModelId", () => { + it("resolves strings, locale overrides, and defaults", () => { + expect(resolveModelId("default-model", "pt-BR")).toBe("default-model"); + const models = { default: "default-model", "ja-JP": "japanese-model" }; + expect(resolveModelId(models, "ja-JP")).toBe("japanese-model"); + expect(resolveModelId(models, "pt-BR")).toBe("default-model"); + }); +}); + +describe("translateBatch", () => { + it("short-circuits empty input without calling the translator", async () => { + const translate = vi.fn(); + const result = await translateBatch({ ...options({ modelId: "test", translate }), segments: [] }); + expect(result.size).toBe(0); + expect(translate).not.toHaveBeenCalled(); + }); + + it("builds a prompt, forwards the signal, and parses the response", async () => { + const controller = new AbortController(); + const translate = vi.fn().mockResolvedValue(goodResponse); + const result = await translateBatch({ ...options({ modelId: "test", translate }), signal: controller.signal }); + expect(result.get("fm:title")).toBe("Um pedido de desculpas"); + expect(translate).toHaveBeenCalledOnce(); + expect(translate.mock.calls[0]?.[0]).toMatch(/professional translator/); + expect(translate.mock.calls[0]?.[1]).toMatch(/@@fm:title@@/); + expect(translate.mock.calls[0]?.[2]).toBe(controller.signal); + }); + + it("retries parse and transient failures and reports only followed retries", async () => { + const translate = vi + .fn() + .mockResolvedValueOnce("not marker-delimited") + .mockRejectedValueOnce(new Error("503")) + .mockResolvedValueOnce(goodResponse); + const onRetry = vi.fn(); + const result = await translateBatch({ + ...options({ modelId: "test", translate }), + maxRetries: 2, + onRetry, + }); + expect(result.size).toBe(2); + expect(translate).toHaveBeenCalledTimes(3); + expect(onRetry).toHaveBeenCalledTimes(2); + expect(onRetry.mock.calls.map(([event]) => [event.attempt, event.totalAttempts])).toEqual([ + [1, 3], + [2, 3], + ]); + }); + + it("throws the final error and does not report the final failed attempt as a retry", async () => { + const translate = vi + .fn() + .mockRejectedValueOnce(new Error("first")) + .mockRejectedValueOnce(new Error("second")) + .mockRejectedValueOnce(new Error("final")); + const onRetry = vi.fn(); + await expect(translateBatch({ ...options({ modelId: "test", translate }), maxRetries: 2, onRetry })).rejects.toThrow("final"); + expect(translate).toHaveBeenCalledTimes(3); + expect(onRetry).toHaveBeenCalledTimes(2); + }); + + it("defaults to one attempt", async () => { + const translate = vi.fn().mockRejectedValue(new Error("boom")); + await expect(translateBatch(options({ modelId: "test", translate }))).rejects.toThrow("boom"); + expect(translate).toHaveBeenCalledOnce(); + }); + + it("short-circuits retries for local and foreign permanent errors", async () => { + const localTranslate = vi.fn().mockRejectedValue(new PermanentProviderError("local permanent")); + await expect(translateBatch({ ...options({ modelId: "test", translate: localTranslate }), maxRetries: 3 })).rejects.toThrow( + "local permanent", + ); + expect(localTranslate).toHaveBeenCalledOnce(); + + const foreignError = Object.assign(new Error("foreign permanent"), { _tag: "PermanentProviderError" as const }); + const foreignTranslate = vi.fn().mockRejectedValue(foreignError); + expect(isPermanentProviderError(foreignError)).toBe(true); + await expect(translateBatch({ ...options({ modelId: "test", translate: foreignTranslate }), maxRetries: 3 })).rejects.toThrow( + "foreign permanent", + ); + expect(foreignTranslate).toHaveBeenCalledOnce(); + }); + + it("honors pre-aborted and between-attempt cancellation", async () => { + const preAborted = new AbortController(); + preAborted.abort(new Error("cancelled")); + const neverCalled = vi.fn().mockResolvedValue(goodResponse); + await expect(translateBatch({ ...options({ modelId: "test", translate: neverCalled }), signal: preAborted.signal })).rejects.toThrow(); + expect(neverCalled).not.toHaveBeenCalled(); + + const duringRetry = new AbortController(); + const calledOnce = vi.fn().mockImplementation(async () => { + duringRetry.abort(new Error("cancelled")); + throw new Error("transient"); + }); + await expect( + translateBatch({ + ...options({ modelId: "test", translate: calledOnce }), + maxRetries: 3, + signal: duringRetry.signal, + }), + ).rejects.toThrow(); + expect(calledOnce).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/core/tests/translate-segments.test.ts b/packages/core/tests/translate-segments.test.ts new file mode 100644 index 0000000..75c81f2 --- /dev/null +++ b/packages/core/tests/translate-segments.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vitest"; + +import { EMPTY_GLOSSARY, PermanentProviderError, translateSegments, type Segment, type Translator } from "../src/index.js"; + +const segment = (id: string, text: string): Segment => ({ id, text }); + +function responseFor(ids: string[]): string { + return ids.map((id) => `@@${id}@@\nTR:${id}`).join("\n\n"); +} + +function makeEchoTranslator(): Translator & { calls: string[][]; systemPrompts: string[] } { + const translator = { + modelId: "stub/echo", + calls: [] as string[][], + systemPrompts: [] as string[], + async translate(systemPrompt: string, userPrompt: string) { + const ids = [...userPrompt.matchAll(/^@@([^@\n]+?)@@\s*$/gm)].map((match) => match[1]!.trim()); + translator.calls.push(ids); + translator.systemPrompts.push(systemPrompt); + return responseFor(ids); + }, + }; + return translator; +} + +const commonOptions = { + glossary: EMPTY_GLOSSARY, + sourceLocale: "en-US", + targetLocale: "pt-BR", +}; + +describe("translateSegments", () => { + it("uses one batch by default and short-circuits empty input", async () => { + const translator = makeEchoTranslator(); + const translated = await translateSegments({ + ...commonOptions, + translator, + segments: [segment("a", "alpha"), segment("b", "beta")], + }); + expect(translator.calls).toEqual([["a", "b"]]); + expect(translated.batchCount).toBe(1); + expect([...translated.translations]).toEqual([ + ["a", "TR:a"], + ["b", "TR:b"], + ]); + + const empty = await translateSegments({ ...commonOptions, translator, segments: [] }); + expect(empty).toEqual({ translations: new Map(), batchCount: 0 }); + expect(translator.calls).toHaveLength(1); + }); + + it("dispatches forced batches sequentially and merges results", async () => { + const translator = makeEchoTranslator(); + const first = [segment("a", "hello"), segment("b", "world")]; + const second = [segment("c", "hello"), segment("d", "world")]; + const translated = await translateSegments({ + ...commonOptions, + translator, + segments: [...first, ...second], + groups: [first, second], + inputTokenBudget: 7, + }); + expect(translator.calls).toEqual([ + ["a", "b"], + ["c", "d"], + ]); + expect(translated.batchCount).toBe(2); + expect([...translated.translations.keys()]).toEqual(["a", "b", "c", "d"]); + }); + + it("adds document context to every batch", async () => { + const translator = makeEchoTranslator(); + const groups = [[segment("a", "hello")], [segment("b", "world")]]; + await translateSegments({ + ...commonOptions, + translator, + segments: groups.flat(), + groups, + inputTokenBudget: 4, + documentContext: "Title: Example", + }); + expect(translator.systemPrompts).toHaveLength(2); + expect(translator.systemPrompts.every((prompt) => prompt.includes("DOCUMENT CONTEXT") && prompt.includes("Title: Example"))).toBe(true); + }); + + it("keeps retries inside the failing batch", async () => { + const translate = vi + .fn() + .mockRejectedValueOnce(new Error("503")) + .mockResolvedValueOnce(responseFor(["a"])) + .mockResolvedValueOnce(responseFor(["b"])); + const groups = [[segment("a", "first")], [segment("b", "second")]]; + const translated = await translateSegments({ + ...commonOptions, + translator: { modelId: "stub/retry", translate }, + segments: groups.flat(), + groups, + inputTokenBudget: 4, + maxRetries: 1, + }); + expect(translate).toHaveBeenCalledTimes(3); + expect(translated.batchCount).toBe(2); + }); + + it("stops before later batches after a permanent failure", async () => { + const translate = vi.fn().mockRejectedValue(new PermanentProviderError("401")); + const groups = [[segment("a", "first")], [segment("b", "second")]]; + await expect( + translateSegments({ + ...commonOptions, + translator: { modelId: "stub/permanent", translate }, + segments: groups.flat(), + groups, + inputTokenBudget: 4, + maxRetries: 2, + }), + ).rejects.toThrow("401"); + expect(translate).toHaveBeenCalledOnce(); + }); + + it("honors cancellation before and between batches", async () => { + const before = new AbortController(); + before.abort(new Error("cancelled")); + const translator = makeEchoTranslator(); + await expect( + translateSegments({ ...commonOptions, translator, segments: [segment("a", "first")], signal: before.signal }), + ).rejects.toThrow(); + expect(translator.calls).toHaveLength(0); + + const between = new AbortController(); + const translate = vi.fn().mockImplementationOnce(async () => { + between.abort(new Error("cancelled")); + return responseFor(["a"]); + }); + const groups = [[segment("a", "first")], [segment("b", "second")]]; + await expect( + translateSegments({ + ...commonOptions, + translator: { modelId: "stub/abort", translate }, + segments: groups.flat(), + groups, + inputTokenBudget: 4, + signal: between.signal, + }), + ).rejects.toThrow(); + expect(translate).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 0000000..2bf2ce3 --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["tests/**", "**/*.test.ts", "dist/**"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..7588060 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "tests/**/*"] +} diff --git a/packages/providers/package.json b/packages/providers/package.json new file mode 100644 index 0000000..8fac5da --- /dev/null +++ b/packages/providers/package.json @@ -0,0 +1,57 @@ +{ + "name": "@cloudflare/polystella-providers", + "version": "0.4.0", + "type": "module", + "description": "Portable AI provider transports for PolyStella.", + "license": "MIT", + "homepage": "https://github.com/cloudflare/polystella", + "repository": { + "type": "git", + "url": "git+https://github.com/cloudflare/polystella.git", + "directory": "packages/providers" + }, + "bugs": { + "url": "https://github.com/cloudflare/polystella/issues" + }, + "keywords": [ + "translation", + "ai", + "cloudflare", + "workers-ai", + "anthropic" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./workers-ai": { + "types": "./dist/workers-ai.d.ts", + "default": "./dist/workers-ai.js" + }, + "./anthropic": { + "types": "./dist/anthropic.d.ts", + "default": "./dist/anthropic.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "test": "vitest run", + "build": "tsc -p tsconfig.build.json" + }, + "dependencies": { + "@cloudflare/polystella-core": "workspace:0.4.0" + }, + "devDependencies": { + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/packages/providers/src/anthropic.ts b/packages/providers/src/anthropic.ts new file mode 100644 index 0000000..9ba8560 --- /dev/null +++ b/packages/providers/src/anthropic.ts @@ -0,0 +1,53 @@ +import type { Translator } from "@cloudflare/polystella-core"; + +import { createProviderHttpError } from "./http-error.js"; + +export interface AnthropicTranslatorOptions { + apiKey: string; + modelId: string; + maxTokens: number; + fetchImpl?: typeof fetch | undefined; +} + +export function createAnthropicTranslator(options: AnthropicTranslatorOptions): Translator { + const { apiKey, modelId, maxTokens, fetchImpl = fetch } = options; + return { + modelId, + async translate(systemPrompt, userPrompt, signal) { + signal?.throwIfAborted(); + const response = await fetchImpl("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: modelId, + max_tokens: maxTokens, + system: systemPrompt, + messages: [{ role: "user", content: userPrompt }], + }), + ...(signal !== undefined ? { signal } : {}), + }); + signal?.throwIfAborted(); + if (!response.ok) throw await createProviderHttpError("Anthropic", response, signal); + + const data: unknown = await response.json(); + signal?.throwIfAborted(); + const content = property(data, "content"); + if (Array.isArray(content)) { + const text = property( + content.find((block) => property(block, "type") === "text"), + "text", + ); + if (typeof text === "string") return text; + } + throw new Error("[polystella] unexpected Anthropic response shape: no text content block"); + }, + }; +} + +function property(value: unknown, key: string): unknown { + return value !== null && typeof value === "object" ? (value as Record)[key] : undefined; +} diff --git a/packages/providers/src/http-error.ts b/packages/providers/src/http-error.ts new file mode 100644 index 0000000..b996066 --- /dev/null +++ b/packages/providers/src/http-error.ts @@ -0,0 +1,10 @@ +import { PermanentProviderError } from "@cloudflare/polystella-core"; + +const PERMANENT_HTTP_STATUSES = new Set([400, 401, 403, 404, 422]); + +export async function createProviderHttpError(providerName: string, response: Response, signal?: AbortSignal | undefined): Promise { + const text = await response.text().catch(() => ""); + signal?.throwIfAborted(); + const message = `[polystella] ${providerName} request failed: ${response.status} ${response.statusText}${text ? `\n${text}` : ""}`; + return PERMANENT_HTTP_STATUSES.has(response.status) ? new PermanentProviderError(message) : new Error(message); +} diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts new file mode 100644 index 0000000..27f9152 --- /dev/null +++ b/packages/providers/src/index.ts @@ -0,0 +1,2 @@ +export * from "./anthropic.js"; +export * from "./workers-ai.js"; diff --git a/packages/providers/src/workers-ai.ts b/packages/providers/src/workers-ai.ts new file mode 100644 index 0000000..1ab86a2 --- /dev/null +++ b/packages/providers/src/workers-ai.ts @@ -0,0 +1,153 @@ +import type { Translator } from "@cloudflare/polystella-core"; + +import { createProviderHttpError } from "./http-error.js"; + +export interface WorkersAIInput { + messages: Array<{ role: "system" | "user"; content: string }>; + max_tokens: number; +} + +interface WorkersAITranslatorOptions { + modelId: string; + maxTokens: number; +} + +export interface WorkersAIHttpTranslatorOptions extends WorkersAITranslatorOptions { + accountId: string; + apiToken: string; + endpoint?: string | undefined; + fetchImpl?: typeof fetch | undefined; +} + +export type WorkersAIBindingRun = (modelId: string, input: WorkersAIInput) => Promise; + +export interface WorkersAIBindingTranslatorOptions extends WorkersAITranslatorOptions { + run: WorkersAIBindingRun; +} + +export function createWorkersAIHttpTranslator(options: WorkersAIHttpTranslatorOptions): Translator { + const { accountId, apiToken, modelId, maxTokens, endpoint, fetchImpl = fetch } = options; + const url = endpoint ?? `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${modelId}`; + + return { + modelId, + async translate(systemPrompt, userPrompt, signal) { + signal?.throwIfAborted(); + const response = await fetchImpl(url, { + method: "POST", + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(createInput(systemPrompt, userPrompt, maxTokens)), + ...(signal !== undefined ? { signal } : {}), + }); + signal?.throwIfAborted(); + if (!response.ok) throw await createProviderHttpError("Workers AI", response, signal); + + const data: unknown = await response.json(); + signal?.throwIfAborted(); + if (property(data, "success") === false) { + throw new Error(`[polystella] Workers AI returned errors: ${JSON.stringify(property(data, "errors") ?? [])}`); + } + + return normalizeWorkersAIHttpResponse(data, modelId); + }, + }; +} + +export function createWorkersAIBindingTranslator(options: WorkersAIBindingTranslatorOptions): Translator { + const { modelId, maxTokens, run } = options; + return { + modelId, + async translate(systemPrompt, userPrompt, signal) { + signal?.throwIfAborted(); + const data = await run(modelId, createInput(systemPrompt, userPrompt, maxTokens)); + signal?.throwIfAborted(); + return normalizeWorkersAIBindingResponse(data, modelId); + }, + }; +} + +function createInput(systemPrompt: string, userPrompt: string, maxTokens: number): WorkersAIInput { + return { + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + max_tokens: maxTokens, + }; +} + +function normalizeWorkersAIHttpResponse(data: unknown, modelId: string): string { + const result = property(data, "result"); + const candidates = [ + property(result, "response"), + firstChoiceContent(property(result, "choices")), + firstChoiceContent(property(data, "choices")), + ]; + for (const candidate of candidates) { + const normalized = normalizeValue(candidate); + if (normalized !== undefined) return normalized; + } + + throw unexpectedResponseError( + "Workers AI", + modelId, + "none of result.response, result.choices[0].message.content, or choices[0].message.content held a usable string or object", + data, + ); +} + +function normalizeWorkersAIBindingResponse(data: unknown, modelId: string): string { + if (typeof data === "string") return data; + if (data !== null && typeof data === "object") { + const hasResponseEnvelope = Object.hasOwn(data, "response"); + const hasChoicesEnvelope = Object.hasOwn(data, "choices"); + if (!hasResponseEnvelope && !hasChoicesEnvelope) { + const normalized = normalizeValue(data); + if (normalized !== undefined) return normalized; + } + + for (const candidate of [property(data, "response"), firstChoiceContent(property(data, "choices"))]) { + const normalized = normalizeValue(candidate); + if (normalized !== undefined) return normalized; + } + } + + throw unexpectedResponseError( + "Workers AI binding", + modelId, + "none of the direct value, response, or choices[0].message.content held a usable string or object", + data, + ); +} + +function firstChoiceContent(value: unknown): unknown { + if (!Array.isArray(value)) return undefined; + return property(property(value[0], "message"), "content"); +} + +function property(value: unknown, key: string): unknown { + return value !== null && typeof value === "object" ? (value as Record)[key] : undefined; +} + +function normalizeValue(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (value !== null && typeof value === "object") return JSON.stringify(value); + return undefined; +} + +function unexpectedResponseError(providerName: string, modelId: string, detail: string, data: unknown): Error { + const serialized = safeStringify(data); + const preview = serialized.length > 800 ? `${serialized.slice(0, 800)}\n... [truncated, total length ${serialized.length}]` : serialized; + return new Error(`[polystella] unexpected ${providerName} response shape (model="${modelId}"): ${detail}. Raw response was:\n${preview}`); +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} diff --git a/packages/providers/tests/providers.test.ts b/packages/providers/tests/providers.test.ts new file mode 100644 index 0000000..3693660 --- /dev/null +++ b/packages/providers/tests/providers.test.ts @@ -0,0 +1,251 @@ +import { EMPTY_GLOSSARY, PermanentProviderError, translateBatch } from "@cloudflare/polystella-core"; +import { describe, expect, it, vi } from "vitest"; + +import { createAnthropicTranslator } from "../src/anthropic.js"; +import { createWorkersAIBindingTranslator, createWorkersAIHttpTranslator } from "../src/workers-ai.js"; + +function makeFetchStub(body: unknown, init: { status?: number; statusText?: string; rawText?: string } = {}) { + const responseBody = init.rawText ?? JSON.stringify(body); + return vi.fn().mockResolvedValue( + new Response(responseBody, { + status: init.status ?? 200, + statusText: init.statusText ?? "OK", + headers: { "Content-Type": "application/json" }, + }), + ); +} + +function createWorkersHttp(fetchImpl: typeof fetch) { + return createWorkersAIHttpTranslator({ + accountId: "ACCT", + apiToken: "TOKEN", + modelId: "@cf/test/model", + maxTokens: 8192, + fetchImpl, + }); +} + +describe("Workers AI HTTP translator", () => { + it("sends the exact endpoint, authorization, chat messages, max tokens, and signal", async () => { + const fetchImpl = makeFetchStub({ result: { response: "OK" }, success: true }); + const translator = createWorkersHttp(fetchImpl); + const controller = new AbortController(); + await translator.translate("system", "user", controller.signal); + + expect(translator.modelId).toBe("@cf/test/model"); + expect(fetchImpl).toHaveBeenCalledOnce(); + const [url, init] = fetchImpl.mock.calls[0]!; + expect(url).toBe("https://api.cloudflare.com/client/v4/accounts/ACCT/ai/run/@cf/test/model"); + expect(init).toMatchObject({ method: "POST", signal: controller.signal }); + expect(init?.headers).toEqual({ Authorization: "Bearer TOKEN", "Content-Type": "application/json" }); + expect(JSON.parse(String(init?.body))).toEqual({ + messages: [ + { role: "system", content: "system" }, + { role: "user", content: "user" }, + ], + max_tokens: 8192, + }); + }); + + it("uses a custom endpoint", async () => { + const fetchImpl = makeFetchStub({ result: { response: "OK" } }); + const translator = createWorkersAIHttpTranslator({ + accountId: "ignored", + apiToken: "TOKEN", + modelId: "model", + maxTokens: 10, + endpoint: "https://gateway.example/run", + fetchImpl, + }); + await translator.translate("s", "u"); + expect(fetchImpl.mock.calls[0]![0]).toBe("https://gateway.example/run"); + }); + + it.each([ + ["legacy", { result: { response: "legacy", choices: [{ message: { content: "chat" } }] } }, "legacy"], + ["result choices", { result: { choices: [{ message: { content: "chat" } }] } }, "chat"], + ["top-level choices", { choices: [{ message: { content: "flat" } }] }, "flat"], + ["parsed object", { result: { response: { title: "Translated" } } }, '{"title":"Translated"}'], + ])("normalizes the %s response shape", async (_name, body, expected) => { + expect(await createWorkersHttp(makeFetchStub(body)).translate("s", "u")).toBe(expected); + }); + + it("reports API envelopes with success false", async () => { + const translator = createWorkersHttp(makeFetchStub({ success: false, errors: [{ message: "bad" }] })); + await expect(translator.translate("s", "u")).rejects.toThrow(/Workers AI returned errors.*bad/); + }); + + it("previews unexpected response envelopes", async () => { + const translator = createWorkersHttp(makeFetchStub({ result: { response: 42 }, extra: "x".repeat(900) })); + await expect(translator.translate("s", "u")).rejects.toThrow(/none of result\.response.*"response":42.*truncated, total length/s); + }); + + it.each([400, 401, 403, 404, 422])("classifies HTTP %s as permanent", async (status) => { + const translator = createWorkersHttp(makeFetchStub({}, { status, statusText: "Failure", rawText: "details" })); + const error = await translator.translate("s", "u").catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(PermanentProviderError); + expect(error).toMatchObject({ message: expect.stringContaining(`${status} Failure\ndetails`) }); + }); + + it.each([429, 503])("leaves HTTP %s retriable", async (status) => { + const translator = createWorkersHttp(makeFetchStub({}, { status, statusText: "Retry" })); + const error = await translator.translate("s", "u").catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(PermanentProviderError); + }); +}); + +describe("Workers AI binding translator", () => { + it("passes the exact model and input object to the binding callback", async () => { + const run = vi.fn().mockResolvedValue("OK"); + const translator = createWorkersAIBindingTranslator({ modelId: "@cf/binding/model", maxTokens: 4096, run }); + expect(await translator.translate("system", "user")).toBe("OK"); + expect(run).toHaveBeenCalledWith("@cf/binding/model", { + messages: [ + { role: "system", content: "system" }, + { role: "user", content: "user" }, + ], + max_tokens: 4096, + }); + }); + + it.each([ + ["direct string", "translated", "translated"], + ["direct object", { title: "Translated" }, '{"title":"Translated"}'], + ["response string", { response: "translated" }, "translated"], + ["response object", { response: { title: "Translated" } }, '{"title":"Translated"}'], + ["choices string", { choices: [{ message: { content: "translated" } }] }, "translated"], + ["choices object", { choices: [{ message: { content: { title: "Translated" } } }] }, '{"title":"Translated"}'], + ])("normalizes a %s", async (_name, response, expected) => { + const translator = createWorkersAIBindingTranslator({ + modelId: "model", + maxTokens: 100, + run: vi.fn().mockResolvedValue(response), + }); + expect(await translator.translate("s", "u")).toBe(expected); + }); + + it("rejects a pre-aborted signal without invoking the binding", async () => { + const run = vi.fn().mockResolvedValue("ignored"); + const controller = new AbortController(); + controller.abort(new Error("cancelled")); + const translator = createWorkersAIBindingTranslator({ modelId: "model", maxTokens: 100, run }); + await expect(translator.translate("s", "u", controller.signal)).rejects.toThrow("cancelled"); + expect(run).not.toHaveBeenCalled(); + }); + + it("checks cancellation after binding inference without claiming in-flight cancellation", async () => { + const controller = new AbortController(); + const run = vi.fn().mockImplementation(async () => { + controller.abort(new Error("cancelled after inference")); + return "ignored"; + }); + const translator = createWorkersAIBindingTranslator({ modelId: "model", maxTokens: 100, run }); + await expect(translator.translate("s", "u", controller.signal)).rejects.toThrow("cancelled after inference"); + expect(run).toHaveBeenCalledOnce(); + }); + + it("leaves binding callback errors unchanged", async () => { + const failure = new Error("binding failure"); + const translator = createWorkersAIBindingTranslator({ + modelId: "model", + maxTokens: 100, + run: vi.fn().mockRejectedValue(failure), + }); + await expect(translator.translate("s", "u")).rejects.toBe(failure); + }); + + it("includes a preview for unsupported binding responses", async () => { + const translator = createWorkersAIBindingTranslator({ + modelId: "model", + maxTokens: 100, + run: vi.fn().mockResolvedValue(42), + }); + await expect(translator.translate("s", "u")).rejects.toThrow(/unexpected Workers AI binding response shape.*42/s); + }); +}); + +describe("Anthropic translator", () => { + function createAnthropic(fetchImpl: typeof fetch) { + return createAnthropicTranslator({ apiKey: "KEY", modelId: "claude-test", maxTokens: 8192, fetchImpl }); + } + + it("sends the documented endpoint, headers, body, and signal", async () => { + const fetchImpl = makeFetchStub({ content: [{ type: "text", text: "OK" }] }); + const controller = new AbortController(); + const translator = createAnthropic(fetchImpl); + await translator.translate("system", "user", controller.signal); + + const [url, init] = fetchImpl.mock.calls[0]!; + expect(url).toBe("https://api.anthropic.com/v1/messages"); + expect(init).toMatchObject({ method: "POST", signal: controller.signal }); + expect(init?.headers).toEqual({ + "x-api-key": "KEY", + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + }); + expect(JSON.parse(String(init?.body))).toEqual({ + model: "claude-test", + max_tokens: 8192, + system: "system", + messages: [{ role: "user", content: "user" }], + }); + }); + + it("returns the first text content block", async () => { + const translator = createAnthropic( + makeFetchStub({ + content: [ + { type: "tool_use", id: "x" }, + { type: "text", text: "translated" }, + ], + }), + ); + expect(await translator.translate("s", "u")).toBe("translated"); + }); + + it("rejects responses without text content", async () => { + const translator = createAnthropic(makeFetchStub({ content: [{ type: "tool_use", id: "x" }] })); + await expect(translator.translate("s", "u")).rejects.toThrow(/unexpected Anthropic response shape/); + }); + + it("rejects a malformed first text block instead of skipping to a later one", async () => { + const translator = createAnthropic( + makeFetchStub({ + content: [ + { type: "text", text: 42 }, + { type: "text", text: "later" }, + ], + }), + ); + await expect(translator.translate("s", "u")).rejects.toThrow(/unexpected Anthropic response shape/); + }); + + it("uses the canonical core permanent error", async () => { + const translator = createAnthropic(makeFetchStub({}, { status: 401, statusText: "Unauthorized" })); + await expect(translator.translate("s", "u")).rejects.toBeInstanceOf(PermanentProviderError); + }); +}); + +describe("core retry integration", () => { + const translationInput = { + segments: [{ id: "body:0", text: "Hello" }], + glossary: EMPTY_GLOSSARY, + sourceLocale: "en-US", + targetLocale: "pt-BR", + maxRetries: 2, + retryMinTimeoutMs: 0, + }; + + it("makes one attempt for a provider 401", async () => { + const fetchImpl = makeFetchStub({}, { status: 401, statusText: "Unauthorized" }); + await expect(translateBatch({ ...translationInput, translator: createWorkersHttp(fetchImpl) })).rejects.toThrow(/401/); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it("lets a provider 503 consume the core retry budget", async () => { + const fetchImpl = makeFetchStub({}, { status: 503, statusText: "Unavailable" }); + await expect(translateBatch({ ...translationInput, translator: createWorkersHttp(fetchImpl) })).rejects.toThrow(/503/); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/providers/tsconfig.build.json b/packages/providers/tsconfig.build.json new file mode 100644 index 0000000..2bf2ce3 --- /dev/null +++ b/packages/providers/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["tests/**", "**/*.test.ts", "dist/**"] +} diff --git a/packages/providers/tsconfig.json b/packages/providers/tsconfig.json new file mode 100644 index 0000000..7588060 --- /dev/null +++ b/packages/providers/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "tests/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b33a00e..bd9796e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,75 @@ importers: specifier: ^4.0.0 version: 4.93.0 + packages/adapters: + dependencies: + '@cloudflare/polystella-core': + specifier: workspace:0.4.0 + version: link:../core + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 + picomatch: + specifier: ^4.0.4 + version: 4.0.5 + remark-frontmatter: + specifier: ^5.0.0 + version: 5.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + remark-mdx: + specifier: ^3.1.1 + version: 3.1.1 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 + smol-toml: + specifier: ^1.6.1 + version: 1.7.0 + unified: + specifier: ^11.0.5 + version: 11.0.5 + yaml: + specifier: ^2.8.3 + version: 2.9.0 + devDependencies: + '@types/picomatch': + specifier: ^4.0.0 + version: 4.0.3 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + + packages/core: + dependencies: + p-retry: + specifier: ^8.0.0 + version: 8.0.0 + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + + packages/providers: + dependencies: + '@cloudflare/polystella-core': + specifier: workspace:0.4.0 + version: link:../core + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + playgrounds/mdx-jsx: dependencies: '@astrojs/mdx': From ab709dcfd0706b4bd16b6a1e70b3eccf12192b68 Mon Sep 17 00:00:00 2001 From: Diogo Carneiro Date: Fri, 28 Aug 2026 16:31:24 +0100 Subject: [PATCH 03/13] chore: restructure repository into monorepo with four public packages Reorganize from single-package to monorepo with fixed-version group: @cloudflare/polystella-core (translation engine), @cloudflare/polystella-adapters (file format parsers), @cloudflare/polystella-providers (AI transport factories), and @cloudflare/polystella (Astro integration). Update Changesets config to link all four packages at same version. Split test suite into package-level, workerd, and boundary tests with increased CI timeout. Update all --- .changeset/README.md | 20 +- .changeset/clean-stars-extract.md | 8 + .changeset/config.json | 5 +- .gitattributes | 1 + .github/workflows/ci.yml | 47 +- .github/workflows/docs.yml | 30 +- .github/workflows/pr-triage.yml | 37 +- .github/workflows/preview-releases.yml | 2 +- .github/workflows/publish.yml | 37 - .github/workflows/release.yml | 6 +- .prettierignore | 2 +- AGENTS.md | 58 +- ARCHITECTURE.md | 122 +- CONTRIBUTING.md | 53 +- README.md | 27 +- agent-work/polystella-monorepo-baseline.md | 9 +- agent-work/polystella-monorepo-extraction.md | 205 +- boundary-tests/reusable-packages.test.ts | 266 ++ docs/astro.config.ts | 2 +- docs/examples/README.md | 28 +- docs/examples/direct-packages/index.ts | 29 + docs/examples/direct-packages/tsconfig.json | 12 + docs/package.json | 14 +- docs/scripts/check-examples.ts | 98 +- docs/scripts/check-exports.ts | 112 +- docs/scripts/generate-config-ref.ts | 6 +- .../src/content/docs/concepts/how-it-works.md | 10 + docs/src/content/docs/configuration/index.md | 4 +- .../content/docs/configuration/reference.md | 2 +- .../content/docs/getting-started/install.md | 4 +- .../docs/providers/permanent-errors.md | 75 +- docs/src/content/docs/providers/workers-ai.md | 33 +- .../docs/reference/breaking-changes.md | 23 +- docs/src/content/docs/reference/exports.md | 111 +- fixtures/workerd/worker.ts | 28 + fixtures/workerd/wrangler.jsonc | 6 + llms-full.txt | 457 +-- llms.txt | 21 +- package.json | 160 +- packages/adapters/CHANGELOG.md | 1 + packages/adapters/LICENSE | 21 + packages/adapters/README.md | 5 + packages/adapters/package.json | 9 +- CHANGELOG.md => packages/astro/CHANGELOG.md | 0 packages/astro/LICENSE | 21 + packages/astro/README.md | 5 + client.d.ts => packages/astro/client.d.ts | 0 packages/astro/package.json | 133 + {src => packages/astro/src}/catalog/astro.ts | 0 .../astro/src}/catalog/constants.ts | 0 {src => packages/astro/src}/catalog/index.ts | 0 {src => packages/astro/src}/catalog/locals.ts | 0 .../astro/src}/catalog/middleware.ts | 0 .../astro/src}/catalog/runtime.ts | 0 {src => packages/astro/src}/cli.ts | 0 {src => packages/astro/src}/cli/audit-mdx.ts | 0 {src => packages/astro/src}/cli/check-ui.ts | 0 .../astro/src}/cli/i18n-config.ts | 0 {src => packages/astro/src}/cli/sync-ui.ts | 0 .../astro/src}/cli/translate-ui.ts | 3 +- {src => packages/astro/src}/config/options.ts | 0 {src => packages/astro/src}/content/build.ts | 0 .../astro/src}/content/custom-loader.ts | 0 .../astro/src}/content/extend-schema.ts | 0 .../astro/src}/content/file-loader.ts | 0 {src => packages/astro/src}/content/index.ts | 0 .../astro/src}/glossary/glossary.ts | 42 +- {src => packages/astro/src}/i18n/drift.ts | 0 {src => packages/astro/src}/i18n/index.ts | 0 {src => packages/astro/src}/i18n/loader.ts | 0 {src => packages/astro/src}/i18n/sitemap.ts | 0 {src => packages/astro/src}/i18n/sync.ts | 0 {src => packages/astro/src}/i18n/translate.ts | 0 .../astro/src}/i18n/ui-translate.ts | 13 +- {src => packages/astro/src}/index.ts | 44 +- .../astro/src}/parsing/adapter.ts | 93 +- packages/astro/src/parsing/adapters/json.ts | 46 + .../astro/src/parsing/adapters/markdown.ts | 103 + packages/astro/src/parsing/adapters/toml.ts | 47 + packages/astro/src/parsing/adapters/yaml.ts | 60 + .../astro/src}/parsing/mdx-audit.ts | 3 +- .../astro/src}/parsing/mdx-rules.ts | 15 +- {src => packages/astro/src}/parsing/parse.ts | 47 +- .../astro/src}/parsing/registry.ts | 0 .../astro/src}/parsing/rewrite-links.ts | 0 .../astro/src}/parsing/rewrite-mdx-imports.ts | 0 {src => packages/astro/src}/react/index.ts | 0 {src => packages/astro/src}/recipes/index.ts | 0 .../astro/src}/recipes/starlight.ts | 0 {src => packages/astro/src}/recipes/types.ts | 0 .../astro/src}/routing/expand-routes.ts | 0 {src => packages/astro/src}/routing/shim.ts | 0 .../astro/src}/routing/walk-pages.ts | 0 .../src}/runtime/custom-loader-runtime.ts | 6 +- .../src}/runtime/get-localized-collection.ts | 0 .../astro/src}/runtime/get-localized-entry.ts | 0 {src => packages/astro/src}/runtime/index.ts | 0 .../astro/src}/runtime/localized-href.ts | 0 {src => packages/astro/src}/runtime/locals.ts | 0 .../astro/src}/runtime/middleware-core.ts | 0 .../astro/src}/runtime/middleware.ts | 0 .../astro/src}/source/overrides.ts | 0 {src => packages/astro/src}/source/pool.ts | 0 {src => packages/astro/src}/source/walk.ts | 0 {src => packages/astro/src}/storage/cache.ts | 15 +- {src => packages/astro/src}/storage/hash.ts | 0 .../astro/src}/storage/local-cache.ts | 0 {src => packages/astro/src}/storage/paths.ts | 0 {src => packages/astro/src}/storage/prune.ts | 0 {src => packages/astro/src}/storage/r2.ts | 0 {src => packages/astro/src}/storage/report.ts | 0 packages/astro/src/translation/provider.ts | 40 + .../astro/src}/translation/run.ts | 14 +- {src => packages/astro/src}/version.ts | 0 .../astro/tests}/catalog/astro.test.ts | 0 .../astro/tests}/catalog/catalog.test.ts | 0 .../astro/tests}/catalog/middleware.test.ts | 0 {tests => packages/astro/tests}/cli.test.ts | 0 .../astro/tests}/cli/audit-mdx.test.ts | 0 .../astro/tests}/cli/check-ui.test.ts | 0 .../astro/tests}/cli/sync-ui.test.ts | 0 .../astro/tests}/cli/translate-ui.test.ts | 0 .../astro/tests}/config/options.test.ts | 0 .../astro/tests}/content/content.test.ts | 0 .../tests}/content/content.types.test.ts | 0 .../tests}/content/custom-loader.test.ts | 0 .../astro/tests}/content/file-loader.test.ts | 0 .../tests}/content/schema-extend.test.ts | 0 {tests => packages/astro/tests}/docs.test.ts | 81 +- .../astro/tests}/fixtures/multi-section.md | 0 .../astro/tests}/fixtures/oversize-section.md | 0 .../fixtures/parsing/round-trip/01-minimal.md | 0 .../round-trip/02-frontmatter-shapes.md | 0 .../round-trip/03-inline-formatting.md | 0 .../parsing/round-trip/04-headings.md | 0 .../fixtures/parsing/round-trip/05-lists.md | 0 .../parsing/round-trip/06-code-blocks.md | 0 .../parsing/round-trip/07-blockquotes.md | 0 .../fixtures/parsing/round-trip/08-tables.md | 0 .../parsing/round-trip/09-html-blocks.md | 0 .../parsing/round-trip/10-thematic-breaks.md | 0 .../round-trip/11-empty-frontmatter.md | 0 .../parsing/round-trip/12-no-frontmatter.md | 0 .../parsing/round-trip/13-footnotes.md | 0 .../fixtures/parsing/round-trip/14-images.md | 0 .../parsing/round-trip/15-edge-cases.md | 0 .../astro/tests}/glossary/glossary.test.ts | 3 +- .../astro/tests}/i18n/sitemap-types.test.ts | 0 .../astro/tests}/i18n/sitemap.test.ts | 0 .../astro/tests}/i18n/sync.test.ts | 0 .../astro/tests}/i18n/ui-drift.test.ts | 0 .../astro/tests}/i18n/ui-loader.test.ts | 0 .../tests}/i18n/ui-translate-pipeline.test.ts | 3 +- .../astro/tests}/i18n/ui-translate.test.ts | 0 .../tests}/parsing/adapter-registry.test.ts | 5 +- .../astro/tests}/parsing/apply.test.ts | 3 +- .../astro/tests}/parsing/extract.test.ts | 11 +- .../astro/tests}/parsing/json-adapter.test.ts | 2 +- .../astro/tests}/parsing/key-paths.test.ts | 2 +- .../tests}/parsing/markdown-adapter.test.ts | 14 +- .../astro/tests}/parsing/mdx-rules.test.ts | 8 +- .../astro/tests}/parsing/mdx.test.ts | 0 .../astro/tests}/parsing/parse.test.ts | 23 +- .../tests}/parsing/rewrite-links.test.ts | 0 .../astro/tests}/parsing/round-trip.test.ts | 3 +- .../astro/tests}/parsing/spot.test.ts | 3 +- .../astro/tests}/parsing/toml-adapter.test.ts | 2 +- .../astro/tests}/parsing/yaml-adapter.test.ts | 2 +- .../astro/tests}/recipes/recipes.test.ts | 0 .../tests}/routing/expand-routes.test.ts | 0 .../astro/tests}/routing/routing.test.ts | 0 .../astro/tests}/routing/walk-pages.test.ts | 0 .../runtime/custom-loader-runtime.test.ts | 3 +- .../runtime/get-localized-collection.test.ts | 0 .../get-localized-collection.types.test.ts | 0 .../tests}/runtime/localized-href.test.ts | 0 .../astro/tests}/runtime/middleware.test.ts | 0 .../astro/tests}/runtime/runtime.test.ts | 0 {tests => packages/astro/tests}/smoke.test.ts | 2 +- .../astro/tests}/source/overrides.test.ts | 0 .../astro/tests}/source/pool.test.ts | 0 .../astro/tests}/source/walk.test.ts | 0 .../astro/tests}/storage/cache.test.ts | 7 +- .../astro/tests}/storage/hash.test.ts | 0 .../astro/tests}/storage/local-cache.test.ts | 0 .../astro/tests}/storage/prune.test.ts | 0 .../astro/tests}/storage/r2.test.ts | 0 .../astro/tests}/storage/report.test.ts | 0 .../astro/tests}/translation/batch.test.ts | 3 +- .../astro/tests}/translation/prompt.test.ts | 4 +- .../astro/tests}/translation/provider.test.ts | 8 +- .../astro/tests}/translation/run.test.ts | 6 +- .../translation/translate-segments.test.ts | 5 +- .../astro/tsconfig.build.json | 0 tsconfig.json => packages/astro/tsconfig.json | 0 .../astro/types-internal}/astro-content.d.ts | 0 .../astro/vitest.config.ts | 0 packages/core/CHANGELOG.md | 1 + packages/core/LICENSE | 21 + packages/core/README.md | 5 + packages/core/package.json | 7 +- packages/providers/CHANGELOG.md | 1 + packages/providers/LICENSE | 21 + packages/providers/README.md | 5 + packages/providers/package.json | 9 +- playgrounds/mdx-jsx/package.json | 4 +- .../mdx-jsx/scripts/translate-local.mjs | 4 +- .../mdx-jsx/scripts/translate-workers-ai.mjs | 22 +- pnpm-lock.yaml | 2643 ++++++++--------- pnpm-workspace.yaml | 9 +- scripts/build-llms-full.mjs | 2 +- scripts/check-monorepo-baseline.mjs | 213 ++ scripts/check-packages.mjs | 437 +++ scripts/check-workerd-portability.mjs | 216 ++ scripts/configure-git-hooks.mjs | 3 + scripts/run-command.mjs | 104 + skills/polystella-consumer/SKILL.md | 32 +- skills/polystella-contributor/SKILL.md | 182 +- src/parsing/adapters/json.ts | 199 -- src/parsing/adapters/markdown.ts | 263 -- src/parsing/adapters/toml.ts | 185 -- src/parsing/adapters/yaml.ts | 217 -- src/parsing/apply.ts | 274 -- src/parsing/extract.ts | 207 -- src/parsing/key-paths.ts | 327 -- src/parsing/mdx-jsx-attributes.ts | 108 - src/parsing/mdx-placeholders.ts | 309 -- src/parsing/mdx-static-data.ts | 326 -- src/parsing/traverse.ts | 108 - src/translation/batch.ts | 149 - src/translation/logger.ts | 15 - src/translation/prompt.ts | 245 -- src/translation/provider.ts | 327 -- src/translation/translate-segments.ts | 95 - tsconfig.workerd.json | 7 + vitest.boundaries.config.ts | 8 + vitest.workerd.config.ts | 9 + workerd-tests/portability.test.ts | 140 + 238 files changed, 4661 insertions(+), 5957 deletions(-) create mode 100644 .changeset/clean-stars-extract.md create mode 100644 .gitattributes delete mode 100644 .github/workflows/publish.yml create mode 100644 boundary-tests/reusable-packages.test.ts create mode 100644 docs/examples/direct-packages/index.ts create mode 100644 docs/examples/direct-packages/tsconfig.json create mode 100644 fixtures/workerd/worker.ts create mode 100644 fixtures/workerd/wrangler.jsonc create mode 100644 packages/adapters/CHANGELOG.md create mode 100644 packages/adapters/LICENSE create mode 100644 packages/adapters/README.md rename CHANGELOG.md => packages/astro/CHANGELOG.md (100%) create mode 100644 packages/astro/LICENSE create mode 100644 packages/astro/README.md rename client.d.ts => packages/astro/client.d.ts (100%) create mode 100644 packages/astro/package.json rename {src => packages/astro/src}/catalog/astro.ts (100%) rename {src => packages/astro/src}/catalog/constants.ts (100%) rename {src => packages/astro/src}/catalog/index.ts (100%) rename {src => packages/astro/src}/catalog/locals.ts (100%) rename {src => packages/astro/src}/catalog/middleware.ts (100%) rename {src => packages/astro/src}/catalog/runtime.ts (100%) rename {src => packages/astro/src}/cli.ts (100%) rename {src => packages/astro/src}/cli/audit-mdx.ts (100%) rename {src => packages/astro/src}/cli/check-ui.ts (100%) rename {src => packages/astro/src}/cli/i18n-config.ts (100%) rename {src => packages/astro/src}/cli/sync-ui.ts (100%) rename {src => packages/astro/src}/cli/translate-ui.ts (99%) rename {src => packages/astro/src}/config/options.ts (100%) rename {src => packages/astro/src}/content/build.ts (100%) rename {src => packages/astro/src}/content/custom-loader.ts (100%) rename {src => packages/astro/src}/content/extend-schema.ts (100%) rename {src => packages/astro/src}/content/file-loader.ts (100%) rename {src => packages/astro/src}/content/index.ts (100%) rename {src => packages/astro/src}/glossary/glossary.ts (81%) rename {src => packages/astro/src}/i18n/drift.ts (100%) rename {src => packages/astro/src}/i18n/index.ts (100%) rename {src => packages/astro/src}/i18n/loader.ts (100%) rename {src => packages/astro/src}/i18n/sitemap.ts (100%) rename {src => packages/astro/src}/i18n/sync.ts (100%) rename {src => packages/astro/src}/i18n/translate.ts (100%) rename {src => packages/astro/src}/i18n/ui-translate.ts (97%) rename {src => packages/astro/src}/index.ts (93%) rename {src => packages/astro/src}/parsing/adapter.ts (81%) create mode 100644 packages/astro/src/parsing/adapters/json.ts create mode 100644 packages/astro/src/parsing/adapters/markdown.ts create mode 100644 packages/astro/src/parsing/adapters/toml.ts create mode 100644 packages/astro/src/parsing/adapters/yaml.ts rename {src => packages/astro/src}/parsing/mdx-audit.ts (99%) rename {src => packages/astro/src}/parsing/mdx-rules.ts (91%) rename {src => packages/astro/src}/parsing/parse.ts (93%) rename {src => packages/astro/src}/parsing/registry.ts (100%) rename {src => packages/astro/src}/parsing/rewrite-links.ts (100%) rename {src => packages/astro/src}/parsing/rewrite-mdx-imports.ts (100%) rename {src => packages/astro/src}/react/index.ts (100%) rename {src => packages/astro/src}/recipes/index.ts (100%) rename {src => packages/astro/src}/recipes/starlight.ts (100%) rename {src => packages/astro/src}/recipes/types.ts (100%) rename {src => packages/astro/src}/routing/expand-routes.ts (100%) rename {src => packages/astro/src}/routing/shim.ts (100%) rename {src => packages/astro/src}/routing/walk-pages.ts (100%) rename {src => packages/astro/src}/runtime/custom-loader-runtime.ts (98%) rename {src => packages/astro/src}/runtime/get-localized-collection.ts (100%) rename {src => packages/astro/src}/runtime/get-localized-entry.ts (100%) rename {src => packages/astro/src}/runtime/index.ts (100%) rename {src => packages/astro/src}/runtime/localized-href.ts (100%) rename {src => packages/astro/src}/runtime/locals.ts (100%) rename {src => packages/astro/src}/runtime/middleware-core.ts (100%) rename {src => packages/astro/src}/runtime/middleware.ts (100%) rename {src => packages/astro/src}/source/overrides.ts (100%) rename {src => packages/astro/src}/source/pool.ts (100%) rename {src => packages/astro/src}/source/walk.ts (100%) rename {src => packages/astro/src}/storage/cache.ts (96%) rename {src => packages/astro/src}/storage/hash.ts (100%) rename {src => packages/astro/src}/storage/local-cache.ts (100%) rename {src => packages/astro/src}/storage/paths.ts (100%) rename {src => packages/astro/src}/storage/prune.ts (100%) rename {src => packages/astro/src}/storage/r2.ts (100%) rename {src => packages/astro/src}/storage/report.ts (100%) create mode 100644 packages/astro/src/translation/provider.ts rename {src => packages/astro/src}/translation/run.ts (98%) rename {src => packages/astro/src}/version.ts (100%) rename {tests => packages/astro/tests}/catalog/astro.test.ts (100%) rename {tests => packages/astro/tests}/catalog/catalog.test.ts (100%) rename {tests => packages/astro/tests}/catalog/middleware.test.ts (100%) rename {tests => packages/astro/tests}/cli.test.ts (100%) rename {tests => packages/astro/tests}/cli/audit-mdx.test.ts (100%) rename {tests => packages/astro/tests}/cli/check-ui.test.ts (100%) rename {tests => packages/astro/tests}/cli/sync-ui.test.ts (100%) rename {tests => packages/astro/tests}/cli/translate-ui.test.ts (100%) rename {tests => packages/astro/tests}/config/options.test.ts (100%) rename {tests => packages/astro/tests}/content/content.test.ts (100%) rename {tests => packages/astro/tests}/content/content.types.test.ts (100%) rename {tests => packages/astro/tests}/content/custom-loader.test.ts (100%) rename {tests => packages/astro/tests}/content/file-loader.test.ts (100%) rename {tests => packages/astro/tests}/content/schema-extend.test.ts (100%) rename {tests => packages/astro/tests}/docs.test.ts (77%) rename {tests => packages/astro/tests}/fixtures/multi-section.md (100%) rename {tests => packages/astro/tests}/fixtures/oversize-section.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/01-minimal.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/02-frontmatter-shapes.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/03-inline-formatting.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/04-headings.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/05-lists.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/06-code-blocks.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/07-blockquotes.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/08-tables.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/09-html-blocks.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/10-thematic-breaks.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/11-empty-frontmatter.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/12-no-frontmatter.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/13-footnotes.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/14-images.md (100%) rename {tests => packages/astro/tests}/fixtures/parsing/round-trip/15-edge-cases.md (100%) rename {tests => packages/astro/tests}/glossary/glossary.test.ts (98%) rename {tests => packages/astro/tests}/i18n/sitemap-types.test.ts (100%) rename {tests => packages/astro/tests}/i18n/sitemap.test.ts (100%) rename {tests => packages/astro/tests}/i18n/sync.test.ts (100%) rename {tests => packages/astro/tests}/i18n/ui-drift.test.ts (100%) rename {tests => packages/astro/tests}/i18n/ui-loader.test.ts (100%) rename {tests => packages/astro/tests}/i18n/ui-translate-pipeline.test.ts (99%) rename {tests => packages/astro/tests}/i18n/ui-translate.test.ts (100%) rename {tests => packages/astro/tests}/parsing/adapter-registry.test.ts (96%) rename {tests => packages/astro/tests}/parsing/apply.test.ts (98%) rename {tests => packages/astro/tests}/parsing/extract.test.ts (95%) rename {tests => packages/astro/tests}/parsing/json-adapter.test.ts (99%) rename {tests => packages/astro/tests}/parsing/key-paths.test.ts (99%) rename {tests => packages/astro/tests}/parsing/markdown-adapter.test.ts (96%) rename {tests => packages/astro/tests}/parsing/mdx-rules.test.ts (95%) rename {tests => packages/astro/tests}/parsing/mdx.test.ts (100%) rename {tests => packages/astro/tests}/parsing/parse.test.ts (72%) rename {tests => packages/astro/tests}/parsing/rewrite-links.test.ts (100%) rename {tests => packages/astro/tests}/parsing/round-trip.test.ts (94%) rename {tests => packages/astro/tests}/parsing/spot.test.ts (98%) rename {tests => packages/astro/tests}/parsing/toml-adapter.test.ts (99%) rename {tests => packages/astro/tests}/parsing/yaml-adapter.test.ts (99%) rename {tests => packages/astro/tests}/recipes/recipes.test.ts (100%) rename {tests => packages/astro/tests}/routing/expand-routes.test.ts (100%) rename {tests => packages/astro/tests}/routing/routing.test.ts (100%) rename {tests => packages/astro/tests}/routing/walk-pages.test.ts (100%) rename {tests => packages/astro/tests}/runtime/custom-loader-runtime.test.ts (99%) rename {tests => packages/astro/tests}/runtime/get-localized-collection.test.ts (100%) rename {tests => packages/astro/tests}/runtime/get-localized-collection.types.test.ts (100%) rename {tests => packages/astro/tests}/runtime/localized-href.test.ts (100%) rename {tests => packages/astro/tests}/runtime/middleware.test.ts (100%) rename {tests => packages/astro/tests}/runtime/runtime.test.ts (100%) rename {tests => packages/astro/tests}/smoke.test.ts (99%) rename {tests => packages/astro/tests}/source/overrides.test.ts (100%) rename {tests => packages/astro/tests}/source/pool.test.ts (100%) rename {tests => packages/astro/tests}/source/walk.test.ts (100%) rename {tests => packages/astro/tests}/storage/cache.test.ts (99%) rename {tests => packages/astro/tests}/storage/hash.test.ts (100%) rename {tests => packages/astro/tests}/storage/local-cache.test.ts (100%) rename {tests => packages/astro/tests}/storage/prune.test.ts (100%) rename {tests => packages/astro/tests}/storage/r2.test.ts (100%) rename {tests => packages/astro/tests}/storage/report.test.ts (100%) rename {tests => packages/astro/tests}/translation/batch.test.ts (98%) rename {tests => packages/astro/tests}/translation/prompt.test.ts (98%) rename {tests => packages/astro/tests}/translation/provider.test.ts (99%) rename {tests => packages/astro/tests}/translation/run.test.ts (99%) rename {tests => packages/astro/tests}/translation/translate-segments.test.ts (97%) rename tsconfig.build.json => packages/astro/tsconfig.build.json (100%) rename tsconfig.json => packages/astro/tsconfig.json (100%) rename {types-internal => packages/astro/types-internal}/astro-content.d.ts (100%) rename vitest.config.ts => packages/astro/vitest.config.ts (100%) create mode 100644 packages/core/CHANGELOG.md create mode 100644 packages/core/LICENSE create mode 100644 packages/core/README.md create mode 100644 packages/providers/CHANGELOG.md create mode 100644 packages/providers/LICENSE create mode 100644 packages/providers/README.md create mode 100644 scripts/check-monorepo-baseline.mjs create mode 100644 scripts/check-packages.mjs create mode 100644 scripts/check-workerd-portability.mjs create mode 100644 scripts/configure-git-hooks.mjs create mode 100644 scripts/run-command.mjs delete mode 100644 src/parsing/adapters/json.ts delete mode 100644 src/parsing/adapters/markdown.ts delete mode 100644 src/parsing/adapters/toml.ts delete mode 100644 src/parsing/adapters/yaml.ts delete mode 100644 src/parsing/apply.ts delete mode 100644 src/parsing/extract.ts delete mode 100644 src/parsing/key-paths.ts delete mode 100644 src/parsing/mdx-jsx-attributes.ts delete mode 100644 src/parsing/mdx-placeholders.ts delete mode 100644 src/parsing/mdx-static-data.ts delete mode 100644 src/parsing/traverse.ts delete mode 100644 src/translation/batch.ts delete mode 100644 src/translation/logger.ts delete mode 100644 src/translation/prompt.ts delete mode 100644 src/translation/provider.ts delete mode 100644 src/translation/translate-segments.ts create mode 100644 tsconfig.workerd.json create mode 100644 vitest.boundaries.config.ts create mode 100644 vitest.workerd.config.ts create mode 100644 workerd-tests/portability.test.ts diff --git a/.changeset/README.md b/.changeset/README.md index 13e865c..61afbc4 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -1,7 +1,7 @@ # Changesets This directory tracks pending version + changelog entries between -releases. Each PR that user-visibly changes the package adds one +releases. Each PR that user-visibly changes a public package adds one markdown file here describing the change. ## Adding a changeset @@ -12,13 +12,15 @@ pnpm changeset …which interactively asks: -- **Which packages are affected?** Just `@cloudflare/polystella` for most - changes. The `polystella-docs` site is ignored (`ignore` list in - `config.json`). +- **Which packages are affected?** Select every package changed by the PR: + `@cloudflare/polystella-core`, `@cloudflare/polystella-adapters`, + `@cloudflare/polystella-providers`, and/or `@cloudflare/polystella`. + They form one fixed group, so releasing any one releases all four at + the same version. The private root and `polystella-docs` are not released. - **Is the change major / minor / patch?** Pre-1.0, "major" stays reserved for 1.0; bump minor for breaking changes within 0.x, patch otherwise. -- **A summary.** One-liner that lands in `CHANGELOG.md`. +- **A summary.** One-liner that lands in the affected package changelogs. The result is a small markdown file in this directory. Commit it with the PR. @@ -28,12 +30,10 @@ with the PR. Pending changesets are consumed by the versioning step for a release. That step: -- Bumps `package.json`'s version per the changeset severities. -- Updates `CHANGELOG.md` with each changeset's summary. +- Bumps all four public package manifests to the same version. +- Updates the package-local `CHANGELOG.md` files. - Deletes the consumed changeset files. Publishing is automated from `.github/workflows/release.yml`. Merging the generated release PR back to `main` publishes to npm via Trusted -Publishing, so routine releases must not use an npm token secret. The -tag-based `.github/workflows/publish.yml` workflow remains a manual -fallback for `v*` tags. +Publishing, so routine releases must not use an npm token secret. diff --git a/.changeset/clean-stars-extract.md b/.changeset/clean-stars-extract.md new file mode 100644 index 0000000..3ffb992 --- /dev/null +++ b/.changeset/clean-stars-extract.md @@ -0,0 +1,8 @@ +--- +"@cloudflare/polystella-core": minor +"@cloudflare/polystella-adapters": minor +"@cloudflare/polystella-providers": minor +"@cloudflare/polystella": minor +--- + +Extract PolyStella into four lockstep packages with portable core, adapters, providers, and Astro integration. diff --git a/.changeset/config.json b/.changeset/config.json index c4fc267..165e816 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,9 +7,12 @@ } ], "commit": false, - "fixed": [], + "fixed": [ + ["@cloudflare/polystella-core", "@cloudflare/polystella-adapters", "@cloudflare/polystella-providers", "@cloudflare/polystella"] + ], "linked": [], "access": "public", + "privatePackages": false, "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": ["polystella-docs"] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 334b77e..04735f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: test: name: Test runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -30,8 +30,18 @@ jobs: node-version: 24.x cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm test - - run: pnpm exec tsc --noEmit + - name: Audit production dependencies + run: pnpm audit:prod + - name: Test reusable packages under Node + run: pnpm test:packages + - name: Test Astro package under Node + run: pnpm test:node + - name: Test reusable packages under workerd + run: pnpm test:workerd + - name: Check package boundaries + run: pnpm test:boundaries + - name: Typecheck all public packages + run: pnpm typecheck format: name: Prettier check @@ -74,9 +84,9 @@ jobs: fi build: - name: Library + CLI build + name: Topological package build runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -87,23 +97,26 @@ jobs: node-version: 24.x cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm build + - name: Build all public packages in dependency order + run: pnpm build + - name: Check packed packages in a clean consumer + run: pnpm check:packages - name: Verify dist tree # Smoke-check every exports entry resolves to an emitted file. run: | - test -f dist/cli.js - test -x dist/cli.js - test -f dist/index.js - test -f dist/index.d.ts - test -f dist/runtime/index.js - test -f dist/runtime/middleware.js - test -f dist/runtime/locals.d.ts - test -f dist/content/index.js - test -f dist/i18n/index.js - test -f dist/react/index.js + test -f packages/astro/dist/cli.js + test -x packages/astro/dist/cli.js + test -f packages/astro/dist/index.js + test -f packages/astro/dist/index.d.ts + test -f packages/astro/dist/runtime/index.js + test -f packages/astro/dist/runtime/middleware.js + test -f packages/astro/dist/runtime/locals.d.ts + test -f packages/astro/dist/content/index.js + test -f packages/astro/dist/i18n/index.js + test -f packages/astro/dist/react/index.js - name: Smoke-test the CLI bundle under bare Node # No tsx, no Vite — exactly the environment a downstream # consumer's standalone `polystella ` invocation # uses. Regression guard for the Node 24 # `ERR_UNSUPPORTED_NODE_MODULES_TYPESCRIPT` class of bug. - run: node dist/cli.js --version + run: node packages/astro/dist/cli.js --version diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c027d4c..d718996 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,16 +6,36 @@ on: - main paths: - "docs/**" - - "src/config/options.ts" + - "packages/astro/src/config/options.ts" + - "packages/core/package.json" + - "packages/adapters/package.json" + - "packages/providers/package.json" + - "packages/astro/package.json" - "package.json" + - "AGENTS.md" + - "ARCHITECTURE.md" + - "skills/polystella-consumer/SKILL.md" + - "skills/polystella-contributor/SKILL.md" + - "scripts/build-llms-full.mjs" + - "llms-full.txt" - ".github/workflows/docs.yml" pull_request: branches: - main paths: - "docs/**" - - "src/config/options.ts" + - "packages/astro/src/config/options.ts" + - "packages/core/package.json" + - "packages/adapters/package.json" + - "packages/providers/package.json" + - "packages/astro/package.json" - "package.json" + - "AGENTS.md" + - "ARCHITECTURE.md" + - "skills/polystella-consumer/SKILL.md" + - "skills/polystella-contributor/SKILL.md" + - "scripts/build-llms-full.mjs" + - "llms-full.txt" - ".github/workflows/docs.yml" permissions: @@ -40,9 +60,13 @@ jobs: node-version: 24.x cache: pnpm - run: pnpm install --frozen-lockfile + - name: Verify generated agent context is current + run: | + pnpm build:llms + git diff --exit-code llms-full.txt - name: Build docs (includes config-reference generation) run: pnpm --filter polystella-docs build - name: Verify every public export is documented run: pnpm --filter polystella-docs check-exports - - name: Verify every example builds (currently no-op) + - name: Verify examples compile run: pnpm --filter polystella-docs check-examples diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index 4422040..1b277ed 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -66,19 +66,32 @@ jobs: } const areaMap = { - "area/catalog": (file) => file.startsWith("src/catalog/") || file.includes("CATALOG_ONLY_PLAN"), - "area/cli": (file) => file === "src/cli.ts" || file.startsWith("src/cli/"), - "area/config": (file) => file.startsWith("src/config/"), - "area/content": (file) => file.startsWith("src/content/"), - "area/docs": (file) => file.startsWith("docs/") || file === "README.md" || file === "CHANGELOG.md", + "area/catalog": (file) => file.startsWith("packages/astro/src/catalog/") || file.includes("CATALOG_ONLY_PLAN"), + "area/cli": (file) => file === "packages/astro/src/cli.ts" || file.startsWith("packages/astro/src/cli/"), + "area/config": (file) => file.startsWith("packages/astro/src/config/"), + "area/content": (file) => file.startsWith("packages/astro/src/content/"), + "area/docs": (file) => + file.startsWith("docs/") || + file === "README.md" || + /^packages\/(astro|core|adapters|providers)\/(README|CHANGELOG)\.md$/.test(file), "area/github": (file) => file.startsWith(".github/"), - "area/i18n": (file) => file.startsWith("src/i18n/"), - "area/parsing": (file) => file.startsWith("src/parsing/"), - "area/routing": (file) => file.startsWith("src/routing/"), - "area/runtime": (file) => file.startsWith("src/runtime/") || file.startsWith("src/react/"), - "area/storage": (file) => file.startsWith("src/storage/"), - "area/tests": (file) => file.startsWith("tests/"), - "area/translation": (file) => file.startsWith("src/translation/") || file.startsWith("src/glossary/"), + "area/i18n": (file) => file.startsWith("packages/astro/src/i18n/"), + "area/parsing": (file) => + file.startsWith("packages/adapters/") || file.startsWith("packages/astro/src/parsing/"), + "area/routing": (file) => file.startsWith("packages/astro/src/routing/"), + "area/runtime": (file) => + file.startsWith("packages/astro/src/runtime/") || file.startsWith("packages/astro/src/react/"), + "area/storage": (file) => file.startsWith("packages/astro/src/storage/"), + "area/tests": (file) => + /^packages\/(astro|core|adapters|providers)\/tests\//.test(file) || + file.startsWith("boundary-tests/") || + file.startsWith("workerd-tests/") || + file.startsWith("fixtures/workerd/"), + "area/translation": (file) => + file.startsWith("packages/core/") || + file.startsWith("packages/providers/") || + file.startsWith("packages/astro/src/translation/") || + file.startsWith("packages/astro/src/glossary/"), }; for (const file of files) { diff --git a/.github/workflows/preview-releases.yml b/.github/workflows/preview-releases.yml index 1e2c957..acd0da0 100644 --- a/.github/workflows/preview-releases.yml +++ b/.github/workflows/preview-releases.yml @@ -35,4 +35,4 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build - name: Publish preview - run: pnpm exec pkg-pr-new publish --pnpm --commentWithSha . + run: pnpm exec pkg-pr-new publish --pnpm --commentWithSha './packages/*' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index daffde8..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Publish - -on: - push: - tags: - - "v*" - -permissions: - contents: read - id-token: write - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -jobs: - publish: - name: Publish to npm - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 24.x - cache: pnpm - registry-url: "https://registry.npmjs.org" - - run: npm install -g "npm@^11.5.1" - - run: pnpm install --frozen-lockfile - - run: pnpm test - - run: pnpm exec tsc --noEmit - - run: pnpm build - - name: Publish package - run: npm publish --access public diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ef8a29..f3f16df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,6 @@ on: push: branches: - main - workflow_dispatch: permissions: {} @@ -34,9 +33,12 @@ jobs: registry-url: "https://registry.npmjs.org" - run: npm install -g "npm@^11.5.1" - run: pnpm install --frozen-lockfile + - run: pnpm audit:prod - run: pnpm test - - run: pnpm exec tsc --noEmit + - run: pnpm typecheck - run: pnpm build + - run: pnpm check:baseline + - run: pnpm check:packages - name: Create release PR or publish uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1 with: diff --git a/.prettierignore b/.prettierignore index 4b8ee48..f87c3b3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,4 +15,4 @@ docs/src/content/docs/configuration/reference.md # variants (e.g. `***` / `___` / `---` thematic breaks, inline vs. # block footnote definitions). Prettier would normalise them away # and defeat the test corpus. -tests/fixtures/parsing/round-trip/ +packages/astro/tests/fixtures/parsing/round-trip/ diff --git a/AGENTS.md b/AGENTS.md index d8f695b..6c91e1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,18 +23,19 @@ section numbers. Inserting new sections never breaks links. ## Commands -| Command | What it does | -| :----------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pnpm test` | Run vitest (1104 tests / 56 files / ~1.2s at time of writing). | -| `pnpm test:watch` | Vitest in watch mode. | -| `pnpm build` | Compile `src/` → `dist/` via `tsc -p tsconfig.build.json` (mirrored layout, `.js` + `.d.ts` + sourcemaps + declaration maps). Produces the standalone `polystella` CLI at `dist/cli.js` and library entries. | -| `pnpm exec tsc --noEmit` | Typecheck against the root `tsconfig.json` (which includes tests). The build config (`tsconfig.build.json`) sets `noEmit: false` and narrows `include` to `src/**`. | -| `pnpm changeset` | Add a Changesets entry for package-affecting work. Use `pnpm changeset add --empty` only for changes that intentionally do not need a package release. | +| Command | What it does | +| :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm test` | Run all package, Astro, workerd, and boundary tests. | +| `pnpm test:watch` | Run the Astro package tests in watch mode. | +| `pnpm build` | Build all four public packages. Astro emits its standalone CLI and library entries under `packages/astro/dist/`. | +| `pnpm typecheck` | Build all four packages, then typecheck every public package against its package-local `tsconfig.json`. | +| `pnpm check:packages` | Pack all four public packages and exercise every export from a clean temporary consumer. | +| `pnpm changeset` | Add a Changesets entry for package-affecting work. Use `pnpm changeset add --empty` only for changes that intentionally do not need a package release. | No lint step yet. > Test counts age. The authoritative count is `pnpm test`'s output; -> the number here is a snapshot pinned by [`tests/docs.test.ts`](./tests/docs.test.ts). +> the number here is a snapshot pinned by [`packages/astro/tests/docs.test.ts`](./packages/astro/tests/docs.test.ts). --- @@ -44,24 +45,29 @@ Task → entry-point file(s) → key contract → deep-dive link. | Task | Entry point | Contract | See | | :----------------------------------------------------- | :----------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------- | -| Add a file-format adapter | `src/parsing/adapters/.ts`; register in `src/parsing/registry.ts` | `FileTypeAdapter` in `src/parsing/adapter.ts` | [#adapter-contract](./ARCHITECTURE.md#adapter-contract); recipe in [contributor SKILL](./skills/polystella-contributor/SKILL.md#add-adapter) | -| Add a CLI subcommand | Handler in `src/cli/.ts`; register in `src/cli.ts` (`parseSubcommand` + switch) | Argv parser + `run(args, deps)` | Recipe in [contributor SKILL](./skills/polystella-contributor/SKILL.md#add-cli-subcommand) | -| Add a translation provider | New branch in `createTranslator` (`src/translation/provider.ts`) | `Translator` interface; permanent vs retriable error classification | [#translator-contract](./ARCHITECTURE.md#translator-contract); recipe in [contributor SKILL](./skills/polystella-contributor/SKILL.md#add-provider) | -| Change cache key formula | `src/storage/hash.ts` | **Invariant 1** — cache-wide invalidation | [#cache-key](./ARCHITECTURE.md#cache-key) | -| Edit translation batching | `src/translation/batch.ts`, `src/translation/translate-segments.ts` | **Invariant 2** — `flat(groups) === segments` | [#translation-batching](./ARCHITECTURE.md#translation-batching) | -| Modify cache/storage behaviour | `src/storage/{cache,r2,prune,local-cache,report}.ts` | Apply-before-PUT (**Invariant 3**); index isolation (**Invariant 4**) | [#cache-write-order](./ARCHITECTURE.md#cache-write-order), [#local-staging-index](./ARCHITECTURE.md#local-staging-index) | -| Modify runtime APIs (entry/collection/href/middleware) | `src/runtime/*` | Bridge timing (**Invariant 5**); per-locale closures | [#runtime-bridge](./ARCHITECTURE.md#runtime-bridge) | -| Modify routing shims | `src/routing/{shim,expand-routes,walk-pages}.ts` | Stale shims nuked per build; CSS via `routesImports` | [#routing-shims](./ARCHITECTURE.md#routing-shims) | -| Edit UI-string handling | `src/i18n/*`, `src/cli/{check,sync,translate}-ui.ts` | Three drift modes; layout-aware writer; `{{token}}` preservation | [#ui-strings](./ARCHITECTURE.md#ui-strings) | -| Edit catalog-only adoption | `src/catalog/*`, `src/i18n/{translate,drift,sync}.ts` | Pure imports; middleware binds only `t` + `lhref` | [`CATALOG_ONLY_PLAN.md`](./CATALOG_ONLY_PLAN.md); [consumer SKILL](./skills/polystella-consumer/SKILL.md#catalog-only-adoption) | -| Edit content-collection wiring | `src/content/*` | Sibling collections; custom-loader wrapper; bridge timing | [#runtime-bridge](./ARCHITECTURE.md#runtime-bridge) | +| Add a file-format adapter | `packages/adapters/src/adapters/.ts`; wrap/register in `packages/astro/src/parsing/` | `FileAdapter` in `packages/adapters/src/adapter.ts` | [#adapter-contract](./ARCHITECTURE.md#adapter-contract); recipe in [contributor SKILL](./skills/polystella-contributor/SKILL.md#add-adapter) | +| Add a CLI subcommand | Handler in `packages/astro/src/cli/.ts`; register in `packages/astro/src/cli.ts` | Argv parser + `run(args, deps)` | Recipe in [contributor SKILL](./skills/polystella-contributor/SKILL.md#add-cli-subcommand) | +| Add a translation provider | Factory in `packages/providers/src/`; map config in `packages/astro/src/translation/provider.ts` | `Translator` in `packages/core/src/translator.ts` | [#translator-contract](./ARCHITECTURE.md#translator-contract); recipe in [contributor SKILL](./skills/polystella-contributor/SKILL.md#add-provider) | +| Change cache key formula | `packages/astro/src/storage/hash.ts` | **Invariant 1** — cache-wide invalidation | [#cache-key](./ARCHITECTURE.md#cache-key) | +| Edit translation batching | `packages/core/src/{batch,translate-segments}.ts` | **Invariant 2** — `flat(groups) === segments` | [#translation-batching](./ARCHITECTURE.md#translation-batching) | +| Modify cache/storage behaviour | `packages/astro/src/storage/{cache,r2,prune,local-cache,report}.ts` | Apply-before-PUT (**Invariant 3**); index isolation (**Invariant 4**) | [#cache-write-order](./ARCHITECTURE.md#cache-write-order), [#local-staging-index](./ARCHITECTURE.md#local-staging-index) | +| Modify runtime APIs (entry/collection/href/middleware) | `packages/astro/src/runtime/*` | Bridge timing (**Invariant 5**); per-locale closures | [#runtime-bridge](./ARCHITECTURE.md#runtime-bridge) | +| Modify routing shims | `packages/astro/src/routing/{shim,expand-routes,walk-pages}.ts` | Stale shims nuked per build; CSS via `routesImports` | [#routing-shims](./ARCHITECTURE.md#routing-shims) | +| Edit UI-string handling | `packages/astro/src/i18n/*`, `packages/astro/src/cli/{check,sync,translate}-ui.ts` | Three drift modes; layout-aware writer; `{{token}}` preservation | [#ui-strings](./ARCHITECTURE.md#ui-strings) | +| Edit catalog-only adoption | `packages/astro/src/catalog/*`, `packages/astro/src/i18n/{translate,drift,sync}.ts` | Pure imports; middleware binds only `t` + `lhref` | [`CATALOG_ONLY_PLAN.md`](./CATALOG_ONLY_PLAN.md); [consumer SKILL](./skills/polystella-consumer/SKILL.md#catalog-only-adoption) | +| Edit content-collection wiring | `packages/astro/src/content/*` | Sibling collections; custom-loader wrapper; bridge timing | [#runtime-bridge](./ARCHITECTURE.md#runtime-bridge) | | Debug a translation that's wrong | Start: `pnpm translate --dry-run` to inspect planned R2 keys; `LOG_LEVEL=debug` for batch detail | — | Recipe in [contributor SKILL](./skills/polystella-contributor/SKILL.md#debug-translation) | | Tune cold-cache build performance | `r2.bulkListOnStart`, `concurrency`, `batchInputTokenBudget` knobs | — | [#bulk-prelist](./ARCHITECTURE.md#bulk-prelist), [#translation-batching](./ARCHITECTURE.md#translation-batching) | -If your task isn't on this list, the answer is in `src//` +If your task isn't on this list, the answer is in `packages/astro/src//` matching one of the subsystem sections in [ARCHITECTURE.md](./ARCHITECTURE.md). +The root is private. Reusable package code uses standard Web APIs and must +work without `nodejs_compat`, although consumers may enable it. Import +low-level contracts from core, formats from adapters, and transports from +providers; do not add compatibility shims to the Astro package. + --- ## Invariants @@ -92,10 +98,11 @@ to the explanatory section when adding code that touches one. `pnpm changeset add --empty` only when the change deliberately does not require a package release (for example, docs-site-only or CI-only maintenance). -- Bump the package version in `package.json` only — `POLYSTELLA_VERSION` - (in `src/version.ts`) reads it at module-load time via a JSON - import attribute, so the constant flows automatically through to - `dist/version.js` after `pnpm build`. [→ #version-constant](./ARCHITECTURE.md#version-constant) +- Let Changesets version the fixed four-package group; do not manually bump + individual manifests. `POLYSTELLA_VERSION` in + `packages/astro/src/version.ts` reads the Astro manifest at module-load time + and flows to `packages/astro/dist/version.js` after `pnpm build`. + [→ #version-constant](./ARCHITECTURE.md#version-constant) - Mirror filesystem path semantics across OS: forward slashes for R2 keys, `path.sep` for local I/O. - Forward `signal: AbortSignal` when adding a new async function on @@ -156,7 +163,8 @@ Tiered so you can scan the ones that matter for your change. Before pushing: - `pnpm test` must pass. -- `pnpm exec tsc --noEmit` must pass (strict mode). +- `pnpm typecheck` must pass (strict mode). +- `pnpm check:packages` must pass for packaging or export changes. - For changes to the translation pipeline, run end-to-end against a real consumer's fixtures: `polystella translate --dry-run` walks the full pipeline without hitting AI/R2. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e6dcfe4..a3b3a27 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -17,6 +17,7 @@ section never invalidates an existing link. ## Contents - [Overview](#overview) +- [Package boundaries](#package-boundaries) - [Glossary](#glossary) - [Invariants](#invariants) - [Pipeline](#pipeline) @@ -52,6 +53,15 @@ Cloudflare R2, and injects locale-prefixed routes for the translated pages. The same orchestrator powers a standalone `polystella` CLI so operators can run the pipeline outside `astro build`. +The direct in-process flow is exact: + +```text +source/record -> adapter -> core -> provider -> core -> adapter -> output +``` + +There is no hosted core service or required network hop beyond the +selected provider transport. + ``` ┌────────────────────────────────────────────────────────┐ │ astro:config:setup (or CLI) │ @@ -95,15 +105,41 @@ operators can run the pipeline outside `astro build`. astro build → dist/ ``` -Two entry points share `runTranslationPass` in `src/translation/run.ts`: +Two entry points share `runTranslationPass` in `packages/astro/src/translation/run.ts`: -- **Astro integration** (`src/index.ts`) — registers hooks, runs the +- **Astro integration** (`packages/astro/src/index.ts`) — registers hooks, runs the pass, publishes the runtime bridge. -- **CLI** (`src/cli.ts`) — verb-style dispatcher routing to - `src/cli/.ts`. `translate` reuses `runTranslationPass`; +- **CLI** (`packages/astro/src/cli.ts`) — verb-style dispatcher routing to + `packages/astro/src/cli/.ts`. `translate` reuses `runTranslationPass`; `check-ui`, `sync-ui`, `translate-ui` operate on UI-string JSONs and don't touch the markdown pipeline or R2. +## Package boundaries + + + +- `@cloudflare/polystella-core` owns `Segment`, glossaries, the + `Translator` contract, prompt construction/response parsing, batching, + and retries. +- `@cloudflare/polystella-adapters` owns portable format parsing, + extraction, grouping, and translation application. +- `@cloudflare/polystella-providers` owns Workers AI HTTP/binding and + Anthropic transports. +- `@cloudflare/polystella` owns Astro hooks, filesystem and R2 access, + cache/marker/URL policy, routing, runtime APIs, and the CLI. + +The three reusable packages depend only on standard Web APIs at runtime. +They execute in Workers without `nodejs_compat`; enabling the flag in a +consumer remains supported. Providers use package-owned structural types +for bindings and do not import generated Cloudflare types. + +Low-level exports moved to their owning packages during extraction. In +particular, `Segment`, `Glossary`, `Translator`, +`PermanentProviderError`, prompt helpers, and batching moved from the +Astro root to core; portable parsing/application moved to adapters; and +provider factories moved to providers. No compatibility shims preserve +the old low-level imports. + --- ## Glossary @@ -112,7 +148,7 @@ Two entry points share `runTranslationPass` in `src/translation/run.ts`: | Term | Meaning | | :------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Adapter** | A `FileTypeAdapter` implementation owning one file format (markdown, TOML, JSON, YAML). Parses, extracts segments, applies translations. See [adapter contract](#adapter-contract). | +| **Adapter** | A reusable `FileAdapter` implementation owning one file format (markdown, TOML, JSON, YAML). Astro wraps it with host policy as `FileTypeAdapter`. See [adapter contract](#adapter-contract). | | **Segment** | The atomic translatable unit emitted by `adapter.extractSegments`. Has a stable per-file ID (`body:N`, `fm:key`, `fm:key[i]`, or a dotted key path). | | **Group** | An ordered list of segments inside one section (e.g. all paragraphs under one heading). Produced by `adapter.groupSegments`; the batcher packs groups into batches under a token budget. | | **Batch** | One prompt round-trip's worth of segments. Produced by `packGroupsIntoBatches`. Each batch carries its own document-context block. | @@ -123,7 +159,7 @@ Two entry points share `runTranslationPass` in `src/translation/run.ts`: | **Drift** | A non-default-locale UI-string JSON disagreeing with the default-locale source: missing keys, extra keys, or `""` placeholders where the source is non-empty. | | **Miss path** | The code branch in the cache layer when an R2 GET returns nothing. Triggers translator + apply + PUT. | | **Live phase / live run** | A run that actually translates (provider configured, `dryRun: false`). The pipeline only walks sources once in this mode. | -| **Bridge** | `src/runtime/custom-loader-runtime.ts` — the module-scoped singleton holding live JS objects (R2 client, translators, glossaries) shared between `astro:config:setup` and the sibling content collections registered at content-sync time. | +| **Bridge** | `packages/astro/src/runtime/custom-loader-runtime.ts` — the symbol-keyed `globalThis` value holding live JS objects (R2 client, translators, glossaries) across `astro:config:setup` and sibling content collections at content-sync time. | | **Sibling collection** | A per-locale content collection (`publications__pt-BR`, etc.) auto-registered by `polystellaCollections` alongside the user's source collection. | | **Branch dispatch** | The three-mode R2 prefix selection (local / CI main / CI preview) driven by `WORKERS_CI_BRANCH` and `POLYSTELLA_CLI`. See [R2 dispatch](#r2-dispatch). | @@ -153,20 +189,20 @@ adding new code that touches one. -`runTranslationPass` (in `src/translation/run.ts`) is the +`runTranslationPass` (in `packages/astro/src/translation/run.ts`) is the orchestrator. Same function powers both the Astro integration and the `polystella translate` CLI subcommand. Zero direct dependency on Astro's types so the CLI can run without Astro on the import path. Sequenced steps: -1. Load glossaries (`src/glossary/`). -2. Walk sources (`src/source/walk.ts`) — respects `include` / `exclude`. +1. Load glossaries (`packages/astro/src/glossary/`). +2. Walk sources (`packages/astro/src/source/walk.ts`) — respects `include` / `exclude`. 3. Bulk pre-list R2 once per (prefix × locale) — populates an in-memory existence predicate (see [#bulk-prelist](#bulk-prelist)). 4. Read the local staging index (see [#local-staging-index](#local-staging-index)). 5. Run a worker pool over (file, locale) pairs with - `runWithConcurrency` (`src/source/pool.ts`). + `runWithConcurrency` (`packages/astro/src/source/pool.ts`). 6. Per pair: short-circuit on local-skip → check override → check R2 → translate → apply → PUT → rewrite URLs → stage. 7. Persist `nextLocalCacheIndex` to disk. @@ -227,10 +263,10 @@ content-sync time). The two halves need to share live JS objects (R2 client, translators, glossaries) that can't be serialised through the `polystella:runtime-config` virtual module. -The bridge in `src/runtime/custom-loader-runtime.ts` is a module-scoped -singleton populated by `setRuntimeBridge` during `config:setup` and -read by the sibling loaders at sync time. Module-scoped state is fine -because Astro runs both halves in the same Node process. +The bridge in `packages/astro/src/runtime/custom-loader-runtime.ts` is a +symbol-keyed `globalThis` value populated by `setRuntimeBridge` during +`config:setup` and read by sibling loaders at sync time. The global survives +Vite module-graph reloads while Astro runs both halves in one process. `publishRuntimeBridge` in `index.ts` re-loads glossaries and constructs translators that `runTranslationPass` already built internally. The @@ -374,7 +410,7 @@ tests must not produce different output. -The cache layer (`src/storage/cache.ts`) is format-agnostic. On a miss +The cache layer (`packages/astro/src/storage/cache.ts`) is format-agnostic. On a miss it: 1. calls the translator, @@ -416,11 +452,13 @@ exemptions apply uniformly. Both layers are idempotent -Every file format implements `FileTypeAdapter` in -`src/parsing/adapter.ts` and registers in `src/parsing/registry.ts`. -No changes to `run.ts` or the cache layer required. +Every reusable file format implements `FileAdapter` in +`packages/adapters/src/adapter.ts`. Astro wraps it with cache selection, +`noTranslate`, URL, document-context, marker, and parser policies in +`packages/astro/src/parsing/`, then registers it in +`packages/astro/src/parsing/registry.ts`. -Abbreviated shape: +The Astro wrapper's abbreviated superset shape: ```ts interface FileTypeAdapter { @@ -465,7 +503,9 @@ call `resetRegistry()` before registering. One `Translator` per (provider, locale). Two concrete providers ship: Workers AI and Anthropic. Both speak the same prompt-and-JSON-back -contract enforced by `src/translation/prompt.ts`. +contract enforced by `packages/core/src/prompt.ts`. +`packages/astro/src/translation/provider.ts` only maps validated Astro configuration +to the concrete factories in `packages/providers`. ```ts interface Translator { @@ -511,7 +551,7 @@ single-group default in `translateSegments`, which then packs by token budget alone. **Token-aware packing.** `packGroupsIntoBatches` (in -`src/translation/batch.ts`) is a pure function over `Segment[][]`. +`packages/core/src/batch.ts`) is a pure function over `Segment[][]`. Greedy-fills batches under a soft input-token budget (`provider.batchInputTokenBudget`, default `4000`) using `Math.ceil((id + text + 8) / 4)` per segment — the `+8` covers the @@ -594,26 +634,27 @@ source of truth; non-default locales must match its key set. Three CLI subcommands maintain the invariant: -- **`check-ui`** (`src/cli/check-ui.ts`) — pure drift detection. Zero +- **`check-ui`** (`packages/astro/src/cli/check-ui.ts`) — pure drift detection. Zero writes, zero network. Pre-commit hook target. Catches three failure modes: missing keys, extra keys, and **empty-placeholder values** (a key shared with the source dict but with `""` in the locale where the source value is non-empty). The build's own drift check at `astro:config:setup` uses the same predicate. -- **`sync-ui`** (`src/cli/sync-ui.ts` + `src/i18n/sync.ts`) — +- **`sync-ui`** (`packages/astro/src/cli/sync-ui.ts` + + `packages/astro/src/i18n/sync.ts`) — mechanical key reconciliation. Adds missing keys as empty strings, drops extras, preserves existing values (empty or not), re-emits files in source-file key order with blank-line section breaks preserved. -- **`translate-ui`** (`src/cli/translate-ui.ts` + - `src/i18n/ui-translate.ts`) — runs sync, skips fully translated +- **`translate-ui`** (`packages/astro/src/cli/translate-ui.ts` + + `packages/astro/src/i18n/ui-translate.ts`) — runs sync, skips fully translated locale JSONs before provider setup, then translates each queued locale in small sequential request batches capped by `provider.batchInputTokenBudget` and 25 UI strings per request. Queued locales run in parallel via `runWithConcurrency` with a hard max locale concurrency of 3. -**Layout-aware writer.** `formatLocaleFile` in `src/i18n/sync.ts` +**Layout-aware writer.** `formatLocaleFile` in `packages/astro/src/i18n/sync.ts` parses the source file's text (not just its JSON) to recover top-level key order AND which keys start a new "section" (blank line immediately before). The output then mirrors that layout for every @@ -623,7 +664,7 @@ keys alphabetically. **`{{token}}` preservation.** Validated post-translation by extracting `{{\w+}}` tokens from both source and translation and comparing the sets. Validator lives _outside_ `translateBatch` (in -`src/i18n/ui-translate.ts`) because `translateBatch` doesn't expose +`packages/astro/src/i18n/ui-translate.ts`) because `translateBatch` doesn't expose a post-parse hook. The orchestrator runs its own per-request-batch retry wrapper with `maxRetries: 0` passed to `translateBatch` so the retry loop is single-layer. A token-invalid translation after all @@ -714,19 +755,22 @@ boundaries that could otherwise run indefinitely. -`POLYSTELLA_VERSION` lives in `src/version.ts` as a JSON import from +`POLYSTELLA_VERSION` lives in `packages/astro/src/version.ts` as a JSON import from `package.json` (`import pkg from "../package.json" with { type: -"json" }`). Re-exported from `src/index.ts`, consumed directly by -`src/cli.ts`. - -Both the CLI (`dist/cli.js`) and the library entries (`dist/index.js`, -`dist/runtime/index.js`, …) are produced by `tsc -p -tsconfig.build.json` (`pnpm build`). tsc preserves the `with { type: -"json" }` import attribute, so `dist/version.js` resolves +"json" }`). Re-exported from `packages/astro/src/index.ts`, consumed directly by +`packages/astro/src/cli.ts`. + +Both the CLI (`packages/astro/dist/cli.js`) and the library entries +(`packages/astro/dist/index.js`, `packages/astro/dist/runtime/index.js`, …) +are produced by `tsc -p packages/astro/tsconfig.build.json` (`pnpm build`). +tsc preserves the `with { type: "json" }` import attribute, so +`packages/astro/dist/version.js` resolves `../package.json` (i.e. the package root) at module-load time inside -the consumer's `node_modules/polystella/`. No version inlining; one +the consumer's `node_modules/@cloudflare/polystella/`. No version inlining; one source of truth. -Bump `package.json` only; both surfaces follow. The constant is baked -into R2 metadata and the build report but is NOT in the cache key -formula, so a version bump doesn't re-translate. +Changesets versions all four public packages as a fixed group. The Astro +manifest remains this constant's source, so both Astro surfaces follow its +generated version. The constant is baked into R2 metadata and the build +report but is NOT in the cache key formula, so a version bump doesn't +re-translate. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3b463e..00648b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,12 +10,15 @@ easier to evaluate when maintainer time is available. ## Repository overview -PolyStella is a pnpm workspace with two members: +PolyStella is a pnpm workspace with four public packages under `packages/`: -- **The package itself** at the repo root (`package.json` → - `@cloudflare/polystella`). -- **The docs site** under `docs/` (`docs/package.json` → - `polystella-docs`). +- `packages/astro/` — `@cloudflare/polystella`. +- `packages/core/` — platform-neutral translation orchestration. +- `packages/adapters/` — Markdown, MDX, JSON, YAML, and TOML adapters. +- `packages/providers/` — Workers AI and Anthropic transports. + +The private root coordinates those packages, the `docs/` site, and the +`playgrounds/` fixtures. The agent-facing context lives in [`AGENTS.md`](./AGENTS.md). The system-level design rationale lives in @@ -32,16 +35,16 @@ pnpm install Required: -- Node 20+ (24 LTS recommended). +- Node 22.12+ (24 recommended). - pnpm 9+ (the lockfile is `pnpm-lock.yaml`). ## Commands | Command | Purpose | | ------------------------------------- | --------------------------------------------------------------- | -| `pnpm test` | Run the package's unit + smoke tests (vitest). | -| `pnpm exec tsc --noEmit` | Typecheck the package. | -| `pnpm build` | Compile `src/` → `dist/` (library + CLI, JS + `.d.ts`). | +| `pnpm test` | Run package, Astro, workerd, and boundary tests. | +| `pnpm typecheck` | Build and typecheck all four public packages. | +| `pnpm build` | Build all four public packages. | | `pnpm build:llms` | Regenerate `llms-full.txt` from canonical agent docs. | | `pnpm --filter polystella-docs dev` | Run the Nimbus docs site locally. | | `pnpm --filter polystella-docs build` | Build the docs site (includes auto-generated config reference). | @@ -69,7 +72,7 @@ Required: - **TypeScript strict mode**, including `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitReturns`, `noFallthroughCasesInSwitch`. Tooling configured in - `tsconfig.json`. + `tsconfig.base.json` and each package's local config. - **No `any`, no `!`.** Use `unknown` + type guards; use destructure-and-check instead of non-null assertions. See [`AGENTS.md`](./AGENTS.md) for the rationale. @@ -79,35 +82,34 @@ Required: - **Comments document the "why", not the "what".** Long-form rationale belongs in `ARCHITECTURE.md`. Inline comments are for non-obvious decisions and known footguns. -- **Tests are integration-heavy.** We use vitest with - `singleThread: true` (faster than multi-worker at our scale). - Tests live under `tests//.test.ts` mirroring - the source structure. A 9-test smoke suite under `tests/smoke.test.ts` - exercises the integration end-to-end against a temp project. +- **Tests are integration-heavy.** Package tests live under each + `packages/*/tests/` directory. Astro tests under `packages/astro/tests/` + mirror `packages/astro/src/` and include an end-to-end smoke suite. ## Adding new APIs Before adding to the public surface: -- **Is it covered by an existing export path?** Check the - `exports` field in `package.json`. Eight subpaths are exposed; - unless your addition needs its own namespace, it should fit in - one of them. +- **Is it covered by an existing export path?** Check the `exports` field in + the owning `packages/*/package.json`; avoid adding a namespace when an + existing entry already fits. - **Does it have a documentation page?** `pnpm --filter polystella-docs check-exports` asserts every `exports` path is mentioned on `docs/src/content/docs/reference/exports.md`. CI fails if not. - **Does the schema reference need updating?** The `docs/scripts/generate-config-ref.ts` script auto-walks - `src/config/options.ts`'s zod schema. If your change adds a new + `packages/astro/src/config/options.ts` zod schema. If your change adds a new config field, regenerate the page locally with `pnpm --filter polystella-docs prebuild` and verify the output reads cleanly. ## Adding a new adapter -Adapters implement the `FileTypeAdapter` interface in -`src/parsing/adapter.ts` and register via `parsing/registry.ts`. +Portable adapters implement `FileAdapter` in +`packages/adapters/src/adapter.ts`; Astro policy wrappers implement +`FileTypeAdapter` in `packages/astro/src/parsing/adapter.ts` and register via +`packages/astro/src/parsing/registry.ts`. See [`ARCHITECTURE.md`](./ARCHITECTURE.md) `#adapter-contract` for the full contract. The Markdown adapter is the reference implementation. @@ -115,8 +117,9 @@ implementation. ## Adding a new provider Providers implement the `Translator` interface in -`src/translation/provider.ts`. Throw `PermanentProviderError` on -4xx HTTP responses that retries can't fix (401/403/404/422); throw +`packages/core/src/translator.ts` and expose transports from +`packages/providers/src/`. Throw `PermanentProviderError` on +4xx HTTP responses that retries can't fix (400/401/403/404/422); throw plain `Error` on anything retriable. See [`ARCHITECTURE.md`](./ARCHITECTURE.md) `#translator-contract` for the detail. @@ -128,7 +131,7 @@ Bug reports against PolyStella are most useful when they include: that exhibits the issue). - Source file(s) that trigger the issue (or a synthetic example with the same shape). -- The build report from `dist/i18n-r2-report.json`, if relevant. +- The `i18n-r2-report.json` build report, if relevant. - The PolyStella version (visible in the report; or `polystella --version`). ## License diff --git a/README.md b/README.md index f2c5870..d5d9cff 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,24 @@ PolyStella is an [Astro](https://astro.build) integration that translates content into additional locales at build time using AI, caches translations in Cloudflare R2, and injects locale-prefixed routes for the translated pages. +The repository publishes four lockstep packages: + +| Package | Owns | +| ---------------------------------- | ------------------------------------------------------------------ | +| `@cloudflare/polystella` | Astro integration, CLI, R2, routing, runtime, and host policy. | +| `@cloudflare/polystella-core` | Platform-neutral prompts, batching, retries, and shared contracts. | +| `@cloudflare/polystella-adapters` | Portable Markdown, MDX, JSON, YAML, and TOML adapters. | +| `@cloudflare/polystella-providers` | Workers AI HTTP/binding and Anthropic transports. | + +Direct low-level use stays in-process: + +```text +source/record -> adapter -> core -> provider -> core -> adapter -> output +``` + +Core, adapters, and providers require standard Web APIs and work in +Workers without `nodejs_compat`; consumers may still enable it. + ## What it does - **Build-time translation.** Translates `.md`, `.mdx`, and `.toml` content into additional locales during `astro build`. Visitors get static bytes; no runtime AI calls. @@ -21,7 +39,14 @@ Install from npm: pnpm add @cloudflare/polystella ``` -Peer dependencies: `astro ^7.0.0`, optionally `react ^17 || ^18 || ^19`. +Peer dependencies: `astro ^7.0.10`, optionally `react ^17 || ^18 || ^19`. + +Install the owning package for low-level APIs. `Segment`, `Glossary`, +`Translator`, `PermanentProviderError`, prompt helpers, and batching moved +to `@cloudflare/polystella-core`; portable format helpers moved to +`@cloudflare/polystella-adapters`; provider factories moved to +`@cloudflare/polystella-providers`. The Astro package does not provide +compatibility shims for those old low-level imports. ## Quick start diff --git a/agent-work/polystella-monorepo-baseline.md b/agent-work/polystella-monorepo-baseline.md index ce9d2ae..77454d6 100644 --- a/agent-work/polystella-monorepo-baseline.md +++ b/agent-work/polystella-monorepo-baseline.md @@ -115,7 +115,8 @@ Stable report totals: For comparison, replace the complete `aiTranslatedAt:` line with `aiTranslatedAt: ` and compute SHA-256 over the resulting UTF-8 -bytes. +bytes. These are the `i18n-preview` inspection copies; MDX files under +`.astro/i18n-staging` have intentionally different relative import paths. | Output | SHA-256 | | ------------------------------ | ------------------------------------------------------------------ | @@ -133,12 +134,14 @@ bytes. Normalized report digest: ```text -7c5a3be43011144d9f173ccc118207e9a17222c75da1385eb40dbe129df05b26 +44d4a570c5d4ece964117508ef1b22ab5bfe5e3fc4ca80b0f0af0a6bb844ba50 ``` Report normalization removes `build.startedAt`, `build.durationMs`, and every entry `durationMs`; entries are sorted by `{locale}/{sourcePath}` before -hashing. +hashing the emitted two-space-indented JSON with its trailing newline. The +original recorded checksum was corrected in Step 8 because it did not match +this documented normalization; every normalized report field still matched. ## Manual Output Observations diff --git a/agent-work/polystella-monorepo-extraction.md b/agent-work/polystella-monorepo-extraction.md index deb1c78..53a83e9 100644 --- a/agent-work/polystella-monorepo-extraction.md +++ b/agent-work/polystella-monorepo-extraction.md @@ -1,6 +1,6 @@ # PolyStella Monorepo Extraction Plan -Status: In progress (Steps 1-3 complete) +Status: Local Step 12 gate complete; release blocked by external prerequisites Last updated: 2026-08-28 ## ELI5: What Will Happen @@ -156,20 +156,20 @@ Owns: ## Progress -| Step | Status | Completion evidence | -| ------------------------------ | ----------- | --------------------------------------------------------------------------------------------------------------------- | -| 1. Record the baseline | Complete | `agent-work/polystella-monorepo-baseline.md` records commands, keys, hashes, outputs, and known limitations | -| 2. Prepare the workspace | Complete | `packages/*` discovery and framework-neutral `tsconfig.base.json` added; existing package remains unchanged and green | -| 3. Extract core | Complete | Core builds from one runtime dependency; 26 package tests and import-boundary inspection pass | -| 4. Extract adapters | Complete | Shared adapters build with 40 portable format, parser, grouping, and reconstruction tests passing | -| 5. Extract providers | Complete | Three portable factories build with 33 HTTP, binding, cancellation, and retry-integration tests passing | -| 6. Reconnect Astro | Not started | Existing Astro suite and playground pass | -| 7. Prove portability | Not started | Node, no-compat workerd, and boundary checks pass | -| 8. Check the extraction | Not started | Before/after outputs and keys match | -| 9. Move Astro | Not started | Private root and moved Astro package build cleanly | -| 10. Finish packaging | Not started | Four tarballs install and import in a clean project | -| 11. Update automation and docs | Not started | CI/docs/pkg.pr.new configuration covers all packages | -| 12. Run the release gate | Not started | Every automated and manual release check passes | +| Step | Status | Completion evidence | +| ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- | +| 1. Record the baseline | Complete | `agent-work/polystella-monorepo-baseline.md` records commands, keys, hashes, outputs, and known limitations | +| 2. Prepare the workspace | Complete | `packages/*` discovery and framework-neutral `tsconfig.base.json` added; existing package remains unchanged and green | +| 3. Extract core | Complete | Core builds from one runtime dependency; 26 package tests and import-boundary inspection pass | +| 4. Extract adapters | Complete | Shared adapters build with 40 portable format, parser, grouping, and reconstruction tests passing | +| 5. Extract providers | Complete | Three portable factories build with 33 HTTP, binding, cancellation, and retry-integration tests passing | +| 6. Reconnect Astro | Complete | Root delegates to shared packages; 1,161 Astro tests and baseline playground outputs pass | +| 7. Prove portability | Complete | Node, no-compat workerd, and boundary checks pass | +| 8. Check the extraction | Complete | Four-package tarball install and all deterministic Step 1 baseline comparisons pass | +| 9. Move Astro | Complete | Private root, moved Astro package, aggregate gates, package checks, baselines, playground, and docs pass | +| 10. Finish packaging | Complete | Four release-ready tarballs install and expose every public entrypoint with exact internal versions | +| 11. Update automation and docs | Complete | CI/docs/pkg.pr.new configuration covers all packages | +| 12. Run the release gate | Blocked | Local automation passes; npm bootstrap, publisher setup, and external manual checks remain | ## Step 1: Record The Baseline @@ -662,6 +662,17 @@ Manual verification: Stop condition: Any cache-key, staged-byte, routing, marker, retry, or report drift blocks the move to `packages/astro`. +Completion evidence (2026-08-28): The root Astro integration delegates core, +adapter, and provider behavior to the three workspace packages while retaining +Satteri parsing and host cache/URL/marker policy. Eleven duplicate source files +were removed. All 1,161 root tests and 99 package tests pass, including direct +Remark routing, Satteri-to-Remark MDX fallback, and marker-before-cache checks. +Root/package typechecks and builds, CLI `0.4.0`, the six-page playground build, +10-key dry-run, and local fake-provider translation pass. Dry-run keys and all +10 normalized staged outputs match the Step 1 baseline. Review found no +remaining Step 6 defects; the coordinated Changesets entry remains scheduled +for Step 10. + ## Step 7: Prove Node And Workerd Portability Purpose: Turn the Node/workerd support decision into executable checks. @@ -673,7 +684,8 @@ Changes: separate workerd Vitest configuration. - Run representative core, adapters, and providers tests inside workerd. - Add a minimal Worker fixture importing all three reusable packages. -- Configure that fixture without `nodejs_compat`. +- Configure that fixture with explicit `no_nodejs_compat` and + `no_nodejs_compat_v2`. - Add a small source/package boundary test rejecting imports of `node:`, Astro, React, Satteri, filesystem, and environment modules from reusable packages. - Keep consumers free to enable `nodejs_compat`. Passing without it is a @@ -690,16 +702,18 @@ Workerd coverage: Automated verification: ```sh +pnpm test:packages pnpm test:node pnpm test:workerd pnpm test:boundaries pnpm build +pnpm bundle:workerd:dry-run ``` Manual verification: -- Inspect the Worker fixture's Wrangler configuration and confirm there is no - `nodejs_compat` compatibility flag. +- Inspect the Worker fixture's Wrangler configuration and confirm it explicitly + sets `no_nodejs_compat` and `no_nodejs_compat_v2`. - Inspect its generated bundle for Node built-in imports and Satteri/native binding references. - Optionally repeat the fixture with `nodejs_compat` enabled to confirm that a @@ -708,6 +722,19 @@ Manual verification: Stop condition: A reusable package that only works when `nodejs_compat` is enabled is not platform-neutral and must be corrected before proceeding. +Completion evidence (2026-08-28): The existing 1,161-test Node suite, eight +detailed Vitest workerd tests, and 23 reusable-package boundary checks pass. +The Vitest suite covers core prompt parsing, batching, retries, cancellation, +JSON and Remark Markdown round trips, and Workers AI binding and HTTP +transports; it is not the no-compat proof. A separate Node harness uses the docs +workspace's Wrangler 4.127.0 to bundle, inspect, start, and fetch the Worker with +explicit `no_nodejs_compat` and `no_nodejs_compat_v2`. The 1,248.57 KiB bundle +(255.99 KiB gzip) contains no Node built-in imports, Astro, React, Satteri, or +native binding references. The aggregate root test command includes the +Wrangler runtime check and all 99 lower-package tests. Root/package typechecks, +clean frozen install, root build, bounded subprocesses, cross-platform process- +tree cleanup, unique OS-temp state, and generated-artifact checks pass. + ## Step 8: Check The Extraction Before Moving Astro Purpose: Isolate extraction defects from path-move defects. @@ -758,6 +785,32 @@ Manual verification: Stop condition: Do not move Astro or make the root private until the intermediate packages work outside the repository. +Completion evidence (2026-08-28): The local automated gate is complete. +`check:packages` builds and packs the explicit root, core, adapters, and +providers directories into OS-temporary storage; validates names, exports, +allowlists, required files, forbidden files, common versions, exact internal +dependency versions, and removal of `workspace:` ranges; then proves from the +lockfile and installed manifests that a clean ESM consumer uses all four +tarballs. That consumer imports every lower-package entry and all six Node-safe +Astro entries, resolves `./client` types, builds and typechecks an installed +Astro project exercising the integration-backed virtual-module entrypoints, +and runs CLI `0.4.0`. `check:baseline` first deletes all ignored playground +outputs, staging, and report data, then builds and runs the dry-run and local +fake provider itself before checking the exact 10 R2 keys, preview and staged +bytes including opposite MDX import paths, normalized report +digest/totals/models/source hashes, source/prompt fixtures, and JSON, YAML, and +TOML reconstruction bytes. Subprocesses are bounded with cross-platform process- +tree cleanup, all temporary package data is removed, and `.gitattributes` pins +text to LF. The original report checksum was corrected because it did not match +its documented normalization; every report field matched. The full 1,291-test +gate, root and lower-package typechecks, build, six-page playground build, +dry-run, local fake-provider run, CLI, formatting, and diff checks pass. The +preview workflow publishes all four explicit directories in one pkg.pr.new +invocation. The pkg.pr.new PR comment and preview-URL install remain pending +external checks because they require the enabled GitHub App/workflow. Real R2 +remains external/manual; R2 hit/write/prune, override, `noTranslate`, and local- +skip paths remain covered by existing tests. No changeset was added. + ## Step 9: Move Astro And Privatize The Root Purpose: Finish the desired monorepo layout after extraction is proven. @@ -801,7 +854,7 @@ Automated verification: ```sh pnpm install pnpm test -pnpm exec tsc --noEmit +pnpm typecheck pnpm build node packages/astro/dist/cli.js --version ``` @@ -821,6 +874,21 @@ Stop condition: The move is incomplete while any source/test assumes the root is still `@cloudflare/polystella` or any package version reads the private root manifest. +Completion evidence (2026-08-28): The Astro source, 1,162 tests, client types, +internal types, changelog, and package-local TypeScript/Vitest configuration now +live under `packages/astro`; the root is a private workspace orchestrator with +no publish fields, runtime dependencies, or peers. All four public packages +remain at `0.4.0`, retain the current internal ranges, expose package-local +typechecks, and build source/declaration maps pointing to their own `src` trees; +all four tarballs include those sources. The 1,292-test aggregate +Node/workerd/boundary gate, no-compat Wrangler runtime, aggregate +typecheck/build, four-tarball package check, deterministic baseline, CLI +`0.4.0`, six-page playground build/dry-run/local translation, docs generator, +export/example checks, full docs build, and formatting pass. Root typecheck and +all five playground scripts also pass with every ignored package `dist` tree +temporarily absent. `release.yml` is the sole publisher. No changeset or version +change was added. + ## Step 10: Finish Packaging And Lockstep Versions Purpose: Make the four packages behave like released artifacts rather than @@ -872,6 +940,22 @@ Manual verification: Stop condition: Workspace tests are insufficient; packaging is blocked until a fresh project can install only the tarballs and use every public entrypoint. +Completion evidence (2026-08-28): All four source manifests remain at `0.4.0` +and use `workspace:*` for internal runtime dependencies; the private root's +workspace-only references match. One fixed Changesets group and one minor +changeset produce exactly four planned `0.5.0` releases, while private +workspaces remain unchanged. Lower-package changelogs exist now because +Changesets creates missing files only during versioning, after this step's +tarball inspection. The strengthened `check:packages` gate validates source and +packed versions, exact exports and internal versions, required `src`, `dist`, +README, license, and changelog files, tarball allowlists, forbidden files, +resolved workspace ranges, and the CLI mode and shebang. Its clean consumer +installed all four `0.4.0` tarballs, exercised all public surfaces through +direct imports, client types, and an Astro build/typecheck, and ran CLI `0.4.0`. +Install, build, package check, Changesets status/release-state inspection, +focused formatting, and `git diff --check` all pass. No publish, commit, or Step +11 work was performed. + ## Step 11: Update Preview, Release, CI, And Documentation Purpose: Make repository automation and guidance agree with the final layout. @@ -911,8 +995,8 @@ Documentation changes: - Update root `README.md`, `ARCHITECTURE.md`, `AGENTS.md`, `llms.txt`, generated `llms-full.txt`, and contributor/consumer skills. -- Update stable repository paths in `tests/docs.test.ts` and planning/reference - documents that describe current architecture. +- Update stable repository paths in `packages/astro/tests/docs.test.ts` and + planning/reference documents that describe current architecture. - Update `docs/scripts/generate-config-ref.ts` to import the moved Astro schema. - Update `docs/scripts/check-exports.ts` to inspect all four public manifests. - Document direct in-process flow: @@ -951,6 +1035,32 @@ Manual verification: Stop condition: Do not release while automation still assumes the root is a publishable package or docs show old source paths/imports. +Completion evidence (2026-08-28): The opt-in preview workflow builds first, +then publishes `./packages/*` in one exact quoted pkg.pr.new invocation. +`release.yml` is the sole publisher and `publish.yml` is absent. CI explicitly +runs reusable-package and Astro Node tests, workerd tests, boundary checks, +typecheck, the topological build, four-tarball package checks, and the moved +Astro CLI smoke. Docs filters name the moved schema and all four manifests; +triage maps all package trees; Changesets guidance describes the fixed +four-package release. Current docs and skills identify package ownership, +migrated low-level imports with no compatibility shims, standard-Web-API +portability without requiring `nodejs_compat`, the exact direct package flow, +and a package-typed Workers AI binding example. `pnpm build`, the 1,292-test +Node/workerd/boundary gate, aggregate typecheck, clean-consumer package check, +43-page docs build, exact bidirectional checks for all 17 documented export +paths and their import examples, and a TypeScript-compiled direct +core/adapters/providers example with a structural Workers AI binding all pass. +`llms-full.txt` generation is deterministic, formatting passes, the CLI reports +`0.4.0`, all 10 workflow files parse as YAML, static assertions confirm package +checks immediately precede Changesets and docs verify generated context, and +`git diff --check` passes. Registry lookups for the core, adapters, and providers +package names returned 404; creating those npm packages is an external +prerequisite before the first release. The pkg.pr.new GitHub App comment, its +four external preview links, and a preview-URL install could not be exercised +locally; they remain external checks requiring the enabled GitHub App and +workflow. No commit, publish, npm bootstrap, Changesets versioning, or Step 12 +work was performed. + ## Step 12: Run The Final Release Gate Purpose: Verify the same code paths and package artifacts real consumers will @@ -964,7 +1074,7 @@ pnpm test pnpm test:node pnpm test:workerd pnpm test:boundaries -pnpm exec tsc --noEmit +pnpm typecheck pnpm build pnpm check:packages pnpm format:check @@ -997,6 +1107,57 @@ Release condition: Every automated gate passes, required manual checks are recorded, and no known behavior regression remains. Only then merge the Changesets release PR and publish all four packages together. +Completion evidence (2026-08-28): The stale Step 12 root `tsc` command now +uses the aggregate `pnpm typecheck`. Every listed command passes in order: +frozen install, the 1,292-test aggregate gate, intentionally redundant 1,162- +test Astro Node, eight-test workerd/no-compat, and 23-test boundary reruns, +aggregate typecheck, build, four-tarball clean-consumer check, formatting, +43-page docs build, 17-export documentation check, direct-package example +compile, six-page playground build, 10-key dry-run, and CLI `0.4.0`. The final +`check:baseline` guard matches all 10 dry-run keys, all 10 normalized preview +and staged outputs, report totals/digest/models/source hashes, prompt/hash +fixtures, links/import rewrites, markers, and three structured adapters. It now +asserts the report version against the current Astro package manifest and +normalizes only that version before the otherwise-exact report digest. +Changesets status plans the fixed four-package group at minor (`0.5.0`). The +production audit improved from 23 advisories (12 high, eight moderate, three +low) to zero after compatible Astro, MDX, astro-icon, Nimbus, and narrow patched +transitive updates; the audit and peer-dependency checks both pass. + +Manual artifact and release-state inspection also passes locally. Four public +`0.4.0` tarballs contain only allowlisted package files and complete source +maps: Astro has 132 emitted JS/declaration files and 132 maps, adapters 30/30, +core 18/18, and providers 8/8. Packed internal dependencies resolve exactly to +`0.4.0`; providers exposes both provider subpaths; Astro exposes all 12 export +paths and its executable CLI retains the Node shebang. All 10 workflow files +parse as YAML. The release runs only for main pushes, audits production +dependencies, and runs baseline then package checks immediately before the sole +Changesets action; CI also audits production dependencies, and no npm +environment was added. The tarball consumer now typechecks representative core, +adapter, aggregate-provider, both provider-subpath, and Astro APIs without path +mappings. The docs example launcher invokes TypeScript's JavaScript CLI through +Node on every platform and handles spawn errors. The upgraded docs render all +43 pages with zero diagnostics and +the same 41-page search index. `git diff --check` passes; changed and untracked +files have no forbidden secret filenames or recognized secret content +signatures, no files are staged, and final review found no unrelated change or +local extraction defect. + +Release remains blocked. Registry reads return 404 for the three new package +names, so npm package bootstrap and Trusted Publishing setup are still +required. Workers AI credential variables are present only in the ignored +playground env file, but no explicit safe endpoint/model target is configured; +the real Astro HTTP call was therefore omitted. No real Worker binding target, +Anthropic credential, or safe R2 bucket/configuration exists, so the binding, +Anthropic, cold/warm R2, live cache metadata, and live output comparisons were +not run. Existing provider/workerd/cache/run tests and the local fake-provider +baseline cover those code paths without external calls, but do not complete +the manual checks. pkg.pr.new comment/link and preview-consumer checks also +remain omitted: local GitHub CLI authentication is absent and the public API +request returned 403, so the app state and preview URLs could not be confirmed. +No commit, publish, npm bootstrap, Changesets versioning, or paid external call +was performed. + ## Explicitly Deferred - Moving the Translation Agent into this repository. diff --git a/boundary-tests/reusable-packages.test.ts b/boundary-tests/reusable-packages.test.ts new file mode 100644 index 0000000..591a808 --- /dev/null +++ b/boundary-tests/reusable-packages.test.ts @@ -0,0 +1,266 @@ +import { builtinModules } from "node:module"; +import { readFile, readdir } from "node:fs/promises"; +import { join, relative } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const ROOT = process.cwd(); +const PACKAGE_NAMES = ["core", "adapters", "providers"] as const; +const NODE_BUILTINS = new Set(builtinModules.map((name) => name.replace(/^node:/, ""))); +const FORBIDDEN_PACKAGES = new Set(["astro", "react", "react-dom", "satteri", "dotenv", "std-env", "wrangler"]); + +interface ModuleReference { + specifier: string; + runtime: boolean; +} + +describe.each(PACKAGE_NAMES)("packages/%s boundary", (packageName) => { + it("has only portable, declared source imports", async () => { + const packageRoot = join(ROOT, "packages", packageName); + const manifest = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")) as { + name: string; + dependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; + }; + const declaredRuntimeDependencies = new Set([ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.optionalDependencies ?? {}), + ...Object.keys(manifest.peerDependencies ?? {}), + ]); + const violations: string[] = []; + + for (const filePath of await listTypeScriptFiles(join(packageRoot, "src"))) { + const source = await readFile(filePath, "utf8"); + violations.push(...sourceViolations(source, relative(ROOT, filePath), manifest.name, declaredRuntimeDependencies)); + } + + for (const dependencyName of declaredRuntimeDependencies) { + if (isForbidden(dependencyName)) + violations.push(`packages/${packageName}/package.json: forbidden runtime dependency ${dependencyName}`); + } + + expect(violations).toEqual([]); + }); +}); + +describe("boundary analyzer regressions", () => { + it.each([ + ["bare Node builtins", 'import "fs"', "forbidden import fs"], + ["node: builtins", 'import "node:fs"', "forbidden import node:fs"], + ["Astro", 'import "astro"', "forbidden import astro"], + ["Satteri", 'import "satteri"', "forbidden import satteri"], + ["undeclared packages", 'import "left-pad"', "undeclared runtime import left-pad"], + ["unsupported protocols", 'import "npm:left-pad"', "unsupported protocol import npm:left-pad"], + ["process", "process.env.API_KEY", "forbidden Node global process"], + ["Buffer", 'Buffer.from("x")', "forbidden Node global Buffer"], + ["globalThis.process", "globalThis.process.env.API_KEY", "forbidden Node global process"], + ["globalThis.Buffer", 'globalThis.Buffer.from("x")', "forbidden Node global Buffer"], + ["parenthesized globalThis", "(globalThis).process.env.API_KEY", "forbidden Node global process"], + ["asserted globalThis", '(globalThis as typeof globalThis).Buffer.from("x")', "forbidden Node global Buffer"], + ["globalThis element access", 'globalThis["Buffer"].from("x")', "forbidden Node global Buffer"], + ["ambient process", "declare const process: { env: object }; process.env", "forbidden Node global process"], + ["ambient Buffer", 'declare class Buffer { static from(value: string): Buffer } Buffer.from("x")', "forbidden Node global Buffer"], + ])("rejects %s", (_name, source, expected) => { + expect(sourceViolations(source, "fixture.ts", "test-package", new Set())).toContainEqual(expect.stringContaining(expected)); + }); + + it("allows declarations and property names matching Node globals", () => { + const source = "const process = 1; class Buffer {} const value = { process: true, Buffer: true }; value.process; value.Buffer"; + expect(sourceViolations(source, "fixture.ts", "test-package", new Set())).toEqual([]); + }); + + it.each([ + ["variable", "const process = { env: {} }; process.env.API_KEY", new Set()], + ["parameter", "function read(process: { env: object }) { return process.env }", new Set()], + ["class", 'class Buffer { static from(_value: string) {} } Buffer.from("x")', new Set()], + ["import", 'import Buffer from "portable-buffer"; Buffer.from("x")', new Set(["portable-buffer"])], + ])("allows a locally bound Node-global name from a %s", (_name, source, dependencies) => { + expect(sourceViolations(source, "fixture.ts", "test-package", dependencies)).toEqual([]); + }); +}); + +async function listTypeScriptFiles(directory: string): Promise { + const files: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...(await listTypeScriptFiles(path))); + else if (entry.isFile() && /\.(?:ts|tsx|mts|cts)$/.test(entry.name)) files.push(path); + } + return files; +} + +function collectModuleReferences(source: string, filePath: string): ModuleReference[] { + const sourceFile = createSourceFile(source, filePath); + const references: ModuleReference[] = []; + const add = (node: ts.Expression | ts.LiteralTypeNode, runtime: boolean): void => { + const literal = ts.isLiteralTypeNode(node) ? node.literal : node; + if (ts.isStringLiteralLike(literal)) references.push({ specifier: literal.text, runtime }); + }; + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node)) { + const clause = node.importClause; + const runtime = + clause === undefined || + (!clause.isTypeOnly && + (clause.name !== undefined || + clause.namedBindings === undefined || + ts.isNamespaceImport(clause.namedBindings) || + clause.namedBindings.elements.some((element) => !element.isTypeOnly))); + add(node.moduleSpecifier, runtime); + } else if (ts.isExportDeclaration(node) && node.moduleSpecifier !== undefined) { + add(node.moduleSpecifier, !node.isTypeOnly); + } else if (ts.isImportTypeNode(node)) { + add(node.argument, false); + } else if ( + ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === "require")) && + node.arguments.length === 1 + ) { + const argument = node.arguments[0]; + if (argument !== undefined) add(argument, true); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return references; +} + +function sourceViolations(source: string, filePath: string, packageName: string, declaredDependencies: Set): string[] { + const violations: string[] = []; + for (const reference of collectModuleReferences(source, filePath)) { + const dependencyName = packageNameFromSpecifier(reference.specifier); + if (hasUnsupportedProtocol(reference.specifier)) { + violations.push(`${filePath}: unsupported protocol import ${reference.specifier}`); + } else if (isForbidden(reference.specifier)) { + violations.push(`${filePath}: forbidden import ${reference.specifier}`); + } + if ( + reference.runtime && + dependencyName !== undefined && + dependencyName !== packageName && + !isNodeBuiltin(reference.specifier) && + !declaredDependencies.has(dependencyName) + ) { + violations.push(`${filePath}: undeclared runtime import ${reference.specifier}`); + } + } + + const sourceFile = createSourceFile(source, filePath); + const options: ts.CompilerOptions = { noLib: true, noResolve: true, target: ts.ScriptTarget.Latest }; + const host = ts.createCompilerHost(options); + host.fileExists = (candidate) => candidate === filePath; + host.readFile = (candidate) => (candidate === filePath ? source : undefined); + host.getSourceFile = (candidate) => (candidate === filePath ? sourceFile : undefined); + const checker = ts.createProgram([filePath], options, host).getTypeChecker(); + const visit = (node: ts.Node): void => { + const globalMember = readGlobalThisMember(node, checker, sourceFile); + if (globalMember !== undefined) { + violations.push(`${filePath}: forbidden Node global ${globalMember}`); + } else if ( + ts.isIdentifier(node) && + (node.text === "process" || node.text === "Buffer") && + isIdentifierUse(node) && + !isLocallyBound(node, checker, sourceFile) + ) { + violations.push(`${filePath}: forbidden Node global ${node.text}`); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return violations; +} + +function isLocallyBound(node: ts.Identifier, checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean { + return ( + checker + .getSymbolAtLocation(node) + ?.declarations?.some((declaration) => declaration.getSourceFile() === sourceFile && isRuntimeBinding(declaration)) === true + ); +} + +function isRuntimeBinding(declaration: ts.Declaration): boolean { + if (declaration.getSourceFile().isDeclarationFile) return false; + for (let node: ts.Node | undefined = declaration; node !== undefined; node = node.parent) { + if (ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword)) + return false; + } + if (ts.isImportClause(declaration)) return !declaration.isTypeOnly; + if (ts.isImportSpecifier(declaration)) return !declaration.isTypeOnly && !declaration.parent.parent.isTypeOnly; + if (ts.isNamespaceImport(declaration)) return !declaration.parent.parent.isTypeOnly; + return ( + ts.isVariableDeclaration(declaration) || + ts.isBindingElement(declaration) || + ts.isParameter(declaration) || + ts.isFunctionLike(declaration) || + ts.isClassLike(declaration) || + ts.isEnumDeclaration(declaration) || + ts.isImportEqualsDeclaration(declaration) + ); +} + +function readGlobalThisMember(node: ts.Node, checker: ts.TypeChecker, sourceFile: ts.SourceFile): "process" | "Buffer" | undefined { + if (!ts.isPropertyAccessExpression(node) && !ts.isElementAccessExpression(node)) return undefined; + const expression = unwrapExpression(node.expression); + if (!ts.isIdentifier(expression) || expression.text !== "globalThis" || isLocallyBound(expression, checker, sourceFile)) return undefined; + const name = ts.isPropertyAccessExpression(node) + ? node.name.text + : ts.isStringLiteralLike(node.argumentExpression) + ? node.argumentExpression.text + : undefined; + return name === "process" || name === "Buffer" ? name : undefined; +} + +function unwrapExpression(expression: ts.Expression): ts.Expression { + while ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isTypeAssertionExpression(expression) || + ts.isNonNullExpression(expression) || + ts.isSatisfiesExpression(expression) + ) { + expression = expression.expression; + } + return expression; +} + +function createSourceFile(source: string, filePath: string): ts.SourceFile { + return ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest, + true, + filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); +} + +function isIdentifierUse(node: ts.Identifier): boolean { + const parent = node.parent; + if (ts.isPropertyAccessExpression(parent) && parent.name === node) return false; + if (ts.isQualifiedName(parent) && parent.right === node) return false; + if (ts.isNamedDeclaration(parent) && parent.name === node) return false; + return true; +} + +function isForbidden(specifier: string): boolean { + if (isNodeBuiltin(specifier) || specifier.startsWith("astro:") || specifier.startsWith("cloudflare:")) return true; + const dependencyName = packageNameFromSpecifier(specifier); + return dependencyName !== undefined && (FORBIDDEN_PACKAGES.has(dependencyName) || dependencyName.startsWith("@astrojs/")); +} + +function hasUnsupportedProtocol(specifier: string): boolean { + const protocol = /^([a-z][a-z+.-]*):/i.exec(specifier)?.[1]; + return protocol !== undefined && protocol !== "node" && protocol !== "astro" && protocol !== "cloudflare"; +} + +function isNodeBuiltin(specifier: string): boolean { + const normalized = specifier.replace(/^node:/, ""); + return specifier.startsWith("node:") || NODE_BUILTINS.has(normalized); +} + +function packageNameFromSpecifier(specifier: string): string | undefined { + if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("#") || specifier.includes(":")) return undefined; + const [scopeOrName, name] = specifier.split("/"); + return scopeOrName?.startsWith("@") ? (name === undefined ? scopeOrName : `${scopeOrName}/${name}`) : scopeOrName; +} diff --git a/docs/astro.config.ts b/docs/astro.config.ts index f6696c8..538f3d7 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -83,7 +83,7 @@ export default defineConfig({ defaultStrategy: "hover", }, integrations: [ - icon(), + icon({ iconDir: "src/components" }), nimbus(nimbusConfig, { rules: { "nimbus/frontmatter-shape": "error", diff --git a/docs/examples/README.md b/docs/examples/README.md index b38a75b..6b46e7c 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -1,31 +1,21 @@ # PolyStella docs — runnable examples -This directory will hold minimal Astro projects paired with the -cookbook recipes in `docs/src/content/docs/cookbook/`. +This directory holds compile-only documentation fixtures. ## Status -Empty for v0.x. The cookbook pages are written but don't yet have -companion projects. +`direct-packages/` typechecks the direct core/adapters/providers flow and +the package-owned Workers AI binding types without generated Cloudflare +types. ## Adding an example -When you add a cookbook recipe, drop a runnable Astro project here -under a slug matching the recipe filename: +Add a focused fixture under a descriptive slug: ``` -docs/examples/monorepo/ # paired with cookbook/monorepo.md -docs/examples/custom-loader/ # paired with cookbook/custom-loader.md (planned) +docs/examples/direct-packages/ ``` -Each example project must: - -- Have its own `package.json` declaring `@cloudflare/polystella` as a - workspace dependency (`"@cloudflare/polystella": "workspace:*"`). -- Be buildable in isolation via `pnpm --filter polystella-example- build`. -- Include a `README.md` pointing back to the cookbook recipe it - illustrates. - -`docs/scripts/check-examples.ts` walks this directory and runs -`astro check` against each example. The CI build's `docs:build` -step depends on this passing. +`docs/scripts/check-examples.ts` runs `tsc` against the fixture. Keep it +dependency-free and map public workspace imports to package sources in its +local `tsconfig.json`. diff --git a/docs/examples/direct-packages/index.ts b/docs/examples/direct-packages/index.ts new file mode 100644 index 0000000..a61da77 --- /dev/null +++ b/docs/examples/direct-packages/index.ts @@ -0,0 +1,29 @@ +import { jsonAdapter } from "@cloudflare/polystella-adapters"; +import { EMPTY_GLOSSARY, translateSegments, type Translator } from "@cloudflare/polystella-core"; +import { createWorkersAIBindingTranslator, type WorkersAIInput } from "@cloudflare/polystella-providers/workers-ai"; + +interface WorkersAIBinding { + run(modelId: string, input: WorkersAIInput): Promise; +} + +export async function translateRecord(ai: WorkersAIBinding): Promise { + const source = JSON.stringify({ title: "Hello" }); + const parsed = jsonAdapter.parse(source, "record.json"); + const segments = jsonAdapter.extractSegments(parsed, source, { + sourcePath: "record.json", + translatableKeys: { "record.json": ["title"] }, + }); + const translator: Translator = createWorkersAIBindingTranslator({ + modelId: "@cf/meta/llama-3.1-8b-instruct", + maxTokens: 8192, + run: (modelId, input) => ai.run(modelId, input), + }); + const { translations } = await translateSegments({ + translator, + segments, + glossary: EMPTY_GLOSSARY, + sourceLocale: "en-US", + targetLocale: "pt-BR", + }); + return jsonAdapter.applyTranslations(parsed, source, translations); +} diff --git a/docs/examples/direct-packages/tsconfig.json b/docs/examples/direct-packages/tsconfig.json new file mode 100644 index 0000000..02e2300 --- /dev/null +++ b/docs/examples/direct-packages/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "paths": { + "@cloudflare/polystella-core": ["../../../packages/core/src/index.ts"], + "@cloudflare/polystella-adapters": ["../../../packages/adapters/src/index.ts"], + "@cloudflare/polystella-providers": ["../../../packages/providers/src/index.ts"], + "@cloudflare/polystella-providers/*": ["../../../packages/providers/src/*.ts"] + } + }, + "include": ["index.ts"] +} diff --git a/docs/package.json b/docs/package.json index bb19bb2..c2aa3b5 100644 --- a/docs/package.json +++ b/docs/package.json @@ -20,24 +20,24 @@ "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@iconify-json/ph": "^1.2.0", - "astro": "^7.0.0", - "astro-icon": "^1.1.0", + "astro": "^7.2.9", + "astro-icon": "^1.2.0", "astro-og-canvas": "^0.13.0", "canvaskit-wasm": "^0.41.1", "clsx": "^2.1.1", - "nimbus-docs": "^0.1.22", + "nimbus-docs": "^0.5.0", + "sharp": "^0.35.3", "shiki": "^4.2.0", - "tailwind-merge": "^3.5.0", - "sharp": "^0.35.3" + "tailwind-merge": "^3.5.0" }, "devDependencies": { "@astrojs/check": "^0.9.9", "@tailwindcss/postcss": "^4.1.4", + "@types/node": "^26.1.0", "pagefind": "^1.5.2", "tailwindcss": "^4.1.4", - "@types/node": "^26.1.0", "tsx": "^4.19.0", "typescript": "^6.0.3", - "wrangler": "^4.0.0" + "wrangler": "^4.127.0" } } diff --git a/docs/scripts/check-examples.ts b/docs/scripts/check-examples.ts index 5e04ec6..3e7d565 100644 --- a/docs/scripts/check-examples.ts +++ b/docs/scripts/check-examples.ts @@ -1,98 +1,34 @@ #!/usr/bin/env tsx -/** - * Walk `docs/examples//` and run `astro check` against each - * example project. Catches the "we changed PolyStella's API in a - * way the documented examples no longer compile against" class of - * regression. - * - * For v0.x the examples directory is empty (the cookbook recipes - * exist but don't have companion projects yet). The script reports - * "no examples" and exits cleanly so CI doesn't fail prematurely. - * - * Exit codes: - * 0 no examples present, OR every example checks clean - * 1 one or more examples failed `astro check` - */ +/** Compile the local public-package example with the workspace compiler. */ -import { readdir, stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; const DOCS_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -const EXAMPLES_DIR = path.join(DOCS_ROOT, "examples"); +const REPOSITORY_ROOT = path.join(DOCS_ROOT, ".."); +const EXAMPLE_CONFIG = path.join(DOCS_ROOT, "examples", "direct-packages", "tsconfig.json"); +const tsc = fileURLToPath(import.meta.resolve("typescript/bin/tsc")); -async function isDirectory(p: string): Promise { - try { - return (await stat(p)).isDirectory(); - } catch { - return false; - } -} - -async function listExamples(): Promise { - const entries = await readdir(EXAMPLES_DIR); - const out: string[] = []; - for (const entry of entries) { - if (entry.startsWith(".") || entry === "README.md") continue; - const full = path.join(EXAMPLES_DIR, entry); - if (!(await isDirectory(full))) continue; - // An example must have a package.json — anything else is - // probably not a runnable project. - if (!(await isDirectory(full))) continue; - try { - await stat(path.join(full, "package.json")); - out.push(entry); - } catch { - console.warn(`[check-examples] skipping ${entry} (no package.json)`); - } - } - return out; -} - -async function checkExample(slug: string): Promise<{ slug: string; ok: boolean; output: string }> { - const dir = path.join(EXAMPLES_DIR, slug); - const result = spawnSync("pnpm", ["exec", "astro", "check"], { - cwd: dir, +function main(): void { + const result = spawnSync(process.execPath, [tsc, "--noEmit", "-p", EXAMPLE_CONFIG], { + cwd: REPOSITORY_ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); - const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; - return { slug, ok: result.status === 0, output }; -} - -async function main(): Promise { - if (!(await isDirectory(EXAMPLES_DIR))) { - console.log("[check-examples] no examples directory; nothing to check."); - return; + if (result.error !== undefined) { + throw new Error("failed to start TypeScript for direct-packages compilation", { cause: result.error }); } - - const examples = await listExamples(); - if (examples.length === 0) { - console.log("[check-examples] examples directory is empty; nothing to check."); - return; - } - - console.log(`[check-examples] checking ${examples.length} example${examples.length === 1 ? "" : "s"}…`); - - let failures = 0; - for (const slug of examples) { - const result = await checkExample(slug); - if (result.ok) { - console.log(` ✓ ${slug}`); - } else { - failures++; - console.error(` ✗ ${slug}`); - console.error(result.output); - } - } - - if (failures > 0) { - process.exitCode = 1; + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + if (result.status !== 0) { + throw new Error(`direct-packages failed to compile:\n${output}`); } + console.log("[check-examples] direct-packages compiled with tsc."); } -main().catch((err) => { +try { + main(); +} catch (err) { console.error("[check-examples] failed:", err); process.exitCode = 1; -}); +} diff --git a/docs/scripts/check-exports.ts b/docs/scripts/check-exports.ts index fe050ab..d935bf0 100644 --- a/docs/scripts/check-exports.ts +++ b/docs/scripts/check-exports.ts @@ -1,23 +1,17 @@ #!/usr/bin/env tsx -/** - * Sanity-check that every public export path in `package.json` is - * mentioned at least once on the `reference/exports` docs page. - * - * Catches the "new export, forgot to document it" regression that - * would otherwise surface only when someone tries to import from - * the path and finds nothing in the docs. - * - * Exit codes: - * 0 every exports entry is mentioned in reference/exports.md - * 1 one or more entries are missing — print the list and exit - */ +/** Check that the exports table exactly matches all public manifests. */ import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const DOCS_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -const PACKAGE_JSON = path.join(DOCS_ROOT, "..", "package.json"); +const PACKAGE_MANIFESTS = [ + { owner: "Core", directory: "core" }, + { owner: "Adapters", directory: "adapters" }, + { owner: "Providers", directory: "providers" }, + { owner: "Astro", directory: "astro" }, +] as const; const EXPORTS_PAGE = path.join(DOCS_ROOT, "src", "content", "docs", "reference", "exports.md"); interface PackageJson { @@ -25,45 +19,75 @@ interface PackageJson { exports?: Record; } -async function main(): Promise { - const pkg = JSON.parse(await readFile(PACKAGE_JSON, "utf8")) as PackageJson; - const exportsField = pkg.exports; - if (!exportsField || typeof exportsField !== "object") { - console.error("[check-exports] package.json has no `exports` map."); - process.exitCode = 1; - return; - } +interface DocumentedExport { + owner: string; + importPath: string; + example: string; +} - // Build the list of import paths a consumer can write. The map's - // keys are subpaths starting with `.` (e.g. `.`, `./content`). - // Translate to the consumer-facing form: `@cloudflare/polystella`, - // `@cloudflare/polystella/content`, etc. - const importPaths = Object.keys(exportsField).map((key) => { - if (key === ".") return pkg.name; - return `${pkg.name}${key.slice(1)}`; +function parseExportsTable(content: string): DocumentedExport[] { + const lines = content.split("\n"); + const headerIndex = lines.findIndex((line) => { + const cells = line + .split("|") + .slice(1, -1) + .map((cell) => cell.trim()); + return cells.join("|") === "Owner|Path|Purpose|Example"; }); + if (headerIndex < 0) throw new Error("exports page has no Owner/Path/Purpose/Example table"); - const exportsPageContent = await readFile(EXPORTS_PAGE, "utf8"); - - const missing: string[] = []; - for (const importPath of importPaths) { - if (!exportsPageContent.includes(`\`${importPath}\``)) { - missing.push(importPath); - } + const rows: DocumentedExport[] = []; + for (const line of lines.slice(headerIndex + 2)) { + if (!line.startsWith("|")) break; + const cells = line + .split("|") + .slice(1, -1) + .map((cell) => cell.trim()); + if (cells.length !== 4) throw new Error(`invalid exports table row: ${line}`); + const [owner, pathCell, , exampleCell] = cells; + const importPath = pathCell?.match(/^`([^`]+)`$/)?.[1]; + const example = exampleCell?.match(/^`([^`]+)`$/)?.[1]; + if (!owner || !importPath || !example) throw new Error(`invalid exports table row: ${line}`); + rows.push({ owner, importPath, example }); } + return rows; +} - if (missing.length === 0) { - console.log(`[check-exports] ${importPaths.length} export paths, all documented.`); - return; +async function main(): Promise { + const actual = new Map(); + for (const packageInfo of PACKAGE_MANIFESTS) { + const manifestPath = path.join(DOCS_ROOT, "..", "packages", packageInfo.directory, "package.json"); + const pkg = JSON.parse(await readFile(manifestPath, "utf8")) as PackageJson; + if (!pkg.exports || typeof pkg.exports !== "object") throw new Error(`${pkg.name} has no \`exports\` map`); + for (const key of Object.keys(pkg.exports)) { + const importPath = key === "." ? pkg.name : `${pkg.name}${key.slice(1)}`; + actual.set(importPath, packageInfo.owner); + } } - console.error("[check-exports] the following export paths are NOT mentioned in reference/exports.md:"); - for (const m of missing) console.error(` - ${m}`); - console.error( - `\nAdd a row for each missing path to ${path.relative(process.cwd(), EXPORTS_PAGE)} ` + - "(typically as a row in the path/purpose table, with the path wrapped in backticks).", + const documented = parseExportsTable(await readFile(EXPORTS_PAGE, "utf8")); + const documentedPaths = documented.map(({ importPath }) => importPath); + const duplicates = documentedPaths.filter((importPath, index) => documentedPaths.indexOf(importPath) !== index); + const missing = [...actual.keys()].filter((importPath) => !documentedPaths.includes(importPath)); + const extra = documentedPaths.filter((importPath) => !actual.has(importPath)); + const badOwners = documented.filter(({ owner, importPath }) => actual.get(importPath) !== owner); + const badExamples = documented.filter( + ({ importPath, example }) => + !example.includes(`from "${importPath}"`) && + !example.includes(`import "${importPath}"`) && + !example.includes(`types="${importPath}"`), ); - process.exitCode = 1; + + const errors = [ + ...duplicates.map((value) => `duplicate: ${value}`), + ...missing.map((value) => `missing: ${value}`), + ...extra.map((value) => `extra: ${value}`), + ...badOwners.map(({ importPath, owner }) => `wrong owner: ${importPath} (${owner})`), + ...badExamples.map(({ importPath }) => `missing import/reference example: ${importPath}`), + ]; + if (errors.length > 0) throw new Error(`exports table does not match manifests:\n${errors.map((error) => ` - ${error}`).join("\n")}`); + + console.log(`[check-exports] ${documented.length} export paths exactly documented with examples.`); } main().catch((err) => { diff --git a/docs/scripts/generate-config-ref.ts b/docs/scripts/generate-config-ref.ts index 2f92a19..5eac4e9 100644 --- a/docs/scripts/generate-config-ref.ts +++ b/docs/scripts/generate-config-ref.ts @@ -1,7 +1,7 @@ #!/usr/bin/env tsx /** * Auto-generate the configuration reference markdown page from the - * canonical zod schema in `src/config/options.ts`. Runs in + * canonical zod schema in `packages/astro/src/config/options.ts`. Runs in * `pnpm --filter polystella-docs prebuild` so any `astro dev` / * `astro build` cycle picks up schema drift automatically. * @@ -20,7 +20,7 @@ import { fileURLToPath } from "node:url"; import { z } from "astro/zod"; -import { polystellaOptionsSchema } from "../../src/config/options.js"; +import { polystellaOptionsSchema } from "../../packages/astro/src/config/options.js"; const DOCS_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const OUTPUT_PATH = path.join(DOCS_ROOT, "src", "content", "docs", "configuration", "reference.md"); @@ -249,7 +249,7 @@ async function main(): Promise { "---", "", ":::note[Auto-generated]", - "This page is regenerated from `src/config/options.ts` on every", + "This page is regenerated from `packages/astro/src/config/options.ts` on every", "`pnpm --filter polystella-docs build`. Don't hand-edit; the", "generator overwrites it.", ":::", diff --git a/docs/src/content/docs/concepts/how-it-works.md b/docs/src/content/docs/concepts/how-it-works.md index d1472d7..57553fd 100644 --- a/docs/src/content/docs/concepts/how-it-works.md +++ b/docs/src/content/docs/concepts/how-it-works.md @@ -11,6 +11,16 @@ R2, and injects locale-prefixed routes for the translated pages. The same orchestrator powers a standalone `polystella` CLI so operators can run the pipeline outside `astro build`. +The package boundary follows this direct in-process flow: + +```text +source/record -> adapter -> core -> provider -> core -> adapter -> output +``` + +Adapters parse and reconstruct content, core builds and validates the +translation protocol, and providers perform inference. Astro adds +filesystem, cache, marker, URL, and routing policy around that flow. + ## High-level diagram ```text diff --git a/docs/src/content/docs/configuration/index.md b/docs/src/content/docs/configuration/index.md index d6a773f..7fa14f9 100644 --- a/docs/src/content/docs/configuration/index.md +++ b/docs/src/content/docs/configuration/index.md @@ -57,7 +57,7 @@ export default { ## Validation and errors The config is parsed through zod at integration startup -(`resolveOptions` in `src/config/options.ts`). Bad input fails the +(`resolveOptions` in `packages/astro/src/config/options.ts`). Bad input fails the build with a concrete error pointing at the offending field — no silent fallbacks. A few specific cross-checks: @@ -96,6 +96,6 @@ without polluting it. ## Schema source of truth The [reference page](/configuration/reference/) is **auto-generated** -from `src/config/options.ts` on every docs build. If you spot a +from `packages/astro/src/config/options.ts` on every docs build. If you spot a field there that isn't documented, it's a generator gap, not a hidden feature — file an issue. diff --git a/docs/src/content/docs/configuration/reference.md b/docs/src/content/docs/configuration/reference.md index 794d277..d81b3f6 100644 --- a/docs/src/content/docs/configuration/reference.md +++ b/docs/src/content/docs/configuration/reference.md @@ -5,7 +5,7 @@ aiGenerated: true --- :::note[Auto-generated] -This page is regenerated from `src/config/options.ts` on every +This page is regenerated from `packages/astro/src/config/options.ts` on every `pnpm --filter polystella-docs build`. Don't hand-edit; the generator overwrites it. ::: diff --git a/docs/src/content/docs/getting-started/install.md b/docs/src/content/docs/getting-started/install.md index b0c4cc5..41796a6 100644 --- a/docs/src/content/docs/getting-started/install.md +++ b/docs/src/content/docs/getting-started/install.md @@ -10,8 +10,8 @@ entirely — the output is regular static-Astro bytes. ## Compatibility -- **Astro 6.0+** as a peer dependency. -- **Node 20+** for the standalone `polystella` CLI. +- **Astro 7.0+** as a peer dependency. +- **Node 22.12+** for the standalone `polystella` CLI. - **Cloudflare R2** for build cache. No other storage backend is supported today. - **Workers AI** or **Anthropic** as the translation provider. diff --git a/docs/src/content/docs/providers/permanent-errors.md b/docs/src/content/docs/providers/permanent-errors.md index f3e077e..b70dc8d 100644 --- a/docs/src/content/docs/providers/permanent-errors.md +++ b/docs/src/content/docs/providers/permanent-errors.md @@ -9,10 +9,10 @@ give up? The `PermanentProviderError` class encodes that decision. ## The class -Exported from `@cloudflare/polystella` as a named export: +Owned by the platform-neutral core package: ```ts -import { PermanentProviderError } from "@cloudflare/polystella"; +import { PermanentProviderError, type Translator } from "@cloudflare/polystella-core"; ``` Both built-in providers (`workers-ai`, `anthropic`) throw it from @@ -20,28 +20,39 @@ their `translate(...)` method on specific HTTP statuses. ## Which statuses are "permanent" -| HTTP status | Classification | Reasoning | -| ----------- | -------------- | -------------------------------------------------------------------------------------------------- | -| 401 | Permanent | Auth failure. Retries with the same credentials will fail the same way. | -| 403 | Permanent | Forbidden — wrong account, wrong model permission, gated feature. Retry won't fix. | -| 404 | Permanent | Model id is wrong (or revoked). Retry against the same id will fail. | -| 422 | Permanent | Request shape rejected at validation. Retries with the same body will fail. | -| 408 | Retriable | Timeout. Backoff before retry. | -| 425, 429 | Retriable | Rate-limited / too-early. Backoff before retry. | -| 5xx | Retriable | Server-side error. Backoff before retry. | -| Other 4xx | Retriable | Default — but if the request body is permanently bad, retries won't help. Caller sees the failure. | +| HTTP status | Classification | Reasoning | +| ----------- | -------------- | ---------------------------------------------------------------------------------- | +| 400 | Permanent | The request is invalid. Retries with the same body will fail. | +| 401 | Permanent | Auth failure. Retries with the same credentials will fail the same way. | +| 403 | Permanent | Forbidden — wrong account, wrong model permission, gated feature. Retry won't fix. | +| 404 | Permanent | Model id is wrong (or revoked). Retry against the same id will fail. | +| 422 | Permanent | Request shape rejected at validation. Retries with the same body will fail. | +| 408 | Retriable | Timeout. Backoff before retry. | +| 425, 429 | Retriable | Rate-limited / too-early. Backoff before retry. | +| 5xx | Retriable | Server-side error. Backoff before retry. | +| Other 4xx | Retriable | Default. The built-in permanent set is deliberately narrow. | ## What the retry loop does PolyStella uses `p-retry` for the translator retry loop: ```ts -await pRetry(() => translator.translate(prompt), { - retries: maxRetries, // default 2 → up to 3 attempts - factor: 2, - randomize: true, - shouldRetry: (err) => !(err instanceof PermanentProviderError), -}); +import pRetry from "p-retry"; +import { isPermanentProviderError, parseResponse } from "@cloudflare/polystella-core"; + +const translations = await pRetry( + async () => { + signal?.throwIfAborted(); + const rawText = await translator.translate(systemPrompt, userPrompt, signal); + return parseResponse(rawText, expectedIds); + }, + { + retries: maxRetries, + factor: 2, + randomize: true, + shouldRetry: ({ error }) => !isPermanentProviderError(error), + }, +); ``` If `translator.translate(...)` throws a `PermanentProviderError`, @@ -60,21 +71,29 @@ providers cover the common cases), throw `PermanentProviderError` for any failure that retries can't fix: ```ts -async translate(prompt) { - const res = await fetch(this.endpoint, { ... }); - if (res.status === 401) { - throw new PermanentProviderError("authentication failed"); - } - if (!res.ok) { - throw new Error(`provider error ${res.status}: ${await res.text()}`); - } - return await res.text(); -} +const translator: Translator = { + modelId: "example-model", + async translate(systemPrompt, userPrompt, signal) { + signal?.throwIfAborted(); + const response = await fetch(endpoint, { + method: "POST", + body: JSON.stringify({ systemPrompt, userPrompt }), + ...(signal !== undefined ? { signal } : {}), + }); + if (response.status === 401) throw new PermanentProviderError("authentication failed"); + if (!response.ok) throw new Error(`provider error ${response.status}: ${await response.text()}`); + return response.text(); + }, +}; ``` The retry loop sees `PermanentProviderError` and stops. Anything else gets retried. +Before the package split this class was a low-level named export from +`@cloudflare/polystella`. Import it from core now; no compatibility shim +is provided by the Astro package. + ## AbortSignal Independently of the permanent/retriable distinction, every diff --git a/docs/src/content/docs/providers/workers-ai.md b/docs/src/content/docs/providers/workers-ai.md index ea8e54f..17f9477 100644 --- a/docs/src/content/docs/providers/workers-ai.md +++ b/docs/src/content/docs/providers/workers-ai.md @@ -41,6 +41,33 @@ load the project and plan the work without calling Workers AI. Then run a normal translation or `astro build` to make the first live provider call. +## Worker binding + +Direct Worker consumers can use the binding transport from the providers +package. Its callback and `WorkersAIInput` are package-owned structural +types, so the provider package does not import generated Cloudflare types: + +```ts +import { createWorkersAIBindingTranslator, type WorkersAIInput } from "@cloudflare/polystella-providers/workers-ai"; + +interface Env { + AI: { + run(modelId: string, input: WorkersAIInput): Promise; + }; +} + +export function createTranslator(env: Env) { + return createWorkersAIBindingTranslator({ + modelId: "@cf/meta/llama-3.1-8b-instruct", + maxTokens: 8192, + run: (modelId, input) => env.AI.run(modelId, input), + }); +} +``` + +Core, adapters, and providers use standard Web APIs and work without +`nodejs_compat`; a consumer may still enable the flag. + ## Configuration ```js @@ -105,14 +132,12 @@ include it in the `endpoint` value you configure. Workers AI returns three classes of HTTP error PolyStella treats differently: -- **401, 403, 404, 422** — permanent. `PermanentProviderError` +- **400, 401, 403, 404, 422** — permanent. `PermanentProviderError` short-circuits the retry loop. Fix your credentials / model id and rerun. - **429, 500, 502, 503, 504** — retriable. `p-retry` retries with exponential backoff and jitter. -- **Other 4xx (e.g. 400)** — treated as retriable by default. If - the request shape is malformed, retries won't help; PolyStella - logs the response body and exits non-zero. +- **Other 4xx** — retriable by default. See [Providers → Permanent errors](/providers/permanent-errors/) for the contract. diff --git a/docs/src/content/docs/reference/breaking-changes.md b/docs/src/content/docs/reference/breaking-changes.md index 1cb74f6..9cb07af 100644 --- a/docs/src/content/docs/reference/breaking-changes.md +++ b/docs/src/content/docs/reference/breaking-changes.md @@ -7,11 +7,20 @@ aiGenerated: true PolyStella is in pre-1.0 development. Breaking changes happen. The log below tracks them so consumers can update incrementally. -The package's `CHANGELOG.md` is the authoritative source; this -page mirrors the breaking entries. +Each public package's `CHANGELOG.md` is authoritative; this page mirrors +the breaking entries. ## Unreleased (v0.x) +### Low-level APIs moved to owning packages + +Translation contracts and orchestration now come from +`@cloudflare/polystella-core`, portable formats from +`@cloudflare/polystella-adapters`, and provider transports from +`@cloudflare/polystella-providers`. `@cloudflare/polystella` retains the +Astro integration and host-owned APIs. No compatibility shims preserve the +old low-level root imports. + ### `r2.bulkListOnStart` defaults to `true` Issues one `r2.list()` per locale at the start of the live phase @@ -34,10 +43,10 @@ value explicitly. - Every breaking change gets an entry under "Breaking changes" in the relevant version's changelog block. -- Major version bumps (0.x → 0.y) signal "this release contains at - least one breaking change; read the entries before upgrading". -- Minor / patch versions never break; if they do, that's a bug and - we'll yank the release. +- Before 1.0, minor bumps (`0.x` → `0.y`) may contain breaking changes; + read their entries before upgrading. +- Patch bumps (`0.x.y` → `0.x.z`) do not intentionally break public APIs. +- After 1.0, breaking changes require a major bump. -`CHANGELOG.md` follows the [Keep a Changelog](https://keepachangelog.com/) +Package changelogs follow the [Keep a Changelog](https://keepachangelog.com/) format. diff --git a/docs/src/content/docs/reference/exports.md b/docs/src/content/docs/reference/exports.md index aa095f2..0084677 100644 --- a/docs/src/content/docs/reference/exports.md +++ b/docs/src/content/docs/reference/exports.md @@ -1,29 +1,84 @@ --- title: Public exports -description: "Every export path in package.json — what it provides and when to import from it." +description: "Every export path across the four public packages, with ownership and import examples." aiGenerated: true --- -PolyStella ships twelve public import paths. Each has a narrow, -documented purpose; mixing them up rarely produces a useful build. - -| Path | Purpose | -| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `@cloudflare/polystella` | Default export: the Astro integration factory. | -| `@cloudflare/polystella/content` | Content-config helpers: `polystellaCollections`, `file`, `polystellaLoader`. | -| `@cloudflare/polystella/runtime` | Runtime API: `getLocalizedEntry`, `getLocalizedCollection`, `localizedHref`, `polystellaMiddleware`. | -| `@cloudflare/polystella/runtime/middleware` | Direct middleware entrypoint — used by the integration's `addMiddleware` call. Rarely imported by consumers. | -| `@cloudflare/polystella/i18n` | UI-strings glue: `i18nLoader`, `i18nSchema`, `getTranslations`, `getDictionary`, drift helpers. | -| `@cloudflare/polystella/catalog` | Pure catalog helpers for JSON UI-string dictionaries. | -| `@cloudflare/polystella/catalog/middleware` | Catalog-only middleware that binds `Astro.locals.t` and `Astro.locals.lhref`. | -| `@cloudflare/polystella/catalog/astro` | Catalog-only Astro integration factory. | -| `@cloudflare/polystella/react` | React hooks: `useTranslations`, `useLocalizedHref` — for islands. | -| `@cloudflare/polystella/recipes` | MDX recipe helpers and built-in recipes. | -| `@cloudflare/polystella/recipes/starlight` | Conservative Starlight MDX recipe. | -| `@cloudflare/polystella/client` | Types only. Reference from `src/env.d.ts` for virtual-module types. No runtime import. | +PolyStella ships seventeen public import paths across four packages. + +| Owner | Path | Purpose | Example | +| --------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Core | `@cloudflare/polystella-core` | `Segment`, glossary and translator contracts, prompts, batching, parsing provider responses, and retries. | `import { translateSegments } from "@cloudflare/polystella-core";` | +| Adapters | `@cloudflare/polystella-adapters` | Portable Markdown, MDX, JSON, YAML, and TOML adapters plus parser and key-path helpers. | `import { jsonAdapter } from "@cloudflare/polystella-adapters";` | +| Providers | `@cloudflare/polystella-providers` | All provider factories from one entrypoint. | `import { createWorkersAIBindingTranslator } from "@cloudflare/polystella-providers";` | +| Providers | `@cloudflare/polystella-providers/workers-ai` | Workers AI HTTP and binding factories plus their structural input types. | `import { createWorkersAIHttpTranslator } from "@cloudflare/polystella-providers/workers-ai";` | +| Providers | `@cloudflare/polystella-providers/anthropic` | Anthropic HTTP factory. | `import { createAnthropicTranslator } from "@cloudflare/polystella-providers/anthropic";` | +| Astro | `@cloudflare/polystella` | Default export: the Astro integration factory and Astro-owned host utilities. | `import polystella from "@cloudflare/polystella";` | +| Astro | `@cloudflare/polystella/content` | Content-config helpers: `polystellaCollections`, `file`, `polystellaLoader`. | `import { polystellaCollections } from "@cloudflare/polystella/content";` | +| Astro | `@cloudflare/polystella/runtime` | Runtime API: `getLocalizedEntry`, `getLocalizedCollection`, `localizedHref`, `polystellaMiddleware`. | `import { localizedHref } from "@cloudflare/polystella/runtime";` | +| Astro | `@cloudflare/polystella/runtime/middleware` | Direct middleware entrypoint used by the integration. Rarely imported by consumers. | `import { polystellaMiddleware } from "@cloudflare/polystella/runtime/middleware";` | +| Astro | `@cloudflare/polystella/i18n` | UI-string glue: `i18nLoader`, `i18nSchema`, `getTranslations`, `getDictionary`, drift helpers. | `import { getDictionary } from "@cloudflare/polystella/i18n";` | +| Astro | `@cloudflare/polystella/catalog` | Pure catalog helpers for JSON UI-string dictionaries. | `import { buildTranslateFn } from "@cloudflare/polystella/catalog";` | +| Astro | `@cloudflare/polystella/catalog/middleware` | Catalog-only middleware that binds `Astro.locals.t` and `Astro.locals.lhref`. | `import { catalogMiddleware } from "@cloudflare/polystella/catalog/middleware";` | +| Astro | `@cloudflare/polystella/catalog/astro` | Catalog-only Astro integration factory. | `import catalogAstro from "@cloudflare/polystella/catalog/astro";` | +| Astro | `@cloudflare/polystella/react` | React hooks: `useTranslations`, `useLocalizedHref` for islands. | `import { useTranslations } from "@cloudflare/polystella/react";` | +| Astro | `@cloudflare/polystella/recipes` | MDX recipe helpers and built-in recipes. | `import { defineMdxRecipe } from "@cloudflare/polystella/recipes";` | +| Astro | `@cloudflare/polystella/recipes/starlight` | Conservative Starlight MDX recipe. | `import { starlightRecipe } from "@cloudflare/polystella/recipes/starlight";` | +| Astro | `@cloudflare/polystella/client` | Types only. Reference from `src/env.d.ts` for virtual-module types. No runtime import. | `/// ` | + +## Direct package flow + +Low-level consumers run directly in-process, with no hosted service or +extra network hop: + +```text +source/record -> adapter -> core -> provider -> core -> adapter -> output +``` + +For example, extract a JSON field, translate it through core, and apply +the returned map through the same adapter: + +```ts +import { EMPTY_GLOSSARY, translateSegments } from "@cloudflare/polystella-core"; +import { jsonAdapter } from "@cloudflare/polystella-adapters"; +import { createWorkersAIHttpTranslator } from "@cloudflare/polystella-providers/workers-ai"; + +const source = JSON.stringify({ title: "Hello" }); +const parsed = jsonAdapter.parse(source, "record.json"); +const segments = jsonAdapter.extractSegments(parsed, source, { + sourcePath: "record.json", + translatableKeys: { "record.json": ["title"] }, +}); +const translator = createWorkersAIHttpTranslator({ + accountId: "your-account-id", + apiToken: "your-api-token", + modelId: "@cf/meta/llama-3.1-8b-instruct", + maxTokens: 8192, +}); +const { translations } = await translateSegments({ + translator, + segments, + glossary: EMPTY_GLOSSARY, + sourceLocale: "en-US", + targetLocale: "pt-BR", +}); +const output = jsonAdapter.applyTranslations(parsed, source, translations); +``` + +The reusable packages use standard Web APIs and work in Workers without +`nodejs_compat`. Enabling `nodejs_compat` in a consuming Worker is also +supported. ## Which import goes where +### Direct core, adapter, and provider use + +```ts +import { translateSegments, type Translator } from "@cloudflare/polystella-core"; +import { markdownAdapter, type FileAdapter } from "@cloudflare/polystella-adapters"; +import { createAnthropicTranslator } from "@cloudflare/polystella-providers/anthropic"; +``` + ### `astro.config.mjs` ```js @@ -77,12 +132,14 @@ copy-paste starting point you can drop into your project. ## What's NOT public -Anything not in the table above is implementation detail and may -move between minor versions. The package's `package.json` `exports` -field is the source of truth — if it isn't listed there, importing -it directly is unsupported. - -The full list of internal subpaths under `src/` (which are -reachable via the `.` default export's `main` field for now) is -**not** a public API. We may add `"./internal/*": null` to -`exports` in a future minor to enforce this. +Anything not in the table above is implementation detail and may move +between minor versions. Each package's `package.json` `exports` field is +the source of truth. + +Low-level names formerly exported from `@cloudflare/polystella` moved to +their owners: `Segment`, `Glossary`, `Translator`, +`PermanentProviderError`, prompt helpers, and batching are in +`@cloudflare/polystella-core`; portable parsing and application helpers +are in `@cloudflare/polystella-adapters`; concrete transports are in +`@cloudflare/polystella-providers`. The old low-level imports have no +compatibility shims. diff --git a/fixtures/workerd/worker.ts b/fixtures/workerd/worker.ts new file mode 100644 index 0000000..191cd4a --- /dev/null +++ b/fixtures/workerd/worker.ts @@ -0,0 +1,28 @@ +import { jsonAdapter } from "@cloudflare/polystella-adapters"; +import { buildPrompt, EMPTY_GLOSSARY } from "@cloudflare/polystella-core"; +import { createWorkersAIBindingTranslator } from "@cloudflare/polystella-providers"; + +export default { + async fetch(): Promise { + const prompt = buildPrompt({ + segments: [{ id: "title", text: "Hello" }], + glossary: EMPTY_GLOSSARY, + sourceLocale: "en-US", + targetLocale: "pt-BR", + }); + const source = '{"entry":{"title":"Hello"}}'; + const parsed = jsonAdapter.parse(source); + const output = jsonAdapter.applyTranslations(parsed, source, new Map([["entry.title", "Ola"]])); + const translator = createWorkersAIBindingTranslator({ + modelId: "@cf/test/model", + maxTokens: 32, + run: async () => "Ola", + }); + + return Response.json({ + prompt: prompt.userPrompt.includes("@@title@@"), + title: (JSON.parse(output) as { entry: { title: string } }).entry.title, + translation: await translator.translate("system", "user"), + }); + }, +}; diff --git a/fixtures/workerd/wrangler.jsonc b/fixtures/workerd/wrangler.jsonc new file mode 100644 index 0000000..cc33248 --- /dev/null +++ b/fixtures/workerd/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "polystella-portability-test", + "main": "./worker.ts", + "compatibility_date": "2026-08-28", + "compatibility_flags": ["no_nodejs_compat", "no_nodejs_compat_v2"], +} diff --git a/llms-full.txt b/llms-full.txt index 1fb20a4..e7d3f80 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -38,17 +38,19 @@ section numbers. Inserting new sections never breaks links. ## Commands -| Command | What it does | -| :----------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pnpm test` | Run vitest (1104 tests / 56 files / ~1.2s at time of writing). | -| `pnpm test:watch` | Vitest in watch mode. | -| `pnpm build` | Compile `src/` → `dist/` via `tsc -p tsconfig.build.json` (mirrored layout, `.js` + `.d.ts` + sourcemaps + declaration maps). Produces the standalone `polystella` CLI at `dist/cli.js` and library entries. | -| `pnpm exec tsc --noEmit` | Typecheck against the root `tsconfig.json` (which includes tests). The build config (`tsconfig.build.json`) sets `noEmit: false` and narrows `include` to `src/**`. | +| Command | What it does | +| :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm test` | Run all package, Astro, workerd, and boundary tests. | +| `pnpm test:watch` | Run the Astro package tests in watch mode. | +| `pnpm build` | Build all four public packages. Astro emits its standalone CLI and library entries under `packages/astro/dist/`. | +| `pnpm typecheck` | Build all four packages, then typecheck every public package against its package-local `tsconfig.json`. | +| `pnpm check:packages` | Pack all four public packages and exercise every export from a clean temporary consumer. | +| `pnpm changeset` | Add a Changesets entry for package-affecting work. Use `pnpm changeset add --empty` only for changes that intentionally do not need a package release. | No lint step yet. > Test counts age. The authoritative count is `pnpm test`'s output; -> the number here is a snapshot pinned by [`tests/docs.test.ts`](./tests/docs.test.ts). +> the number here is a snapshot pinned by [`packages/astro/tests/docs.test.ts`](./packages/astro/tests/docs.test.ts). --- @@ -58,24 +60,29 @@ Task → entry-point file(s) → key contract → deep-dive link. | Task | Entry point | Contract | See | | :----------------------------------------------------- | :----------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------- | -| Add a file-format adapter | `src/parsing/adapters/.ts`; register in `src/parsing/registry.ts` | `FileTypeAdapter` in `src/parsing/adapter.ts` | [#adapter-contract](./ARCHITECTURE.md#adapter-contract); recipe in [contributor SKILL](./skills/polystella-contributor/SKILL.md#add-adapter) | -| Add a CLI subcommand | Handler in `src/cli/.ts`; register in `src/cli.ts` (`parseSubcommand` + switch) | Argv parser + `run(args, deps)` | Recipe in [contributor SKILL](./skills/polystella-contributor/SKILL.md#add-cli-subcommand) | -| Add a translation provider | New branch in `createTranslator` (`src/translation/provider.ts`) | `Translator` interface; permanent vs retriable error classification | [#translator-contract](./ARCHITECTURE.md#translator-contract); recipe in [contributor SKILL](./skills/polystella-contributor/SKILL.md#add-provider) | -| Change cache key formula | `src/storage/hash.ts` | **Invariant 1** — cache-wide invalidation | [#cache-key](./ARCHITECTURE.md#cache-key) | -| Edit translation batching | `src/translation/batch.ts`, `src/translation/translate-segments.ts` | **Invariant 2** — `flat(groups) === segments` | [#translation-batching](./ARCHITECTURE.md#translation-batching) | -| Modify cache/storage behaviour | `src/storage/{cache,r2,prune,local-cache,report}.ts` | Apply-before-PUT (**Invariant 3**); index isolation (**Invariant 4**) | [#cache-write-order](./ARCHITECTURE.md#cache-write-order), [#local-staging-index](./ARCHITECTURE.md#local-staging-index) | -| Modify runtime APIs (entry/collection/href/middleware) | `src/runtime/*` | Bridge timing (**Invariant 5**); per-locale closures | [#runtime-bridge](./ARCHITECTURE.md#runtime-bridge) | -| Modify routing shims | `src/routing/{shim,expand-routes,walk-pages}.ts` | Stale shims nuked per build; CSS via `routesImports` | [#routing-shims](./ARCHITECTURE.md#routing-shims) | -| Edit UI-string handling | `src/i18n/*`, `src/cli/{check,sync,translate}-ui.ts` | Three drift modes; layout-aware writer; `{{token}}` preservation | [#ui-strings](./ARCHITECTURE.md#ui-strings) | -| Edit catalog-only adoption | `src/catalog/*`, `src/i18n/{translate,drift,sync}.ts` | Pure imports; middleware binds only `t` + `lhref` | [`CATALOG_ONLY_PLAN.md`](./CATALOG_ONLY_PLAN.md); [consumer SKILL](./skills/polystella-consumer/SKILL.md#catalog-only-adoption) | -| Edit content-collection wiring | `src/content/*` | Sibling collections; custom-loader wrapper; bridge timing | [#runtime-bridge](./ARCHITECTURE.md#runtime-bridge) | +| Add a file-format adapter | `packages/adapters/src/adapters/.ts`; wrap/register in `packages/astro/src/parsing/` | `FileAdapter` in `packages/adapters/src/adapter.ts` | [#adapter-contract](./ARCHITECTURE.md#adapter-contract); recipe in [contributor SKILL](./skills/polystella-contributor/SKILL.md#add-adapter) | +| Add a CLI subcommand | Handler in `packages/astro/src/cli/.ts`; register in `packages/astro/src/cli.ts` | Argv parser + `run(args, deps)` | Recipe in [contributor SKILL](./skills/polystella-contributor/SKILL.md#add-cli-subcommand) | +| Add a translation provider | Factory in `packages/providers/src/`; map config in `packages/astro/src/translation/provider.ts` | `Translator` in `packages/core/src/translator.ts` | [#translator-contract](./ARCHITECTURE.md#translator-contract); recipe in [contributor SKILL](./skills/polystella-contributor/SKILL.md#add-provider) | +| Change cache key formula | `packages/astro/src/storage/hash.ts` | **Invariant 1** — cache-wide invalidation | [#cache-key](./ARCHITECTURE.md#cache-key) | +| Edit translation batching | `packages/core/src/{batch,translate-segments}.ts` | **Invariant 2** — `flat(groups) === segments` | [#translation-batching](./ARCHITECTURE.md#translation-batching) | +| Modify cache/storage behaviour | `packages/astro/src/storage/{cache,r2,prune,local-cache,report}.ts` | Apply-before-PUT (**Invariant 3**); index isolation (**Invariant 4**) | [#cache-write-order](./ARCHITECTURE.md#cache-write-order), [#local-staging-index](./ARCHITECTURE.md#local-staging-index) | +| Modify runtime APIs (entry/collection/href/middleware) | `packages/astro/src/runtime/*` | Bridge timing (**Invariant 5**); per-locale closures | [#runtime-bridge](./ARCHITECTURE.md#runtime-bridge) | +| Modify routing shims | `packages/astro/src/routing/{shim,expand-routes,walk-pages}.ts` | Stale shims nuked per build; CSS via `routesImports` | [#routing-shims](./ARCHITECTURE.md#routing-shims) | +| Edit UI-string handling | `packages/astro/src/i18n/*`, `packages/astro/src/cli/{check,sync,translate}-ui.ts` | Three drift modes; layout-aware writer; `{{token}}` preservation | [#ui-strings](./ARCHITECTURE.md#ui-strings) | +| Edit catalog-only adoption | `packages/astro/src/catalog/*`, `packages/astro/src/i18n/{translate,drift,sync}.ts` | Pure imports; middleware binds only `t` + `lhref` | [`CATALOG_ONLY_PLAN.md`](./CATALOG_ONLY_PLAN.md); [consumer SKILL](./skills/polystella-consumer/SKILL.md#catalog-only-adoption) | +| Edit content-collection wiring | `packages/astro/src/content/*` | Sibling collections; custom-loader wrapper; bridge timing | [#runtime-bridge](./ARCHITECTURE.md#runtime-bridge) | | Debug a translation that's wrong | Start: `pnpm translate --dry-run` to inspect planned R2 keys; `LOG_LEVEL=debug` for batch detail | — | Recipe in [contributor SKILL](./skills/polystella-contributor/SKILL.md#debug-translation) | | Tune cold-cache build performance | `r2.bulkListOnStart`, `concurrency`, `batchInputTokenBudget` knobs | — | [#bulk-prelist](./ARCHITECTURE.md#bulk-prelist), [#translation-batching](./ARCHITECTURE.md#translation-batching) | -If your task isn't on this list, the answer is in `src//` +If your task isn't on this list, the answer is in `packages/astro/src//` matching one of the subsystem sections in [ARCHITECTURE.md](./ARCHITECTURE.md). +The root is private. Reusable package code uses standard Web APIs and must +work without `nodejs_compat`, although consumers may enable it. Import +low-level contracts from core, formats from adapters, and transports from +providers; do not add compatibility shims to the Astro package. + --- ## Invariants @@ -83,7 +90,7 @@ matching one of the subsystem sections in Hard contracts. Don't violate without thinking carefully — link back to the explanatory section when adding code that touches one. -1. **Cache key formula** — `sha256(body + selectedFrontmatterValues + glossaryHash + modelId)`. Changing any input is a cache-wide invalidation. [→ #cache-key](./ARCHITECTURE.md#cache-key) +1. **Cache key formula** — `sha256(body + selectedFrontmatterValues + glossaryHash + modelId + optionalExtractionPolicyHash)`. Changing any input is a cache-wide invalidation. [→ #cache-key](./ARCHITECTURE.md#cache-key) 2. **Group flattening** — `flat(adapter.groupSegments(...)) === segments` (reference-equal, order-preserved). Asserted at runtime. [→ #translation-batching](./ARCHITECTURE.md#translation-batching) 3. **Apply before PUT** — `applyTranslations` produces the exact bytes PUT to R2; markers woven inside `apply`, never after. [→ #cache-write-order](./ARCHITECTURE.md#cache-write-order) 4. **Local cache index write isolation** — workers write to `nextLocalCacheIndex` only; read only from `localCacheIndex`. [→ #local-staging-index](./ARCHITECTURE.md#local-staging-index) @@ -100,10 +107,17 @@ to the explanatory section when adding code that touches one. ### Always - Run `pnpm test` before pushing. Tests must stay green. -- Bump the package version in `package.json` only — `POLYSTELLA_VERSION` - (in `src/version.ts`) reads it at module-load time via a JSON - import attribute, so the constant flows automatically through to - `dist/version.js` after `pnpm build`. [→ #version-constant](./ARCHITECTURE.md#version-constant) +- Add a Changesets entry for every change that affects the published + package, its documented behaviour, or release-facing contributor + guidance. Use `pnpm changeset` for release notes; use + `pnpm changeset add --empty` only when the change deliberately does + not require a package release (for example, docs-site-only or CI-only + maintenance). +- Let Changesets version the fixed four-package group; do not manually bump + individual manifests. `POLYSTELLA_VERSION` in + `packages/astro/src/version.ts` reads the Astro manifest at module-load time + and flows to `packages/astro/dist/version.js` after `pnpm build`. + [→ #version-constant](./ARCHITECTURE.md#version-constant) - Mirror filesystem path semantics across OS: forward slashes for R2 keys, `path.sep` for local I/O. - Forward `signal: AbortSignal` when adding a new async function on @@ -145,10 +159,10 @@ Tiered so you can scan the ones that matter for your change. ### Will produce confusing failures - **Override files at `i18n/overrides/{locale}/`** win over AI output verbatim. They run through the URL rewriter (idempotent) but are NOT written to R2. Cache key changes don't affect overrides. -- **MDX vs MD** — `remark-mdx` disables indented code, autolinks, and raw-HTML blocks. Route through the right parser by extension; never apply MDX rules to `.md`. +- **MDX vs MD** — `.mdx` uses MDX syntax rules (imports/exports, JSX, expressions); `.md` stays plain Markdown. Route by extension; never apply MDX rules to `.md`. - **Drift check fails on empty placeholders** ([#ui-strings](./ARCHITECTURE.md#ui-strings)). `pnpm i18n:sync` alone leaves the tree non-shippable until `pnpm i18n:translate` (or a hand-edit) fills the placeholders. Intentionally-blank labels are supported via matching `""` in the source dict. - **UI-string sync writer is layout-aware** ([#ui-strings](./ARCHITECTURE.md#ui-strings)). Running `prettier --write` on a synced file collapses the blank-line section breaks. The pre-commit hook only runs `prettier --check`, so it doesn't trip — but a manual `pnpm format` will churn diffs. -- **`translate-ui` runs locales in parallel** ([#ui-strings](./ARCHITECTURE.md#ui-strings)). Workers MUST catch every error internally and record it on the per-locale outcome — never re-throw. Re-throwing kills the rest of the run. +- **`translate-ui` pre-scans, then runs queued locales in parallel** ([#ui-strings](./ARCHITECTURE.md#ui-strings)). Complete locale JSONs are skipped before provider setup. Queued locales are capped at 3 concurrent workers; each locale is internally split into small sequential request batches. Workers MUST catch every error internally and record it on the per-locale outcome — never re-throw. Re-throwing kills the rest of the run. - **`adapter.parse` is called once per source per live run** ([#dry-run-vs-live](./ARCHITECTURE.md#dry-run-vs-live)). Adapter `parse` should still be idempotent — calling it twice during debugging or in tests must not produce different output. ### Performance tuning, not correctness @@ -164,7 +178,8 @@ Tiered so you can scan the ones that matter for your change. Before pushing: - `pnpm test` must pass. -- `pnpm exec tsc --noEmit` must pass (strict mode). +- `pnpm typecheck` must pass (strict mode). +- `pnpm check:packages` must pass for packaging or export changes. - For changes to the translation pipeline, run end-to-end against a real consumer's fixtures: `polystella translate --dry-run` walks the full pipeline without hitting AI/R2. @@ -207,6 +222,7 @@ section never invalidates an existing link. ## Contents - [Overview](#overview) +- [Package boundaries](#package-boundaries) - [Glossary](#glossary) - [Invariants](#invariants) - [Pipeline](#pipeline) @@ -242,6 +258,15 @@ Cloudflare R2, and injects locale-prefixed routes for the translated pages. The same orchestrator powers a standalone `polystella` CLI so operators can run the pipeline outside `astro build`. +The direct in-process flow is exact: + +```text +source/record -> adapter -> core -> provider -> core -> adapter -> output +``` + +There is no hosted core service or required network hop beyond the +selected provider transport. + ``` ┌────────────────────────────────────────────────────────┐ │ astro:config:setup (or CLI) │ @@ -285,15 +310,41 @@ operators can run the pipeline outside `astro build`. astro build → dist/ ``` -Two entry points share `runTranslationPass` in `src/translation/run.ts`: +Two entry points share `runTranslationPass` in `packages/astro/src/translation/run.ts`: -- **Astro integration** (`src/index.ts`) — registers hooks, runs the +- **Astro integration** (`packages/astro/src/index.ts`) — registers hooks, runs the pass, publishes the runtime bridge. -- **CLI** (`src/cli.ts`) — verb-style dispatcher routing to - `src/cli/.ts`. `translate` reuses `runTranslationPass`; +- **CLI** (`packages/astro/src/cli.ts`) — verb-style dispatcher routing to + `packages/astro/src/cli/.ts`. `translate` reuses `runTranslationPass`; `check-ui`, `sync-ui`, `translate-ui` operate on UI-string JSONs and don't touch the markdown pipeline or R2. +## Package boundaries + + + +- `@cloudflare/polystella-core` owns `Segment`, glossaries, the + `Translator` contract, prompt construction/response parsing, batching, + and retries. +- `@cloudflare/polystella-adapters` owns portable format parsing, + extraction, grouping, and translation application. +- `@cloudflare/polystella-providers` owns Workers AI HTTP/binding and + Anthropic transports. +- `@cloudflare/polystella` owns Astro hooks, filesystem and R2 access, + cache/marker/URL policy, routing, runtime APIs, and the CLI. + +The three reusable packages depend only on standard Web APIs at runtime. +They execute in Workers without `nodejs_compat`; enabling the flag in a +consumer remains supported. Providers use package-owned structural types +for bindings and do not import generated Cloudflare types. + +Low-level exports moved to their owning packages during extraction. In +particular, `Segment`, `Glossary`, `Translator`, +`PermanentProviderError`, prompt helpers, and batching moved from the +Astro root to core; portable parsing/application moved to adapters; and +provider factories moved to providers. No compatibility shims preserve +the old low-level imports. + --- ## Glossary @@ -302,7 +353,7 @@ Two entry points share `runTranslationPass` in `src/translation/run.ts`: | Term | Meaning | | :------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Adapter** | A `FileTypeAdapter` implementation owning one file format (markdown, TOML, JSON, YAML). Parses, extracts segments, applies translations. See [adapter contract](#adapter-contract). | +| **Adapter** | A reusable `FileAdapter` implementation owning one file format (markdown, TOML, JSON, YAML). Astro wraps it with host policy as `FileTypeAdapter`. See [adapter contract](#adapter-contract). | | **Segment** | The atomic translatable unit emitted by `adapter.extractSegments`. Has a stable per-file ID (`body:N`, `fm:key`, `fm:key[i]`, or a dotted key path). | | **Group** | An ordered list of segments inside one section (e.g. all paragraphs under one heading). Produced by `adapter.groupSegments`; the batcher packs groups into batches under a token budget. | | **Batch** | One prompt round-trip's worth of segments. Produced by `packGroupsIntoBatches`. Each batch carries its own document-context block. | @@ -313,7 +364,7 @@ Two entry points share `runTranslationPass` in `src/translation/run.ts`: | **Drift** | A non-default-locale UI-string JSON disagreeing with the default-locale source: missing keys, extra keys, or `""` placeholders where the source is non-empty. | | **Miss path** | The code branch in the cache layer when an R2 GET returns nothing. Triggers translator + apply + PUT. | | **Live phase / live run** | A run that actually translates (provider configured, `dryRun: false`). The pipeline only walks sources once in this mode. | -| **Bridge** | `src/runtime/custom-loader-runtime.ts` — the module-scoped singleton holding live JS objects (R2 client, translators, glossaries) shared between `astro:config:setup` and the sibling content collections registered at content-sync time. | +| **Bridge** | `packages/astro/src/runtime/custom-loader-runtime.ts` — the symbol-keyed `globalThis` value holding live JS objects (R2 client, translators, glossaries) across `astro:config:setup` and sibling content collections at content-sync time. | | **Sibling collection** | A per-locale content collection (`publications__pt-BR`, etc.) auto-registered by `polystellaCollections` alongside the user's source collection. | | **Branch dispatch** | The three-mode R2 prefix selection (local / CI main / CI preview) driven by `WORKERS_CI_BRANCH` and `POLYSTELLA_CLI`. See [R2 dispatch](#r2-dispatch). | @@ -327,7 +378,7 @@ Hard contracts. Violating any of these breaks correctness or production data. Always link back to the explanatory section when adding new code that touches one. -1. **Cache key formula.** `hash = sha256(body + selectedFrontmatterValues + glossaryHash + modelId)`. Any change is a cache-wide invalidation. See [#cache-key](#cache-key). +1. **Cache key formula.** `hash = sha256(body + selectedFrontmatterValues + glossaryHash + modelId + optionalExtractionPolicyHash)`. Any change is a cache-wide invalidation. See [#cache-key](#cache-key). 2. **Group flattening.** `flat(adapter.groupSegments(...)) === segments` (reference-equal, order-preserved). Asserted at runtime. See [#translation-batching](#translation-batching). 3. **Apply before PUT.** `adapter.applyTranslations` must produce the exact bytes that get PUT to R2; any AI-translation marker is woven in inside `apply`, never after. Cache hits return the PUT bytes verbatim. See [#cache-write-order](#cache-write-order). 4. **Local cache index write isolation.** Pool workers read from `localCacheIndex` (immutable for the run) and write to `nextLocalCacheIndex` (accumulated, persisted at end). A worker MUST NOT read from `nextLocalCacheIndex`. See [#local-staging-index](#local-staging-index). @@ -343,20 +394,20 @@ adding new code that touches one. -`runTranslationPass` (in `src/translation/run.ts`) is the +`runTranslationPass` (in `packages/astro/src/translation/run.ts`) is the orchestrator. Same function powers both the Astro integration and the `polystella translate` CLI subcommand. Zero direct dependency on Astro's types so the CLI can run without Astro on the import path. Sequenced steps: -1. Load glossaries (`src/glossary/`). -2. Walk sources (`src/source/walk.ts`) — respects `include` / `exclude`. +1. Load glossaries (`packages/astro/src/glossary/`). +2. Walk sources (`packages/astro/src/source/walk.ts`) — respects `include` / `exclude`. 3. Bulk pre-list R2 once per (prefix × locale) — populates an in-memory existence predicate (see [#bulk-prelist](#bulk-prelist)). 4. Read the local staging index (see [#local-staging-index](#local-staging-index)). 5. Run a worker pool over (file, locale) pairs with - `runWithConcurrency` (`src/source/pool.ts`). + `runWithConcurrency` (`packages/astro/src/source/pool.ts`). 6. Per pair: short-circuit on local-skip → check override → check R2 → translate → apply → PUT → rewrite URLs → stage. 7. Persist `nextLocalCacheIndex` to disk. @@ -417,10 +468,10 @@ content-sync time). The two halves need to share live JS objects (R2 client, translators, glossaries) that can't be serialised through the `polystella:runtime-config` virtual module. -The bridge in `src/runtime/custom-loader-runtime.ts` is a module-scoped -singleton populated by `setRuntimeBridge` during `config:setup` and -read by the sibling loaders at sync time. Module-scoped state is fine -because Astro runs both halves in the same Node process. +The bridge in `packages/astro/src/runtime/custom-loader-runtime.ts` is a +symbol-keyed `globalThis` value populated by `setRuntimeBridge` during +`config:setup` and read by sibling loaders at sync time. The global survives +Vite module-graph reloads while Astro runs both halves in one process. `publishRuntimeBridge` in `index.ts` re-loads glossaries and constructs translators that `runTranslationPass` already built internally. The @@ -437,7 +488,7 @@ pipeline. A future consolidation can extract a shared dep builder. R2 keys are content-addressed: ``` -hash = sha256(body + selectedFrontmatterValues + glossaryHash + modelId) +hash = sha256(body + selectedFrontmatterValues + glossaryHash + modelId + optionalExtractionPolicyHash) ``` Inputs: @@ -452,6 +503,10 @@ Inputs: corpus. - **`modelId`** — the per-locale resolved model. Switching models is an explicit invalidation. +- **`optionalExtractionPolicyHash`** — included by adapters whose + extraction boundaries are configurable. Markdown/MDX includes the + `markdown.parser` choice; MDX also includes normalized JSX/static-data + rules. **Not in the hash:** @@ -560,7 +615,7 @@ tests must not produce different output. -The cache layer (`src/storage/cache.ts`) is format-agnostic. On a miss +The cache layer (`packages/astro/src/storage/cache.ts`) is format-agnostic. On a miss it: 1. calls the translator, @@ -602,11 +657,13 @@ exemptions apply uniformly. Both layers are idempotent -Every file format implements `FileTypeAdapter` in -`src/parsing/adapter.ts` and registers in `src/parsing/registry.ts`. -No changes to `run.ts` or the cache layer required. +Every reusable file format implements `FileAdapter` in +`packages/adapters/src/adapter.ts`. Astro wraps it with cache selection, +`noTranslate`, URL, document-context, marker, and parser policies in +`packages/astro/src/parsing/`, then registers it in +`packages/astro/src/parsing/registry.ts`. -Abbreviated shape: +The Astro wrapper's abbreviated superset shape: ```ts interface FileTypeAdapter { @@ -651,7 +708,9 @@ call `resetRegistry()` before registering. One `Translator` per (provider, locale). Two concrete providers ship: Workers AI and Anthropic. Both speak the same prompt-and-JSON-back -contract enforced by `src/translation/prompt.ts`. +contract enforced by `packages/core/src/prompt.ts`. +`packages/astro/src/translation/provider.ts` only maps validated Astro configuration +to the concrete factories in `packages/providers`. ```ts interface Translator { @@ -697,7 +756,7 @@ single-group default in `translateSegments`, which then packs by token budget alone. **Token-aware packing.** `packGroupsIntoBatches` (in -`src/translation/batch.ts`) is a pure function over `Segment[][]`. +`packages/core/src/batch.ts`) is a pure function over `Segment[][]`. Greedy-fills batches under a soft input-token budget (`provider.batchInputTokenBudget`, default `4000`) using `Math.ceil((id + text + 8) / 4)` per segment — the `+8` covers the @@ -780,23 +839,27 @@ source of truth; non-default locales must match its key set. Three CLI subcommands maintain the invariant: -- **`check-ui`** (`src/cli/check-ui.ts`) — pure drift detection. Zero +- **`check-ui`** (`packages/astro/src/cli/check-ui.ts`) — pure drift detection. Zero writes, zero network. Pre-commit hook target. Catches three failure modes: missing keys, extra keys, and **empty-placeholder values** (a key shared with the source dict but with `""` in the locale where the source value is non-empty). The build's own drift check at `astro:config:setup` uses the same predicate. -- **`sync-ui`** (`src/cli/sync-ui.ts` + `src/i18n/sync.ts`) — +- **`sync-ui`** (`packages/astro/src/cli/sync-ui.ts` + + `packages/astro/src/i18n/sync.ts`) — mechanical key reconciliation. Adds missing keys as empty strings, drops extras, preserves existing values (empty or not), re-emits files in source-file key order with blank-line section breaks preserved. -- **`translate-ui`** (`src/cli/translate-ui.ts` + - `src/i18n/ui-translate.ts`) — runs sync, then for each locale calls - `translateBatch` once with every empty-valued key as a segment. - Locales run in parallel via `runWithConcurrency`. - -**Layout-aware writer.** `formatLocaleFile` in `src/i18n/sync.ts` +- **`translate-ui`** (`packages/astro/src/cli/translate-ui.ts` + + `packages/astro/src/i18n/ui-translate.ts`) — runs sync, skips fully translated + locale JSONs before provider setup, then translates each queued + locale in small sequential request batches capped by + `provider.batchInputTokenBudget` and 25 UI strings per request. + Queued locales run in parallel via `runWithConcurrency` with a hard + max locale concurrency of 3. + +**Layout-aware writer.** `formatLocaleFile` in `packages/astro/src/i18n/sync.ts` parses the source file's text (not just its JSON) to recover top-level key order AND which keys start a new "section" (blank line immediately before). The output then mirrors that layout for every @@ -806,21 +869,22 @@ keys alphabetically. **`{{token}}` preservation.** Validated post-translation by extracting `{{\w+}}` tokens from both source and translation and comparing the sets. Validator lives _outside_ `translateBatch` (in -`src/i18n/ui-translate.ts`) because `translateBatch` doesn't expose -a post-parse hook. The orchestrator runs its own retry wrapper with -`maxRetries: 0` passed to `translateBatch` so the retry loop is -single-layer. A token-invalid translation after all retries leaves -the key empty and reports it — a broken `{{year}}` placeholder breaks -the page at runtime, so "obviously untranslated" is safer than -"subtly broken". - -**Parallel locale execution.** `translate-ui` runs locales in -parallel via `runWithConcurrency`. The pool primitive short-circuits -on the first worker rejection (matches `Promise.all`), which would -let one locale's failure kill the rest. Workers MUST catch every -error internally and record it on the per-locale outcome — never -re-throw. Per-locale logs are buffered and flushed in `targets` -order so the final output is deterministic. +`packages/astro/src/i18n/ui-translate.ts`) because `translateBatch` doesn't expose +a post-parse hook. The orchestrator runs its own per-request-batch +retry wrapper with `maxRetries: 0` passed to `translateBatch` so the +retry loop is single-layer. A token-invalid translation after all +retries leaves the key empty and reports it — a broken `{{year}}` +placeholder breaks the page at runtime, so "obviously untranslated" is +safer than "subtly broken". + +**Parallel locale execution.** `translate-ui` first scans every target +locale JSON and logs skip/queue progress (`[n/total]`). Only queued +locales run in parallel via `runWithConcurrency`, capped by both +`polystella.config.mjs` `concurrency` and a hard UI-locale max of 3. +The pool primitive short-circuits on the first worker rejection +(matches `Promise.all`), which would let one locale's failure kill the +rest. Workers MUST catch every error internally and record it on the +per-locale outcome — never re-throw. **No R2 caching.** Intentionally not wired into `translate-ui`. The content-collection cache keys files by SHA-256 of full file body, so @@ -896,22 +960,25 @@ boundaries that could otherwise run indefinitely. -`POLYSTELLA_VERSION` lives in `src/version.ts` as a JSON import from +`POLYSTELLA_VERSION` lives in `packages/astro/src/version.ts` as a JSON import from `package.json` (`import pkg from "../package.json" with { type: -"json" }`). Re-exported from `src/index.ts`, consumed directly by -`src/cli.ts`. - -Both the CLI (`dist/cli.js`) and the library entries (`dist/index.js`, -`dist/runtime/index.js`, …) are produced by `tsc -p -tsconfig.build.json` (`pnpm build`). tsc preserves the `with { type: -"json" }` import attribute, so `dist/version.js` resolves +"json" }`). Re-exported from `packages/astro/src/index.ts`, consumed directly by +`packages/astro/src/cli.ts`. + +Both the CLI (`packages/astro/dist/cli.js`) and the library entries +(`packages/astro/dist/index.js`, `packages/astro/dist/runtime/index.js`, …) +are produced by `tsc -p packages/astro/tsconfig.build.json` (`pnpm build`). +tsc preserves the `with { type: "json" }` import attribute, so +`packages/astro/dist/version.js` resolves `../package.json` (i.e. the package root) at module-load time inside -the consumer's `node_modules/polystella/`. No version inlining; one +the consumer's `node_modules/@cloudflare/polystella/`. No version inlining; one source of truth. -Bump `package.json` only; both surfaces follow. The constant is baked -into R2 metadata and the build report but is NOT in the cache key -formula, so a version bump doesn't re-translate. +Changesets versions all four public packages as a fixed group. The Astro +manifest remains this constant's source, so both Astro surfaces follow its +generated version. The constant is baked into R2 metadata and the build +report but is NOT in the cache key formula, so a version bump doesn't +re-translate. @@ -956,7 +1023,7 @@ pnpm add @cloudflare/polystella The standalone CLI binary is still named `polystella`. -Peer dependency: `astro ^6.0.0`. +Peer dependency: `astro ^7.0.10`. ## Four-file integration @@ -987,7 +1054,9 @@ export default defineConfig({ ### 2. `polystella.config.mjs` -Where provider, glossary, R2, format-specific keys live. Schema source of truth is `src/config/options.ts` in the package; everything is zod-validated at the boundary. +Where provider, glossary, R2, format-specific keys live. The schema source +of truth is `packages/astro/src/config/options.ts` in the repository; +everything is zod-validated at the boundary. Skeleton: @@ -1068,6 +1137,26 @@ export const collections = { Picks up types for PolyStella's virtual modules (`polystella:runtime-config`). +## Direct package use + +Use the lower-level packages when Astro should not own the operation: + +```text +source/record -> adapter -> core -> provider -> core -> adapter -> output +``` + +- Import translation contracts, glossaries, prompts, batching, and + `PermanentProviderError` from `@cloudflare/polystella-core`. +- Import portable format adapters from + `@cloudflare/polystella-adapters`. +- Import Workers AI and Anthropic factories from + `@cloudflare/polystella-providers` or its provider subpaths. + +These packages use standard Web APIs and run in Workers without +`nodejs_compat`; enabling `nodejs_compat` is also supported. The Astro +package has no compatibility shims for low-level imports that moved to +these owners. + ## UI strings Chrome text (nav, footer, accessibility strings) lives in @@ -1226,7 +1315,7 @@ After every translation pass, `astro build` (and `polystella translate`) writes 6. **`prettier --write` collapses sync writer's blank lines** — The UI-string sync writer preserves blank-line section breaks between key groups. `prettier --write` collapses them. The pre-commit hook should use `prettier --check` (not `--write`). 7. **`{{token}}` placeholders dropped by AI** — Validated post-translation; if a token is missing or renamed after all retries, the key is left empty for manual fix-up. Hand-edit the locale JSON in that case. 8. **Override files don't get cache-invalidated** — Edits to overrides aren't reflected in the cache (overrides aren't cached). The override is read fresh every build. -9. **MDX vs MD** — `remark-mdx` disables indented code, autolinks, and raw-HTML blocks. If your `.md` files use any of these, don't accidentally rename them to `.mdx`. +9. **MDX vs MD** — `.mdx` uses MDX syntax rules (imports/exports, JSX, expressions); `.md` stays plain Markdown. If your `.md` files use Markdown-only constructs, don't accidentally rename them to `.mdx`. 10. **R2 credentials in repo** — Never commit credentials. Use `.env` (gitignored) + `dotenv/config` at the top of `polystella.config.mjs`. Workers Builds inject credentials via env vars; local development reads from `.env`. ## CLI quick reference @@ -1287,9 +1376,9 @@ When a translation is wrong: | You want to | Look at | | :-------------------- | :-------------------------------------------------------------------- | -| Understand the system | `node_modules/polystella/ARCHITECTURE.md` | -| See config schema | `node_modules/polystella/src/config/options.ts` | -| See available exports | `node_modules/polystella/package.json` (`exports` field) | +| Understand the system | `https://github.com/cloudflare/polystella/blob/main/ARCHITECTURE.md` | +| See config schema | `node_modules/@cloudflare/polystella/src/config/options.ts` | +| See available exports | Each installed `@cloudflare/polystella*` package manifest | | See CLI flags | `polystella --help`, `polystella --help` | | Debug a translation | `dist/i18n-r2-report.json`, `/.astro/i18n-staging//...` | | File an issue | `https://github.com/cloudflare/polystella/issues` | @@ -1320,6 +1409,18 @@ Read first: Then come back here for step-by-step task recipes. +Package ownership follows the direct in-process flow: + +```text +source/record -> adapter -> core -> provider -> core -> adapter -> output +``` + +Core owns low-level translation contracts and orchestration, adapters own +portable formats, providers own transports, and Astro owns host policy. +Reusable packages use standard Web APIs and must work without +`nodejs_compat`; consumers may enable it. Do not add compatibility shims +for low-level imports that moved out of the Astro package. + --- ## Recipes @@ -1342,17 +1443,17 @@ Then come back here for step-by-step task recipes. **When to use:** Supporting a new file extension (`.xml`, `.html`, `.po`, custom format). -**Contract:** `FileTypeAdapter` in `src/parsing/adapter.ts`. See [#adapter-contract](../../ARCHITECTURE.md#adapter-contract). +**Contract:** `FileAdapter` in `packages/adapters/src/adapter.ts`; Astro policies wrap it in `packages/astro/src/parsing/adapter.ts`. See [#adapter-contract](../../ARCHITECTURE.md#adapter-contract). **Steps:** -1. Implement the adapter at `src/parsing/adapters/.ts`: +1. Implement the portable adapter at `packages/adapters/src/adapters/.ts`: ```ts - import type { FileTypeAdapter, AdapterExtractOptions, AdapterApplyOptions } from "../adapter.js"; - import type { Segment } from "../extract.js"; + import type { Segment } from "@cloudflare/polystella-core"; + import type { FileAdapter, AdapterExtractOptions, AdapterApplyOptions } from "../adapter.js"; - export const myFormatAdapter: FileTypeAdapter = { + export const myFormatAdapter: FileAdapter = { extensions: [".myext"], parse(source, sourcePath) { @@ -1374,24 +1475,14 @@ Then come back here for step-by-step task recipes. // into the output here, not after. }, - selectedValuesForHash(parsed, source, opts): Record { - // Snapshot of values that feed the cache hash. Only fields - // your adapter considers translatable should appear here. - }, - - peekNoTranslate(parsed): boolean { - // Return true when the source is opted out via your format's - // convention (e.g. top-level `noTranslate: true`). - }, - - // Optional: - rewriteUrls(bytes, opts): string { ... }, // post-cache; idempotent - groupSegments(parsed, segments): Segment[][] { ... }, // INVARIANT 2 - documentContext(parsed, opts): string | undefined { ... }, + groupSegments(parsed, segments): Segment[][] { ... }, // optional, INVARIANT 2 }; ``` -2. Register in `src/parsing/registry.ts`: +2. Add Astro's cache-selection, `noTranslate`, URL, document-context, + marker, and parser policies in a small wrapper under + `packages/astro/src/parsing/adapters/`, then register that wrapper in + `packages/astro/src/parsing/registry.ts`: ```ts import { myFormatAdapter } from "./adapters/myformat.js"; @@ -1401,27 +1492,23 @@ Then come back here for step-by-step task recipes. **First-registered wins.** If your adapter claims an extension another adapter already owns, your registration is silently ignored. The order at the bottom of `registry.ts` is the de-facto priority. -3. Add tests under `tests/parsing/adapters/.test.ts`. Mirror the structure of an existing adapter test (`tests/parsing/adapters/toml.test.ts` is a good template — it's structured-data-flavoured like most new adapters will be). +3. Add portable tests under `packages/adapters/tests/` and retain Astro-policy parity tests under `packages/astro/tests/parsing/`. - Required test coverage: - - `parse` round-trip (parse → reserialize via `applyTranslations` with no translations → byte-identical) - - `extractSegments` produces expected IDs - - `applyTranslations` splices correctly - - `selectedValuesForHash` snapshots ONLY translatable fields - - `peekNoTranslate` honours your format's opt-out convention - - If you implement `rewriteUrls`: idempotent on already-rewritten input - - If you implement `groupSegments`: `flat(result) === segments` (reference-equal) + Required portable coverage: parsing/reconstruction, segment IDs, + translation application, and group flattening by reference. Astro wrapper + tests cover selected hash values, `noTranslate`, markers, context, and + idempotent URL rewriting. -4. **No changes to `src/translation/run.ts` or `src/storage/cache.ts`.** The orchestrator dispatches by extension via the registry; the cache layer is format-agnostic. If you find yourself editing either, you're doing something wrong. +4. **No changes to `packages/astro/src/translation/run.ts` or `packages/astro/src/storage/cache.ts`.** The orchestrator dispatches by extension via the registry; the cache layer is format-agnostic. If you find yourself editing either, you're doing something wrong. 5. Verify: ```sh pnpm test - pnpm exec tsc --noEmit + pnpm typecheck ``` -6. Update the contributor README's status table and any per-format docs. +6. Update the package README and any per-format docs. --- @@ -1431,11 +1518,11 @@ Then come back here for step-by-step task recipes. **When to use:** Adding a new top-level verb (`polystella `). -**Pattern:** Each subcommand owns its argv parsing and a `run(args, deps)` handler. The dispatcher in `src/cli.ts` is a thin router. +**Pattern:** Each subcommand owns its argv parsing and a `run(args, deps)` handler. The dispatcher in `packages/astro/src/cli.ts` is a thin router. **Steps:** -1. Create `src/cli/.ts`: +1. Create `packages/astro/src/cli/.ts`: ```ts export interface MySubcommandArgs { @@ -1478,15 +1565,15 @@ Then come back here for step-by-step task recipes. } ``` -2. Wire dispatch in `src/cli.ts`: +2. Wire dispatch in `packages/astro/src/cli.ts`: - Add to the `Subcommand` union type. - Add the literal to `parseSubcommand`'s `if (first === "translate" || ...)` check. - Add a case to `main()`'s switch statement. - Update `TOP_LEVEL_USAGE` to mention the new verb. 3. Add tests: - - `tests/cli/.test.ts` for the argv parser + handler (with stubbed deps). - - Extend `tests/cli.test.ts` if the top-level dispatch needs new coverage (it usually does — add at least one "dispatches `my-subcommand` to the right handler" case). + - `packages/astro/tests/cli/.test.ts` for the argv parser + handler (with stubbed deps). + - Extend `packages/astro/tests/cli.test.ts` if the top-level dispatch needs new coverage (it usually does — add at least one "dispatches `my-subcommand` to the right handler" case). 4. If consumers typically wrap the subcommand in a `pnpm` script (e.g. `pnpm i18n:sync`), document the pattern in the docs site's CLI section. Don't add the wrapper to this package — consumer projects own their own scripts. @@ -1494,9 +1581,9 @@ Then come back here for step-by-step task recipes. ```sh pnpm test - pnpm exec tsc --noEmit + pnpm typecheck pnpm build - node dist/cli.js my-subcommand --help # sanity-check the emitted CLI + node packages/astro/dist/cli.js my-subcommand --help # sanity-check the emitted CLI ``` --- @@ -1507,11 +1594,11 @@ Then come back here for step-by-step task recipes. **When to use:** Adding a third translator (e.g. OpenAI, Bedrock). -**Contract:** `Translator` in `src/translation/provider.ts`. See [#translator-contract](../../ARCHITECTURE.md#translator-contract). +**Contract:** `Translator` in `packages/core/src/translator.ts`. Provider transports live in `packages/providers`; `packages/astro/src/translation/provider.ts` only maps Astro config. See [#translator-contract](../../ARCHITECTURE.md#translator-contract). **Steps:** -1. Add a config variant to the provider zod schema in `src/config/options.ts`: +1. Add a config variant to the provider zod schema in `packages/astro/src/config/options.ts`: ```ts const newProviderSchema = z.object({ @@ -1526,55 +1613,47 @@ Then come back here for step-by-step task recipes. const providerSchema = z.discriminatedUnion("kind", [workersAISchema, anthropicSchema, newProviderSchema]); ``` -2. Implement the translator factory in `src/translation/provider.ts`: +2. Implement a concrete-model factory in `packages/providers/src/.ts`: ```ts - function createNewProviderTranslator( - provider: NewProviderConfig, - locale: string, - fetchImpl: typeof fetch, - ): Translator { - const modelId = resolveModelId(provider.model, locale); - + export function createNewProviderTranslator(options: { + apiKey: string; + modelId: string; + maxTokens: number; + fetchImpl?: typeof fetch; + }): Translator { return { - modelId, + modelId: options.modelId, async translate(systemPrompt, userPrompt, signal) { - const res = await fetchImpl(endpoint, { + const res = await (options.fetchImpl ?? fetch)(endpoint, { method: "POST", headers: { ... }, body: JSON.stringify({ ... }), ...(signal !== undefined ? { signal } : {}), }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - const message = `[polystella] new-provider request failed: ${res.status} ${res.statusText}${text ? `\n${text}` : ""}`; - if (PERMANENT_HTTP_STATUSES.has(res.status)) { - throw new PermanentProviderError(message); - } - throw new Error(message); - } - - const data = await res.json(); - // Extract the model's raw text; caller validates via parseResponse. - // Round-trip via JSON.stringify if the provider pre-parses on the server. - return text; + if (!res.ok) throw await createProviderHttpError("New provider", res, signal); + return normalizeResponse(await res.json()); }, }; } ``` -3. Wire into `createTranslator`: +3. Export the factory from `packages/providers/src/index.ts`, then map the validated config in Astro's `createTranslator`: ```ts if (provider.kind === "new-provider") { - return createNewProviderTranslator(provider, locale, fetchImpl); + return createNewProviderTranslator({ + apiKey: provider.apiKey, + modelId: resolveModelId(provider.model, locale), + maxTokens: provider.maxTokens, + }); } ``` -4. **Permanent vs retriable** — `PERMANENT_HTTP_STATUSES` is `{400, 401, 403, 404, 422}`. Don't widen this without thinking about what flaky responses might wrongly skip retry. 5xx, 408, 425, 429 are retriable. **Ask first** before adding statuses (per `AGENTS.md` Boundaries). +4. **Permanent vs retriable** — reuse the providers package's HTTP classifier. The permanent set is `{400, 401, 403, 404, 422}`; 5xx, 408, 425, and 429 are retriable. **Ask first** before adding statuses. -5. Add tests at `tests/translation/provider.test.ts` covering: +5. Add transport tests under `packages/providers/tests/` and retain Astro facade parity coverage in `packages/astro/tests/translation/provider.test.ts`: - Happy path (mock fetch returns expected shape). - Each permanent status → `PermanentProviderError`. - 5xx → plain `Error` (retriable). @@ -1582,7 +1661,7 @@ Then come back here for step-by-step task recipes. - Unexpected response shape → clear error message with raw response preview. - `signal` propagation to `fetch`. -6. Document the new provider in the package README's config section. +6. Document the new provider in the package README and docs provider section. --- @@ -1599,16 +1678,16 @@ Then come back here for step-by-step task recipes. 1. Read [#cache-key](../../ARCHITECTURE.md#cache-key). The current formula is: ``` - hash = sha256(body + selectedFrontmatterValues + glossaryHash + modelId) + hash = sha256(body + selectedFrontmatterValues + glossaryHash + modelId + optionalExtractionPolicyHash) ``` 2. **Stop.** Coordinate with the owner before merging. This is **Invariant 1** in `AGENTS.md`. The change needs to be in a major version bump and called out in CHANGELOG. 3. If you're confident this is the right change: - - Edit `src/storage/hash.ts` (the `computeSourceHash` function). + - Edit `packages/astro/src/storage/hash.ts` (the `computeSourceHash` function). - Update the formula description in `ARCHITECTURE.md#cache-key`. - Update `AGENTS.md` Invariant #1. - - Update the hash test pin in `tests/storage/hash.test.ts` — it pins a literal hash to catch accidental formula drift. Compute the new literal and replace it. + - Update the hash test pin in `packages/astro/tests/storage/hash.test.ts` — it pins a literal hash to catch accidental formula drift. Compute the new literal and replace it. - Add a CHANGELOG entry under a "Breaking changes" heading. - Bump the major version (or 0.x minor pre-1.0). @@ -1616,7 +1695,7 @@ Then come back here for step-by-step task recipes. ```sh pnpm test - pnpm exec tsc --noEmit + pnpm typecheck ``` The pinned-hash test will catch drift if you missed the test update. @@ -1631,7 +1710,7 @@ Then come back here for step-by-step task recipes. **Diagnostic flow:** -1. **Reproduce on the fixture.** If the regression is reported against a consumer's content, reduce to the smallest source file that reproduces. Add it under `tests/fixtures/` if it's worth a regression test. +1. **Reproduce on the fixture.** If the regression is reported against a consumer's content, reduce to the smallest source file that reproduces. Add it under `packages/astro/tests/fixtures/` if it's worth a regression test. 2. **Inspect what the cache layer planned:** @@ -1657,7 +1736,7 @@ Then come back here for step-by-step task recipes. cat dist/i18n-r2-report.json | jq '.entries[] | select(.sourcePath == "")' ``` - Outcome will be `hit`, `miss`, `override`, `error`, or `localSkipped`. Read the corresponding code path in `src/storage/cache.ts` or `src/source/overrides.ts`. + Outcome will be `hit`, `miss`, `override`, `error`, or `localSkipped`. Read the corresponding code path in `packages/astro/src/storage/cache.ts` or `packages/astro/src/source/overrides.ts`. 5. **Crank up verbosity:** @@ -1694,13 +1773,13 @@ Then come back here for step-by-step task recipes. **Files:** -- `src/runtime/middleware.ts` — request middleware; pre-binds locale to all four locals. -- `src/runtime/middleware-core.ts` — middleware body (test-friendly extract). -- `src/runtime/get-localized-entry.ts`, `get-localized-collection.ts` — fetcher implementations. -- `src/runtime/localized-href.ts` — URL prefixer. -- `src/runtime/custom-loader-runtime.ts` — the **bridge** (module-scoped singleton shared with sibling collections). -- `src/runtime/locals.ts` — TypeScript ambient declarations for `Astro.locals`. Was `locals.d.ts` until the dist-emit rework; renamed so tsc emits both an empty `.js` and the `.d.ts` declarations, and `runtime/index.ts` pulls it in via a side-effect import (the previous triple-slash `` directive gets stripped by tsc at emit time). -- `src/react/index.ts` — `useTranslations`, `useLocalizedHref` hooks. +- `packages/astro/src/runtime/middleware.ts` — request middleware; pre-binds locale to all four locals. +- `packages/astro/src/runtime/middleware-core.ts` — middleware body (test-friendly extract). +- `packages/astro/src/runtime/get-localized-entry.ts`, `get-localized-collection.ts` — fetcher implementations. +- `packages/astro/src/runtime/localized-href.ts` — URL prefixer. +- `packages/astro/src/runtime/custom-loader-runtime.ts` — the **bridge** (symbol-keyed `globalThis` state shared with sibling collections across Vite module reloads). +- `packages/astro/src/runtime/locals.ts` — TypeScript ambient declarations for `Astro.locals`. Was `locals.d.ts` until the dist-emit rework; renamed so tsc emits both an empty `.js` and the `.d.ts` declarations, and `runtime/index.ts` pulls it in via a side-effect import (the previous triple-slash `` directive gets stripped by tsc at emit time). +- `packages/astro/src/react/index.ts` — `useTranslations`, `useLocalizedHref` hooks. **Key contracts:** @@ -1710,11 +1789,11 @@ Then come back here for step-by-step task recipes. **Steps:** 1. Edit the relevant runtime file. -2. Update `src/runtime/locals.ts` if you're changing the shape of `Astro.locals`. +2. Update `packages/astro/src/runtime/locals.ts` if you're changing the shape of `Astro.locals`. 3. Update the `polystella-consumer` skill's "Runtime APIs" section. -4. Add tests under `tests/runtime/`: +4. Add tests under `packages/astro/tests/runtime/`: - Behaviour test for the new/changed function. - - Middleware-binding test if the locals shape changes (`tests/runtime/middleware.test.ts`). + - Middleware-binding test if the locals shape changes (`packages/astro/tests/runtime/middleware.test.ts`). 5. Don't forget the React side — `useTranslations` / `useLocalizedHref` and their consumer-side wiring (`getDictionary`). --- @@ -1727,19 +1806,19 @@ Then come back here for step-by-step task recipes. **Files:** -- `src/i18n/drift.ts` — `checkI18nDrift`, `loadAndCheckDrift`. -- `src/i18n/sync.ts` — key reconciliation; **layout-aware** JSON writer (`formatLocaleFile`). -- `src/i18n/ui-translate.ts` — AI-fill orchestrator; parallel-locale execution; `{{token}}` validator + retry wrapper. -- `src/i18n/loader.ts`, `i18n/index.ts` — content-layer loader, dictionary fetcher. -- `src/catalog/*` — catalog-only public exports, middleware, and Astro integration. Must stay free of content translation, R2, route shims, and localized collection imports. -- `src/cli/check-ui.ts`, `sync-ui.ts`, `translate-ui.ts` — CLI handlers. +- `packages/astro/src/i18n/drift.ts` — `checkI18nDrift`, `loadAndCheckDrift`. +- `packages/astro/src/i18n/sync.ts` — key reconciliation; **layout-aware** JSON writer (`formatLocaleFile`). +- `packages/astro/src/i18n/ui-translate.ts` — AI-fill orchestrator; `{{token}}` validator + retry wrapper. +- `packages/astro/src/i18n/loader.ts`, `i18n/index.ts` — content-layer loader, dictionary fetcher. +- `packages/astro/src/catalog/*` — catalog-only public exports, middleware, and Astro integration. Must stay free of content translation, R2, route shims, and localized collection imports. +- `packages/astro/src/cli/check-ui.ts`, `sync-ui.ts`, `translate-ui.ts` — CLI handlers. **Key contracts:** - **Three drift failure modes** — missing keys, extra keys, **empty-placeholder values** (a non-default locale has `""` where the source has a non-empty string). The build's `astro:config:setup` drift check and the `check-ui` CLI use the SAME predicate. If you add a fourth failure mode, update both. - **Layout-aware sync writer** — parses the source file's text (not just its JSON) to recover key order and blank-line section breaks. The output mirrors that layout for every locale. Don't drop this — every sync would churn diffs. - **`{{token}}` validator runs OUTSIDE `translateBatch`** — the orchestrator's retry wrapper sets `maxRetries: 0` on `translateBatch`. Don't add a second retry layer. -- **Parallel locales catch errors internally** — `translate-ui` runs locales in parallel via `runWithConcurrency`. Workers MUST catch every error and record it on the per-locale outcome — never re-throw. Re-throwing kills the whole run. +- **Queued locales catch errors internally** — `translate-ui` pre-scans locale JSONs, skips complete catalogs before provider setup, then runs queued locales in parallel via `runWithConcurrency` with a hard cap of 3. Each locale is split into small sequential request batches. Workers MUST catch every error and record it on the per-locale outcome — never re-throw. Re-throwing kills the whole run. - **Catalog-only middleware scope** — `polystella/catalog/middleware` binds `Astro.locals.t` and `Astro.locals.lhref` only. Do not add localized collection APIs to that surface. See [#ui-strings](../../ARCHITECTURE.md#ui-strings). @@ -1827,20 +1906,20 @@ if (typeof data !== "object" || data === null) throw new Error(`unexpected: ${x} -- Tests live under `tests//.test.ts`. Top-level exceptions: `tests/cli.test.ts` (top-level dispatch + translate-subcommand parsing), `tests/cli/` (per-subcommand handlers), `tests/smoke.test.ts` (end-to-end integration smoke). -- Vitest config in `vitest.config.ts`. `singleThread: true` — faster than multi-worker at this scale. +- Astro tests live under `packages/astro/tests//.test.ts`. Top-level exceptions: `packages/astro/tests/cli.test.ts` (top-level dispatch + translate-subcommand parsing), `packages/astro/tests/cli/` (per-subcommand handlers), `packages/astro/tests/smoke.test.ts` (end-to-end integration smoke). +- Astro Vitest config is `packages/astro/vitest.config.ts`. `singleThread: true` — faster than multi-worker at this scale. - Fakeable boundaries: each subsystem accepts a `deps`-shaped object so tests can inject stubs. The CLI's `runCheckUi(args, deps)` shape is the canonical example. - For tests that need a clean adapter registry: call `resetRegistry()` before re-registering. -- For tests that exercise R2: use the in-memory R2 client at `tests/helpers/in-memory-r2.ts` (or whatever the equivalent helper is). +- For tests that exercise R2: follow the inline in-memory client in `packages/astro/tests/storage/cache.test.ts`. - For tests that exercise the translator: pass `translatorOverrides` to `runTranslationPass` with a fake `Translator`. -- For smoke tests: drive `polystella(options)` with stubbed Astro context against a real temp project. `tests/smoke.test.ts` is the template. -- For the doc-claims test (`tests/docs.test.ts`): pins file paths and command names referenced in `AGENTS.md` / `ARCHITECTURE.md`. If you move a file or rename a subcommand, update both the docs AND this test. +- For smoke tests: drive `polystella(options)` with stubbed Astro context against a real temp project. `packages/astro/tests/smoke.test.ts` is the template. +- For the doc-claims test (`packages/astro/tests/docs.test.ts`): pins file paths and command names referenced in `AGENTS.md` / `ARCHITECTURE.md`. If you move a file or rename a subcommand, update both the docs AND this test. Verify before pushing: ```sh pnpm test -pnpm exec tsc --noEmit +pnpm typecheck ``` diff --git a/llms.txt b/llms.txt index c07fc9b..d2bd23b 100644 --- a/llms.txt +++ b/llms.txt @@ -2,7 +2,9 @@ > AI-driven content localization for Astro: build-time translation of Markdown/MDX/TOML/JSON/YAML into additional locales, cached in Cloudflare R2, with locale-prefixed routing. -PolyStella is an Astro integration. Translations happen at build time, are content-addressed (source bytes + glossary + model), cached in R2, and rendered as static bytes — no runtime AI calls. Two surfaces ship together: the integration (auto-runs in `astro build`) and a `polystella` CLI with verb-style subcommands (`translate`, `check-ui`, `sync-ui`, `translate-ui`). +PolyStella is an Astro integration plus three reusable packages. Translations happen at build time, are content-addressed (source bytes + glossary + model), cached in R2, and rendered as static bytes — no runtime AI calls. The Astro package includes the integration and a `polystella` CLI with verb-style subcommands (`translate`, `check-ui`, `sync-ui`, `translate-ui`). + +Direct flow: `source/record -> adapter -> core -> provider -> core -> adapter -> output`. Core, adapters, and providers use standard Web APIs and work without `nodejs_compat`; consumers may enable it. Low-level APIs live in their owning packages and have no Astro compatibility shims. The package is dual-audience: @@ -22,17 +24,12 @@ The package is dual-audience: ## Source layout (top-level) -- `src/index.ts`: Astro integration entry. Registers hooks, runs the translation pass, publishes the runtime bridge. -- `src/cli.ts`: standalone `polystella` CLI dispatcher. -- `src/cli/`: per-subcommand handlers. -- `src/config/options.ts`: zod schema + `resolveOptions`. Locales derive from Astro's `i18n` config. -- `src/parsing/`: adapter contract, per-format adapters, key-path utilities, link rewriter. -- `src/translation/`: orchestrator, provider implementations (Workers AI, Anthropic), prompt protocol, batching. -- `src/storage/`: R2 client, cache orchestrator, hash, prune, local staging index, build report. -- `src/runtime/`: middleware, `Astro.locals` bindings, localized-href, custom-loader runtime bridge. -- `src/content/`: `polystellaCollections`, custom-loader wrapper, file-loader wrapper, schema extension. -- `src/i18n/`: UI strings loader, drift detection, sync, AI translation orchestrator, sitemap helper. -- `src/react/`: React hooks for islands. +- `packages/astro/src/index.ts`: Astro integration entry; sibling directories own the CLI, storage, routing, runtime, content, UI strings, and React helpers. +- `packages/core/src/index.ts`: platform-neutral prompt, batching, retry, and translation contracts. +- `packages/adapters/src/index.ts`: portable Markdown, MDX, JSON, YAML, and TOML parsing and reconstruction. +- `packages/providers/src/index.ts`: Workers AI HTTP/binding and Anthropic transports. +- `packages/astro/tests/docs.test.ts`: one of the Astro integration, policy, CLI, storage, routing, and runtime test suites. +- `packages/core/tests/batch.test.ts`: one of the focused reusable-package test suites. ## Optional diff --git a/package.json b/package.json index c4aa1ba..a8c2f13 100644 --- a/package.json +++ b/package.json @@ -1,153 +1,45 @@ { - "name": "@cloudflare/polystella", - "version": "0.4.0", + "name": "polystella-workspace", + "private": true, "type": "module", - "description": "AI-driven content localization for Astro (build-time markdown translation, R2-cached).", - "license": "MIT", "packageManager": "pnpm@11.5.2", - "homepage": "https://github.com/cloudflare/polystella", - "repository": { - "type": "git", - "url": "git+https://github.com/cloudflare/polystella.git" - }, - "bugs": { - "url": "https://github.com/cloudflare/polystella/issues" - }, - "keywords": [ - "astro", - "astro-integration", - "astro-component", - "i18n", - "localization", - "translation", - "ai", - "cloudflare", - "workers-ai", - "r2" - ], - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "bin": { - "polystella": "dist/cli.js" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./runtime": { - "types": "./dist/runtime/index.d.ts", - "default": "./dist/runtime/index.js" - }, - "./runtime/middleware": { - "types": "./dist/runtime/middleware.d.ts", - "default": "./dist/runtime/middleware.js" - }, - "./content": { - "types": "./dist/content/index.d.ts", - "default": "./dist/content/index.js" - }, - "./i18n": { - "types": "./dist/i18n/index.d.ts", - "default": "./dist/i18n/index.js" - }, - "./catalog": { - "types": "./dist/catalog/index.d.ts", - "default": "./dist/catalog/index.js" - }, - "./catalog/middleware": { - "types": "./dist/catalog/middleware.d.ts", - "default": "./dist/catalog/middleware.js" - }, - "./catalog/astro": { - "types": "./dist/catalog/astro.d.ts", - "default": "./dist/catalog/astro.js" - }, - "./react": { - "types": "./dist/react/index.d.ts", - "default": "./dist/react/index.js" - }, - "./recipes": { - "types": "./dist/recipes/index.d.ts", - "default": "./dist/recipes/index.js" - }, - "./recipes/starlight": { - "types": "./dist/recipes/starlight.d.ts", - "default": "./dist/recipes/starlight.js" - }, - "./client": { - "types": "./client.d.ts" - } - }, - "files": [ - "src", - "dist", - "client.d.ts", - "AGENTS.md", - "ARCHITECTURE.md", - "CHANGELOG.md", - "README.md", - "llms.txt", - "llms-full.txt", - "skills", - "tsconfig.json", - "tsconfig.build.json", - "types-internal" - ], "scripts": { - "test": "vitest run", - "test:watch": "vitest", - "build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js", + "build:internal": "pnpm --filter @cloudflare/polystella-core build && pnpm --filter @cloudflare/polystella-adapters build && pnpm --filter @cloudflare/polystella-providers build", + "test": "pnpm test:packages && pnpm test:node && pnpm test:workerd && pnpm test:boundaries", + "test:packages": "pnpm build:internal && pnpm --filter @cloudflare/polystella-core test && pnpm --filter @cloudflare/polystella-adapters test && pnpm --filter @cloudflare/polystella-providers test", + "test:node": "pnpm build:internal && pnpm --filter @cloudflare/polystella test", + "test:workerd": "pnpm build:internal && tsc -p tsconfig.workerd.json && vitest run --config vitest.workerd.config.ts && node scripts/check-workerd-portability.mjs", + "test:boundaries": "vitest run --config vitest.boundaries.config.ts", + "bundle:workerd:dry-run": "pnpm build:internal && node scripts/check-workerd-portability.mjs", + "typecheck": "pnpm build && pnpm typecheck:packages", + "typecheck:packages": "pnpm --filter @cloudflare/polystella-core typecheck && pnpm --filter @cloudflare/polystella-adapters typecheck && pnpm --filter @cloudflare/polystella-providers typecheck && pnpm --filter @cloudflare/polystella typecheck", + "audit:prod": "pnpm audit --prod", + "check:packages": "node scripts/check-packages.mjs", + "check:baseline": "node scripts/check-monorepo-baseline.mjs", + "test:watch": "pnpm build:internal && pnpm --filter @cloudflare/polystella test:watch", + "build": "pnpm build:internal && pnpm --filter @cloudflare/polystella build", "build:llms": "node scripts/build-llms-full.mjs", - "playground:mdx-jsx:build": "pnpm --filter polystella-playground-mdx-jsx build", - "playground:mdx-jsx:audit": "pnpm --filter polystella-playground-mdx-jsx audit-mdx", - "playground:mdx-jsx:translate:dry-run": "pnpm --filter polystella-playground-mdx-jsx translate:dry-run", - "playground:mdx-jsx:translate:local": "pnpm --filter polystella-playground-mdx-jsx translate:local", - "playground:mdx-jsx:translate:workers-ai": "pnpm --filter polystella-playground-mdx-jsx translate:workers-ai", + "playground:mdx-jsx:build": "pnpm build && pnpm --filter polystella-playground-mdx-jsx build", + "playground:mdx-jsx:audit": "pnpm build && pnpm --filter polystella-playground-mdx-jsx audit-mdx", + "playground:mdx-jsx:translate:dry-run": "pnpm build && pnpm --filter polystella-playground-mdx-jsx translate:dry-run", + "playground:mdx-jsx:translate:local": "pnpm build && pnpm --filter polystella-playground-mdx-jsx translate:local", + "playground:mdx-jsx:translate:workers-ai": "pnpm build && pnpm --filter polystella-playground-mdx-jsx translate:workers-ai", "format": "prettier --write .", "format:check": "prettier --check .", - "prepare": "pnpm build && (git config core.hooksPath .githooks 2>/dev/null || true)", - "prepublishOnly": "pnpm build && pnpm build:llms", + "prepare": "node scripts/configure-git-hooks.mjs", "release:version": "changeset version && pnpm install --no-frozen-lockfile", "release:publish": "changeset publish" }, - "dependencies": { - "acorn": "^8.17.0", - "p-retry": "^8.0.0", - "picomatch": "^4.0.4", - "remark-frontmatter": "^5.0.0", - "remark-gfm": "^4.0.1", - "remark-mdx": "^3.1.1", - "remark-parse": "^11.0.0", - "s3mini": "^0.9.4", - "satteri": "^0.9.4", - "smol-toml": "^1.6.1", - "unified": "^11.0.5", - "yaml": "^2.8.3" - }, "devDependencies": { - "@astrojs/sitemap": "^3.7.3", "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.27.0", - "@types/mdast": "^4.0.4", - "@types/picomatch": "^4.0.0", - "@types/react": "^19.2.14", + "@cloudflare/vitest-plugin": "^1.1.1", + "@cloudflare/polystella-adapters": "workspace:*", + "@cloudflare/polystella-core": "workspace:*", + "@cloudflare/polystella-providers": "workspace:*", "pkg-pr-new": "^0.0.75", "prettier": "3.9.4", "typescript": "^6.0.3", "vitest": "^4.1.9" - }, - "peerDependencies": { - "astro": "^7.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } } } diff --git a/packages/adapters/CHANGELOG.md b/packages/adapters/CHANGELOG.md new file mode 100644 index 0000000..484be7e --- /dev/null +++ b/packages/adapters/CHANGELOG.md @@ -0,0 +1 @@ +# @cloudflare/polystella-adapters diff --git a/packages/adapters/LICENSE b/packages/adapters/LICENSE new file mode 100644 index 0000000..dcdadd6 --- /dev/null +++ b/packages/adapters/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Cloudflare, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/adapters/README.md b/packages/adapters/README.md new file mode 100644 index 0000000..14c382c --- /dev/null +++ b/packages/adapters/README.md @@ -0,0 +1,5 @@ +# @cloudflare/polystella-adapters + +Portable Markdown, MDX, JSON, YAML, and TOML translation adapters for PolyStella. + +See the [PolyStella repository](https://github.com/cloudflare/polystella#readme) for documentation. diff --git a/packages/adapters/package.json b/packages/adapters/package.json index b1f53e5..6e40972 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -34,14 +34,19 @@ } }, "files": [ - "dist" + "dist", + "src", + "CHANGELOG.md", + "README.md", + "LICENSE" ], "scripts": { "test": "vitest run", + "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.build.json" }, "dependencies": { - "@cloudflare/polystella-core": "workspace:0.4.0", + "@cloudflare/polystella-core": "workspace:*", "@types/mdast": "^4.0.4", "picomatch": "^4.0.4", "remark-frontmatter": "^5.0.0", diff --git a/CHANGELOG.md b/packages/astro/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to packages/astro/CHANGELOG.md diff --git a/packages/astro/LICENSE b/packages/astro/LICENSE new file mode 100644 index 0000000..dcdadd6 --- /dev/null +++ b/packages/astro/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Cloudflare, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/astro/README.md b/packages/astro/README.md new file mode 100644 index 0000000..516a682 --- /dev/null +++ b/packages/astro/README.md @@ -0,0 +1,5 @@ +# @cloudflare/polystella + +Astro integration for build-time AI translation, R2 caching, and localized routes. + +See the [repository README](https://github.com/cloudflare/polystella#readme) for installation and usage. diff --git a/client.d.ts b/packages/astro/client.d.ts similarity index 100% rename from client.d.ts rename to packages/astro/client.d.ts diff --git a/packages/astro/package.json b/packages/astro/package.json new file mode 100644 index 0000000..55b1fce --- /dev/null +++ b/packages/astro/package.json @@ -0,0 +1,133 @@ +{ + "name": "@cloudflare/polystella", + "version": "0.4.0", + "type": "module", + "description": "AI-driven content localization for Astro (build-time markdown translation, R2-cached).", + "license": "MIT", + "homepage": "https://github.com/cloudflare/polystella", + "repository": { + "type": "git", + "url": "git+https://github.com/cloudflare/polystella.git", + "directory": "packages/astro" + }, + "bugs": { + "url": "https://github.com/cloudflare/polystella/issues" + }, + "keywords": [ + "astro", + "astro-integration", + "astro-component", + "i18n", + "localization", + "translation", + "ai", + "cloudflare", + "workers-ai", + "r2" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "polystella": "dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./runtime": { + "types": "./dist/runtime/index.d.ts", + "default": "./dist/runtime/index.js" + }, + "./runtime/middleware": { + "types": "./dist/runtime/middleware.d.ts", + "default": "./dist/runtime/middleware.js" + }, + "./content": { + "types": "./dist/content/index.d.ts", + "default": "./dist/content/index.js" + }, + "./i18n": { + "types": "./dist/i18n/index.d.ts", + "default": "./dist/i18n/index.js" + }, + "./catalog": { + "types": "./dist/catalog/index.d.ts", + "default": "./dist/catalog/index.js" + }, + "./catalog/middleware": { + "types": "./dist/catalog/middleware.d.ts", + "default": "./dist/catalog/middleware.js" + }, + "./catalog/astro": { + "types": "./dist/catalog/astro.d.ts", + "default": "./dist/catalog/astro.js" + }, + "./react": { + "types": "./dist/react/index.d.ts", + "default": "./dist/react/index.js" + }, + "./recipes": { + "types": "./dist/recipes/index.d.ts", + "default": "./dist/recipes/index.js" + }, + "./recipes/starlight": { + "types": "./dist/recipes/starlight.d.ts", + "default": "./dist/recipes/starlight.js" + }, + "./client": { + "types": "./client.d.ts" + } + }, + "files": [ + "src", + "dist", + "client.d.ts", + "CHANGELOG.md", + "README.md", + "LICENSE", + "tsconfig.json", + "tsconfig.build.json", + "types-internal" + ], + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "build": "tsc -p tsconfig.build.json && node -e \"require('node:fs').chmodSync('dist/cli.js', 0o755)\"" + }, + "dependencies": { + "@cloudflare/polystella-adapters": "workspace:*", + "@cloudflare/polystella-core": "workspace:*", + "@cloudflare/polystella-providers": "workspace:*", + "acorn": "^8.17.0", + "p-retry": "^8.0.0", + "picomatch": "^4.0.4", + "s3mini": "^0.9.4", + "satteri": "^0.9.4", + "yaml": "^2.8.3" + }, + "devDependencies": { + "@astrojs/sitemap": "^3.7.3", + "@types/mdast": "^4.0.4", + "@types/picomatch": "^4.0.0", + "@types/react": "^19.2.14", + "astro": "^7.2.9", + "react": "^19.2.6", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + }, + "peerDependencies": { + "astro": "^7.0.10", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } +} diff --git a/src/catalog/astro.ts b/packages/astro/src/catalog/astro.ts similarity index 100% rename from src/catalog/astro.ts rename to packages/astro/src/catalog/astro.ts diff --git a/src/catalog/constants.ts b/packages/astro/src/catalog/constants.ts similarity index 100% rename from src/catalog/constants.ts rename to packages/astro/src/catalog/constants.ts diff --git a/src/catalog/index.ts b/packages/astro/src/catalog/index.ts similarity index 100% rename from src/catalog/index.ts rename to packages/astro/src/catalog/index.ts diff --git a/src/catalog/locals.ts b/packages/astro/src/catalog/locals.ts similarity index 100% rename from src/catalog/locals.ts rename to packages/astro/src/catalog/locals.ts diff --git a/src/catalog/middleware.ts b/packages/astro/src/catalog/middleware.ts similarity index 100% rename from src/catalog/middleware.ts rename to packages/astro/src/catalog/middleware.ts diff --git a/src/catalog/runtime.ts b/packages/astro/src/catalog/runtime.ts similarity index 100% rename from src/catalog/runtime.ts rename to packages/astro/src/catalog/runtime.ts diff --git a/src/cli.ts b/packages/astro/src/cli.ts similarity index 100% rename from src/cli.ts rename to packages/astro/src/cli.ts diff --git a/src/cli/audit-mdx.ts b/packages/astro/src/cli/audit-mdx.ts similarity index 100% rename from src/cli/audit-mdx.ts rename to packages/astro/src/cli/audit-mdx.ts diff --git a/src/cli/check-ui.ts b/packages/astro/src/cli/check-ui.ts similarity index 100% rename from src/cli/check-ui.ts rename to packages/astro/src/cli/check-ui.ts diff --git a/src/cli/i18n-config.ts b/packages/astro/src/cli/i18n-config.ts similarity index 100% rename from src/cli/i18n-config.ts rename to packages/astro/src/cli/i18n-config.ts diff --git a/src/cli/sync-ui.ts b/packages/astro/src/cli/sync-ui.ts similarity index 100% rename from src/cli/sync-ui.ts rename to packages/astro/src/cli/sync-ui.ts diff --git a/src/cli/translate-ui.ts b/packages/astro/src/cli/translate-ui.ts similarity index 99% rename from src/cli/translate-ui.ts rename to packages/astro/src/cli/translate-ui.ts index 6f231be..32b2507 100644 --- a/src/cli/translate-ui.ts +++ b/packages/astro/src/cli/translate-ui.ts @@ -18,7 +18,8 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { resolveOptions, type PolyStellaResolvedOptions } from "../config/options.js"; -import { EMPTY_GLOSSARY, loadGlossaries } from "../glossary/glossary.js"; +import { EMPTY_GLOSSARY } from "@cloudflare/polystella-core"; +import { loadGlossaries } from "../glossary/glossary.js"; import { applySyncToDisk, formatLocaleFile, formatSyncSummary, parseSourceLayout, syncLocaleDict } from "../i18n/sync.js"; import { DEFAULT_UI_STRING_BATCH_SIZE, diff --git a/src/config/options.ts b/packages/astro/src/config/options.ts similarity index 100% rename from src/config/options.ts rename to packages/astro/src/config/options.ts diff --git a/src/content/build.ts b/packages/astro/src/content/build.ts similarity index 100% rename from src/content/build.ts rename to packages/astro/src/content/build.ts diff --git a/src/content/custom-loader.ts b/packages/astro/src/content/custom-loader.ts similarity index 100% rename from src/content/custom-loader.ts rename to packages/astro/src/content/custom-loader.ts diff --git a/src/content/extend-schema.ts b/packages/astro/src/content/extend-schema.ts similarity index 100% rename from src/content/extend-schema.ts rename to packages/astro/src/content/extend-schema.ts diff --git a/src/content/file-loader.ts b/packages/astro/src/content/file-loader.ts similarity index 100% rename from src/content/file-loader.ts rename to packages/astro/src/content/file-loader.ts diff --git a/src/content/index.ts b/packages/astro/src/content/index.ts similarity index 100% rename from src/content/index.ts rename to packages/astro/src/content/index.ts diff --git a/src/glossary/glossary.ts b/packages/astro/src/glossary/glossary.ts similarity index 81% rename from src/glossary/glossary.ts rename to packages/astro/src/glossary/glossary.ts index 8d00974..c093c23 100644 --- a/src/glossary/glossary.ts +++ b/packages/astro/src/glossary/glossary.ts @@ -2,51 +2,11 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { EMPTY_GLOSSARY, type Glossary, type StyleRule } from "@cloudflare/polystella-core"; import { z } from "astro/zod"; import { parse as parseYaml } from "yaml"; import type { PolyStellaResolvedOptions } from "../config/options.js"; -/** - * A single categorised style rule. `category` is a short tag that - * groups related rules in the rendered prompt (e.g. `numbers`, - * `dates`, `tone`). `instruction` is the imperative the model must - * follow. `example` is an optional source-→-target illustration — - * leave it off when the instruction stands alone. - */ -export interface StyleRule { - category: string; - instruction: string; - example?: string; -} - -/** - * Validated, normalised glossary for one locale. - * - * Normalisation: `doNotTranslate` deduped + sorted, preferred- - * translation keys sorted on serialisation, `styleRules` preserved - * in author order (curator intent matters; reordering the YAML is - * a meaningful edit and re-hashes), optional fields default to - * `""` / `[]` / `{}`. Two glossaries with the same semantic content - * hash identically regardless of YAML formatting, key order, or - * comments — but reordering style rules DOES change the hash. - */ -export interface Glossary { - version: string; - doNotTranslate: string[]; - preferredTranslations: Record; - styleRules: StyleRule[]; - notes: string; -} - -/** Sentinel "no glossary" value; hashes to `EMPTY_GLOSSARY_HASH`. */ -export const EMPTY_GLOSSARY: Glossary = { - version: "", - doNotTranslate: [], - preferredTranslations: {}, - styleRules: [], - notes: "", -}; - const styleRuleSchema = z .object({ category: z.string().min(1), diff --git a/src/i18n/drift.ts b/packages/astro/src/i18n/drift.ts similarity index 100% rename from src/i18n/drift.ts rename to packages/astro/src/i18n/drift.ts diff --git a/src/i18n/index.ts b/packages/astro/src/i18n/index.ts similarity index 100% rename from src/i18n/index.ts rename to packages/astro/src/i18n/index.ts diff --git a/src/i18n/loader.ts b/packages/astro/src/i18n/loader.ts similarity index 100% rename from src/i18n/loader.ts rename to packages/astro/src/i18n/loader.ts diff --git a/src/i18n/sitemap.ts b/packages/astro/src/i18n/sitemap.ts similarity index 100% rename from src/i18n/sitemap.ts rename to packages/astro/src/i18n/sitemap.ts diff --git a/src/i18n/sync.ts b/packages/astro/src/i18n/sync.ts similarity index 100% rename from src/i18n/sync.ts rename to packages/astro/src/i18n/sync.ts diff --git a/src/i18n/translate.ts b/packages/astro/src/i18n/translate.ts similarity index 100% rename from src/i18n/translate.ts rename to packages/astro/src/i18n/translate.ts diff --git a/src/i18n/ui-translate.ts b/packages/astro/src/i18n/ui-translate.ts similarity index 97% rename from src/i18n/ui-translate.ts rename to packages/astro/src/i18n/ui-translate.ts index 307ea47..c171b8d 100644 --- a/src/i18n/ui-translate.ts +++ b/packages/astro/src/i18n/ui-translate.ts @@ -19,11 +19,14 @@ * not as a prompt-only instruction. */ -import type { Glossary } from "../glossary/glossary.js"; -import type { Segment } from "../parsing/extract.js"; -import { packGroupsIntoBatches } from "../translation/batch.js"; -import type { Translator } from "../translation/provider.js"; -import { translateBatch, type TranslateBatchRetryEvent } from "../translation/provider.js"; +import { + packGroupsIntoBatches, + translateBatch, + type Glossary, + type Segment, + type TranslateBatchRetryEvent, + type Translator, +} from "@cloudflare/polystella-core"; /** * `{{token}}` extractor. The runtime grammar in `translate.ts` uses diff --git a/src/index.ts b/packages/astro/src/index.ts similarity index 93% rename from src/index.ts rename to packages/astro/src/index.ts index 9653fad..434f48f 100644 --- a/src/index.ts +++ b/packages/astro/src/index.ts @@ -2,9 +2,10 @@ import type { AstroIntegration } from "astro"; import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import type { Glossary, Translator } from "@cloudflare/polystella-core"; import { resolveOptions, type PolyStellaOptions, type PolyStellaResolvedOptions } from "./config/options.js"; -import { EMPTY_GLOSSARY_HASH, hashGlossary, loadGlossaries, type Glossary } from "./glossary/glossary.js"; +import { EMPTY_GLOSSARY_HASH, hashGlossary, loadGlossaries } from "./glossary/glossary.js"; import { formatDriftIssues, loadAndCheckDrift } from "./i18n/drift.js"; import { setRuntimeBridge, type CustomLoaderTranslateRecord, type PolystellaRuntimeBridge } from "./runtime/custom-loader-runtime.js"; import { computeBuildReportTotals, emitBuildReport, type BuildReport } from "./storage/report.js"; @@ -13,7 +14,7 @@ import { createR2Client, type R2Client } from "./storage/r2.js"; import { expandRoutes } from "./routing/expand-routes.js"; import { deriveUrlPattern, generateShimSource } from "./routing/shim.js"; import { walkPages } from "./routing/walk-pages.js"; -import { createTranslator, type Translator } from "./translation/provider.js"; +import { createTranslator } from "./translation/provider.js"; import { runTranslationPass, type RunTranslationResult } from "./translation/run.js"; export { POLYSTELLA_VERSION } from "./version.js"; @@ -22,37 +23,8 @@ import { POLYSTELLA_VERSION } from "./version.js"; export type { PolyStellaOptions, PolyStellaResolvedOptions }; export { computeSourceHash, type HashInput } from "./storage/hash.js"; export { walkSources, type SourceFile, type WalkOptions } from "./source/walk.js"; -export { - EMPTY_GLOSSARY, - EMPTY_GLOSSARY_HASH, - hashGlossary, - loadGlossaries, - type Glossary, - type LoadGlossariesOptions, - type StyleRule, -} from "./glossary/glossary.js"; -export { applyTranslations } from "./parsing/apply.js"; +export { EMPTY_GLOSSARY_HASH, hashGlossary, loadGlossaries, type LoadGlossariesOptions } from "./glossary/glossary.js"; export { rewriteInternalLinks, rewriteUrlIfInternal, type RewriteInternalLinksOptions } from "./parsing/rewrite-links.js"; -export { - extractSegments, - peekNoTranslate, - resolveFrontmatterKeys, - selectTranslatableFrontmatter, - type Segment, - type ExtractOptions, -} from "./parsing/extract.js"; -export { parseMarkdown, createMarkdownProcessor } from "./parsing/parse.js"; -export { buildPrompt, parseResponse, type BuildPromptInput, type BuiltPrompt } from "./translation/prompt.js"; -export { - createTranslator, - PermanentProviderError, - resolveModelId, - translateBatch, - type CreateTranslatorOptions, - type TranslateBatchOptions, - type TranslateBatchRetryEvent, - type Translator, -} from "./translation/provider.js"; export { buildR2Key, createR2Client, @@ -94,13 +66,7 @@ export { type EmitBuildReportOptions, } from "./storage/report.js"; export { deriveUrlPattern, generateShimSource, type DerivedUrlPattern, type GenerateShimSourceInput } from "./routing/shim.js"; -export { - runTranslationPass, - type Logger, - type RunTranslationOptions, - type RunTranslationResult, - type RunTranslationCounts, -} from "./translation/run.js"; +export { runTranslationPass, type RunTranslationOptions, type RunTranslationResult, type RunTranslationCounts } from "./translation/run.js"; export { loadAndCheckDrift, formatDriftIssues } from "./i18n/drift.js"; export { astroSitemapI18n, type AstroSitemapI18nInput, type AstroSitemapI18nOptions, type AstroSitemapI18nOutput } from "./i18n/sitemap.js"; diff --git a/src/parsing/adapter.ts b/packages/astro/src/parsing/adapter.ts similarity index 81% rename from src/parsing/adapter.ts rename to packages/astro/src/parsing/adapter.ts index 54c1333..f7a3fab 100644 --- a/src/parsing/adapter.ts +++ b/packages/astro/src/parsing/adapter.ts @@ -1,5 +1,14 @@ -import type { Segment } from "./extract.js"; -import type { NormalizedMdxRules } from "./mdx-rules.js"; +import { + expandPath, + parsePath, + readAtPath, + resolveConcretePaths, + type AdapterApplyOptions, + type AdapterExtractOptions, + type FileAdapter, + type PathSegment, +} from "@cloudflare/polystella-adapters"; +import type { Segment } from "@cloudflare/polystella-core"; import type { MarkdownParser } from "./parse.js"; /** @@ -150,52 +159,11 @@ export interface FileTypeAdapter { documentContext?(parsed: TParsed, opts: AdapterDocumentContextOptions): string | undefined; } -/** - * Per-pair options threaded through `extract` / `selectedValuesForHash`. - * - * `translatableKeys` is the resolved per-glob → key-paths map for - * the adapter being invoked. The runtime picks the right map per - * adapter (markdown reads `frontmatter`; TOML reads `tomlKeys`; etc.) - * before calling the adapter — adapters never see the user-facing - * option name. - */ -export interface AdapterExtractOptions { - /** Forward-slash path relative to `sourceDir`. */ - sourcePath: string; - /** - * Per-glob → translatable key paths. Globs match the source path; - * key paths use the adapter's ID grammar. - */ - translatableKeys: Record; - /** Normalized MDX rules for `.mdx` sources. Omitted for non-MDX formats. */ - mdxRules?: NormalizedMdxRules | undefined; -} - export interface AdapterParseOptions { /** Markdown/MDX parser implementation. Ignored by non-markdown adapters. */ markdownParser?: MarkdownParser | undefined; } -/** - * Per-pair options threaded through `applyTranslations`. - */ -export interface AdapterApplyOptions { - /** Forward-slash path relative to `sourceDir`, when known. */ - sourcePath?: string | undefined; - /** Normalized MDX rules for `.mdx` sources. Omitted for non-MDX formats. */ - mdxRules?: NormalizedMdxRules | undefined; - /** - * Top-level key/value pairs merged into the output. Used by the - * AI-translation marker injection. Keys here override same-named - * keys already in the source (the marker reflects this build's - * output, not stale source state). - * - * - Markdown: merged into the YAML frontmatter block. - * - TOML / JSON / YAML: written as top-level keys. - */ - topLevelAdditions?: Record; -} - /** * Per-pair options threaded through `rewriteUrls` (post-cache URL * rewriting). Adapters that don't have key-path-based URL fields @@ -239,3 +207,42 @@ export interface AdapterDocumentContextOptions { */ contextKeys: Record; } + +export function createStructuredAstroAdapter( + adapter: FileAdapter, + peekNoTranslate: (parsed: TParsed) => boolean, +): FileTypeAdapter { + return { + extensions: adapter.extensions, + parse: (source, sourcePath) => adapter.parse(source, sourcePath), + extractSegments: (parsed, source, options) => adapter.extractSegments(parsed, source, options), + applyTranslations: (parsed, source, translations, options) => adapter.applyTranslations(parsed, source, translations, options), + selectedValuesForHash(parsed, _source, options) { + const selected: Record = {}; + for (const keyPath of resolveConcretePaths({ + parsed, + sourcePath: options.sourcePath, + translatableKeys: options.translatableKeys, + })) { + const value = readAtPath(parsed, parsePath(keyPath).segments as PathSegment[]); + if (value !== undefined) selected[keyPath] = value; + } + return selected; + }, + peekNoTranslate, + rewriteUrls(bytes, options) { + if (options.paths.length === 0) return bytes; + const parsed = adapter.parse(bytes); + const translations = new Map(); + for (const rule of options.paths) { + for (const keyPath of expandPath(rule, parsed)) { + const value = readAtPath(parsed, parsePath(keyPath).segments as PathSegment[]); + if (typeof value !== "string") continue; + const rewritten = options.rewriter(value); + if (rewritten !== null && rewritten !== value) translations.set(keyPath, rewritten); + } + } + return translations.size === 0 ? bytes : adapter.applyTranslations(parsed, bytes, translations); + }, + }; +} diff --git a/packages/astro/src/parsing/adapters/json.ts b/packages/astro/src/parsing/adapters/json.ts new file mode 100644 index 0000000..4883e15 --- /dev/null +++ b/packages/astro/src/parsing/adapters/json.ts @@ -0,0 +1,46 @@ +import { jsonAdapter as portableJsonAdapter, type JsonData } from "@cloudflare/polystella-adapters"; +import { createStructuredAstroAdapter } from "../adapter.js"; + +/** + * JSON adapter. Parses with the native `JSON.parse`, extracts + * translatable scalars at user-configured key paths (with wildcard + * support), and applies translations by mutating the parsed + * structure and re-stringifying with a stable two-space indent. + * + * **Round-trip fidelity (relaxed).** JSON has no comments, but key + * order, indentation, and trailing-newline conventions in the source + * are NOT preserved. `JSON.stringify(_, null, 2)` produces canonical + * output. Source files are never rewritten by polystella, so this + * only affects translation outputs (regenerated each build). + * + * **Cache key.** Uses the same body+selectedValues+glossary+model + * hash composition as the markdown / TOML adapters today; whitespace + * in source files DOES bust the cache. The structured-data variant + * (drop `rawBody`, hash only canonical selected values) is documented + * as future work in the design doc §3.1. + * + * **noTranslate opt-out.** Top-level boolean `noTranslate: true` + * skips the file. JSON's strict type system means no string aliases + * (matching TOML; YAML's looser parsing accepts `"true"` / `"yes"` + * for parity with markdown frontmatter). + * + * **AI-marker injection (per-entry, not file root).** Astro's + * `file()` loader maps each top-level JSON key to a separate + * collection entry, with the value as that entry's `data`. Marker + * fields written at the file root would manifest as bogus extra + * entries (e.g. an entry with id `aiTranslated` whose data is + * `true`) and fail schema validation. The adapter therefore injects + * the marker fields INSIDE each top-level object-valued key — top- + * level scalar keys are skipped (their values are already valid + * entry data and the marker has nowhere meaningful to attach). + * + * **Top-level array handling.** A JSON file with a top-level array + * (Astro maps each array element to a collection entry by `id` / + * `slug`) gets the marker injected into each element that's an + * object — same intent as the per-key injection for object roots. + */ +export const jsonAdapter = createStructuredAstroAdapter(portableJsonAdapter, (parsed: JsonData) => { + return ( + parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && (parsed as Record).noTranslate === true + ); +}); diff --git a/packages/astro/src/parsing/adapters/markdown.ts b/packages/astro/src/parsing/adapters/markdown.ts new file mode 100644 index 0000000..bb2182b --- /dev/null +++ b/packages/astro/src/parsing/adapters/markdown.ts @@ -0,0 +1,103 @@ +import { + createMarkdownAdapter, + resolveFrontmatterKeys, + type AdapterApplyOptions, + type AdapterExtractOptions, +} from "@cloudflare/polystella-adapters"; +import type { Segment } from "@cloudflare/polystella-core"; +import type { Root, Yaml } from "mdast"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; + +import type { AdapterDocumentContextOptions, AdapterParseOptions, AdapterRewriteUrlsOptions, FileTypeAdapter } from "../adapter.js"; +import { parseMarkdown, resolveMarkdownParser } from "../parse.js"; + +const portableMarkdownAdapter = createMarkdownAdapter(); + +export const markdownAdapter: FileTypeAdapter = { + extensions: portableMarkdownAdapter.extensions, + + parse(source: string, sourcePath?: string, options: AdapterParseOptions = {}): Root { + return createMarkdownAdapter(resolveMarkdownParser(options.markdownParser)).parse(source, sourcePath); + }, + + extractSegments(parsed: Root, source: string, options: AdapterExtractOptions): Segment[] { + return portableMarkdownAdapter.extractSegments(parsed, source, options); + }, + + applyTranslations(parsed: Root, source: string, translations: Map, options: AdapterApplyOptions): string { + return portableMarkdownAdapter.applyTranslations(parsed, source, translations, options); + }, + + selectedValuesForHash(parsed: Root, _source: string, options: AdapterExtractOptions): Record { + const frontmatter = readFrontmatter(parsed); + const selected: Record = {}; + for (const key of resolveFrontmatterKeys(options.sourcePath, options.translatableKeys)) { + if (Object.hasOwn(frontmatter, key)) selected[key] = frontmatter[key]; + } + return selected; + }, + + peekNoTranslate(parsed: Root): boolean { + const value = readFrontmatter(parsed).noTranslate; + if (value === true) return true; + return typeof value === "string" && ["true", "yes"].includes(value.toLowerCase().trim()); + }, + + rewriteUrls(bytes: string, options: AdapterRewriteUrlsOptions): string { + if (options.paths.length === 0) return bytes; + const ast = parseMarkdown(bytes, { parser: options.markdownParser }); + const frontmatterNode = ast.children.find((child): child is Yaml => child.type === "yaml"); + const start = frontmatterNode?.position?.start?.offset; + const end = frontmatterNode?.position?.end?.offset; + if (frontmatterNode === undefined || typeof start !== "number" || typeof end !== "number") return bytes; + + const frontmatter = parseYaml(frontmatterNode.value) as Record; + let changed = false; + for (const key of options.paths) { + const value = frontmatter[key]; + if (typeof value !== "string") continue; + const rewritten = options.rewriter(value); + if (rewritten === null || rewritten === value) continue; + frontmatter[key] = rewritten; + changed = true; + } + if (!changed) return bytes; + const inner = stringifyYaml(frontmatter).replace(/\n+$/, ""); + return `${bytes.slice(0, start)}---\n${inner}\n---${bytes.slice(end)}`; + }, + + groupSegments(parsed: Root, segments: Segment[]): Segment[][] { + return portableMarkdownAdapter.groupSegments?.(parsed, segments) ?? [segments]; + }, + + documentContext(parsed: Root, options: AdapterDocumentContextOptions): string | undefined { + const frontmatter = readFrontmatter(parsed); + const lines: string[] = []; + for (const key of resolveFrontmatterKeys(options.sourcePath, options.contextKeys)) { + const value = frontmatter[key]; + if (typeof value !== "string") continue; + const flattened = value.replace(/\s*\n\s*/g, " ").trim(); + if (flattened.length > 0) lines.push(`${titleCaseKey(key)}: ${flattened}`); + } + return lines.length > 0 ? lines.join("\n") : undefined; + }, +}; + +function readFrontmatter(parsed: Root): Record { + const node = parsed.children.find((child): child is Yaml => child.type === "yaml"); + if (node === undefined) return {}; + try { + const value = parseYaml(node.value); + return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; + } catch { + return {}; + } +} + +function titleCaseKey(key: string): string { + return key + .split(/[_-]+/) + .filter((word) => word.length > 0) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} diff --git a/packages/astro/src/parsing/adapters/toml.ts b/packages/astro/src/parsing/adapters/toml.ts new file mode 100644 index 0000000..198bc78 --- /dev/null +++ b/packages/astro/src/parsing/adapters/toml.ts @@ -0,0 +1,47 @@ +import { tomlAdapter as portableTomlAdapter, type TomlData } from "@cloudflare/polystella-adapters"; +import { createStructuredAstroAdapter } from "../adapter.js"; + +/** + * TOML adapter. Parses with `smol-toml`, extracts translatable + * scalars at user-configured key paths (with wildcard support), and + * applies translations by mutating the parsed structure and + * re-stringifying. + * + * **Round-trip fidelity (relaxed).** Comments and exact key ordering + * are not preserved on output — `smol-toml.stringify` produces clean + * canonical TOML. This is acceptable for translation outputs (the + * staged file is regenerated each build); source files are never + * rewritten by polystella. + * + * **Cache key.** Today the runtime feeds raw body bytes + selected + * values into `computeSourceHash`. For TOML that means whitespace + * and comment edits in source files DO bust the cache; design doc + * §3.1 calls out a structured-data variant that drops `rawBody` and + * hashes `canonicalSelectedValues + glossary + model`. Implementing + * that variant is M3.5 follow-up work — for v0.1.x ship, the + * conservative current behaviour is fine. + * + * **noTranslate opt-out.** Top-level boolean `noTranslate = true` + * skips the file. (No string aliases — TOML's stricter type system + * doesn't need them, unlike YAML frontmatter.) + * + * **AI-marker injection (per-entry, not file root).** Astro's + * `file()` loader maps each top-level TOML key to a separate + * collection entry, with the value as that entry's `data`. Marker + * fields written at the file root would manifest as bogus extra + * entries (e.g. an entry with id `aiTranslated` whose data is + * `true`) and fail schema validation. The adapter therefore injects + * the marker fields INSIDE each top-level object-valued key — so a + * file like `[main.featuredResearch]\n...` becomes `[main]\n + * aiTranslated = true\n[main.featuredResearch]\n...` after + * translation. Top-level scalar keys (numbers / booleans / strings) + * are left untouched: they're already entries with non-object data + * and the marker has nowhere meaningful to live on them. Files with + * a single top-level key (the common `file()` loader case) get the + * marker on that key; multi-entry files get it on each one. + * + * Consumer schemas extended by `polystellaCollections` accept these + * fields uniformly across formats — TOML siblings work identically + * to markdown siblings on the consumer side. + */ +export const tomlAdapter = createStructuredAstroAdapter(portableTomlAdapter, (parsed: TomlData) => parsed.noTranslate === true); diff --git a/packages/astro/src/parsing/adapters/yaml.ts b/packages/astro/src/parsing/adapters/yaml.ts new file mode 100644 index 0000000..4d13e96 --- /dev/null +++ b/packages/astro/src/parsing/adapters/yaml.ts @@ -0,0 +1,60 @@ +import { yamlAdapter as portableYamlAdapter, type YamlData } from "@cloudflare/polystella-adapters"; +import { createStructuredAstroAdapter } from "../adapter.js"; + +/** + * YAML adapter. Parses with the `yaml` library (already a dep for + * markdown frontmatter), extracts translatable scalars at user- + * configured key paths (with wildcard support), and applies + * translations by mutating the parsed structure and re-stringifying. + * + * **Round-trip fidelity (relaxed).** Comments, anchors / aliases, + * exact key ordering, and quoting style are NOT preserved on output + * — `yaml.stringify` produces canonical output. Source files are + * never rewritten by polystella, so this only affects translation + * outputs (regenerated each build). Document mode (which preserves + * more structure) is on the table for future strict round-trip; the + * v0.1.x ship uses the simpler parse/stringify path. + * + * **Cache key.** Uses the same body+selectedValues+glossary+model + * hash composition as the markdown / TOML / JSON adapters today; + * whitespace and comment edits in source files DO bust the cache. + * The structured-data variant (drop `rawBody`, hash only canonical + * selected values) is documented as future work in the design doc + * §3.1. + * + * **noTranslate opt-out.** Top-level `noTranslate` accepts both + * boolean `true` and the string aliases `"true"` / `"yes"` (matching + * markdown frontmatter, which IS YAML — operators expect parity + * across the two YAML surfaces). TOML and JSON are stricter. + * + * **AI-marker injection (per-entry, not file root).** Astro's + * `file()` loader maps each top-level YAML key to a separate + * collection entry, with the value as that entry's `data`. Marker + * fields written at the file root would manifest as bogus extra + * entries (e.g. an entry with id `aiTranslated` whose data is + * `true`) and fail schema validation. The adapter therefore injects + * the marker fields INSIDE each top-level object-valued key. Top- + * level scalar keys are skipped (their values are already valid + * entry data and the marker has nowhere meaningful to attach). + * + * **Top-level sequence handling.** A YAML file with a top-level + * sequence (Astro maps each element to a collection entry by + * `id` / `slug`) gets the marker injected into each element that's + * a mapping — same intent as the per-key injection for mapping + * roots. + * + * **Date / timestamp interop.** This adapter uses the `yaml` + * package (eemeli/yaml v2), which returns unquoted ISO 8601 strings + * as plain strings — quoted and unquoted forms hash identically in + * `selectedValuesForHash`. Astro's `file()` loader, however, uses + * `js-yaml` internally, which DOES auto-parse unquoted ISO + * timestamps to `Date`. The schema-extender accommodates both: + * `aiTranslatedAt: z.union([z.string(), z.date()])`, so the marker + * round-trips correctly through both ends of the pipeline. + */ +export const yamlAdapter = createStructuredAstroAdapter(portableYamlAdapter, (parsed: YamlData) => { + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false; + const value = (parsed as Record).noTranslate; + if (value === true) return true; + return typeof value === "string" && ["true", "yes"].includes(value.toLowerCase().trim()); +}); diff --git a/src/parsing/mdx-audit.ts b/packages/astro/src/parsing/mdx-audit.ts similarity index 99% rename from src/parsing/mdx-audit.ts rename to packages/astro/src/parsing/mdx-audit.ts index 7cb36ba..a636556 100644 --- a/src/parsing/mdx-audit.ts +++ b/packages/astro/src/parsing/mdx-audit.ts @@ -1,8 +1,7 @@ import type { Root } from "mdast"; +import type { NormalizedMdxRules } from "@cloudflare/polystella-adapters"; import picomatch from "picomatch"; -import type { NormalizedMdxRules } from "./mdx-rules.js"; - export type MdxAuditSeverity = "low" | "medium" | "high"; export type MdxAuditCode = diff --git a/src/parsing/mdx-rules.ts b/packages/astro/src/parsing/mdx-rules.ts similarity index 91% rename from src/parsing/mdx-rules.ts rename to packages/astro/src/parsing/mdx-rules.ts index b52078d..0000201 100644 --- a/src/parsing/mdx-rules.ts +++ b/packages/astro/src/parsing/mdx-rules.ts @@ -1,11 +1,10 @@ import { createHash } from "node:crypto"; +import { MDX_RULES_VERSION, type NormalizedMdxComponentRule, type NormalizedMdxRules } from "@cloudflare/polystella-adapters"; import picomatch from "picomatch"; import type { PolyStellaResolvedOptions } from "../config/options.js"; -export const MDX_RULES_VERSION = "mdx-rules-v1"; - export const DEFAULT_MDX_HTML_ATTRIBUTES: Readonly> = { "*": ["alt", "title", "aria-label", "placeholder"], }; @@ -20,18 +19,6 @@ interface ScopedMdxRecipe { use: ResolvedMdxRuleFragment; } -export interface NormalizedMdxComponentRule { - children?: boolean | undefined; - props: string[]; -} - -export interface NormalizedMdxRules { - version: typeof MDX_RULES_VERSION; - htmlAttributes: Record; - components: Record; - data: Record>; -} - const matcherCache = new Map boolean>(); function getMatcher(pattern: string): (path: string) => boolean { diff --git a/src/parsing/parse.ts b/packages/astro/src/parsing/parse.ts similarity index 93% rename from src/parsing/parse.ts rename to packages/astro/src/parsing/parse.ts index 6d76e8e..755d92e 100644 --- a/src/parsing/parse.ts +++ b/packages/astro/src/parsing/parse.ts @@ -1,11 +1,7 @@ import type { Root } from "mdast"; import { parse as parseJavaScript, type Comment } from "acorn"; -import remarkFrontmatter from "remark-frontmatter"; -import remarkGfm from "remark-gfm"; -import remarkMdx from "remark-mdx"; -import remarkParse from "remark-parse"; +import { remarkMarkdownParser, type MarkdownParser as PortableMarkdownParser } from "@cloudflare/polystella-adapters"; import { markdownToMdast, mdxToMdast } from "satteri"; -import { unified } from "unified"; export type MarkdownParser = "satteri" | "remark"; @@ -43,22 +39,28 @@ export const DEFAULT_MARKDOWN_PARSER: MarkdownParser = "satteri"; * suffices and we skip `.run()`. */ -/** Re-usable plain-markdown processor. */ -export function createMarkdownProcessor() { - return unified().use(remarkParse).use(remarkFrontmatter, ["yaml"]).use(remarkGfm); -} +const satteriMarkdownParser: PortableMarkdownParser = { + parseMarkdown(source) { + return markdownToMdast(source) as Root; + }, + parseMdx(source) { + try { + const ast = mdxToMdast(source) as Root; + attachSatteriMdxCompat(ast, source); + return ast; + } catch { + return remarkMarkdownParser.parseMdx(source); + } + }, +}; -/** Re-usable MDX processor (markdown + JSX + ESM + expressions). */ -export function createMdxProcessor() { - return createMarkdownProcessor().use(remarkMdx); +export function resolveMarkdownParser(parser: MarkdownParser = DEFAULT_MARKDOWN_PARSER): PortableMarkdownParser { + return parser === "satteri" ? satteriMarkdownParser : remarkMarkdownParser; } /** Pure: no I/O, no Astro coupling. */ export function parseMarkdown(source: string, opts: MarkdownParseOptions = {}): Root { - if ((opts.parser ?? DEFAULT_MARKDOWN_PARSER) === "satteri") { - return markdownToMdast(source) as Root; - } - return createMarkdownProcessor().parse(source) as Root; + return resolveMarkdownParser(opts.parser).parseMarkdown(source); } /** @@ -67,18 +69,7 @@ export function parseMarkdown(source: string, opts: MarkdownParseOptions = {}): * blocks (the latter become JSX elements). */ export function parseMdx(source: string, opts: MarkdownParseOptions = {}): Root { - if ((opts.parser ?? DEFAULT_MARKDOWN_PARSER) === "satteri") { - try { - const ast = mdxToMdast(source) as Root; - attachSatteriMdxCompat(ast, source); - return ast; - } catch { - // Sätteri's MDX parser is stricter than remark-mdx for some - // expression shapes. Keep the default usable while callers can - // still force the legacy parser explicitly with parser: "remark". - } - } - return createMdxProcessor().parse(source) as Root; + return resolveMarkdownParser(opts.parser).parseMdx(source); } function attachSatteriMdxCompat(root: Root, source: string): void { diff --git a/src/parsing/registry.ts b/packages/astro/src/parsing/registry.ts similarity index 100% rename from src/parsing/registry.ts rename to packages/astro/src/parsing/registry.ts diff --git a/src/parsing/rewrite-links.ts b/packages/astro/src/parsing/rewrite-links.ts similarity index 100% rename from src/parsing/rewrite-links.ts rename to packages/astro/src/parsing/rewrite-links.ts diff --git a/src/parsing/rewrite-mdx-imports.ts b/packages/astro/src/parsing/rewrite-mdx-imports.ts similarity index 100% rename from src/parsing/rewrite-mdx-imports.ts rename to packages/astro/src/parsing/rewrite-mdx-imports.ts diff --git a/src/react/index.ts b/packages/astro/src/react/index.ts similarity index 100% rename from src/react/index.ts rename to packages/astro/src/react/index.ts diff --git a/src/recipes/index.ts b/packages/astro/src/recipes/index.ts similarity index 100% rename from src/recipes/index.ts rename to packages/astro/src/recipes/index.ts diff --git a/src/recipes/starlight.ts b/packages/astro/src/recipes/starlight.ts similarity index 100% rename from src/recipes/starlight.ts rename to packages/astro/src/recipes/starlight.ts diff --git a/src/recipes/types.ts b/packages/astro/src/recipes/types.ts similarity index 100% rename from src/recipes/types.ts rename to packages/astro/src/recipes/types.ts diff --git a/src/routing/expand-routes.ts b/packages/astro/src/routing/expand-routes.ts similarity index 100% rename from src/routing/expand-routes.ts rename to packages/astro/src/routing/expand-routes.ts diff --git a/src/routing/shim.ts b/packages/astro/src/routing/shim.ts similarity index 100% rename from src/routing/shim.ts rename to packages/astro/src/routing/shim.ts diff --git a/src/routing/walk-pages.ts b/packages/astro/src/routing/walk-pages.ts similarity index 100% rename from src/routing/walk-pages.ts rename to packages/astro/src/routing/walk-pages.ts diff --git a/src/runtime/custom-loader-runtime.ts b/packages/astro/src/runtime/custom-loader-runtime.ts similarity index 98% rename from src/runtime/custom-loader-runtime.ts rename to packages/astro/src/runtime/custom-loader-runtime.ts index ba9df51..920503b 100644 --- a/src/runtime/custom-loader-runtime.ts +++ b/packages/astro/src/runtime/custom-loader-runtime.ts @@ -15,16 +15,14 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import type { Loader } from "astro/loaders"; +import { parsePath, readAtPath, writeAtPath, type PathSegment } from "@cloudflare/polystella-adapters"; +import { EMPTY_GLOSSARY, type Glossary, type Segment, type Translator } from "@cloudflare/polystella-core"; import type { CapturedEntry, PolystellaCustomLoaderMarker } from "../content/custom-loader.js"; -import { EMPTY_GLOSSARY, type Glossary } from "../glossary/glossary.js"; -import { parsePath, readAtPath, writeAtPath, type PathSegment } from "../parsing/key-paths.js"; -import type { Segment } from "../parsing/extract.js"; import { runWithConcurrency } from "../source/pool.js"; import { buildCacheMetadata, translateOrLoadFromCache, type CacheEvents } from "../storage/cache.js"; import { computeSourceHash } from "../storage/hash.js"; import { buildR2Key, type R2Client } from "../storage/r2.js"; -import type { Translator } from "../translation/provider.js"; /** Per-(loader, locale) translation outcome. */ export type CustomLoaderTranslateOutcome = "cache-hit" | "ai-translated" | "skipped-no-translator" | "staged" | "error"; diff --git a/src/runtime/get-localized-collection.ts b/packages/astro/src/runtime/get-localized-collection.ts similarity index 100% rename from src/runtime/get-localized-collection.ts rename to packages/astro/src/runtime/get-localized-collection.ts diff --git a/src/runtime/get-localized-entry.ts b/packages/astro/src/runtime/get-localized-entry.ts similarity index 100% rename from src/runtime/get-localized-entry.ts rename to packages/astro/src/runtime/get-localized-entry.ts diff --git a/src/runtime/index.ts b/packages/astro/src/runtime/index.ts similarity index 100% rename from src/runtime/index.ts rename to packages/astro/src/runtime/index.ts diff --git a/src/runtime/localized-href.ts b/packages/astro/src/runtime/localized-href.ts similarity index 100% rename from src/runtime/localized-href.ts rename to packages/astro/src/runtime/localized-href.ts diff --git a/src/runtime/locals.ts b/packages/astro/src/runtime/locals.ts similarity index 100% rename from src/runtime/locals.ts rename to packages/astro/src/runtime/locals.ts diff --git a/src/runtime/middleware-core.ts b/packages/astro/src/runtime/middleware-core.ts similarity index 100% rename from src/runtime/middleware-core.ts rename to packages/astro/src/runtime/middleware-core.ts diff --git a/src/runtime/middleware.ts b/packages/astro/src/runtime/middleware.ts similarity index 100% rename from src/runtime/middleware.ts rename to packages/astro/src/runtime/middleware.ts diff --git a/src/source/overrides.ts b/packages/astro/src/source/overrides.ts similarity index 100% rename from src/source/overrides.ts rename to packages/astro/src/source/overrides.ts diff --git a/src/source/pool.ts b/packages/astro/src/source/pool.ts similarity index 100% rename from src/source/pool.ts rename to packages/astro/src/source/pool.ts diff --git a/src/source/walk.ts b/packages/astro/src/source/walk.ts similarity index 100% rename from src/source/walk.ts rename to packages/astro/src/source/walk.ts diff --git a/src/storage/cache.ts b/packages/astro/src/storage/cache.ts similarity index 96% rename from src/storage/cache.ts rename to packages/astro/src/storage/cache.ts index b9a7952..ab8db1b 100644 --- a/src/storage/cache.ts +++ b/packages/astro/src/storage/cache.ts @@ -1,11 +1,14 @@ import pRetry from "p-retry"; +import { MdxPlaceholderError } from "@cloudflare/polystella-adapters"; +import { + translateSegments, + type Glossary, + type Logger, + type Segment, + type TranslateBatchRetryEvent, + type Translator, +} from "@cloudflare/polystella-core"; -import type { Segment } from "../parsing/extract.js"; -import { MdxPlaceholderError } from "../parsing/mdx-placeholders.js"; -import type { Glossary } from "../glossary/glossary.js"; -import type { Logger } from "../translation/logger.js"; -import { type TranslateBatchRetryEvent, type Translator } from "../translation/provider.js"; -import { translateSegments } from "../translation/translate-segments.js"; import type { R2Client } from "./r2.js"; /** diff --git a/src/storage/hash.ts b/packages/astro/src/storage/hash.ts similarity index 100% rename from src/storage/hash.ts rename to packages/astro/src/storage/hash.ts diff --git a/src/storage/local-cache.ts b/packages/astro/src/storage/local-cache.ts similarity index 100% rename from src/storage/local-cache.ts rename to packages/astro/src/storage/local-cache.ts diff --git a/src/storage/paths.ts b/packages/astro/src/storage/paths.ts similarity index 100% rename from src/storage/paths.ts rename to packages/astro/src/storage/paths.ts diff --git a/src/storage/prune.ts b/packages/astro/src/storage/prune.ts similarity index 100% rename from src/storage/prune.ts rename to packages/astro/src/storage/prune.ts diff --git a/src/storage/r2.ts b/packages/astro/src/storage/r2.ts similarity index 100% rename from src/storage/r2.ts rename to packages/astro/src/storage/r2.ts diff --git a/src/storage/report.ts b/packages/astro/src/storage/report.ts similarity index 100% rename from src/storage/report.ts rename to packages/astro/src/storage/report.ts diff --git a/packages/astro/src/translation/provider.ts b/packages/astro/src/translation/provider.ts new file mode 100644 index 0000000..492dcd8 --- /dev/null +++ b/packages/astro/src/translation/provider.ts @@ -0,0 +1,40 @@ +import { resolveModelId, type Translator } from "@cloudflare/polystella-core"; +import { createAnthropicTranslator } from "@cloudflare/polystella-providers/anthropic"; +import { createWorkersAIHttpTranslator } from "@cloudflare/polystella-providers/workers-ai"; + +import type { PolyStellaResolvedOptions } from "../config/options.js"; + +type ProviderConfig = NonNullable; + +export interface CreateTranslatorOptions { + /** Defaults to global `fetch`; tests pass a stub. */ + fetchImpl?: typeof fetch; +} + +/** + * Throws on unknown provider kind. Doesn't validate credentials — + * auth failures surface from the first `translate()` call. + */ +export function createTranslator(provider: ProviderConfig, locale: string, options: CreateTranslatorOptions = {}): Translator { + if (provider.kind === "workers-ai") { + const modelId = resolveModelId(provider.model, locale); + return createWorkersAIHttpTranslator({ + accountId: provider.accountId, + apiToken: provider.apiToken, + modelId, + maxTokens: provider.maxTokens, + ...(provider.endpoint !== undefined ? { endpoint: provider.endpoint } : {}), + ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), + }); + } + if (provider.kind === "anthropic") { + const modelId = resolveModelId(provider.model, locale); + return createAnthropicTranslator({ + apiKey: provider.apiKey, + modelId, + maxTokens: provider.maxTokens, + ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), + }); + } + throw new Error(`[polystella] unknown provider kind: ${(provider as { kind: string }).kind}`); +} diff --git a/src/translation/run.ts b/packages/astro/src/translation/run.ts similarity index 98% rename from src/translation/run.ts rename to packages/astro/src/translation/run.ts index 752da47..de4fb39 100644 --- a/src/translation/run.ts +++ b/packages/astro/src/translation/run.ts @@ -4,10 +4,12 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import picomatch from "picomatch"; +import type { AdapterExtractOptions } from "@cloudflare/polystella-adapters"; +import { EMPTY_GLOSSARY, type Glossary, type Logger, type Translator } from "@cloudflare/polystella-core"; import type { PolyStellaResolvedOptions } from "../config/options.js"; -import { EMPTY_GLOSSARY, EMPTY_GLOSSARY_HASH, hashGlossary, loadGlossaries, type Glossary } from "../glossary/glossary.js"; -import type { AdapterExtractOptions, FileTypeAdapter } from "../parsing/adapter.js"; +import { EMPTY_GLOSSARY_HASH, hashGlossary, loadGlossaries } from "../glossary/glossary.js"; +import type { FileTypeAdapter } from "../parsing/adapter.js"; import { computeMdxRulesPolicyHash, normalizeMdxRulesForSource } from "../parsing/mdx-rules.js"; import { rewriteInternalLinks, rewriteUrlIfInternal, type RewriteInternalLinksOptions } from "../parsing/rewrite-links.js"; import { rewriteMdxRelativeImportsForStaging } from "../parsing/rewrite-mdx-imports.js"; @@ -27,8 +29,7 @@ import { import { encodeTouchedPair, pruneCacheByPair } from "../storage/prune.js"; import { buildR2Key, createR2Client, DEFAULT_R2_KEY_PREFIX, type R2Client } from "../storage/r2.js"; import type { BuildReportEntry, BuildReportPruning } from "../storage/report.js"; -import { type Logger } from "./logger.js"; -import { createTranslator, type Translator } from "./provider.js"; +import { createTranslator } from "./provider.js"; /** * Translation pass shared between the Astro integration and the @@ -39,11 +40,6 @@ import { createTranslator, type Translator } from "./provider.js"; * write the build report — those are caller responsibilities. */ -// Re-exported so callers can keep importing `Logger` from `run.ts` -// (the historical location). The canonical definition lives in -// `./logger.ts` so leaf primitives don't import the orchestrator. -export type { Logger }; - export interface RunTranslationOptions { /** Pre-validated via `resolveOptions`; schema invariants are trusted. */ resolved: PolyStellaResolvedOptions; diff --git a/src/version.ts b/packages/astro/src/version.ts similarity index 100% rename from src/version.ts rename to packages/astro/src/version.ts diff --git a/tests/catalog/astro.test.ts b/packages/astro/tests/catalog/astro.test.ts similarity index 100% rename from tests/catalog/astro.test.ts rename to packages/astro/tests/catalog/astro.test.ts diff --git a/tests/catalog/catalog.test.ts b/packages/astro/tests/catalog/catalog.test.ts similarity index 100% rename from tests/catalog/catalog.test.ts rename to packages/astro/tests/catalog/catalog.test.ts diff --git a/tests/catalog/middleware.test.ts b/packages/astro/tests/catalog/middleware.test.ts similarity index 100% rename from tests/catalog/middleware.test.ts rename to packages/astro/tests/catalog/middleware.test.ts diff --git a/tests/cli.test.ts b/packages/astro/tests/cli.test.ts similarity index 100% rename from tests/cli.test.ts rename to packages/astro/tests/cli.test.ts diff --git a/tests/cli/audit-mdx.test.ts b/packages/astro/tests/cli/audit-mdx.test.ts similarity index 100% rename from tests/cli/audit-mdx.test.ts rename to packages/astro/tests/cli/audit-mdx.test.ts diff --git a/tests/cli/check-ui.test.ts b/packages/astro/tests/cli/check-ui.test.ts similarity index 100% rename from tests/cli/check-ui.test.ts rename to packages/astro/tests/cli/check-ui.test.ts diff --git a/tests/cli/sync-ui.test.ts b/packages/astro/tests/cli/sync-ui.test.ts similarity index 100% rename from tests/cli/sync-ui.test.ts rename to packages/astro/tests/cli/sync-ui.test.ts diff --git a/tests/cli/translate-ui.test.ts b/packages/astro/tests/cli/translate-ui.test.ts similarity index 100% rename from tests/cli/translate-ui.test.ts rename to packages/astro/tests/cli/translate-ui.test.ts diff --git a/tests/config/options.test.ts b/packages/astro/tests/config/options.test.ts similarity index 100% rename from tests/config/options.test.ts rename to packages/astro/tests/config/options.test.ts diff --git a/tests/content/content.test.ts b/packages/astro/tests/content/content.test.ts similarity index 100% rename from tests/content/content.test.ts rename to packages/astro/tests/content/content.test.ts diff --git a/tests/content/content.types.test.ts b/packages/astro/tests/content/content.types.test.ts similarity index 100% rename from tests/content/content.types.test.ts rename to packages/astro/tests/content/content.types.test.ts diff --git a/tests/content/custom-loader.test.ts b/packages/astro/tests/content/custom-loader.test.ts similarity index 100% rename from tests/content/custom-loader.test.ts rename to packages/astro/tests/content/custom-loader.test.ts diff --git a/tests/content/file-loader.test.ts b/packages/astro/tests/content/file-loader.test.ts similarity index 100% rename from tests/content/file-loader.test.ts rename to packages/astro/tests/content/file-loader.test.ts diff --git a/tests/content/schema-extend.test.ts b/packages/astro/tests/content/schema-extend.test.ts similarity index 100% rename from tests/content/schema-extend.test.ts rename to packages/astro/tests/content/schema-extend.test.ts diff --git a/tests/docs.test.ts b/packages/astro/tests/docs.test.ts similarity index 77% rename from tests/docs.test.ts rename to packages/astro/tests/docs.test.ts index 936b043..520d406 100644 --- a/tests/docs.test.ts +++ b/packages/astro/tests/docs.test.ts @@ -27,15 +27,30 @@ import { parseSubcommand } from "../src/cli.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PACKAGE_ROOT = path.resolve(__dirname, ".."); +const REPOSITORY_ROOT = path.resolve(PACKAGE_ROOT, "..", ".."); function readDoc(relPath: string): string { - return readFileSync(path.join(PACKAGE_ROOT, relPath), "utf8"); + return readFileSync(path.join(REPOSITORY_ROOT, relPath), "utf8"); } function fileExists(relPath: string): boolean { + return existsSync(path.join(REPOSITORY_ROOT, relPath)); +} + +function readPackageFile(relPath: string): string { + return readFileSync(path.join(PACKAGE_ROOT, relPath), "utf8"); +} + +function packageFileExists(relPath: string): boolean { return existsSync(path.join(PACKAGE_ROOT, relPath)); } +function currentRepositoryPaths(text: string): string[] { + return [...text.matchAll(/`(packages\/(?:astro|core|adapters|providers)\/(?:src|tests)\/[^`]+)`/g)] + .map((match) => match[1]) + .filter((candidate): candidate is string => candidate !== undefined && !/[<>{}*]/.test(candidate)); +} + describe("doc-claim invariants", () => { describe("AGENTS.md", () => { it("exists", () => { @@ -48,20 +63,20 @@ describe("doc-claim invariants", () => { // both. New refs from the doc don't need a pin here unless they // matter for navigation. const referenced = [ - "src/parsing/adapter.ts", - "src/parsing/registry.ts", - "src/cli.ts", - "src/storage/hash.ts", - "src/storage/cache.ts", - "src/translation/provider.ts", - "src/translation/batch.ts", - "src/translation/translate-segments.ts", - "src/translation/run.ts", - "src/runtime/custom-loader-runtime.ts", - "src/version.ts", - "src/cli/check-ui.ts", - "src/cli/sync-ui.ts", - "src/cli/translate-ui.ts", + "packages/astro/src/parsing/adapter.ts", + "packages/astro/src/parsing/registry.ts", + "packages/astro/src/cli.ts", + "packages/astro/src/storage/hash.ts", + "packages/astro/src/storage/cache.ts", + "packages/astro/src/translation/provider.ts", + "packages/core/src/batch.ts", + "packages/core/src/translate-segments.ts", + "packages/astro/src/translation/run.ts", + "packages/astro/src/runtime/custom-loader-runtime.ts", + "packages/astro/src/version.ts", + "packages/astro/src/cli/check-ui.ts", + "packages/astro/src/cli/sync-ui.ts", + "packages/astro/src/cli/translate-ui.ts", ]; for (const p of referenced) { expect(fileExists(p), `AGENTS.md references ${p} which does not exist`).toBe(true); @@ -126,9 +141,27 @@ describe("doc-claim invariants", () => { }); }); + describe("current repository paths", () => { + it("all concrete paths documented for contributors exist", () => { + for (const doc of ["AGENTS.md", "ARCHITECTURE.md", "CONTRIBUTING.md", "llms.txt", "skills/polystella-contributor/SKILL.md"]) { + const text = readDoc(doc); + const paths = currentRepositoryPaths(text); + expect(paths.length, `${doc} has no current package paths`).toBeGreaterThan(0); + for (const documentedPath of paths) { + expect(fileExists(documentedPath), `${doc} references ${documentedPath} which does not exist`).toBe(true); + } + if (doc === "CONTRIBUTING.md" || doc === "llms.txt") { + expect(text, `${doc} references the removed root source tree`).not.toMatch( + /`src\/(?:index\.ts|cli(?:\.ts|\/)|config\/options\.ts|parsing\/|translation\/|storage\/|runtime\/|react\/)/, + ); + } + } + }); + }); + describe("CLI / package.json alignment", () => { it("package.json `bin` points to a built path", () => { - const pkg = JSON.parse(readDoc("package.json")) as { bin?: Record }; + const pkg = JSON.parse(readPackageFile("package.json")) as { bin?: Record }; expect(pkg.bin).toBeDefined(); expect(pkg.bin?.["polystella"]).toBe("dist/cli.js"); }); @@ -148,16 +181,16 @@ describe("doc-claim invariants", () => { it("every CLI subcommand has a handler file", () => { // translate is handled inline in src/cli.ts; the rest have // dedicated files under src/cli/. - expect(fileExists("src/cli/check-ui.ts")).toBe(true); - expect(fileExists("src/cli/sync-ui.ts")).toBe(true); - expect(fileExists("src/cli/translate-ui.ts")).toBe(true); - expect(fileExists("src/cli/audit-mdx.ts")).toBe(true); + expect(packageFileExists("src/cli/check-ui.ts")).toBe(true); + expect(packageFileExists("src/cli/sync-ui.ts")).toBe(true); + expect(packageFileExists("src/cli/translate-ui.ts")).toBe(true); + expect(packageFileExists("src/cli/audit-mdx.ts")).toBe(true); }); }); describe("package.json `exports`", () => { it("every documented subpath is declared in `exports`", () => { - const pkg = JSON.parse(readDoc("package.json")) as { + const pkg = JSON.parse(readPackageFile("package.json")) as { exports?: Record; }; const documented = [ @@ -177,7 +210,7 @@ describe("doc-claim invariants", () => { }); it("every entry-point file in `exports` exists (or maps to a `src/` source)", () => { - const pkg = JSON.parse(readDoc("package.json")) as { + const pkg = JSON.parse(readPackageFile("package.json")) as { exports?: Record; }; const exports = pkg.exports ?? {}; @@ -192,11 +225,11 @@ describe("doc-claim invariants", () => { const inner = distPath.slice("./dist/".length); // Strip the emitted extension; check every plausible source. const stem = inner.replace(/\.(js|d\.ts)$/, ""); - return fileExists(`src/${stem}.ts`) || fileExists(`src/${stem}.d.ts`); + return packageFileExists(`src/${stem}.ts`) || packageFileExists(`src/${stem}.d.ts`); } function checkPath(subpath: string, target: string): void { - if (fileExists(target)) return; + if (packageFileExists(target)) return; if (distMapsToSource(target)) return; expect.fail( `exports["${subpath}"] = ${target}, but no matching file exists ` + diff --git a/tests/fixtures/multi-section.md b/packages/astro/tests/fixtures/multi-section.md similarity index 100% rename from tests/fixtures/multi-section.md rename to packages/astro/tests/fixtures/multi-section.md diff --git a/tests/fixtures/oversize-section.md b/packages/astro/tests/fixtures/oversize-section.md similarity index 100% rename from tests/fixtures/oversize-section.md rename to packages/astro/tests/fixtures/oversize-section.md diff --git a/tests/fixtures/parsing/round-trip/01-minimal.md b/packages/astro/tests/fixtures/parsing/round-trip/01-minimal.md similarity index 100% rename from tests/fixtures/parsing/round-trip/01-minimal.md rename to packages/astro/tests/fixtures/parsing/round-trip/01-minimal.md diff --git a/tests/fixtures/parsing/round-trip/02-frontmatter-shapes.md b/packages/astro/tests/fixtures/parsing/round-trip/02-frontmatter-shapes.md similarity index 100% rename from tests/fixtures/parsing/round-trip/02-frontmatter-shapes.md rename to packages/astro/tests/fixtures/parsing/round-trip/02-frontmatter-shapes.md diff --git a/tests/fixtures/parsing/round-trip/03-inline-formatting.md b/packages/astro/tests/fixtures/parsing/round-trip/03-inline-formatting.md similarity index 100% rename from tests/fixtures/parsing/round-trip/03-inline-formatting.md rename to packages/astro/tests/fixtures/parsing/round-trip/03-inline-formatting.md diff --git a/tests/fixtures/parsing/round-trip/04-headings.md b/packages/astro/tests/fixtures/parsing/round-trip/04-headings.md similarity index 100% rename from tests/fixtures/parsing/round-trip/04-headings.md rename to packages/astro/tests/fixtures/parsing/round-trip/04-headings.md diff --git a/tests/fixtures/parsing/round-trip/05-lists.md b/packages/astro/tests/fixtures/parsing/round-trip/05-lists.md similarity index 100% rename from tests/fixtures/parsing/round-trip/05-lists.md rename to packages/astro/tests/fixtures/parsing/round-trip/05-lists.md diff --git a/tests/fixtures/parsing/round-trip/06-code-blocks.md b/packages/astro/tests/fixtures/parsing/round-trip/06-code-blocks.md similarity index 100% rename from tests/fixtures/parsing/round-trip/06-code-blocks.md rename to packages/astro/tests/fixtures/parsing/round-trip/06-code-blocks.md diff --git a/tests/fixtures/parsing/round-trip/07-blockquotes.md b/packages/astro/tests/fixtures/parsing/round-trip/07-blockquotes.md similarity index 100% rename from tests/fixtures/parsing/round-trip/07-blockquotes.md rename to packages/astro/tests/fixtures/parsing/round-trip/07-blockquotes.md diff --git a/tests/fixtures/parsing/round-trip/08-tables.md b/packages/astro/tests/fixtures/parsing/round-trip/08-tables.md similarity index 100% rename from tests/fixtures/parsing/round-trip/08-tables.md rename to packages/astro/tests/fixtures/parsing/round-trip/08-tables.md diff --git a/tests/fixtures/parsing/round-trip/09-html-blocks.md b/packages/astro/tests/fixtures/parsing/round-trip/09-html-blocks.md similarity index 100% rename from tests/fixtures/parsing/round-trip/09-html-blocks.md rename to packages/astro/tests/fixtures/parsing/round-trip/09-html-blocks.md diff --git a/tests/fixtures/parsing/round-trip/10-thematic-breaks.md b/packages/astro/tests/fixtures/parsing/round-trip/10-thematic-breaks.md similarity index 100% rename from tests/fixtures/parsing/round-trip/10-thematic-breaks.md rename to packages/astro/tests/fixtures/parsing/round-trip/10-thematic-breaks.md diff --git a/tests/fixtures/parsing/round-trip/11-empty-frontmatter.md b/packages/astro/tests/fixtures/parsing/round-trip/11-empty-frontmatter.md similarity index 100% rename from tests/fixtures/parsing/round-trip/11-empty-frontmatter.md rename to packages/astro/tests/fixtures/parsing/round-trip/11-empty-frontmatter.md diff --git a/tests/fixtures/parsing/round-trip/12-no-frontmatter.md b/packages/astro/tests/fixtures/parsing/round-trip/12-no-frontmatter.md similarity index 100% rename from tests/fixtures/parsing/round-trip/12-no-frontmatter.md rename to packages/astro/tests/fixtures/parsing/round-trip/12-no-frontmatter.md diff --git a/tests/fixtures/parsing/round-trip/13-footnotes.md b/packages/astro/tests/fixtures/parsing/round-trip/13-footnotes.md similarity index 100% rename from tests/fixtures/parsing/round-trip/13-footnotes.md rename to packages/astro/tests/fixtures/parsing/round-trip/13-footnotes.md diff --git a/tests/fixtures/parsing/round-trip/14-images.md b/packages/astro/tests/fixtures/parsing/round-trip/14-images.md similarity index 100% rename from tests/fixtures/parsing/round-trip/14-images.md rename to packages/astro/tests/fixtures/parsing/round-trip/14-images.md diff --git a/tests/fixtures/parsing/round-trip/15-edge-cases.md b/packages/astro/tests/fixtures/parsing/round-trip/15-edge-cases.md similarity index 100% rename from tests/fixtures/parsing/round-trip/15-edge-cases.md rename to packages/astro/tests/fixtures/parsing/round-trip/15-edge-cases.md diff --git a/tests/glossary/glossary.test.ts b/packages/astro/tests/glossary/glossary.test.ts similarity index 98% rename from tests/glossary/glossary.test.ts rename to packages/astro/tests/glossary/glossary.test.ts index 146c725..a06b1f9 100644 --- a/tests/glossary/glossary.test.ts +++ b/packages/astro/tests/glossary/glossary.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { EMPTY_GLOSSARY, EMPTY_GLOSSARY_HASH, hashGlossary, loadGlossaries, type Glossary } from "../../src/glossary/glossary.js"; +import { EMPTY_GLOSSARY, type Glossary } from "@cloudflare/polystella-core"; +import { EMPTY_GLOSSARY_HASH, hashGlossary, loadGlossaries } from "../../src/glossary/glossary.js"; import type { PolyStellaResolvedOptions } from "../../src/config/options.js"; /** diff --git a/tests/i18n/sitemap-types.test.ts b/packages/astro/tests/i18n/sitemap-types.test.ts similarity index 100% rename from tests/i18n/sitemap-types.test.ts rename to packages/astro/tests/i18n/sitemap-types.test.ts diff --git a/tests/i18n/sitemap.test.ts b/packages/astro/tests/i18n/sitemap.test.ts similarity index 100% rename from tests/i18n/sitemap.test.ts rename to packages/astro/tests/i18n/sitemap.test.ts diff --git a/tests/i18n/sync.test.ts b/packages/astro/tests/i18n/sync.test.ts similarity index 100% rename from tests/i18n/sync.test.ts rename to packages/astro/tests/i18n/sync.test.ts diff --git a/tests/i18n/ui-drift.test.ts b/packages/astro/tests/i18n/ui-drift.test.ts similarity index 100% rename from tests/i18n/ui-drift.test.ts rename to packages/astro/tests/i18n/ui-drift.test.ts diff --git a/tests/i18n/ui-loader.test.ts b/packages/astro/tests/i18n/ui-loader.test.ts similarity index 100% rename from tests/i18n/ui-loader.test.ts rename to packages/astro/tests/i18n/ui-loader.test.ts diff --git a/tests/i18n/ui-translate-pipeline.test.ts b/packages/astro/tests/i18n/ui-translate-pipeline.test.ts similarity index 99% rename from tests/i18n/ui-translate-pipeline.test.ts rename to packages/astro/tests/i18n/ui-translate-pipeline.test.ts index 3808bfe..755e1e2 100644 --- a/tests/i18n/ui-translate-pipeline.test.ts +++ b/packages/astro/tests/i18n/ui-translate-pipeline.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { EMPTY_GLOSSARY } from "../../src/glossary/glossary.js"; -import type { Translator } from "../../src/translation/provider.js"; +import { EMPTY_GLOSSARY, type Translator } from "@cloudflare/polystella-core"; import { extractTokens, selectEmptyKeys, diff --git a/tests/i18n/ui-translate.test.ts b/packages/astro/tests/i18n/ui-translate.test.ts similarity index 100% rename from tests/i18n/ui-translate.test.ts rename to packages/astro/tests/i18n/ui-translate.test.ts diff --git a/tests/parsing/adapter-registry.test.ts b/packages/astro/tests/parsing/adapter-registry.test.ts similarity index 96% rename from tests/parsing/adapter-registry.test.ts rename to packages/astro/tests/parsing/adapter-registry.test.ts index 9e54dd6..bfcff4c 100644 --- a/tests/parsing/adapter-registry.test.ts +++ b/packages/astro/tests/parsing/adapter-registry.test.ts @@ -1,12 +1,13 @@ import { afterEach, describe, expect, it } from "vitest"; +import type { AdapterApplyOptions, AdapterExtractOptions } from "@cloudflare/polystella-adapters"; +import type { Segment } from "@cloudflare/polystella-core"; -import type { AdapterApplyOptions, AdapterExtractOptions, FileTypeAdapter } from "../../src/parsing/adapter.js"; +import type { FileTypeAdapter } from "../../src/parsing/adapter.js"; import { jsonAdapter } from "../../src/parsing/adapters/json.js"; import { markdownAdapter } from "../../src/parsing/adapters/markdown.js"; import { tomlAdapter } from "../../src/parsing/adapters/toml.js"; import { yamlAdapter } from "../../src/parsing/adapters/yaml.js"; import { getAdapter, listRegisteredExtensions, registerAdapter, resetRegistry } from "../../src/parsing/registry.js"; -import type { Segment } from "../../src/parsing/extract.js"; /** * Registry tests pin three contracts: diff --git a/tests/parsing/apply.test.ts b/packages/astro/tests/parsing/apply.test.ts similarity index 98% rename from tests/parsing/apply.test.ts rename to packages/astro/tests/parsing/apply.test.ts index 93a9c0a..635c7be 100644 --- a/tests/parsing/apply.test.ts +++ b/packages/astro/tests/parsing/apply.test.ts @@ -1,7 +1,6 @@ import type { Paragraph } from "mdast"; import { describe, expect, it } from "vitest"; -import { applyTranslations } from "../../src/parsing/apply.js"; -import { extractSegments } from "../../src/parsing/extract.js"; +import { applyTranslations, extractSegments } from "@cloudflare/polystella-adapters"; import { parseMarkdown } from "../../src/parsing/parse.js"; describe("applyTranslations", () => { diff --git a/tests/parsing/extract.test.ts b/packages/astro/tests/parsing/extract.test.ts similarity index 95% rename from tests/parsing/extract.test.ts rename to packages/astro/tests/parsing/extract.test.ts index 4968dd9..033f6e4 100644 --- a/tests/parsing/extract.test.ts +++ b/packages/astro/tests/parsing/extract.test.ts @@ -1,9 +1,18 @@ import { describe, expect, it } from "vitest"; -import { collectMarkdownSegments, extractSegments, peekNoTranslate, selectTranslatableFrontmatter } from "../../src/parsing/extract.js"; +import { collectMarkdownSegments, extractSegments, type ExtractOptions } from "@cloudflare/polystella-adapters"; +import type { Root } from "mdast"; +import { markdownAdapter } from "../../src/parsing/adapters/markdown.js"; import { parseMarkdown } from "../../src/parsing/parse.js"; import { computeSourceHash } from "../../src/storage/hash.js"; const noFrontmatterRules = { sourcePath: "test.md", frontmatter: {} }; +const peekNoTranslate = (ast: Root): boolean => markdownAdapter.peekNoTranslate(ast); +const selectTranslatableFrontmatter = (ast: Root, options: ExtractOptions): Record => + markdownAdapter.selectedValuesForHash(ast, "", { + sourcePath: options.sourcePath, + translatableKeys: options.frontmatter, + ...(options.mdxRules !== undefined ? { mdxRules: options.mdxRules } : {}), + }); describe("extractSegments — body", () => { it("emits one segment per paragraph with stable body: IDs", () => { diff --git a/tests/parsing/json-adapter.test.ts b/packages/astro/tests/parsing/json-adapter.test.ts similarity index 99% rename from tests/parsing/json-adapter.test.ts rename to packages/astro/tests/parsing/json-adapter.test.ts index 94ef897..8b549d2 100644 --- a/tests/parsing/json-adapter.test.ts +++ b/packages/astro/tests/parsing/json-adapter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { AdapterExtractOptions } from "../../src/parsing/adapter.js"; +import type { AdapterExtractOptions } from "@cloudflare/polystella-adapters"; import { jsonAdapter } from "../../src/parsing/adapters/json.js"; /** diff --git a/tests/parsing/key-paths.test.ts b/packages/astro/tests/parsing/key-paths.test.ts similarity index 99% rename from tests/parsing/key-paths.test.ts rename to packages/astro/tests/parsing/key-paths.test.ts index 448af1d..258abd9 100644 --- a/tests/parsing/key-paths.test.ts +++ b/packages/astro/tests/parsing/key-paths.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { expandPath, formatPath, parsePath, readAtPath, writeAtPath } from "../../src/parsing/key-paths.js"; +import { expandPath, formatPath, parsePath, readAtPath, writeAtPath } from "@cloudflare/polystella-adapters"; /** * Key-path utilities — unit-level tests. The TOML / JSON / YAML diff --git a/tests/parsing/markdown-adapter.test.ts b/packages/astro/tests/parsing/markdown-adapter.test.ts similarity index 96% rename from tests/parsing/markdown-adapter.test.ts rename to packages/astro/tests/parsing/markdown-adapter.test.ts index 13e0e27..d6875c0 100644 --- a/tests/parsing/markdown-adapter.test.ts +++ b/packages/astro/tests/parsing/markdown-adapter.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { remarkMarkdownParser } from "@cloudflare/polystella-adapters"; +import type { Segment } from "@cloudflare/polystella-core"; +import { describe, expect, it, vi } from "vitest"; import { markdownAdapter } from "../../src/parsing/adapters/markdown.js"; -import type { Segment } from "../../src/parsing/extract.js"; /** * Markdown adapter — `rewriteUrls` covers FRONTMATTER URL fields @@ -15,6 +16,15 @@ import type { Segment } from "../../src/parsing/extract.js"; const localePrefix = (url: string) => (url.startsWith("/") ? `/pt-BR${url}` : `/pt-BR/${url}`); +describe("markdownAdapter — parser selection", () => { + it("passes the configured Remark parser through the Astro wrapper", () => { + const parseMarkdown = vi.spyOn(remarkMarkdownParser, "parseMarkdown"); + markdownAdapter.parse("# Hello\n", "docs/sample.md", { markdownParser: "remark" }); + expect(parseMarkdown).toHaveBeenCalledOnce(); + parseMarkdown.mockRestore(); + }); +}); + describe("markdownAdapter — rewriteUrls (frontmatter)", () => { it("rewrites a single URL key in frontmatter", () => { const source = ["---", 'title: "Hello"', "heroImage: /images/hero.png", "---", "", "Body content."].join("\n"); diff --git a/tests/parsing/mdx-rules.test.ts b/packages/astro/tests/parsing/mdx-rules.test.ts similarity index 95% rename from tests/parsing/mdx-rules.test.ts rename to packages/astro/tests/parsing/mdx-rules.test.ts index ad3cf27..66e7a4f 100644 --- a/tests/parsing/mdx-rules.test.ts +++ b/packages/astro/tests/parsing/mdx-rules.test.ts @@ -1,12 +1,8 @@ import { describe, expect, it } from "vitest"; +import { MDX_RULES_VERSION } from "@cloudflare/polystella-adapters"; import { resolveOptions } from "../../src/config/options.js"; -import { - computeMdxRulesPolicyHash, - DEFAULT_MDX_HTML_ATTRIBUTES, - MDX_RULES_VERSION, - normalizeMdxRulesForSource, -} from "../../src/parsing/mdx-rules.js"; +import { computeMdxRulesPolicyHash, DEFAULT_MDX_HTML_ATTRIBUTES, normalizeMdxRulesForSource } from "../../src/parsing/mdx-rules.js"; const I18N = { defaultLocale: "en-US", diff --git a/tests/parsing/mdx.test.ts b/packages/astro/tests/parsing/mdx.test.ts similarity index 100% rename from tests/parsing/mdx.test.ts rename to packages/astro/tests/parsing/mdx.test.ts diff --git a/tests/parsing/parse.test.ts b/packages/astro/tests/parsing/parse.test.ts similarity index 72% rename from tests/parsing/parse.test.ts rename to packages/astro/tests/parsing/parse.test.ts index ff7d225..bc3689d 100644 --- a/tests/parsing/parse.test.ts +++ b/packages/astro/tests/parsing/parse.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { parseMarkdown } from "../../src/parsing/parse.js"; describe("parseMarkdown", () => { @@ -50,4 +50,25 @@ describe("parseMarkdown", () => { expect(ast.children[0]?.type).toBe("table"); }); + + it("falls back to Remark when Satteri cannot parse MDX", async () => { + vi.resetModules(); + vi.doMock("satteri", () => ({ + markdownToMdast: vi.fn(), + mdxToMdast: vi.fn(() => { + throw new Error("unsupported syntax"); + }), + })); + + try { + const { parseMdx } = await import("../../src/parsing/parse.js"); + const ast = parseMdx("Fallback\n"); + expect(ast.children[0]).toMatchObject({ + type: "paragraph", + children: [{ type: "mdxJsxTextElement" }], + }); + } finally { + vi.doUnmock("satteri"); + } + }); }); diff --git a/tests/parsing/rewrite-links.test.ts b/packages/astro/tests/parsing/rewrite-links.test.ts similarity index 100% rename from tests/parsing/rewrite-links.test.ts rename to packages/astro/tests/parsing/rewrite-links.test.ts diff --git a/tests/parsing/round-trip.test.ts b/packages/astro/tests/parsing/round-trip.test.ts similarity index 94% rename from tests/parsing/round-trip.test.ts rename to packages/astro/tests/parsing/round-trip.test.ts index 655d917..841c968 100644 --- a/tests/parsing/round-trip.test.ts +++ b/packages/astro/tests/parsing/round-trip.test.ts @@ -2,8 +2,7 @@ import { readdirSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { applyTranslations } from "../../src/parsing/apply.js"; -import { extractSegments } from "../../src/parsing/extract.js"; +import { applyTranslations, extractSegments } from "@cloudflare/polystella-adapters"; import { parseMarkdown } from "../../src/parsing/parse.js"; /** diff --git a/tests/parsing/spot.test.ts b/packages/astro/tests/parsing/spot.test.ts similarity index 98% rename from tests/parsing/spot.test.ts rename to packages/astro/tests/parsing/spot.test.ts index 8f436cb..0e48934 100644 --- a/tests/parsing/spot.test.ts +++ b/packages/astro/tests/parsing/spot.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; -import { applyTranslations } from "../../src/parsing/apply.js"; -import { extractSegments } from "../../src/parsing/extract.js"; +import { applyTranslations, extractSegments } from "@cloudflare/polystella-adapters"; import { parseMarkdown } from "../../src/parsing/parse.js"; /** diff --git a/tests/parsing/toml-adapter.test.ts b/packages/astro/tests/parsing/toml-adapter.test.ts similarity index 99% rename from tests/parsing/toml-adapter.test.ts rename to packages/astro/tests/parsing/toml-adapter.test.ts index 6550fac..4121175 100644 --- a/tests/parsing/toml-adapter.test.ts +++ b/packages/astro/tests/parsing/toml-adapter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { AdapterExtractOptions } from "../../src/parsing/adapter.js"; +import type { AdapterExtractOptions } from "@cloudflare/polystella-adapters"; import { tomlAdapter } from "../../src/parsing/adapters/toml.js"; /** diff --git a/tests/parsing/yaml-adapter.test.ts b/packages/astro/tests/parsing/yaml-adapter.test.ts similarity index 99% rename from tests/parsing/yaml-adapter.test.ts rename to packages/astro/tests/parsing/yaml-adapter.test.ts index 91d9bd5..ddd9f7c 100644 --- a/tests/parsing/yaml-adapter.test.ts +++ b/packages/astro/tests/parsing/yaml-adapter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { parse as parseYaml } from "yaml"; -import type { AdapterExtractOptions } from "../../src/parsing/adapter.js"; +import type { AdapterExtractOptions } from "@cloudflare/polystella-adapters"; import { yamlAdapter } from "../../src/parsing/adapters/yaml.js"; /** diff --git a/tests/recipes/recipes.test.ts b/packages/astro/tests/recipes/recipes.test.ts similarity index 100% rename from tests/recipes/recipes.test.ts rename to packages/astro/tests/recipes/recipes.test.ts diff --git a/tests/routing/expand-routes.test.ts b/packages/astro/tests/routing/expand-routes.test.ts similarity index 100% rename from tests/routing/expand-routes.test.ts rename to packages/astro/tests/routing/expand-routes.test.ts diff --git a/tests/routing/routing.test.ts b/packages/astro/tests/routing/routing.test.ts similarity index 100% rename from tests/routing/routing.test.ts rename to packages/astro/tests/routing/routing.test.ts diff --git a/tests/routing/walk-pages.test.ts b/packages/astro/tests/routing/walk-pages.test.ts similarity index 100% rename from tests/routing/walk-pages.test.ts rename to packages/astro/tests/routing/walk-pages.test.ts diff --git a/tests/runtime/custom-loader-runtime.test.ts b/packages/astro/tests/runtime/custom-loader-runtime.test.ts similarity index 99% rename from tests/runtime/custom-loader-runtime.test.ts rename to packages/astro/tests/runtime/custom-loader-runtime.test.ts index e9deb03..f6a4379 100644 --- a/tests/runtime/custom-loader-runtime.test.ts +++ b/packages/astro/tests/runtime/custom-loader-runtime.test.ts @@ -13,8 +13,7 @@ import { type PolystellaRuntimeBridge, } from "../../src/runtime/custom-loader-runtime.js"; import type { CapturedEntry, PolystellaCustomLoaderMarker } from "../../src/content/custom-loader.js"; -import { EMPTY_GLOSSARY } from "../../src/glossary/glossary.js"; -import type { Translator } from "../../src/translation/provider.js"; +import { EMPTY_GLOSSARY, type Translator } from "@cloudflare/polystella-core"; import type { R2Client } from "../../src/storage/r2.js"; /** diff --git a/tests/runtime/get-localized-collection.test.ts b/packages/astro/tests/runtime/get-localized-collection.test.ts similarity index 100% rename from tests/runtime/get-localized-collection.test.ts rename to packages/astro/tests/runtime/get-localized-collection.test.ts diff --git a/tests/runtime/get-localized-collection.types.test.ts b/packages/astro/tests/runtime/get-localized-collection.types.test.ts similarity index 100% rename from tests/runtime/get-localized-collection.types.test.ts rename to packages/astro/tests/runtime/get-localized-collection.types.test.ts diff --git a/tests/runtime/localized-href.test.ts b/packages/astro/tests/runtime/localized-href.test.ts similarity index 100% rename from tests/runtime/localized-href.test.ts rename to packages/astro/tests/runtime/localized-href.test.ts diff --git a/tests/runtime/middleware.test.ts b/packages/astro/tests/runtime/middleware.test.ts similarity index 100% rename from tests/runtime/middleware.test.ts rename to packages/astro/tests/runtime/middleware.test.ts diff --git a/tests/runtime/runtime.test.ts b/packages/astro/tests/runtime/runtime.test.ts similarity index 100% rename from tests/runtime/runtime.test.ts rename to packages/astro/tests/runtime/runtime.test.ts diff --git a/tests/smoke.test.ts b/packages/astro/tests/smoke.test.ts similarity index 99% rename from tests/smoke.test.ts rename to packages/astro/tests/smoke.test.ts index 6c8bc09..86fb75a 100644 --- a/tests/smoke.test.ts +++ b/packages/astro/tests/smoke.test.ts @@ -25,7 +25,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import polystella, { POLYSTELLA_VERSION } from "../src/index.js"; -import type { Translator } from "../src/translation/provider.js"; +import type { Translator } from "@cloudflare/polystella-core"; interface CapturedRoute { pattern: string; diff --git a/tests/source/overrides.test.ts b/packages/astro/tests/source/overrides.test.ts similarity index 100% rename from tests/source/overrides.test.ts rename to packages/astro/tests/source/overrides.test.ts diff --git a/tests/source/pool.test.ts b/packages/astro/tests/source/pool.test.ts similarity index 100% rename from tests/source/pool.test.ts rename to packages/astro/tests/source/pool.test.ts diff --git a/tests/source/walk.test.ts b/packages/astro/tests/source/walk.test.ts similarity index 100% rename from tests/source/walk.test.ts rename to packages/astro/tests/source/walk.test.ts diff --git a/tests/storage/cache.test.ts b/packages/astro/tests/storage/cache.test.ts similarity index 99% rename from tests/storage/cache.test.ts rename to packages/astro/tests/storage/cache.test.ts index 125d5cb..5917b6f 100644 --- a/tests/storage/cache.test.ts +++ b/packages/astro/tests/storage/cache.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { buildCacheMetadata, translateOrLoadFromCache, type TranslateOrLoadOptions } from "../../src/storage/cache.js"; -import { applyTranslations } from "../../src/parsing/apply.js"; -import { extractSegments, type Segment } from "../../src/parsing/extract.js"; -import { EMPTY_GLOSSARY, hashGlossary, type Glossary } from "../../src/glossary/glossary.js"; +import { applyTranslations, extractSegments } from "@cloudflare/polystella-adapters"; +import { EMPTY_GLOSSARY, type Glossary, type Segment, type Translator } from "@cloudflare/polystella-core"; +import { hashGlossary } from "../../src/glossary/glossary.js"; import { computeSourceHash } from "../../src/storage/hash.js"; import { parseMarkdown, parseMdx } from "../../src/parsing/parse.js"; import { buildR2Key, type R2Client, type R2GetResult } from "../../src/storage/r2.js"; -import type { Translator } from "../../src/translation/provider.js"; /** * Cache-aware orchestrator tests. diff --git a/tests/storage/hash.test.ts b/packages/astro/tests/storage/hash.test.ts similarity index 100% rename from tests/storage/hash.test.ts rename to packages/astro/tests/storage/hash.test.ts diff --git a/tests/storage/local-cache.test.ts b/packages/astro/tests/storage/local-cache.test.ts similarity index 100% rename from tests/storage/local-cache.test.ts rename to packages/astro/tests/storage/local-cache.test.ts diff --git a/tests/storage/prune.test.ts b/packages/astro/tests/storage/prune.test.ts similarity index 100% rename from tests/storage/prune.test.ts rename to packages/astro/tests/storage/prune.test.ts diff --git a/tests/storage/r2.test.ts b/packages/astro/tests/storage/r2.test.ts similarity index 100% rename from tests/storage/r2.test.ts rename to packages/astro/tests/storage/r2.test.ts diff --git a/tests/storage/report.test.ts b/packages/astro/tests/storage/report.test.ts similarity index 100% rename from tests/storage/report.test.ts rename to packages/astro/tests/storage/report.test.ts diff --git a/tests/translation/batch.test.ts b/packages/astro/tests/translation/batch.test.ts similarity index 98% rename from tests/translation/batch.test.ts rename to packages/astro/tests/translation/batch.test.ts index d20d926..7598823 100644 --- a/tests/translation/batch.test.ts +++ b/packages/astro/tests/translation/batch.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import type { Segment } from "../../src/parsing/extract.js"; -import { DEFAULT_INPUT_TOKEN_BUDGET, estimateInputTokens, packGroupsIntoBatches } from "../../src/translation/batch.js"; +import { DEFAULT_INPUT_TOKEN_BUDGET, estimateInputTokens, packGroupsIntoBatches, type Segment } from "@cloudflare/polystella-core"; /** * Unit tests for the token-aware batching primitive. diff --git a/tests/translation/prompt.test.ts b/packages/astro/tests/translation/prompt.test.ts similarity index 98% rename from tests/translation/prompt.test.ts rename to packages/astro/tests/translation/prompt.test.ts index b8ec1a8..bb9b2e9 100644 --- a/tests/translation/prompt.test.ts +++ b/packages/astro/tests/translation/prompt.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { Segment } from "../../src/parsing/extract.js"; -import { EMPTY_GLOSSARY, type Glossary } from "../../src/glossary/glossary.js"; -import { buildPrompt, parseResponse } from "../../src/translation/prompt.js"; +import { buildPrompt, EMPTY_GLOSSARY, parseResponse, type Glossary, type Segment } from "@cloudflare/polystella-core"; const sampleSegments: Segment[] = [ { id: "fm:title", text: "An apology for outdated cryptography" }, diff --git a/tests/translation/provider.test.ts b/packages/astro/tests/translation/provider.test.ts similarity index 99% rename from tests/translation/provider.test.ts rename to packages/astro/tests/translation/provider.test.ts index 160618f..63f35b2 100644 --- a/tests/translation/provider.test.ts +++ b/packages/astro/tests/translation/provider.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it, vi } from "vitest"; -import type { Segment } from "../../src/parsing/extract.js"; -import { EMPTY_GLOSSARY } from "../../src/glossary/glossary.js"; import { - createTranslator, + EMPTY_GLOSSARY, PermanentProviderError, resolveModelId, translateBatch, + type Segment, type Translator, -} from "../../src/translation/provider.js"; +} from "@cloudflare/polystella-core"; +import { createTranslator } from "../../src/translation/provider.js"; /** * Build a fetch stub that returns a single canned response. Each test diff --git a/tests/translation/run.test.ts b/packages/astro/tests/translation/run.test.ts similarity index 99% rename from tests/translation/run.test.ts rename to packages/astro/tests/translation/run.test.ts index e61a9ec..59c4d9d 100644 --- a/tests/translation/run.test.ts +++ b/packages/astro/tests/translation/run.test.ts @@ -5,8 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { resolveOptions } from "../../src/config/options.js"; import { runTranslationPass } from "../../src/translation/run.js"; -import type { Logger } from "../../src/translation/run.js"; -import type { Translator } from "../../src/translation/provider.js"; +import type { Logger, Translator } from "@cloudflare/polystella-core"; import type { R2Client, R2GetResult } from "../../src/storage/r2.js"; /** @@ -322,6 +321,9 @@ describe("runTranslationPass — staging output", () => { const cached = [...r2.store.values()].at(0); if (cached === undefined) throw new Error("expected translated bytes to be cached"); const cachedBody = new TextDecoder().decode(cached.body); + expect(cachedBody).toContain("aiTranslated: true"); + expect(cachedBody).toContain("aiTranslationModel: stub/mdx-imports-1"); + expect(cachedBody).toMatch(/aiTranslatedAt: \d{4}-\d{2}-\d{2}T/); expect(cachedBody).toContain('import Callout from "../../components/Callout.astro";'); expect(cachedBody).not.toContain("../../../../src/components/Callout.astro"); }); diff --git a/tests/translation/translate-segments.test.ts b/packages/astro/tests/translation/translate-segments.test.ts similarity index 97% rename from tests/translation/translate-segments.test.ts rename to packages/astro/tests/translation/translate-segments.test.ts index c4c8b9c..311303a 100644 --- a/tests/translation/translate-segments.test.ts +++ b/packages/astro/tests/translation/translate-segments.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { EMPTY_GLOSSARY } from "../../src/glossary/glossary.js"; -import type { Segment } from "../../src/parsing/extract.js"; -import { PermanentProviderError, type Translator } from "../../src/translation/provider.js"; -import { translateSegments } from "../../src/translation/translate-segments.js"; +import { EMPTY_GLOSSARY, PermanentProviderError, translateSegments, type Segment, type Translator } from "@cloudflare/polystella-core"; /** * Unit tests for the multi-batch `translateSegments` wrapper. diff --git a/tsconfig.build.json b/packages/astro/tsconfig.build.json similarity index 100% rename from tsconfig.build.json rename to packages/astro/tsconfig.build.json diff --git a/tsconfig.json b/packages/astro/tsconfig.json similarity index 100% rename from tsconfig.json rename to packages/astro/tsconfig.json diff --git a/types-internal/astro-content.d.ts b/packages/astro/types-internal/astro-content.d.ts similarity index 100% rename from types-internal/astro-content.d.ts rename to packages/astro/types-internal/astro-content.d.ts diff --git a/vitest.config.ts b/packages/astro/vitest.config.ts similarity index 100% rename from vitest.config.ts rename to packages/astro/vitest.config.ts diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 0000000..0e4a70f --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1 @@ +# @cloudflare/polystella-core diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 0000000..dcdadd6 --- /dev/null +++ b/packages/core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Cloudflare, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..b2787f2 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,5 @@ +# @cloudflare/polystella-core + +Platform-neutral translation primitives for PolyStella. + +See the [PolyStella repository](https://github.com/cloudflare/polystella#readme) for documentation. diff --git a/packages/core/package.json b/packages/core/package.json index 640c1a3..07afef9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -33,10 +33,15 @@ } }, "files": [ - "dist" + "dist", + "src", + "CHANGELOG.md", + "README.md", + "LICENSE" ], "scripts": { "test": "vitest run", + "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.build.json" }, "dependencies": { diff --git a/packages/providers/CHANGELOG.md b/packages/providers/CHANGELOG.md new file mode 100644 index 0000000..a1b0090 --- /dev/null +++ b/packages/providers/CHANGELOG.md @@ -0,0 +1 @@ +# @cloudflare/polystella-providers diff --git a/packages/providers/LICENSE b/packages/providers/LICENSE new file mode 100644 index 0000000..dcdadd6 --- /dev/null +++ b/packages/providers/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Cloudflare, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/providers/README.md b/packages/providers/README.md new file mode 100644 index 0000000..b38e793 --- /dev/null +++ b/packages/providers/README.md @@ -0,0 +1,5 @@ +# @cloudflare/polystella-providers + +Portable Workers AI and Anthropic transports for PolyStella. + +See the [PolyStella repository](https://github.com/cloudflare/polystella#readme) for documentation. diff --git a/packages/providers/package.json b/packages/providers/package.json index 8fac5da..4f8b015 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -41,14 +41,19 @@ } }, "files": [ - "dist" + "dist", + "src", + "CHANGELOG.md", + "README.md", + "LICENSE" ], "scripts": { "test": "vitest run", + "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.build.json" }, "dependencies": { - "@cloudflare/polystella-core": "workspace:0.4.0" + "@cloudflare/polystella-core": "workspace:*" }, "devDependencies": { "typescript": "^6.0.3", diff --git a/playgrounds/mdx-jsx/package.json b/playgrounds/mdx-jsx/package.json index b7cf518..86ed2d3 100644 --- a/playgrounds/mdx-jsx/package.json +++ b/playgrounds/mdx-jsx/package.json @@ -14,9 +14,9 @@ "translate:workers-ai": "node ./scripts/translate-workers-ai.mjs" }, "dependencies": { - "@astrojs/mdx": "^7.0.0", + "@astrojs/mdx": "^7.0.8", "@cloudflare/polystella": "workspace:*", - "astro": "^7.0.0" + "astro": "^7.2.9" }, "devDependencies": { "@types/node": "^26.1.0", diff --git a/playgrounds/mdx-jsx/scripts/translate-local.mjs b/playgrounds/mdx-jsx/scripts/translate-local.mjs index f7204bb..47b09c7 100644 --- a/playgrounds/mdx-jsx/scripts/translate-local.mjs +++ b/playgrounds/mdx-jsx/scripts/translate-local.mjs @@ -132,8 +132,8 @@ function close(serverToClose) { /** @param {string} endpoint */ function runPolystellaTranslate(endpoint) { return new Promise((resolve, reject) => { - const bin = process.platform === "win32" ? "polystella.cmd" : "polystella"; - const child = spawn(bin, ["translate", "--branch", "local-mdx-jsx", "--report", "./i18n-r2-report.json"], { + const cli = path.join(playgroundRoot, "node_modules", "@cloudflare", "polystella", "dist", "cli.js"); + const child = spawn(process.execPath, [cli, "translate", "--branch", "local-mdx-jsx", "--report", "./i18n-r2-report.json"], { cwd: playgroundRoot, stdio: "inherit", env: { diff --git a/playgrounds/mdx-jsx/scripts/translate-workers-ai.mjs b/playgrounds/mdx-jsx/scripts/translate-workers-ai.mjs index cbd1274..55973b1 100644 --- a/playgrounds/mdx-jsx/scripts/translate-workers-ai.mjs +++ b/playgrounds/mdx-jsx/scripts/translate-workers-ai.mjs @@ -179,16 +179,20 @@ function stripInlineComment(raw) { /** @param {string[]} forwardedArgs */ function runPolystellaTranslate(forwardedArgs) { return new Promise((resolve, reject) => { - const bin = process.platform === "win32" ? "polystella.cmd" : "polystella"; - const child = spawn(bin, ["translate", "--branch", "workers-ai-mdx-jsx", "--report", "./i18n-r2-report.json", ...forwardedArgs], { - cwd: playgroundRoot, - stdio: "inherit", - env: { - ...process.env, - POLYSTELLA_MDX_JSX_LOADED_ENV_FILES: loadedEnvFiles.join(path.delimiter), - POLYSTELLA_MDX_JSX_WORKERS_AI_TRANSLATE: "1", + const cli = path.join(playgroundRoot, "node_modules", "@cloudflare", "polystella", "dist", "cli.js"); + const child = spawn( + process.execPath, + [cli, "translate", "--branch", "workers-ai-mdx-jsx", "--report", "./i18n-r2-report.json", ...forwardedArgs], + { + cwd: playgroundRoot, + stdio: "inherit", + env: { + ...process.env, + POLYSTELLA_MDX_JSX_LOADED_ENV_FILES: loadedEnvFiles.join(path.delimiter), + POLYSTELLA_MDX_JSX_WORKERS_AI_TRANSLATE: "1", + }, }, - }); + ); child.on("error", reject); child.on("close", (code, signal) => { if (signal !== null) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd9796e..b44694f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,71 +4,35 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + esbuild@>=0.27.3 <0.28.1: 0.28.2 + js-yaml@>=4.0.0 <4.3.1: 4.3.1 + nanoid@<3.3.18: 3.3.18 + postcss@<=8.5.22: 8.5.26 + svgo@>=4.0.0 <4.0.2: 4.1.0 + importers: .: - dependencies: - acorn: - specifier: ^8.17.0 - version: 8.17.0 - astro: - specifier: ^7.0.0 - version: 7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0) - p-retry: - specifier: ^8.0.0 - version: 8.0.0 - picomatch: - specifier: ^4.0.4 - version: 4.0.5 - react: - specifier: ^17.0.0 || ^18.0.0 || ^19.0.0 - version: 19.2.6 - remark-frontmatter: - specifier: ^5.0.0 - version: 5.0.0 - remark-gfm: - specifier: ^4.0.1 - version: 4.0.1 - remark-mdx: - specifier: ^3.1.1 - version: 3.1.1 - remark-parse: - specifier: ^11.0.0 - version: 11.0.0 - s3mini: - specifier: ^0.9.4 - version: 0.9.5 - satteri: - specifier: ^0.9.4 - version: 0.9.4 - smol-toml: - specifier: ^1.6.1 - version: 1.7.0 - unified: - specifier: ^11.0.5 - version: 11.0.5 - yaml: - specifier: ^2.8.3 - version: 2.9.0 devDependencies: - '@astrojs/sitemap': - specifier: ^3.7.3 - version: 3.7.3 '@changesets/changelog-github': specifier: ^0.7.0 version: 0.7.0 '@changesets/cli': specifier: ^2.27.0 version: 2.31.0(@types/node@26.1.0) - '@types/mdast': - specifier: ^4.0.4 - version: 4.0.4 - '@types/picomatch': - specifier: ^4.0.0 - version: 4.0.3 - '@types/react': - specifier: ^19.2.14 - version: 19.2.14 + '@cloudflare/polystella-adapters': + specifier: workspace:* + version: link:packages/adapters + '@cloudflare/polystella-core': + specifier: workspace:* + version: link:packages/core + '@cloudflare/polystella-providers': + specifier: workspace:* + version: link:packages/providers + '@cloudflare/vitest-plugin': + specifier: ^1.1.1 + version: 1.1.1(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))) pkg-pr-new: specifier: ^0.0.75 version: 0.0.75 @@ -80,7 +44,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) docs: dependencies: @@ -94,14 +58,14 @@ importers: specifier: ^1.2.0 version: 1.2.2 astro: - specifier: ^7.0.0 - version: 7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0) + specifier: ^7.2.9 + version: 7.2.9(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.1.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) astro-icon: - specifier: ^1.1.0 - version: 1.1.5 + specifier: ^1.2.0 + version: 1.2.0 astro-og-canvas: specifier: ^0.13.0 - version: 0.13.0(astro@7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0)) + version: 0.13.0(astro@7.2.9(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.1.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) canvaskit-wasm: specifier: ^0.41.1 version: 0.41.1 @@ -109,8 +73,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 nimbus-docs: - specifier: ^0.1.22 - version: 0.1.22(astro@7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0))(react@19.2.6)(shiki@4.3.1) + specifier: ^0.5.0 + version: 0.5.0(astro@7.2.9(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.1.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))(react@19.2.6) sharp: specifier: ^0.35.3 version: 0.35.3(@types/node@26.1.0) @@ -143,13 +107,13 @@ importers: specifier: ^6.0.3 version: 6.0.3 wrangler: - specifier: ^4.0.0 - version: 4.93.0 + specifier: ^4.127.0 + version: 4.127.0 packages/adapters: dependencies: '@cloudflare/polystella-core': - specifier: workspace:0.4.0 + specifier: workspace:* version: link:../core '@types/mdast': specifier: ^4.0.4 @@ -187,7 +151,62 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + + packages/astro: + dependencies: + '@cloudflare/polystella-adapters': + specifier: workspace:* + version: link:../adapters + '@cloudflare/polystella-core': + specifier: workspace:* + version: link:../core + '@cloudflare/polystella-providers': + specifier: workspace:* + version: link:../providers + acorn: + specifier: ^8.17.0 + version: 8.17.0 + p-retry: + specifier: ^8.0.0 + version: 8.0.0 + picomatch: + specifier: ^4.0.4 + version: 4.0.5 + s3mini: + specifier: ^0.9.4 + version: 0.9.5 + satteri: + specifier: ^0.9.4 + version: 0.9.4 + yaml: + specifier: ^2.8.3 + version: 2.9.0 + devDependencies: + '@astrojs/sitemap': + specifier: ^3.7.3 + version: 3.7.3 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 + '@types/picomatch': + specifier: ^4.0.0 + version: 4.0.3 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + astro: + specifier: ^7.2.9 + version: 7.2.9(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.1.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) + react: + specifier: ^19.2.6 + version: 19.2.6 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) packages/core: dependencies: @@ -200,12 +219,12 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) packages/providers: dependencies: '@cloudflare/polystella-core': - specifier: workspace:0.4.0 + specifier: workspace:* version: link:../core devDependencies: typescript: @@ -213,19 +232,19 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) playgrounds/mdx-jsx: dependencies: '@astrojs/mdx': - specifier: ^7.0.0 - version: 7.0.2(@astrojs/markdown-satteri@0.3.3)(astro@7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0)) + specifier: ^7.0.8 + version: 7.0.8(@astrojs/markdown-satteri@0.3.8)(astro@7.2.9(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.1.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) '@cloudflare/polystella': specifier: workspace:* - version: link:../.. + version: link:../../packages/astro astro: - specifier: ^7.0.0 - version: 7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0) + specifier: ^7.2.9 + version: 7.2.9(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.1.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) devDependencies: '@types/node': specifier: ^26.1.0 @@ -243,88 +262,82 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@antfu/utils@8.1.1': - resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==} - '@astrojs/check@0.9.9': resolution: {integrity: sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg==} hasBin: true peerDependencies: typescript: ^5.0.0 || ^6.0.0 - '@astrojs/compiler-binding-darwin-arm64@0.3.0': - resolution: {integrity: sha512-3n0uu+uJpnCq8b4JFi3uGDsIisAvHctxSmH+cIO9Gbei1H1Y1QXaYboXyiWJugUmprr3OEYP7+LdodzpVFzLMQ==} + '@astrojs/compiler-binding-darwin-arm64@0.4.0': + resolution: {integrity: sha512-ZVUwHundaQyFNjE6uoa0usaC0WOCitDCLS/4mdb4rOiJXwVUuKJBMxI5WMzXLWmamsXtK/Z//ifLXvV5Yeh4Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@astrojs/compiler-binding-darwin-x64@0.3.0': - resolution: {integrity: sha512-scxNGKjOBydMo1QR4LtK0FMgh7ubQomJDv953nz2msQFkPKke/0FpPv/cQM0T/kuZdReZQFU8Oz3iOrP/6WHEg==} + '@astrojs/compiler-binding-darwin-x64@0.4.0': + resolution: {integrity: sha512-FI6G8AY8u6fR1SI/QRR5yGMwtvZwP34CDmZpZ5HwJGa50UM1VISTLhqkhV4a476pmgd25X1Aur2dqw6hUnrlKA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@astrojs/compiler-binding-linux-arm64-gnu@0.3.0': - resolution: {integrity: sha512-NZrWLolVUANmrnl0zrFK/Sx5Sock1gEUT49ALfMTTCA5Ya2ec/BoJXMIg4KgE+wZcrdXJ8e+WyEhM7YLk/FJkA==} + '@astrojs/compiler-binding-linux-arm64-gnu@0.4.0': + resolution: {integrity: sha512-lB9gLFJK7m82EnjaU8nlRBEfcwGNeHidW3sSjODTUjMNaoewVuUz9fwwdY5M4jiSXIqWLH3yl6TX8FTDKA74Sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@astrojs/compiler-binding-linux-arm64-musl@0.3.0': - resolution: {integrity: sha512-PjwRmKgMFDsFhg82g0poXlIY8Qn3fMA3hXjaR0coJWJzTJsRH9ATU0j2ocigjtU1h3vL/yR7yLUxGj/lTCq73g==} + '@astrojs/compiler-binding-linux-arm64-musl@0.4.0': + resolution: {integrity: sha512-HPbvWqbxFxyaoQJhLxCaSjtYBx9KBo7JGVzEFZCmMl968a2PsSH0UfiODYgYPXofTOIsIH2aoCcrHXML0IA3ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@astrojs/compiler-binding-linux-x64-gnu@0.3.0': - resolution: {integrity: sha512-Dr69VJYlnSfyL8gzELW6S4mE41P7TDPn1IKjwMnjdZ7+dxgJI50oMLFSk1LVe26bHmWB3ktuh8fDVK1THI9e9A==} + '@astrojs/compiler-binding-linux-x64-gnu@0.4.0': + resolution: {integrity: sha512-tQKolMxoJ/+0AmLWm1PmJ/i+z3i10ZU1bNuVjEDulCf48azEMtUNjTZgHJ5MPtpYRNc7dlETr8QujUfduzoC7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@astrojs/compiler-binding-linux-x64-musl@0.3.0': - resolution: {integrity: sha512-AEt+bRw8PfImCcyRH1lpXVB8CdmQ1K/wPo5u99iec4/U/XdNvQZ715YVuNzIJpbJXelgQeZ5H2+Ea7XwRyWY5g==} + '@astrojs/compiler-binding-linux-x64-musl@0.4.0': + resolution: {integrity: sha512-5v5YymudsxMHp3NBLCS8BUlu5CRqeLtWD9cKS/4nIhIEHCbpz9okmVV6I0HWqmBAPhWYcDa3vw/vltYPrOQCTA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@astrojs/compiler-binding-wasm32-wasi@0.3.0': - resolution: {integrity: sha512-U80tA1j8V6LjhiTZzVCtG4E8hrNVVNXDGV5fCgJ94q8FU9CPH+XwdDDhLzBybfWhKfyItXmQiZNRPTiPCYTpVg==} + '@astrojs/compiler-binding-wasm32-wasi@0.4.0': + resolution: {integrity: sha512-m/phuH3x3PREvv1OnkM44NoPh4MatUadix1fB1u5SvMLCyDTUZykDJbKnWf1cjnYmHdlB8HcjTjl6JrCqAIXcw==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@astrojs/compiler-binding-win32-arm64-msvc@0.3.0': - resolution: {integrity: sha512-CpY1RII2r1XMpOUVD1VR/F2wtuRsiOCkFULS10Khyj8/DFZMtxVuUCAWGw+CW2Ka0h6eP3Xc1CA+glFlvXMPxA==} + '@astrojs/compiler-binding-win32-arm64-msvc@0.4.0': + resolution: {integrity: sha512-B9zYf3okEY83kM8gydlpH2BHP00w4ifxPqlYlWrgTwuD6wnkrJDCwBlgy1q31cERjCJRXN1lrE2VmkLvFjv/6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@astrojs/compiler-binding-win32-x64-msvc@0.3.0': - resolution: {integrity: sha512-qmFbs769oeeGrRebAnCW7aBk8m71vf85W/dX/jddfx5Z06/w0wf7TZCfJPOX1Fld2t+4N+iXzfGEJG+zJQ+bzg==} + '@astrojs/compiler-binding-win32-x64-msvc@0.4.0': + resolution: {integrity: sha512-zB0Nrv0dGc0zZWPGDRmmETTPhDRqyZjAjk+gWMlVrJX5U89obpB3VUUE1ZiHxOCN5LQojeLK6O8L/dnoHolvNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@astrojs/compiler-binding@0.3.0': - resolution: {integrity: sha512-zlsOT5COD9hRwplJCgQhS21unxON5AKirf0vgt1ijXwuseYIaZdm2ZOpF8fsz+DY9EyXx+I/ukxtg7uoBep68A==} + '@astrojs/compiler-binding@0.4.0': + resolution: {integrity: sha512-x2RjDUuWfwLNtc3mjAdSRInwqh/rqbLar9cm/5FOMbHvmYZB7yfKewzSclAxWjIZsypJDXv1lhaP2WG+P8TK3g==} engines: {node: ^20.19.0 || >=22.12.0} - '@astrojs/compiler-rs@0.3.0': - resolution: {integrity: sha512-J2qEVHtIDjEM9TxwmwuebOGmZNwhKu/dR7P7qBpnJKGmBBX0vdweQ/4cEXhj8fBbWVUB5V12xWChri3CgKNULQ==} + '@astrojs/compiler-rs@0.4.0': + resolution: {integrity: sha512-koVikeon1kreEy+/JzLQRy3vzHHQVOjycs4degg4vFufKApZOwMZvSSAEztYNhmcQVfNVsVZZI4cEge3cexAbQ==} engines: {node: '>=22.12.0'} '@astrojs/compiler@2.13.1': resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==} - '@astrojs/internal-helpers@0.10.0': - resolution: {integrity: sha512-Ry2R3VPeIN4uPCSA4xQc+e+vsJXkalKpEbDc07hV+a/o5Bs2N/s/uDcPJH/05L19DKh9tAy7e6JM3YZ6Cxfezw==} - - '@astrojs/internal-helpers@0.10.1': - resolution: {integrity: sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q==} + '@astrojs/internal-helpers@0.10.4': + resolution: {integrity: sha512-nozZSy/mKYLqe4YrqbKtdOszedAfXYCtw3wZ0d+CAjz4GqQ4L9rl1ltIL5BlgwmYVinJg/RZ0MgGuWOdlyRZlA==} '@astrojs/language-server@2.16.9': resolution: {integrity: sha512-L9kddTg+ZSO3X0Pwfx0ZPO+Z+eSSq0/39jXRyIkHzcBICzusdn2T464R4P6K0WcDZ6pMkLlFpuGS73u1pOnMSw==} @@ -338,30 +351,14 @@ packages: prettier-plugin-astro: optional: true - '@astrojs/markdown-remark@7.2.0': - resolution: {integrity: sha512-+YxmVQu1Bd+MFfSzjq1rOJvD9+nIOJzz5YIIhdIH01RrxRkKbyKoEgyIqP3yv51MhzMDgd79QaPv+kCVPT8vHw==} - - '@astrojs/markdown-remark@7.2.1': - resolution: {integrity: sha512-jPVNIqTvk+yKviikszv/Y1U4jGUSKpp/Nw48QZV4qjWgp70j4Lkq3lhSDRbWwCfgKvEyO9GHuVbV1dM2WYXy1w==} - - '@astrojs/markdown-satteri@0.2.1': - resolution: {integrity: sha512-rsdT8ucMLmu1v/POYApJcfH/t30Yuae6pZdfFU0yG3Kya8pwNItcJ6TLqUACRlRV9W50g4gLIxCwwfWjKsJ67w==} + '@astrojs/markdown-remark@7.2.4': + resolution: {integrity: sha512-MvspGMynWKAjTe4/lTUdmBPHIFKNVLTCF6UlyWGogTGzNrTvjD+D4n48k7h8swxsEPKHK2TwxkZO7uoaCv1Pow==} - '@astrojs/markdown-satteri@0.3.3': - resolution: {integrity: sha512-Lje33Ittd8UQGgbIIWQvhPkj5X5c4b1sZnZWX3JQV/AWpfbuQGxVi2ONt6+ScydcwfR4egilslEWyczMclrJ1g==} - - '@astrojs/mdx@6.0.1': - resolution: {integrity: sha512-J5K8F7A1LMH+cj+dcxm+uAeIznkfwxMcRpG7DD6ABNDOt8da98ph6ie4TD+4FV/ojVOJK0PQGWY4hmxQORLv0w==} - engines: {node: '>=22.12.0'} - peerDependencies: - '@astrojs/markdown-satteri': 0.2.1 - astro: ^6.4.0 - peerDependenciesMeta: - '@astrojs/markdown-satteri': - optional: true + '@astrojs/markdown-satteri@0.3.8': + resolution: {integrity: sha512-n8ItpFTCmlDsVR5+rwDmehSf+jFCYLWmZiisZNGuF7xILqYhsVeBfcha4qgS2Seq3fAc9Tm58ZVrvqFQ6RQgRQ==} - '@astrojs/mdx@7.0.2': - resolution: {integrity: sha512-l+sJY5U1KkGZUdr+bIL4Y6BefeS549qoSHVSkUSs6A9INwdCND+/0+vN0NroPBXwl5Vcg5u78t7VQRsJjePxbw==} + '@astrojs/mdx@7.0.8': + resolution: {integrity: sha512-RNuwq2ccTSi7NX9YqlR0noaWoVdOrZuJvdfWXotAzdhMVB0b0zEMLnNU+xar7le0GwTMlTObD/QAu8jeHA/3nA==} engines: {node: '>=22.12.0'} peerDependencies: '@astrojs/markdown-satteri': ^0.3.1 @@ -377,8 +374,8 @@ packages: '@astrojs/sitemap@3.7.3': resolution: {integrity: sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==} - '@astrojs/telemetry@3.3.2': - resolution: {integrity: sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==} + '@astrojs/telemetry@3.3.3': + resolution: {integrity: sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} '@astrojs/yaml2ts@0.2.4': @@ -405,8 +402,8 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@bruits/satteri-darwin-arm64@0.6.3': - resolution: {integrity: sha512-oKgMfpmNzQ8vaqmkE37PBu8tOyVjoOc4s+DV2tLpWvO6WO467qn/+Nbcirm/ceer7wUM8v4vMLGcZO0gkRzBEg==} + '@bruits/satteri-darwin-arm64@0.10.5': + resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} cpu: [arm64] os: [darwin] @@ -415,8 +412,8 @@ packages: cpu: [arm64] os: [darwin] - '@bruits/satteri-darwin-x64@0.6.3': - resolution: {integrity: sha512-9I5pbwZRWH5LvhoCtwpRr4rYSDe43/dLvps6zO70ipVF2XbH4rJ20T+EfvPcmou5jsWMsq9Ybn5GX3PwlSrBaw==} + '@bruits/satteri-darwin-x64@0.10.5': + resolution: {integrity: sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==} cpu: [x64] os: [darwin] @@ -425,20 +422,32 @@ packages: cpu: [x64] os: [darwin] + '@bruits/satteri-linux-arm64-gnu@0.10.5': + resolution: {integrity: sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@bruits/satteri-linux-arm64-gnu@0.9.4': resolution: {integrity: sha512-gJxU9rGGoqIznSEgEzpjxkry24jeHuMpoo1tCIAhHYh7WaD3j5F8zt3jmHxEaN1Uwa+K5+wFgIR2uIGOnMzEmw==} cpu: [arm64] os: [linux] libc: [glibc] + '@bruits/satteri-linux-arm64-musl@0.10.5': + resolution: {integrity: sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@bruits/satteri-linux-arm64-musl@0.9.4': resolution: {integrity: sha512-Wjzu9hmmAbfmDkBfPI1VdZygJtYz9uYZQnkEyrXi6S2JFi+2pXQ1A5irj38bqm0IZmWcTbk0cVG4NZnPdtVNJA==} cpu: [arm64] os: [linux] libc: [musl] - '@bruits/satteri-linux-x64-gnu@0.6.3': - resolution: {integrity: sha512-aFfw2DL2HpIcAQ8I3ZEtKuz+/GoF0H0sq387jAlvr00Q7buiiFbvARFuQlvTk00N7u3SJIh/3+YkKssTJDT+yQ==} + '@bruits/satteri-linux-x64-gnu@0.10.5': + resolution: {integrity: sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==} cpu: [x64] os: [linux] libc: [glibc] @@ -449,14 +458,20 @@ packages: os: [linux] libc: [glibc] + '@bruits/satteri-linux-x64-musl@0.10.5': + resolution: {integrity: sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==} + cpu: [x64] + os: [linux] + libc: [musl] + '@bruits/satteri-linux-x64-musl@0.9.4': resolution: {integrity: sha512-T4gxhXve3zyNAZesrXAd/rDZOGRkbfFIUFld4TGsw6BsjoIteCcDji6IMqeXyaWEVSykY2X8Eid2hr6aXGYAaw==} cpu: [x64] os: [linux] libc: [musl] - '@bruits/satteri-wasm32-wasi@0.6.3': - resolution: {integrity: sha512-DdpfnJ+04Mb4YtHaxAeETUvhdxFKg3URnroGY39FvweJEgeXx8cFNutuF5w904BhqaiblhmF76AoBnJwNLxsXg==} + '@bruits/satteri-wasm32-wasi@0.10.5': + resolution: {integrity: sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==} engines: {node: '>=14.0.0'} cpu: [wasm32] @@ -465,13 +480,18 @@ packages: engines: {node: '>=14.0.0'} cpu: [wasm32] + '@bruits/satteri-win32-arm64-msvc@0.10.5': + resolution: {integrity: sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==} + cpu: [arm64] + os: [win32] + '@bruits/satteri-win32-arm64-msvc@0.9.4': resolution: {integrity: sha512-E1ZPQbgCtFKiU7pFYVndynvY7ne4coeVDUgnVThErSFlJ2ceQCBZrfRTD1lzrIDy63Bbqo+g/cZY9duw+JYjIw==} cpu: [arm64] os: [win32] - '@bruits/satteri-win32-x64-msvc@0.6.3': - resolution: {integrity: sha512-J/CZqACnBbv77eImx/JeO5RmCuCyliihiC81u3M4VobA8eupsygaPen3/UFFqf3yfeHvMlE3myilouh/2iHMOA==} + '@bruits/satteri-win32-x64-msvc@0.10.5': + resolution: {integrity: sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==} cpu: [x64] os: [win32] @@ -572,32 +592,39 @@ packages: workerd: optional: true - '@cloudflare/workerd-darwin-64@1.20260518.1': - resolution: {integrity: sha512-IhZEf5kDd0CLRtFxGS9AUqfM5SY3EFScqqCY1VF9twNMdYpJDYrDZDJAkQitHF8sF/sPVVHYR4Aifpdq6tzmaA==} + '@cloudflare/vitest-plugin@1.1.1': + resolution: {integrity: sha512-Fqnh9JHOsqhMzqXPHMxOXyVdgZcHnzIwp+doprGRo+CIdLKGWNryFcU95iS9dJ2z9XQz1I1JrNbW6bB5wfeqTA==} + peerDependencies: + '@vitest/runner': ^4.1.0 + '@vitest/snapshot': ^4.1.0 + vitest: ^4.1.0 + + '@cloudflare/workerd-darwin-64@1.20260826.1': + resolution: {integrity: sha512-8UsGGY8ZUiYHOWdsxBlNsGmaHBGArVwJ3CM4nWpfBhthjjYe4M/OqrTpqKF7NNWb63qQiv1d8Z+Z6/hmUqNKIQ==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260518.1': - resolution: {integrity: sha512-uqlNP1psd8SWfN1Lg5p8ePv8/piOOXt+ycvb8+NQopXECGeh9+PQ/yr/IQjpurxBhYpvSaMC+vEeihejahjkJg==} + '@cloudflare/workerd-darwin-arm64@1.20260826.1': + resolution: {integrity: sha512-0bLqVQYsQ3v3FdYGmzh23vi9fJeYTBx19o4LUySIsRcgBggGSlR39ml162vTXvZzUISOjVW2qfL7Y+SMrrfXmQ==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260518.1': - resolution: {integrity: sha512-D9p8Hl0lIQ46nYs4fQZp5F+9hhvgOcQJTF1SMQWpAxQSS5f8oX+vL5YdCrETUYnyoaoyEQETtkRrWYKJkPTFeg==} + '@cloudflare/workerd-linux-64@1.20260826.1': + resolution: {integrity: sha512-DTC0yWzybX4gUH5Q1pJo3UwEQjp0Gmz0Q71I+39xT9SXBesX5QndOIQv45yHQ86Z84EzK2WwaENdlpmDSvJKmw==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260518.1': - resolution: {integrity: sha512-+vNRkuOp9E/uRKHgQXVDUBPF5cwtTeXK6+ucLK50QUFzMYycqVl8kTFN2b//BX2H5BI4bjMRhXoBpe/zAlGRWQ==} + '@cloudflare/workerd-linux-arm64@1.20260826.1': + resolution: {integrity: sha512-PFerWi+DP2Ckc6eATAS4dhotBt8IeXJjTzIgy18H+uqsswlXc7A8HsnAunHL9v/7/BjCgq46iMo2g4iUAsEpDw==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260518.1': - resolution: {integrity: sha512-tnqofUq+ZvKliQHhboygbH7iy/Zm/MaCCotIlrqVj5a988+tPtndxyLM0r4vaAIC10iy/2LWCkwnE67VFTFiUA==} + '@cloudflare/workerd-windows-64@1.20260826.1': + resolution: {integrity: sha512-X26hulrG2MSSfpRmjZbCq98LNaZnrRqbmGcgo7g1U/U0nJ5npYruPm3fAyZ2y6VDr5CmQo9BLCahxP8VB/QR6A==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -606,6 +633,9 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@cyberalien/svg-utils@1.2.19': + resolution: {integrity: sha512-paDJoDu+LhuH5Ma1q0BDqBpg6d16Xk22cZZrlg/s/J9oB8KgnnpU/snaEETJu2G1pwYcWQINUz6WUqu0Wm9k6A==} + '@emmetio/abbreviation@2.3.3': resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} @@ -630,335 +660,326 @@ packages: '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - '@emnapi/core@1.9.1': - resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} - - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} - '@emnapi/runtime@1.9.1': - resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} - - '@emnapi/wasi-threads@1.2.0': - resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.28.0': - resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.28.0': - resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-arm@0.28.0': - resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.28.0': - resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.28.0': - resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.28.0': - resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.28.0': - resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.0': - resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.28.0': - resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.28.0': - resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.28.0': - resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.28.0': - resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.28.0': - resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.28.0': - resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.28.0': - resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.28.0': - resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.28.0': - resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.28.0': - resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.0': - resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.28.0': - resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.0': - resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.28.0': - resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.28.0': - resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.28.0': - resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.28.0': - resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.28.0': - resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -972,22 +993,22 @@ packages: '@iconify-json/ph@1.2.2': resolution: {integrity: sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==} - '@iconify/tools@4.2.0': - resolution: {integrity: sha512-WRxPva/ipxYkqZd1+CkEAQmd86dQmrwH0vwK89gmp2Kh2WyyVw57XbPng0NehP3x4V1LzLsXUneP1uMfTMZmUA==} + '@iconify/tools@5.0.14': + resolution: {integrity: sha512-wJ4ZE3/HZh2iBqLwG+8FOMHe7CMJAJqwSXZSpPwMdj1gZM3Z4t2hbQMVw4Vm45oQ985qn0g89PUIcDEdKTheBA==} '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@iconify/utils@2.3.0': - resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==} + '@iconify/utils@3.1.4': + resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] @@ -997,9 +1018,15 @@ packages: cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] @@ -1009,13 +1036,29 @@ packages: cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-freebsd-wasm32@0.35.3': resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} cpu: [arm64] os: [darwin] @@ -1024,8 +1067,13 @@ packages: cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} cpu: [x64] os: [darwin] @@ -1034,8 +1082,13 @@ packages: cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} cpu: [arm64] os: [linux] libc: [glibc] @@ -1046,8 +1099,14 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} cpu: [arm] os: [linux] libc: [glibc] @@ -1058,8 +1117,14 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} cpu: [ppc64] os: [linux] libc: [glibc] @@ -1070,8 +1135,14 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} cpu: [riscv64] os: [linux] libc: [glibc] @@ -1082,8 +1153,14 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} cpu: [s390x] os: [linux] libc: [glibc] @@ -1094,8 +1171,14 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} cpu: [x64] os: [linux] libc: [glibc] @@ -1106,8 +1189,14 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} cpu: [arm64] os: [linux] libc: [musl] @@ -1118,8 +1207,14 @@ packages: os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} cpu: [x64] os: [linux] libc: [musl] @@ -1130,9 +1225,15 @@ packages: os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] @@ -1144,9 +1245,16 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] @@ -1158,9 +1266,16 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] @@ -1172,9 +1287,16 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] @@ -1186,9 +1308,16 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] @@ -1200,9 +1329,16 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] @@ -1214,9 +1350,16 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] @@ -1228,9 +1371,16 @@ packages: os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] @@ -1242,23 +1392,43 @@ packages: os: [linux] libc: [musl] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} '@img/sharp-wasm32@0.35.3': resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} engines: {node: '>=20.9.0'} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-webcontainers-wasm32@0.35.3': resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] @@ -1268,9 +1438,15 @@ packages: cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] @@ -1280,9 +1456,15 @@ packages: cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -1292,6 +1474,12 @@ packages: cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -1301,10 +1489,6 @@ packages: '@types/node': optional: true - '@isaacs/fs-minipass@4.0.1': - resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} - engines: {node: '>=18.0.0'} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1339,6 +1523,13 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1428,223 +1619,76 @@ packages: '@rolldown/binding-linux-arm-gnueabihf@1.1.4': resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.1.4': - resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-musl@1.1.4': - resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-ppc64-gnu@1.1.4': - resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-s390x-gnu@1.1.4': - resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.1.4': - resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-musl@1.1.4': - resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.1.4': - resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.1.4': - resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.4': - resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.1.4': - resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.1': - resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/rollup-android-arm-eabi@4.60.4': - resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.60.4': - resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.60.4': - resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.60.4': - resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.60.4': - resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.60.4': - resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.60.4': - resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.60.4': - resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.60.4': - resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.60.4': - resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.60.4': - resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.60.4': - resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} - cpu: [loong64] + cpu: [arm] os: [linux] - libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.60.4': - resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} - cpu: [ppc64] + '@rolldown/binding-linux-arm64-gnu@1.1.4': + resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.60.4': - resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} - cpu: [ppc64] + '@rolldown/binding-linux-arm64-musl@1.1.4': + resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.60.4': - resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} - cpu: [riscv64] + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.60.4': - resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.60.4': - resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + '@rolldown/binding-linux-s390x-gnu@1.1.4': + resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.60.4': - resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + '@rolldown/binding-linux-x64-gnu@1.1.4': + resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.60.4': - resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + '@rolldown/binding-linux-x64-musl@1.1.4': + resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.60.4': - resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.60.4': - resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + '@rolldown/binding-openharmony-arm64@1.1.4': + resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.4': - resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} - cpu: [arm64] - os: [win32] + '@rolldown/binding-wasm32-wasi@1.1.4': + resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] - '@rollup/rollup-win32-ia32-msvc@4.60.4': - resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} - cpu: [ia32] + '@rolldown/binding-win32-arm64-msvc@1.1.4': + resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.4': - resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + '@rolldown/binding-win32-x64-msvc@1.1.4': + resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.4': - resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} - cpu: [x64] - os: [win32] + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} '@shikijs/core@4.3.1': resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==} @@ -1798,15 +1842,15 @@ packages: '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1843,9 +1887,6 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} @@ -1980,20 +2021,21 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true - astro-icon@1.1.5: - resolution: {integrity: sha512-CJYS5nWOw9jz4RpGWmzNQY7D0y2ZZacH7atL2K9DeJXJVaz7/5WrxeyIxO8KASk1jCM96Q4LjRx/F3R+InjJrw==} + astro-icon@1.2.0: + resolution: {integrity: sha512-0848V2WZuTPhqGSqLeZimLJZ5wmamMtWYzvA2kvg8s2DrUPZ3/nrTcHJWN0eJHt62jy7uvNxgTNiUJGIr7JNxg==} + engines: {node: '>=22.12.0'} astro-og-canvas@0.13.0: resolution: {integrity: sha512-jcIDhE9OGIbd7LstZQ4CyzOpbbEKAlxJqEgdkVu6r26m7yJKlQjKysszfDxlox717cnCLPXKivyFjsufcq8sYA==} peerDependencies: astro: ^5.0.0 || ^6.0.0 || ^7.0.0 - astro@7.0.6: - resolution: {integrity: sha512-Myw0sFia+zs/Y0yqfZEsUYXfDPh3ELcLf1f0Q/qQzVXBh/af1qO62WNT+P89DCcfGVV51nMoQhEfkBYqJmoUOQ==} + astro@7.2.9: + resolution: {integrity: sha512-o5nZFo/bieF6rp4x9sQSLhI7GO7Bahpld38Y4LvX27nFoxNiuttjI+Hea81w5ksORLHgdPUlQpU9obPz5QRa8g==} engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true peerDependencies: - '@astrojs/markdown-remark': 7.2.1 + '@astrojs/markdown-remark': 7.2.4 peerDependenciesMeta: '@astrojs/markdown-remark': optional: true @@ -2022,9 +2064,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - canvaskit-wasm@0.41.1: resolution: {integrity: sha512-gO2rNMIE0lOQ8MaarM4qNeq2zZAEknAObKP2XAKidMSAY8P5sURGdFkrx1TSdHuBFqTf5w35JqjuUzjibqkD5g==} @@ -2050,13 +2089,6 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - cheerio-select@2.1.0: - resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} - - cheerio@1.2.0: - resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} - engines: {node: '>=20.18.1'} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2065,14 +2097,13 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} - chownr@3.0.0: - resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} - engines: {node: '>=18'} - ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} + cjs-module-lexer@1.2.3: + resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2098,20 +2129,10 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} - commander@7.2.0: - resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} - engines: {node: '>= 10'} - common-ancestor-path@2.0.0: resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} engines: {node: '>= 18'} - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - confbox@0.2.4: - resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2122,6 +2143,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2129,23 +2154,19 @@ packages: crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} - css-select@5.2.2: - resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} css-tree@2.2.1: resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - css-tree@2.3.1: - resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css-what@6.2.2: - resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} engines: {node: '>= 6'} csso@5.0.5: @@ -2198,8 +2219,8 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - diff@8.0.4: - resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} dir-glob@3.0.1: @@ -2233,12 +2254,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - encoding-sniffer@0.2.1: - resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.21.6: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} @@ -2255,10 +2270,6 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - entities@7.0.1: - resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} - engines: {node: '>=0.12'} - entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -2275,13 +2286,13 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true - esbuild@0.28.0: - resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -2316,9 +2327,6 @@ packages: estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2329,20 +2337,12 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} - exsolve@1.1.0: - resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} - extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2368,9 +2368,6 @@ packages: fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2380,10 +2377,17 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-proc@0.1.0: + resolution: {integrity: sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg==} + engines: {node: ^20.19.0 || >=22.12.0} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2420,10 +2424,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - get-tsconfig@5.0.0-beta.4: resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} engines: {node: '>=20.20.0'} @@ -2435,10 +2435,6 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - globals@15.15.0: - resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} - engines: {node: '>=18'} - globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} @@ -2491,9 +2487,6 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - htmlparser2@10.1.0: - resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} - http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -2501,10 +2494,6 @@ packages: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -2513,6 +2502,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -2528,11 +2520,6 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - is-docker@3.0.0: - resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true - is-docker@4.0.0: resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} engines: {node: '>=20'} @@ -2553,11 +2540,6 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - is-inside-container@1.0.0: - resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} - engines: {node: '>=14.16'} - hasBin: true - is-network-error@1.3.2: resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} engines: {node: '>=16'} @@ -2578,10 +2560,6 @@ packages: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} - is-wsl@3.1.1: - resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} - engines: {node: '>=16'} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -2593,8 +2571,8 @@ packages: resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true json-schema-traverse@1.0.0: @@ -2613,9 +2591,6 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - kolorist@1.8.0: - resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -2690,10 +2665,6 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - local-pkg@1.2.1: - resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} - engines: {node: '>=14'} - locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -2711,6 +2682,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.2.3: + resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + magicast@0.5.3: resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} @@ -2778,9 +2752,6 @@ packages: mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} - mdn-data@2.0.30: - resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} - mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -2900,21 +2871,13 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - miniflare@4.20260518.0: - resolution: {integrity: sha512-jbvp43zWa66tuQ+P7bl7s25VJWzGMv4mVhxEEZEEATPvuqAQhGn2wj3rQViVZkZZBZmXQtZ5ZV5kX9VtmWGzuA==} + miniflare@5.20260826.0-alpha: + resolution: {integrity: sha512-ZXR3Bieg+B5MK0T/zYIWaZiCGCb9Z3en4+/TleYKhIGPLx/e0cRcG/ZvvTviUapVBBK2zODB+aiypdIojU3x6Q==} engines: {node: '>=22.0.0'} - hasBin: true - - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - - minizlib@3.1.0: - resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} - engines: {node: '>= 18'} - mlly@1.8.2: - resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + modern-tar@0.8.4: + resolution: {integrity: sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==} + engines: {node: '>=18.0.0'} mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} @@ -2930,23 +2893,23 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - neotraverse@0.6.18: - resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + neotraverse@1.0.1: + resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} engines: {node: '>= 10'} - nimbus-docs@0.1.22: - resolution: {integrity: sha512-XLtC4CXg9BM+nhNH0zmVwb6H9eXRPAH0UpbgbhtkKJapckDViKLhryW8WcNZa9KBnfnIIH9Eqxq65IzJQS7O+g==} + nimbus-docs@0.5.0: + resolution: {integrity: sha512-lip+ckY3JtspYYPyIblp45//O/Oyr1FF5JmlwuutGcNfNnG/ZbQvSQRtMQKQyON/b1MWMsnwnp78AgZ8dQkpaA==} + engines: {node: '>=22.12.0'} hasBin: true peerDependencies: - astro: '>=6.4.0' + astro: ^7.0.0 react: '>=19.0.0' react-dom: '>=19.0.0' - shiki: ^4.0.0 peerDependenciesMeta: react: optional: true @@ -2988,9 +2951,6 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -3052,12 +3012,6 @@ packages: parse-latin@7.0.0: resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} - parse5-htmlparser2-tree-adapter@7.1.0: - resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} - - parse5-parser-stream@7.1.2: - resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} - parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -3082,9 +3036,6 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - piccolore@0.1.3: resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} @@ -3107,14 +3058,8 @@ packages: resolution: {integrity: sha512-u9mdErTewKSMsr+ceCt8VcNuNP0ro5AXiPXhUVApuEyqr2Zlvt+DdCFBcm+yGWN8mhOdZJ27meIDbnoZgfzpOw==} hasBin: true - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - pkg-types@2.3.1: - resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} prettier@2.8.8: @@ -3143,9 +3088,6 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -3294,11 +3236,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.60.4: - resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3309,8 +3246,8 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - satteri@0.6.3: - resolution: {integrity: sha512-iY5xd2tBDQveYRFkL1F0cegkabWSVoXHi64e1p49SiCs1bZDaqTQPGQI+PqEQIaWkz0iqK80CuQQUYvhsssviw==} + satteri@0.10.5: + resolution: {integrity: sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==} satteri@0.9.4: resolution: {integrity: sha512-BKob126Tay84diOZsnVNH/Q/c+3njPJTCad3w5zLKa6j8bVjxskPNHDtxrMwYK4bN/RlqUSdMnPwKY4k65EMOQ==} @@ -3319,6 +3256,10 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + semver@7.8.0: resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} engines: {node: '>=10'} @@ -3329,9 +3270,9 @@ packages: engines: {node: '>=10'} hasBin: true - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} sharp@0.35.3: resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} @@ -3342,6 +3283,15 @@ packages: '@types/node': optional: true + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3428,13 +3378,8 @@ packages: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} - svgo@3.3.3: - resolution: {integrity: sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==} - engines: {node: '>=14.0.0'} - hasBin: true - - svgo@4.0.1: - resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} engines: {node: '>=16'} hasBin: true @@ -3448,10 +3393,6 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - tar@7.5.19: - resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==} - engines: {node: '>=18'} - term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -3525,10 +3466,14 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - undici@7.24.8: - resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -3538,8 +3483,8 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - unifont@0.7.4: - resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==} + unifont@0.7.5: + resolution: {integrity: sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==} unist-util-find-after@5.0.0: resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} @@ -3653,7 +3598,7 @@ packages: peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 '@vitejs/devtools': ^0.3.0 - esbuild: ^0.27.0 || ^0.28.0 + esbuild: 0.28.2 jiti: '>=1.21.0' less: ^4.0.0 sass: ^1.70.0 @@ -3836,22 +3781,9 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} - whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - which-pm-runs@1.1.0: - resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} - engines: {node: '>=4'} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3862,17 +3794,17 @@ packages: engines: {node: '>=8'} hasBin: true - workerd@1.20260518.1: - resolution: {integrity: sha512-rLquk/eeqqJCbdGljSSuIZWW25vzYjTblXkD/tXQXKR5YsSIC91EtlqrzA1L4TJDZCxXKeFXPYqkW7R16UipXQ==} + workerd@1.20260826.1: + resolution: {integrity: sha512-oTG9ot5zxO9OjjKCskt86+nVrBL0kiUqExHnYaTg4OCkHf/K4zr+9hYvgwmG8DdCWG0TQGErHzD+1h50TM5b+w==} engines: {node: '>=16'} hasBin: true - wrangler@4.93.0: - resolution: {integrity: sha512-qNsPr0oWRTc85SG7s1MjX+mWNTvkNV1zEQvRpTsV6eo8uqtvZoEAq8t8strQi9TtrDP3BOsxmy+N/G3ML6hH2w==} + wrangler@4.127.0: + resolution: {integrity: sha512-4dPqcBEMJfGeZeNnjHT7ThNJs+EiNYxUTg4ywqIdQubcXHBhFeVMQyHV4A9AOhZRFr83cckqdds034KGcr/dtw==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20260518.1 + '@cloudflare/workers-types': ^5.20260826.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -3881,11 +3813,8 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3903,10 +3832,6 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} - yallist@5.0.0: - resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} - engines: {node: '>=18'} - yaml-language-server@1.20.0: resolution: {integrity: sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA==} hasBin: true @@ -3933,9 +3858,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -3961,8 +3883,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.2.4 - '@antfu/utils@8.1.1': {} - '@astrojs/check@0.9.9(prettier@3.9.4)(typescript@6.0.3)': dependencies: '@astrojs/language-server': 2.16.9(prettier@3.9.4)(typescript@6.0.3) @@ -3974,78 +3894,67 @@ snapshots: - prettier - prettier-plugin-astro - '@astrojs/compiler-binding-darwin-arm64@0.3.0': + '@astrojs/compiler-binding-darwin-arm64@0.4.0': optional: true - '@astrojs/compiler-binding-darwin-x64@0.3.0': + '@astrojs/compiler-binding-darwin-x64@0.4.0': optional: true - '@astrojs/compiler-binding-linux-arm64-gnu@0.3.0': + '@astrojs/compiler-binding-linux-arm64-gnu@0.4.0': optional: true - '@astrojs/compiler-binding-linux-arm64-musl@0.3.0': + '@astrojs/compiler-binding-linux-arm64-musl@0.4.0': optional: true - '@astrojs/compiler-binding-linux-x64-gnu@0.3.0': + '@astrojs/compiler-binding-linux-x64-gnu@0.4.0': optional: true - '@astrojs/compiler-binding-linux-x64-musl@0.3.0': + '@astrojs/compiler-binding-linux-x64-musl@0.4.0': optional: true - '@astrojs/compiler-binding-wasm32-wasi@0.3.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)': + '@astrojs/compiler-binding-wasm32-wasi@0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' optional: true - '@astrojs/compiler-binding-win32-arm64-msvc@0.3.0': + '@astrojs/compiler-binding-win32-arm64-msvc@0.4.0': optional: true - '@astrojs/compiler-binding-win32-x64-msvc@0.3.0': + '@astrojs/compiler-binding-win32-x64-msvc@0.4.0': optional: true - '@astrojs/compiler-binding@0.3.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)': + '@astrojs/compiler-binding@0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': optionalDependencies: - '@astrojs/compiler-binding-darwin-arm64': 0.3.0 - '@astrojs/compiler-binding-darwin-x64': 0.3.0 - '@astrojs/compiler-binding-linux-arm64-gnu': 0.3.0 - '@astrojs/compiler-binding-linux-arm64-musl': 0.3.0 - '@astrojs/compiler-binding-linux-x64-gnu': 0.3.0 - '@astrojs/compiler-binding-linux-x64-musl': 0.3.0 - '@astrojs/compiler-binding-wasm32-wasi': 0.3.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2) - '@astrojs/compiler-binding-win32-arm64-msvc': 0.3.0 - '@astrojs/compiler-binding-win32-x64-msvc': 0.3.0 + '@astrojs/compiler-binding-darwin-arm64': 0.4.0 + '@astrojs/compiler-binding-darwin-x64': 0.4.0 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.4.0 + '@astrojs/compiler-binding-linux-arm64-musl': 0.4.0 + '@astrojs/compiler-binding-linux-x64-gnu': 0.4.0 + '@astrojs/compiler-binding-linux-x64-musl': 0.4.0 + '@astrojs/compiler-binding-wasm32-wasi': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/compiler-binding-win32-arm64-msvc': 0.4.0 + '@astrojs/compiler-binding-win32-x64-msvc': 0.4.0 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' - '@astrojs/compiler-rs@0.3.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)': + '@astrojs/compiler-rs@0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': dependencies: - '@astrojs/compiler-binding': 0.3.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2) + '@astrojs/compiler-binding': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' '@astrojs/compiler@2.13.1': {} - '@astrojs/internal-helpers@0.10.0': - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - js-yaml: 4.3.0 - picomatch: 4.0.5 - retext-smartypants: 6.2.0 - shiki: 4.3.1 - smol-toml: 1.7.0 - unified: 11.0.5 - - '@astrojs/internal-helpers@0.10.1': + '@astrojs/internal-helpers@0.10.4': dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 - js-yaml: 4.3.0 + js-yaml: 4.3.1 picomatch: 4.0.5 retext-smartypants: 6.2.0 shiki: 4.3.1 @@ -4077,31 +3986,9 @@ snapshots: transitivePeerDependencies: - typescript - '@astrojs/markdown-remark@7.2.0': - dependencies: - '@astrojs/internal-helpers': 0.10.0 - '@astrojs/prism': 4.0.2 - github-slugger: 2.0.0 - hast-util-from-html: 2.0.3 - hast-util-to-text: 4.0.2 - mdast-util-definitions: 6.0.0 - rehype-raw: 7.0.0 - rehype-stringify: 10.0.1 - remark-gfm: 4.0.1 - remark-parse: 11.0.0 - remark-rehype: 11.1.2 - remark-smartypants: 3.0.2 - unified: 11.0.5 - unist-util-remove-position: 5.0.0 - unist-util-visit: 5.1.0 - unist-util-visit-parents: 6.0.2 - vfile: 6.0.3 - transitivePeerDependencies: - - supports-color - - '@astrojs/markdown-remark@7.2.1': + '@astrojs/markdown-remark@7.2.4': dependencies: - '@astrojs/internal-helpers': 0.10.1 + '@astrojs/internal-helpers': 0.10.4 '@astrojs/prism': 4.0.2 github-slugger: 2.0.0 hast-util-from-html: 2.0.3 @@ -4121,48 +4008,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/markdown-satteri@0.2.1': - dependencies: - '@astrojs/internal-helpers': 0.10.0 - github-slugger: 2.0.0 - satteri: 0.6.3 - - '@astrojs/markdown-satteri@0.3.3': + '@astrojs/markdown-satteri@0.3.8': dependencies: - '@astrojs/internal-helpers': 0.10.1 + '@astrojs/internal-helpers': 0.10.4 '@astrojs/prism': 4.0.2 github-slugger: 2.0.0 - satteri: 0.9.4 - - '@astrojs/mdx@6.0.1(@astrojs/markdown-satteri@0.2.1)(astro@7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0))': - dependencies: - '@astrojs/internal-helpers': 0.10.0 - '@astrojs/markdown-remark': 7.2.0 - '@mdx-js/mdx': 3.1.1 - acorn: 8.17.0 - astro: 7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0) - es-module-lexer: 2.3.0 - estree-util-visit: 2.0.0 - hast-util-to-html: 9.0.5 - piccolore: 0.1.3 - rehype-raw: 7.0.0 - remark-gfm: 4.0.1 - remark-smartypants: 3.0.2 - source-map: 0.7.6 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - optionalDependencies: - '@astrojs/markdown-satteri': 0.2.1 - transitivePeerDependencies: - - supports-color + satteri: 0.10.5 - '@astrojs/mdx@7.0.2(@astrojs/markdown-satteri@0.3.3)(astro@7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0))': + '@astrojs/mdx@7.0.8(@astrojs/markdown-satteri@0.3.8)(astro@7.2.9(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.1.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))': dependencies: - '@astrojs/internal-helpers': 0.10.1 - '@astrojs/markdown-remark': 7.2.1 + '@astrojs/internal-helpers': 0.10.4 + '@astrojs/markdown-remark': 7.2.4 '@mdx-js/mdx': 3.1.1 acorn: 8.17.0 - astro: 7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0) + astro: 7.2.9(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.1.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) es-module-lexer: 2.3.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -4174,7 +4033,7 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 optionalDependencies: - '@astrojs/markdown-satteri': 0.3.3 + '@astrojs/markdown-satteri': 0.3.8 transitivePeerDependencies: - supports-color @@ -4188,13 +4047,12 @@ snapshots: stream-replace-string: 2.0.0 zod: 4.4.3 - '@astrojs/telemetry@3.3.2': + '@astrojs/telemetry@3.3.3': dependencies: ci-info: 4.4.0 dset: 3.1.4 is-docker: 4.0.0 - is-wsl: 3.1.1 - which-pm-runs: 1.1.0 + package-manager-detector: 1.6.0 '@astrojs/yaml2ts@0.2.4': dependencies: @@ -4215,38 +4073,47 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@bruits/satteri-darwin-arm64@0.6.3': + '@bruits/satteri-darwin-arm64@0.10.5': optional: true '@bruits/satteri-darwin-arm64@0.9.4': optional: true - '@bruits/satteri-darwin-x64@0.6.3': + '@bruits/satteri-darwin-x64@0.10.5': optional: true '@bruits/satteri-darwin-x64@0.9.4': optional: true + '@bruits/satteri-linux-arm64-gnu@0.10.5': + optional: true + '@bruits/satteri-linux-arm64-gnu@0.9.4': optional: true + '@bruits/satteri-linux-arm64-musl@0.10.5': + optional: true + '@bruits/satteri-linux-arm64-musl@0.9.4': optional: true - '@bruits/satteri-linux-x64-gnu@0.6.3': + '@bruits/satteri-linux-x64-gnu@0.10.5': optional: true '@bruits/satteri-linux-x64-gnu@0.9.4': optional: true + '@bruits/satteri-linux-x64-musl@0.10.5': + optional: true + '@bruits/satteri-linux-x64-musl@0.9.4': optional: true - '@bruits/satteri-wasm32-wasi@0.6.3': + '@bruits/satteri-wasm32-wasi@0.10.5': dependencies: - '@emnapi/core': 1.9.1 - '@emnapi/runtime': 1.9.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@bruits/satteri-wasm32-wasi@0.9.4': @@ -4256,10 +4123,13 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true + '@bruits/satteri-win32-arm64-msvc@0.10.5': + optional: true + '@bruits/satteri-win32-arm64-msvc@0.9.4': optional: true - '@bruits/satteri-win32-x64-msvc@0.6.3': + '@bruits/satteri-win32-x64-msvc@0.10.5': optional: true '@bruits/satteri-win32-x64-msvc@0.9.4': @@ -4392,7 +4262,7 @@ snapshots: '@changesets/parse@0.4.3': dependencies: '@changesets/types': 6.1.0 - js-yaml: 4.3.0 + js-yaml: 4.3.1 '@changesets/pre@2.0.2': dependencies: @@ -4452,31 +4322,50 @@ snapshots: '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260518.1)': + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260826.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20260518.1 + workerd: 1.20260826.1 + + '@cloudflare/vitest-plugin@1.1.1(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)))': + dependencies: + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + cjs-module-lexer: 1.2.3 + esbuild: 0.28.1 + miniflare: 5.20260826.0-alpha + vitest: 4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + wrangler: 4.127.0 + zod: 4.4.3 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - bufferutil + - utf-8-validate - '@cloudflare/workerd-darwin-64@1.20260518.1': + '@cloudflare/workerd-darwin-64@1.20260826.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260518.1': + '@cloudflare/workerd-darwin-arm64@1.20260826.1': optional: true - '@cloudflare/workerd-linux-64@1.20260518.1': + '@cloudflare/workerd-linux-64@1.20260826.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260518.1': + '@cloudflare/workerd-linux-arm64@1.20260826.1': optional: true - '@cloudflare/workerd-windows-64@1.20260518.1': + '@cloudflare/workerd-windows-64@1.20260826.1': optional: true '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@cyberalien/svg-utils@1.2.19': + dependencies: + '@iconify/types': 2.0.0 + '@emmetio/abbreviation@2.3.3': dependencies: '@emmetio/scanner': 1.0.4 @@ -4506,17 +4395,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/core@1.9.1': - dependencies: - '@emnapi/wasi-threads': 1.2.0 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 @@ -4527,12 +4405,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.9.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.0': + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 optional: true @@ -4542,160 +4415,160 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.27.3': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/aix-ppc64@0.28.0': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.27.3': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm64@0.28.0': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.27.3': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-arm@0.28.0': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.27.3': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/android-x64@0.28.0': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.27.3': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.28.0': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.27.3': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/darwin-x64@0.28.0': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.27.3': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.28.0': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.27.3': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.28.0': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.27.3': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm64@0.28.0': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.27.3': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-arm@0.28.0': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.27.3': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-ia32@0.28.0': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.27.3': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-loong64@0.28.0': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.27.3': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-mips64el@0.28.0': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.27.3': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-ppc64@0.28.0': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.27.3': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.28.0': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.27.3': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-s390x@0.28.0': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.27.3': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/linux-x64@0.28.0': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.27.3': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.28.0': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.27.3': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.28.0': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.27.3': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.28.0': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.27.3': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.28.0': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.27.3': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.28.0': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.27.3': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/sunos-x64@0.28.0': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.27.3': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-arm64@0.28.0': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.27.3': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-ia32@0.28.0': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.27.3': + '@esbuild/win32-x64@0.28.1': optional: true - '@esbuild/win32-x64@0.28.0': + '@esbuild/win32-x64@0.28.2': optional: true '@fontsource-variable/inter@5.2.8': {} @@ -4706,40 +4579,29 @@ snapshots: dependencies: '@iconify/types': 2.0.0 - '@iconify/tools@4.2.0': + '@iconify/tools@5.0.14': dependencies: + '@cyberalien/svg-utils': 1.2.19 '@iconify/types': 2.0.0 - '@iconify/utils': 2.3.0 - cheerio: 1.2.0 - domhandler: 5.0.3 - extract-zip: 2.0.1 - local-pkg: 1.2.1 + '@iconify/utils': 3.1.4 + fflate: 0.8.3 + modern-tar: 0.8.4 pathe: 2.0.3 - svgo: 3.3.3 - tar: 7.5.19 - transitivePeerDependencies: - - supports-color + svgo: 4.1.0 '@iconify/types@2.0.0': {} - '@iconify/utils@2.3.0': + '@iconify/utils@3.1.4': dependencies: '@antfu/install-pkg': 1.1.0 - '@antfu/utils': 8.1.1 '@iconify/types': 2.0.0 - debug: 4.4.3 - globals: 15.15.0 - kolorist: 1.8.0 - local-pkg: 1.2.1 - mlly: 1.8.2 - transitivePeerDependencies: - - supports-color + import-meta-resolve: 4.2.0 '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.2': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.1 optional: true '@img/sharp-darwin-arm64@0.35.3': @@ -4747,9 +4609,14 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.3.2 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + + '@img/sharp-darwin-x64@0.35.2': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.1 optional: true '@img/sharp-darwin-x64@0.35.3': @@ -4757,74 +4624,119 @@ snapshots: '@img/sharp-libvips-darwin-x64': 1.3.2 optional: true + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + '@img/sharp-freebsd-wasm32@0.35.3': dependencies: '@img/sharp-wasm32': 0.35.3 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.1': optional: true '@img/sharp-libvips-darwin-arm64@1.3.2': optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.1': optional: true '@img/sharp-libvips-darwin-x64@1.3.2': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.1': optional: true '@img/sharp-libvips-linux-arm64@1.3.2': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.1': optional: true '@img/sharp-libvips-linux-arm@1.3.2': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.1': optional: true '@img/sharp-libvips-linux-ppc64@1.3.2': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.1': optional: true '@img/sharp-libvips-linux-riscv64@1.3.2': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.1': optional: true '@img/sharp-libvips-linux-s390x@1.3.2': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.1': optional: true '@img/sharp-libvips-linux-x64@1.3.2': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': optional: true '@img/sharp-libvips-linuxmusl-arm64@1.3.2': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': optional: true '@img/sharp-libvips-linuxmusl-x64@1.3.2': optional: true - '@img/sharp-linux-arm64@0.34.5': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.3.1 optional: true '@img/sharp-linux-arm64@0.35.3': @@ -4832,9 +4744,14 @@ snapshots: '@img/sharp-libvips-linux-arm64': 1.3.2 optional: true - '@img/sharp-linux-arm@0.34.5': + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.3.1 optional: true '@img/sharp-linux-arm@0.35.3': @@ -4842,9 +4759,14 @@ snapshots: '@img/sharp-libvips-linux-arm': 1.3.2 optional: true - '@img/sharp-linux-ppc64@0.34.5': + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.3.1 optional: true '@img/sharp-linux-ppc64@0.35.3': @@ -4852,9 +4774,14 @@ snapshots: '@img/sharp-libvips-linux-ppc64': 1.3.2 optional: true - '@img/sharp-linux-riscv64@0.34.5': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 optional: true '@img/sharp-linux-riscv64@0.35.3': @@ -4862,9 +4789,14 @@ snapshots: '@img/sharp-libvips-linux-riscv64': 1.3.2 optional: true - '@img/sharp-linux-s390x@0.34.5': + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.3.1 optional: true '@img/sharp-linux-s390x@0.35.3': @@ -4872,9 +4804,14 @@ snapshots: '@img/sharp-libvips-linux-s390x': 1.3.2 optional: true - '@img/sharp-linux-x64@0.34.5': + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.3.1 optional: true '@img/sharp-linux-x64@0.35.3': @@ -4882,9 +4819,14 @@ snapshots: '@img/sharp-libvips-linux-x64': 1.3.2 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 optional: true '@img/sharp-linuxmusl-arm64@0.35.3': @@ -4892,9 +4834,14 @@ snapshots: '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 optional: true '@img/sharp-linuxmusl-x64@0.35.3': @@ -4902,9 +4849,14 @@ snapshots: '@img/sharp-libvips-linuxmusl-x64': 1.3.2 optional: true - '@img/sharp-wasm32@0.34.5': + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.2': dependencies: - '@emnapi/runtime': 1.10.0 + '@emnapi/runtime': 1.11.3 optional: true '@img/sharp-wasm32@0.35.3': @@ -4912,29 +4864,53 @@ snapshots: '@emnapi/runtime': 1.11.2 optional: true + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + '@img/sharp-webcontainers-wasm32@0.35.3': dependencies: '@img/sharp-wasm32': 0.35.3 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.2': optional: true '@img/sharp-win32-arm64@0.35.3': optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.2': optional: true '@img/sharp-win32-ia32@0.35.3': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.2': optional: true '@img/sharp-win32-x64@0.35.3': optional: true + '@img/sharp-win32-x64@0.35.4': + optional: true + '@inquirer/external-editor@1.0.3(@types/node@26.1.0)': dependencies: chardet: 2.1.1 @@ -4942,10 +4918,6 @@ snapshots: optionalDependencies: '@types/node': 26.1.0 - '@isaacs/fs-minipass@4.0.1': - dependencies: - minipass: 7.1.3 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5023,17 +4995,17 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.2 + '@emnapi/runtime': 1.11.1 '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': dependencies: - '@emnapi/core': 1.9.1 - '@emnapi/runtime': 1.9.1 + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.3 '@tybys/wasm-util': 0.10.3 optional: true @@ -5126,7 +5098,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@rolldown/binding-win32-arm64-msvc@1.1.4': @@ -5137,89 +5109,6 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/pluginutils@5.3.0(rollup@4.60.4)': - dependencies: - '@types/estree': 1.0.9 - estree-walker: 2.0.2 - picomatch: 4.0.5 - optionalDependencies: - rollup: 4.60.4 - - '@rollup/rollup-android-arm-eabi@4.60.4': - optional: true - - '@rollup/rollup-android-arm64@4.60.4': - optional: true - - '@rollup/rollup-darwin-arm64@4.60.4': - optional: true - - '@rollup/rollup-darwin-x64@4.60.4': - optional: true - - '@rollup/rollup-freebsd-arm64@4.60.4': - optional: true - - '@rollup/rollup-freebsd-x64@4.60.4': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.60.4': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.60.4': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.60.4': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.60.4': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.60.4': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.60.4': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.60.4': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.60.4': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.60.4': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.60.4': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.60.4': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.60.4': - optional: true - - '@rollup/rollup-linux-x64-musl@4.60.4': - optional: true - - '@rollup/rollup-openbsd-x64@4.60.4': - optional: true - - '@rollup/rollup-openharmony-arm64@4.60.4': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.60.4': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.60.4': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.60.4': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.60.4': - optional: true - '@shikijs/core@4.3.1': dependencies: '@shikijs/primitive': 4.3.1 @@ -5337,7 +5226,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.3.2 '@tailwindcss/oxide': 4.3.2 - postcss: 8.5.16 + postcss: 8.5.26 tailwindcss: 4.3.2 '@tybys/wasm-util@0.10.3': @@ -5360,15 +5249,16 @@ snapshots: dependencies: '@types/estree': 1.0.9 - '@types/estree@1.0.8': - optional: true - '@types/estree@1.0.9': {} '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -5405,11 +5295,6 @@ snapshots: '@types/unist@3.0.3': {} - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 26.1.0 - optional: true - '@ungap/structured-clone@1.3.1': {} '@vercel/detect-agent@1.2.3': {} @@ -5423,13 +5308,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) + vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -5559,55 +5444,53 @@ snapshots: astring@1.9.0: {} - astro-icon@1.1.5: + astro-icon@1.2.0: dependencies: - '@iconify/tools': 4.2.0 + '@iconify/tools': 5.0.14 '@iconify/types': 2.0.0 - '@iconify/utils': 2.3.0 - transitivePeerDependencies: - - supports-color + '@iconify/utils': 3.1.4 - astro-og-canvas@0.13.0(astro@7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0)): + astro-og-canvas@0.13.0(astro@7.2.9(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.1.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)): dependencies: - astro: 7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0) + astro: 7.2.9(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.1.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) canvaskit-wasm: 0.41.1 deterministic-object-hash: 2.0.2 entities: 8.0.0 - astro@7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0): + astro@7.2.9(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.1.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0): dependencies: - '@astrojs/compiler-rs': 0.3.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2) - '@astrojs/internal-helpers': 0.10.1 - '@astrojs/markdown-satteri': 0.3.3 - '@astrojs/telemetry': 3.3.2 + '@astrojs/compiler-rs': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/internal-helpers': 0.10.4 + '@astrojs/markdown-satteri': 0.3.8 + '@astrojs/telemetry': 3.3.3 '@capsizecss/unpack': 4.0.0 '@clack/prompts': 1.4.0 '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.60.4) am-i-vibing: 0.4.0 aria-query: 5.3.2 axobject-query: 4.1.0 ci-info: 4.4.0 clsx: 2.1.1 common-ancestor-path: 2.0.0 - cookie: 1.1.1 + cookie: 2.0.1 devalue: 5.8.1 - diff: 8.0.4 + diff: 9.0.0 dset: 3.1.4 es-module-lexer: 2.3.0 - esbuild: 0.28.0 + esbuild: 0.28.2 + find-proc: 0.1.0 flattie: 1.1.1 fontace: 0.4.1 get-tsconfig: 5.0.0-beta.4 github-slugger: 2.0.0 html-escaper: 3.0.3 http-cache-semantics: 4.2.0 - js-yaml: 4.3.0 + js-yaml: 4.3.1 jsonc-parser: 3.3.1 - magic-string: 0.30.21 + magic-string: 1.2.3 magicast: 0.5.3 mrmime: 2.0.1 - neotraverse: 0.6.18 + neotraverse: 1.0.1 obug: 2.1.3 p-limit: 7.3.0 p-queue: 9.3.0 @@ -5617,21 +5500,21 @@ snapshots: semver: 7.8.5 shiki: 4.3.1 smol-toml: 1.7.0 - svgo: 4.0.1 + svgo: 4.1.0 tinyclip: 0.1.12 tinyexec: 1.2.4 tinyglobby: 0.2.17 ultrahtml: 1.6.0 - unifont: 0.7.4 + unifont: 0.7.5 unstorage: 1.17.5 - vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.4.3 optionalDependencies: - '@astrojs/markdown-remark': 7.2.1 - sharp: 0.35.3(@types/node@26.1.0) + '@astrojs/markdown-remark': 7.2.4 + sharp: 0.35.4(@types/node@26.1.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -5657,7 +5540,6 @@ snapshots: - ioredis - jiti - less - - rollup - sass - sass-embedded - stylus @@ -5685,8 +5567,6 @@ snapshots: dependencies: fill-range: 7.1.1 - buffer-crc32@0.2.13: {} - canvaskit-wasm@0.41.1: dependencies: '@webgpu/types': 0.1.21 @@ -5705,29 +5585,6 @@ snapshots: chardet@2.1.1: {} - cheerio-select@2.1.0: - dependencies: - boolbase: 1.0.0 - css-select: 5.2.2 - css-what: 6.2.2 - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - - cheerio@1.2.0: - dependencies: - cheerio-select: 2.1.0 - dom-serializer: 2.0.0 - domhandler: 5.0.3 - domutils: 3.2.2 - encoding-sniffer: 0.2.1 - htmlparser2: 10.1.0 - parse5: 7.3.0 - parse5-htmlparser2-tree-adapter: 7.1.0 - parse5-parser-stream: 7.1.2 - undici: 7.24.8 - whatwg-mimetype: 4.0.0 - chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -5736,10 +5593,10 @@ snapshots: dependencies: readdirp: 5.0.0 - chownr@3.0.0: {} - ci-info@4.4.0: {} + cjs-module-lexer@1.2.3: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -5760,20 +5617,16 @@ snapshots: commander@11.1.0: {} - commander@7.2.0: {} - common-ancestor-path@2.0.0: {} - confbox@0.1.8: {} - - confbox@0.2.4: {} - convert-source-map@2.0.0: {} cookie-es@1.2.3: {} cookie@1.1.1: {} + cookie@2.0.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5784,10 +5637,10 @@ snapshots: dependencies: uncrypto: 0.1.3 - css-select@5.2.2: + css-select@6.0.0: dependencies: boolbase: 1.0.0 - css-what: 6.2.2 + css-what: 7.0.0 domhandler: 5.0.3 domutils: 3.2.2 nth-check: 2.1.1 @@ -5797,17 +5650,12 @@ snapshots: mdn-data: 2.0.28 source-map-js: 1.2.1 - css-tree@2.3.1: - dependencies: - mdn-data: 2.0.30 - source-map-js: 1.2.1 - css-tree@3.2.1: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 - css-what@6.2.2: {} + css-what@7.0.0: {} csso@5.0.5: dependencies: @@ -5845,7 +5693,7 @@ snapshots: dependencies: dequal: 2.0.3 - diff@8.0.4: {} + diff@9.0.0: {} dir-glob@3.0.1: dependencies: @@ -5880,15 +5728,6 @@ snapshots: emoji-regex@8.0.0: {} - encoding-sniffer@0.2.1: - dependencies: - iconv-lite: 0.6.3 - whatwg-encoding: 3.1.1 - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 @@ -5903,8 +5742,6 @@ snapshots: entities@6.0.1: {} - entities@7.0.1: {} - entities@8.0.0: {} error-stack-parser-es@1.0.5: {} @@ -5925,63 +5762,63 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 - esbuild@0.27.3: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 - - esbuild@0.28.0: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.0 - '@esbuild/android-arm': 0.28.0 - '@esbuild/android-arm64': 0.28.0 - '@esbuild/android-x64': 0.28.0 - '@esbuild/darwin-arm64': 0.28.0 - '@esbuild/darwin-x64': 0.28.0 - '@esbuild/freebsd-arm64': 0.28.0 - '@esbuild/freebsd-x64': 0.28.0 - '@esbuild/linux-arm': 0.28.0 - '@esbuild/linux-arm64': 0.28.0 - '@esbuild/linux-ia32': 0.28.0 - '@esbuild/linux-loong64': 0.28.0 - '@esbuild/linux-mips64el': 0.28.0 - '@esbuild/linux-ppc64': 0.28.0 - '@esbuild/linux-riscv64': 0.28.0 - '@esbuild/linux-s390x': 0.28.0 - '@esbuild/linux-x64': 0.28.0 - '@esbuild/netbsd-arm64': 0.28.0 - '@esbuild/netbsd-x64': 0.28.0 - '@esbuild/openbsd-arm64': 0.28.0 - '@esbuild/openbsd-x64': 0.28.0 - '@esbuild/openharmony-arm64': 0.28.0 - '@esbuild/sunos-x64': 0.28.0 - '@esbuild/win32-arm64': 0.28.0 - '@esbuild/win32-ia32': 0.28.0 - '@esbuild/win32-x64': 0.28.0 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escalade@3.2.0: {} @@ -6018,8 +5855,6 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/unist': 3.0.3 - estree-walker@2.0.2: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -6028,22 +5863,10 @@ snapshots: expect-type@1.4.0: {} - exsolve@1.1.0: {} - extend@3.0.2: {} extendable-error@0.1.7: {} - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -6074,18 +5897,18 @@ snapshots: dependencies: format: 0.2.2 - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 + fflate@0.8.3: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-proc@0.1.0: {} + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -6120,10 +5943,6 @@ snapshots: get-caller-file@2.0.5: {} - get-stream@5.2.0: - dependencies: - pump: 3.0.4 - get-tsconfig@5.0.0-beta.4: dependencies: resolve-pkg-maps: 1.0.0 @@ -6134,8 +5953,6 @@ snapshots: dependencies: is-glob: 4.0.3 - globals@15.15.0: {} - globby@11.1.0: dependencies: array-union: 2.1.0 @@ -6170,7 +5987,7 @@ snapshots: hast-util-from-parse5@8.0.3: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 devlop: 1.1.0 hastscript: 9.0.1 @@ -6181,11 +5998,11 @@ snapshots: hast-util-is-element@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-parse-selector@4.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-raw@9.1.0: dependencies: @@ -6207,7 +6024,7 @@ snapshots: dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 devlop: 1.1.0 estree-util-attach-comments: 3.0.0 @@ -6260,7 +6077,7 @@ snapshots: hast-util-to-parse5@8.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 devlop: 1.1.0 property-information: 7.2.0 @@ -6281,7 +6098,7 @@ snapshots: hastscript@9.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 hast-util-parse-selector: 4.0.0 property-information: 7.2.0 @@ -6291,27 +6108,18 @@ snapshots: html-void-elements@3.0.0: {} - htmlparser2@10.1.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 7.0.1 - http-cache-semantics@4.2.0: {} human-id@4.1.3: {} - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 ignore@5.3.2: {} + import-meta-resolve@4.2.0: {} + inline-style-parser@0.2.7: {} iron-webcrypto@1.2.1: {} @@ -6325,8 +6133,6 @@ snapshots: is-decimal@2.0.1: {} - is-docker@3.0.0: {} - is-docker@4.0.0: {} is-extglob@2.1.1: {} @@ -6339,10 +6145,6 @@ snapshots: is-hexadecimal@2.0.1: {} - is-inside-container@1.0.0: - dependencies: - is-docker: 3.0.0 - is-network-error@1.3.2: {} is-number@7.0.0: {} @@ -6355,10 +6157,6 @@ snapshots: is-windows@1.0.2: {} - is-wsl@3.1.1: - dependencies: - is-inside-container: 1.0.0 - isexe@2.0.0: {} jiti@2.7.0: {} @@ -6368,7 +6166,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -6384,8 +6182,6 @@ snapshots: kleur@4.1.5: {} - kolorist@1.8.0: {} - lightningcss-android-arm64@1.32.0: optional: true @@ -6435,12 +6231,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - local-pkg@1.2.1: - dependencies: - mlly: 1.8.2 - pkg-types: 2.3.1 - quansync: 0.2.11 - locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -6455,6 +6245,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.2.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: dependencies: '@babel/parser': 7.29.3 @@ -6647,8 +6441,6 @@ snapshots: mdn-data@2.0.28: {} - mdn-data@2.0.30: {} - mdn-data@2.27.1: {} merge2@1.4.1: {} @@ -6929,30 +6721,19 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 - miniflare@4.20260518.0: + miniflare@5.20260826.0-alpha: dependencies: '@cspotcode/source-map-support': 0.8.1 - sharp: 0.34.5 - undici: 7.24.8 - workerd: 1.20260518.1 - ws: 8.18.0 + sharp: 0.35.2 + undici: 7.29.0 + workerd: 1.20260826.1 + ws: 8.21.0 youch: 4.1.0-beta.10 transitivePeerDependencies: - bufferutil - utf-8-validate - minipass@7.1.3: {} - - minizlib@3.1.0: - dependencies: - minipass: 7.1.3 - - mlly@1.8.2: - dependencies: - acorn: 8.17.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.4 + modern-tar@0.8.4: {} mri@1.2.0: {} @@ -6962,19 +6743,19 @@ snapshots: muggle-string@0.4.1: {} - nanoid@3.3.15: {} + nanoid@3.3.18: {} - neotraverse@0.6.18: {} + neotraverse@1.0.1: {} - nimbus-docs@0.1.22(astro@7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0))(react@19.2.6)(shiki@4.3.1): + nimbus-docs@0.5.0(astro@7.2.9(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.1.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))(react@19.2.6): dependencies: - '@astrojs/markdown-satteri': 0.2.1 - '@astrojs/mdx': 6.0.1(@astrojs/markdown-satteri@0.2.1)(astro@7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0)) + '@astrojs/markdown-satteri': 0.3.8 + '@astrojs/mdx': 7.0.8(@astrojs/markdown-satteri@0.3.8)(astro@7.2.9(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.1.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) '@astrojs/sitemap': 3.7.3 '@clack/prompts': 0.9.1 '@shikijs/transformers': 4.3.1 '@vercel/detect-agent': 1.2.3 - astro: 7.0.6(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.0)(jiti@2.7.0)(rollup@4.60.4)(tsx@4.22.2)(yaml@2.9.0) + astro: 7.2.9(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.1.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) clsx: 2.1.1 github-slugger: 2.0.0 mri: 1.2.0 @@ -6986,7 +6767,7 @@ snapshots: remark-lint-no-literal-urls: 4.0.1 remark-lint-no-multiple-toplevel-headings: 4.0.1 remark-lint-unordered-list-marker-style: 4.0.1 - satteri: 0.6.3 + satteri: 0.9.4 shiki: 4.3.1 tailwind-merge: 3.6.0 unified: 11.0.5 @@ -7025,10 +6806,6 @@ snapshots: ohash@2.0.11: {} - once@1.4.0: - dependencies: - wrappy: 1.0.2 - oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -7105,15 +6882,6 @@ snapshots: unist-util-visit-children: 3.0.0 vfile: 6.0.3 - parse5-htmlparser2-tree-adapter@7.1.0: - dependencies: - domhandler: 5.0.3 - parse5: 7.3.0 - - parse5-parser-stream@7.1.2: - dependencies: - parse5: 7.3.0 - parse5@7.3.0: dependencies: entities: 6.0.1 @@ -7130,8 +6898,6 @@ snapshots: pathe@2.0.3: {} - pend@1.2.0: {} - piccolore@0.1.3: {} picocolors@1.1.1: {} @@ -7144,21 +6910,9 @@ snapshots: pkg-pr-new@0.0.75: {} - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.2 - pathe: 2.0.3 - - pkg-types@2.3.1: - dependencies: - confbox: 0.2.4 - exsolve: 1.1.0 - pathe: 2.0.3 - - postcss@8.5.16: + postcss@8.5.26: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -7174,11 +6928,6 @@ snapshots: property-information@7.2.0: {} - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - quansync@0.2.11: {} queue-microtask@1.2.3: {} @@ -7455,38 +7204,6 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.4 '@rolldown/binding-win32-x64-msvc': 1.1.4 - rollup@4.60.4: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.4 - '@rollup/rollup-android-arm64': 4.60.4 - '@rollup/rollup-darwin-arm64': 4.60.4 - '@rollup/rollup-darwin-x64': 4.60.4 - '@rollup/rollup-freebsd-arm64': 4.60.4 - '@rollup/rollup-freebsd-x64': 4.60.4 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 - '@rollup/rollup-linux-arm-musleabihf': 4.60.4 - '@rollup/rollup-linux-arm64-gnu': 4.60.4 - '@rollup/rollup-linux-arm64-musl': 4.60.4 - '@rollup/rollup-linux-loong64-gnu': 4.60.4 - '@rollup/rollup-linux-loong64-musl': 4.60.4 - '@rollup/rollup-linux-ppc64-gnu': 4.60.4 - '@rollup/rollup-linux-ppc64-musl': 4.60.4 - '@rollup/rollup-linux-riscv64-gnu': 4.60.4 - '@rollup/rollup-linux-riscv64-musl': 4.60.4 - '@rollup/rollup-linux-s390x-gnu': 4.60.4 - '@rollup/rollup-linux-x64-gnu': 4.60.4 - '@rollup/rollup-linux-x64-musl': 4.60.4 - '@rollup/rollup-openbsd-x64': 4.60.4 - '@rollup/rollup-openharmony-arm64': 4.60.4 - '@rollup/rollup-win32-arm64-msvc': 4.60.4 - '@rollup/rollup-win32-ia32-msvc': 4.60.4 - '@rollup/rollup-win32-x64-gnu': 4.60.4 - '@rollup/rollup-win32-x64-msvc': 4.60.4 - fsevents: 2.3.3 - optional: true - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -7495,18 +7212,22 @@ snapshots: safer-buffer@2.1.2: {} - satteri@0.6.3: + satteri@0.10.5: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 optionalDependencies: - '@bruits/satteri-darwin-arm64': 0.6.3 - '@bruits/satteri-darwin-x64': 0.6.3 - '@bruits/satteri-linux-x64-gnu': 0.6.3 - '@bruits/satteri-wasm32-wasi': 0.6.3 - '@bruits/satteri-win32-x64-msvc': 0.6.3 + '@bruits/satteri-darwin-arm64': 0.10.5 + '@bruits/satteri-darwin-x64': 0.10.5 + '@bruits/satteri-linux-arm64-gnu': 0.10.5 + '@bruits/satteri-linux-arm64-musl': 0.10.5 + '@bruits/satteri-linux-x64-gnu': 0.10.5 + '@bruits/satteri-linux-x64-musl': 0.10.5 + '@bruits/satteri-wasm32-wasi': 0.10.5 + '@bruits/satteri-win32-arm64-msvc': 0.10.5 + '@bruits/satteri-win32-x64-msvc': 0.10.5 satteri@0.9.4: dependencies: @@ -7527,40 +7248,43 @@ snapshots: sax@1.6.0: {} + sax@1.6.1: {} + semver@7.8.0: {} semver@7.8.5: {} - sharp@0.34.5: + sharp@0.35.2: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 sharp@0.35.3(@types/node@26.1.0): dependencies: @@ -7595,6 +7319,40 @@ snapshots: '@img/sharp-win32-x64': 0.35.3 '@types/node': 26.1.0 + sharp@0.35.4(@types/node@26.1.0): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 26.1.0 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -7675,25 +7433,15 @@ snapshots: supports-color@10.2.2: {} - svgo@3.3.3: - dependencies: - commander: 7.2.0 - css-select: 5.2.2 - css-tree: 2.3.1 - css-what: 6.2.2 - csso: 5.0.5 - picocolors: 1.1.1 - sax: 1.6.0 - - svgo@4.0.1: + svgo@4.1.0: dependencies: commander: 11.1.0 - css-select: 5.2.2 + css-select: 6.0.0 css-tree: 3.2.1 - css-what: 6.2.2 + css-what: 7.0.0 csso: 5.0.5 picocolors: 1.1.1 - sax: 1.6.0 + sax: 1.6.1 tailwind-merge@3.6.0: {} @@ -7701,14 +7449,6 @@ snapshots: tapable@2.3.3: {} - tar@7.5.19: - dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.3 - minizlib: 3.1.0 - yallist: 5.0.0 - term-size@2.2.1: {} tiny-inflate@1.0.3: {} @@ -7741,7 +7481,7 @@ snapshots: tsx@4.22.2: dependencies: - esbuild: 0.28.0 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 @@ -7763,7 +7503,9 @@ snapshots: undici-types@8.3.0: {} - undici@7.24.8: {} + undici@7.29.0: {} + + undici@8.10.0: {} unenv@2.0.0-rc.24: dependencies: @@ -7786,11 +7528,11 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 - unifont@0.7.4: + unifont@0.7.5: dependencies: css-tree: 3.2.1 - ofetch: 1.5.1 ohash: 2.0.11 + undici: 8.10.0 unist-util-find-after@5.0.0: dependencies: @@ -7866,29 +7608,29 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0): + vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 - postcss: 8.5.16 + postcss: 8.5.26 rolldown: 1.1.4 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.1.0 - esbuild: 0.28.0 + esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.22.2 yaml: 2.9.0 - vitefu@1.1.3(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)): + vitefu@1.1.3(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)): optionalDependencies: - vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) + vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) - vitest@4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)): + vitest@4.1.9(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -7905,7 +7647,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) + vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.0 @@ -8013,19 +7755,11 @@ snapshots: webidl-conversions@3.0.1: {} - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 - - whatwg-mimetype@4.0.0: {} - whatwg-url@5.0.0: dependencies: tr46: 0.0.3 webidl-conversions: 3.0.1 - which-pm-runs@1.1.0: {} - which@2.0.2: dependencies: isexe: 2.0.0 @@ -8035,24 +7769,24 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - workerd@1.20260518.1: + workerd@1.20260826.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260518.1 - '@cloudflare/workerd-darwin-arm64': 1.20260518.1 - '@cloudflare/workerd-linux-64': 1.20260518.1 - '@cloudflare/workerd-linux-arm64': 1.20260518.1 - '@cloudflare/workerd-windows-64': 1.20260518.1 + '@cloudflare/workerd-darwin-64': 1.20260826.1 + '@cloudflare/workerd-darwin-arm64': 1.20260826.1 + '@cloudflare/workerd-linux-64': 1.20260826.1 + '@cloudflare/workerd-linux-arm64': 1.20260826.1 + '@cloudflare/workerd-windows-64': 1.20260826.1 - wrangler@4.93.0: + wrangler@4.127.0: dependencies: '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260518.1) + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260826.1) blake3-wasm: 2.1.5 - esbuild: 0.27.3 - miniflare: 4.20260518.0 + esbuild: 0.28.1 + miniflare: 5.20260826.0-alpha path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20260518.1 + workerd: 1.20260826.1 optionalDependencies: fsevents: 2.3.3 transitivePeerDependencies: @@ -8065,16 +7799,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrappy@1.0.2: {} - - ws@8.18.0: {} + ws@8.21.0: {} xxhash-wasm@1.1.0: {} y18n@5.0.8: {} - yallist@5.0.0: {} - yaml-language-server@1.20.0: dependencies: '@vscode/l10n': 0.0.18 @@ -8107,11 +7837,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - yocto-queue@1.2.2: {} youch-core@0.3.3: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e994a42..ac13f5f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,5 +9,12 @@ allowBuilds: sharp: true workerd: true +overrides: + "esbuild@>=0.27.3 <0.28.1": 0.28.2 + "js-yaml@>=4.0.0 <4.3.1": 4.3.1 + "nanoid@<3.3.18": 3.3.18 + "postcss@<=8.5.22": 8.5.26 + "svgo@>=4.0.0 <4.0.2": 4.1.0 + minimumReleaseAgeExclude: - - nimbus-docs@0.1.22 + - astro@7.2.9 diff --git a/scripts/build-llms-full.mjs b/scripts/build-llms-full.mjs index dd2cc8e..493bf30 100644 --- a/scripts/build-llms-full.mjs +++ b/scripts/build-llms-full.mjs @@ -10,7 +10,7 @@ * Run from the package root: * pnpm build:llms * - * The test in `tests/docs.test.ts` checks that the file's section + * The test in `packages/astro/tests/docs.test.ts` checks that the file's section * ordering matches expectations. It does NOT enforce byte-for-byte * equality with the regenerated content — re-run this script after * meaningful edits to the source docs. diff --git a/scripts/check-monorepo-baseline.mjs b/scripts/check-monorepo-baseline.mjs new file mode 100644 index 0000000..b64f7b9 --- /dev/null +++ b/scripts/check-monorepo-baseline.mjs @@ -0,0 +1,213 @@ +#!/usr/bin/env node +// @ts-check + +import { createHash } from "node:crypto"; +import { readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { cleanupCommands, runCommand } from "./run-command.mjs"; + +const repositoryRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url))); +const playgroundRoot = path.join(repositoryRoot, "playgrounds", "mdx-jsx"); +const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const astroManifest = JSON.parse(await readFile(path.join(repositoryRoot, "packages", "astro", "package.json"), "utf8")); +if (typeof astroManifest.version !== "string") throw new Error("Astro package version is missing"); +const polystellaVersion = astroManifest.version; + +const dryRunHashes = { + "docs/block-jsx.mdx": "1bd2533d354aac689865d610b9ef2c660a940eba7df2d02b3b01c4dee9a8081c", + "docs/expressions.mdx": "a45472cf8b6fafe58710b6f810cf8116a9213a02039b9209c686f339d28c5fc3", + "docs/inline-jsx.mdx": "db88cc682b22c78b6e7cdcfba1d16d906e49ffd93a52c8504808d961565c1de2", + "docs/plain-markdown.md": "8ccebd5b5dd1254b71c67d3b0e58c1e567219d67fd68437347893d30526337ea", + "docs/static-data.mdx": "ea3f9e1ca502f7714e794dd273e1dae8ccab2fc49fd3809478dd76f3f640b99e", +}; +const localSourceHashes = { + "pt-BR/docs/block-jsx.mdx": "d603785e2ee730cd61c299fffed971882438f20abd59a1776f52432a6844b95c", + "pt-BR/docs/expressions.mdx": "b3aeadf4eda27ef6f83faf521b7c382271c603601a8eba91578bf8e12cdf33ca", + "pt-BR/docs/inline-jsx.mdx": "d0fd6e76bd4fe60319b2427ddaac6f13f1a6f49e0c9ca52a66e2f4e7bad50adf", + "pt-BR/docs/plain-markdown.md": "da48b6282dd5fb8ef174eca1cc6bde469d0f1dc6516445e0cab45180196d8f3c", + "pt-BR/docs/static-data.mdx": "66841e3dcdc8e1a4926fb34b244cf42da073b5b3215946ad0770b98f71524ebb", + "fr-FR/docs/block-jsx.mdx": "6e0fa4eeda9f2d8be76e38f6583fba8c7877e4ef50380f2375813ce268af5aa2", + "fr-FR/docs/expressions.mdx": "9ec3feed4aeae36d06a2fecb049752fdefa962814a7760164222d497f0b73562", + "fr-FR/docs/inline-jsx.mdx": "9126c18f9bc2059a260e002d9ee1dec3aca7462175afb870c9be20ef7bb5f191", + "fr-FR/docs/plain-markdown.md": "f302200778242da8d6574216e40a2bb2328493e235c78d5324c923986c5574a6", + "fr-FR/docs/static-data.mdx": "4c1c9a1ac749596481fbfac4e4bc33d205356f1e880f57410d3406464a0fad23", +}; +const outputDigests = { + "pt-BR/docs/block-jsx.mdx": "0dd866e0845d17b368399c067368c5e9ea5ebcd04e2c76f057851f2dd1bcb407", + "pt-BR/docs/expressions.mdx": "5b26369e1494cc12d929f10343aa5da90ef6edbfc91b87aa8dcae8c3eb58bc24", + "pt-BR/docs/inline-jsx.mdx": "5a18c49dad816aaa4927b6895a314868137650453d471236a4f8ee5f99c3f87c", + "pt-BR/docs/plain-markdown.md": "63d19914e7932d5911b7a2e2db502f63d05ad1ceb52c3ec2ab0e5e183dea1bff", + "pt-BR/docs/static-data.mdx": "6587cc19c5b1aa37729e6ad8c2cc412ae0b73138b920fba04c7974b14d7e9661", + "fr-FR/docs/block-jsx.mdx": "0580cc3c2e0aaea0dfb1905ce689a971bf850f56cd8451c2ddbb912800b65271", + "fr-FR/docs/expressions.mdx": "7fe3552758a30774a699932fa2036ce10811d6c25ece4d4a51d24c2ef1dc707b", + "fr-FR/docs/inline-jsx.mdx": "094b341575f2189ab1a51bb9bde4e0076aa35cbcd5f9879f5d5eafdbd45d0f2e", + "fr-FR/docs/plain-markdown.md": "eec245f660e9574679ae1f7dd4956867f4f7b7742e904b45199dbf58a147d22d", + "fr-FR/docs/static-data.mdx": "1cabe32c0a9ef9764e83b7ed3952ec9d567ab6ef3e4b621651f105695e9415f6", +}; +const expectedTotals = { cacheHits: 0, aiTranslated: 10, overrides: 0, skipped: 0, localSkipped: 0, errors: 0 }; +const mdxImportCounts = { + "docs/block-jsx.mdx": 2, + "docs/expressions.mdx": 1, + "docs/inline-jsx.mdx": 2, + "docs/static-data.mdx": 1, +}; + +process.once("SIGINT", () => void stop(130)); +process.once("SIGTERM", () => void stop(143)); + +try { + await Promise.all([ + rm(path.join(playgroundRoot, "i18n-preview"), { recursive: true, force: true }), + rm(path.join(playgroundRoot, ".astro", "i18n-staging"), { recursive: true, force: true }), + rm(path.join(playgroundRoot, "i18n-r2-report.json"), { force: true }), + ]); + await runCommand(pnpm, ["build"], { cwd: repositoryRoot, timeoutMs: 300_000 }); + const dryRunResult = await runCommand(pnpm, ["--filter", "polystella-playground-mdx-jsx", "translate:dry-run"], { + cwd: repositoryRoot, + env: { ...process.env, LOG_LEVEL: "debug" }, + }); + const dryRunOutput = dryRunResult.stdout + dryRunResult.stderr; + await runCommand(pnpm, ["--filter", "polystella-playground-mdx-jsx", "translate:local"], { cwd: repositoryRoot }); + + const actualDryRunKeys = [...dryRunOutput.matchAll(/would check cache for (i18n\/\S+)/g)].map((match) => match[1]).sort(); + const expectedDryRunKeys = Object.entries(dryRunHashes) + .flatMap(([source, hash]) => ["pt-BR", "fr-FR"].map((locale) => `i18n/${locale}/${source}#${hash}.md`)) + .sort(); + assertEqual("dry-run R2 keys", actualDryRunKeys, expectedDryRunKeys); + + for (const [relativePath, expectedDigest] of Object.entries(outputDigests)) { + const preview = await readFile(path.join(playgroundRoot, "i18n-preview", relativePath), "utf8"); + assertMdxImports(relativePath, preview, "../../components/", "preview"); + assertEqual(`${relativePath} preview digest`, sha256(normalizeOutput(preview)), expectedDigest); + + const staged = await readFile(path.join(playgroundRoot, ".astro", "i18n-staging", relativePath), "utf8"); + assertMdxImports(relativePath, staged, "../../../../src/components/", "staged"); + const normalizedStaged = normalizeOutput(staged).replaceAll('from "../../../../src/components/', 'from "../../components/'); + assertEqual(`${relativePath} staged digest`, sha256(normalizedStaged), expectedDigest); + } + + const report = JSON.parse(await readFile(path.join(playgroundRoot, "i18n-r2-report.json"), "utf8")); + assertEqual("report totals", report.totals, expectedTotals); + assertEqual("report PolyStella version", report.build.polystellaVersion, polystellaVersion); + const reportEntries = [...report.entries].sort((left, right) => + `${left.locale}/${left.sourcePath}`.localeCompare(`${right.locale}/${right.sourcePath}`), + ); + for (const entry of reportEntries) { + const key = `${entry.locale}/${entry.sourcePath}`; + assertEqual(`${key} source hash`, entry.sourceHash, localSourceHashes[key]); + assertEqual(`${key} model`, entry.model, `playground/fake-workers-ai/${entry.locale}`); + } + const normalizedReport = structuredClone(report); + delete normalizedReport.build.startedAt; + delete normalizedReport.build.durationMs; + normalizedReport.build.polystellaVersion = ""; + for (const entry of normalizedReport.entries) delete entry.durationMs; + normalizedReport.entries.sort((left, right) => `${left.locale}/${left.sourcePath}`.localeCompare(`${right.locale}/${right.sourcePath}`)); + assertEqual( + "normalized report digest", + sha256(`${JSON.stringify(normalizedReport, null, 2)}\n`), + "ab8321dd5d768adff6b5df6556a1ae6a288c1cfdbdef6a43691e1255fab88ac0", + ); + + const { computeSourceHash } = await import(pathToFileURL(path.join(repositoryRoot, "packages", "astro", "dist", "index.js")).href); + assertEqual( + "source-hash fixture", + computeSourceHash({ + body: "# Hello\n\nA paragraph.\n", + frontmatter: { title: "Hello", year: 2025 }, + glossaryHash: "g0", + modelId: "@cf/meta/llama-3.1-8b-instruct", + }), + "df40a08682e9df8e0643f5e95651478da8ff06922ad2f8aaec7d479db70bb7ee", + ); + + const { buildPrompt, EMPTY_GLOSSARY } = await import( + pathToFileURL(path.join(repositoryRoot, "packages", "core", "dist", "index.js")).href + ); + const prompt = buildPrompt({ + segments: [ + { id: "fm:title", text: "Hello" }, + { id: "body:0", text: "A paragraph." }, + ], + glossary: EMPTY_GLOSSARY, + sourceLocale: "en-US", + targetLocale: "pt-BR", + }); + assertEqual("system prompt length", prompt.systemPrompt.length, 734); + assertEqual("system prompt digest", sha256(prompt.systemPrompt), "32b336fbdce5ab269488f3f5d77840d1f1a9fa7c7e544d6f4c70bca570e4ef34"); + assertEqual("user prompt length", prompt.userPrompt.length, 322); + assertEqual("user prompt digest", sha256(prompt.userPrompt), "975ae31980e7f7a782ec257d7584e0ba689b01f006e86c34c82ba029a1363685"); + + const { jsonAdapter, yamlAdapter, tomlAdapter } = await import( + pathToFileURL(path.join(repositoryRoot, "packages", "adapters", "dist", "index.js")).href + ); + const adapterCases = [ + [ + jsonAdapter, + '{"title":"Hello","nested":{"body":"World"}}', + '{\n "title": "X:Hello",\n "nested": {\n "body": "X:World",\n "aiTranslated": true\n }\n}', + ], + [yamlAdapter, "title: Hello\nnested:\n body: World\n", "title: X:Hello\nnested:\n body: X:World\n aiTranslated: true\n"], + [ + tomlAdapter, + 'title = "Hello"\n\n[nested]\nbody = "World"\n', + 'title = "X:Hello"\n\n[nested]\nbody = "X:World"\naiTranslated = true\n', + ], + ]; + for (const [adapter, source, expectedOutput] of adapterCases) { + const parsed = adapter.parse(source); + const segments = adapter.extractSegments(parsed, source, { + sourcePath: "content/entry.data", + translatableKeys: { "content/**": ["title", "nested.body"] }, + }); + assertEqual("structured adapter segments", segments, [ + { id: "title", text: "Hello" }, + { id: "nested.body", text: "World" }, + ]); + const translations = new Map(segments.map((segment) => [segment.id, `X:${segment.text}`])); + assertEqual( + "structured adapter output", + adapter.applyTranslations(parsed, source, translations, { topLevelAdditions: { aiTranslated: true } }), + expectedOutput, + ); + } + + console.log("check:baseline passed: 10 dry-run keys, 10 preview/staged outputs, report, hashes, prompts, and 3 structured adapters"); + console.log( + "not checked here: real R2 hits/writes/pruning, overrides, noTranslate, and local-cache skips; existing tests cover these paths", + ); +} finally { + await cleanupCommands(); +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function normalizeOutput(value) { + return value.replace(/^aiTranslatedAt:.*$/mu, "aiTranslatedAt: "); +} + +function assertMdxImports(relativePath, value, expectedPrefix, artifact) { + const sourcePath = relativePath.slice(relativePath.indexOf("/") + 1); + const expectedCount = mdxImportCounts[sourcePath]; + if (expectedCount === undefined) return; + const imports = [...value.matchAll(/^import .+ from "([^"]+)";$/gmu)].map((match) => match[1]); + assertEqual(`${relativePath} ${artifact} import count`, imports.length, expectedCount); + if (!imports.every((specifier) => specifier.startsWith(expectedPrefix))) { + throw new Error(`${relativePath} ${artifact} imports are ${JSON.stringify(imports)}, expected prefix ${expectedPrefix}`); + } +} + +function assertEqual(label, actual, expected) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${label} mismatch\nactual: ${JSON.stringify(actual)}\nexpected: ${JSON.stringify(expected)}`); + } +} + +async function stop(exitCode) { + await cleanupCommands(); + process.exit(exitCode); +} diff --git a/scripts/check-packages.mjs b/scripts/check-packages.mjs new file mode 100644 index 0000000..05cf0e0 --- /dev/null +++ b/scripts/check-packages.mjs @@ -0,0 +1,437 @@ +#!/usr/bin/env node +// @ts-check + +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { gunzipSync } from "node:zlib"; + +import { cleanupCommands, runCommand } from "./run-command.mjs"; + +const repositoryRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url))); +const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const packages = [ + { + directory: path.join(repositoryRoot, "packages", "astro"), + name: "@cloudflare/polystella", + exports: [ + ".", + "./runtime", + "./runtime/middleware", + "./content", + "./i18n", + "./catalog", + "./catalog/middleware", + "./catalog/astro", + "./react", + "./recipes", + "./recipes/starlight", + "./client", + ], + internalDependencies: ["@cloudflare/polystella-core", "@cloudflare/polystella-adapters", "@cloudflare/polystella-providers"], + allowedTopLevel: [ + "CHANGELOG.md", + "LICENSE", + "README.md", + "client.d.ts", + "dist", + "package.json", + "src", + "tsconfig.build.json", + "tsconfig.json", + "types-internal", + ], + requiredFiles: [ + "CHANGELOG.md", + "LICENSE", + "README.md", + "client.d.ts", + "dist/cli.js", + "dist/index.d.ts", + "dist/index.js", + "src/index.ts", + ], + executable: "dist/cli.js", + }, + { + directory: path.join(repositoryRoot, "packages", "core"), + name: "@cloudflare/polystella-core", + exports: ["."], + internalDependencies: [], + allowedTopLevel: ["CHANGELOG.md", "LICENSE", "README.md", "dist", "package.json", "src"], + requiredFiles: ["CHANGELOG.md", "LICENSE", "README.md", "dist/index.d.ts", "dist/index.js", "src/index.ts"], + }, + { + directory: path.join(repositoryRoot, "packages", "adapters"), + name: "@cloudflare/polystella-adapters", + exports: ["."], + internalDependencies: ["@cloudflare/polystella-core"], + allowedTopLevel: ["CHANGELOG.md", "LICENSE", "README.md", "dist", "package.json", "src"], + requiredFiles: ["CHANGELOG.md", "LICENSE", "README.md", "dist/index.d.ts", "dist/index.js", "src/index.ts"], + }, + { + directory: path.join(repositoryRoot, "packages", "providers"), + name: "@cloudflare/polystella-providers", + exports: [".", "./workers-ai", "./anthropic"], + internalDependencies: ["@cloudflare/polystella-core"], + allowedTopLevel: ["CHANGELOG.md", "LICENSE", "README.md", "dist", "package.json", "src"], + requiredFiles: ["CHANGELOG.md", "LICENSE", "README.md", "dist/index.d.ts", "dist/index.js", "src/index.ts"], + }, +]; +const lowerPackageEntries = [ + "@cloudflare/polystella-core", + "@cloudflare/polystella-adapters", + "@cloudflare/polystella-providers", + "@cloudflare/polystella-providers/workers-ai", + "@cloudflare/polystella-providers/anthropic", +]; +const nodeSafeAstroEntries = [ + "@cloudflare/polystella", + "@cloudflare/polystella/catalog", + "@cloudflare/polystella/catalog/middleware", + "@cloudflare/polystella/catalog/astro", + "@cloudflare/polystella/recipes", + "@cloudflare/polystella/recipes/starlight", +]; + +let temporaryRoot; +let cleanupPromise; +let handlingFailure = false; + +process.once("SIGINT", () => void fail(undefined, 130)); +process.once("SIGTERM", () => void fail(undefined, 143)); +process.once("uncaughtException", (error) => void fail(error, 1)); +process.once("unhandledRejection", (error) => void fail(error, 1)); + +await main().catch((error) => fail(error, 1)); + +async function main() { + temporaryRoot = await mkdtemp(path.join(tmpdir(), "polystella-packages-")); + const packDirectory = path.join(temporaryRoot, "tarballs"); + const consumerDirectory = path.join(temporaryRoot, "consumer"); + try { + const rootManifest = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8")); + assertEqual("workspace root private", rootManifest.private, true); + for (const { name } of packages.filter(({ name }) => name !== "@cloudflare/polystella")) { + assertEqual(`workspace root dependency ${name}`, rootManifest.devDependencies?.[name], "workspace:*"); + } + await runCommand(pnpm, ["build"], { cwd: repositoryRoot, timeoutMs: 300_000 }); + await mkdir(packDirectory, { recursive: true }); + await mkdir(consumerDirectory, { recursive: true }); + + const packedPackages = new Map(); + let sourceVersion; + for (const packageInfo of packages) { + const sourceManifest = JSON.parse(await readFile(path.join(packageInfo.directory, "package.json"), "utf8")); + if (typeof sourceManifest.version !== "string") throw new Error(`${packageInfo.name}: source version is missing`); + if (sourceVersion === undefined) sourceVersion = sourceManifest.version; + assertSourceManifest(packageInfo, sourceManifest, sourceVersion); + await runCommand(pnpm, ["pack", "--pack-destination", packDirectory], { cwd: packageInfo.directory }); + const tarballPath = path.join(packDirectory, `${packageInfo.name.slice(1).replace("/", "-")}-${sourceManifest.version}.tgz`); + if (!existsSync(tarballPath)) throw new Error(`pnpm did not create ${tarballPath}`); + + const files = readTarball(await readFile(tarballPath)); + const packedManifestFile = files.get("package/package.json"); + if (packedManifestFile === undefined) throw new Error(`${packageInfo.name}: package.json is missing from tarball`); + const packedManifest = JSON.parse(packedManifestFile.contents.toString("utf8")); + assertManifest(packageInfo, packedManifest, files); + assertTarball(packageInfo, files); + packedPackages.set(packageInfo.name, { manifest: packedManifest, tarballPath }); + } + + const versions = new Set([...packedPackages.values()].map(({ manifest }) => manifest.version)); + if (versions.size !== 1) throw new Error(`packed package versions differ: ${JSON.stringify([...versions])}`); + const [commonVersion] = versions; + if (typeof commonVersion !== "string") throw new Error("packed package version is missing"); + for (const packageInfo of packages) { + const packed = packedPackages.get(packageInfo.name); + if (packed === undefined) throw new Error(`missing packed package ${packageInfo.name}`); + const actualInternalDependencies = Object.keys(packed.manifest.dependencies ?? {}).filter((name) => + name.startsWith("@cloudflare/polystella"), + ); + assertEqual( + `${packageInfo.name} internal dependencies`, + actualInternalDependencies.sort(), + [...packageInfo.internalDependencies].sort(), + ); + for (const dependency of packageInfo.internalDependencies) { + assertEqual(`${packageInfo.name} dependency ${dependency}`, packed.manifest.dependencies?.[dependency], commonVersion); + } + } + + await writeConsumer(consumerDirectory, packedPackages); + await runCommand(pnpm, ["install", "--ignore-scripts"], { cwd: consumerDirectory, timeoutMs: 300_000 }); + await assertTarballInstall(consumerDirectory, packedPackages); + + await runCommand(process.execPath, ["check-imports.mjs"], { cwd: consumerDirectory }); + const cli = await runCommand(pnpm, ["exec", "polystella", "--version"], { cwd: consumerDirectory }); + assertEqual("installed CLI version", cli.stdout.trim(), commonVersion); + await runCommand(pnpm, ["exec", "astro", "build"], { + cwd: consumerDirectory, + env: { ...process.env, CI: "true" }, + timeoutMs: 180_000, + }); + await runCommand(pnpm, ["exec", "tsc", "--noEmit"], { cwd: consumerDirectory, timeoutMs: 180_000 }); + + console.log( + `check:packages passed: 4 tarballs at ${commonVersion}, 11 runtime imports, lower-package and Astro types, installed Astro build/typecheck, and CLI`, + ); + } finally { + await cleanup(); + } +} + +async function writeConsumer(consumerDirectory, packedPackages) { + const tarballDependencies = Object.fromEntries( + packages.map(({ name }) => { + const packed = packedPackages.get(name); + if (packed === undefined) throw new Error(`missing packed package ${name}`); + return [name, `file:${packed.tarballPath}`]; + }), + ); + await writeFile( + path.join(consumerDirectory, "package.json"), + `${JSON.stringify( + { + private: true, + type: "module", + dependencies: { ...tarballDependencies, astro: "^7.0.10", react: "^19.0.0" }, + devDependencies: { "@types/react": "^19.0.0", typescript: "^6.0.3" }, + }, + null, + 2, + )}\n`, + ); + await writeFile( + path.join(consumerDirectory, "pnpm-workspace.yaml"), + `overrides:\n${packages + .filter(({ name }) => name !== "@cloudflare/polystella") + .map(({ name }) => ` ${JSON.stringify(name)}: ${JSON.stringify(tarballDependencies[name])}`) + .join("\n")}\n`, + ); + await writeFile( + path.join(consumerDirectory, "check-imports.mjs"), + `${[...lowerPackageEntries, ...nodeSafeAstroEntries] + .map( + (specifier) => + `if (Object.keys(await import(${JSON.stringify(specifier)})).length === 0) throw new Error(${JSON.stringify(`${specifier} has no exports`)});`, + ) + .join("\n")}\n`, + ); + await writeFile( + path.join(consumerDirectory, "astro.config.mjs"), + `import polystella from "@cloudflare/polystella";\nimport { defineConfig } from "astro/config";\n\nexport default defineConfig({\n integrations: [polystella({ sourceDir: "./src/content", include: ["**/*.md"], dryRun: true })],\n i18n: { defaultLocale: "en-US", locales: ["en-US", "pt-BR"] },\n});\n`, + ); + await writeFile( + path.join(consumerDirectory, "tsconfig.json"), + `${JSON.stringify( + { + extends: "astro/tsconfigs/strict", + include: [".astro/types.d.ts", "src/**/*.ts", "src/**/*.d.ts"], + compilerOptions: { noEmit: true }, + }, + null, + 2, + )}\n`, + ); + await mkdir(path.join(consumerDirectory, "src", "content", "docs"), { recursive: true }); + await mkdir(path.join(consumerDirectory, "src", "pages"), { recursive: true }); + await writeFile( + path.join(consumerDirectory, "src", "env.d.ts"), + `/// \n/// \n`, + ); + await writeFile( + path.join(consumerDirectory, "src", "content.config.ts"), + `import { polystellaCollections } from "@cloudflare/polystella/content";\nimport { defineCollection, z } from "astro:content";\nimport { glob } from "astro/loaders";\n\nconst docs = defineCollection({ loader: glob({ pattern: "**/*.md", base: "./src/content/docs" }), schema: z.object({ title: z.string() }) });\nexport const collections = polystellaCollections({ source: { docs } });\n`, + ); + await writeFile(path.join(consumerDirectory, "src", "content", "docs", "hello.md"), `---\ntitle: Hello\n---\n\n# Hello\n`); + await writeFile( + path.join(consumerDirectory, "src", "entrypoints.ts"), + `import { jsonAdapter } from "@cloudflare/polystella-adapters";\nimport { buildPrompt, EMPTY_GLOSSARY, type Segment, type Translator } from "@cloudflare/polystella-core";\nimport { createWorkersAIHttpTranslator } from "@cloudflare/polystella-providers";\nimport { createAnthropicTranslator, type AnthropicTranslatorOptions } from "@cloudflare/polystella-providers/anthropic";\nimport { createWorkersAIBindingTranslator, type WorkersAIInput } from "@cloudflare/polystella-providers/workers-ai";\nimport { polystellaCollections } from "@cloudflare/polystella/content";\nimport { getTranslations } from "@cloudflare/polystella/i18n";\nimport { useTranslations } from "@cloudflare/polystella/react";\nimport { localizedHref } from "@cloudflare/polystella/runtime";\nimport { polystellaMiddleware } from "@cloudflare/polystella/runtime/middleware";\nimport { defaultLocale } from "polystella:runtime-config";\n\nconst segment: Segment = { id: "body:0", text: "Hello" };\nconst prompt = buildPrompt({ segments: [segment], glossary: EMPTY_GLOSSARY, sourceLocale: "en-US", targetLocale: "pt-BR" });\nconst input: WorkersAIInput = { messages: [{ role: "user", content: prompt.userPrompt }], max_tokens: 64 };\nconst bindingTranslator: Translator = createWorkersAIBindingTranslator({ modelId: "test", maxTokens: 64, run: async () => ({ response: "Ola" }) });\nconst httpTranslator: Translator = createWorkersAIHttpTranslator({ accountId: "test", apiToken: "test", modelId: "test", maxTokens: 64 });\nconst anthropicOptions: AnthropicTranslatorOptions = { apiKey: "test", modelId: "test", maxTokens: 64 };\nconst anthropicTranslator: Translator = createAnthropicTranslator(anthropicOptions);\n\nexport const typedEntrypoints = [jsonAdapter, prompt, input, bindingTranslator, httpTranslator, anthropicTranslator, polystellaCollections, getTranslations, useTranslations, localizedHref, polystellaMiddleware, defaultLocale];\n`, + ); + await writeFile( + path.join(consumerDirectory, "src", "pages", "index.astro"), + `---\nimport { getTranslations } from "@cloudflare/polystella/i18n";\nimport { useTranslations } from "@cloudflare/polystella/react";\nimport { localizedHref } from "@cloudflare/polystella/runtime";\nimport { polystellaMiddleware } from "@cloudflare/polystella/runtime/middleware";\n\nconst surfaces = [getTranslations, useTranslations, localizedHref, polystellaMiddleware];\n---\nInstalled PolyStella\n`, + ); +} + +async function assertTarballInstall(consumerDirectory, packedPackages) { + const lockfile = await readFile(path.join(consumerDirectory, "pnpm-lock.yaml"), "utf8"); + const consumerLocation = await realpath(consumerDirectory); + for (const packageInfo of packages) { + const packed = packedPackages.get(packageInfo.name); + if (packed === undefined) throw new Error(`missing packed package ${packageInfo.name}`); + const tarballName = path.basename(packed.tarballPath); + if (!new RegExp(`file:[^\\n]*${escapeRegExp(tarballName)}`).test(lockfile)) + throw new Error(`${packageInfo.name}: lockfile does not reference ${tarballName}`); + + const installedManifestPath = path.join(consumerDirectory, "node_modules", ...packageInfo.name.split("/"), "package.json"); + const installedLocation = await realpath(path.dirname(installedManifestPath)); + const relativeLocation = path.relative(consumerLocation, installedLocation); + if (relativeLocation.startsWith("..") || path.isAbsolute(relativeLocation)) { + throw new Error(`${packageInfo.name}: installed outside temporary consumer at ${installedLocation}`); + } + const installedManifest = JSON.parse(await readFile(installedManifestPath, "utf8")); + assertEqual(`${packageInfo.name} installed manifest`, installedManifest, packed.manifest); + } +} + +function assertManifest(packageInfo, manifest, files) { + assertEqual(`${packageInfo.name} packed name`, manifest.name, packageInfo.name); + assertEqual(`${packageInfo.name} exports`, Object.keys(manifest.exports ?? {}).sort(), [...packageInfo.exports].sort()); + if (JSON.stringify(manifest).includes("workspace:")) throw new Error(`${packageInfo.name}: packed manifest contains a workspace: range`); + for (const target of exportTargets(manifest.exports)) { + const file = `package/${target.replace(/^\.\//, "")}`; + if (!files.has(file)) throw new Error(`${packageInfo.name}: export target ${target} is missing from tarball`); + } +} + +function assertSourceManifest(packageInfo, manifest, sourceVersion) { + assertEqual(`${packageInfo.name} source version`, manifest.version, sourceVersion); + const actualInternalDependencies = Object.keys(manifest.dependencies ?? {}).filter((name) => name.startsWith("@cloudflare/polystella")); + assertEqual( + `${packageInfo.name} source internal dependencies`, + actualInternalDependencies.sort(), + [...packageInfo.internalDependencies].sort(), + ); + for (const dependency of packageInfo.internalDependencies) { + assertEqual(`${packageInfo.name} source dependency ${dependency}`, manifest.dependencies?.[dependency], "workspace:*"); + } +} + +function assertTarball(packageInfo, files) { + const allowedTopLevel = new Set(packageInfo.allowedTopLevel); + for (const [file, entry] of files) { + if (!file.startsWith("package/")) throw new Error(`${packageInfo.name}: unexpected tar entry ${file}`); + const relativePath = file.slice("package/".length); + const pathSegments = relativePath.split("/"); + const normalizedPathSegments = pathSegments.map((segment) => segment.toLowerCase()); + if (!allowedTopLevel.has(pathSegments[0])) throw new Error(`${packageInfo.name}: ${relativePath} is outside the tar allowlist`); + if ( + /(?:^|\/)[^/]+\.(?:test|spec)(?:-[^/]+)?\.[^/]+$/i.test(relativePath) || + /(?:^|\/)[^/]+\.snap$/i.test(relativePath) || + /(?:^|\/)[^/]+\.(?:key|p12|pem|pfx)$/i.test(relativePath) || + /(?:^|\/)(?:credentials?|private[-_.]?key|secrets?)\.(?:json|toml|txt|ya?ml)$/i.test(relativePath) || + /(?:^|\/)id_(?:dsa|ecdsa|ed25519|rsa)$/i.test(relativePath) || + /-----BEGIN (?:PRIVATE KEY|(?:DSA|EC|ENCRYPTED|OPENSSH|RSA) PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----/.test( + entry.contents.toString("utf8"), + ) || + normalizedPathSegments.some( + (segment) => + segment === "test" || + segment === "tests" || + segment === "__tests__" || + segment === "__fixtures__" || + segment === "__snapshots__" || + segment === "node_modules" || + segment === ".wrangler" || + segment.startsWith(".dev.vars") || + segment === ".env" || + segment.startsWith(".env."), + ) + ) { + throw new Error(`${packageInfo.name}: forbidden tar entry ${relativePath}`); + } + } + for (const requiredFile of ["package.json", ...packageInfo.requiredFiles]) { + if (!files.has(`package/${requiredFile}`)) throw new Error(`${packageInfo.name}: required file ${requiredFile} is missing`); + } + assertSourceMaps(packageInfo, files); + if (packageInfo.executable !== undefined) { + const executable = files.get(`package/${packageInfo.executable}`); + if (executable === undefined) throw new Error(`${packageInfo.name}: executable ${packageInfo.executable} is missing`); + if ((executable.mode & 0o111) === 0) throw new Error(`${packageInfo.name}: ${packageInfo.executable} is not executable`); + if (!executable.contents.toString("utf8").startsWith("#!/usr/bin/env node\n")) { + throw new Error(`${packageInfo.name}: ${packageInfo.executable} has no Node shebang`); + } + } +} + +function assertSourceMaps(packageInfo, files) { + const emittedFiles = [...files.keys()].filter( + (file) => file.startsWith("package/dist/") && (file.endsWith(".js") || file.endsWith(".d.ts")), + ); + for (const emittedFile of emittedFiles) { + const mapFile = `${emittedFile}.map`; + const mapEntry = files.get(mapFile); + if (mapEntry === undefined) throw new Error(`${packageInfo.name}: ${mapFile.slice(8)} is missing`); + + const sourceMap = JSON.parse(mapEntry.contents.toString("utf8")); + if ( + sourceMap.version !== 3 || + !Array.isArray(sourceMap.sources) || + sourceMap.sources.length === 0 || + !sourceMap.sources.every((source) => typeof source === "string") || + (sourceMap.sourceRoot !== undefined && typeof sourceMap.sourceRoot !== "string") + ) { + throw new Error(`${packageInfo.name}: ${mapFile.slice(8)} has invalid sources`); + } + const mapDirectory = path.posix.dirname(mapFile.slice("package/".length)); + const sourceRoot = typeof sourceMap.sourceRoot === "string" ? sourceMap.sourceRoot : ""; + for (const source of sourceMap.sources) { + const resolvedSource = path.posix.normalize(path.posix.join(mapDirectory, sourceRoot, source)); + if (resolvedSource.startsWith("../") || !files.has(`package/${resolvedSource}`)) { + throw new Error(`${packageInfo.name}: ${mapFile.slice(8)} references missing source ${resolvedSource}`); + } + } + } +} + +function exportTargets(value) { + if (typeof value === "string") return value.startsWith("./") ? [value] : []; + if (value === null || typeof value !== "object") return []; + return Object.values(value).flatMap(exportTargets); +} + +function readTarball(compressed) { + const archive = gunzipSync(compressed); + const files = new Map(); + for (let offset = 0; offset + 512 <= archive.length;) { + const header = archive.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) break; + const name = readTarString(header, 0, 100); + const prefix = readTarString(header, 345, 155); + const mode = Number.parseInt(readTarString(header, 100, 8).trim() || "0", 8); + const size = Number.parseInt(readTarString(header, 124, 12).trim() || "0", 8); + if (!Number.isFinite(mode)) throw new Error(`invalid tar mode for ${name}`); + if (!Number.isFinite(size)) throw new Error(`invalid tar size for ${name}`); + const fullName = prefix ? `${prefix}/${name}` : name; + const start = offset + 512; + files.set(fullName, { contents: archive.subarray(start, start + size), mode }); + offset = start + Math.ceil(size / 512) * 512; + } + return files; +} + +function readTarString(buffer, offset, length) { + const end = buffer.indexOf(0, offset); + return buffer.toString("utf8", offset, end === -1 || end > offset + length ? offset + length : end); +} + +function assertEqual(label, actual, expected) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${label} mismatch\nactual: ${JSON.stringify(actual)}\nexpected: ${JSON.stringify(expected)}`); + } +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function cleanup() { + cleanupPromise ??= (async () => { + await cleanupCommands(); + if (temporaryRoot !== undefined) await rm(temporaryRoot, { recursive: true, force: true }); + })(); + return cleanupPromise; +} + +async function fail(error, exitCode) { + if (handlingFailure) return; + handlingFailure = true; + if (error !== undefined) console.error(error); + await cleanup(); + process.exitCode = exitCode; +} diff --git a/scripts/check-workerd-portability.mjs b/scripts/check-workerd-portability.mjs new file mode 100644 index 0000000..1022faf --- /dev/null +++ b/scripts/check-workerd-portability.mjs @@ -0,0 +1,216 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { builtinModules } from "node:module"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const wrangler = join(root, "docs", "node_modules", "wrangler", "bin", "wrangler.js"); +const config = join(root, "fixtures", "workerd", "wrangler.jsonc"); +const nodeBuiltins = new Set(builtinModules.map((name) => name.replace(/^node:/, ""))); +const children = new Set(); +let temporaryDirectory; +let cleanupPromise; +let handlingFailure = false; + +process.once("SIGINT", () => void fail(undefined, 130)); +process.once("SIGTERM", () => void fail(undefined, 143)); +process.once("uncaughtException", (error) => void fail(error, 1)); +process.once("unhandledRejection", (error) => void fail(error, 1)); + +await main().catch((error) => fail(error, 1)); + +async function main() { + temporaryDirectory = await mkdtemp(join(tmpdir(), "polystella-workerd-")); + try { + const bundleDirectory = join(temporaryDirectory, "bundle"); + const dryRun = await runWrangler(["deploy", "--dry-run", "--config", config, "--outdir", bundleDirectory], 30_000); + process.stdout.write(dryRun); + + const bundleFiles = (await listFiles(bundleDirectory)).filter((file) => /\.[cm]?js$/.test(file)); + assert(bundleFiles.length > 0, "Wrangler dry-run produced no JavaScript bundle"); + const bundle = (await Promise.all(bundleFiles.map((file) => readFile(file, "utf8")))).join("\n"); + inspectBundle(bundle); + console.log(`Bundle inspection passed (${bundleFiles.length} JavaScript file${bundleFiles.length === 1 ? "" : "s"}).`); + + const port = await getFreePort(); + const server = spawnWrangler([ + "dev", + "--config", + config, + "--ip", + "127.0.0.1", + "--port", + String(port), + "--persist-to", + join(temporaryDirectory, "state"), + ]); + try { + const response = await waitForWorker(server, `http://127.0.0.1:${port}/`); + assert.deepEqual(await response.json(), { prompt: true, title: "Ola", translation: "Ola" }); + console.log("Wrangler no-compat runtime passed."); + } finally { + await terminate(server); + } + } finally { + await cleanup(); + } +} + +function inspectBundle(bundle) { + const moduleReference = /\b(?:from\s+|import\s*(?:\(\s*)?|(?:__)?require\s*\(\s*)["']([^"']+)["']/g; + for (const [, specifier] of bundle.matchAll(moduleReference)) { + assert(!isNodeBuiltin(specifier), `Node builtin ${specifier} found in Wrangler bundle`); + assert( + !/^(?:@astrojs\/|astro(?:\/|$)|react(?:-dom)?(?:\/|$)|satteri(?:\/|$))/.test(specifier), + `${specifier} found in Wrangler bundle`, + ); + } + assert.doesNotMatch(bundle, /node_modules\/(?:@astrojs|astro|react|react-dom|satteri)\//, "Astro/React/Satteri found in Wrangler bundle"); + assert.doesNotMatch(bundle, /["'][^"'\n]+\.node["']/, "native module found in Wrangler bundle"); +} + +function isNodeBuiltin(specifier) { + const normalized = specifier.replace(/^node:/, ""); + return specifier.startsWith("node:") || nodeBuiltins.has(normalized) || nodeBuiltins.has(normalized.split("/")[0]); +} + +function spawnWrangler(args) { + const child = spawn(process.execPath, [wrangler, ...args], { + cwd: root, + detached: process.platform !== "win32", + env: { ...process.env, CI: "true", WRANGLER_SEND_METRICS: "false" }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.output = ""; + child.spawnError = undefined; + child.closed = new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal }))); + child.once("error", (error) => (child.spawnError = error)); + child.stdout.on("data", (chunk) => (child.output += chunk)); + child.stderr.on("data", (chunk) => (child.output += chunk)); + children.add(child); + void child.closed.then(() => children.delete(child)); + return child; +} + +async function runWrangler(args, timeoutMs) { + const child = spawnWrangler(args); + const result = await within(child.closed, timeoutMs); + if (result === undefined) { + await terminate(child); + throw new Error(`Wrangler timed out after ${timeoutMs}ms:\n${child.output}`); + } + if (child.spawnError) throw child.spawnError; + if (result.code !== 0) throw new Error(`Wrangler exited with ${result.code ?? result.signal}:\n${child.output}`); + return child.output; +} + +async function waitForWorker(child, url) { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + if (child.spawnError) throw child.spawnError; + if (child.exitCode !== null || child.signalCode !== null) { + await within(child.closed, 1_000); + throw new Error(`Wrangler dev exited before startup:\n${child.output}`); + } + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1_000) }); + if (response.ok) return response; + } catch { + // Wrangler is still starting. + } + await delay(100); + } + throw new Error(`Wrangler dev did not start within 20 seconds:\n${child.output}`); +} + +async function terminate(child) { + if (child.exitCode !== null || child.signalCode !== null) { + await within(child.closed, 1_000); + return; + } + await killTree(child, false); + if ((await within(child.closed, 5_000)) !== undefined) return; + await killTree(child, true); + if ((await within(child.closed, 5_000)) !== undefined) return; + child.stdout.destroy(); + child.stderr.destroy(); + child.unref(); + throw new Error(`Unable to terminate Wrangler process tree ${child.pid}`); +} + +async function killTree(child, force) { + if (child.pid === undefined) return; + if (process.platform === "win32") { + const taskkill = spawn("taskkill", ["/pid", String(child.pid), "/t", ...(force ? ["/f"] : [])], { stdio: "ignore" }); + const closed = new Promise((resolve) => { + taskkill.once("error", resolve); + taskkill.once("close", resolve); + }); + if ((await within(closed, 3_000)) === undefined) taskkill.kill(); + return; + } + try { + process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM"); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + child.kill(force ? "SIGKILL" : "SIGTERM"); + } +} + +async function cleanup() { + cleanupPromise ??= (async () => { + await Promise.allSettled([...children].map((child) => terminate(child))); + if (temporaryDirectory !== undefined) await rm(temporaryDirectory, { recursive: true, force: true }); + })(); + return cleanupPromise; +} + +async function fail(error, exitCode) { + if (handlingFailure) return; + handlingFailure = true; + if (error !== undefined) console.error(error); + await cleanup(); + process.exitCode = exitCode; +} + +async function getFreePort() { + const server = createServer(); + await new Promise((resolve, reject) => server.listen(0, "127.0.0.1", resolve).once("error", reject)); + const address = server.address(); + assert(address && typeof address === "object"); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + return address.port; +} + +async function listFiles(directory) { + const files = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + files.push(...(entry.isDirectory() ? await listFiles(path) : [path])); + } + return files; +} + +async function within(promise, timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, timeoutMs, undefined); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds, undefined)); +} diff --git a/scripts/configure-git-hooks.mjs b/scripts/configure-git-hooks.mjs new file mode 100644 index 0000000..333340b --- /dev/null +++ b/scripts/configure-git-hooks.mjs @@ -0,0 +1,3 @@ +import { spawnSync } from "node:child_process"; + +spawnSync("git", ["config", "core.hooksPath", ".githooks"], { stdio: "ignore" }); diff --git a/scripts/run-command.mjs b/scripts/run-command.mjs new file mode 100644 index 0000000..8391977 --- /dev/null +++ b/scripts/run-command.mjs @@ -0,0 +1,104 @@ +import { spawn } from "node:child_process"; + +const children = new Map(); + +export async function runCommand(command, args, options) { + const invocation = resolveInvocation(command, args); + const child = spawn(invocation.command, invocation.args, { + cwd: options.cwd, + detached: process.platform !== "win32", + env: options.env ?? process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + const state = { + stdout: "", + stderr: "", + spawnError: undefined, + closed: new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal }))), + }; + child.once("error", (error) => (state.spawnError = error)); + child.stdout.on("data", (chunk) => { + state.stdout += chunk; + if (options.echo) process.stdout.write(chunk); + }); + child.stderr.on("data", (chunk) => { + state.stderr += chunk; + if (options.echo) process.stderr.write(chunk); + }); + children.set(child, state); + void state.closed.then(() => children.delete(child)); + + const timeoutMs = options.timeoutMs ?? 120_000; + const result = await within(state.closed, timeoutMs); + if (result === undefined) { + await terminateCommand(child); + throw new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms\n${state.stdout}${state.stderr}`); + } + if (state.spawnError !== undefined) throw state.spawnError; + if (result.code !== 0) { + throw new Error(`${command} ${args.join(" ")} exited with ${result.code ?? result.signal}\n${state.stdout}${state.stderr}`); + } + return { stdout: state.stdout, stderr: state.stderr }; +} + +function resolveInvocation(command, args) { + if (process.platform !== "win32" || !command.toLowerCase().endsWith(".cmd")) return { command, args }; + const commandLine = [command, ...args].map((value) => `"${value.replaceAll('"', '""')}"`).join(" "); + return { command: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", commandLine] }; +} + +export async function cleanupCommands() { + await Promise.allSettled([...children.keys()].map((child) => terminateCommand(child))); +} + +async function terminateCommand(child) { + const state = children.get(child); + if (state === undefined) return; + if (child.exitCode !== null || child.signalCode !== null) { + await within(state.closed, 1_000); + return; + } + await killTree(child, false); + if ((await within(state.closed, 5_000)) !== undefined) return; + await killTree(child, true); + if ((await within(state.closed, 5_000)) !== undefined) return; + child.stdout.destroy(); + child.stderr.destroy(); + child.unref(); + throw new Error(`Unable to terminate process tree ${child.pid}`); +} + +async function killTree(child, force) { + if (child.pid === undefined) return; + if (process.platform === "win32") { + const taskkill = spawn("taskkill", ["/pid", String(child.pid), "/t", ...(force ? ["/f"] : [])], { stdio: "ignore" }); + const closed = new Promise((resolve) => { + taskkill.once("error", resolve); + taskkill.once("close", resolve); + }); + if ((await within(closed, 3_000)) === undefined) taskkill.kill(); + return; + } + try { + process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM"); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + child.kill(force ? "SIGKILL" : "SIGTERM"); + } +} + +function within(promise, timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, timeoutMs, undefined); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} diff --git a/skills/polystella-consumer/SKILL.md b/skills/polystella-consumer/SKILL.md index 7d478f2..2f9355d 100644 --- a/skills/polystella-consumer/SKILL.md +++ b/skills/polystella-consumer/SKILL.md @@ -35,7 +35,7 @@ pnpm add @cloudflare/polystella The standalone CLI binary is still named `polystella`. -Peer dependency: `astro ^7.0.0`. +Peer dependency: `astro ^7.0.10`. ## Four-file integration @@ -66,7 +66,9 @@ export default defineConfig({ ### 2. `polystella.config.mjs` -Where provider, glossary, R2, format-specific keys live. Schema source of truth is `src/config/options.ts` in the package; everything is zod-validated at the boundary. +Where provider, glossary, R2, format-specific keys live. The schema source +of truth is `packages/astro/src/config/options.ts` in the repository; +everything is zod-validated at the boundary. Skeleton: @@ -147,6 +149,26 @@ export const collections = { Picks up types for PolyStella's virtual modules (`polystella:runtime-config`). +## Direct package use + +Use the lower-level packages when Astro should not own the operation: + +```text +source/record -> adapter -> core -> provider -> core -> adapter -> output +``` + +- Import translation contracts, glossaries, prompts, batching, and + `PermanentProviderError` from `@cloudflare/polystella-core`. +- Import portable format adapters from + `@cloudflare/polystella-adapters`. +- Import Workers AI and Anthropic factories from + `@cloudflare/polystella-providers` or its provider subpaths. + +These packages use standard Web APIs and run in Workers without +`nodejs_compat`; enabling `nodejs_compat` is also supported. The Astro +package has no compatibility shims for low-level imports that moved to +these owners. + ## UI strings Chrome text (nav, footer, accessibility strings) lives in @@ -366,9 +388,9 @@ When a translation is wrong: | You want to | Look at | | :-------------------- | :-------------------------------------------------------------------- | -| Understand the system | `node_modules/polystella/ARCHITECTURE.md` | -| See config schema | `node_modules/polystella/src/config/options.ts` | -| See available exports | `node_modules/polystella/package.json` (`exports` field) | +| Understand the system | `https://github.com/cloudflare/polystella/blob/main/ARCHITECTURE.md` | +| See config schema | `node_modules/@cloudflare/polystella/src/config/options.ts` | +| See available exports | Each installed `@cloudflare/polystella*` package manifest | | See CLI flags | `polystella --help`, `polystella --help` | | Debug a translation | `dist/i18n-r2-report.json`, `/.astro/i18n-staging//...` | | File an issue | `https://github.com/cloudflare/polystella/issues` | diff --git a/skills/polystella-contributor/SKILL.md b/skills/polystella-contributor/SKILL.md index 1bdd4fa..f2a3232 100644 --- a/skills/polystella-contributor/SKILL.md +++ b/skills/polystella-contributor/SKILL.md @@ -18,6 +18,18 @@ Read first: Then come back here for step-by-step task recipes. +Package ownership follows the direct in-process flow: + +```text +source/record -> adapter -> core -> provider -> core -> adapter -> output +``` + +Core owns low-level translation contracts and orchestration, adapters own +portable formats, providers own transports, and Astro owns host policy. +Reusable packages use standard Web APIs and must work without +`nodejs_compat`; consumers may enable it. Do not add compatibility shims +for low-level imports that moved out of the Astro package. + --- ## Recipes @@ -40,17 +52,17 @@ Then come back here for step-by-step task recipes. **When to use:** Supporting a new file extension (`.xml`, `.html`, `.po`, custom format). -**Contract:** `FileTypeAdapter` in `src/parsing/adapter.ts`. See [#adapter-contract](../../ARCHITECTURE.md#adapter-contract). +**Contract:** `FileAdapter` in `packages/adapters/src/adapter.ts`; Astro policies wrap it in `packages/astro/src/parsing/adapter.ts`. See [#adapter-contract](../../ARCHITECTURE.md#adapter-contract). **Steps:** -1. Implement the adapter at `src/parsing/adapters/.ts`: +1. Implement the portable adapter at `packages/adapters/src/adapters/.ts`: ```ts - import type { FileTypeAdapter, AdapterExtractOptions, AdapterApplyOptions } from "../adapter.js"; - import type { Segment } from "../extract.js"; + import type { Segment } from "@cloudflare/polystella-core"; + import type { FileAdapter, AdapterExtractOptions, AdapterApplyOptions } from "../adapter.js"; - export const myFormatAdapter: FileTypeAdapter = { + export const myFormatAdapter: FileAdapter = { extensions: [".myext"], parse(source, sourcePath) { @@ -72,24 +84,14 @@ Then come back here for step-by-step task recipes. // into the output here, not after. }, - selectedValuesForHash(parsed, source, opts): Record { - // Snapshot of values that feed the cache hash. Only fields - // your adapter considers translatable should appear here. - }, - - peekNoTranslate(parsed): boolean { - // Return true when the source is opted out via your format's - // convention (e.g. top-level `noTranslate: true`). - }, - - // Optional: - rewriteUrls(bytes, opts): string { ... }, // post-cache; idempotent - groupSegments(parsed, segments): Segment[][] { ... }, // INVARIANT 2 - documentContext(parsed, opts): string | undefined { ... }, + groupSegments(parsed, segments): Segment[][] { ... }, // optional, INVARIANT 2 }; ``` -2. Register in `src/parsing/registry.ts`: +2. Add Astro's cache-selection, `noTranslate`, URL, document-context, + marker, and parser policies in a small wrapper under + `packages/astro/src/parsing/adapters/`, then register that wrapper in + `packages/astro/src/parsing/registry.ts`: ```ts import { myFormatAdapter } from "./adapters/myformat.js"; @@ -99,27 +101,23 @@ Then come back here for step-by-step task recipes. **First-registered wins.** If your adapter claims an extension another adapter already owns, your registration is silently ignored. The order at the bottom of `registry.ts` is the de-facto priority. -3. Add tests under `tests/parsing/adapters/.test.ts`. Mirror the structure of an existing adapter test (`tests/parsing/adapters/toml.test.ts` is a good template — it's structured-data-flavoured like most new adapters will be). +3. Add portable tests under `packages/adapters/tests/` and retain Astro-policy parity tests under `packages/astro/tests/parsing/`. - Required test coverage: - - `parse` round-trip (parse → reserialize via `applyTranslations` with no translations → byte-identical) - - `extractSegments` produces expected IDs - - `applyTranslations` splices correctly - - `selectedValuesForHash` snapshots ONLY translatable fields - - `peekNoTranslate` honours your format's opt-out convention - - If you implement `rewriteUrls`: idempotent on already-rewritten input - - If you implement `groupSegments`: `flat(result) === segments` (reference-equal) + Required portable coverage: parsing/reconstruction, segment IDs, + translation application, and group flattening by reference. Astro wrapper + tests cover selected hash values, `noTranslate`, markers, context, and + idempotent URL rewriting. -4. **No changes to `src/translation/run.ts` or `src/storage/cache.ts`.** The orchestrator dispatches by extension via the registry; the cache layer is format-agnostic. If you find yourself editing either, you're doing something wrong. +4. **No changes to `packages/astro/src/translation/run.ts` or `packages/astro/src/storage/cache.ts`.** The orchestrator dispatches by extension via the registry; the cache layer is format-agnostic. If you find yourself editing either, you're doing something wrong. 5. Verify: ```sh pnpm test - pnpm exec tsc --noEmit + pnpm typecheck ``` -6. Update the contributor README's status table and any per-format docs. +6. Update the package README and any per-format docs. --- @@ -129,11 +127,11 @@ Then come back here for step-by-step task recipes. **When to use:** Adding a new top-level verb (`polystella `). -**Pattern:** Each subcommand owns its argv parsing and a `run(args, deps)` handler. The dispatcher in `src/cli.ts` is a thin router. +**Pattern:** Each subcommand owns its argv parsing and a `run(args, deps)` handler. The dispatcher in `packages/astro/src/cli.ts` is a thin router. **Steps:** -1. Create `src/cli/.ts`: +1. Create `packages/astro/src/cli/.ts`: ```ts export interface MySubcommandArgs { @@ -176,15 +174,15 @@ Then come back here for step-by-step task recipes. } ``` -2. Wire dispatch in `src/cli.ts`: +2. Wire dispatch in `packages/astro/src/cli.ts`: - Add to the `Subcommand` union type. - Add the literal to `parseSubcommand`'s `if (first === "translate" || ...)` check. - Add a case to `main()`'s switch statement. - Update `TOP_LEVEL_USAGE` to mention the new verb. 3. Add tests: - - `tests/cli/.test.ts` for the argv parser + handler (with stubbed deps). - - Extend `tests/cli.test.ts` if the top-level dispatch needs new coverage (it usually does — add at least one "dispatches `my-subcommand` to the right handler" case). + - `packages/astro/tests/cli/.test.ts` for the argv parser + handler (with stubbed deps). + - Extend `packages/astro/tests/cli.test.ts` if the top-level dispatch needs new coverage (it usually does — add at least one "dispatches `my-subcommand` to the right handler" case). 4. If consumers typically wrap the subcommand in a `pnpm` script (e.g. `pnpm i18n:sync`), document the pattern in the docs site's CLI section. Don't add the wrapper to this package — consumer projects own their own scripts. @@ -192,9 +190,9 @@ Then come back here for step-by-step task recipes. ```sh pnpm test - pnpm exec tsc --noEmit + pnpm typecheck pnpm build - node dist/cli.js my-subcommand --help # sanity-check the emitted CLI + node packages/astro/dist/cli.js my-subcommand --help # sanity-check the emitted CLI ``` --- @@ -205,11 +203,11 @@ Then come back here for step-by-step task recipes. **When to use:** Adding a third translator (e.g. OpenAI, Bedrock). -**Contract:** `Translator` in `src/translation/provider.ts`. See [#translator-contract](../../ARCHITECTURE.md#translator-contract). +**Contract:** `Translator` in `packages/core/src/translator.ts`. Provider transports live in `packages/providers`; `packages/astro/src/translation/provider.ts` only maps Astro config. See [#translator-contract](../../ARCHITECTURE.md#translator-contract). **Steps:** -1. Add a config variant to the provider zod schema in `src/config/options.ts`: +1. Add a config variant to the provider zod schema in `packages/astro/src/config/options.ts`: ```ts const newProviderSchema = z.object({ @@ -224,55 +222,47 @@ Then come back here for step-by-step task recipes. const providerSchema = z.discriminatedUnion("kind", [workersAISchema, anthropicSchema, newProviderSchema]); ``` -2. Implement the translator factory in `src/translation/provider.ts`: +2. Implement a concrete-model factory in `packages/providers/src/.ts`: ```ts - function createNewProviderTranslator( - provider: NewProviderConfig, - locale: string, - fetchImpl: typeof fetch, - ): Translator { - const modelId = resolveModelId(provider.model, locale); - + export function createNewProviderTranslator(options: { + apiKey: string; + modelId: string; + maxTokens: number; + fetchImpl?: typeof fetch; + }): Translator { return { - modelId, + modelId: options.modelId, async translate(systemPrompt, userPrompt, signal) { - const res = await fetchImpl(endpoint, { + const res = await (options.fetchImpl ?? fetch)(endpoint, { method: "POST", headers: { ... }, body: JSON.stringify({ ... }), ...(signal !== undefined ? { signal } : {}), }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - const message = `[polystella] new-provider request failed: ${res.status} ${res.statusText}${text ? `\n${text}` : ""}`; - if (PERMANENT_HTTP_STATUSES.has(res.status)) { - throw new PermanentProviderError(message); - } - throw new Error(message); - } - - const data = await res.json(); - // Extract the model's raw text; caller validates via parseResponse. - // Round-trip via JSON.stringify if the provider pre-parses on the server. - return text; + if (!res.ok) throw await createProviderHttpError("New provider", res, signal); + return normalizeResponse(await res.json()); }, }; } ``` -3. Wire into `createTranslator`: +3. Export the factory from `packages/providers/src/index.ts`, then map the validated config in Astro's `createTranslator`: ```ts if (provider.kind === "new-provider") { - return createNewProviderTranslator(provider, locale, fetchImpl); + return createNewProviderTranslator({ + apiKey: provider.apiKey, + modelId: resolveModelId(provider.model, locale), + maxTokens: provider.maxTokens, + }); } ``` -4. **Permanent vs retriable** — `PERMANENT_HTTP_STATUSES` is `{400, 401, 403, 404, 422}`. Don't widen this without thinking about what flaky responses might wrongly skip retry. 5xx, 408, 425, 429 are retriable. **Ask first** before adding statuses (per `AGENTS.md` Boundaries). +4. **Permanent vs retriable** — reuse the providers package's HTTP classifier. The permanent set is `{400, 401, 403, 404, 422}`; 5xx, 408, 425, and 429 are retriable. **Ask first** before adding statuses. -5. Add tests at `tests/translation/provider.test.ts` covering: +5. Add transport tests under `packages/providers/tests/` and retain Astro facade parity coverage in `packages/astro/tests/translation/provider.test.ts`: - Happy path (mock fetch returns expected shape). - Each permanent status → `PermanentProviderError`. - 5xx → plain `Error` (retriable). @@ -280,7 +270,7 @@ Then come back here for step-by-step task recipes. - Unexpected response shape → clear error message with raw response preview. - `signal` propagation to `fetch`. -6. Document the new provider in the package README's config section. +6. Document the new provider in the package README and docs provider section. --- @@ -303,10 +293,10 @@ Then come back here for step-by-step task recipes. 2. **Stop.** Coordinate with the owner before merging. This is **Invariant 1** in `AGENTS.md`. The change needs to be in a major version bump and called out in CHANGELOG. 3. If you're confident this is the right change: - - Edit `src/storage/hash.ts` (the `computeSourceHash` function). + - Edit `packages/astro/src/storage/hash.ts` (the `computeSourceHash` function). - Update the formula description in `ARCHITECTURE.md#cache-key`. - Update `AGENTS.md` Invariant #1. - - Update the hash test pin in `tests/storage/hash.test.ts` — it pins a literal hash to catch accidental formula drift. Compute the new literal and replace it. + - Update the hash test pin in `packages/astro/tests/storage/hash.test.ts` — it pins a literal hash to catch accidental formula drift. Compute the new literal and replace it. - Add a CHANGELOG entry under a "Breaking changes" heading. - Bump the major version (or 0.x minor pre-1.0). @@ -314,7 +304,7 @@ Then come back here for step-by-step task recipes. ```sh pnpm test - pnpm exec tsc --noEmit + pnpm typecheck ``` The pinned-hash test will catch drift if you missed the test update. @@ -329,7 +319,7 @@ Then come back here for step-by-step task recipes. **Diagnostic flow:** -1. **Reproduce on the fixture.** If the regression is reported against a consumer's content, reduce to the smallest source file that reproduces. Add it under `tests/fixtures/` if it's worth a regression test. +1. **Reproduce on the fixture.** If the regression is reported against a consumer's content, reduce to the smallest source file that reproduces. Add it under `packages/astro/tests/fixtures/` if it's worth a regression test. 2. **Inspect what the cache layer planned:** @@ -355,7 +345,7 @@ Then come back here for step-by-step task recipes. cat dist/i18n-r2-report.json | jq '.entries[] | select(.sourcePath == "")' ``` - Outcome will be `hit`, `miss`, `override`, `error`, or `localSkipped`. Read the corresponding code path in `src/storage/cache.ts` or `src/source/overrides.ts`. + Outcome will be `hit`, `miss`, `override`, `error`, or `localSkipped`. Read the corresponding code path in `packages/astro/src/storage/cache.ts` or `packages/astro/src/source/overrides.ts`. 5. **Crank up verbosity:** @@ -392,13 +382,13 @@ Then come back here for step-by-step task recipes. **Files:** -- `src/runtime/middleware.ts` — request middleware; pre-binds locale to all four locals. -- `src/runtime/middleware-core.ts` — middleware body (test-friendly extract). -- `src/runtime/get-localized-entry.ts`, `get-localized-collection.ts` — fetcher implementations. -- `src/runtime/localized-href.ts` — URL prefixer. -- `src/runtime/custom-loader-runtime.ts` — the **bridge** (module-scoped singleton shared with sibling collections). -- `src/runtime/locals.ts` — TypeScript ambient declarations for `Astro.locals`. Was `locals.d.ts` until the dist-emit rework; renamed so tsc emits both an empty `.js` and the `.d.ts` declarations, and `runtime/index.ts` pulls it in via a side-effect import (the previous triple-slash `` directive gets stripped by tsc at emit time). -- `src/react/index.ts` — `useTranslations`, `useLocalizedHref` hooks. +- `packages/astro/src/runtime/middleware.ts` — request middleware; pre-binds locale to all four locals. +- `packages/astro/src/runtime/middleware-core.ts` — middleware body (test-friendly extract). +- `packages/astro/src/runtime/get-localized-entry.ts`, `get-localized-collection.ts` — fetcher implementations. +- `packages/astro/src/runtime/localized-href.ts` — URL prefixer. +- `packages/astro/src/runtime/custom-loader-runtime.ts` — the **bridge** (symbol-keyed `globalThis` state shared with sibling collections across Vite module reloads). +- `packages/astro/src/runtime/locals.ts` — TypeScript ambient declarations for `Astro.locals`. Was `locals.d.ts` until the dist-emit rework; renamed so tsc emits both an empty `.js` and the `.d.ts` declarations, and `runtime/index.ts` pulls it in via a side-effect import (the previous triple-slash `` directive gets stripped by tsc at emit time). +- `packages/astro/src/react/index.ts` — `useTranslations`, `useLocalizedHref` hooks. **Key contracts:** @@ -408,11 +398,11 @@ Then come back here for step-by-step task recipes. **Steps:** 1. Edit the relevant runtime file. -2. Update `src/runtime/locals.ts` if you're changing the shape of `Astro.locals`. +2. Update `packages/astro/src/runtime/locals.ts` if you're changing the shape of `Astro.locals`. 3. Update the `polystella-consumer` skill's "Runtime APIs" section. -4. Add tests under `tests/runtime/`: +4. Add tests under `packages/astro/tests/runtime/`: - Behaviour test for the new/changed function. - - Middleware-binding test if the locals shape changes (`tests/runtime/middleware.test.ts`). + - Middleware-binding test if the locals shape changes (`packages/astro/tests/runtime/middleware.test.ts`). 5. Don't forget the React side — `useTranslations` / `useLocalizedHref` and their consumer-side wiring (`getDictionary`). --- @@ -425,12 +415,12 @@ Then come back here for step-by-step task recipes. **Files:** -- `src/i18n/drift.ts` — `checkI18nDrift`, `loadAndCheckDrift`. -- `src/i18n/sync.ts` — key reconciliation; **layout-aware** JSON writer (`formatLocaleFile`). -- `src/i18n/ui-translate.ts` — AI-fill orchestrator; `{{token}}` validator + retry wrapper. -- `src/i18n/loader.ts`, `i18n/index.ts` — content-layer loader, dictionary fetcher. -- `src/catalog/*` — catalog-only public exports, middleware, and Astro integration. Must stay free of content translation, R2, route shims, and localized collection imports. -- `src/cli/check-ui.ts`, `sync-ui.ts`, `translate-ui.ts` — CLI handlers. +- `packages/astro/src/i18n/drift.ts` — `checkI18nDrift`, `loadAndCheckDrift`. +- `packages/astro/src/i18n/sync.ts` — key reconciliation; **layout-aware** JSON writer (`formatLocaleFile`). +- `packages/astro/src/i18n/ui-translate.ts` — AI-fill orchestrator; `{{token}}` validator + retry wrapper. +- `packages/astro/src/i18n/loader.ts`, `i18n/index.ts` — content-layer loader, dictionary fetcher. +- `packages/astro/src/catalog/*` — catalog-only public exports, middleware, and Astro integration. Must stay free of content translation, R2, route shims, and localized collection imports. +- `packages/astro/src/cli/check-ui.ts`, `sync-ui.ts`, `translate-ui.ts` — CLI handlers. **Key contracts:** @@ -525,18 +515,18 @@ if (typeof data !== "object" || data === null) throw new Error(`unexpected: ${x} -- Tests live under `tests//.test.ts`. Top-level exceptions: `tests/cli.test.ts` (top-level dispatch + translate-subcommand parsing), `tests/cli/` (per-subcommand handlers), `tests/smoke.test.ts` (end-to-end integration smoke). -- Vitest config in `vitest.config.ts`. `singleThread: true` — faster than multi-worker at this scale. +- Astro tests live under `packages/astro/tests//.test.ts`. Top-level exceptions: `packages/astro/tests/cli.test.ts` (top-level dispatch + translate-subcommand parsing), `packages/astro/tests/cli/` (per-subcommand handlers), `packages/astro/tests/smoke.test.ts` (end-to-end integration smoke). +- Astro Vitest config is `packages/astro/vitest.config.ts`. `singleThread: true` — faster than multi-worker at this scale. - Fakeable boundaries: each subsystem accepts a `deps`-shaped object so tests can inject stubs. The CLI's `runCheckUi(args, deps)` shape is the canonical example. - For tests that need a clean adapter registry: call `resetRegistry()` before re-registering. -- For tests that exercise R2: use the in-memory R2 client at `tests/helpers/in-memory-r2.ts` (or whatever the equivalent helper is). +- For tests that exercise R2: follow the inline in-memory client in `packages/astro/tests/storage/cache.test.ts`. - For tests that exercise the translator: pass `translatorOverrides` to `runTranslationPass` with a fake `Translator`. -- For smoke tests: drive `polystella(options)` with stubbed Astro context against a real temp project. `tests/smoke.test.ts` is the template. -- For the doc-claims test (`tests/docs.test.ts`): pins file paths and command names referenced in `AGENTS.md` / `ARCHITECTURE.md`. If you move a file or rename a subcommand, update both the docs AND this test. +- For smoke tests: drive `polystella(options)` with stubbed Astro context against a real temp project. `packages/astro/tests/smoke.test.ts` is the template. +- For the doc-claims test (`packages/astro/tests/docs.test.ts`): pins file paths and command names referenced in `AGENTS.md` / `ARCHITECTURE.md`. If you move a file or rename a subcommand, update both the docs AND this test. Verify before pushing: ```sh pnpm test -pnpm exec tsc --noEmit +pnpm typecheck ``` diff --git a/src/parsing/adapters/json.ts b/src/parsing/adapters/json.ts deleted file mode 100644 index d7e045d..0000000 --- a/src/parsing/adapters/json.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { AdapterApplyOptions, AdapterExtractOptions, AdapterRewriteUrlsOptions, FileTypeAdapter } from "../adapter.js"; -import type { Segment } from "../extract.js"; -import { expandPath, parsePath, readAtPath, resolveConcretePaths, writeAtPath, type PathSegment } from "../key-paths.js"; - -/** - * JSON adapter. Parses with the native `JSON.parse`, extracts - * translatable scalars at user-configured key paths (with wildcard - * support), and applies translations by mutating the parsed - * structure and re-stringifying with a stable two-space indent. - * - * **Round-trip fidelity (relaxed).** JSON has no comments, but key - * order, indentation, and trailing-newline conventions in the source - * are NOT preserved. `JSON.stringify(_, null, 2)` produces canonical - * output. Source files are never rewritten by polystella, so this - * only affects translation outputs (regenerated each build). - * - * **Cache key.** Uses the same body+selectedValues+glossary+model - * hash composition as the markdown / TOML adapters today; whitespace - * in source files DOES bust the cache. The structured-data variant - * (drop `rawBody`, hash only canonical selected values) is documented - * as future work in the design doc §3.1. - * - * **noTranslate opt-out.** Top-level boolean `noTranslate: true` - * skips the file. JSON's strict type system means no string aliases - * (matching TOML; YAML's looser parsing accepts `"true"` / `"yes"` - * for parity with markdown frontmatter). - * - * **AI-marker injection (per-entry, not file root).** Astro's - * `file()` loader maps each top-level JSON key to a separate - * collection entry, with the value as that entry's `data`. Marker - * fields written at the file root would manifest as bogus extra - * entries (e.g. an entry with id `aiTranslated` whose data is - * `true`) and fail schema validation. The adapter therefore injects - * the marker fields INSIDE each top-level object-valued key — top- - * level scalar keys are skipped (their values are already valid - * entry data and the marker has nowhere meaningful to attach). - * - * **Top-level array handling.** A JSON file with a top-level array - * (Astro maps each array element to a collection entry by `id` / - * `slug`) gets the marker injected into each element that's an - * object — same intent as the per-key injection for object roots. - */ -export const jsonAdapter: FileTypeAdapter = { - extensions: [".json"], - - parse(source: string): JsonData { - return JSON.parse(source) as JsonData; - }, - - extractSegments(parsed: JsonData, _source: string, opts: AdapterExtractOptions): Segment[] { - const segments: Segment[] = []; - const concretePaths = resolveConcretePaths({ - parsed, - sourcePath: opts.sourcePath, - translatableKeys: opts.translatableKeys, - }); - for (const path of concretePaths) { - const { segments: pathSegs } = parsePath(path); - const value = readAtPath(parsed, pathSegs as PathSegment[]); - // Only string scalars are translatable; numbers / booleans / - // null / nested structures are passed through untouched. - if (typeof value === "string" && value.length > 0) { - segments.push({ id: path, text: value }); - } - } - return segments; - }, - - applyTranslations(parsed: JsonData, _source: string, translations: Map, opts: AdapterApplyOptions): string { - // Deep-clone before mutating so adapters can be re-invoked on - // the same parsed object across cache misses without cross- - // contamination. JSON values are pure JSON-ish (strings, - // numbers, booleans, null, plain objects, arrays) — `structuredClone` - // handles all of them. - const out = structuredClone(parsed) as JsonData; - - for (const [id, translation] of translations) { - const { segments } = parsePath(id); - // Translator round-trips IDs verbatim, so wildcards never - // appear here. If they do (malformed translator output), - // the path is invalid and the write throws — caller's per-pair - // try/catch surfaces it. - writeAtPath(out, segments as PathSegment[], translation); - } - - // Marker injection: per-entry, not file-root. Mirrors the TOML - // adapter's logic — see toml.ts for the rationale. JSON adds - // top-level-array handling: when the root is an array, each - // element that's an object gets the marker (matches Astro's - // file() loader rule of "id-or-slug per array element → entry"). - if (opts.topLevelAdditions) { - injectMarkerIntoEntries(out, opts.topLevelAdditions); - } - - return JSON.stringify(out, null, 2); - }, - - selectedValuesForHash(parsed: JsonData, _source: string, opts: AdapterExtractOptions): Record { - const result: Record = {}; - const concretePaths = resolveConcretePaths({ - parsed, - sourcePath: opts.sourcePath, - translatableKeys: opts.translatableKeys, - }); - for (const path of concretePaths) { - const { segments } = parsePath(path); - const value = readAtPath(parsed, segments as PathSegment[]); - // Capture every value the rules cover, not just strings: - // `year: 2025 → 2026` should bust the cache too. Non-string - // values flow into the canonical hash as-is. - if (value !== undefined) { - result[path] = value; - } - } - return result; - }, - - peekNoTranslate(parsed: JsonData): boolean { - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return false; - } - return (parsed as Record).noTranslate === true; - }, - - /** - * Walk configured URL paths in the parsed bytes and rewrite - * matched string values via `opts.rewriter`. Re-parses the bytes - * (rather than receiving a parsed structure) because the pipeline - * calls this AFTER `applyTranslations` returns serialised bytes - * — keeps cached bytes URL-rewrite-naïve so a `noPrefixUrls` - * config edit doesn't bust the cache. - * - * Wildcards (`[*]`, `.*`) expand against the post-apply structure - * so URL paths can target dynamic shapes like `entries[*].url`. - * No-op when the configured paths produce no concrete matches or - * every matched value passes the rewriter unchanged. - */ - rewriteUrls(bytes: string, opts: AdapterRewriteUrlsOptions): string { - if (opts.paths.length === 0) return bytes; - const parsed = JSON.parse(bytes) as JsonData; - const out = structuredClone(parsed) as JsonData; - let mutated = false; - for (const rule of opts.paths) { - for (const concrete of expandPath(rule, out)) { - const { segments } = parsePath(concrete); - const value = readAtPath(out, segments as PathSegment[]); - if (typeof value !== "string") continue; - const rewritten = opts.rewriter(value); - if (rewritten === null || rewritten === value) continue; - writeAtPath(out, segments as PathSegment[], rewritten); - mutated = true; - } - } - if (!mutated) return bytes; - return JSON.stringify(out, null, 2); - }, -}; - -/** - * Type alias for `JSON.parse` output. Modelled as `unknown` because - * a JSON file's root may be an object, array, or scalar; the adapter - * handles each shape dynamically. - */ -export type JsonData = unknown; - -/** - * Inject the AI-marker fields per-entry. Behaviour: - * - * - Object root → walk top-level keys; merge marker into each - * object-valued key. Scalar-valued top-level keys are skipped - * (Astro maps them to entries with scalar `data` — no place to - * attach the marker). - * - Array root → walk each element; merge marker into each - * object-valued element. Scalar / null elements are skipped. - * - Scalar root → no-op (the file doesn't represent collection - * entries in any meaningful way). - * - * Mutates `out` in place. Caller owns the deep-clone. - */ -function injectMarkerIntoEntries(out: JsonData, additions: Record): void { - if (out === null || typeof out !== "object") return; - - if (Array.isArray(out)) { - for (const element of out) { - if (element === null || typeof element !== "object" || Array.isArray(element)) continue; - for (const [key, value] of Object.entries(additions)) { - (element as Record)[key] = value; - } - } - return; - } - - for (const [, topValue] of Object.entries(out)) { - if (topValue === null || typeof topValue !== "object" || Array.isArray(topValue)) continue; - for (const [key, value] of Object.entries(additions)) { - (topValue as Record)[key] = value; - } - } -} diff --git a/src/parsing/adapters/markdown.ts b/src/parsing/adapters/markdown.ts deleted file mode 100644 index 376f2b2..0000000 --- a/src/parsing/adapters/markdown.ts +++ /dev/null @@ -1,263 +0,0 @@ -import type { Root, Yaml } from "mdast"; -import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; - -import type { - AdapterApplyOptions, - AdapterDocumentContextOptions, - AdapterExtractOptions, - AdapterParseOptions, - AdapterRewriteUrlsOptions, - FileTypeAdapter, -} from "../adapter.js"; -import { applyTranslations } from "../apply.js"; -import { extractSegments, peekNoTranslate, resolveFrontmatterKeys, selectTranslatableFrontmatter } from "../extract.js"; -import type { Segment } from "../extract.js"; -import { parseMarkdown, parseMdx } from "../parse.js"; -import { visitTranslatableBlocks } from "../traverse.js"; - -/** - * Markdown / MDX adapter. Wraps the existing `parse.ts` / `extract.ts` - * / `apply.ts` markdown pipeline behind the generic `FileTypeAdapter` - * interface so the registry can dispatch by extension uniformly with - * structured-data adapters. - * - * **Behaviour invariant.** The adapter is a thin shim — no logic - * lives here that doesn't already live in the underlying functions. - * Existing tests against `extractSegments` / `applyTranslations` / - * `peekNoTranslate` continue to exercise the same code paths. - * - * `.mdx` sources use the MDX-aware parser path; `.md` sources keep - * plain Markdown parsing rules. - */ -export const markdownAdapter: FileTypeAdapter = { - extensions: [".md", ".mdx"], - - /** - * Dispatch parser by file extension: `.mdx` opts into MDX-aware - * parsing (recognising `import`/`export`, JSX components, and - * expression bindings as first-class AST nodes); `.md` (or no - * hint) uses pure markdown. The hint is optional for backward - * compatibility — callers pre-dating the multi-format dispatch - * still get plain-markdown parsing, which matches the historical - * behaviour for `.mdx` files (treated as markdown with HTML). - */ - parse(source: string, sourcePath?: string, opts: AdapterParseOptions = {}): Root { - if (sourcePath !== undefined && sourcePath.toLowerCase().endsWith(".mdx")) { - return parseMdx(source, { parser: opts.markdownParser }); - } - return parseMarkdown(source, { parser: opts.markdownParser }); - }, - - extractSegments(parsed: Root, source: string, opts: AdapterExtractOptions): Segment[] { - // The existing extractor takes the user-facing `frontmatter` map; - // the adapter interface generalises that to `translatableKeys`. - // For markdown the two are interchangeable. - return extractSegments( - parsed, - { - sourcePath: opts.sourcePath, - frontmatter: opts.translatableKeys, - ...(opts.mdxRules !== undefined ? { mdxRules: opts.mdxRules } : {}), - }, - source, - ); - }, - - applyTranslations(parsed: Root, source: string, translations: Map, opts: AdapterApplyOptions): string { - return applyTranslations(parsed, translations, source, { - ...(opts.sourcePath !== undefined ? { sourcePath: opts.sourcePath } : {}), - ...(opts.mdxRules !== undefined ? { mdxRules: opts.mdxRules } : {}), - ...(opts.topLevelAdditions ? { frontmatterAdditions: opts.topLevelAdditions } : {}), - }); - }, - - selectedValuesForHash(parsed: Root, _source: string, opts: AdapterExtractOptions): Record { - return selectTranslatableFrontmatter(parsed, { - sourcePath: opts.sourcePath, - frontmatter: opts.translatableKeys, - }); - }, - - peekNoTranslate(parsed: Root): boolean { - return peekNoTranslate(parsed); - }, - - /** - * Frontmatter URL rewriter. Body inline links are NOT touched - * here — the pipeline runs `rewriteInternalLinks` over body bytes - * separately. Rationale: body link rewriting is span-based - * (mdast `link` nodes byte-spliced in place), so it has no shared - * code with key-path-based URL rewriting and folding it in here - * would be a behaviour change with no upside. - * - * Re-parses `bytes` to find the frontmatter span, walks the - * configured URL keys, and splices a re-stringified YAML block - * back in. No-op when there's no frontmatter, no configured keys, - * or no value at any configured key passes the rewriter check. - */ - rewriteUrls(bytes: string, opts: AdapterRewriteUrlsOptions): string { - if (opts.paths.length === 0) return bytes; - const ast = parseMarkdown(bytes, { parser: opts.markdownParser }); - const fm = ast.children.find((child): child is Yaml => child.type === "yaml"); - if (!fm || typeof fm.position?.start?.offset !== "number" || typeof fm.position?.end?.offset !== "number") { - return bytes; - } - const data = parseYaml(fm.value) as Record; - let mutated = false; - for (const key of opts.paths) { - const value = data[key]; - if (typeof value !== "string") continue; - const rewritten = opts.rewriter(value); - if (rewritten === null || rewritten === value) continue; - data[key] = rewritten; - mutated = true; - } - if (!mutated) return bytes; - const newInner = stringifyYaml(data).replace(/\n+$/, ""); - const start = fm.position.start.offset; - const end = fm.position.end.offset; - return `${bytes.slice(0, start)}---\n${newInner}\n---${bytes.slice(end)}`; - }, - - /** - * Heading-anchored grouping (ARCHITECTURE.md §17). - * - * Walks the AST in DFS order via the shared `visitTranslatableBlocks` - * (the same iteration `extractSegments` uses, so IDs align) and - * partitions emitted segments into groups. Every heading node - * starts a new group; non-heading blocks (paragraphs, table cells) - * append to the current group. Frontmatter segments are appended - * as a single trailing group regardless of body shape. - * - * Invariant: `flat(result) === segments` by reference, in order. - * The runtime assertion at the end catches grouping bugs early — - * if it ever fires in production it means the AST shape changed - * out from under us (e.g. an MDX node type whose ID numbering - * doesn't match `extractSegments`). - */ - groupSegments(parsed: Root, segments: Segment[]): Segment[][] { - if (segments.length === 0) return []; - - // Index segments by ID for O(1) lookup during the walk. The - // visitor numbers `body:N` for every translatable block; only - // blocks whose inline span yielded text are in `segments`. - const segmentById = new Map(); - for (const seg of segments) segmentById.set(seg.id, seg); - - const bodyGroups: Segment[][] = []; - let currentGroup: Segment[] = []; - - visitTranslatableBlocks(parsed, ({ block, id }) => { - const seg = segmentById.get(id); - if (seg === undefined) return; // block didn't emit a segment (empty span) - if (block.type === "heading") { - if (currentGroup.length > 0) { - bodyGroups.push(currentGroup); - currentGroup = []; - } - currentGroup.push(seg); - } else { - currentGroup.push(seg); - } - }); - if (currentGroup.length > 0) { - bodyGroups.push(currentGroup); - } - - // Frontmatter segments use the `fm:` prefix and are appended - // after body segments by `extractSegments`. A prefix scan - // avoids a second AST walk and preserves their original order. - const fmGroup: Segment[] = []; - for (const seg of segments) { - if (seg.id.startsWith("fm:")) fmGroup.push(seg); - } - - const mdxDataGroup: Segment[] = []; - for (const seg of segments) { - if (!seg.id.startsWith("body:") && !seg.id.startsWith("fm:")) mdxDataGroup.push(seg); - } - - const groups: Segment[][] = [...bodyGroups]; - if (mdxDataGroup.length > 0) groups.push(mdxDataGroup); - if (fmGroup.length > 0) groups.push(fmGroup); - - // Always-on invariant check: flat(groups) must equal segments - // by reference + order. Cost is O(n) on already-small arrays; - // cheap relative to the AST walk we just did. - const flat = groups.flat(); - if (flat.length !== segments.length) { - throw new Error( - `[polystella] markdownAdapter.groupSegments invariant violated: produced ${flat.length} segments but received ${segments.length}`, - ); - } - for (let i = 0; i < flat.length; i++) { - if (flat[i] !== segments[i]) { - throw new Error( - `[polystella] markdownAdapter.groupSegments invariant violated: segment at position ${i} differs (expected "${segments[i]?.id}", got "${flat[i]?.id}")`, - ); - } - } - - return groups; - }, - - /** - * Document-context framing block (ARCHITECTURE.md §17). - * - * Reads configured `contextKeys` for the source's glob, pulls - * matching frontmatter values (string-typed only), and formats - * each as `: `. Multi-line - * values collapse to one line so the model treats each entry as - * a single context item. - * - * Returns `undefined` when no values resolve — the caller then - * omits the DOCUMENT CONTEXT block from the prompt, preserving - * byte-identical output to today. - */ - documentContext(parsed: Root, opts: AdapterDocumentContextOptions): string | undefined { - const fm = parsed.children.find((child): child is Yaml => child.type === "yaml"); - if (!fm) return undefined; - - const keys = resolveFrontmatterKeys(opts.sourcePath, opts.contextKeys); - if (keys.length === 0) return undefined; - - let data: unknown; - try { - data = parseYaml(fm.value); - } catch { - // Malformed frontmatter is the operator's problem; don't - // crash the build over a missing context block. - return undefined; - } - if (data === null || typeof data !== "object") return undefined; - const map = data as Record; - - const lines: string[] = []; - for (const key of keys) { - const value = map[key]; - if (typeof value !== "string") continue; - // Collapse runs of whitespace-around-newline to a single space. - // Handles `\n`, `\r\n`, and double-newlines uniformly. - const flat = value.replace(/\s*\n\s*/g, " ").trim(); - if (flat.length === 0) continue; - lines.push(`${titleCaseKey(key)}: ${flat}`); - } - - return lines.length > 0 ? lines.join("\n") : undefined; - }, -}; - -/** - * Convert a snake/kebab key into a title-cased label for the - * document-context block. `og_description` → `Og Description`, - * `title` → `Title`, `seo-meta_image` → `Seo Meta Image`. Multiple - * adjacent separators collapse to a single space; leading/trailing - * separators don't produce empty words. - */ -function titleCaseKey(key: string): string { - return key - .split(/[_-]+/) - .filter((w) => w.length > 0) - .map((word) => word[0]!.toUpperCase() + word.slice(1)) - .join(" "); -} diff --git a/src/parsing/adapters/toml.ts b/src/parsing/adapters/toml.ts deleted file mode 100644 index f3edf93..0000000 --- a/src/parsing/adapters/toml.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; - -import type { AdapterApplyOptions, AdapterExtractOptions, AdapterRewriteUrlsOptions, FileTypeAdapter } from "../adapter.js"; -import type { Segment } from "../extract.js"; -import { expandPath, parsePath, readAtPath, resolveConcretePaths, writeAtPath, type PathSegment } from "../key-paths.js"; - -/** - * TOML adapter. Parses with `smol-toml`, extracts translatable - * scalars at user-configured key paths (with wildcard support), and - * applies translations by mutating the parsed structure and - * re-stringifying. - * - * **Round-trip fidelity (relaxed).** Comments and exact key ordering - * are not preserved on output — `smol-toml.stringify` produces clean - * canonical TOML. This is acceptable for translation outputs (the - * staged file is regenerated each build); source files are never - * rewritten by polystella. - * - * **Cache key.** Today the runtime feeds raw body bytes + selected - * values into `computeSourceHash`. For TOML that means whitespace - * and comment edits in source files DO bust the cache; design doc - * §3.1 calls out a structured-data variant that drops `rawBody` and - * hashes `canonicalSelectedValues + glossary + model`. Implementing - * that variant is M3.5 follow-up work — for v0.1.x ship, the - * conservative current behaviour is fine. - * - * **noTranslate opt-out.** Top-level boolean `noTranslate = true` - * skips the file. (No string aliases — TOML's stricter type system - * doesn't need them, unlike YAML frontmatter.) - * - * **AI-marker injection (per-entry, not file root).** Astro's - * `file()` loader maps each top-level TOML key to a separate - * collection entry, with the value as that entry's `data`. Marker - * fields written at the file root would manifest as bogus extra - * entries (e.g. an entry with id `aiTranslated` whose data is - * `true`) and fail schema validation. The adapter therefore injects - * the marker fields INSIDE each top-level object-valued key — so a - * file like `[main.featuredResearch]\n...` becomes `[main]\n - * aiTranslated = true\n[main.featuredResearch]\n...` after - * translation. Top-level scalar keys (numbers / booleans / strings) - * are left untouched: they're already entries with non-object data - * and the marker has nowhere meaningful to live on them. Files with - * a single top-level key (the common `file()` loader case) get the - * marker on that key; multi-entry files get it on each one. - * - * Consumer schemas extended by `polystellaCollections` accept these - * fields uniformly across formats — TOML siblings work identically - * to markdown siblings on the consumer side. - */ -export const tomlAdapter: FileTypeAdapter = { - extensions: [".toml"], - - parse(source: string): TomlData { - return parseToml(source) as TomlData; - }, - - extractSegments(parsed: TomlData, _source: string, opts: AdapterExtractOptions): Segment[] { - const segments: Segment[] = []; - const concretePaths = resolveConcretePaths({ - parsed, - sourcePath: opts.sourcePath, - translatableKeys: opts.translatableKeys, - }); - for (const path of concretePaths) { - const { segments: pathSegs } = parsePath(path); - const value = readAtPath(parsed, pathSegs as PathSegment[]); - // Only string scalars are translatable; numbers / booleans / - // dates / nested structures are passed through untouched. - if (typeof value === "string" && value.length > 0) { - segments.push({ id: path, text: value }); - } - } - return segments; - }, - - applyTranslations(parsed: TomlData, _source: string, translations: Map, opts: AdapterApplyOptions): string { - // Deep-clone before mutating so adapters can be re-invoked on the - // same parsed object across cache misses without cross-contamination. - // smol-toml's parse output is pure JSON-ish (strings, numbers, - // booleans, plain objects, arrays, Dates) — `structuredClone` - // handles all of those. - const out = structuredClone(parsed) as TomlData; - - for (const [id, translation] of translations) { - const { segments } = parsePath(id); - // Translator round-trips IDs verbatim, so wildcards never - // appear here. If they do (malformed translator output), - // the path is invalid and the write throws — caller's per-pair - // try/catch surfaces it. - writeAtPath(out, segments as PathSegment[], translation); - } - - // Marker injection: per-entry, not file-root. - // - // Astro's `file()` loader treats every top-level TOML key as an - // entry. Writing `aiTranslated = true` at the file root would - // produce a bogus extra entry whose data is `true` (failing - // schema validation). Instead, we walk the parsed top level and - // merge the marker fields into every object-valued key — they - // become part of each entry's `data`, accepted by the - // polystellaCollections-extended sibling schema. - // - // Top-level scalar keys are skipped: their values are already - // valid entry data (number/string/boolean) and the marker has - // nowhere meaningful to attach. - if (opts.topLevelAdditions) { - for (const [topKey, topValue] of Object.entries(out)) { - if (topValue === null || typeof topValue !== "object" || Array.isArray(topValue)) { - continue; - } - for (const [markerKey, markerValue] of Object.entries(opts.topLevelAdditions)) { - (topValue as Record)[markerKey] = markerValue; - } - } - } - - return stringifyToml(out as Record); - }, - - selectedValuesForHash(parsed: TomlData, _source: string, opts: AdapterExtractOptions): Record { - const result: Record = {}; - const concretePaths = resolveConcretePaths({ - parsed, - sourcePath: opts.sourcePath, - translatableKeys: opts.translatableKeys, - }); - for (const path of concretePaths) { - const { segments } = parsePath(path); - const value = readAtPath(parsed, segments as PathSegment[]); - // Capture every value the rules cover, not just strings: - // `year: 2025 → 2026` should bust the cache too. Non-string - // values flow into the canonical hash as-is. - if (value !== undefined) { - result[path] = value; - } - } - return result; - }, - - peekNoTranslate(parsed: TomlData): boolean { - if (parsed === null || typeof parsed !== "object") return false; - return (parsed as Record).noTranslate === true; - }, - - /** - * Walk configured URL paths in the parsed bytes and rewrite - * matched string values via `opts.rewriter`. Re-parses the bytes - * (rather than receiving a parsed structure) because the pipeline - * calls this AFTER `applyTranslations` returns serialised bytes - * — keeps cached bytes URL-rewrite-naïve so a `noPrefixUrls` - * config edit doesn't bust the cache. - * - * Wildcards (`[*]`, `.*`) expand against the post-apply structure - * so URL paths can target dynamic shapes like `tags[*].url`. No-op - * when the configured paths produce no concrete matches or every - * matched value passes the rewriter unchanged. - */ - rewriteUrls(bytes: string, opts: AdapterRewriteUrlsOptions): string { - if (opts.paths.length === 0) return bytes; - const parsed = parseToml(bytes) as TomlData; - const out = structuredClone(parsed) as TomlData; - let mutated = false; - for (const rule of opts.paths) { - for (const concrete of expandPath(rule, out)) { - const { segments } = parsePath(concrete); - const value = readAtPath(out, segments as PathSegment[]); - if (typeof value !== "string") continue; - const rewritten = opts.rewriter(value); - if (rewritten === null || rewritten === value) continue; - writeAtPath(out, segments as PathSegment[], rewritten); - mutated = true; - } - } - if (!mutated) return bytes; - return stringifyToml(out as Record); - }, -}; - -/** - * Type alias for the `smol-toml` parse output. Modelled as - * `Record` because the actual type is recursive - * (objects/arrays/scalars) and we walk it dynamically — there's no - * useful narrower type at this layer. - */ -export type TomlData = Record; diff --git a/src/parsing/adapters/yaml.ts b/src/parsing/adapters/yaml.ts deleted file mode 100644 index 5a8e52d..0000000 --- a/src/parsing/adapters/yaml.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; - -import type { AdapterApplyOptions, AdapterExtractOptions, AdapterRewriteUrlsOptions, FileTypeAdapter } from "../adapter.js"; -import type { Segment } from "../extract.js"; -import { expandPath, parsePath, readAtPath, resolveConcretePaths, writeAtPath, type PathSegment } from "../key-paths.js"; - -/** - * YAML adapter. Parses with the `yaml` library (already a dep for - * markdown frontmatter), extracts translatable scalars at user- - * configured key paths (with wildcard support), and applies - * translations by mutating the parsed structure and re-stringifying. - * - * **Round-trip fidelity (relaxed).** Comments, anchors / aliases, - * exact key ordering, and quoting style are NOT preserved on output - * — `yaml.stringify` produces canonical output. Source files are - * never rewritten by polystella, so this only affects translation - * outputs (regenerated each build). Document mode (which preserves - * more structure) is on the table for future strict round-trip; the - * v0.1.x ship uses the simpler parse/stringify path. - * - * **Cache key.** Uses the same body+selectedValues+glossary+model - * hash composition as the markdown / TOML / JSON adapters today; - * whitespace and comment edits in source files DO bust the cache. - * The structured-data variant (drop `rawBody`, hash only canonical - * selected values) is documented as future work in the design doc - * §3.1. - * - * **noTranslate opt-out.** Top-level `noTranslate` accepts both - * boolean `true` and the string aliases `"true"` / `"yes"` (matching - * markdown frontmatter, which IS YAML — operators expect parity - * across the two YAML surfaces). TOML and JSON are stricter. - * - * **AI-marker injection (per-entry, not file root).** Astro's - * `file()` loader maps each top-level YAML key to a separate - * collection entry, with the value as that entry's `data`. Marker - * fields written at the file root would manifest as bogus extra - * entries (e.g. an entry with id `aiTranslated` whose data is - * `true`) and fail schema validation. The adapter therefore injects - * the marker fields INSIDE each top-level object-valued key. Top- - * level scalar keys are skipped (their values are already valid - * entry data and the marker has nowhere meaningful to attach). - * - * **Top-level sequence handling.** A YAML file with a top-level - * sequence (Astro maps each element to a collection entry by - * `id` / `slug`) gets the marker injected into each element that's - * a mapping — same intent as the per-key injection for mapping - * roots. - * - * **Date / timestamp interop.** This adapter uses the `yaml` - * package (eemeli/yaml v2), which returns unquoted ISO 8601 strings - * as plain strings — quoted and unquoted forms hash identically in - * `selectedValuesForHash`. Astro's `file()` loader, however, uses - * `js-yaml` internally, which DOES auto-parse unquoted ISO - * timestamps to `Date`. The schema-extender accommodates both: - * `aiTranslatedAt: z.union([z.string(), z.date()])`, so the marker - * round-trips correctly through both ends of the pipeline. - */ -export const yamlAdapter: FileTypeAdapter = { - extensions: [".yaml", ".yml"], - - parse(source: string): YamlData { - return parseYaml(source) as YamlData; - }, - - extractSegments(parsed: YamlData, _source: string, opts: AdapterExtractOptions): Segment[] { - const segments: Segment[] = []; - const concretePaths = resolveConcretePaths({ - parsed, - sourcePath: opts.sourcePath, - translatableKeys: opts.translatableKeys, - }); - for (const path of concretePaths) { - const { segments: pathSegs } = parsePath(path); - const value = readAtPath(parsed, pathSegs as PathSegment[]); - // Only string scalars are translatable; numbers / booleans / - // dates / null / nested structures are passed through - // untouched. - if (typeof value === "string" && value.length > 0) { - segments.push({ id: path, text: value }); - } - } - return segments; - }, - - applyTranslations(parsed: YamlData, _source: string, translations: Map, opts: AdapterApplyOptions): string { - // Deep-clone before mutating so adapters can be re-invoked on - // the same parsed object across cache misses without cross- - // contamination. `yaml.parse` returns JS-native types (strings, - // numbers, booleans, Dates, plain objects, arrays) — all of - // which `structuredClone` handles. - const out = structuredClone(parsed) as YamlData; - - for (const [id, translation] of translations) { - const { segments } = parsePath(id); - // Translator round-trips IDs verbatim, so wildcards never - // appear here. If they do (malformed translator output), - // the path is invalid and the write throws — caller's per-pair - // try/catch surfaces it. - writeAtPath(out, segments as PathSegment[], translation); - } - - // Marker injection: per-entry, not file-root. Mirrors the JSON - // adapter's logic — see json.ts for the array-vs-object cases. - if (opts.topLevelAdditions) { - injectMarkerIntoEntries(out, opts.topLevelAdditions); - } - - return stringifyYaml(out); - }, - - selectedValuesForHash(parsed: YamlData, _source: string, opts: AdapterExtractOptions): Record { - const result: Record = {}; - const concretePaths = resolveConcretePaths({ - parsed, - sourcePath: opts.sourcePath, - translatableKeys: opts.translatableKeys, - }); - for (const path of concretePaths) { - const { segments } = parsePath(path); - const value = readAtPath(parsed, segments as PathSegment[]); - // Capture every value the rules cover (strings AND non-strings): - // a numeric / boolean / Date value change in a translatable - // key MUST bust the cache. The hasher's canonical JSON pass - // serialises Dates as `""`, scalars verbatim — sufficient - // for cache-key composition. - if (value !== undefined) { - result[path] = value; - } - } - return result; - }, - - peekNoTranslate(parsed: YamlData): boolean { - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return false; - } - const value = (parsed as Record).noTranslate; - if (value === true) return true; - if (typeof value === "string") { - const normalised = value.toLowerCase().trim(); - return normalised === "true" || normalised === "yes"; - } - return false; - }, - - /** - * Walk configured URL paths in the parsed bytes and rewrite - * matched string values via `opts.rewriter`. Re-parses the bytes - * (rather than receiving a parsed structure) because the pipeline - * calls this AFTER `applyTranslations` returns serialised bytes - * — keeps cached bytes URL-rewrite-naïve so a `noPrefixUrls` - * config edit doesn't bust the cache. - * - * Wildcards (`[*]`, `.*`) expand against the post-apply structure. - * No-op when the configured paths produce no concrete matches or - * every matched value passes the rewriter unchanged. - */ - rewriteUrls(bytes: string, opts: AdapterRewriteUrlsOptions): string { - if (opts.paths.length === 0) return bytes; - const parsed = parseYaml(bytes) as YamlData; - const out = structuredClone(parsed) as YamlData; - let mutated = false; - for (const rule of opts.paths) { - for (const concrete of expandPath(rule, out)) { - const { segments } = parsePath(concrete); - const value = readAtPath(out, segments as PathSegment[]); - if (typeof value !== "string") continue; - const rewritten = opts.rewriter(value); - if (rewritten === null || rewritten === value) continue; - writeAtPath(out, segments as PathSegment[], rewritten); - mutated = true; - } - } - if (!mutated) return bytes; - return stringifyYaml(out); - }, -}; - -/** - * Type alias for `yaml.parse` output. Modelled as `unknown` because - * a YAML file's root may be a mapping, sequence, or scalar; the - * adapter handles each shape dynamically. - */ -export type YamlData = unknown; - -/** - * Inject the AI-marker fields per-entry. Behaviour mirrors the JSON - * adapter's helper: - * - * - Mapping root → walk top-level keys; merge marker into each - * object-valued key. Scalar-valued keys skipped. - * - Sequence root → walk elements; merge marker into each - * mapping-valued element. Scalar / null elements skipped. - * - Scalar root → no-op. - * - * Mutates `out` in place. Caller owns the deep-clone. - */ -function injectMarkerIntoEntries(out: YamlData, additions: Record): void { - if (out === null || typeof out !== "object") return; - - if (Array.isArray(out)) { - for (const element of out) { - if (element === null || typeof element !== "object" || Array.isArray(element)) continue; - for (const [key, value] of Object.entries(additions)) { - (element as Record)[key] = value; - } - } - return; - } - - for (const [, topValue] of Object.entries(out)) { - if (topValue === null || typeof topValue !== "object" || Array.isArray(topValue)) continue; - for (const [key, value] of Object.entries(additions)) { - (topValue as Record)[key] = value; - } - } -} diff --git a/src/parsing/apply.ts b/src/parsing/apply.ts deleted file mode 100644 index 0f253be..0000000 --- a/src/parsing/apply.ts +++ /dev/null @@ -1,274 +0,0 @@ -import type { Root, Yaml } from "mdast"; -import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; -import { collectMarkdownSegments } from "./extract.js"; -import { restoreInlineMdxPlaceholders } from "./mdx-placeholders.js"; - -interface TextEdit { - start: number; - end: number; - replacement: string; - label: string; -} - -/** - * Replace translatable segments in `source` with their translations - * and return the new markdown. - * - * Splices source text rather than using `remark-stringify` because - * the stringifier defensively re-escapes characters that round-trip - * fine in the source (`[citation]` → `\[citation]`, `S&P` → `S\&P`), - * which would break the byte-identical no-translation round-trip - * the corpus tests require. Using `position.offset`s from - * `remark-parse`, we replace just the spans we care about and copy - * untouched characters verbatim. - * - * Both extractor and applier target the children's inline range (not - * the whole block), so translations may contain their own inline - * markdown (`**bold**`, `[link](url)`) which re-parses correctly, - * while block-level markers (`# `, `- `, `> `) outside the splice - * range are preserved. - */ -export interface ApplyTranslationsOptions { - /** Forward-slash path relative to `sourceDir`, when known. */ - sourcePath?: string | undefined; - /** Normalized MDX rules for `.mdx` sources. */ - mdxRules?: Parameters[1]["mdxRules"] | undefined; - /** - * Frontmatter keys merged into the translated output. Used by the - * AI-translation marker injection. Keys here override same-named - * keys already in the source frontmatter (the marker reflects this - * build's output, not stale source state). - * - * - Source has frontmatter: additions merged alongside in-place - * translations; pre-existing un-touched keys survive. - * - Source has none: a fresh `---\n\n---\n\n` block is - * prepended at offset 0. - * - Empty object: no-op (preserves the byte-identical round-trip). - */ - frontmatterAdditions?: Record; -} - -export function applyTranslations( - ast: Root, - translations: Map, - source: string, - options: ApplyTranslationsOptions = {}, -): string { - const additions = options.frontmatterAdditions ?? {}; - const additionKeys = Object.keys(additions); - const hasAdditions = additionKeys.length > 0; - - // Round-trip short-circuit: nothing changed, return verbatim. - if (translations.size === 0 && !hasAdditions) { - return source; - } - - // Edits are applied right-to-left so earlier offsets stay valid - // while we splice. - const edits: TextEdit[] = []; - - const collected = collectMarkdownSegments( - ast, - { sourcePath: options.sourcePath ?? "", frontmatter: {}, mdxRules: options.mdxRules }, - source, - ); - for (const entry of collected) { - if (entry.kind === "frontmatter") continue; - const translation = translations.get(entry.segment.id); - if (translation === undefined) continue; - if (!entry.span) continue; - const replacement = formatSegmentReplacement(entry, translation, translations); - // Inline span (children's range), not the whole block — keeps - // heading/list/blockquote markers in place. The extractor reads - // the same range, so the round-trip works. - edits.push({ ...entry.span, replacement, label: entry.segment.id }); - } - - const frontmatterNode = ast.children.find((child): child is Yaml => child.type === "yaml"); - if (frontmatterNode) { - const fmTranslations = collectFrontmatterTranslations(translations); - if (fmTranslations.size > 0 || hasAdditions) { - const fmSpan = nodeSpan(frontmatterNode); - if (fmSpan) { - const data = parseYaml(frontmatterNode.value) as Record; - for (const [path, translation] of fmTranslations) { - applyFrontmatterTranslation(data, path, translation); - } - // Additions overwrite existing same-name keys. - for (const [key, value] of Object.entries(additions)) { - data[key] = value; - } - // `yaml` appends a trailing newline; strip so the shape - // between `---` markers matches the input. - const newInner = stringifyYaml(data).replace(/\n+$/, ""); - edits.push({ - ...fmSpan, - replacement: `---\n${newInner}\n---`, - label: "frontmatter", - }); - } - } - } else if (hasAdditions) { - // No source frontmatter — prepend a fresh block at offset 0. The - // `\n\n` separates the closing `---` from the body. - const newInner = stringifyYaml(additions).replace(/\n+$/, ""); - const block = `---\n${newInner}\n---\n\n`; - edits.push({ start: 0, end: 0, replacement: block, label: "frontmatter:add" }); - } - - if (edits.length === 0) { - return source; - } - - assertNonOverlappingEdits(edits, options.sourcePath); - edits.sort((a, b) => b.start - a.start); - let output = source; - for (const edit of edits) { - output = output.slice(0, edit.start) + edit.replacement + output.slice(edit.end); - } - return output; -} - -function assertNonOverlappingEdits(edits: readonly TextEdit[], sourcePath: string | undefined): void { - for (const edit of edits) { - if (edit.start < 0 || edit.end < edit.start) { - throw new Error( - `[polystella] invalid markdown replacement span${formatSourcePath(sourcePath)}: ${edit.label} [${edit.start}, ${edit.end})`, - ); - } - } - - const occupied = edits.filter((edit) => edit.start < edit.end).sort((a, b) => a.start - b.start || a.end - b.end); - let previous: TextEdit | undefined; - for (const edit of occupied) { - if (previous !== undefined && edit.start < previous.end) { - throw new Error( - `[polystella] overlapping markdown replacement spans${formatSourcePath(sourcePath)}: ${previous.label} [${previous.start}, ${previous.end}) overlaps ${edit.label} [${edit.start}, ${edit.end})`, - ); - } - previous = edit; - } -} - -function formatSourcePath(sourcePath: string | undefined): string { - return sourcePath && sourcePath.length > 0 ? ` in ${sourcePath}` : ""; -} - -function formatSegmentReplacement( - entry: { - placeholders?: Parameters[1] | undefined; - replacement?: { kind: "js-string" | "quoted-attribute"; quote: "'" | '"' } | undefined; - }, - value: string, - translations: ReadonlyMap, -): string { - const restored = entry.placeholders !== undefined ? restoreInlineMdxPlaceholders(value, entry.placeholders, translations) : value; - return formatCollectedReplacement(entry.replacement, restored); -} - -function formatCollectedReplacement( - replacement: { kind: "js-string" | "quoted-attribute"; quote: "'" | '"' } | undefined, - value: string, -): string { - if (replacement?.kind === "js-string") return escapeJsStringContent(value, replacement.quote); - if (replacement?.kind === "quoted-attribute") return escapeQuotedAttributeContent(value, replacement.quote); - return value; -} - -function escapeJsStringContent(value: string, quote: "'" | '"'): string { - let out = ""; - for (const char of value) { - switch (char) { - case "\\": - out += "\\\\"; - break; - case "\n": - out += "\\n"; - break; - case "\r": - out += "\\r"; - break; - case "\t": - out += "\\t"; - break; - case "'": - out += quote === "'" ? "\\'" : char; - break; - case '"': - out += quote === '"' ? '\\"' : char; - break; - default: - out += char; - break; - } - } - return out; -} - -function escapeQuotedAttributeContent(value: string, quote: "'" | '"'): string { - let out = ""; - for (const char of value) { - switch (char) { - case "&": - out += "&"; - break; - case "'": - out += quote === "'" ? "'" : char; - break; - case '"': - out += quote === '"' ? """ : char; - break; - case "\n": - case "\r": - out += " "; - break; - default: - out += char; - break; - } - } - return out; -} - -/** Pull `start`/`end` offsets off an mdast node's position. */ -function nodeSpan(node: unknown): { start: number; end: number } | undefined { - if (typeof node !== "object" || node === null) return undefined; - const pos = (node as { position?: { start?: { offset?: unknown }; end?: { offset?: unknown } } }).position; - const start = pos?.start?.offset; - const end = pos?.end?.offset; - if (typeof start !== "number" || typeof end !== "number") return undefined; - return { start, end }; -} - -/** - * Pull `fm:*` entries out of the translations map, returning a new map - * keyed by the path-after-`fm:` (e.g. `title`, `tags[0]`). - */ -function collectFrontmatterTranslations(translations: Map): Map { - const fm = new Map(); - for (const [id, value] of translations) { - if (id.startsWith("fm:")) { - fm.set(id.slice(3), value); - } - } - return fm; -} - -/** - * Apply a single frontmatter translation. `path` is either `key` (top- - * level scalar) or `key[i]` (i-th element of a top-level array). - */ -function applyFrontmatterTranslation(data: Record, path: string, translation: string): void { - const arrayMatch = /^([^[]+)\[(\d+)\]$/.exec(path); - if (arrayMatch) { - const [, key, indexStr] = arrayMatch; - if (key === undefined || indexStr === undefined) return; - const index = Number(indexStr); - const arr = data[key]; - if (Array.isArray(arr) && index < arr.length) { - arr[index] = translation; - } - return; - } - data[path] = translation; -} diff --git a/src/parsing/extract.ts b/src/parsing/extract.ts deleted file mode 100644 index 3e2b295..0000000 --- a/src/parsing/extract.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type { Root, Yaml } from "mdast"; -import picomatch from "picomatch"; -import { parse as parseYaml } from "yaml"; -import { collectMdxJsxAttributeSegments } from "./mdx-jsx-attributes.js"; -import type { InlineMdxPlaceholder } from "./mdx-placeholders.js"; -import { protectInlineMdxJsx } from "./mdx-placeholders.js"; -import { collectMdxStaticDataSegments } from "./mdx-static-data.js"; -import type { NormalizedMdxRules } from "./mdx-rules.js"; -import { inlineSpan, visitTranslatableBlocks } from "./traverse.js"; - -/** - * Per-pattern compiled-matcher cache. Pattern strings are de facto - * bounded by config (operator declares a small set of globs), so an - * unbounded Map is safe and avoids re-compiling on every source. - */ -const patternMatcherCache = new Map boolean>(); -function getMatcher(pattern: string): (path: string) => boolean { - const cached = patternMatcherCache.get(pattern); - if (cached !== undefined) return cached; - const matcher = picomatch(pattern); - patternMatcherCache.set(pattern, matcher); - return matcher; -} - -/** - * A translatable unit. IDs are stable across re-runs and shared with - * `apply.ts` for byte-replacement at matching positions. - * - * ID grammar: - * body: n-th translatable block in DFS order - * fm: frontmatter scalar at top-level - * fm:[] i-th element of a top-level string-array - */ -export interface Segment { - id: string; - text: string; -} - -export interface ExtractOptions { - /** Forward-slash path relative to `sourceDir`. */ - sourcePath: string; - /** Per-glob → translatable frontmatter keys. */ - frontmatter: Record; - /** Normalized MDX rules for `.mdx` sources. */ - mdxRules?: NormalizedMdxRules | undefined; -} - -export type MarkdownSegmentKind = "body" | "frontmatter" | "mdx-static-data" | "jsx-attribute" | "placeholder-inline-jsx"; - -export interface MarkdownCollectedSegment { - segment: Segment; - kind: MarkdownSegmentKind; - span?: { start: number; end: number } | undefined; - replacement?: { kind: "js-string" | "quoted-attribute"; quote: "'" | '"' } | undefined; - placeholders?: InlineMdxPlaceholder[] | undefined; -} - -/** - * Body segments preserve inline formatting markers (`**bold**` etc.) - * verbatim — the model preserves them and the applier byte-replaces - * the same range, keeping block markers (`#`, `> `, `- `) intact. - * Frontmatter segments hold parsed YAML scalars. - */ -export function extractSegments(ast: Root, opts: ExtractOptions, source: string): Segment[] { - return collectMarkdownSegments(ast, opts, source).map((entry) => entry.segment); -} - -export function collectMarkdownSegments(ast: Root, opts: ExtractOptions, source: string): MarkdownCollectedSegment[] { - const segments: MarkdownCollectedSegment[] = []; - const placeholderSegments: MarkdownCollectedSegment[] = []; - - visitTranslatableBlocks(ast, ({ block, id }) => { - const span = inlineSpan(block); - if (!span) return; - const protectedText = protectInlineMdxJsx(block, source, span, opts.mdxRules); - const text = protectedText?.text ?? source.slice(span.start, span.end); - if (text.length > 0) { - segments.push({ - segment: { id, text }, - kind: "body", - span, - ...(protectedText !== undefined ? { placeholders: protectedText.placeholders } : {}), - }); - if (protectedText !== undefined) { - for (const placeholder of protectedText.placeholders) { - for (const attribute of placeholder.attributes) { - placeholderSegments.push({ - segment: { id: attribute.id, text: attribute.text }, - kind: "placeholder-inline-jsx", - }); - } - } - } - } - }); - - if (opts.mdxRules !== undefined) { - segments.push(...placeholderSegments); - segments.push(...collectMdxStaticDataSegments(ast, source, { sourcePath: opts.sourcePath, mdxRules: opts.mdxRules })); - segments.push(...collectMdxJsxAttributeSegments(ast, source, { mdxRules: opts.mdxRules })); - } - - const frontmatterNode = ast.children.find((child): child is Yaml => child.type === "yaml"); - if (frontmatterNode) { - const keys = resolveFrontmatterKeys(opts.sourcePath, opts.frontmatter); - if (keys.length > 0) { - // Empty / whitespace-only / non-object YAML parses to null, - // undefined, or a scalar. Coerce to an empty record so the - // configured keys silently miss instead of crashing on a - // null-property access. Real-world trigger: a `---\n---` - // block with no content (intentional or stripped by a tool). - const parsed = parseYaml(frontmatterNode.value); - const data: Record = - parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; - for (const key of keys) { - const value = data[key]; - // Empty strings emit no segment — translating "" is meaningless - // and provokes empty-response failures from small instruct - // models. Mirrors the `text.length > 0` guard the body - // extractor uses for inline spans, and the equivalent check - // in the structured-data adapters (TOML / JSON / YAML). - if (typeof value === "string" && value.length > 0) { - segments.push({ segment: { id: `fm:${key}`, text: value }, kind: "frontmatter" }); - } else if (Array.isArray(value)) { - value.forEach((item, i) => { - if (typeof item === "string" && item.length > 0) { - segments.push({ segment: { id: `fm:${key}[${i}]`, text: item }, kind: "frontmatter" }); - } - }); - } - // Numbers, dates, nested objects, mixed-type arrays: not translatable. - } - } - } - - return segments; -} - -/** - * Resolve which frontmatter keys to translate for `sourcePath`, by - * unioning the key lists of every matching glob in `rules`. - */ -export function resolveFrontmatterKeys(sourcePath: string, rules: Record): string[] { - const matched = new Set(); - for (const [pattern, keys] of Object.entries(rules)) { - if (getMatcher(pattern)(sourcePath)) { - for (const key of keys) { - matched.add(key); - } - } - } - return [...matched]; -} - -/** - * Read the `noTranslate` flag. Returns `true` for boolean `true` and - * the string aliases `"true"` / `"yes"` (common in hand-edited YAML); - * everything else returns `false`. Build hook uses this to skip the - * translation loop entirely. - */ -export function peekNoTranslate(ast: Root): boolean { - const frontmatterNode = ast.children.find((child): child is Yaml => child.type === "yaml"); - if (!frontmatterNode) return false; - - let parsed: unknown; - try { - parsed = parseYaml(frontmatterNode.value); - } catch { - return false; - } - if (parsed === null || typeof parsed !== "object") return false; - const value = (parsed as Record).noTranslate; - if (value === true) return true; - if (typeof value === "string") { - const normalised = value.toLowerCase().trim(); - return normalised === "true" || normalised === "yes"; - } - return false; -} - -/** - * Translatable-frontmatter values keyed by name. Feeds the cache-key - * hash directly (separate from `extractSegments`'s flat `{id, text}` - * shape so reordering / adding non-translatable keys is invisible to - * the hash; non-string values still propagate to the hash so e.g. a - * `year: 2025 → 2026` change re-keys the cache). - */ -export function selectTranslatableFrontmatter(ast: Root, opts: ExtractOptions): Record { - const frontmatterNode = ast.children.find((child): child is Yaml => child.type === "yaml"); - if (!frontmatterNode) return {}; - - const keys = resolveFrontmatterKeys(opts.sourcePath, opts.frontmatter); - if (keys.length === 0) return {}; - - // Same defensive coercion as `extractSegments`: empty / non-object - // YAML (e.g. `---\n---`) parses to null and `key in null` throws. - const parsed = parseYaml(frontmatterNode.value); - const data: Record = - parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; - const result: Record = {}; - for (const key of keys) { - if (key in data) { - result[key] = data[key]; - } - } - return result; -} diff --git a/src/parsing/key-paths.ts b/src/parsing/key-paths.ts deleted file mode 100644 index 299580f..0000000 --- a/src/parsing/key-paths.ts +++ /dev/null @@ -1,327 +0,0 @@ -/** - * Dotted key-path utilities for structured-data adapters (TOML / JSON / YAML). - * - * Grammar: - * `key` / `a.b.c` nested scalars - * `a[0]` / `a.b[3].c` array index, mixed - * `a[*]` / `a.*` wildcards (array elements / object values) - * `a[*].b.*.c` compose - * - * Wildcards expand at extract time into concrete paths; concrete - * paths never contain `*`. Each path serialises to a canonical - * dotted+bracket form so IDs round-trip through the translator. - */ - -export type PathSegment = string | number; - -/** - * Prototype-chain segments rejected at `parsePath` time so - * misconfigured `translatableKeys` can't drive `readAtPath` into - * `Object.prototype` or pollute it via `writeAtPath`. - */ -const FORBIDDEN_SEGMENT_NAMES = new Set(["__proto__", "prototype", "constructor"]); - -function assertSafeSegment(seg: string, path: string): void { - if (FORBIDDEN_SEGMENT_NAMES.has(seg)) { - throw new Error( - `[polystella] key path "${path}" contains reserved segment "${seg}". ` + - `Segments named __proto__, prototype, or constructor are rejected ` + - `because they traverse the JavaScript prototype chain.`, - ); - } -} - -/** - * Parse a dotted/bracketed path. Throws on malformed input - * (mismatched brackets, trailing dot) and on prototype-chain segments. - */ -export function parsePath(path: string): { segments: (PathSegment | "*")[]; hasWildcard: boolean } { - if (path.length === 0) { - throw new Error(`[polystella] empty key path is invalid`); - } - const segments: (PathSegment | "*")[] = []; - let hasWildcard = false; - let i = 0; - - while (i < path.length) { - if (path[i] === ".") { - // Leading dot or doubled dot. - throw new Error(`[polystella] malformed key path "${path}": unexpected "." at index ${i}`); - } - // Bracket form: `[N]` or `[*]`. - if (path[i] === "[") { - const closeIdx = path.indexOf("]", i); - if (closeIdx === -1) { - throw new Error(`[polystella] malformed key path "${path}": unclosed "[" at index ${i}`); - } - const inner = path.slice(i + 1, closeIdx); - if (inner === "*") { - segments.push("*"); - hasWildcard = true; - } else if (/^\d+$/.test(inner)) { - segments.push(Number(inner)); - } else { - throw new Error(`[polystella] malformed key path "${path}": "[${inner}]" must be a non-negative integer or "*"`); - } - i = closeIdx + 1; - // Bracket may be followed by `.next`, `[next]`, or end-of-string. - if (i < path.length && path[i] === ".") { - i++; - if (i === path.length) { - throw new Error(`[polystella] malformed key path "${path}": trailing "."`); - } - } - continue; - } - // Dotted form: read until next `.` or `[`. - let j = i; - while (j < path.length && path[j] !== "." && path[j] !== "[") j++; - const key = path.slice(i, j); - if (key.length === 0) { - throw new Error(`[polystella] malformed key path "${path}": empty segment near index ${i}`); - } - if (key === "*") { - segments.push("*"); - hasWildcard = true; - } else { - assertSafeSegment(key, path); - segments.push(key); - } - i = j; - if (i < path.length && path[i] === ".") { - i++; - if (i === path.length) { - throw new Error(`[polystella] malformed key path "${path}": trailing "."`); - } - } - } - - return { segments, hasWildcard }; -} - -/** - * Render concrete (no-wildcard) segments back to canonical string - * form. Numbers become bracketed indices; first segment never gets - * a leading dot. Used as segment IDs so the translator response - * round-trips back to the same concrete path. - */ -export function formatPath(segments: readonly PathSegment[]): string { - let out = ""; - for (let i = 0; i < segments.length; i++) { - const seg = segments[i]; - if (seg === undefined) continue; - if (typeof seg === "number") { - out += `[${seg}]`; - } else { - out += i === 0 ? seg : `.${seg}`; - } - } - return out; -} - -/** - * Expand wildcards against `data`. Non-wildcard paths return - * `[path]` verbatim. Wildcards over absent/non-iterable nodes - * silently expand to no paths (partial data shapes are common). - */ -export function expandPath(path: string, data: unknown): string[] { - const { segments, hasWildcard } = parsePath(path); - if (!hasWildcard) return [path]; - return expandSegments(segments, data, []); -} - -/** Recursive `expandPath` worker; branches on `*` segments. */ -function expandSegments(segments: readonly (PathSegment | "*")[], node: unknown, acc: PathSegment[]): string[] { - if (segments.length === 0) { - return [formatPath(acc)]; - } - const [head, ...rest] = segments; - if (head === "*") { - if (node === null || node === undefined) return []; - if (Array.isArray(node)) { - const out: string[] = []; - for (let i = 0; i < node.length; i++) { - out.push(...expandSegments(rest, node[i], [...acc, i])); - } - return out; - } - if (typeof node === "object") { - const out: string[] = []; - // `Object.keys` only returns OWN enumerable string keys, so no - // prototype-chain entries leak into the expansion. - for (const key of Object.keys(node as Record)) { - out.push(...expandSegments(rest, (node as Record)[key], [...acc, key])); - } - return out; - } - return []; - } - if (node === null || node === undefined) { - // Non-wildcard segment over absent node — let the caller's read - // step decide whether to skip; emit the concrete path verbatim. - return [formatPath([...acc, head as PathSegment, ...rest.filter((s): s is PathSegment => s !== "*")])]; - } - if (typeof head === "number") { - if (!Array.isArray(node)) return []; - return expandSegments(rest, node[head], [...acc, head]); - } - // String key. - if (typeof node !== "object") return []; - // `Object.hasOwn` gates prototype-chain traversal. `parsePath` - // already rejects __proto__/prototype/constructor segments, but - // this is the same defence on the access side in case a caller - // builds `PathSegment[]` directly. - if (!Object.hasOwn(node as object, head as string)) return []; - return expandSegments(rest, (node as Record)[head as string], [...acc, head as string]); -} - -/** - * Read the value at `segments` from `node`. Returns `undefined` when - * any segment is absent / non-traversable. Concrete (no-wildcard) - * paths only — wildcards must be expanded first via `expandPath`. - */ -export function readAtPath(node: unknown, segments: readonly PathSegment[]): unknown { - let current: unknown = node; - for (const seg of segments) { - if (current === null || current === undefined) return undefined; - if (typeof seg === "number") { - if (!Array.isArray(current)) return undefined; - // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop -- numeric array index, cannot hit prototype chain. - current = current[seg]; - } else { - if (typeof current !== "object") return undefined; - // `Object.hasOwn` gates prototype-chain access so a segment like - // `__proto__` reads as "missing" rather than returning the - // object's prototype. `parsePath` already rejects those names; - // this is defence in depth for any caller that builds - // `PathSegment[]` directly (Semgrep - // js/prototype-pollution-loop). - if (!Object.hasOwn(current as object, seg)) return undefined; - // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop -- Object.hasOwn gate above blocks prototype-chain segments. - current = (current as Record)[seg]; - } - } - return current; -} - -/** - * Write `value` at `segments` in `node`, mutating in place. Throws - * when an intermediate segment doesn't traverse to a usable container - * — better than silently dropping the write on a misconfigured path. - * - * Concrete paths only. - */ -export function writeAtPath(node: unknown, segments: readonly PathSegment[], value: unknown): void { - if (segments.length === 0) { - throw new Error(`[polystella] cannot write at empty path`); - } - let current: unknown = node; - for (let i = 0; i < segments.length - 1; i++) { - const seg = segments[i]; - // The loop bound guarantees this, but `noUncheckedIndexedAccess` - // surfaces it as `T | undefined`. - if (seg === undefined) continue; - if (current === null || current === undefined) { - throw new Error(`[polystella] cannot write at ${formatPath(segments)}: parent is null/undefined at segment ${i}`); - } - if (typeof seg === "number") { - if (!Array.isArray(current)) { - throw new Error(`[polystella] cannot write at ${formatPath(segments)}: expected array at segment ${i}, got ${typeof current}`); - } - // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop -- numeric array index, cannot hit prototype chain. - current = current[seg]; - } else { - if (typeof current !== "object") { - throw new Error(`[polystella] cannot write at ${formatPath(segments)}: expected object at segment ${i}, got ${typeof current}`); - } - // `Object.hasOwn` gates prototype-chain traversal. When the - // intermediate property doesn't exist we fall through to - // `undefined`, and the next iteration's null/undefined guard - // surfaces the existing "parent is null/undefined" error. This - // preserves the original error shape while blocking - // `current = current["__proto__"]` from landing on - // `Object.prototype` (Semgrep js/prototype-pollution-loop). - current = Object.hasOwn(current as object, seg) ? (current as Record)[seg] : undefined; - } - } - const last = segments[segments.length - 1]; - if (last === undefined) { - // segments.length > 0 was asserted at function entry, so the - // terminal element exists. Re-throwing here gives the type - // narrower what it needs without leaking a `!`. - throw new Error(`[polystella] cannot write at empty path`); - } - if (current === null || current === undefined) { - throw new Error(`[polystella] cannot write at ${formatPath(segments)}: terminal parent is null/undefined`); - } - if (typeof last === "number") { - if (!Array.isArray(current)) { - throw new Error(`[polystella] cannot write at ${formatPath(segments)}: expected array as terminal parent`); - } - current[last] = value; - } else { - if (typeof current !== "object") { - throw new Error(`[polystella] cannot write at ${formatPath(segments)}: expected object as terminal parent`); - } - // Terminal write: forbid any segment that would land on the - // prototype chain. `parsePath` blocks these at config parse - // time; this guard catches direct `PathSegment[]` callers (and - // satisfies Semgrep's pollution-loop detector at the actual - // sink). - if (FORBIDDEN_SEGMENT_NAMES.has(last)) { - throw new Error(`[polystella] cannot write at ${formatPath(segments)}: terminal segment "${last}" is reserved (prototype-chain).`); - } - (current as Record)[last] = value; - } -} - -/** - * Match a relative source path against a glob (as produced by - * `picomatch`). Re-exported here so adapters don't need a direct - * picomatch import — picomatch is already a transitive dep. - */ -export { default as picomatchMatcher } from "picomatch"; - -import picomatch from "picomatch"; - -/** - * Per-pattern compiled-matcher cache. Sized by config (bounded). - */ -const patternMatcherCache = new Map boolean>(); -function getMatcher(pattern: string): (path: string) => boolean { - const cached = patternMatcherCache.get(pattern); - if (cached !== undefined) return cached; - const matcher = picomatch(pattern); - patternMatcherCache.set(pattern, matcher); - return matcher; -} - -/** - * Resolve concrete translatable paths for a source. Union matching - * globs' paths, expand wildcards against `parsed`, dedupe by first - * occurrence (order matters for ID stability). - */ -export function resolveConcretePaths(args: { parsed: unknown; sourcePath: string; translatableKeys: Record }): string[] { - const { parsed, sourcePath, translatableKeys } = args; - const matchedRulePaths: string[] = []; - for (const [pattern, paths] of Object.entries(translatableKeys)) { - if (getMatcher(pattern)(sourcePath)) { - for (const p of paths) { - if (!matchedRulePaths.includes(p)) { - matchedRulePaths.push(p); - } - } - } - } - const concrete: string[] = []; - const seen = new Set(); - for (const rule of matchedRulePaths) { - for (const expanded of expandPath(rule, parsed)) { - if (!seen.has(expanded)) { - seen.add(expanded); - concrete.push(expanded); - } - } - } - return concrete; -} diff --git a/src/parsing/mdx-jsx-attributes.ts b/src/parsing/mdx-jsx-attributes.ts deleted file mode 100644 index 735f137..0000000 --- a/src/parsing/mdx-jsx-attributes.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { Root } from "mdast"; - -import type { MarkdownCollectedSegment } from "./extract.js"; -import type { NormalizedMdxRules } from "./mdx-rules.js"; - -export interface CollectMdxJsxAttributeOptions { - mdxRules: NormalizedMdxRules; -} - -export function collectMdxJsxAttributeSegments(ast: Root, source: string, opts: CollectMdxJsxAttributeOptions): MarkdownCollectedSegment[] { - const out: MarkdownCollectedSegment[] = []; - walkUnknown(ast, (node) => { - if (!isMdxJsxElement(node)) return; - const elementName = node.name; - const attributes = readArrayProperty(node, "attributes"); - if (attributes === undefined) return; - const allowed = allowedAttributesForElement(elementName, opts.mdxRules); - if (allowed.size === 0) return; - for (const attribute of attributes) { - if (!isMdxJsxAttribute(attribute)) continue; - if (!allowed.has(attribute.name)) continue; - if (typeof attribute.value !== "string" || attribute.value.length === 0) continue; - const attrSpan = readPositionSpan(attribute); - if (attrSpan === undefined) continue; - const valueSpan = findQuotedAttributeValueSpan(source, attrSpan); - if (valueSpan === undefined) continue; - out.push({ - segment: { id: `mdx:attr:${elementName}.${attribute.name}:${valueSpan.start}`, text: attribute.value }, - kind: "jsx-attribute", - span: { start: valueSpan.start, end: valueSpan.end }, - replacement: { kind: "quoted-attribute", quote: valueSpan.quote }, - }); - } - }); - return out; -} - -function allowedAttributesForElement(elementName: string, rules: NormalizedMdxRules): Set { - if (isLowercaseElementName(elementName)) { - return new Set([...(rules.htmlAttributes["*"] ?? []), ...(rules.htmlAttributes[elementName] ?? [])]); - } - return new Set(rules.components[elementName]?.props ?? []); -} - -function isLowercaseElementName(name: string): boolean { - const first = name[0]; - return first !== undefined && first.toLowerCase() === first; -} - -function findQuotedAttributeValueSpan( - source: string, - attrSpan: { start: number; end: number }, -): { start: number; end: number; quote: "'" | '"' } | undefined { - const slice = source.slice(attrSpan.start, attrSpan.end); - const equalsIndex = slice.indexOf("="); - if (equalsIndex < 0) return undefined; - let quoteIndex = equalsIndex + 1; - while (quoteIndex < slice.length && /\s/.test(slice[quoteIndex] ?? "")) quoteIndex++; - const quote = slice[quoteIndex]; - if (quote !== "'" && quote !== '"') return undefined; - const valueStartInSlice = quoteIndex + 1; - const valueEndInSlice = slice.indexOf(quote, valueStartInSlice); - if (valueEndInSlice < 0) return undefined; - return { - start: attrSpan.start + valueStartInSlice, - end: attrSpan.start + valueEndInSlice, - quote, - }; -} - -function isMdxJsxElement(node: unknown): node is { type: string; name: string } { - if (typeof node !== "object" || node === null) return false; - const candidate = node as { type?: unknown; name?: unknown }; - return candidate.type === "mdxJsxFlowElement" && typeof candidate.name === "string"; -} - -function isMdxJsxAttribute(node: unknown): node is { type: string; name: string; value: unknown } { - if (typeof node !== "object" || node === null) return false; - const candidate = node as { type?: unknown; name?: unknown }; - return candidate.type === "mdxJsxAttribute" && typeof candidate.name === "string"; -} - -function readPositionSpan(node: unknown): { start: number; end: number } | undefined { - if (typeof node !== "object" || node === null) return undefined; - const pos = (node as { position?: { start?: { offset?: unknown }; end?: { offset?: unknown } } }).position; - const start = pos?.start?.offset; - const end = pos?.end?.offset; - if (typeof start !== "number" || typeof end !== "number") return undefined; - return { start, end }; -} - -function walkUnknown(value: unknown, visitor: (node: unknown) => void): void { - if (typeof value !== "object" || value === null) return; - visitor(value); - if (Array.isArray(value)) { - for (const item of value) walkUnknown(item, visitor); - return; - } - for (const child of Object.values(value as Record)) { - walkUnknown(child, visitor); - } -} - -function readArrayProperty(node: unknown, property: string): unknown[] | undefined { - if (typeof node !== "object" || node === null) return undefined; - const value = (node as Record)[property]; - return Array.isArray(value) ? value : undefined; -} diff --git a/src/parsing/mdx-placeholders.ts b/src/parsing/mdx-placeholders.ts deleted file mode 100644 index c6c58cd..0000000 --- a/src/parsing/mdx-placeholders.ts +++ /dev/null @@ -1,309 +0,0 @@ -import type { NormalizedMdxRules } from "./mdx-rules.js"; -import type { TranslatableBlock } from "./traverse.js"; - -export class MdxPlaceholderError extends Error { - readonly _tag = "MdxPlaceholderError" as const; - - constructor(message: string) { - super(message); - this.name = "MdxPlaceholderError"; - } -} - -export interface InlineMdxPlaceholderAttribute { - id: string; - text: string; - start: number; - end: number; - quote: "'" | '"'; -} - -export type InlineMdxPlaceholder = - | { - id: string; - kind: "wrapper"; - opening: string; - closing: string; - attributes: InlineMdxPlaceholderAttribute[]; - } - | { - id: string; - kind: "opaque"; - source: string; - attributes: InlineMdxPlaceholderAttribute[]; - }; - -export interface ProtectedInlineMdxText { - text: string; - placeholders: InlineMdxPlaceholder[]; -} - -export function protectInlineMdxJsx( - block: TranslatableBlock, - source: string, - span: { start: number; end: number }, - rules: NormalizedMdxRules | undefined, -): ProtectedInlineMdxText | undefined { - const inlineNodes = readInlineMdxJsxNodes(block) - .map((node) => ({ node, span: readPositionSpan(node) })) - .filter((entry): entry is { node: MdxJsxTextNode; span: { start: number; end: number } } => entry.span !== undefined) - .filter((entry) => entry.span.start >= span.start && entry.span.end <= span.end) - .sort((a, b) => a.span.start - b.span.start); - - if (inlineNodes.length === 0) return undefined; - - const placeholders: InlineMdxPlaceholder[] = []; - let cursor = span.start; - let text = ""; - - inlineNodes.forEach((entry, index) => { - text += source.slice(cursor, entry.span.start); - const id = String(index); - const placeholder = buildPlaceholder(id, entry.node, entry.span, source, rules); - placeholders.push(placeholder.placeholder); - text += placeholder.text; - cursor = entry.span.end; - }); - - text += source.slice(cursor, span.end); - return { text, placeholders }; -} - -export function restoreInlineMdxPlaceholders( - value: string, - placeholders: readonly InlineMdxPlaceholder[], - translations?: ReadonlyMap | undefined, -): string { - let output = value; - for (const placeholder of placeholders) { - if (placeholder.kind === "wrapper") { - const pattern = new RegExp(`([\\s\\S]*?)<\\/ph>`, "g"); - let count = 0; - output = output.replace(pattern, (_match, inner: string) => { - count++; - const opening = applyInlinePlaceholderAttributeTranslations(placeholder.opening, placeholder.attributes, translations); - return `${opening}${inner}${placeholder.closing}`; - }); - if (count !== 1) { - throw new MdxPlaceholderError(`[polystella] translated segment lost or duplicated inline MDX placeholder id=${placeholder.id}`); - } - } else { - const pattern = new RegExp(``, "g"); - let count = 0; - output = output.replace(pattern, () => { - count++; - return applyInlinePlaceholderAttributeTranslations(placeholder.source, placeholder.attributes, translations); - }); - if (count !== 1) { - throw new MdxPlaceholderError(`[polystella] translated segment lost or duplicated inline MDX placeholder id=${placeholder.id}`); - } - } - } - if (/`, placeholder: { id, kind: "opaque", source: nodeSource, attributes } }; - } - - const childSpan = readChildrenSpan(node.children); - if (childSpan === undefined || childSpan.start < nodeSpan.start || childSpan.end > nodeSpan.end) { - return { text: ``, placeholder: { id, kind: "opaque", source: nodeSource, attributes } }; - } - - const opening = source.slice(nodeSpan.start, childSpan.start); - const inner = source.slice(childSpan.start, childSpan.end); - const closing = source.slice(childSpan.end, nodeSpan.end); - return { - text: `${inner}`, - placeholder: { id, kind: "wrapper", opening, closing, attributes }, - }; -} - -function collectPlaceholderAttributes( - node: MdxJsxTextNode, - source: string, - nodeSpan: { start: number; end: number }, - rules: NormalizedMdxRules | undefined, -): InlineMdxPlaceholderAttribute[] { - const allowed = allowedAttributesForElement(node.name, rules); - if (allowed.size === 0) return []; - const attributes = readArrayProperty(node, "attributes"); - if (attributes === undefined) return []; - - const out: InlineMdxPlaceholderAttribute[] = []; - for (const attribute of attributes) { - if (!isMdxJsxAttribute(attribute)) continue; - if (!allowed.has(attribute.name)) continue; - if (typeof attribute.value !== "string" || attribute.value.length === 0) continue; - const attrSpan = readPositionSpan(attribute); - if (attrSpan === undefined) continue; - const valueSpan = findQuotedAttributeValueSpan(source, attrSpan); - if (valueSpan === undefined) continue; - out.push({ - id: `mdx:inline-attr:${node.name}.${attribute.name}:${valueSpan.start}`, - text: attribute.value, - start: valueSpan.start - nodeSpan.start, - end: valueSpan.end - nodeSpan.start, - quote: valueSpan.quote, - }); - } - return out; -} - -function applyInlinePlaceholderAttributeTranslations( - source: string, - attributes: readonly InlineMdxPlaceholderAttribute[], - translations: ReadonlyMap | undefined, -): string { - if (translations === undefined || attributes.length === 0) return source; - const edits: Array<{ start: number; end: number; replacement: string }> = []; - for (const attribute of attributes) { - if (attribute.start < 0 || attribute.end > source.length || attribute.end < attribute.start) continue; - const translation = translations.get(attribute.id); - if (translation === undefined) continue; - edits.push({ - start: attribute.start, - end: attribute.end, - replacement: escapeQuotedAttributeContent(translation, attribute.quote), - }); - } - edits.sort((a, b) => b.start - a.start); - let output = source; - for (const edit of edits) { - output = output.slice(0, edit.start) + edit.replacement + output.slice(edit.end); - } - return output; -} - -function allowedAttributesForElement(elementName: string, rules: NormalizedMdxRules | undefined): Set { - if (rules === undefined) return new Set(); - if (isLowercaseElementName(elementName)) { - return new Set([...(rules.htmlAttributes["*"] ?? []), ...(rules.htmlAttributes[elementName] ?? [])]); - } - return new Set(rules.components[elementName]?.props ?? []); -} - -function isLowercaseElementName(name: string): boolean { - const first = name[0]; - return first !== undefined && first.toLowerCase() === first; -} - -function findQuotedAttributeValueSpan( - source: string, - attrSpan: { start: number; end: number }, -): { start: number; end: number; quote: "'" | '"' } | undefined { - const slice = source.slice(attrSpan.start, attrSpan.end); - const equalsIndex = slice.indexOf("="); - if (equalsIndex < 0) return undefined; - let quoteIndex = equalsIndex + 1; - while (quoteIndex < slice.length && /\s/.test(slice[quoteIndex] ?? "")) quoteIndex++; - const quote = slice[quoteIndex]; - if (quote !== "'" && quote !== '"') return undefined; - const valueStartInSlice = quoteIndex + 1; - const valueEndInSlice = slice.indexOf(quote, valueStartInSlice); - if (valueEndInSlice < 0) return undefined; - return { - start: attrSpan.start + valueStartInSlice, - end: attrSpan.start + valueEndInSlice, - quote, - }; -} - -function isMdxJsxAttribute(node: unknown): node is { type: string; name: string; value: unknown } { - if (typeof node !== "object" || node === null) return false; - const candidate = node as { type?: unknown; name?: unknown }; - return candidate.type === "mdxJsxAttribute" && typeof candidate.name === "string"; -} - -function readArrayProperty(node: unknown, property: string): unknown[] | undefined { - if (typeof node !== "object" || node === null) return undefined; - const value = (node as Record)[property]; - return Array.isArray(value) ? value : undefined; -} - -function escapeQuotedAttributeContent(value: string, quote: "'" | '"'): string { - let out = ""; - for (const char of value) { - switch (char) { - case "&": - out += "&"; - break; - case "'": - out += quote === "'" ? "'" : char; - break; - case '"': - out += quote === '"' ? """ : char; - break; - case "\n": - case "\r": - out += " "; - break; - default: - out += char; - break; - } - } - return out; -} - -function shouldTreatAsOpaque(node: MdxJsxTextNode, rules: NormalizedMdxRules | undefined): boolean { - if (!Array.isArray(node.children) || node.children.length === 0) return true; - return rules?.components[node.name]?.children === false; -} - -function readInlineMdxJsxNodes(block: TranslatableBlock): MdxJsxTextNode[] { - const out: MdxJsxTextNode[] = []; - for (const child of block.children) { - if (isMdxJsxTextNode(child)) out.push(child); - } - return out; -} - -function isMdxJsxTextNode(node: unknown): node is MdxJsxTextNode { - if (typeof node !== "object" || node === null) return false; - const candidate = node as { type?: unknown; name?: unknown }; - return candidate.type === "mdxJsxTextElement" && typeof candidate.name === "string"; -} - -function readChildrenSpan(children: unknown[] | undefined): { start: number; end: number } | undefined { - if (!Array.isArray(children) || children.length === 0) return undefined; - const first = children[0]; - const last = children[children.length - 1]; - const firstSpan = readPositionSpan(first); - const lastSpan = readPositionSpan(last); - if (firstSpan === undefined || lastSpan === undefined) return undefined; - return { start: firstSpan.start, end: lastSpan.end }; -} - -function readPositionSpan(node: unknown): { start: number; end: number } | undefined { - if (typeof node !== "object" || node === null) return undefined; - const pos = (node as { position?: { start?: { offset?: unknown }; end?: { offset?: unknown } } }).position; - const start = pos?.start?.offset; - const end = pos?.end?.offset; - if (typeof start !== "number" || typeof end !== "number") return undefined; - return { start, end }; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/src/parsing/mdx-static-data.ts b/src/parsing/mdx-static-data.ts deleted file mode 100644 index 7e91ede..0000000 --- a/src/parsing/mdx-static-data.ts +++ /dev/null @@ -1,326 +0,0 @@ -import type { Root } from "mdast"; -import picomatch from "picomatch"; - -import type { MarkdownCollectedSegment } from "./extract.js"; -import type { NormalizedMdxRules } from "./mdx-rules.js"; - -export interface CollectMdxStaticDataOptions { - sourcePath: string; - mdxRules: NormalizedMdxRules; -} - -interface BindingRule { - bindingName: string; - pathSpecs: string[]; -} - -interface TranslateDirective { - rangeEnd: number; - pathSpecs: string[]; -} - -interface LiteralRoot { - node: unknown; - range: { start: number; end: number }; -} - -const matcherCache = new Map boolean>(); - -function getMatcher(pattern: string): (path: string) => boolean { - const cached = matcherCache.get(pattern); - if (cached !== undefined) return cached; - const matcher = picomatch(pattern); - matcherCache.set(pattern, matcher); - return matcher; -} - -export function collectMdxStaticDataSegments(ast: Root, source: string, opts: CollectMdxStaticDataOptions): MarkdownCollectedSegment[] { - const bindingRules = resolveBindingRules(opts.mdxRules.data, opts.sourcePath); - const rulesByBinding = new Map(bindingRules.map((rule) => [rule.bindingName, rule.pathSpecs])); - const out: MarkdownCollectedSegment[] = []; - const occupiedSpans = new Set(); - - for (const program of readEstreePrograms(ast)) { - collectAnnotatedStaticData({ program, source, out, occupiedSpans }); - for (const declarator of readVariableDeclarators(program)) { - const bindingName = readIdentifierName(readProperty(declarator, "id")); - if (bindingName === undefined) continue; - const pathSpecs = rulesByBinding.get(bindingName); - if (pathSpecs === undefined) continue; - const init = readProperty(declarator, "init"); - collectFromStaticLiteral({ idPrefix: `mdx:data:${bindingName}`, node: init, path: "", pathSpecs, source, out, occupiedSpans }); - } - } - - return out; -} - -function resolveBindingRules(dataRules: NormalizedMdxRules["data"], sourcePath: string): BindingRule[] { - const merged = new Map(); - for (const [pattern, bindings] of Object.entries(dataRules)) { - if (!getMatcher(pattern)(sourcePath)) continue; - for (const [bindingName, paths] of Object.entries(bindings)) { - const existing = merged.get(bindingName) ?? []; - for (const path of paths) { - if (!existing.includes(path)) existing.push(path); - } - merged.set(bindingName, existing); - } - } - return [...merged.entries()].map(([bindingName, pathSpecs]) => ({ bindingName, pathSpecs })); -} - -function collectFromStaticLiteral(args: { - idPrefix: string; - node: unknown; - path: string; - pathSpecs: string[]; - source: string; - out: MarkdownCollectedSegment[]; - occupiedSpans: Set; -}): void { - if (!isNode(args.node)) return; - - if (args.node.type === "ArrayExpression") { - const elements = readArrayProperty(args.node, "elements"); - if (elements === undefined) return; - elements.forEach((element, index) => { - if (element === null) return; - collectFromStaticLiteral({ ...args, node: element, path: `${args.path}[${index}]` }); - }); - return; - } - - if (args.node.type === "ObjectExpression") { - const properties = readArrayProperty(args.node, "properties"); - if (properties === undefined) return; - for (const property of properties) { - if (!isNode(property) || property.type !== "Property") continue; - if (readBooleanProperty(property, "computed") === true) continue; - const key = readPropertyKey(readProperty(property, "key")); - if (key === undefined) continue; - const nextPath = args.path.length > 0 ? `${args.path}.${key}` : key; - collectFromStaticLiteral({ ...args, node: readProperty(property, "value"), path: nextPath }); - } - return; - } - - if (args.node.type !== "Literal") return; - const value = readProperty(args.node, "value"); - if (typeof value !== "string" || value.length === 0) return; - if (!pathMatches(args.path, args.pathSpecs)) return; - const range = readRange(args.node); - if (range === undefined) return; - const raw = readStringProperty(args.node, "raw") ?? args.source.slice(range.start, range.end); - const quote = readStringQuote(raw); - if (quote === undefined) return; - const span = { start: range.start + 1, end: range.end - 1 }; - const spanKey = `${span.start}:${span.end}`; - if (args.occupiedSpans.has(spanKey)) return; - args.occupiedSpans.add(spanKey); - args.out.push({ - segment: { id: `${args.idPrefix}${args.path}`, text: value }, - kind: "mdx-static-data", - span, - replacement: { kind: "js-string", quote }, - }); -} - -function collectAnnotatedStaticData(args: { - program: unknown; - source: string; - out: MarkdownCollectedSegment[]; - occupiedSpans: Set; -}): void { - const directives = readTranslateDirectives(args.program); - if (directives.length === 0) return; - const roots = readLiteralRoots(args.program); - for (const directive of directives) { - const root = roots.filter((candidate) => candidate.range.start >= directive.rangeEnd).sort((a, b) => a.range.start - b.range.start)[0]; - if (root === undefined) continue; - const pathSpecs = expandAnnotationPathSpecs(directive.pathSpecs, root.node); - collectFromStaticLiteral({ - idPrefix: `mdx:annotation:${root.range.start}`, - node: root.node, - path: "", - pathSpecs, - source: args.source, - out: args.out, - occupiedSpans: args.occupiedSpans, - }); - } -} - -function expandAnnotationPathSpecs(pathSpecs: string[], root: unknown): string[] { - if (!isNode(root) || root.type !== "ArrayExpression") return pathSpecs; - const expanded: string[] = []; - for (const spec of pathSpecs) { - expanded.push(spec); - if (!spec.startsWith("[") && !spec.includes("[]")) { - expanded.push(`[].${spec}`); - } - } - return expanded; -} - -function readTranslateDirectives(program: unknown): TranslateDirective[] { - const comments = readArrayProperty(program, "comments") ?? []; - const out: TranslateDirective[] = []; - for (const comment of comments) { - const value = readStringProperty(comment, "value"); - if (value === undefined) continue; - const range = readRange(comment); - if (range === undefined) continue; - const pathSpecs = parseTranslateDirective(value); - if (pathSpecs.length === 0) continue; - out.push({ rangeEnd: range.end, pathSpecs }); - } - return out.sort((a, b) => a.rangeEnd - b.rangeEnd); -} - -function parseTranslateDirective(value: string): string[] { - const cleaned = value - .split("\n") - .map((line) => line.replace(/^\s*\*\s?/, "").trim()) - .join("\n"); - const match = /@polystella\s+translate\b([\s\S]*)/m.exec(cleaned); - const rawPaths = match?.[1]?.trim(); - if (!rawPaths) return []; - return rawPaths - .split(/[\n,]/) - .map((part) => part.replace(/^\s*-\s*/, "").trim()) - .filter((part) => part.length > 0); -} - -function readLiteralRoots(program: unknown): LiteralRoot[] { - const roots: LiteralRoot[] = []; - walkUnknown(program, (node) => { - if (!isNode(node)) return; - if (node.type !== "ArrayExpression" && node.type !== "ObjectExpression") return; - const range = readRange(node); - if (range === undefined) return; - roots.push({ node, range }); - }); - return roots; -} - -function walkUnknown(value: unknown, visitor: (node: unknown) => void): void { - if (typeof value !== "object" || value === null) return; - visitor(value); - if (Array.isArray(value)) { - for (const item of value) walkUnknown(item, visitor); - return; - } - for (const child of Object.values(value as Record)) { - walkUnknown(child, visitor); - } -} - -function pathMatches(actualPath: string, specs: readonly string[]): boolean { - for (const spec of specs) { - if (pathSpecToRegExp(spec).test(actualPath)) return true; - } - return false; -} - -function pathSpecToRegExp(spec: string): RegExp { - const escaped = spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`^${escaped.replace(/\\\[\\\]/g, "\\[\\d+\\]")}$`); -} - -function readEstreePrograms(root: unknown): unknown[] { - const programs: unknown[] = []; - walkUnknown(root, (node) => { - const program = readEstreeProgram(node); - if (program !== undefined && !programs.includes(program)) programs.push(program); - }); - return programs; -} - -function readEstreeProgram(node: unknown): unknown | undefined { - if (typeof node !== "object" || node === null) return undefined; - const data = (node as { data?: unknown }).data; - if (typeof data !== "object" || data === null) return undefined; - const estree = (data as { estree?: unknown }).estree; - return isNode(estree) && estree.type === "Program" ? estree : undefined; -} - -function readVariableDeclarators(program: unknown): unknown[] { - if (!isNode(program)) return []; - const body = readArrayProperty(program, "body"); - if (body === undefined) return []; - const out: unknown[] = []; - for (const statement of body) { - const declaration = unwrapVariableDeclaration(statement); - if (declaration === undefined) continue; - const declarations = readArrayProperty(declaration, "declarations"); - if (declarations === undefined) continue; - out.push(...declarations); - } - return out; -} - -function unwrapVariableDeclaration(statement: unknown): unknown | undefined { - if (!isNode(statement)) return undefined; - if (statement.type === "VariableDeclaration") return statement; - if (statement.type === "ExportNamedDeclaration") { - const declaration = readProperty(statement, "declaration"); - return isNode(declaration) && declaration.type === "VariableDeclaration" ? declaration : undefined; - } - return undefined; -} - -function readPropertyKey(key: unknown): string | undefined { - const identifier = readIdentifierName(key); - if (identifier !== undefined) return identifier; - if (!isNode(key) || key.type !== "Literal") return undefined; - const value = readProperty(key, "value"); - return typeof value === "string" ? value : undefined; -} - -function readIdentifierName(node: unknown): string | undefined { - if (!isNode(node) || node.type !== "Identifier") return undefined; - return readStringProperty(node, "name"); -} - -function readStringQuote(raw: string): "'" | '"' | undefined { - if (raw.length < 2) return undefined; - const first = raw[0]; - const last = raw[raw.length - 1]; - if ((first === "'" || first === '"') && first === last) return first; - return undefined; -} - -function readRange(node: unknown): { start: number; end: number } | undefined { - if (typeof node !== "object" || node === null) return undefined; - const range = (node as { range?: unknown }).range; - if (!Array.isArray(range) || range.length < 2) return undefined; - const start = range[0]; - const end = range[1]; - if (typeof start !== "number" || typeof end !== "number") return undefined; - return { start, end }; -} - -function isNode(node: unknown): node is { type: string } { - return typeof node === "object" && node !== null && typeof (node as { type?: unknown }).type === "string"; -} - -function readProperty(node: unknown, property: string): unknown { - if (typeof node !== "object" || node === null) return undefined; - return (node as Record)[property]; -} - -function readArrayProperty(node: unknown, property: string): unknown[] | undefined { - const value = readProperty(node, property); - return Array.isArray(value) ? value : undefined; -} - -function readStringProperty(node: unknown, property: string): string | undefined { - const value = readProperty(node, property); - return typeof value === "string" ? value : undefined; -} - -function readBooleanProperty(node: unknown, property: string): boolean | undefined { - const value = readProperty(node, property); - return typeof value === "boolean" ? value : undefined; -} diff --git a/src/parsing/traverse.ts b/src/parsing/traverse.ts deleted file mode 100644 index c03e6b7..0000000 --- a/src/parsing/traverse.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { Heading, Paragraph, Root, TableCell } from "mdast"; - -/** - * AST traversal shared between `extract.ts` and `apply.ts`. Both - * MUST iterate translatable blocks in the same order so segment IDs - * line up — funnelled through `visitTranslatableBlocks` here. - */ - -/** Block-level nodes carrying translatable inline text. */ -const TRANSLATABLE_BLOCK_TYPES = new Set(["paragraph", "heading", "tableCell"]); - -/** - * Containers we descend into without emitting a segment for the - * container itself. Includes MDX block-level JSX components - * (`mdxJsxFlowElement`) so prose written inside `
`... - * `
` reaches the extractor — without this, MDX files would - * treat component-wrapped content as opaque. - * - * `mdxJsxTextElement` is NOT here: it's an inline node, only ever - * a child of paragraphs / headings / table cells. Inline nodes are - * inside the byte-spliced inline span, so the byte-splicer handles - * them transparently — recursing into them at the block level would - * double-process. - * - * Other MDX node types intentionally absent (= ignored, byte-perfect - * preserved): - * - `mdxjsEsm` — ESM imports/exports at the file root. - * - `mdxFlowExpression` — block-level `{...}` expressions. - * - `mdxTextExpression` — inline `{...}` (handled by inline span). - */ -const RECURSE_INTO_TYPES = new Set([ - "root", - "blockquote", - "list", - "listItem", - "table", - "tableRow", - "footnoteDefinition", - "mdxJsxFlowElement", -]); - -export type TranslatableBlock = Paragraph | Heading | TableCell; - -export interface BlockVisit { - /** The AST node itself. Mutating `block.children` here mutates the AST. */ - block: TranslatableBlock; - /** Stable segment ID, e.g. `body:0`, `body:1`. */ - id: string; -} - -/** - * Visit every translatable block in DFS order. Skips code, HTML, - * thematic breaks, frontmatter, definitions — they're left untouched. - */ -export function visitTranslatableBlocks(ast: Root, visitor: (visit: BlockVisit) => void): void { - let index = 0; - - // mdast's node union is open (MDX adds nodes; remark plugins can - // emit anything). We need the structural fields only — a narrow - // `unknown` walker with type guards keeps us honest at the joints. - function walk(node: unknown): void { - if (!isMdastLikeNode(node)) return; - if (TRANSLATABLE_BLOCK_TYPES.has(node.type)) { - visitor({ block: node as unknown as TranslatableBlock, id: `body:${index}` }); - index++; - return; - } - if (RECURSE_INTO_TYPES.has(node.type) && Array.isArray(node.children)) { - for (const child of node.children) { - walk(child); - } - } - // Anything else (code, html, thematicBreak, yaml, definition, …): ignored. - } - - walk(ast); -} - -/** - * Byte-offset span of a block's CHILDREN (not the block itself). - * For `# Title\n`, the block covers `0..7` but the children cover - * `2..7` — the inline range. Reading and writing this range - * preserves block markers (`#`, `> `, `- `) while letting the inline - * content be replaced cleanly. The extract / apply symmetry on this - * span is what gives us the byte-perfect round-trip. - */ -/** - * Structural type guard for mdast/MDX-style nodes. Recognises the - * `type: string` discriminant + optional `children` array. Doesn't - * assert the type IS a valid mdast node — that's what the - * `TRANSLATABLE_BLOCK_TYPES` / `RECURSE_INTO_TYPES` allow-lists - * downstream are for. - */ -function isMdastLikeNode(node: unknown): node is { type: string; children?: unknown[] } { - return typeof node === "object" && node !== null && typeof (node as { type?: unknown }).type === "string"; -} - -export function inlineSpan(block: TranslatableBlock): { start: number; end: number } | undefined { - const children = block.children; - if (!Array.isArray(children) || children.length === 0) return undefined; - const first = children[0]; - const last = children[children.length - 1]; - if (first === undefined || last === undefined) return undefined; - const start = first.position?.start?.offset; - const end = last.position?.end?.offset; - if (typeof start !== "number" || typeof end !== "number") return undefined; - return { start, end }; -} diff --git a/src/translation/batch.ts b/src/translation/batch.ts deleted file mode 100644 index 6e30c25..0000000 --- a/src/translation/batch.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { Segment } from "../parsing/extract.js"; -import type { Logger } from "./logger.js"; - -/** - * Token-aware batching primitive. Format-agnostic — depends only on - * the `Segment` shape (`{ id, text }`). Adapters provide grouping - * hints (see `FileTypeAdapter.groupSegments`); this module packs - * those groups into batches that fit under a soft input-token - * budget. See ARCHITECTURE.md §17 for the strategy. - * - * Pure functions, no I/O. The `logger` parameter is optional and - * only surfaces the oversize-section degradation warning (§17). - */ - -/** - * Default soft cap for input tokens per batch. Sized to leave - * roughly half of the model's `maxTokens` (8192 default) for the - * response, matching stratus's 2500-token catalog heuristic scaled - * to prose. Configurable via `provider.batchInputTokenBudget`. - */ -export const DEFAULT_INPUT_TOKEN_BUDGET = 4000; - -/** - * Average chars-per-token ratio for English-like scripts. Stratus's - * `TOKEN_CHAR_RATIO = 4` heuristic; pessimistic for CJK (we - * under-pack rather than over-pack on those locales, which is the - * safe direction). - */ -const TOKEN_CHAR_RATIO = 4; - -/** - * Per-segment prompt overhead in chars. Each segment renders as - * `@@@@\n\n\n` in the user prompt — 5 fixed chars plus - * the id length plus the text. The +8 here pads the fixed overhead - * slightly so we under-pack rather than over-pack at the budget - * boundary; the rounding is harmless given the chars/4 estimate is - * itself approximate. - */ -const SEGMENT_OVERHEAD_CHARS = 8; - -/** - * Estimate the input-token cost of rendering `segments` into the - * user-prompt marker format. Returns `0` for an empty list (no - * network call would happen). - * - * The estimate intentionally does NOT account for the system prompt - * (glossary, style rules, doc-context block) — those are roughly - * constant across batches within a single file, so they don't - * affect the *relative* packing decision. Callers who need a hard - * cap including the system prompt should pre-deduct it from the - * configured budget. - */ -export function estimateInputTokens(segments: Segment[]): number { - if (segments.length === 0) return 0; - let chars = 0; - for (const seg of segments) { - chars += seg.id.length + seg.text.length + SEGMENT_OVERHEAD_CHARS; - } - return Math.ceil(chars / TOKEN_CHAR_RATIO); -} - -export interface PackGroupsIntoBatchesOptions { - /** Soft cap; defaults to `DEFAULT_INPUT_TOKEN_BUDGET`. */ - inputTokenBudget?: number; - /** - * Surface oversize-section warnings (logged once per oversize - * group). Optional: tests and unit callers can omit it. - */ - logger?: Logger; - /** Forward-slash path; used in the oversize warning for operators. */ - sourcePath?: string; -} - -/** - * Pack adapter-grouped segments into prompt-sized batches. - * - * Algorithm (per §17): - * 1. Greedy fill: maintain a `currentBatch`. For each group: - * - If `currentBatch + group ≤ budget`: append. - * - Else: flush `currentBatch` and start a new one with this group. - * 2. Oversize-group fallback: when a single group exceeds the - * budget, flatten its segments and split them paragraph-by- - * paragraph using the same greedy fill. Emit `logger?.warn` - * so operators see the degradation (the section's heading - * anchor is lost for sub-batches past the first). - * - * Invariants: - * - Empty `groups` → `[]`. - * - `flat(result) === flat(groups)` (no segment dropped or duplicated). - * - Group order preserved across batches. - * - When everything fits in one budget, `result.length === 1`. - */ -export function packGroupsIntoBatches(groups: Segment[][], opts: PackGroupsIntoBatchesOptions = {}): Segment[][] { - const budget = opts.inputTokenBudget ?? DEFAULT_INPUT_TOKEN_BUDGET; - const batches: Segment[][] = []; - let currentBatch: Segment[] = []; - let currentTokens = 0; - - const flushCurrent = (): void => { - if (currentBatch.length > 0) { - batches.push(currentBatch); - currentBatch = []; - currentTokens = 0; - } - }; - - for (const group of groups) { - if (group.length === 0) continue; - const groupTokens = estimateInputTokens(group); - - if (groupTokens > budget) { - // Oversize group: flush the accumulator first so its contents - // ship as their own batch, then split this group segment-by- - // segment using the same greedy logic. Each segment becomes a - // pseudo-group of size 1; segments smaller than the budget - // still pack together. - flushCurrent(); - opts.logger?.warn( - `[polystella] section in ${opts.sourcePath ?? ""} exceeds batch input-token budget (${groupTokens} > ${budget}); splitting paragraph-by-paragraph — heading anchor is lost for sub-batches past the first`, - ); - for (const seg of group) { - const segTokens = estimateInputTokens([seg]); - if (currentTokens + segTokens <= budget) { - currentBatch.push(seg); - currentTokens += segTokens; - } else { - flushCurrent(); - // Even if the individual segment exceeds the budget on - // its own, push it as a one-segment batch — truncation - // then becomes a content-level problem the operator must - // address (split the source). `parseResponse` already - // raises a clear hint for that case. - currentBatch.push(seg); - currentTokens = segTokens; - } - } - continue; - } - - if (currentTokens + groupTokens > budget) { - flushCurrent(); - } - currentBatch.push(...group); - currentTokens += groupTokens; - } - - flushCurrent(); - return batches; -} diff --git a/src/translation/logger.ts b/src/translation/logger.ts deleted file mode 100644 index a61fe85..0000000 --- a/src/translation/logger.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Minimal logger surface shared across the translation pipeline. - * - * Trivially stub-able from `console` (production CLI), Astro's - * `AstroIntegrationLogger` (build hook), or `{ info: vi.fn(), ... }` - * (tests). Pulled into its own module so leaf primitives like - * `packGroupsIntoBatches` don't have to import from the orchestrator - * (`run.ts`) to type a `logger?` option. - */ -export interface Logger { - info(message: string): void; - warn(message: string): void; - error(message: string): void; - debug(message: string): void; -} diff --git a/src/translation/prompt.ts b/src/translation/prompt.ts deleted file mode 100644 index 19ae347..0000000 --- a/src/translation/prompt.ts +++ /dev/null @@ -1,245 +0,0 @@ -import type { Segment } from "../parsing/extract.js"; -import type { Glossary } from "../glossary/glossary.js"; - -/** - * Prompt construction. Each translated segment is wrapped in a - * delimited block: - * - * @@@@ - * - * - * Beats a JSON-object protocol on small models: no nested syntax, - * fewer truncation/escaping failures on long Portuguese/CJK content, - * fewer output tokens. Translated bytes pass through verbatim - * between markers (no escaping rules). - * - * Pure — no I/O, no provider deps — unit-testable without a network. - */ - -/** Marker delimiter — `@@` is rare in technical prose. */ -const MARKER = "@@"; - -/** - * Marker-line regex for `parseResponse`. Hardcoded literal (not - * `new RegExp(MARKER + ...)`) so static analysers (e.g. Semgrep - * detect-non-literal-regexp) can prove the pattern isn't tainted. - * MUST stay in sync with `MARKER`; the import-time guard below trips - * if they drift. Lazy id capture (`[^@\n]+?`) is bounded by line - * length → linear, no ReDoS surface. - */ -const MARKER_LINE_RE = /^@@([^@\n]+?)@@\s*$/gm; -if (MARKER !== "@@") { - throw new Error( - `[polystella] internal invariant violated: MARKER_LINE_RE assumes MARKER === "@@", got ${JSON.stringify(MARKER)}. ` + - `Update both together.`, - ); -} - -export interface BuildPromptInput { - segments: Segment[]; - glossary: Glossary; - sourceLocale: string; - targetLocale: string; - /** Optional system-prompt line appended after the generic opener. */ - context?: string | undefined; - /** - * Optional source-language framing block (title, excerpt, ...) - * injected into the system prompt between the markdown-format - * preservation clause and the do-not-translate list. Sits inside - * a fenced `DOCUMENT CONTEXT (for terminology only; do not - * translate this block)` preamble so the model anchors - * terminology without echoing the framing back as translations. - * - * Absent or empty → no DOCUMENT CONTEXT block is emitted; the - * prompt is byte-identical to today. - * - * See ARCHITECTURE.md §17. - */ - documentContext?: string | undefined; -} - -export interface BuiltPrompt { - systemPrompt: string; - userPrompt: string; -} - -export function buildPrompt(input: BuildPromptInput): BuiltPrompt { - const { segments, glossary, sourceLocale, targetLocale, context, documentContext } = input; - const sourceName = localeName(sourceLocale); - const targetName = localeName(targetLocale); - - const systemLines: string[] = [`You are a professional translator.`]; - const trimmedContext = context?.trim(); - if (trimmedContext) { - systemLines.push(trimmedContext); - } - systemLines.push( - `Translate from ${sourceName} (${sourceLocale}) to ${targetName} (${targetLocale}).`, - ``, - `Preserve markdown formatting markers exactly: **bold**, *italic*, _italic_, \`code\`, [link text](url). Translate the visible text but never the URL or any code identifier.`, - ); - - // Document-context block sits between the formatting preservation - // clause and the do-not-translate list, so terminology rules - // immediately follow the framing the model just read. The - // "do not translate this block" clause is load-bearing: without - // it, small instruct models occasionally emit a marker for the - // context block and break `parseResponse`. See ARCHITECTURE.md §17. - const trimmedDocContext = documentContext?.trim(); - if (trimmedDocContext) { - systemLines.push(""); - systemLines.push("DOCUMENT CONTEXT (for terminology only; do not translate this block):"); - systemLines.push(trimmedDocContext); - } - - if (glossary.doNotTranslate.length > 0) { - systemLines.push(""); - systemLines.push("TERMS THAT MUST NOT BE TRANSLATED (preserve verbatim, including capitalisation):"); - for (const term of glossary.doNotTranslate) { - systemLines.push(`- ${term}`); - } - } - - const preferred = Object.entries(glossary.preferredTranslations); - if (preferred.length > 0) { - systemLines.push(""); - systemLines.push("PREFERRED TRANSLATIONS (use these renderings, case-insensitive, when the source term appears):"); - for (const [src, tgt] of preferred) { - systemLines.push(`- ${src} -> ${tgt}`); - } - } - - if (glossary.styleRules.length > 0) { - systemLines.push(""); - systemLines.push("STYLE RULES (apply these throughout):"); - for (const rule of glossary.styleRules) { - systemLines.push(`- [${rule.category}] ${rule.instruction}`); - if (rule.example !== undefined) { - // Two-space indent so the example visually nests under its - // rule. Keeping it on a separate line (rather than inlining - // with " — example: …") makes the structure scannable for - // the model and keeps each rule on bounded line widths. - systemLines.push(` Example: ${rule.example}`); - } - } - } - - const trimmedNotes = glossary.notes.trim(); - if (trimmedNotes.length > 0) { - systemLines.push(""); - systemLines.push("ADDITIONAL NOTES:"); - systemLines.push(trimmedNotes); - } - - systemLines.push(""); - systemLines.push("OUTPUT FORMAT:"); - systemLines.push( - `For each segment in the user message, output a marker line of the form ${MARKER}${MARKER} on its own line, followed by the translated text on subsequent lines. Repeat for every segment id; do not skip any. The set of segment ids in your response MUST equal the set in the user message — do not add, omit, or rename any. Do NOT wrap your output in JSON, code fences, or any other surrounding syntax. Output the markers and translations only.`, - ); - - const userPromptParts: string[] = [ - `Translate the following segments to ${targetName}. Each segment is preceded by a marker line ${MARKER}${MARKER}. Output translations in the SAME format with the SAME segment ids — one marker line per segment, then the translation, then a blank line before the next marker.`, - "", - ]; - for (const seg of segments) { - userPromptParts.push(`${MARKER}${seg.id}${MARKER}`); - userPromptParts.push(seg.text); - userPromptParts.push(""); - } - - return { - systemPrompt: systemLines.join("\n"), - userPrompt: userPromptParts.join("\n").trimEnd(), - }; -} - -/** - * Parse marker-delimited response → `Map`. - * Tolerant: strips code fences, discards preamble. Strict on id - * set: unknown ids dropped silently (small models hallucinate), - * omitted expected ids throw with a truncated dump. - */ -export function parseResponse(rawText: string, expectedIds: string[]): Map { - const cleaned = stripCodeFences(rawText.trim()); - - // Split on `@@@@` lines. Alternating array shape: - // parts[0] = preamble; [1] = id1; [2] = content1; [3] = id2; ... - const parts = cleaned.split(MARKER_LINE_RE); - - if (parts.length < 3) { - throw new Error( - `[polystella] no segment markers in the model response. Expected ${expectedIds.length} markers of the form "${MARKER}${MARKER}". Total length: ${rawText.length} chars.\nRaw response was:\n${truncateRaw(rawText)}`, - ); - } - - // Odd indices = ids; even indices ≥ 2 = the following content. - // Hallucinated ids dropped silently; missing expected ids throw - // below (so this tolerance doesn't mask real failures). - const expected = new Set(expectedIds); - const result = new Map(); - for (let i = 1; i + 1 < parts.length; i += 2) { - const id = (parts[i] ?? "").trim(); - const value = (parts[i + 1] ?? "").trim(); - if (id.length === 0) continue; - if (!expected.has(id)) continue; // hallucinated id; drop - if (value.length === 0) { - throw new Error(`[polystella] model returned an empty translation for segment "${id}"`); - } - result.set(id, value); - } - - for (const id of expectedIds) { - if (!result.has(id)) { - // Distinguish truncation (no marker for id) from skip - // (model emitted markers for other ids but not this one). - const lastEmitted = [...result.keys()].at(-1); - const totalCharsInResult = [...result.values()].reduce((n, v) => n + v.length, 0); - const looksTruncated = - lastEmitted !== undefined && rawText.length > totalCharsInResult && !rawText.includes(`${MARKER}${id}${MARKER}`); - const hint = looksTruncated - ? ` Response appears truncated after segment "${lastEmitted}" — the model likely hit its output-token limit. Raise \`provider.maxTokens\` or split the source into smaller files.` - : ""; - throw new Error(`[polystella] model omitted segment "${id}" from response.${hint}\nRaw response was:\n${truncateRaw(rawText)}`); - } - } - - return result; -} - -/** - * Unwrap a triple-backtick code fence if present. Linear scans - * instead of a regex — `/^```(?:\w+)?\s*\n([\s\S]*?)\n```$/i` - * backtracks quadratically on adversarial model output (CodeQL - * js/polynomial-redos). Model output is uncontrolled enough that - * the linear variant is worth the extra lines. - */ -function stripCodeFences(text: string): string { - if (!text.startsWith("```") || !text.endsWith("```") || text.length < 6) { - return text; - } - // Opening-fence line ends at first \n. Anything between matches - // the old regex's `(?:\w+)?\s*` (optional lang tag + whitespace). - const firstNewline = text.indexOf("\n"); - if (firstNewline === -1) return text; - // Closing ``` must be preceded by \n (own-line fence). - const closeIdx = text.length - 3; - if (text.charCodeAt(closeIdx - 1) !== 10 /* \n */) return text; - if (closeIdx - 1 <= firstNewline) return text; - return text.slice(firstNewline + 1, closeIdx - 1).trim(); -} - -function truncateRaw(text: string, max = 2000): string { - if (text.length <= max) return text; - // Head + tail so both opening structure and cutoff are visible. - const headChars = Math.floor(max / 2); - const tailChars = max - headChars; - return `${text.slice(0, headChars)}\n... [truncated middle, total length ${text.length}] ...\n${text.slice(-tailChars)}`; -} - -function localeName(code: string): string { - try { - return new Intl.DisplayNames(["en-US"], { type: "language" }).of(code) ?? code; - } catch { - return code; - } -} diff --git a/src/translation/provider.ts b/src/translation/provider.ts deleted file mode 100644 index ab8ca08..0000000 --- a/src/translation/provider.ts +++ /dev/null @@ -1,327 +0,0 @@ -import pRetry, { AbortError } from "p-retry"; - -import type { Segment } from "../parsing/extract.js"; -import type { Glossary } from "../glossary/glossary.js"; -import type { PolyStellaResolvedOptions } from "../config/options.js"; -import { buildPrompt, parseResponse } from "./prompt.js"; - -/** - * Permanent translator failure — `translateBatch` does NOT retry - * these. Throw for: auth errors (401/403), bad-request (400), - * not-found (404), unsupported-model. Anything network-flaky or - * model-glitchy must be a plain Error so the retry loop catches it. - */ -export class PermanentProviderError extends Error { - readonly _tag = "PermanentProviderError" as const; - constructor(message: string) { - super(message); - this.name = "PermanentProviderError"; - } -} - -/** - * HTTP status codes the provider treats as permanent. - * 400 — bad request (malformed prompt / unsupported parameter) - * 401 — unauthenticated (wrong / missing API key) - * 403 — forbidden (permission / quota / account state) - * 404 — not found (wrong model id / endpoint) - * 422 — semantic-invalid request body - * Everything else (incl. 408, 425, 429, 500-599) is treated as - * retriable; the model is the operator's problem to fix when 4xx - * (other than retry-after) reaches us, not ours to paper over. - */ -const PERMANENT_HTTP_STATUSES = new Set([400, 401, 403, 404, 422]); - -/** - * One `Translator` per (provider, locale). Two concrete providers - * ship: Workers AI and Anthropic. Both speak the same prompt-and- - * JSON-back contract enforced by `prompt.ts`. - */ -export interface Translator { - /** Resolved model id (per-locale). Folded into the cache key. */ - readonly modelId: string; - /** - * Returns the model's raw text; caller validates via `parseResponse`. - * `signal` cancels in-flight HTTP and propagates `AbortError`. - */ - translate(systemPrompt: string, userPrompt: string, signal?: AbortSignal): Promise; -} - -type ProviderConfig = NonNullable; -type WorkersAIConfig = Extract; -type AnthropicConfig = Extract; -type ModelSpec = WorkersAIConfig["model"]; - -export interface CreateTranslatorOptions { - /** Defaults to global `fetch`; tests pass a stub. */ - fetchImpl?: typeof fetch; -} - -/** - * Throws on unknown provider kind. Doesn't validate credentials — - * auth failures surface from the first `translate()` call. - */ -export function createTranslator(provider: ProviderConfig, locale: string, options: CreateTranslatorOptions = {}): Translator { - const fetchImpl = options.fetchImpl ?? fetch; - if (provider.kind === "workers-ai") { - return createWorkersAITranslator(provider, locale, fetchImpl); - } - if (provider.kind === "anthropic") { - return createAnthropicTranslator(provider, locale, fetchImpl); - } - throw new Error(`[polystella] unknown provider kind: ${(provider as { kind: string }).kind}`); -} - -/** - * Resolve a (possibly per-locale) model spec to a concrete model id: - * "x" → "x" - * { default: "X", "ja-JP": "Y" } → locale-keyed lookup, falls to default. - * - * Exported because the cache key needs the resolved id before - * `translate()` runs. - */ -export function resolveModelId(spec: ModelSpec, locale: string): string { - if (typeof spec === "string") return spec; - return spec[locale] ?? spec.default; -} - -function createWorkersAITranslator(provider: WorkersAIConfig, locale: string, fetchImpl: typeof fetch): Translator { - const modelId = resolveModelId(provider.model, locale); - const endpoint = provider.endpoint ?? `https://api.cloudflare.com/client/v4/accounts/${provider.accountId}/ai/run/${modelId}`; - - return { - modelId, - async translate(systemPrompt, userPrompt, signal) { - const res = await fetchImpl(endpoint, { - method: "POST", - headers: { - Authorization: `Bearer ${provider.apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - // WAI's default cap (~256 tokens) truncates multi-segment - // translations and breaks JSON. Schema default 8192. - max_tokens: provider.maxTokens, - }), - ...(signal !== undefined ? { signal } : {}), - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - const message = `[polystella] Workers AI request failed: ${res.status} ${res.statusText}${text ? `\n${text}` : ""}`; - if (PERMANENT_HTTP_STATUSES.has(res.status)) { - throw new PermanentProviderError(message); - } - throw new Error(message); - } - // Three response shapes observed in the wild: - // - `result.response` — legacy text-generation envelope. - // - `result.choices[0].message.content` — OpenAI-compatible - // chat-completion (qwen3-30b-a3b-fp8 etc.). - // - `choices[0].message.content` — gateway-flattened variant. - const data = (await res.json()) as { - result?: { - response?: unknown; - choices?: Array<{ message?: { content?: unknown } }>; - }; - choices?: Array<{ message?: { content?: unknown } }>; - success?: boolean; - errors?: unknown[]; - }; - if (data.success === false) { - throw new Error(`[polystella] Workers AI returned errors: ${JSON.stringify(data.errors ?? [])}`); - } - - // Probe legacy `result.response` first (most text models), - // then chat-completion shapes. Some models pre-parse JSON - // server-side and return an object; round-trip via stringify - // so `parseResponse` sees a string regardless of provider. - const candidates: Array = [ - data.result?.response, - data.result?.choices?.[0]?.message?.content, - data.choices?.[0]?.message?.content, - ]; - for (const candidate of candidates) { - if (typeof candidate === "string") return candidate; - if (candidate !== null && typeof candidate === "object") { - return JSON.stringify(candidate); - } - } - - // None of the known shapes matched. Dump the envelope so the - // operator can see what came back; caps at ~800 chars to keep - // build logs readable. The error message is intentionally - // explicit about which fields we probed so a future shape - // surfaces with a clear "we tried these places" trail. - const dump = JSON.stringify(data); - const preview = dump.length > 800 ? `${dump.slice(0, 800)}\n... [truncated, total length ${dump.length}]` : dump; - throw new Error( - `[polystella] unexpected Workers AI response shape (model="${modelId}"): none of result.response, result.choices[0].message.content, or choices[0].message.content held a usable string or object. Raw response was:\n${preview}`, - ); - }, - }; -} - -function createAnthropicTranslator(provider: AnthropicConfig, locale: string, fetchImpl: typeof fetch): Translator { - const modelId = resolveModelId(provider.model, locale); - const endpoint = "https://api.anthropic.com/v1/messages"; - - return { - modelId, - async translate(systemPrompt, userPrompt, signal) { - const res = await fetchImpl(endpoint, { - method: "POST", - headers: { - "x-api-key": provider.apiKey, - "anthropic-version": "2023-06-01", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: modelId, - max_tokens: provider.maxTokens, - system: systemPrompt, - messages: [{ role: "user", content: userPrompt }], - }), - ...(signal !== undefined ? { signal } : {}), - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - const message = `[polystella] Anthropic request failed: ${res.status} ${res.statusText}${text ? `\n${text}` : ""}`; - if (PERMANENT_HTTP_STATUSES.has(res.status)) { - throw new PermanentProviderError(message); - } - throw new Error(message); - } - const data = (await res.json()) as { - content?: Array<{ type?: string; text?: string }>; - }; - const textBlock = data.content?.find((b) => b.type === "text"); - const text = textBlock?.text; - if (typeof text !== "string") { - throw new Error(`[polystella] unexpected Anthropic response shape: no text content block`); - } - return text; - }, - }; -} - -export interface TranslateBatchOptions { - translator: Translator; - segments: Segment[]; - glossary: Glossary; - sourceLocale: string; - targetLocale: string; - /** Optional site-/domain-specific prompt extension; see `BuildPromptInput.context`. */ - context?: string | undefined; - /** - * Optional per-batch document-context framing (title, excerpt, ...); - * see `BuildPromptInput.documentContext`. Threaded through unchanged - * to `buildPrompt`. When absent, the prompt is byte-identical to - * the pre-batching path. - */ - documentContext?: string | undefined; - /** - * Retries on transient failure (network 5xx, parse errors, model - * hallucinations). `0` (default) = single attempt. `N` allows up - * to `N+1` total attempts. `PermanentProviderError` (4xx auth / - * bad request) short-circuits regardless of `maxRetries`. - */ - maxRetries?: number; - /** - * Fires after each failed attempt that's followed by another - * retry; does NOT fire on the final (failing) attempt. - */ - onRetry?: (event: TranslateBatchRetryEvent) => void; - /** - * Backoff between retries. Defaults are zero-wait so tests and - * unit callers stay fast; production callers (`runTranslationPass`) - * pass real backoff for thundering-herd avoidance. - */ - retryMinTimeoutMs?: number; - retryFactor?: number; - retryRandomize?: boolean; - /** Abort in-flight translations cleanly when the build is cancelled. */ - signal?: AbortSignal; -} - -export interface TranslateBatchRetryEvent { - /** 1-indexed attempt number that just failed. */ - attempt: number; - /** Total attempts that will be made (= 1 + maxRetries). */ - totalAttempts: number; - /** Error from the failed attempt. */ - error: Error; -} - -/** - * Build prompt → translate → parse → return `Map`. - * Empty segments short-circuit with no network call. - * - * Retries with exponential backoff + jitter on transient failures. - * `PermanentProviderError` (4xx auth/bad-request) skips retries. - * `signal` cancels in-flight work; the AbortError propagates. - * - * The final-failure throw carries the last attempt's error so logs - * reflect the actual death mode, not the first attempt's. - */ -export async function translateBatch(opts: TranslateBatchOptions): Promise> { - const { - translator, - segments, - glossary, - sourceLocale, - targetLocale, - context, - documentContext, - maxRetries = 0, - onRetry, - retryMinTimeoutMs = 0, - retryFactor = 2, - retryRandomize = false, - signal, - } = opts; - if (segments.length === 0) return new Map(); - - const { systemPrompt, userPrompt } = buildPrompt({ - segments, - glossary, - sourceLocale, - targetLocale, - context, - documentContext, - }); - - const expectedIds = segments.map((s) => s.id); - const totalAttempts = Math.max(1, maxRetries + 1); - - return pRetry( - async () => { - // p-retry doesn't auto-check the signal between attempts in - // older versions; cheap inline guard keeps the contract sharp. - signal?.throwIfAborted(); - const rawText = await translator.translate(systemPrompt, userPrompt, signal); - return parseResponse(rawText, expectedIds); - }, - { - retries: maxRetries, - minTimeout: retryMinTimeoutMs, - factor: retryFactor, - randomize: retryRandomize, - ...(signal !== undefined ? { signal } : {}), - // Wrap permanent provider errors in AbortError so p-retry - // skips the remaining attempts. Plain Errors retry normally. - shouldRetry: ({ error }) => !(error instanceof PermanentProviderError), - onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { - // Mirror the legacy contract: fire `onRetry` ONLY when - // another attempt is coming. p-retry calls - // `onFailedAttempt` on every failure (incl. the last). - if (retriesLeft > 0 && !(error instanceof PermanentProviderError)) { - onRetry?.({ attempt: attemptNumber, totalAttempts, error }); - } - }, - }, - ); -} diff --git a/src/translation/translate-segments.ts b/src/translation/translate-segments.ts deleted file mode 100644 index b3ef447..0000000 --- a/src/translation/translate-segments.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { Segment } from "../parsing/extract.js"; -import { packGroupsIntoBatches } from "./batch.js"; -import type { Logger } from "./logger.js"; -import { translateBatch, type TranslateBatchOptions } from "./provider.js"; - -/** - * Multi-batch translation wrapper above `translateBatch`. Takes the - * full segment list plus optional adapter-grouped chunks and an - * optional per-batch document-context block, packs the groups into - * batches under the configured token budget, and runs each batch - * sequentially through `translateBatch`. Returns the merged - * `Map` across all batches. - * - * Why sequential (not parallel) within a file: - * - Parallelism already exists at the (file, locale) pair level - * via `runWithConcurrency` in `src/source/pool.ts`. Sequential - * batches keep rate-limit math simple — effective in-flight - * requests = pool size. - * - Per-batch retry isolation: a transient failure on batch N - * only retries batch N; batches 1..N-1 are already in hand. - * - * Why a separate file (not appended to `provider.ts`): - * - Retry semantics live in `translateBatch`; batching/groups - * live here; provider HTTP code lives in `provider.ts`. Three - * concerns, three files. - * - * See ARCHITECTURE.md §17. - */ - -export interface TranslateSegmentsOptions extends TranslateBatchOptions { - /** - * Adapter-grouped chunks. When omitted, the wrapper treats the - * full `segments` list as a single group — single-batch behaviour - * indistinguishable from calling `translateBatch` directly. - */ - groups?: Segment[][]; - /** - * Source-language framing block injected into every batch's - * system prompt. Threaded to `translateBatch.buildPrompt` via the - * `documentContext` field on `BuildPromptInput`. - */ - documentContext?: string | undefined; - /** Soft cap on per-batch input tokens; defaults applied in batch.ts. */ - inputTokenBudget?: number; - /** Surfaces oversize-section warnings from `packGroupsIntoBatches`. */ - logger?: Logger; - /** Forward-slash path; threads through to the oversize warning. */ - sourcePath?: string; -} - -export interface TranslateSegmentsResult { - /** Merged `segmentId → translatedText` across every batch. */ - translations: Map; - /** Number of batches the wrapper dispatched. `0` when segments was empty. */ - batchCount: number; -} - -export async function translateSegments(opts: TranslateSegmentsOptions): Promise { - const { segments, groups, documentContext, inputTokenBudget, logger, sourcePath, signal, ...rest } = opts; - - // Honour cancellation at entry — cheap, and avoids the - // pack-then-loop dance for work that's about to be discarded. - signal?.throwIfAborted(); - if (segments.length === 0) return { translations: new Map(), batchCount: 0 }; - - // Single-group fallback when the caller doesn't pass `groups`: - // mimics today's behaviour where `translateBatch` sends every - // segment in one prompt. The batcher may still split this group - // if it exceeds the token budget. - const groupsToUse: Segment[][] = groups ?? [segments]; - const batches = packGroupsIntoBatches(groupsToUse, { - ...(inputTokenBudget !== undefined ? { inputTokenBudget } : {}), - ...(logger !== undefined ? { logger } : {}), - ...(sourcePath !== undefined ? { sourcePath } : {}), - }); - - // Empty batches list (only possible when every group was empty) - // → no work; return an empty result rather than calling translateBatch. - if (batches.length === 0) return { translations: new Map(), batchCount: 0 }; - - const merged = new Map(); - for (const batch of batches) { - signal?.throwIfAborted(); - const batchResult = await translateBatch({ - ...rest, - segments: batch, - ...(documentContext !== undefined ? { documentContext } : {}), - ...(signal !== undefined ? { signal } : {}), - }); - for (const [id, text] of batchResult) { - merged.set(id, text); - } - } - return { translations: merged, batchCount: batches.length }; -} diff --git a/tsconfig.workerd.json b/tsconfig.workerd.json new file mode 100644 index 0000000..363dc85 --- /dev/null +++ b/tsconfig.workerd.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "types": ["@cloudflare/vitest-plugin/types"] + }, + "include": ["fixtures/workerd/**/*.ts", "workerd-tests/**/*.ts"] +} diff --git a/vitest.boundaries.config.ts b/vitest.boundaries.config.ts new file mode 100644 index 0000000..f37d751 --- /dev/null +++ b/vitest.boundaries.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["boundary-tests/**/*.test.ts"], + }, +}); diff --git a/vitest.workerd.config.ts b/vitest.workerd.config.ts new file mode 100644 index 0000000..9424e23 --- /dev/null +++ b/vitest.workerd.config.ts @@ -0,0 +1,9 @@ +import { cloudflareTest } from "@cloudflare/vitest-plugin"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [cloudflareTest({ wrangler: { configPath: "./fixtures/workerd/wrangler.jsonc" } })], + test: { + include: ["workerd-tests/**/*.test.ts"], + }, +}); diff --git a/workerd-tests/portability.test.ts b/workerd-tests/portability.test.ts new file mode 100644 index 0000000..402d35c --- /dev/null +++ b/workerd-tests/portability.test.ts @@ -0,0 +1,140 @@ +import { jsonAdapter, markdownAdapter } from "@cloudflare/polystella-adapters"; +import { + buildPrompt, + EMPTY_GLOSSARY, + packGroupsIntoBatches, + parseResponse, + translateBatch, + type Segment, +} from "@cloudflare/polystella-core"; +import { createWorkersAIBindingTranslator, createWorkersAIHttpTranslator } from "@cloudflare/polystella-providers/workers-ai"; +import { SELF } from "cloudflare:test"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const segment: Segment = { id: "body:0", text: "Hello" }; + +describe("core in workerd", () => { + it("builds prompts and parses marker responses", () => { + const prompt = buildPrompt({ + segments: [segment], + glossary: EMPTY_GLOSSARY, + sourceLocale: "en-US", + targetLocale: "pt-BR", + }); + + expect(prompt.systemPrompt).toContain("Brazilian Portuguese (pt-BR)"); + expect(prompt.userPrompt).toContain("@@body:0@@\nHello"); + expect(parseResponse("@@body:0@@\nOla", [segment.id]).get(segment.id)).toBe("Ola"); + }); + + it("batches without losing reference identity or order", () => { + const second: Segment = { id: "body:1", text: "World" }; + const batches = packGroupsIntoBatches([[segment], [second]], { inputTokenBudget: 4 }); + + expect(batches.flat()).toEqual([segment, second]); + expect(batches.flat()[0]).toBe(segment); + expect(batches.flat()[1]).toBe(second); + }); + + it("retries parse failures and honors cancellation", async () => { + let attempts = 0; + const result = await translateBatch({ + translator: { + modelId: "test", + translate: async () => (++attempts === 1 ? "invalid" : "@@body:0@@\nOla"), + }, + segments: [segment], + glossary: EMPTY_GLOSSARY, + sourceLocale: "en-US", + targetLocale: "pt-BR", + maxRetries: 1, + }); + expect(result.get(segment.id)).toBe("Ola"); + expect(attempts).toBe(2); + + const controller = new AbortController(); + controller.abort(new Error("cancelled")); + await expect( + translateBatch({ + translator: { modelId: "test", translate: async () => "@@body:0@@\nignored" }, + segments: [segment], + glossary: EMPTY_GLOSSARY, + sourceLocale: "en-US", + targetLocale: "pt-BR", + signal: controller.signal, + }), + ).rejects.toThrow("cancelled"); + }); +}); + +describe("adapters in workerd", () => { + it("round-trips JSON and applies a translation", () => { + const source = '{"entry":{"title":"Hello"}}'; + const options = { sourcePath: "content/entry.json", translatableKeys: { "content/**": ["entry.title"] } }; + const parsed = jsonAdapter.parse(source); + const segments = jsonAdapter.extractSegments(parsed, source, options); + + expect(segments).toEqual([{ id: "entry.title", text: "Hello" }]); + const output = jsonAdapter.applyTranslations(parsed, source, new Map([["entry.title", "Ola"]])); + expect(JSON.parse(output)).toEqual({ entry: { title: "Ola" } }); + }); + + it("round-trips Remark Markdown and applies translations", () => { + const source = "# Hello\n\nA **bold** paragraph.\n"; + const options = { sourcePath: "content/entry.md", translatableKeys: {} }; + const parsed = markdownAdapter.parse(source, options.sourcePath); + const segments = markdownAdapter.extractSegments(parsed, source, options); + + expect(markdownAdapter.applyTranslations(parsed, source, new Map())).toBe(source); + expect( + markdownAdapter.applyTranslations( + parsed, + source, + new Map([ + ["body:0", "Ola"], + ["body:1", "Um paragrafo **em negrito**."], + ]), + ), + ).toBe("# Ola\n\nUm paragrafo **em negrito**.\n"); + expect(segments.map(({ id }) => id)).toEqual(["body:0", "body:1"]); + }); +}); + +describe("Workers AI providers in workerd", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("invokes a binding callback with the documented input", async () => { + const run = vi.fn(async () => ({ response: "Ola" })); + const translator = createWorkersAIBindingTranslator({ modelId: "@cf/test/model", maxTokens: 64, run }); + + await expect(translator.translate("system", "user")).resolves.toBe("Ola"); + expect(run).toHaveBeenCalledWith("@cf/test/model", { + messages: [ + { role: "system", content: "system" }, + { role: "user", content: "user" }, + ], + max_tokens: 64, + }); + }); + + it("uses a fake global fetch for the HTTP transport", async () => { + const fetchMock = vi.fn(async (..._args: Parameters) => Response.json({ success: true, result: { response: "Ola" } })); + vi.stubGlobal("fetch", fetchMock); + const translator = createWorkersAIHttpTranslator({ + accountId: "account", + apiToken: "token", + modelId: "@cf/test/model", + maxTokens: 64, + }); + + await expect(translator.translate("system", "user")).resolves.toBe("Ola"); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.cloudflare.com/client/v4/accounts/account/ai/run/@cf/test/model"); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: "POST" }); + }); +}); + +it("executes the no-compat Worker fixture", async () => { + const response = await SELF.fetch("https://example.test/"); + expect(await response.json()).toEqual({ prompt: true, title: "Ola", translation: "Ola" }); +}); From 166954449f8cc7cd359868717402e856d59c9bc8 Mon Sep 17 00:00:00 2001 From: Diogo Carneiro Date: Fri, 28 Aug 2026 16:31:50 +0100 Subject: [PATCH 04/13] chore: remove completed monorepo extraction planning documents --- agent-work/polystella-monorepo-baseline.md | 275 ---- agent-work/polystella-monorepo-extraction.md | 1186 ------------------ 2 files changed, 1461 deletions(-) delete mode 100644 agent-work/polystella-monorepo-baseline.md delete mode 100644 agent-work/polystella-monorepo-extraction.md diff --git a/agent-work/polystella-monorepo-baseline.md b/agent-work/polystella-monorepo-baseline.md deleted file mode 100644 index 77454d6..0000000 --- a/agent-work/polystella-monorepo-baseline.md +++ /dev/null @@ -1,275 +0,0 @@ -# PolyStella Monorepo Extraction Baseline - -Status: Recorded -Recorded: 2026-08-28 -Source commit: `c7a8e0e0ea7638ce2e98a771255f809d1265abb7` - -## Purpose - -This is the before-extraction reference for -`agent-work/polystella-monorepo-extraction.md`. Step 8 must reproduce the -stable values below before Astro moves to `packages/astro`. - -Timestamps, durations, temporary server ports, and concurrent log order are -not parity fields. - -## Existing Package Freeze - -The existing package remains unchanged through Steps 1-5. No baseline tests -were added because the current suite already covers the critical invariants. - -Protected until Step 6: - -```text -package.json -src/** -tests/** -client.d.ts -types-internal/** -``` - -## Environment - -| Item | Baseline | -| ---------------------------- | ------------------------------------------ | -| Node | `v22.22.3` | -| pnpm | `11.5.2` | -| Vitest | `4.1.9` | -| PolyStella | `0.4.0` | -| Git commit | `c7a8e0e0ea7638ce2e98a771255f809d1265abb7` | -| Local branch selected by CLI | `main` | - -CI currently runs Node 24. The local Node 22 baseline satisfies the current -`p-retry` engine requirement. - -## Automated Baseline - -| Command | Result | -| ----------------------------------------------------------- | -------------------------------------------------------------------------- | -| `pnpm test` | Pass: 62 files, 1,159 tests, 1.28 seconds | -| `pnpm exec tsc --noEmit` | Pass | -| `pnpm build` | Pass | -| `node dist/cli.js --version` | Pass: `0.4.0` | -| `pnpm playground:mdx-jsx:build` | Pass: 6 pages; dry-run reports 10 keys across 5 files and 2 target locales | -| `LOG_LEVEL=debug pnpm playground:mdx-jsx:translate:dry-run` | Pass: exact keys recorded below | -| `pnpm playground:mdx-jsx:translate:local` | Pass: 10 misses, 0 hits, 0 overrides, 0 failures | -| `pnpm pack --dry-run` | Pass: `@cloudflare/polystella@0.4.0` | - -`pnpm pack --dry-run` runs the existing `prepare` build. Its expected tarball -name is `cloudflare-polystella-0.4.0.tgz`. The dry run includes `src`, `dist`, -all current export targets, the executable CLI, package docs, `llms` files, -and both shipped skills. - -## Dry-Run R2 Keys - -The dry run has no configured provider, so each source has the same hash for -both target locales. The locale still appears in the key. - -| Source | Hash | -| ------------------------ | ------------------------------------------------------------------ | -| `docs/block-jsx.mdx` | `1bd2533d354aac689865d610b9ef2c660a940eba7df2d02b3b01c4dee9a8081c` | -| `docs/expressions.mdx` | `a45472cf8b6fafe58710b6f810cf8116a9213a02039b9209c686f339d28c5fc3` | -| `docs/inline-jsx.mdx` | `db88cc682b22c78b6e7cdcfba1d16d906e49ffd93a52c8504808d961565c1de2` | -| `docs/plain-markdown.md` | `8ccebd5b5dd1254b71c67d3b0e58c1e567219d67fd68437347893d30526337ea` | -| `docs/static-data.mdx` | `ea3f9e1ca502f7714e794dd273e1dae8ccab2fc49fd3809478dd76f3f640b99e` | - -Key formula used by the CLI: - -```text -i18n/{locale}/{source}#{hash}.md -``` - -Target locales are `pt-BR` and `fr-FR`, producing 10 keys total. - -## Fake-Provider Translation Baseline - -The local playground uses one concrete model per locale: - -```text -pt-BR = playground/fake-workers-ai/pt-BR -fr-FR = playground/fake-workers-ai/fr-FR -``` - -| Source | Segments | `pt-BR` source hash | `fr-FR` source hash | -| ------------------------ | -------: | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `docs/block-jsx.mdx` | 11 | `d603785e2ee730cd61c299fffed971882438f20abd59a1776f52432a6844b95c` | `6e0fa4eeda9f2d8be76e38f6583fba8c7877e4ef50380f2375813ce268af5aa2` | -| `docs/expressions.mdx` | 5 | `b3aeadf4eda27ef6f83faf521b7c382271c603601a8eba91578bf8e12cdf33ca` | `9ec3feed4aeae36d06a2fecb049752fdefa962814a7760164222d497f0b73562` | -| `docs/inline-jsx.mdx` | 8 | `d0fd6e76bd4fe60319b2427ddaac6f13f1a6f49e0c9ca52a66e2f4e7bad50adf` | `9126c18f9bc2059a260e002d9ee1dec3aca7462175afb870c9be20ef7bb5f191` | -| `docs/plain-markdown.md` | 5 | `da48b6282dd5fb8ef174eca1cc6bde469d0f1dc6516445e0cab45180196d8f3c` | `f302200778242da8d6574216e40a2bb2328493e235c78d5324c923986c5574a6` | -| `docs/static-data.mdx` | 14 | `66841e3dcdc8e1a4926fb34b244cf42da073b5b3215946ad0770b98f71524ebb` | `4c1c9a1ac749596481fbfac4e4bc33d205356f1e880f57410d3406464a0fad23` | - -Stable report totals: - -```json -{ - "cacheHits": 0, - "aiTranslated": 10, - "overrides": 0, - "skipped": 0, - "localSkipped": 0, - "errors": 0 -} -``` - -## Normalized Output Digests - -For comparison, replace the complete `aiTranslatedAt:` line with -`aiTranslatedAt: ` and compute SHA-256 over the resulting UTF-8 -bytes. These are the `i18n-preview` inspection copies; MDX files under -`.astro/i18n-staging` have intentionally different relative import paths. - -| Output | SHA-256 | -| ------------------------------ | ------------------------------------------------------------------ | -| `pt-BR/docs/block-jsx.mdx` | `0dd866e0845d17b368399c067368c5e9ea5ebcd04e2c76f057851f2dd1bcb407` | -| `pt-BR/docs/expressions.mdx` | `5b26369e1494cc12d929f10343aa5da90ef6edbfc91b87aa8dcae8c3eb58bc24` | -| `pt-BR/docs/inline-jsx.mdx` | `5a18c49dad816aaa4927b6895a314868137650453d471236a4f8ee5f99c3f87c` | -| `pt-BR/docs/plain-markdown.md` | `63d19914e7932d5911b7a2e2db502f63d05ad1ceb52c3ec2ab0e5e183dea1bff` | -| `pt-BR/docs/static-data.mdx` | `6587cc19c5b1aa37729e6ad8c2cc412ae0b73138b920fba04c7974b14d7e9661` | -| `fr-FR/docs/block-jsx.mdx` | `0580cc3c2e0aaea0dfb1905ce689a971bf850f56cd8451c2ddbb912800b65271` | -| `fr-FR/docs/expressions.mdx` | `7fe3552758a30774a699932fa2036ce10811d6c25ece4d4a51d24c2ef1dc707b` | -| `fr-FR/docs/inline-jsx.mdx` | `094b341575f2189ab1a51bb9bde4e0076aa35cbcd5f9879f5d5eafdbd45d0f2e` | -| `fr-FR/docs/plain-markdown.md` | `eec245f660e9574679ae1f7dd4956867f4f7b7742e904b45199dbf58a147d22d` | -| `fr-FR/docs/static-data.mdx` | `1cabe32c0a9ef9764e83b7ed3952ec9d567ab6ef3e4b621651f105695e9415f6` | - -Normalized report digest: - -```text -44d4a570c5d4ece964117508ef1b22ab5bfe5e3fc4ca80b0f0af0a6bb844ba50 -``` - -Report normalization removes `build.startedAt`, `build.durationMs`, and every -entry `durationMs`; entries are sorted by `{locale}/{sourcePath}` before -hashing the emitted two-space-indented JSON with its trailing newline. The -original recorded checksum was corrected in Step 8 because it did not match -this documented normalization; every normalized report field still matched. - -## Manual Output Observations - -Plain Markdown: - -- Frontmatter translations are quoted by YAML serialization. -- `canonicalUrl` changes from `/docs/plain-markdown` to - `/pt-BR/docs/plain-markdown` for the `pt-BR` output. -- `aiTranslated`, `aiTranslationModel`, and `aiTranslatedAt` appear in - frontmatter before the closing delimiter. -- Body links are locale-prefixed. -- The fake translator prefixes each translated segment with `[locale]`. - -MDX: - -- ESM imports remain executable and retain their relative target after staging - rewriting. -- Static exported arrays, annotation-selected arrays, function-return arrays, - and inline JSX prop arrays preserve syntax while selected string values are - prefixed. -- JSX expressions and configured visible props preserve their surrounding - syntax. -- Markdown and MDX continue through separate parser paths. - -## Core Hash And Prompt Fixtures - -Source-hash fixture: - -```json -{ - "body": "# Hello\n\nA paragraph.\n", - "frontmatter": { "title": "Hello", "year": 2025 }, - "glossaryHash": "g0", - "modelId": "@cf/meta/llama-3.1-8b-instruct" -} -``` - -Expected source hash: - -```text -df40a08682e9df8e0643f5e95651478da8ff06922ad2f8aaec7d479db70bb7ee -``` - -Prompt fixture uses segments `fm:title = Hello` and -`body:0 = A paragraph.`, source locale `en-US`, target locale `pt-BR`, and -`EMPTY_GLOSSARY`. - -| Prompt field | Length | SHA-256 | -| ------------- | -----: | ------------------------------------------------------------------ | -| System prompt | 734 | `32b336fbdce5ab269488f3f5d77840d1f1a9fa7c7e544d6f4c70bca570e4ef34` | -| User prompt | 322 | `975ae31980e7f7a782ec257d7584e0ba689b01f006e86c34c82ba029a1363685` | - -## Structured Adapter Fixtures - -The current playground contains only Markdown and MDX. JSON, YAML, and TOML -are therefore characterized directly through their adapters and their existing -unit suites. - -Each fixture selects `title` and `nested.body` and must emit these segments in -order: - -```json -[ - { "id": "title", "text": "Hello" }, - { "id": "nested.body", "text": "World" } -] -``` - -Translations are `X:Hello` and `X:World`. The marker is intentionally injected -into the top-level object-valued entry (`nested`), matching Astro's collection -entry behavior. - -Expected JSON output: - -```json -{ - "title": "X:Hello", - "nested": { - "body": "X:World", - "aiTranslated": true - } -} -``` - -Expected YAML output: - -```yaml -title: X:Hello -nested: - body: X:World - aiTranslated: true -``` - -Expected TOML output: - -```toml -title = "X:Hello" - -[nested] -body = "X:World" -aiTranslated = true -``` - -## Existing Invariant Coverage - -| Requirement | Existing coverage | -| -------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| Cache hash composition and boundaries | `tests/storage/hash.test.ts` | -| Apply before PUT and cache hit/miss behavior | `tests/storage/cache.test.ts` | -| Local cache index isolation | `tests/storage/local-cache.test.ts`, `tests/translation/run.test.ts` | -| Prompt bytes and response protocol | `tests/translation/prompt.test.ts` | -| Group flattening and batching | `tests/translation/batch.test.ts`, `tests/translation/translate-segments.test.ts`, adapter tests | -| Permanent versus retriable provider errors | `tests/translation/provider.test.ts` | -| Markdown versus MDX parser behavior | `tests/parsing/parse.test.ts`, `tests/parsing/mdx.test.ts`, `tests/parsing/round-trip.test.ts` | -| Every current format adapter | `tests/parsing/*-adapter.test.ts` | -| Astro setup timing and end-to-end flow | `tests/smoke.test.ts`, `tests/translation/run.test.ts` | -| UI token/retry behavior | `tests/i18n/ui-translate*.test.ts` | - -## Baseline Limitations - -- R2 is intentionally not configured in the playground baseline. Real cache - hit, fallback, prune, and write behavior is covered by storage and run tests. -- Override, `noTranslate`, and local-cache skip paths are covered by tests but - are not represented in the playground's generated report. -- Structured formats have direct adapter fixtures rather than a staged Astro - playground fixture. -- No real provider credentials or R2 credentials were used. - -These are not extraction exceptions. Their existing tests must remain green, -and Step 12 still requires real provider and safe test-R2 manual verification -before release. diff --git a/agent-work/polystella-monorepo-extraction.md b/agent-work/polystella-monorepo-extraction.md deleted file mode 100644 index 53a83e9..0000000 --- a/agent-work/polystella-monorepo-extraction.md +++ /dev/null @@ -1,1186 +0,0 @@ -# PolyStella Monorepo Extraction Plan - -Status: Local Step 12 gate complete; release blocked by external prerequisites -Last updated: 2026-08-28 - -## ELI5: What Will Happen - -PolyStella is currently one large box containing four different things: the -translation engine, file readers, AI provider connections, and Astro-specific -behavior. We will separate those things without changing what an Astro build -produces. - -| Step | ELI5 | -| ------------------------------ | -------------------------------------------------------------------------------------------------------------- | -| 1. Record the baseline | Take a "before" picture so we can tell whether the move changes anything. | -| 2. Prepare the workspace | Mark where the new package shelves will go while leaving the current Astro package where it is. | -| 3. Extract core | Move the translation brain into a package that knows nothing about Astro, filesystems, or Cloudflare bindings. | -| 4. Extract adapters | Move the Markdown, MDX, JSON, YAML, and TOML readers/writers into one reusable package. | -| 5. Extract providers | Move Workers AI and Anthropic connections into one package, including both Workers AI HTTP and binding calls. | -| 6. Reconnect Astro | Make the existing Astro integration use the three new packages while it is still at the repository root. | -| 7. Prove portability | Run the reusable packages in Node and workerd and make sure they do not secretly need Node compatibility. | -| 8. Check the extraction | Compare the new behavior with the "before" picture before moving any remaining Astro files. | -| 9. Move Astro | Move the now-smaller Astro package into `packages/astro` and make the repository root private. | -| 10. Finish packaging | Make versions, tarballs, builds, CLI files, and package exports work as a real four-package release. | -| 11. Update automation and docs | Teach CI, pkg.pr.new, Changesets, documentation, and contributor guidance about all four packages. | -| 12. Run the release gate | Test the exact artifacts consumers will install, including one manual real-provider smoke test. | - -No package is published between these steps. The first release happens only -after Step 12 passes. - -## End State - -```text -polystella/ - package.json private workspace root - tsconfig.base.json - packages/ - core/ @cloudflare/polystella-core - adapters/ @cloudflare/polystella-adapters - providers/ @cloudflare/polystella-providers - astro/ @cloudflare/polystella - docs/ - playgrounds/ - agent-work/ -``` - -Dependency direction: - -```text -@cloudflare/polystella-core - ^ ^ - | | -adapters providers - ^ ^ - +--------+--------+ - | - Astro -``` - -Core never imports adapters, providers, Astro, React, Cloudflare bindings, -filesystem APIs, storage APIs, or environment globals. - -## Fixed Decisions - -- Public names use the existing `@cloudflare` scope. -- All four public packages use lockstep versions. -- The compatibility target is functionality, not preservation of every - existing low-level import or error string. -- Existing Astro configuration, CLI behavior, generated files, cache keys, - markers, routes, and runtime behavior should stay unchanged unless the - extraction itself requires a documented change. -- All current providers live in one package with `./workers-ai` and - `./anthropic` subpath exports. -- Workers AI supports both the current HTTP transport and a workerd binding - transport. -- All current file adapters live in one package. We will not create one - package per format. -- Astro stays at the root during extraction and moves only after the new - packages pass their gates. -- Until Step 6, the existing package is frozen: do not edit root - `package.json`, `src/**`, `tests/**`, `client.d.ts`, or `types-internal/**`. - Steps 2-5 build shadow packages by copying behavior into new files. Step 6 - is the first cutover and the first point where duplicated root - implementations are rewired or removed. -- The Translation Agent is not moved in this plan. The packages and a workerd - fixture prepare for that later migration. -- There is no hosted core service and no extra network hop. Consumers call - core directly in their own process or Worker. - -## Package Responsibilities - -### `@cloudflare/polystella-core` - -Owns: - -- `Segment`, `Glossary`, `StyleRule`, and the empty glossary value. -- `Translator`, `PermanentProviderError`, and permanent-error detection. -- Locale-aware model resolution. -- Prompt construction and strict response parsing. -- Translation batching and group invariants. -- Retry orchestration, logging contracts, and `AbortSignal` propagation. - -Does not own: - -- Files, paths, adapters, cache keys, R2, URL rewriting, output markers, - provider HTTP, or provider bindings. - -### `@cloudflare/polystella-adapters` - -Owns: - -- Shared adapter types and the current Markdown/MDX, JSON, YAML, and TOML - implementations. -- Parsing, extraction, translation application, grouping, key-path helpers, - and MDX placeholder handling. -- A portable Remark parser and an injectable Markdown parser contract. - -Does not own: - -- Satteri, Astro configuration, file walking, cache hashing, AI marker policy, - locale URL policy, or staging-path import rewriting. - -### `@cloudflare/polystella-providers` - -Owns: - -- Workers AI HTTP request construction and response normalization. -- Workers AI binding invocation and response normalization. -- Anthropic HTTP request construction and response normalization. -- Provider-specific HTTP error classification. - -Does not own: - -- Prompts, response protocol validation, retries, locale model maps, cache - behavior, or generated Cloudflare `Ai` types. - -Public exports: - -```text -@cloudflare/polystella-providers -@cloudflare/polystella-providers/workers-ai -@cloudflare/polystella-providers/anthropic -``` - -### `@cloudflare/polystella` - -Owns: - -- Astro integration hooks, configuration, CLI, filesystem access, source - walking, staging, R2 cache, reports, routing, runtime APIs, content - collections, React helpers, UI strings, and recipes. -- Satteri-backed Markdown/MDX parsing for current Astro behavior. -- Source, glossary, and MDX extraction-policy hashing. -- `noTranslate`, URL rewriting, document-context selection, and AI marker - policy around the shared adapters. - -## Progress - -| Step | Status | Completion evidence | -| ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- | -| 1. Record the baseline | Complete | `agent-work/polystella-monorepo-baseline.md` records commands, keys, hashes, outputs, and known limitations | -| 2. Prepare the workspace | Complete | `packages/*` discovery and framework-neutral `tsconfig.base.json` added; existing package remains unchanged and green | -| 3. Extract core | Complete | Core builds from one runtime dependency; 26 package tests and import-boundary inspection pass | -| 4. Extract adapters | Complete | Shared adapters build with 40 portable format, parser, grouping, and reconstruction tests passing | -| 5. Extract providers | Complete | Three portable factories build with 33 HTTP, binding, cancellation, and retry-integration tests passing | -| 6. Reconnect Astro | Complete | Root delegates to shared packages; 1,161 Astro tests and baseline playground outputs pass | -| 7. Prove portability | Complete | Node, no-compat workerd, and boundary checks pass | -| 8. Check the extraction | Complete | Four-package tarball install and all deterministic Step 1 baseline comparisons pass | -| 9. Move Astro | Complete | Private root, moved Astro package, aggregate gates, package checks, baselines, playground, and docs pass | -| 10. Finish packaging | Complete | Four release-ready tarballs install and expose every public entrypoint with exact internal versions | -| 11. Update automation and docs | Complete | CI/docs/pkg.pr.new configuration covers all packages | -| 12. Run the release gate | Blocked | Local automation passes; npm bootstrap, publisher setup, and external manual checks remain | - -## Step 1: Record The Baseline - -Purpose: Establish known-good behavior before changing package boundaries. - -Changes: - -- Add characterization tests only where current tests do not already pin a - critical invariant. -- Record stable values from the MDX playground in - `agent-work/polystella-monorepo-baseline.md` rather than retaining noisy raw - logs. -- Record representative staged Markdown and MDX output from the local fake - translator, plus direct adapter fixtures for JSON, YAML, and TOML because the - playground does not stage those formats. -- Record representative R2 keys, source hashes, markers, model IDs, and build - report fields. -- Inspect the current `pnpm pack --dry-run` output for - `@cloudflare/polystella`. - -Critical invariants: - -- Cache key bytes remain unchanged. -- `flat(groups) === segments` by reference and order. -- Adapter application inserts the AI marker before cache PUT. -- Markdown uses Markdown syntax and MDX uses MDX syntax. -- Permanent provider errors skip retries; transient and parse failures retry. -- Translation still runs during `astro:config:setup`. - -Automated verification: - -```sh -pnpm test -pnpm exec tsc --noEmit -pnpm build -node dist/cli.js --version -pnpm playground:mdx-jsx:build -pnpm playground:mdx-jsx:translate:dry-run -``` - -Manual verification: - -- Read the dry-run log and save the representative R2 keys for comparison in - Step 8. -- Open generated Markdown and MDX files and record formatting, marker - placement, and rewritten links. Inspect direct reconstruction fixtures for - JSON, YAML, and TOML. -- Confirm `git status --short` contains only intentional baseline tests or - artifacts. - -Stop condition: Do not begin extraction while any baseline command fails or a -critical behavior lacks a reproducible check. - -Completion evidence (2026-08-28): All baseline commands passed: 62 test files -and 1,159 tests, strict typecheck, package build, CLI `0.4.0`, six-page -playground build, local fake-provider translation, and package dry-run. The -baseline file records 10 dry-run keys, 10 normalized output digests, source and -prompt hashes, report fields, structured adapter outputs, and existing tests -covering the non-playground paths. - -## Step 2: Prepare The Workspace - -Purpose: Create package/build structure without moving Astro first. - -Changes: - -- Add package workspace patterns for `packages/core`, `packages/adapters`, and - `packages/providers` to `pnpm-workspace.yaml`. -- Keep `.` as the published `@cloudflare/polystella` package during Steps 2-8. -- Add `tsconfig.base.json` with framework-neutral strict TypeScript options. -- Define the package-local `package.json`, `tsconfig.json`, and - `tsconfig.build.json` conventions. Create each real package and manifest with - its first production files in Steps 3-5 instead of adding empty placeholder - packages. -- Use plain `tsc` builds with declarations, declaration maps, and source maps. -- Use pnpm's dependency graph for build order. Do not add Turbo, Nx, tsup, or - another build orchestrator. -- Build each lower package directly during the shadow phase. Do not alter the - root package's build or `prepare` scripts until Step 6. -- Start all new public package manifests at `0.4.0` so they can join the fixed - version group before the first coordinated release. -- Treat root `package.json`, `src/**`, `tests/**`, `client.d.ts`, and - `types-internal/**` as read-only throughout Steps 2-5. - -Expected initial package dependencies: - -```text -core -> p-retry -adapters -> core + portable parsing dependencies -providers -> core -Astro -> core + adapters + providers + host-only dependencies -``` - -Automated verification: - -```sh -pnpm install -pnpm list -r --depth 0 -pnpm build -``` - -Manual verification: - -- Inspect `pnpm list -r --depth 0` and confirm workspace discovery still sees - the current root, docs, and playground packages without phantom package - entries. -- Review the shared TypeScript settings and confirm they do not extend Astro or - include Node types by default. -- Confirm `git diff -- package.json src tests client.d.ts types-internal` is - empty. - -Stop condition: Do not move production code until the shared configuration and -workspace patterns leave the existing root build green. - -Completion evidence (2026-08-28): `pnpm install --frozen-lockfile` retained the -lockfile and discovered only the root, docs, and MDX playground packages. -Tests, typecheck, build, CLI, playground build, and `pnpm pack --dry-run` all -passed. `git diff -- package.json src tests client.d.ts types-internal -pnpm-lock.yaml` is empty. The shared config has no Astro extension and sets -`types: []`, so Node ambient types are not included by default. - -## Step 3: Extract Core - -Purpose: Copy the reusable translation engine and its smallest shared data -contracts into a platform-neutral shadow package. The existing package remains -unchanged until Step 6. - -Target files in `packages/core/src`: - -```text -index.ts -segment.ts -glossary.ts -translator.ts -logger.ts -prompt.ts -batch.ts -translate-batch.ts -translate-segments.ts -``` - -Changes: - -- Copy `Segment` from `src/parsing/extract.ts`. -- Copy `Glossary`, `StyleRule`, and `EMPTY_GLOSSARY` from - `src/glossary/glossary.ts`. -- Copy `Translator` and `PermanentProviderError` from - `src/translation/provider.ts`. -- Add `isPermanentProviderError()` using the existing - `_tag: "PermanentProviderError"` discriminator so duplicate installs do not - accidentally re-enable retries. -- Copy locale-aware model ID resolution into core. Provider factories receive - only a concrete model ID. -- Copy `src/translation/logger.ts`, `prompt.ts`, `batch.ts`, and - `translate-segments.ts` into core. -- Copy `translateBatch` and its retry event/options, without provider transport - code, into `translate-batch.ts`. -- Keep prompt bytes, response parsing, retry counts, retry callbacks, jitter, - empty-input handling, batching order, and abort behavior unchanged. -- Keep `loadGlossaries`, Zod validation, `hashGlossary`, - `EMPTY_GLOSSARY_HASH`, `computeSourceHash`, and MDX policy hashing in Astro. - Those functions are host/cache concerns and currently rely on Node crypto or - filesystem APIs. -- Keep `p-retry` initially. Its implementation and `is-network-error` - dependency use Web primitives, although its package metadata declares Node - `>=22`. - -`nodejs_compat` policy: - -- Consumers may enable `nodejs_compat`; doing so is not a problem. -- Core must also run without it. This is a negative portability test that - prevents an accidental Node-only import from becoming an undocumented - requirement for every Worker consumer. -- Workerd does not enforce npm's Node `engines` metadata. Node installation and - CI continue on Node 24, which satisfies `p-retry`. -- If `p-retry` cannot execute in no-compat workerd, replace only the currently - used retry subset and pin it with the existing retry tests. Do not design a - general retry framework. - -Tests copied to `packages/core/tests` during the shadow phase: - -- Prompt construction and response parsing. -- Token-budget batching and oversize warnings. -- `translateBatch` success, parse failures, transient failures, permanent - failures, final-error behavior, retry callbacks, and cancellation. -- `translateSegments` grouping, sequential batches, merged results, and aborts. - -Automated verification: - -```sh -pnpm --filter @cloudflare/polystella-core test -pnpm --filter @cloudflare/polystella-core build -pnpm exec tsc --noEmit -``` - -Manual verification: - -- Compare one generated system/user prompt with the Step 1 baseline byte for - byte. -- Inspect `packages/core/src` imports and confirm there is no `node:`, Astro, - React, provider, adapter, filesystem, storage, or environment dependency. -- Inspect the emitted core declaration entry and confirm a downstream consumer - can implement `Translator` without importing Astro types. -- Confirm `git diff -- package.json src tests client.d.ts types-internal` is - empty. - -Stop condition: Core is not complete while any host concern or provider -transport is needed to run a translation with a fake `Translator`. - -Completion evidence (2026-08-28): `@cloudflare/polystella-core@0.4.0` -builds declarations and source maps with only `p-retry` at runtime. Its 26 -tests pin the recorded prompt hashes, response parsing, batching and reference -order, model resolution, retries, cross-install permanent errors, sequential -multi-batch translation, and cancellation. Package typecheck, dist import, -tarball dry-run, root tests/typecheck/build, and the six-page playground build -pass. Source import inspection finds no Node, Astro, React, adapter, provider, -filesystem, storage, R2, or environment dependency. Protected root package -paths remain unchanged. - -## Step 4: Extract Adapters - -Purpose: Copy current file-format translation into a reusable shadow package -without carrying Astro or Satteri into workerd. Root parsing code remains -unchanged until Step 6. - -Portable code to copy or reimplement from `src/parsing`: - -```text -adapter.ts -adapters/json.ts -adapters/markdown.ts -adapters/toml.ts -adapters/yaml.ts -apply.ts -extract.ts -key-paths.ts -mdx-jsx-attributes.ts -mdx-placeholders.ts -mdx-static-data.ts -traverse.ts -portable Remark parser functions from parse.ts -normalized MDX rule data types -``` - -Changes: - -- Define the reusable `FileAdapter` around parsing, segment - extraction, translation application, and optional segment grouping. -- Import `Segment` from core rather than defining adapter-owned segment types. -- Accept already-resolved per-source extraction policy instead of Astro's - resolved configuration type. -- Define an injectable Markdown parser contract with separate Markdown and MDX - operations. -- Make the package's built-in parser use Remark and Web-compatible modules. -- Keep Satteri and its ESTree/position compatibility repair in Astro. Astro - maps its existing `markdown.parser` option to either the injected Satteri or - Remark parser. -- Keep cache-value selection, `noTranslate`, URL rewriting, - document-context selection, and AI marker policy out of the required shared - adapter contract. -- Allow pure format helpers for those operations to live in the adapters - package when Astro needs format-aware parsing. Astro decides when and why - they run. -- Keep the Astro policy registry in Astro and register wrappers around the - shared adapter objects. The adapters package exports all current adapters - from one root entry; it does not create per-format packages. -- Keep `mdx-audit.ts`, `rewrite-links.ts`, `rewrite-mdx-imports.ts`, MDX recipe - normalization, glob selection, and policy hashing in Astro. -- Preserve the group flattening runtime assertion. -- Do not add HTML, plain text, or any other new format in this extraction. - -Expected portable runtime dependencies: - -```text -@cloudflare/polystella-core -@types/mdast -picomatch -remark-frontmatter -remark-gfm -remark-mdx -remark-parse -smol-toml -unified -yaml -``` - -`satteri` and `acorn` remain Astro dependencies for the current Satteri -compatibility path. - -Tests copied to `packages/adapters/tests` during the shadow phase: - -- Current JSON, YAML, TOML, Markdown, MDX, extraction, application, key-path, - grouping, placeholder, and round-trip tests. -- Parser-injection tests proving Astro can supply Satteri while a workerd - consumer can use Remark. -- One reconstruction fixture per format that checks untouched bytes or - formatting according to the current format contract. - -Automated verification: - -```sh -pnpm --filter @cloudflare/polystella-adapters test -pnpm --filter @cloudflare/polystella-adapters build -pnpm exec tsc --noEmit -``` - -Manual verification: - -- Open the dependency tree and confirm `satteri`, native bindings, Astro, and - Node built-ins are absent from the adapters package. -- Translate one `.md`, `.mdx`, `.json`, `.yaml`, and `.toml` fixture and compare - segment IDs and reconstructed output with Step 1. -- Confirm the Markdown and MDX paths use distinct syntax rules. -- Confirm applying the adapter twice does not duplicate generic top-level - additions used by the Astro marker wrapper. -- Confirm `git diff -- package.json src tests client.d.ts types-internal` is - empty. - -Stop condition: Do not proceed if Astro parity requires importing Satteri from -the shared adapters entry or if a format emits different segment IDs. - -Completion evidence (2026-08-28): `@cloudflare/polystella-adapters@0.4.0` -builds declarations and source maps with a narrow parser-injection contract and -Remark as its default parser. Its 40 tests cover JSON, YAML, TOML, Markdown, -MDX, parser injection, extraction/application, key paths, placeholders, static -data, reconstruction, generic-addition idempotence, and group reference/order -identity. Package and root typechecks/builds, 1,159 root tests, dist imports, -tarball dry-run, formatting, and the six-page playground build pass. Runtime -dependency and source inspection find no Satteri, Astro, Node built-in, or -native binding dependency. Protected root package paths remain unchanged. - -## Step 5: Extract Providers - -Purpose: Copy provider transports behind the core `Translator` contract so -Node and workerd hosts can use the same inference behavior. Root provider code -remains unchanged until Step 6. - -Target files: - -```text -packages/providers/src/index.ts -packages/providers/src/workers-ai.ts -packages/providers/src/anthropic.ts -``` - -Public factories: - -```text -createWorkersAIHttpTranslator -createWorkersAIBindingTranslator -createAnthropicTranslator -``` - -Changes: - -- Make provider options accept a concrete `modelId` and `maxTokens`. -- Keep locale model maps and `batchInputTokenBudget` outside providers. Astro - resolves the model through core before constructing the translator. -- Preserve Workers AI HTTP endpoint construction, bearer authentication, - custom endpoint support, chat messages, and `max_tokens`. -- Preserve Workers AI HTTP response precedence: - `result.response`, `result.choices[0].message.content`, then - `choices[0].message.content`. -- Add binding normalization for top-level `response`, top-level `choices`, - direct strings, and parsed object responses. -- Accept a small binding invocation callback rather than depending on - `@cloudflare/workers-types` or generated `Ai` model overloads. -- Check the binding translator's `AbortSignal` before and after inference. - Cloudflare's documented binding API does not currently expose true in-flight - cancellation, so do not claim that it does. -- Preserve Anthropic's endpoint, headers, body, first text-block behavior, and - injected `fetch` support without adding an SDK. -- Import the one canonical `PermanentProviderError` from core. -- Preserve permanent HTTP statuses exactly: `400`, `401`, `403`, `404`, and - `422`. Keep `408`, `425`, `429`, and `5xx` retriable. -- Do not retry inside provider factories. Core owns the single retry loop so - provider, parse, and malformed-output failures share one attempt budget. -- Leave binding errors unchanged unless the caller explicitly throws core's - `PermanentProviderError`; do not classify errors by message text. - -Tests copied or added in `packages/providers/tests` during the shadow phase: - -- Current Workers AI and Anthropic request/response tests from - `tests/translation/provider.test.ts`. -- Every permanent status plus representative `429` and `503` failures. -- HTTP signal forwarding and pre-aborted binding signals. -- Binding invocation arguments and every supported response envelope. -- Unexpected response previews and object-to-JSON normalization. -- Core/provider permanent-error identity. - -Automated verification: - -```sh -pnpm --filter @cloudflare/polystella-providers test -pnpm --filter @cloudflare/polystella-providers build -pnpm exec tsc --noEmit -``` - -Manual verification: - -- Inspect captured fake HTTP requests and compare URL, headers, body, and - `max_tokens` with Step 1. -- Run the binding factory with a small fake `env.AI.run` wrapper and inspect the - exact model ID and input object it receives. -- Confirm a `401` makes one core attempt and a `503` can consume the configured - retry budget. -- Confirm package manifests contain no Anthropic SDK or Cloudflare Workers - types runtime dependency. -- Confirm `git diff -- package.json src tests client.d.ts types-internal` is - empty. - -Stop condition: Do not rewire Astro until model identity, permanent errors, -and both existing HTTP transports match the baseline. - -Completion evidence (2026-08-28): `@cloudflare/polystella-providers@0.4.0` -exports Workers AI HTTP/binding and Anthropic factories with concrete model -IDs and the canonical core error type. Its 33 tests pin request bytes, response -precedence and normalization, all five permanent statuses, representative -retriable statuses, signal forwarding, binding cancellation boundaries, and -core retry behavior. Package/root tests, typechecks, builds, dist/subpath -imports, tarball dry-run, and the six-page playground build pass. Runtime -dependency and source inspection find no provider SDK, Workers ambient type, -Astro, Node built-in, locale-resolution, or provider-owned retry dependency. -Protected root package paths remain unchanged, and final review found no -remaining issues. - -## Step 6: Reconnect Astro At The Root - -Purpose: Prove the extraction independently from the later filesystem move. - -Primary callers to update: - -```text -src/index.ts -src/translation/provider.ts -src/translation/run.ts -src/storage/cache.ts -src/runtime/custom-loader-runtime.ts -src/i18n/ui-translate.ts -src/cli/translate-ui.ts -src/cli/audit-mdx.ts -``` - -Changes: - -- Replace relative imports of extracted code with workspace package imports. -- This is the first step allowed to edit root `package.json`, `src/**`, - `tests/**`, `client.d.ts`, or `types-internal/**`. -- Remove duplicated root implementations only after their callers import the - tested shadow packages. -- Keep a small Astro-local `createTranslator()` facade that maps validated - provider configuration to the provider package's concrete factories. -- Resolve each locale's model once and use `translator.modelId` for inference, - cache keys, metadata, reports, and output markers. -- Wrap shared adapters with Astro-owned cache selection, `noTranslate`, URL, - document-context, marker, and parser policies. -- Keep marker insertion inside the `apply` closure passed to - `translateOrLoadFromCache`, before R2 PUT. -- Keep Satteri as Astro's default parser and Remark as its configured fallback. -- Keep source walking, local cache index isolation, R2, overrides, staging, - route shims, bridge timing, and all runtime APIs in Astro. -- Keep the existing Astro package export subpaths. Move low-level translation, - adapter, and provider imports to their new package homes rather than adding - compatibility-only modules. -- Do not mix unrelated behavior fixes into this step. -- Update the root build and `prepare` scripts here, not during workspace - preparation. The root build now builds internal dependencies first; - `prepare` installs the Git hook only. - -Automated verification: - -```sh -pnpm test -pnpm exec tsc --noEmit -pnpm build -node dist/cli.js --version -pnpm playground:mdx-jsx:build -pnpm playground:mdx-jsx:translate:dry-run -``` - -Manual verification: - -- Compare dry-run R2 keys with Step 1. -- Compare one cache miss, one cache hit, one override, one `noTranslate` file, - and one local-cache skip report entry. -- Inspect staged Markdown/MDX and structured-data files for marker placement, - links, MDX imports, and formatting. -- Confirm logs still identify the resolved provider/model for each locale. - -Stop condition: Any cache-key, staged-byte, routing, marker, retry, or report -drift blocks the move to `packages/astro`. - -Completion evidence (2026-08-28): The root Astro integration delegates core, -adapter, and provider behavior to the three workspace packages while retaining -Satteri parsing and host cache/URL/marker policy. Eleven duplicate source files -were removed. All 1,161 root tests and 99 package tests pass, including direct -Remark routing, Satteri-to-Remark MDX fallback, and marker-before-cache checks. -Root/package typechecks and builds, CLI `0.4.0`, the six-page playground build, -10-key dry-run, and local fake-provider translation pass. Dry-run keys and all -10 normalized staged outputs match the Step 1 baseline. Review found no -remaining Step 6 defects; the coordinated Changesets entry remains scheduled -for Step 10. - -## Step 7: Prove Node And Workerd Portability - -Purpose: Turn the Node/workerd support decision into executable checks. - -Changes: - -- Keep the existing Node Vitest configuration for the full suite. -- Add `@cloudflare/vitest-plugin` as a root development dependency and add a - separate workerd Vitest configuration. -- Run representative core, adapters, and providers tests inside workerd. -- Add a minimal Worker fixture importing all three reusable packages. -- Configure that fixture with explicit `no_nodejs_compat` and - `no_nodejs_compat_v2`. -- Add a small source/package boundary test rejecting imports of `node:`, Astro, - React, Satteri, filesystem, and environment modules from reusable packages. -- Keep consumers free to enable `nodejs_compat`. Passing without it is a - stronger portability guarantee, not a prohibition. - -Workerd coverage: - -- Core prompt, parse, batch, retry, and cancellation behavior with a fake - translator. -- JSON and Remark-backed Markdown adapter round trips. -- Workers AI binding behavior with a fake invocation callback. -- Provider HTTP behavior with a fake global `fetch`. - -Automated verification: - -```sh -pnpm test:packages -pnpm test:node -pnpm test:workerd -pnpm test:boundaries -pnpm build -pnpm bundle:workerd:dry-run -``` - -Manual verification: - -- Inspect the Worker fixture's Wrangler configuration and confirm it explicitly - sets `no_nodejs_compat` and `no_nodejs_compat_v2`. -- Inspect its generated bundle for Node built-in imports and Satteri/native - binding references. -- Optionally repeat the fixture with `nodejs_compat` enabled to confirm that a - downstream Worker using the flag remains compatible. - -Stop condition: A reusable package that only works when `nodejs_compat` is -enabled is not platform-neutral and must be corrected before proceeding. - -Completion evidence (2026-08-28): The existing 1,161-test Node suite, eight -detailed Vitest workerd tests, and 23 reusable-package boundary checks pass. -The Vitest suite covers core prompt parsing, batching, retries, cancellation, -JSON and Remark Markdown round trips, and Workers AI binding and HTTP -transports; it is not the no-compat proof. A separate Node harness uses the docs -workspace's Wrangler 4.127.0 to bundle, inspect, start, and fetch the Worker with -explicit `no_nodejs_compat` and `no_nodejs_compat_v2`. The 1,248.57 KiB bundle -(255.99 KiB gzip) contains no Node built-in imports, Astro, React, Satteri, or -native binding references. The aggregate root test command includes the -Wrangler runtime check and all 99 lower-package tests. Root/package typechecks, -clean frozen install, root build, bounded subprocesses, cross-platform process- -tree cleanup, unique OS-temp state, and generated-artifact checks pass. - -## Step 8: Check The Extraction Before Moving Astro - -Purpose: Isolate extraction defects from path-move defects. - -Changes: - -- Pack the root Astro package and all three lower packages from the intermediate - layout. -- Install them into a temporary clean project. -- Import every lower-package entry and representative Astro entrypoints. -- Run the Astro playground against workspace packages. -- Update pkg.pr.new for the intermediate layout in the same commit that creates - the lower package manifests. - -Intermediate preview command: - -```sh -pnpm exec pkg-pr-new publish --pnpm --commentWithSha \ - '.' \ - './packages/core' \ - './packages/adapters' \ - './packages/providers' -``` - -All directories must be published in one command. pkg.pr.new then rewrites -sibling workspace dependencies to the matching preview URLs. - -Automated verification: - -```sh -pnpm test -pnpm exec tsc --noEmit -pnpm build -pnpm check:packages -pnpm playground:mdx-jsx:build -pnpm playground:mdx-jsx:translate:dry-run -``` - -Manual verification: - -- Inspect the pkg.pr.new PR comment and confirm it shows four installable - package previews. -- Install the Astro preview URL in a temporary Astro project and confirm its - package manifest points at the core, adapters, and providers preview URLs, - not unresolved `workspace:` ranges. -- Compare all Step 1 baseline artifacts one final time. - -Stop condition: Do not move Astro or make the root private until the -intermediate packages work outside the repository. - -Completion evidence (2026-08-28): The local automated gate is complete. -`check:packages` builds and packs the explicit root, core, adapters, and -providers directories into OS-temporary storage; validates names, exports, -allowlists, required files, forbidden files, common versions, exact internal -dependency versions, and removal of `workspace:` ranges; then proves from the -lockfile and installed manifests that a clean ESM consumer uses all four -tarballs. That consumer imports every lower-package entry and all six Node-safe -Astro entries, resolves `./client` types, builds and typechecks an installed -Astro project exercising the integration-backed virtual-module entrypoints, -and runs CLI `0.4.0`. `check:baseline` first deletes all ignored playground -outputs, staging, and report data, then builds and runs the dry-run and local -fake provider itself before checking the exact 10 R2 keys, preview and staged -bytes including opposite MDX import paths, normalized report -digest/totals/models/source hashes, source/prompt fixtures, and JSON, YAML, and -TOML reconstruction bytes. Subprocesses are bounded with cross-platform process- -tree cleanup, all temporary package data is removed, and `.gitattributes` pins -text to LF. The original report checksum was corrected because it did not match -its documented normalization; every report field matched. The full 1,291-test -gate, root and lower-package typechecks, build, six-page playground build, -dry-run, local fake-provider run, CLI, formatting, and diff checks pass. The -preview workflow publishes all four explicit directories in one pkg.pr.new -invocation. The pkg.pr.new PR comment and preview-URL install remain pending -external checks because they require the enabled GitHub App/workflow. Real R2 -remains external/manual; R2 hit/write/prune, override, `noTranslate`, and local- -skip paths remain covered by existing tests. No changeset was added. - -## Step 9: Move Astro And Privatize The Root - -Purpose: Finish the desired monorepo layout after extraction is proven. - -Moves: - -```text -src/** -> packages/astro/src/** -Astro-owned tests -> packages/astro/tests/** -client.d.ts -> packages/astro/client.d.ts -types-internal/** -> packages/astro/types-internal/** -CHANGELOG.md -> packages/astro/CHANGELOG.md -Astro package metadata -> packages/astro/package.json -``` - -Changes: - -- Move only the remaining Astro-owned tree. Core, adapter, and provider files - have already reached their final locations and do not move again. -- Put the current public name, description, repository, exports, binary, - peers, publish configuration, and Astro dependencies in - `packages/astro/package.json`. -- Keep `packages/astro/src/version.ts` importing `../package.json`, preserving - the package version used in reports, metadata, and the runtime bridge. -- Preserve every existing Astro subpath export and `./client` type entry. -- Keep `chmod +x dist/cli.js` in the Astro package build. -- Replace the root manifest with a private `polystella-workspace` manifest - containing workspace scripts and development/release dependencies only. -- Remove root `exports`, `bin`, `files`, `publishConfig`, peers, and runtime - dependencies. -- Keep canonical repository-wide `AGENTS.md`, `ARCHITECTURE.md`, plans, and - contributor documentation at the root. -- Add concise package-local READMEs and license files rather than copying the - full architecture into every package. -- Update path-sensitive tests, docs scripts, Prettier ignores, and workflow - filters. Do not globally replace `src/`, because docs contain intentional - downstream Astro paths. - -Automated verification: - -```sh -pnpm install -pnpm test -pnpm typecheck -pnpm build -node packages/astro/dist/cli.js --version -``` - -Manual verification: - -- Inspect the root manifest and confirm it is private and has no publishable - entrypoints. -- Inspect `packages/astro/package.json` and compare its exports with the old - root manifest. -- Run `packages/astro/dist/cli.js --version` and confirm it reports the Astro - package version, not the private root version. -- Inspect a declaration map and source map from every package and confirm its - referenced source is included in that package. - -Stop condition: The move is incomplete while any source/test assumes the root -is still `@cloudflare/polystella` or any package version reads the private root -manifest. - -Completion evidence (2026-08-28): The Astro source, 1,162 tests, client types, -internal types, changelog, and package-local TypeScript/Vitest configuration now -live under `packages/astro`; the root is a private workspace orchestrator with -no publish fields, runtime dependencies, or peers. All four public packages -remain at `0.4.0`, retain the current internal ranges, expose package-local -typechecks, and build source/declaration maps pointing to their own `src` trees; -all four tarballs include those sources. The 1,292-test aggregate -Node/workerd/boundary gate, no-compat Wrangler runtime, aggregate -typecheck/build, four-tarball package check, deterministic baseline, CLI -`0.4.0`, six-page playground build/dry-run/local translation, docs generator, -export/example checks, full docs build, and formatting pass. Root typecheck and -all five playground scripts also pass with every ignored package `dist` tree -temporarily absent. `release.yml` is the sole publisher. No changeset or version -change was added. - -## Step 10: Finish Packaging And Lockstep Versions - -Purpose: Make the four packages behave like released artifacts rather than -workspace-only source folders. - -Changes: - -- Use `workspace:*` for every internal runtime dependency so pnpm writes exact - versions into packed manifests. -- Add all four public names to one Changesets `fixed` group: - -```text -@cloudflare/polystella-core -@cloudflare/polystella-adapters -@cloudflare/polystella-providers -@cloudflare/polystella -``` - -- Keep all four source manifests at `0.4.0` during extraction. -- Add one minor changeset covering all four packages. The release PR should - produce the first coordinated `0.5.0` release. -- Keep `src` and `dist` in each package tarball so declaration/source maps can - resolve source files. -- Add one small root package-check script that packs all four packages, - validates their export targets and internal versions, and installs them in a - temporary consumer. -- In the temporary consumer, import core, adapters, providers root/subpaths, - and all Astro runtime exports; typecheck representative APIs; run the CLI. -- Ensure no tarball contains tests, credentials, root tooling, or unresolved - `workspace:` ranges. - -Automated verification: - -```sh -pnpm build -pnpm check:packages -pnpm changeset status -``` - -Manual verification: - -- Open each tarball file list and inspect package name, version, files, export - targets, README, license, changelog, and internal dependency versions. -- Confirm the providers tarball exposes both subpaths. -- Confirm the Astro tarball contains an executable CLI with its shebang. -- Inspect the generated Changesets release state and confirm all four packages - move to the same version. - -Stop condition: Workspace tests are insufficient; packaging is blocked until a -fresh project can install only the tarballs and use every public entrypoint. - -Completion evidence (2026-08-28): All four source manifests remain at `0.4.0` -and use `workspace:*` for internal runtime dependencies; the private root's -workspace-only references match. One fixed Changesets group and one minor -changeset produce exactly four planned `0.5.0` releases, while private -workspaces remain unchanged. Lower-package changelogs exist now because -Changesets creates missing files only during versioning, after this step's -tarball inspection. The strengthened `check:packages` gate validates source and -packed versions, exact exports and internal versions, required `src`, `dist`, -README, license, and changelog files, tarball allowlists, forbidden files, -resolved workspace ranges, and the CLI mode and shebang. Its clean consumer -installed all four `0.4.0` tarballs, exercised all public surfaces through -direct imports, client types, and an Astro build/typecheck, and ran CLI `0.4.0`. -Install, build, package check, Changesets status/release-state inspection, -focused formatting, and `git diff --check` all pass. No publish, commit, or Step -11 work was performed. - -## Step 11: Update Preview, Release, CI, And Documentation - -Purpose: Make repository automation and guidance agree with the final layout. - -Preview releases: - -- Update `.github/workflows/preview-releases.yml` to build first and publish all - package directories once. -- Keep the existing `PKG_PR_NEW_ENABLED` opt-in unless repository policy - changes separately. - -Final preview command: - -```sh -pnpm exec pkg-pr-new publish --pnpm --commentWithSha './packages/*' -``` - -This generates separate pkg.pr.new previews for core, adapters, providers, and -Astro. Publishing them in one invocation lets pkg.pr.new replace sibling -workspace dependencies with their corresponding preview URLs. - -Release and CI changes: - -- Keep `.github/workflows/release.yml` as the single Changesets publisher. -- Remove `.github/workflows/publish.yml`; `npm publish` from the private root - would be wrong and duplicates the Changesets path. -- Update `.github/workflows/ci.yml` to run Node tests, workerd tests, typecheck, - topological builds, package checks, and the moved Astro CLI smoke test. -- Update `.github/workflows/docs.yml` path filters for - `packages/astro/src/config/options.ts` and all package manifests. -- Update `.github/workflows/pr-triage.yml` area mappings from root `src/**` and - `tests/**` paths to package paths. -- Update `.changeset/README.md` for four fixed public packages and remove the - obsolete tag-publish fallback instructions. - -Documentation changes: - -- Update root `README.md`, `ARCHITECTURE.md`, `AGENTS.md`, `llms.txt`, generated - `llms-full.txt`, and contributor/consumer skills. -- Update stable repository paths in `packages/astro/tests/docs.test.ts` and - planning/reference documents that describe current architecture. -- Update `docs/scripts/generate-config-ref.ts` to import the moved Astro schema. -- Update `docs/scripts/check-exports.ts` to inspect all four public manifests. -- Document direct in-process flow: - -```text -source/record -> adapter -> core -> provider -> core -> adapter -> output -``` - -- Document a Workers AI binding example without importing generated Cloudflare - types into the provider package. -- Document that `nodejs_compat` is allowed but not required by reusable - packages. -- Document migrated low-level imports instead of adding compatibility shims. - -Automated verification: - -```sh -pnpm test -pnpm --filter polystella-docs build -pnpm --filter polystella-docs check-exports -pnpm --filter polystella-docs check-examples -pnpm build:llms -pnpm format:check -``` - -Manual verification: - -- Open the pkg.pr.new PR comment and confirm there are four package links and - one updated comment rather than four separate comments. -- Install at least the Astro and providers preview URLs together in a temporary - consumer and confirm sibling previews resolve. -- Read every changed command in `AGENTS.md` and run it from the repository root. -- Browse the docs export page and verify each package/subpath has a clear owner - and import example. - -Stop condition: Do not release while automation still assumes the root is a -publishable package or docs show old source paths/imports. - -Completion evidence (2026-08-28): The opt-in preview workflow builds first, -then publishes `./packages/*` in one exact quoted pkg.pr.new invocation. -`release.yml` is the sole publisher and `publish.yml` is absent. CI explicitly -runs reusable-package and Astro Node tests, workerd tests, boundary checks, -typecheck, the topological build, four-tarball package checks, and the moved -Astro CLI smoke. Docs filters name the moved schema and all four manifests; -triage maps all package trees; Changesets guidance describes the fixed -four-package release. Current docs and skills identify package ownership, -migrated low-level imports with no compatibility shims, standard-Web-API -portability without requiring `nodejs_compat`, the exact direct package flow, -and a package-typed Workers AI binding example. `pnpm build`, the 1,292-test -Node/workerd/boundary gate, aggregate typecheck, clean-consumer package check, -43-page docs build, exact bidirectional checks for all 17 documented export -paths and their import examples, and a TypeScript-compiled direct -core/adapters/providers example with a structural Workers AI binding all pass. -`llms-full.txt` generation is deterministic, formatting passes, the CLI reports -`0.4.0`, all 10 workflow files parse as YAML, static assertions confirm package -checks immediately precede Changesets and docs verify generated context, and -`git diff --check` passes. Registry lookups for the core, adapters, and providers -package names returned 404; creating those npm packages is an external -prerequisite before the first release. The pkg.pr.new GitHub App comment, its -four external preview links, and a preview-URL install could not be exercised -locally; they remain external checks requiring the enabled GitHub App and -workflow. No commit, publish, npm bootstrap, Changesets versioning, or Step 12 -work was performed. - -## Step 12: Run The Final Release Gate - -Purpose: Verify the same code paths and package artifacts real consumers will -use. - -Automated verification: - -```sh -pnpm install --frozen-lockfile -pnpm test -pnpm test:node -pnpm test:workerd -pnpm test:boundaries -pnpm typecheck -pnpm build -pnpm check:packages -pnpm format:check -pnpm --filter polystella-docs build -pnpm --filter polystella-docs check-exports -pnpm --filter polystella-docs check-examples -pnpm playground:mdx-jsx:build -pnpm playground:mdx-jsx:translate:dry-run -node packages/astro/dist/cli.js --version -``` - -Manual verification: - -- Run one real Workers AI translation through the Astro HTTP path. -- Run one real Workers AI translation through a minimal Worker using the - binding path. This may incur Workers AI usage charges and must remain outside - normal CI. -- Run one Anthropic HTTP smoke test if credentials are available; otherwise - rely on the unchanged request characterization and record the omission. -- Verify one cold cache miss and one warm cache hit against a safe test R2 - bucket. -- Compare resulting staged bytes, cache metadata, report fields, marker values, - model IDs, links, and R2 keys with the Step 1 expectations. -- Install the four pkg.pr.new previews in a clean consumer and run an Astro - build plus a direct core/adapters/providers example. -- Review `git diff`, package tarball contents, and the pending changeset for - secrets or accidental unrelated changes. - -Release condition: Every automated gate passes, required manual checks are -recorded, and no known behavior regression remains. Only then merge the -Changesets release PR and publish all four packages together. - -Completion evidence (2026-08-28): The stale Step 12 root `tsc` command now -uses the aggregate `pnpm typecheck`. Every listed command passes in order: -frozen install, the 1,292-test aggregate gate, intentionally redundant 1,162- -test Astro Node, eight-test workerd/no-compat, and 23-test boundary reruns, -aggregate typecheck, build, four-tarball clean-consumer check, formatting, -43-page docs build, 17-export documentation check, direct-package example -compile, six-page playground build, 10-key dry-run, and CLI `0.4.0`. The final -`check:baseline` guard matches all 10 dry-run keys, all 10 normalized preview -and staged outputs, report totals/digest/models/source hashes, prompt/hash -fixtures, links/import rewrites, markers, and three structured adapters. It now -asserts the report version against the current Astro package manifest and -normalizes only that version before the otherwise-exact report digest. -Changesets status plans the fixed four-package group at minor (`0.5.0`). The -production audit improved from 23 advisories (12 high, eight moderate, three -low) to zero after compatible Astro, MDX, astro-icon, Nimbus, and narrow patched -transitive updates; the audit and peer-dependency checks both pass. - -Manual artifact and release-state inspection also passes locally. Four public -`0.4.0` tarballs contain only allowlisted package files and complete source -maps: Astro has 132 emitted JS/declaration files and 132 maps, adapters 30/30, -core 18/18, and providers 8/8. Packed internal dependencies resolve exactly to -`0.4.0`; providers exposes both provider subpaths; Astro exposes all 12 export -paths and its executable CLI retains the Node shebang. All 10 workflow files -parse as YAML. The release runs only for main pushes, audits production -dependencies, and runs baseline then package checks immediately before the sole -Changesets action; CI also audits production dependencies, and no npm -environment was added. The tarball consumer now typechecks representative core, -adapter, aggregate-provider, both provider-subpath, and Astro APIs without path -mappings. The docs example launcher invokes TypeScript's JavaScript CLI through -Node on every platform and handles spawn errors. The upgraded docs render all -43 pages with zero diagnostics and -the same 41-page search index. `git diff --check` passes; changed and untracked -files have no forbidden secret filenames or recognized secret content -signatures, no files are staged, and final review found no unrelated change or -local extraction defect. - -Release remains blocked. Registry reads return 404 for the three new package -names, so npm package bootstrap and Trusted Publishing setup are still -required. Workers AI credential variables are present only in the ignored -playground env file, but no explicit safe endpoint/model target is configured; -the real Astro HTTP call was therefore omitted. No real Worker binding target, -Anthropic credential, or safe R2 bucket/configuration exists, so the binding, -Anthropic, cold/warm R2, live cache metadata, and live output comparisons were -not run. Existing provider/workerd/cache/run tests and the local fake-provider -baseline cover those code paths without external calls, but do not complete -the manual checks. pkg.pr.new comment/link and preview-consumer checks also -remain omitted: local GitHub CLI authentication is absent and the public API -request returned 403, so the app state and preview URLs could not be confirmed. -No commit, publish, npm bootstrap, Changesets versioning, or paid external call -was performed. - -## Explicitly Deferred - -- Moving the Translation Agent into this repository. -- Designing partial-failure, provenance, token-usage, or model-fallback APIs - before the Translation Agent source is available. -- Creating separate packages per provider or adapter. -- Adding new adapters or providers. -- Moving R2/cache behavior into core. -- Adding a hosted service or any required server hop. -- Securing or migrating to a dedicated npm scope. -- Broad cleanup or unrelated bug fixes discovered during extraction. - -## Decision Log - -Update this table when implementation requires changing an agreed boundary. - -| Date | Decision | Reason | -| ---------- | --------------------------------------------------- | ---------------------------------------------------------------------------------- | -| 2026-08-28 | Use `@cloudflare/polystella-*` names | Dedicated scope is speculative and unnecessary for extraction. | -| 2026-08-28 | Use lockstep versions | The first releases are tightly coordinated and internal dependencies stay exact. | -| 2026-08-28 | One providers package with subpaths | Current providers have no heavy SDK dependencies that justify separate releases. | -| 2026-08-28 | Workers AI owns HTTP and binding transports | Astro and the future Translation Agent can share normalization and error behavior. | -| 2026-08-28 | One adapters package | Current formats share contracts and portable parsing dependencies. | -| 2026-08-28 | Support Node and no-compat workerd | Consumers may use `nodejs_compat`, but reusable packages should not require it. | -| 2026-08-28 | Extract before moving Astro | Each concern moves once and regressions remain attributable. | -| 2026-08-28 | Prepare for, but do not move, the Translation Agent | Its source and final result/provenance requirements are not part of this change. | From f021d0302787e306c03dfe48adbed50216633c28 Mon Sep 17 00:00:00 2001 From: Diogo Carneiro Date: Mon, 31 Aug 2026 10:20:25 +0100 Subject: [PATCH 05/13] chore(ci): update PR template to use pnpm typecheck command Replace `pnpm exec tsc --noEmit` with `pnpm typecheck` in pull request template checklist. --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a858e36..0bcf531 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -37,7 +37,7 @@ Closes # - [ ] I have added a changeset, or this change does not affect the published package - [ ] I have updated docs, or this change does not affect public behavior - [ ] `pnpm test` passes, or I have explained why it was not run -- [ ] `pnpm exec tsc --noEmit` passes, or I have explained why it was not run +- [ ] `pnpm typecheck` passes, or I have explained why it was not run - [ ] Docs checks/builds pass if docs or public APIs changed ## AI-generated code disclosure From 7778a565199f90f98a8a90257f535836e0961955 Mon Sep 17 00:00:00 2001 From: Diogo Carneiro Date: Mon, 31 Aug 2026 11:30:13 +0100 Subject: [PATCH 06/13] chore: add @cloudflare/polystella-astro as fifth package in fixed version group Add `@cloudflare/polystella-astro` compatibility package to Changesets fixed group alongside core, adapters, providers, and polystella packages. Update all documentation, workflows, and examples to reference five packages instead of four. Update install instructions and import examples to prefer `@cloudflare/polystella-astro` as the canonical Astro integration name. Add CLI smoke test for astro-alias package. Update exports reference to document all 29 --- .changeset/README.md | 6 +- .changeset/clean-stars-extract.md | 3 +- .changeset/config.json | 8 +- .github/workflows/ci.yml | 8 +- .github/workflows/docs.yml | 2 + AGENTS.md | 8 +- ARCHITECTURE.md | 10 +- CONTRIBUTING.md | 7 +- README.md | 15 ++- docs/astro.config.ts | 2 + docs/scripts/check-exports.ts | 1 + docs/src/components/Header.astro | 1 + .../content/docs/adapters/custom-loader.md | 4 +- docs/src/content/docs/adapters/mdx.md | 2 +- .../content/docs/getting-started/install.md | 5 +- .../docs/getting-started/quick-start.md | 14 +- docs/src/content/docs/reference/exports.md | 73 ++++++----- .../docs/runtime-api/explicit-imports.md | 12 +- .../content/docs/runtime-api/middleware.md | 2 +- .../content/docs/runtime-api/react-hooks.md | 6 +- docs/tsconfig.json | 2 +- llms-full.txt | 40 +++--- package.json | 5 +- packages/astro-alias/CHANGELOG.md | 5 + packages/astro-alias/LICENSE | 21 +++ packages/astro-alias/README.md | 11 ++ packages/astro-alias/client.d.ts | 1 + packages/astro-alias/package.json | 110 ++++++++++++++++ packages/astro-alias/src/catalog/astro.ts | 2 + packages/astro-alias/src/catalog/index.ts | 1 + .../astro-alias/src/catalog/middleware.ts | 1 + packages/astro-alias/src/cli.ts | 10 ++ packages/astro-alias/src/content/index.ts | 1 + packages/astro-alias/src/i18n/index.ts | 1 + packages/astro-alias/src/index.ts | 2 + packages/astro-alias/src/react/index.ts | 1 + packages/astro-alias/src/recipes/index.ts | 1 + packages/astro-alias/src/recipes/starlight.ts | 1 + packages/astro-alias/src/runtime/index.ts | 1 + .../astro-alias/src/runtime/middleware.ts | 1 + packages/astro-alias/tsconfig.build.json | 13 ++ packages/astro-alias/tsconfig.json | 7 + packages/astro/src/catalog/astro.ts | 6 +- packages/astro/src/index.ts | 5 +- packages/astro/tests/catalog/astro.test.ts | 2 +- packages/astro/tests/smoke.test.ts | 10 +- pnpm-lock.yaml | 22 ++++ scripts/check-packages.mjs | 122 +++++++++++++++++- skills/polystella-consumer/SKILL.md | 22 ++-- 49 files changed, 488 insertions(+), 128 deletions(-) create mode 100644 packages/astro-alias/CHANGELOG.md create mode 100644 packages/astro-alias/LICENSE create mode 100644 packages/astro-alias/README.md create mode 100644 packages/astro-alias/client.d.ts create mode 100644 packages/astro-alias/package.json create mode 100644 packages/astro-alias/src/catalog/astro.ts create mode 100644 packages/astro-alias/src/catalog/index.ts create mode 100644 packages/astro-alias/src/catalog/middleware.ts create mode 100644 packages/astro-alias/src/cli.ts create mode 100644 packages/astro-alias/src/content/index.ts create mode 100644 packages/astro-alias/src/i18n/index.ts create mode 100644 packages/astro-alias/src/index.ts create mode 100644 packages/astro-alias/src/react/index.ts create mode 100644 packages/astro-alias/src/recipes/index.ts create mode 100644 packages/astro-alias/src/recipes/starlight.ts create mode 100644 packages/astro-alias/src/runtime/index.ts create mode 100644 packages/astro-alias/src/runtime/middleware.ts create mode 100644 packages/astro-alias/tsconfig.build.json create mode 100644 packages/astro-alias/tsconfig.json diff --git a/.changeset/README.md b/.changeset/README.md index 61afbc4..407ddb5 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -14,8 +14,8 @@ pnpm changeset - **Which packages are affected?** Select every package changed by the PR: `@cloudflare/polystella-core`, `@cloudflare/polystella-adapters`, - `@cloudflare/polystella-providers`, and/or `@cloudflare/polystella`. - They form one fixed group, so releasing any one releases all four at + `@cloudflare/polystella-providers`, `@cloudflare/polystella`, and/or + `@cloudflare/polystella-astro`. They form one fixed group, so releasing any one releases all five at the same version. The private root and `polystella-docs` are not released. - **Is the change major / minor / patch?** Pre-1.0, "major" stays reserved for 1.0; bump minor for breaking changes within 0.x, @@ -30,7 +30,7 @@ with the PR. Pending changesets are consumed by the versioning step for a release. That step: -- Bumps all four public package manifests to the same version. +- Bumps all five public package manifests to the same version. - Updates the package-local `CHANGELOG.md` files. - Deletes the consumed changeset files. diff --git a/.changeset/clean-stars-extract.md b/.changeset/clean-stars-extract.md index 3ffb992..e949cfd 100644 --- a/.changeset/clean-stars-extract.md +++ b/.changeset/clean-stars-extract.md @@ -3,6 +3,7 @@ "@cloudflare/polystella-adapters": minor "@cloudflare/polystella-providers": minor "@cloudflare/polystella": minor +"@cloudflare/polystella-astro": minor --- -Extract PolyStella into four lockstep packages with portable core, adapters, providers, and Astro integration. +Extract PolyStella into five lockstep packages with portable core, adapters, providers, the Astro integration, and its compatibility package. diff --git a/.changeset/config.json b/.changeset/config.json index 165e816..09e8f33 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,7 +8,13 @@ ], "commit": false, "fixed": [ - ["@cloudflare/polystella-core", "@cloudflare/polystella-adapters", "@cloudflare/polystella-providers", "@cloudflare/polystella"] + [ + "@cloudflare/polystella-core", + "@cloudflare/polystella-adapters", + "@cloudflare/polystella-providers", + "@cloudflare/polystella", + "@cloudflare/polystella-astro" + ] ], "linked": [], "access": "public", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04735f9..00e9eaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,9 +114,15 @@ jobs: test -f packages/astro/dist/content/index.js test -f packages/astro/dist/i18n/index.js test -f packages/astro/dist/react/index.js + test -f packages/astro-alias/dist/cli.js + test -x packages/astro-alias/dist/cli.js + test -f packages/astro-alias/dist/index.js + test -f packages/astro-alias/dist/index.d.ts - name: Smoke-test the CLI bundle under bare Node # No tsx, no Vite — exactly the environment a downstream # consumer's standalone `polystella ` invocation # uses. Regression guard for the Node 24 # `ERR_UNSUPPORTED_NODE_MODULES_TYPESCRIPT` class of bug. - run: node packages/astro/dist/cli.js --version + run: | + node packages/astro/dist/cli.js --version + node packages/astro-alias/dist/cli.js --version diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d718996..36a4cd5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,6 +11,7 @@ on: - "packages/adapters/package.json" - "packages/providers/package.json" - "packages/astro/package.json" + - "packages/astro-alias/package.json" - "package.json" - "AGENTS.md" - "ARCHITECTURE.md" @@ -29,6 +30,7 @@ on: - "packages/adapters/package.json" - "packages/providers/package.json" - "packages/astro/package.json" + - "packages/astro-alias/package.json" - "package.json" - "AGENTS.md" - "ARCHITECTURE.md" diff --git a/AGENTS.md b/AGENTS.md index 6c91e1c..13b9063 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,9 +27,9 @@ section numbers. Inserting new sections never breaks links. | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | | `pnpm test` | Run all package, Astro, workerd, and boundary tests. | | `pnpm test:watch` | Run the Astro package tests in watch mode. | -| `pnpm build` | Build all four public packages. Astro emits its standalone CLI and library entries under `packages/astro/dist/`. | -| `pnpm typecheck` | Build all four packages, then typecheck every public package against its package-local `tsconfig.json`. | -| `pnpm check:packages` | Pack all four public packages and exercise every export from a clean temporary consumer. | +| `pnpm build` | Build all five public packages. Astro emits its standalone CLI and library entries under `packages/astro/dist/`. | +| `pnpm typecheck` | Build all five packages, then typecheck every public package against its package-local `tsconfig.json`. | +| `pnpm check:packages` | Pack all five public packages and exercise every export from a clean temporary consumer. | | `pnpm changeset` | Add a Changesets entry for package-affecting work. Use `pnpm changeset add --empty` only for changes that intentionally do not need a package release. | No lint step yet. @@ -98,7 +98,7 @@ to the explanatory section when adding code that touches one. `pnpm changeset add --empty` only when the change deliberately does not require a package release (for example, docs-site-only or CI-only maintenance). -- Let Changesets version the fixed four-package group; do not manually bump +- Let Changesets version the fixed five-package group; do not manually bump individual manifests. `POLYSTELLA_VERSION` in `packages/astro/src/version.ts` reads the Astro manifest at module-load time and flows to `packages/astro/dist/version.js` after `pnpm build`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a3b3a27..ca5e3f1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -769,8 +769,8 @@ tsc preserves the `with { type: "json" }` import attribute, so the consumer's `node_modules/@cloudflare/polystella/`. No version inlining; one source of truth. -Changesets versions all four public packages as a fixed group. The Astro -manifest remains this constant's source, so both Astro surfaces follow its -generated version. The constant is baked into R2 metadata and the build -report but is NOT in the cache key formula, so a version bump doesn't -re-translate. +Changesets versions all five public packages as a fixed group. The Astro +manifest remains this constant's source, so the canonical package and +`@cloudflare/polystella-astro` alias report the same generated version. The +constant is baked into R2 metadata and the build report but is NOT in the +cache key formula, so a version bump doesn't re-translate. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00648b8..6766025 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,9 +10,10 @@ easier to evaluate when maintainer time is available. ## Repository overview -PolyStella is a pnpm workspace with four public packages under `packages/`: +PolyStella is a pnpm workspace with five public packages under `packages/`: - `packages/astro/` — `@cloudflare/polystella`. +- `packages/astro-alias/` — `@cloudflare/polystella-astro`, a forwarding compatibility package. - `packages/core/` — platform-neutral translation orchestration. - `packages/adapters/` — Markdown, MDX, JSON, YAML, and TOML adapters. - `packages/providers/` — Workers AI and Anthropic transports. @@ -43,8 +44,8 @@ Required: | Command | Purpose | | ------------------------------------- | --------------------------------------------------------------- | | `pnpm test` | Run package, Astro, workerd, and boundary tests. | -| `pnpm typecheck` | Build and typecheck all four public packages. | -| `pnpm build` | Build all four public packages. | +| `pnpm typecheck` | Build and typecheck all five public packages. | +| `pnpm build` | Build all five public packages. | | `pnpm build:llms` | Regenerate `llms-full.txt` from canonical agent docs. | | `pnpm --filter polystella-docs dev` | Run the Nimbus docs site locally. | | `pnpm --filter polystella-docs build` | Build the docs site (includes auto-generated config reference). | diff --git a/README.md b/README.md index d5d9cff..a415118 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ PolyStella is an [Astro](https://astro.build) integration that translates content into additional locales at build time using AI, caches translations in Cloudflare R2, and injects locale-prefixed routes for the translated pages. -The repository publishes four lockstep packages: +The repository publishes five lockstep packages: | Package | Owns | | ---------------------------------- | ------------------------------------------------------------------ | | `@cloudflare/polystella` | Astro integration, CLI, R2, routing, runtime, and host policy. | +| `@cloudflare/polystella-astro` | Preferred compatibility name for the Astro integration. | | `@cloudflare/polystella-core` | Platform-neutral prompts, batching, retries, and shared contracts. | | `@cloudflare/polystella-adapters` | Portable Markdown, MDX, JSON, YAML, and TOML adapters. | | `@cloudflare/polystella-providers` | Workers AI HTTP/binding and Anthropic transports. | @@ -36,7 +37,7 @@ Workers without `nodejs_compat`; consumers may still enable it. Install from npm: ```bash -pnpm add @cloudflare/polystella +pnpm add @cloudflare/polystella-astro ``` Peer dependencies: `astro ^7.0.10`, optionally `react ^17 || ^18 || ^19`. @@ -56,7 +57,7 @@ Four files participate in a typical setup. ```js import { defineConfig } from "astro/config"; -import polystella from "@cloudflare/polystella"; +import polystella from "@cloudflare/polystella-astro"; import polystellaConfig from "./polystella.config.mjs"; export default defineConfig({ @@ -74,8 +75,8 @@ export default defineConfig({ ```ts import { defineCollection } from "astro:content"; -import { polystellaCollections } from "@cloudflare/polystella/content"; -import { i18nLoader, i18nSchema } from "@cloudflare/polystella/i18n"; +import { polystellaCollections } from "@cloudflare/polystella-astro/content"; +import { i18nLoader, i18nSchema } from "@cloudflare/polystella-astro/i18n"; import { blog, authors } from "./content-schemas"; @@ -90,7 +91,7 @@ export const collections = { **4. `src/env.d.ts`** — pick up types for PolyStella's virtual modules: ```ts -/// +/// ``` ## Catalog-Only Usage @@ -99,7 +100,7 @@ Projects that already handle localized content and routing can adopt only PolyStella's JSON catalog flow: ```ts -import catalogAstro from "@cloudflare/polystella/catalog/astro"; +import catalogAstro from "@cloudflare/polystella-astro/catalog/astro"; export default defineConfig({ i18n: { defaultLocale: "en-US", locales: ["en-US", "pt-BR"] }, diff --git a/docs/astro.config.ts b/docs/astro.config.ts index 538f3d7..65c63c5 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from "astro/config"; import icon from "astro-icon"; import nimbus, { defineConfig as defineNimbusConfig } from "nimbus-docs"; +import polystellaPackage from "../packages/astro/package.json"; const nimbusConfig = defineNimbusConfig({ site: "https://polystella-docs.pcx-team.workers.dev", @@ -10,6 +11,7 @@ const nimbusConfig = defineNimbusConfig({ github: "https://github.com/cloudflare/polystella", editPattern: "https://github.com/cloudflare/polystella/edit/main/docs/{path}", socialImageAlt: "PolyStella documentation preview", + versions: { current: polystellaPackage.version, others: [] }, sidebar: { items: [ { diff --git a/docs/scripts/check-exports.ts b/docs/scripts/check-exports.ts index d935bf0..6a6e053 100644 --- a/docs/scripts/check-exports.ts +++ b/docs/scripts/check-exports.ts @@ -11,6 +11,7 @@ const PACKAGE_MANIFESTS = [ { owner: "Adapters", directory: "adapters" }, { owner: "Providers", directory: "providers" }, { owner: "Astro", directory: "astro" }, + { owner: "Astro", directory: "astro-alias" }, ] as const; const EXPORTS_PAGE = path.join(DOCS_ROOT, "src", "content", "docs", "reference", "exports.md"); diff --git a/docs/src/components/Header.astro b/docs/src/components/Header.astro index 9ce2db6..503c92e 100644 --- a/docs/src/components/Header.astro +++ b/docs/src/components/Header.astro @@ -50,6 +50,7 @@ const showSections = sections.length >= 2; + {config.versions && v{config.versions.current}} {showSections && (