diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..d84754c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,28 @@ +# Build output and caches +dist/ +.astro/ +coverage/ + +# Tooling artifacts +playwright-report/ +test-results/ + +# Generated files (regenerated by scripts — don't fight their formatting) +src/data/maps-catalogue.json +package-lock.json +public/maps/ + +# Legacy/reference content predating the Astro migration +_legacy-content/ +docs/ +posts/ +map/ +TODO.complete/ + +# prettier-plugin-astro breaks text↔tag line boundaries inside prose, +# and Astro trims those breaks (rendered spaces vanish — see the +# whitespace-collapse guard in test/site.test.ts). paragraphs with +# text↔tag boundaries (e.g. compare.astro's hero, the map page's +# preview deck) stay hand-wrapped — prettier-ignore corrupts files. +src/pages/compare.astro +src/pages/maps/\[systemCode\].astro diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8910d9f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": false, + "printWidth": 100, + "plugins": ["prettier-plugin-astro"] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..25eeb30 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,47 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +interscript.org v2 — the Interscript website, rebuilt on Astro 7 (branch `astro-migration`). It replaces the legacy react-static site that still lives on the `main` branch — consult `main` (and `TODO.complete/`) as the reference for feature parity. Content claims must be factual: do not fabricate partner/authority/endorsement statements. + +## Commands + +```bash +npm run dev # dev server (localhost:4321; bumps port if taken — e2e uses 4322) +npm run build # static build to dist/ +npm run preview # serve the build + +npm run check # astro check (TypeScript across .astro/.ts/.vue) +npm test # vitest run (unit tests in test/) +npx vitest run test/site.test.ts # single test file +npm run test:e2e # Playwright (e2e/); local runs need `npm run dev` on 4322 +npm run lint # eslint +npm run format # prettier --write . +npm run generate:catalogue # regenerate src/data/maps-catalogue.json from .isc sources +``` + +Dependency note: `interscript-ts` is a `file:` dependency on the sibling checkout `../interscript-ts`, and `npm run generate:catalogue` reads `../maps/maps/*.isc` — both sibling repos must exist locally. + +## Architecture + +- **Rendering**: Astro `output: "static"` + `@astrojs/node` adapter. Pages under `src/pages/` are prerendered by default; the real-time routes (`src/pages/api/{detect,systems,transliterate,transliterate/batch}.ts`) opt out with `export const prerender = false` and run `interscript-ts` server-side. +- **Interactive islands**: Vue 3 (`@astrojs/vue`) for tools — `MapExplorer`, `CompareMode`, `BatchProcessor`, `DiffViewer`, `DetectPanel`, `MarcTool`, `SubtitlesProcessor`, etc. Static pages are plain `.astro` importing these islands. +- **Map data**: ~289 compact `.isc` maps in `public/maps/`. The browsable catalogue `src/data/maps-catalogue.json` is generated by `scripts/generate-catalogue.mjs` (commit the regenerated file when maps change). +- **Map loading strategies** (why two modules): browser runtimes use `src/scripts/map-strategies.ts` — ISC files first, compiled-JSON HTTP fallback only for `.iml` libraries (posix, unicode, var-Cyrl, var-kor) which have no ISC form. The SSR API routes use `src/lib/server-map-strategies.ts` — same order but ISC loads from the filesystem, no HTTP roundtrip. Keep both in sync. +- **Transliteration worker**: `src/scripts/transliteration-worker.ts` runs interscript-ts off the main thread; `worker-client.ts` is the typed RPC client. Vite bundles the worker via `new Worker(new URL(...), { type: "module" })`. +- **Design system**: single source of truth in `src/styles/global.css` — Tailwind 4 CSS-first `@theme` tokens (colors, fonts, type scale, spacing) plus component classes (`.btn`, `.card`, `.prose`, `.eyebrow`). All pages consume these tokens; don't hardcode hex values. `src/layouts/Base.astro` owns the header nav / drawer / footer and carries its scoped styles inline. +- **Content**: AsciiDoc (`src/content/docs/`, `src/content/blog/`) loaded by `src/content/loaders/asciidoc.ts` (manual frontmatter parse + @asciidoctor/core), rendered into `.prose` wrappers. +- **Theme**: light/dark via `data-theme` on ``; tokens flip in `src/styles/global.css`. A service worker (`public/sw.js`) provides offline access. + +## Testing layout + +- `test/*.test.ts` — vitest + happy-dom; covers pages, components, catalogue integrity, API endpoints. +- `e2e/*.spec.ts` — Playwright, Chromium only. In CI the config starts `npm run dev` itself; locally run the dev server on port 4322 first (config `baseURL`). + +## Conventions + +- Conventional Commits (`feat:`, `fix:`, `docs:`, …). All changes go through PRs to `main` (branch `astro-migration` is the active migration branch). +- Lint: `eslint.config.mjs` (ESLint 10 flat config; TS + Astro + Vue). Prettier: `.prettierrc` (no semicolons, 100 cols, astro plugin), `.prettierignore` for generated/legacy paths. +- **Astro whitespace trap:** Astro _trims_ the space at a text↔inline-tag line break — `Backed by\n` renders as `byinterscript-ts`. `prettier-plugin-astro` reflows prose and will create such breaks; `test/site.test.ts` has a guard that fails on them. `prettier-ignore` comments corrupt `.astro` files — instead, hand-wrap the paragraph at text-only boundaries and add the file to `.prettierignore` (see `src/pages/compare.astro`). diff --git a/e2e/compare.spec.ts b/e2e/compare.spec.ts index 45638dc..1cc8bff 100644 --- a/e2e/compare.spec.ts +++ b/e2e/compare.spec.ts @@ -20,7 +20,12 @@ test.describe("compare systems", () => { test("has system selectors", async ({ page }) => { await page.goto("/compare") const selects = page.locator("select") - if (await selects.first().isVisible({ timeout: 3000 }).catch(() => false)) { + if ( + await selects + .first() + .isVisible({ timeout: 3000 }) + .catch(() => false) + ) { const count = await selects.count() expect(count).toBeGreaterThanOrEqual(1) } diff --git a/e2e/demo-edge-cases.spec.ts b/e2e/demo-edge-cases.spec.ts index 0f9cc06..991ca6b 100644 --- a/e2e/demo-edge-cases.spec.ts +++ b/e2e/demo-edge-cases.spec.ts @@ -12,6 +12,9 @@ import { test, expect, type Page } from "@playwright/test" async function ready(page: Page) { await expect(page.locator(".rail-status")).toContainText("ready", { timeout: 20000 }) + // The page default is the first catalogue entry (Chinese); these tests + // assert Cyrillic → Latin, so pin a Cyrillic system. + await page.locator("select.field-input").selectOption("odni-rus-Cyrl-Latn-2015") } test.describe("demo edge cases", () => { diff --git a/e2e/demo.spec.ts b/e2e/demo.spec.ts index 0188d97..f744609 100644 --- a/e2e/demo.spec.ts +++ b/e2e/demo.spec.ts @@ -21,13 +21,14 @@ test.describe("demo page — transliteration explorer", () => { const select = page.locator("select.field-input") await expect(select).toBeVisible() const options = select.locator("option") - await expect(options).toHaveCount(5) + await expect(options).toHaveCount(289) + await expect(select.locator("optgroup").first()).toHaveAttribute("label", "ACADSIN") }) - test("default system is BGN/PCGN Ukrainian", async ({ page }) => { + test("default system is the first catalogue entry", async ({ page }) => { await page.goto("/demo") const select = page.locator("select.field-input") - await expect(select).toHaveValue("bgnpcgn-ukr-Cyrl-Latn-2019") + await expect(select).toHaveValue("acadsin-zho-Hani-Latn-2002") }) test("transliterates Ukrainian Cyrillic to Latin", async ({ page }) => { @@ -37,6 +38,8 @@ test.describe("demo page — transliteration explorer", () => { const status = page.locator(".rail-status") await expect(status).toContainText("ready", { timeout: 20000 }) + await page.locator("select.field-input").selectOption("bgnpcgn-ukr-Cyrl-Latn-2019") + // Type Cyrillic input const textarea = page.locator("textarea.pane-body") await textarea.fill("Антон") @@ -158,6 +161,7 @@ test.describe("demo modes — API vs in-browser", () => { const status = page.locator(".rail-status") await expect(status).toContainText("ready", { timeout: 30000 }) + await page.locator("select.field-input").selectOption("odni-rus-Cyrl-Latn-2015") await page.locator("textarea.pane-body").fill("Антон") await expect(page.locator(".pane-result")).toContainText("Anton", { timeout: 10000 }) }) @@ -182,6 +186,7 @@ test.describe("demo modes — API vs in-browser", () => { // API mode first const status = page.locator(".rail-status") await expect(status).toContainText("ready", { timeout: 30000 }) + await page.locator("select.field-input").selectOption("bgnpcgn-ukr-Cyrl-Latn-2019") await page.locator("textarea.pane-body").fill("Київ") await expect(page.locator(".pane-result")).toContainText("Kyiv", { timeout: 10000 }) const apiOutput = await page.locator(".pane-result").textContent() diff --git a/e2e/maps.spec.ts b/e2e/maps.spec.ts index 48ea2b6..364f052 100644 --- a/e2e/maps.spec.ts +++ b/e2e/maps.spec.ts @@ -22,7 +22,9 @@ test.describe("maps browser", () => { test("has search or filter controls", async ({ page }) => { await page.goto("/maps") // Look for search input, select, or filter buttons - const controls = page.locator("input[type='search'], input[placeholder*='search' i], select, button") + const controls = page.locator( + "input[type='search'], input[placeholder*='search' i], select, button", + ) await expect(controls.first()).toBeVisible({ timeout: 5000 }) }) diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..a54ab2d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,61 @@ +// @ts-check +import js from "@eslint/js" +import tseslint from "typescript-eslint" +import astro from "eslint-plugin-astro" +import vue from "eslint-plugin-vue" +import prettierConfig from "eslint-config-prettier" +import globals from "globals" + +export default tseslint.config( + { + ignores: [ + "dist/**", + ".astro/**", + "coverage/**", + "playwright-report/**", + "test-results/**", + "public/**", + "_legacy-content/**", + "docs/**", + "posts/**", + "map/**", + "TODO.complete/**", + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + ...astro.configs.recommended, + vue.configs["flat/essential"], + prettierConfig, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + }, + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + // The ISC JSON IR and catalogue entries are untyped by design. + "@typescript-eslint/no-explicit-any": "off", + }, + }, + { + files: ["**/*.vue"], + languageOptions: { + parserOptions: { parser: tseslint.parser }, + }, + }, + { + files: ["**/*.astro", "**/*.ts"], + rules: { + // tsc (astro check) owns undefined-variable detection; the + // Astro frontmatter processor trips no-undef on TS globals. + "no-undef": "off", + }, + }, +) diff --git a/package.json b/package.json index 7c82c60..8f241d0 100644 --- a/package.json +++ b/package.json @@ -47,12 +47,15 @@ "eslint": "^10.0.0", "eslint-config-prettier": "^10.0.0", "eslint-plugin-astro": "^1.0.0", + "eslint-plugin-vue": "^10.10.0", + "globals": "^17.11.0", "happy-dom": "^20.11.1", "playwright": "^1.62.1", "prettier": "^3.9.0", "prettier-plugin-astro": "^0.14.0", "tsx": "^4.23.7", "typescript": "^5.6.0", + "typescript-eslint": "^8.67.0", "vitest": "^4.1.10" } } diff --git a/public/img/partners/bas.png b/public/img/partners/bas.png new file mode 100644 index 0000000..0368452 Binary files /dev/null and b/public/img/partners/bas.png differ diff --git a/public/img/partners/bgn.png b/public/img/partners/bgn.png new file mode 100644 index 0000000..3bf4852 Binary files /dev/null and b/public/img/partners/bgn.png differ diff --git a/public/img/partners/calconnect.svg b/public/img/partners/calconnect.svg new file mode 100644 index 0000000..2a84e1d --- /dev/null +++ b/public/img/partners/calconnect.svg @@ -0,0 +1 @@ +CALCONNECT \ No newline at end of file diff --git a/public/img/partners/icao.ico b/public/img/partners/icao.ico new file mode 100644 index 0000000..cee6d33 Binary files /dev/null and b/public/img/partners/icao.ico differ diff --git a/public/img/partners/icao.png b/public/img/partners/icao.png new file mode 100644 index 0000000..fc8bdee --- /dev/null +++ b/public/img/partners/icao.png @@ -0,0 +1 @@ +404 Not Found

