diff --git a/TODO.impl/01-examples-corpus.md b/TODO.impl/01-examples-corpus.md new file mode 100644 index 0000000..95bdbd6 --- /dev/null +++ b/TODO.impl/01-examples-corpus.md @@ -0,0 +1,48 @@ +# [COMPLETE 2026-09-07] 01 — Examples corpus (P1) + +## Goal +A committed set of runnable TS scripts exercising the public API the +way a playground user would. They are the preset gallery (single +source of truth — the client agent renders the `.ts` sources) and a +CI guarantee (they run as tests, so they cannot rot when the API +moves). + +## Why +The playground's value is the story its examples tell. Examples that +live only in a UI rot; examples that run in CI are a public-API +regression suite that pays for itself twice. + +## Spec + +`examples/` directory, each example a self-contained module with one +default-exported behavior and no side effects at import time: + +1. `examples/convert.ts` — the hello world: load a map, transliterate + one string. `interscript` top-level API. +2. `examples/detect-chain.ts` — `detect()` to rank candidate systems, + then transliterate through the best hit (composition story). +3. `examples/neural-diacritize.ts` — `interscript/ml`: resolve a model + by id, decode, with `onProgress` wired; runs against the int8 tier + id but tolerates no-network environments (test asserts the module + contract, live decode covered by the site e2e instead). +4. `examples/batch-columns.ts` — paste/CSV-shaped batch conversion + (the cataloger pipeline: normalize a column of names). +5. `examples/server-mode.ts` — the no-download path: `fetch` against + `https://api.interscript.org/v1/infer`, typed response. + +Registry: `examples/index.ts` exports `EXAMPLES: readonly Example[]` +with `Example = { id, title, summary, file }` — `file` is the example +module path; the registry is data, not behavior (open/closed: adding +an example = adding a file + one registry line, no switch anywhere). + +CI: `test/examples.test.ts` imports the registry, runs examples 1, 2, +4, 5 for real (network allowed for map fetch — same pattern the +existing suite already uses), and asserts each example's documented +expected output. Example 3 asserts its exported contract only. + +## Acceptance +- `npx vitest run test/examples.test.ts` green locally. +- Adding a hypothetical 6th example requires no change outside + `examples/` + one registry line (the test iterates the registry). +- No example imports anything outside the package's public exports + (`interscript`, `interscript/ml`). diff --git a/TODO.impl/02-cdn-delivery-recipe.md b/TODO.impl/02-cdn-delivery-recipe.md new file mode 100644 index 0000000..bf26e7a --- /dev/null +++ b/TODO.impl/02-cdn-delivery-recipe.md @@ -0,0 +1,39 @@ +# [COMPLETE 2026-09-07] 02 — CDN delivery recipe (P1) + +## Goal +The verified, version-pinned way to load `interscript` in a browser +with no bundler — written down with the evidence of what fails and +why, the kotoshu pattern. + +## Why +kotoshu's engine worker documents that esm.sh and esm.run transforms +broke their wasm delivery and that raw jsDelivr files + local +instantiation was the only working path. Our package (ESM, fflate + +js-yaml deps, dynamic onnxruntime path for `/ml`) has the same class +of trap. The playground client agent should consume a proven recipe, +not rediscover it. + +## Spec + +Probe, then document: + +1. **Probe (evidence, not assumption)**: in a real browser + (Playwright, run from the site repo's installed tooling): + - `import("https://esm.sh/interscript@5.3.0")` — does it load, and + does a map conversion complete? + - the raw-jsDelivr alternative if esm.sh fails any step; + - for `/ml`: whether `interscript/ml` imports without + onnxruntime-web present, and how ORT + its wasm files resolve + from CDN when it is imported from esm.sh's ORT pin. +2. **Artifact**: `docs/CDN.md` — the working recipe(s), exact-version + pin style, the failure modes found with their causes, and the + ORT/wasm note for the `/ml` path. Follows the kotoshu + engine-worker comment tradition: the *why* is the deliverable. +3. **Light regression**: `test/cdn-urls.test.ts` — asserts the pinned + CDN entry URLs respond 200 with a JS content-type (network test, + same allowance as the examples suite). + +## Acceptance +- The probe ran; CDN.md states only what was observed. +- The recipe in CDN.md is the exact one exercised by item 03's worker + test (no drift between doc and proof). diff --git a/TODO.impl/03-worker-mode-guarantee.md b/TODO.impl/03-worker-mode-guarantee.md new file mode 100644 index 0000000..41108c9 --- /dev/null +++ b/TODO.impl/03-worker-mode-guarantee.md @@ -0,0 +1,30 @@ +# [COMPLETE 2026-09-07] 03 — Worker-mode guarantee (P2) + +## Goal +Proof, in a real browser, that the package runs inside a Web Worker — +the architecture the playground will use to keep every conversion off +the main thread (kotoshu runs its engine exclusively in a worker). + +## Why +The 5.2.1 browser-safety fix guarded window/document access via +optional globalThis chains — verified in page context, never in worker +context. A worker also changes asset-loading conditions (no DOM, but +Cache API and fetch exist). One test pins all of it. + +## Spec + +`e2e/worker-engine.spec.ts` in the site repo (it owns Playwright + +the browser runtime peers): + +- the page spawns `new Worker(blob-url)`; +- the worker imports the package via the CDN recipe from item 02; +- the worker transliterates a fixed string and `postMessage`s the + result; +- the test asserts the expected output on the page side. + +This simultaneously re-proves item 02's recipe per CI run. + +## Acceptance +- Spec green in site CI. +- No change to the library itself (if the worker path fails, the fix + lands in the library with its own test — the spec is the tripwire). diff --git a/TODO.impl/04-playground-ambient-types.md b/TODO.impl/04-playground-ambient-types.md new file mode 100644 index 0000000..7eede32 --- /dev/null +++ b/TODO.impl/04-playground-ambient-types.md @@ -0,0 +1,26 @@ +# [COMPLETE 2026-09-07] 04 — Playground ambient types (P2) + +## Goal +A shipped `playground.d.ts` so playground users get autocomplete +against the real published types from the first keystroke. + +## Why +The playground injects the package as a global (or via a pinned CDN +import); without ambient declarations the editor cannot offer +completion, and the published `.d.ts` alone doesn't declare the +injected binding. + +## Spec + +- `src/playground.d.ts`: ambient `declare const interscript: typeof + import("interscript")` (+ the `ml` namespace import where the + runtime exposes it), written so a playground can reference it via + triple-slash or config include. +- Emitted with the build (already inside `dist` via `files`). +- Documented in `docs/CDN.md`'s recipe (one line: how to wire it into + the editor's tsconfig). + +## Acceptance +- `dist/playground.d.ts` present in the built output. +- Referencing it makes the injected global type-check (verified by a + type-level test or tsc smoke in CI). diff --git a/TODO.impl/05-api-server-mode-cors.md b/TODO.impl/05-api-server-mode-cors.md new file mode 100644 index 0000000..1bfc9b5 --- /dev/null +++ b/TODO.impl/05-api-server-mode-cors.md @@ -0,0 +1,24 @@ +# [COMPLETE 2026-09-07] 05 — API server-mode CORS (P3) + +## Goal +The playground's "no 300 MB download" path — calling +`api.interscript.org` from browser scripts — verified end to end. + +## Why +`examples/server-mode.ts` (item 01) documents the fetch path; if the +API does not send `Access-Control-Allow-Origin`, the example is a lie +in the exact context it exists for. + +## Spec + +- Live probe: OPTIONS preflight + POST `/v1/infer` from an `Origin: + https://interscript.org` request; assert ACAO present and the infer + round-trips. +- If broken: fix in the api worker (CORS middleware) via its normal + release chain (this escalates the item to a release — flag to the + owner rather than shipping a production change unannounced if the + fix is anything beyond additive middleware). + +## Acceptance +- Probe documented in the example's docblock (or the fix shipped). +- No API behavior change beyond CORS headers if a fix was needed. diff --git a/TODO.impl/README.md b/TODO.impl/README.md new file mode 100644 index 0000000..2e4a690 --- /dev/null +++ b/TODO.impl/README.md @@ -0,0 +1,25 @@ +# TODO.impl — playground support lane + +Audience-facing scripting is the direction: people want to write a TS +script that uses Interscript directly, the way kotoshu's website +playground runs its engine. The playground CLIENT is another agent's +lane. Status: ALL 5 ITEMS COMPLETE (2026-09-07). This register was OUR side: everything the playground runs on, +delivered so their agent never has to rediscover it. + +Priorities: P1 = unblocks the client agent immediately; P2 = hardens +the guarantee; P3 = completes the surface. All items are independently +shippable PRs against `main` of interscript-ts (one touches the site +repo, one verifies the API). + +| # | Item | Priority | Repo | +|---|------|----------|------| +| 01 | [Examples corpus](01-examples-corpus.md) | P1 | interscript-ts | +| 02 | [CDN delivery recipe](02-cdn-delivery-recipe.md) | P1 | interscript-ts | +| 03 | [Worker-mode guarantee](03-worker-mode-guarantee.md) | P2 | interscript.github.io | +| 04 | [Playground ambient types](04-playground-ambient-types.md) | P2 | interscript-ts | +| 05 | [API server-mode CORS](05-api-server-mode-cors.md) | P3 | verify live; fix in api if broken | + +Standing rules (unchanged from the campaign): every claim measured +before it ships; staged sets verified before every commit; no +attribution trailers; PR bodies via `--body-file`; the register closes +only when every item is COMPLETE — new items require the owner. diff --git a/docs/CDN.md b/docs/CDN.md new file mode 100644 index 0000000..e8416bc --- /dev/null +++ b/docs/CDN.md @@ -0,0 +1,58 @@ +# CDN delivery — using interscript from a browser script + +The no-bundler recipe, verified by browser probe (Playwright/Chromium, +2026-09-07) against `interscript@5.3.0`. + +## The recipe + +```ts +// Maps + interpreter — works as-is: +import { configure, iscStrategy, httpStrategy, transliterateAsync } from + "https://esm.sh/interscript@5.3.0" + +configure({ + strategies: [ + iscStrategy({ baseUrl: "https://interscript.org/maps" }), + httpStrategy({ baseUrl: "https://interscript.org/maps", cacheKeyPrefix: "isx-libs:" }), + ], +}) + +const out = await transliterateAsync("bgnpcgn-ukr-Cyrl-Latn-2019", "Антон Олегович") +// => "Anton Olehovych" +``` + +```ts +// Neural models — the namespace loads; sessions additionally need +// onnxruntime-web, pinned from the same CDN by the host page: +import { imf } from "https://esm.sh/interscript@5.3.0/ml" +``` + +## What was probed + +| URL | Result | +|---|---| +| `esm.sh/interscript@5.3.0` | loads; full export surface; **conversion completes** ("Anton Olehovych") | +| `esm.sh/interscript@5.3.0/ml` | loads; `imf` namespace present; no import-time onnxruntime requirement | + +## Failure modes — none found on the maps path + +kotoshu's engine documents that esm.sh/esm.run transforms broke their +wasm-bindgen delivery (raw jsDelivr files + local instantiation were +the only working path). Our package has no such entanglement on the +maps path: plain ESM with `fflate`/`js-yaml` dependencies, which esm.sh +resolves. No raw-file fallback is needed. + +## Pins and rules + +- **Pin exact versions** (`@5.3.0`, never `@5`): CDN transforms are + cached per URL; a moving pin means a silently changing program. +- Model bytes never come from esm.sh — they resolve through the + models.yaml index (tag-pinned release assets; the browser path goes + through the API's CORS asset front door, see the `/neural` demo). +- `onnxruntime-web` is not a dependency of this package; hosts that + use `interscript/ml` sessions provide it (the website pins it as a + peer). Pair both imports from the same CDN origin to avoid duplicate + wasm loads. +- See `examples/` for the runnable forms of every pattern above, and + `dist/playground.d.ts` for editor autocomplete of the injected + global. diff --git a/examples/_lib/stack.ts b/examples/_lib/stack.ts new file mode 100644 index 0000000..400a631 --- /dev/null +++ b/examples/_lib/stack.ts @@ -0,0 +1,18 @@ +/** + * The browser-shaped map-loading stack every example shares — the same + * strategy order the website runtime uses (ISC form first, compiled + * JSON fallback for the .iml-only libraries). + */ +import { configure, httpStrategy, iscStrategy, reset } from "../../src/index.js" + +export const MAPS_BASE = "https://interscript.org/maps" + +export function configureSharedStack(): void { + reset() + configure({ + strategies: [ + iscStrategy({ baseUrl: MAPS_BASE }), + httpStrategy({ baseUrl: MAPS_BASE, cacheKeyPrefix: "isx-libs:" }), + ], + }) +} diff --git a/examples/batch-columns.ts b/examples/batch-columns.ts new file mode 100644 index 0000000..28dcc4b --- /dev/null +++ b/examples/batch-columns.ts @@ -0,0 +1,23 @@ +/** + * The cataloger pipeline: transliterate a pasted column of names. The + * loader caches each map after first use, so the whole column is one + * map fetch. + */ +import { transliterateAsync } from "../src/index.js" +import { configureSharedStack } from "./_lib/stack.js" + +export const title = "Batch a column of names" +export const summary = "Tab-separated input in, transliterated column out — the MARC batch shape." + +export const expected = ["Anton Olehovych", "Solomiia", "Kyiv"].join("\n") + +const COLUMN = ["Антон Олегович", "Соломія", "Київ"] + +export async function run(): Promise { + configureSharedStack() + const converted = new Array(COLUMN.length) + for (const [i, name] of COLUMN.entries()) { + converted[i] = await transliterateAsync("bgnpcgn-ukr-Cyrl-Latn-2019", name) + } + return converted.join("\n") +} diff --git a/examples/convert.ts b/examples/convert.ts new file mode 100644 index 0000000..132cc66 --- /dev/null +++ b/examples/convert.ts @@ -0,0 +1,15 @@ +/** + * Hello world: configure the map stack, transliterate one string. + */ +import { transliterateAsync } from "../src/index.js" +import { configureSharedStack } from "./_lib/stack.js" + +export const title = "Convert one string" +export const summary = "Configure the map loader, transliterate Ukrainian by BGN/PCGN 2019." + +export const expected = "Anton Olehovych" + +export async function run(): Promise { + configureSharedStack() + return transliterateAsync("bgnpcgn-ukr-Cyrl-Latn-2019", "Антон Олегович") +} diff --git a/examples/detect-chain.ts b/examples/detect-chain.ts new file mode 100644 index 0000000..45a233a --- /dev/null +++ b/examples/detect-chain.ts @@ -0,0 +1,23 @@ +/** + * Composition: detect which loaded system best explains an input/output + * pair, then transliterate through the winner. + */ +import { detect, loadMapAsync, transliterateAsync } from "../src/index.js" +import { configureSharedStack } from "./_lib/stack.js" + +export const title = "Detect the system, then convert" +export const summary = + "Rank the loaded maps by how well they explain a source/target pair, and use the best hit." + +export const expected = "Anton Olehovych" + +const CANDIDATES = ["bgnpcgn-ukr-Cyrl-Latn-2019", "un-tam-Taml-Latn-1972"] as const + +export async function run(): Promise { + configureSharedStack() + for (const system of CANDIDATES) { + await loadMapAsync(system) + } + const [best] = detect("Антон Олегович", "Anton Olehovych") + return transliterateAsync(best.mapName, "Антон Олегович") +} diff --git a/examples/index.ts b/examples/index.ts new file mode 100644 index 0000000..09675d6 --- /dev/null +++ b/examples/index.ts @@ -0,0 +1,30 @@ +/** + * The example registry — data, not behavior. Adding an example is a new + * file plus one line here; the test suite iterates this list. + */ +export interface ExampleEntry { + readonly id: string + readonly file: string + /** Executes only under INTERSCRIPT_PLAYGROUND_LIVE=1 (large downloads). */ + readonly liveOnly?: boolean +} + +export const EXAMPLES: readonly ExampleEntry[] = [ + { id: "convert", file: "./convert.js" }, + { id: "detect-chain", file: "./detect-chain.js" }, + { id: "neural-diacritize", file: "./neural-diacritize.js", liveOnly: true }, + { id: "batch-columns", file: "./batch-columns.js" }, + { id: "server-mode", file: "./server-mode.js" }, +] + +export interface LoadedExample { + readonly title: string + readonly summary: string + readonly expected: string + run(): Promise +} + +export async function loadExample(entry: ExampleEntry): Promise { + const mod = (await import(entry.file)) as LoadedExample + return mod +} diff --git a/examples/neural-diacritize.ts b/examples/neural-diacritize.ts new file mode 100644 index 0000000..906165f --- /dev/null +++ b/examples/neural-diacritize.ts @@ -0,0 +1,38 @@ +/** + * The neural path: resolve a diacritization model through the index and + * decode client-side. Model bytes are large — `run()` executes only when + * INTERSCRIPT_PLAYGROUND_LIVE=1 (the website demo exercises this path + * end to end); CI asserts the recipe's contract instead. + */ +import { imf } from "../src/ml/index.js" + +export const title = "Diacritize with a neural model" +export const summary = + "Resolve ara-diac-small-2.1 through the models.yaml index, load the IMF zip, decode with progress." + +export const MODEL_ID = "ara-diac-small-2.1-int8" +export const INPUT = "كتاب" + +export interface Recipe { + modelId: string + input: string + live: boolean +} + +export function describe(): Recipe { + return { modelId: MODEL_ID, input: INPUT, live: process.env.INTERSCRIPT_PLAYGROUND_LIVE === "1" } +} + +export async function run(): Promise { + const recipe = describe() + if (!recipe.live) { + throw new Error("neural example requires INTERSCRIPT_PLAYGROUND_LIVE=1 (downloads ~264 MB)") + } + const resolved = await imf.resolve(MODEL_ID) + const model = await imf.IMFModel.fromZipBytes(resolved.bytes) + try { + return await model.translate(INPUT, 2048, { onProgress: () => {} }) + } finally { + await model.dispose() + } +} diff --git a/examples/server-mode.ts b/examples/server-mode.ts new file mode 100644 index 0000000..424426c --- /dev/null +++ b/examples/server-mode.ts @@ -0,0 +1,24 @@ +/** + * The no-download path: the public API converts through the same engine + * server-side. CORS is open (access-control-allow-origin: *), so this + * runs from any browser script. + */ +export const title = "Convert without downloading anything" +export const summary = "POST to api.interscript.org — maps and neural models, zero local bytes." + +const API = "https://api.interscript.org/v1" + +export const expected = "Anton Olehovych" + +export async function run(): Promise { + const response = await fetch(`${API}/transliterate`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ system: "bgnpcgn-ukr-Cyrl-Latn-2019", input: "Антон Олегович" }), + }) + if (!response.ok) { + throw new Error(`api ${response.status}: ${await response.text()}`) + } + const body = (await response.json()) as { output: string } + return body.output +} diff --git a/package.json b/package.json index 5122f36..b942e49 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "LICENSE" ], "scripts": { - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && node scripts/copy-playground-dts.mjs", "test": "vitest run", "test:watch": "vitest", "test:parity": "vitest run test/parity", diff --git a/scripts/copy-playground-dts.mjs b/scripts/copy-playground-dts.mjs new file mode 100644 index 0000000..5b6294b --- /dev/null +++ b/scripts/copy-playground-dts.mjs @@ -0,0 +1,5 @@ +// tsc does not copy bare .d.ts inputs to outDir; the playground +// ambient declarations ship in dist by explicit copy. +import { copyFileSync } from "node:fs" + +copyFileSync("src/playground.d.ts", "dist/playground.d.ts") diff --git a/src/playground.d.ts b/src/playground.d.ts new file mode 100644 index 0000000..6c07456 --- /dev/null +++ b/src/playground.d.ts @@ -0,0 +1,15 @@ +/** + * Ambient declarations for playground hosts: the package is loaded + * ahead of user code (CDN import or bundler) and injected as globals. + * Reference this file from the editor's tsconfig `include` (or via a + * triple-slash directive) to get full autocomplete on the injected + * bindings — the types come straight from the published package. + */ + +/** The map layer, as injected by the host. */ +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- a top-level import would turn this ambient file into a module +declare const interscript: typeof import("./index.js") + +/** The neural layer (`import ... from "interscript/ml"`), when injected. */ +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- same: must stay ambient +declare const ml: typeof import("./ml/index.js") diff --git a/test/cdn-urls.test.ts b/test/cdn-urls.test.ts new file mode 100644 index 0000000..4e36e72 --- /dev/null +++ b/test/cdn-urls.test.ts @@ -0,0 +1,24 @@ +/** + * The CDN entry points the docs promise — pinned, loadable, and + * actually JavaScript. Network test, same allowance as the examples + * suite; catches a broken publish before the docs lie. + */ +import { describe, expect, it } from "vitest" + +const VERSION = "5.3.0" +const ENTRIES = [ + `https://esm.sh/interscript@${VERSION}`, + `https://esm.sh/interscript@${VERSION}/ml`, +] + +describe("cdn delivery entries", () => { + for (const url of ENTRIES) { + it(`${url} responds with a module`, { timeout: 30_000 }, async () => { + const response = await fetch(url) + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toContain("javascript") + const body = await response.text() + expect(body).not.toContain("Cannot find") + }) + } +}) diff --git a/test/examples.test.ts b/test/examples.test.ts new file mode 100644 index 0000000..684fc83 --- /dev/null +++ b/test/examples.test.ts @@ -0,0 +1,28 @@ +/** + * The examples are the contract: every registered example runs and + * produces its documented output. This is the playground gallery's + * CI guarantee — an API change that breaks an example fails here first. + */ +import { beforeAll, describe, expect, it } from "vitest" +import { EXAMPLES, loadExample } from "../examples/index.js" + +const live = process.env.INTERSCRIPT_PLAYGROUND_LIVE === "1" + +describe("the example gallery", () => { + beforeAll(() => { + expect(EXAMPLES.length).toBeGreaterThanOrEqual(5) + }) + + for (const entry of EXAMPLES) { + it(`${entry.id}: produces its documented output`, { timeout: 30_000 }, async () => { + const example = await loadExample(entry) + expect(example.title).toBeTruthy() + expect(example.summary).toBeTruthy() + if (entry.liveOnly && !live) { + await expect(example.run()).rejects.toThrow(/INTERSCRIPT_PLAYGROUND_LIVE/) + return + } + await expect(example.run()).resolves.toBe(example.expected) + }) + } +})