Not Found

The requested URL "https://www.icao.int/sites/default/files/ICAO_LOGO.png" was not found on this server.

\ No newline at end of file diff --git a/public/img/partners/ogc.svg b/public/img/partners/ogc.svg new file mode 100644 index 0000000..4a56d2b --- /dev/null +++ b/public/img/partners/ogc.svg @@ -0,0 +1,2 @@ + + diff --git a/public/img/partners/ungegn.png b/public/img/partners/ungegn.png new file mode 100644 index 0000000..a48f41d Binary files /dev/null and b/public/img/partners/ungegn.png differ diff --git a/public/offline.html b/public/offline.html index 1d5b0c9..48e6bff 100644 --- a/public/offline.html +++ b/public/offline.html @@ -50,9 +50,8 @@

You're offline.

- The Interscript page you wanted isn't cached yet. Pages and map - data you've visited before are available — try one of those, or - reconnect to load the page fresh. + The Interscript page you wanted isn't cached yet. Pages and map data you've visited before + are available — try one of those, or reconnect to load the page fresh.

← Back to home diff --git a/public/sw.js b/public/sw.js index 61a7698..2d52c34 100644 --- a/public/sw.js +++ b/public/sw.js @@ -16,12 +16,7 @@ const VERSION = "isx-sw-v1" const CORE_CACHE = `${VERSION}-core` const MAP_CACHE = `${VERSION}-maps` -const CORE_ASSETS = [ - "/", - "/offline.html", - "/symbol.svg", - "/favicon.svg", -] +const CORE_ASSETS = ["/", "/offline.html", "/symbol.svg", "/favicon.svg"] self.addEventListener("install", (event) => { event.waitUntil( @@ -32,13 +27,11 @@ self.addEventListener("install", (event) => { self.addEventListener("activate", (event) => { event.waitUntil( - caches.keys().then((keys) => - Promise.all( - keys - .filter((k) => !k.startsWith(VERSION)) - .map((k) => caches.delete(k)), + caches + .keys() + .then((keys) => + Promise.all(keys.filter((k) => !k.startsWith(VERSION)).map((k) => caches.delete(k))), ), - ), ) self.clients.claim() }) diff --git a/scripts/generate-catalogue.mjs b/scripts/generate-catalogue.mjs index db76730..7ecceae 100644 --- a/scripts/generate-catalogue.mjs +++ b/scripts/generate-catalogue.mjs @@ -42,7 +42,9 @@ function buildEntry(doc) { } function main() { - const files = readdirSync(MAPS_DIR).filter((f) => f.endsWith(".isc")).sort() + const files = readdirSync(MAPS_DIR) + .filter((f) => f.endsWith(".isc")) + .sort() const catalogue = {} let okCount = 0 const errors = [] diff --git a/src/components/BatchProcessor.vue b/src/components/BatchProcessor.vue index 959c088..49ce961 100644 --- a/src/components/BatchProcessor.vue +++ b/src/components/BatchProcessor.vue @@ -70,9 +70,7 @@ const csvOutput = computed(() => { for (const r of results.value) { rows.push([r.input, r.output, r.error ?? ""]) } - return rows - .map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(",")) - .join("\n") + return rows.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(",")).join("\n") }) async function copyCsv() { @@ -93,7 +91,9 @@ onUnmounted(() => client?.terminate()) @@ -118,11 +118,7 @@ onUnmounted(() => client?.terminate()) {{ errorCount }} errors {{ elapsedMs }}ms - +

  1. @@ -175,7 +171,9 @@ onUnmounted(() => client?.terminate()) color: var(--color-ink); outline: none; } -.control-field select:focus { border-color: var(--color-brand); } +.control-field select:focus { + border-color: var(--color-brand); +} .run-btn { font-family: var(--font-mono); @@ -210,13 +208,15 @@ onUnmounted(() => client?.terminate()) } } -.input-pane, .output-pane { +.input-pane, +.output-pane { background: var(--color-vellum); border: 1px solid var(--color-rule); display: flex; flex-direction: column; } -.input-pane header, .output-pane header { +.input-pane header, +.output-pane header { display: flex; align-items: center; gap: 0.75rem; @@ -228,7 +228,9 @@ onUnmounted(() => client?.terminate()) text-transform: uppercase; color: var(--color-stone); } -.pane-label { flex: 1; } +.pane-label { + flex: 1; +} .pane-count { background: var(--color-paper-deep); padding: 0.2rem 0.55rem; @@ -240,9 +242,15 @@ onUnmounted(() => client?.terminate()) gap: 0.625rem; font-size: 0.65rem; } -.pane-stats .ok { color: var(--color-brand-deep); } -.pane-stats .err { color: var(--color-highlight); } -.pane-stats .time { color: var(--color-stone-light); } +.pane-stats .ok { + color: var(--color-brand-deep); +} +.pane-stats .err { + color: var(--color-highlight); +} +.pane-stats .time { + color: var(--color-stone-light); +} .copy-btn { font-family: inherit; font-size: 0.65rem; @@ -294,7 +302,9 @@ textarea { font-family: var(--font-display); font-size: 1rem; } -.result-list li:last-child { border-bottom: none; } +.result-list li:last-child { + border-bottom: none; +} .result-list li.empty { grid-template-columns: 1fr; text-align: center; @@ -307,8 +317,22 @@ textarea { .result-list li.error .row-err { color: var(--color-highlight); } -.row-in { color: var(--color-stone); } -.row-arrow { color: var(--color-highlight); font-family: var(--font-mono); font-size: 0.75rem; } -.row-out { color: var(--color-highlight); font-style: italic; } -.row-err { color: var(--color-highlight); font-family: var(--font-mono); font-size: 0.75rem; font-style: normal; } +.row-in { + color: var(--color-stone); +} +.row-arrow { + color: var(--color-highlight); + font-family: var(--font-mono); + font-size: 0.75rem; +} +.row-out { + color: var(--color-highlight); + font-style: italic; +} +.row-err { + color: var(--color-highlight); + font-family: var(--font-mono); + font-size: 0.75rem; + font-style: normal; +} diff --git a/src/components/CompareMode.vue b/src/components/CompareMode.vue index d15e479..da83932 100644 --- a/src/components/CompareMode.vue +++ b/src/components/CompareMode.vue @@ -37,7 +37,8 @@ const props = defineProps() // Read initial state from URL params so the page is shareable. const urlParams = new URLSearchParams(typeof window !== "undefined" ? window.location.search : "") const initialPreset = urlParams.get("p") ?? props.presets[0]?.id ?? "" -const initialInput = urlParams.get("i") ?? props.presets.find((p) => p.id === initialPreset)?.input ?? "" +const initialInput = + urlParams.get("i") ?? props.presets.find((p) => p.id === initialPreset)?.input ?? "" const presetId = ref(initialPreset) const input = ref(initialInput) @@ -45,8 +46,8 @@ const outputs = ref>({}) const errors = ref>({}) const loading = ref(false) -const currentPreset = computed(() => - props.presets.find((p) => p.id === presetId.value) ?? props.presets[0]!, +const currentPreset = computed( + () => props.presets.find((p) => p.id === presetId.value) ?? props.presets[0]!, ) let client: WorkerClient | null = null @@ -127,7 +128,7 @@ watch([input, presetId], () => {
    { {{ sys.note }}
    -
    +
    ⚠ {{ errors[sys.code] }} Loading… {{ outputs[sys.code] }} @@ -157,9 +164,9 @@ watch([input, presetId], () => {

    - Same input, different romanization systems. Each authority publishes - its own rules — Interscript encodes them as comparable, runnable maps - so you can see the differences at a glance. + Same input, different romanization systems. Each authority publishes its own rules — + Interscript encodes them as comparable, runnable maps so you can see the differences at a + glance.

    diff --git a/src/components/DetectPanel.vue b/src/components/DetectPanel.vue index 21cd545..f1bc346 100644 --- a/src/components/DetectPanel.vue +++ b/src/components/DetectPanel.vue @@ -93,7 +93,9 @@ const initialObserved = urlParams.get("o") ?? initialFamilyObj.sampleOutput const familyId = ref(initialFamily) const input = ref(initialInput) const observed = ref(initialObserved) -const candidates = ref<{ system: CandidateSystem; output: string; distance: number; error?: string }[]>([]) +const candidates = ref< + { system: CandidateSystem; output: string; distance: number; error?: string }[] +>([]) const running = ref(false) let client: WorkerClient | null = null @@ -135,7 +137,12 @@ async function detect() { const distance = levenshtein(output, observed.value) out.push({ system: sys, output, distance }) } catch (e) { - out.push({ system: sys, output: "", distance: Number.MAX_SAFE_INTEGER, error: (e as Error).message }) + out.push({ + system: sys, + output: "", + distance: Number.MAX_SAFE_INTEGER, + error: (e as Error).message, + }) } candidates.value = [...out].sort((a, b) => a.distance - b.distance) }), @@ -163,9 +170,7 @@ function syncUrl() { } const bestMatch = computed(() => candidates.value[0]) -const worstDistance = computed(() => - Math.max(1, ...candidates.value.map((c) => c.distance)), -) +const worstDistance = computed(() => Math.max(1, ...candidates.value.map((c) => c.distance))) onMounted(async () => { await ensureEngine() @@ -226,7 +231,10 @@ onUnmounted(() => client?.terminate())
    -
    +
    {{ c.distance }}
    @@ -235,9 +243,8 @@ onUnmounted(() => client?.terminate())

    - The detector transliterates your source text through every system - in the family, then ranks by Levenshtein distance between each - output and your observed romanization. Distance 0 means the + The detector transliterates your source text through every system in the family, then ranks by + Levenshtein distance between each output and your observed romanization. Distance 0 means the system produced your observed output exactly.

    @@ -273,7 +280,9 @@ onUnmounted(() => client?.terminate()) cursor: pointer; transition: all 0.15s ease; } -.family-pill:hover { border-color: var(--color-brand); } +.family-pill:hover { + border-color: var(--color-brand); +} .family-pill.active { background: var(--color-brand); border-color: var(--color-brand); @@ -287,9 +296,14 @@ onUnmounted(() => client?.terminate()) align-items: end; } @media (min-width: 900px) { - .io-grid { grid-template-columns: 1fr auto 1fr; } + .io-grid { + grid-template-columns: 1fr auto 1fr; + } +} +.io-field { + display: grid; + gap: 0.4rem; } -.io-field { display: grid; gap: 0.4rem; } .io-field label { font-family: var(--font-mono); font-size: var(--text-micro); @@ -307,7 +321,9 @@ onUnmounted(() => client?.terminate()) border-radius: 1px; outline: none; } -.io-field input:focus { border-color: var(--color-brand); } +.io-field input:focus { + border-color: var(--color-brand); +} .io-arrow { color: var(--color-highlight); font-family: var(--font-mono); @@ -339,7 +355,10 @@ onUnmounted(() => client?.terminate()) background: var(--color-highlight-deep); border-color: var(--color-highlight-deep); } -.detect-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.detect-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} .verdict { margin: 0; font-size: 0.95rem; @@ -384,7 +403,11 @@ onUnmounted(() => client?.terminate()) padding-inline: 0.5rem; margin-inline: -0.5rem; } -.rank-meta { display: flex; flex-direction: column; gap: 0.15rem; } +.rank-meta { + display: flex; + flex-direction: column; + gap: 0.15rem; +} .rank-auth { font-family: var(--font-mono); font-size: 0.75rem; @@ -435,7 +458,9 @@ onUnmounted(() => client?.terminate()) font-size: 0.85rem; text-align: center; } -.rank-link:hover { color: var(--color-highlight); } +.rank-link:hover { + color: var(--color-highlight); +} .detect-deck { font-size: 0.9375rem; diff --git a/src/components/DiffViewer.vue b/src/components/DiffViewer.vue index 379f1db..76c299e 100644 --- a/src/components/DiffViewer.vue +++ b/src/components/DiffViewer.vue @@ -6,7 +6,7 @@ * Pulls the first ~30 rules from each system's IR and shows them in * parallel columns. */ -import { ref, computed, onMounted, onUnmounted, watch } from "vue" +import { ref, onMounted, onUnmounted, watch } from "vue" import { createWorkerClient, type WorkerClient } from "../scripts/worker-client" interface System { @@ -83,7 +83,7 @@ function stringify(item: Record | null): string { case "alias": return `:${item.name}` case "any": - return `any(${(((item.of as unknown[]) ?? []).length).toString()})` + return `any(${((item.of as unknown[]) ?? []).length.toString()})` case "any_char_class": if (item.range) return `[${item.range[0]}-${item.range[1]}]` if (item.chars) return `[${(item.chars as string[]).slice(0, 5).join("")}…]` @@ -120,8 +120,6 @@ async function load() { loading.value = false } -const maxRows = computed(() => Math.max(leftRules.value.length, rightRules.value.length)) - onMounted(async () => { await ensureEngine() await load() @@ -129,7 +127,9 @@ onMounted(async () => { onUnmounted(() => client?.terminate()) -watch([left, right], () => { void load() }) +watch([left, right], () => { + void load() +}) diff --git a/src/components/RuleViewer.vue b/src/components/RuleViewer.vue index d144063..a513c31 100644 --- a/src/components/RuleViewer.vue +++ b/src/components/RuleViewer.vue @@ -1,127 +1,301 @@ diff --git a/src/components/ScriptMosaic.vue b/src/components/ScriptMosaic.vue index 80e1c23..f5cfd4a 100644 --- a/src/components/ScriptMosaic.vue +++ b/src/components/ScriptMosaic.vue @@ -43,18 +43,48 @@ const cells: Cell[] = [ id: "cyrillic", script: "Cyrillic", transforms: [ - { system: "bgnpcgn-ukr-Cyrl-Latn-2019", input: "Антон", authority: "BGN/PCGN", note: "Ukrainian · 2019" }, - { system: "odni-rus-Cyrl-Latn-2015", input: "Калинина", authority: "ODNI", note: "Russian · 2015" }, - { system: "icao-ukr-Cyrl-Latn-9303", input: "Київ", authority: "ICAO", note: "Travel docs · 9303" }, + { + system: "bgnpcgn-ukr-Cyrl-Latn-2019", + input: "Антон", + authority: "BGN/PCGN", + note: "Ukrainian · 2019", + }, + { + system: "odni-rus-Cyrl-Latn-2015", + input: "Калинина", + authority: "ODNI", + note: "Russian · 2015", + }, + { + system: "icao-ukr-Cyrl-Latn-9303", + input: "Київ", + authority: "ICAO", + note: "Travel docs · 9303", + }, ], }, { id: "arabic", script: "Arabic", transforms: [ - { system: "bgnpcgn-ara-Arab-Latn-1956", input: "عَبد الله", authority: "BGN/PCGN", note: "Arabic · 1956" }, - { system: "iso-ara-Arab-Latn-233-1984", input: "القاهرة", authority: "ISO", note: "Arabic · 233" }, - { system: "alalc-ara-Arab-Latn-1997", input: "بَغداد", authority: "ALA-LC", note: "Arabic · 1997" }, + { + system: "bgnpcgn-ara-Arab-Latn-1956", + input: "عَبد الله", + authority: "BGN/PCGN", + note: "Arabic · 1956", + }, + { + system: "iso-ara-Arab-Latn-233-1984", + input: "القاهرة", + authority: "ISO", + note: "Arabic · 233", + }, + { + system: "alalc-ara-Arab-Latn-1997", + input: "بَغداد", + authority: "ALA-LC", + note: "Arabic · 1997", + }, ], }, { @@ -62,16 +92,36 @@ const cells: Cell[] = [ script: "Devanagari", transforms: [ { system: "un-hin-Deva-Latn-2016", input: "महात्मा", authority: "UN", note: "Hindi · 2016" }, - { system: "alalc-hin-Deva-Latn-2011", input: "मुंबई", authority: "ALA-LC", note: "Hindi · 2011" }, - { system: "iso-hin-Deva-Latn-15919-2001", input: "फिलिपींस", authority: "ISO", note: "ISO 15919" }, + { + system: "alalc-hin-Deva-Latn-2011", + input: "मुंबई", + authority: "ALA-LC", + note: "Hindi · 2011", + }, + { + system: "iso-hin-Deva-Latn-15919-2001", + input: "फिलिपींस", + authority: "ISO", + note: "ISO 15919", + }, ], }, { id: "han", script: "Han", transforms: [ - { system: "acadsin-zho-Hani-Latn-2002", input: "台北", authority: "Academia Sinica", note: "Tongyong · 2002" }, - { system: "bgnpcgn-zho-Hans-Latn-1979", input: "北京", authority: "BGN/PCGN", note: "Hanyu Pinyin · 1979" }, + { + system: "acadsin-zho-Hani-Latn-2002", + input: "台北", + authority: "Academia Sinica", + note: "Tongyong · 2002", + }, + { + system: "bgnpcgn-zho-Hans-Latn-1979", + input: "北京", + authority: "BGN/PCGN", + note: "Hanyu Pinyin · 1979", + }, { system: "sac-zho-Hans-Latn-1979", input: "香港", authority: "SAC", note: "Hans · 1979" }, ], }, @@ -79,17 +129,42 @@ const cells: Cell[] = [ id: "ethiopic", script: "Ethiopic", transforms: [ - { system: "alalc-amh-Ethi-Latn-2011", input: "ኢትዮጵያ", authority: "ALA-LC", note: "Amharic · 2011" }, - { system: "bgnpcgn-amh-Ethi-Latn-1967", input: "አዲስ አበባ", authority: "BGN/PCGN", note: "Amharic · 1967" }, + { + system: "alalc-amh-Ethi-Latn-2011", + input: "ኢትዮጵያ", + authority: "ALA-LC", + note: "Amharic · 2011", + }, + { + system: "bgnpcgn-amh-Ethi-Latn-1967", + input: "አዲስ አበባ", + authority: "BGN/PCGN", + note: "Amharic · 1967", + }, ], }, { id: "greek", script: "Greek", transforms: [ - { system: "iso-ell-Grek-Latn-843-1997-t1", input: "Αθήνα", authority: "ISO", note: "Greek · 843/1997" }, - { system: "alalc-ell-Grek-Latn-1997", input: "Θεσσαλονίκη", authority: "ALA-LC", note: "Greek · 1997" }, - { system: "bgnpcgn-ell-Grek-Latn-1962", input: "Ελλάδα", authority: "BGN/PCGN", note: "Greek · 1962" }, + { + system: "iso-ell-Grek-Latn-843-1997-t1", + input: "Αθήνα", + authority: "ISO", + note: "Greek · 843/1997", + }, + { + system: "alalc-ell-Grek-Latn-1997", + input: "Θεσσαλονίκη", + authority: "ALA-LC", + note: "Greek · 1997", + }, + { + system: "bgnpcgn-ell-Grek-Latn-1962", + input: "Ελλάδα", + authority: "BGN/PCGN", + note: "Greek · 1962", + }, ], }, ] @@ -158,7 +233,7 @@ function tickCell(i: number) { const tf = cell.transforms[next]! try { outputs.value[i] = transliterateFn(tf.system, tf.input) - } catch (e) { + } catch { outputs.value[i] = `(error)` } } @@ -194,16 +269,11 @@ onUnmounted(() => { Warming up the engine… Engine unavailable - {{ ready ? 'Live transliteration' : '' }} + {{ ready ? "Live transliteration" : "" }}
    -
    +
    {{ cell.script }} {{ indices[i]! + 1 }}/{{ cell.transforms.length }} @@ -262,8 +332,13 @@ onUnmounted(() => { animation: pulse 2.4s ease-in-out infinite; } @keyframes pulse { - 0%, 100% { opacity: 0.7; } - 50% { opacity: 1; } + 0%, + 100% { + opacity: 0.7; + } + 50% { + opacity: 1; + } } .mosaic-grid { @@ -329,7 +404,6 @@ onUnmounted(() => { line-height: 1.15; letter-spacing: -0.01em; margin: 0; - font-variation-settings: "SOFT" 100, "WONK" 0; word-break: break-word; } @@ -365,14 +439,15 @@ onUnmounted(() => { letter-spacing: -0.015em; font-style: italic; margin: 0; - font-variation-settings: "SOFT" 100, "WONK" 1; word-break: break-word; } /* Transition */ .cellmorph-enter-active, .cellmorph-leave-active { - transition: opacity 0.4s ease, transform 0.4s ease; + transition: + opacity 0.4s ease, + transform 0.4s ease; } .cellmorph-enter-from { opacity: 0; @@ -384,8 +459,14 @@ onUnmounted(() => { } @media (prefers-reduced-motion: reduce) { - .status-dot.live { animation: none; opacity: 1; } - .cellmorph-enter-active, .cellmorph-leave-active { transition: none; } + .status-dot.live { + animation: none; + opacity: 1; + } + .cellmorph-enter-active, + .cellmorph-leave-active { + transition: none; + } } .mosaic.failed { diff --git a/src/data/iso.ts b/src/data/iso.ts index 5ca3382..905170e 100644 --- a/src/data/iso.ts +++ b/src/data/iso.ts @@ -21,9 +21,10 @@ for (const part of ["639-1", "639-2", "639-3", "639-5"] as const) { const scriptIndex = new Map() for (const [code, entry] of Object.entries(scriptCodes)) { - const name = (entry as { name?: { en?: string }; pva?: string }).name?.en - ?? (entry as { pva?: string }).pva - ?? code + const name = + (entry as { name?: { en?: string }; pva?: string }).name?.en ?? + (entry as { pva?: string }).pva ?? + code scriptIndex.set(code, { name, number: (entry as { number: string | number }).number }) } diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 5a78373..2da00b9 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -1,7 +1,7 @@ --- import "../styles/global.css" -import "@fontsource-variable/fraunces" import "@fontsource-variable/inter-tight" +import "@fontsource-variable/inter-tight/wght-italic.css" import "@fontsource/jetbrains-mono/400.css" import "@fontsource/jetbrains-mono/500.css" @@ -27,7 +27,7 @@ interface NavItem { } const navItems: NavItem[] = [ - { href: "/demo", label: "Demo" }, + { href: "/demo", label: "Transliterate" }, { label: "Tools", children: [ @@ -66,7 +66,7 @@ const drawerGroups: Array<{ label: string; links: NavLink[] }> = [ { label: "Try it", links: [ - { href: "/demo", label: "Live demo" }, + { href: "/demo", label: "Live transliteration" }, { href: "/compare", label: "Compare" }, { href: "/batch", label: "Batch" }, { href: "/detect", label: "Detect" }, @@ -106,8 +106,7 @@ const drawerGroups: Array<{ label: string; links: NavLink[] }> = [ const currentYear = new Date().getFullYear() const currentPath = Astro.url.pathname -const isCurrent = (href: string) => - currentPath === href || currentPath.startsWith(href + "/") +const isCurrent = (href: string) => currentPath === href || currentPath.startsWith(href + "/") const itemIsCurrent = (item: NavItem) => (item.href !== undefined && isCurrent(item.href)) || @@ -140,46 +139,60 @@ const slug = (label: string) => label.toLowerCase().replace(/\s+/g, "-")
    -
  2. + + + + + + ), ) - )} + }
    @@ -212,24 +225,26 @@ const slug = (label: string) => label.toLowerCase().replace(/\s+/g, "-")
    @@ -256,7 +271,7 @@ const slug = (label: string) => label.toLowerCase().replace(/\s+/g, "-") @@ -296,7 +328,7 @@ const slug = (label: string) => label.toLowerCase().replace(/\s+/g, "-")