Compare commits

1 Commits

Author SHA1 Message Date
João Pedro Battistella Nadas
53796f3b03 feat: vendor all browser deps into the image (no runtime CDN)
esm.sh's degraded storage backend served an unpinned transitive
@codemirror/view@^6.27.0 range URL as text/plain, which browsers block for
a disallowed MIME type. A failed top-level module import aborts the whole
<script type=module>, so the language/model dropdowns came up empty despite
a healthy backend — and top-level version pinning can't fix a transitive
range.

Add a frontend/ esbuild stage (Node stage in the Dockerfile) that vendors
everything into static/vendor and serve only from there:
- CodeMirror (view/state/commands) bundled into one self-contained ESM file
- cropperjs (+ CSS) copied from npm dist
- Tesseract.js engine + wasm core + Dutch (nld) language pack; OCR assets
  stay lazy (fetched only when a scan runs) and fully local
- Drop vim mode (@replit/codemirror-vim); keep document-level Shift-H/L

Google Fonts remains the only external <link> (degrades gracefully, not in
the failing path). Bump to 0.5.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:59:27 +02:00
10 changed files with 859 additions and 41 deletions

View File

@@ -8,3 +8,7 @@ __pycache__
.mypy_cache .mypy_cache
README.md README.md
build.sh build.sh
# Rebuilt fresh in the frontend stage; never copy a local build into the image.
frontend/node_modules
static/vendor

4
.gitignore vendored
View File

@@ -7,3 +7,7 @@ __pycache__/
.mypy_cache/ .mypy_cache/
.idea/ .idea/
.vscode/ .vscode/
# Frontend vendor bundle is produced at Docker build time (frontend/build.mjs).
node_modules/
static/vendor/

View File

@@ -7,6 +7,17 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
## [0.5.0] - 2026-07-05
### Changed
- **All browser dependencies are now vendored into the image — nothing loads from a CDN at runtime.** A new `frontend/` esbuild step (run in a Node stage of the Dockerfile) bundles CodeMirror into a single self-contained `static/vendor/codemirror.js` and copies cropperjs + Tesseract.js (engine, wasm core, and the Dutch `nld` language pack) into `static/vendor/`. The page imports only from `/static/vendor/…`. Google Fonts (Material Symbols, Cinzel) is the one remaining external `<link>`; it degrades gracefully and isn't in the failing path.
### Fixed
- Empty language and model dropdowns whenever esm.sh was degraded. The editor's top-level `import`s pulled `@codemirror/*` from esm.sh, whose transitive graph contained an **unpinned** `@codemirror/view@^6.27.0` range URL. During esm.sh's storage outage that range returned an error body as `text/plain`, so the browser blocked the module for a disallowed MIME type — and a single failed top-level import aborts the whole `<script type="module">`, so `fillLangs()`/`loadModels()` never ran despite a healthy backend. Vendoring removes every runtime CDN import, eliminating the failure mode entirely.
### Removed
- Vim editing mode (`@replit/codemirror-vim`) and its `Vim.mapCommand` H/L bindings. The document-level Shift-H/L focus cycling across the dropdowns/buttons is retained. One fewer dependency to vendor.
## [0.3.0] - 2026-06-09 ## [0.3.0] - 2026-06-09
### Added ### Added

View File

@@ -1,5 +1,17 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# Frontend stage: vendor all browser dependencies (CodeMirror, cropperjs,
# Tesseract.js + wasm core + the Dutch language pack) into static/vendor so the
# running container serves everything locally — no esm.sh / CDN at runtime.
# Pinned to the build platform: the output is architecture-neutral browser
# assets, so there's no need to re-run it under emulation for each target arch.
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN node build.mjs
# Build stage: install deps into a venv with uv. # Build stage: install deps into a venv with uv.
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
@@ -22,6 +34,7 @@ WORKDIR /app
COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/.venv /app/.venv
COPY app.py ./ COPY app.py ./
COPY static ./static COPY static ./static
COPY --from=frontend /app/static/vendor ./static/vendor
EXPOSE 8000 EXPOSE 8000

77
frontend/build.mjs Normal file
View File

@@ -0,0 +1,77 @@
// Vendors finrod's browser dependencies into ../static/vendor/ so the running
// container serves everything locally — no esm.sh / CDN dependency at runtime.
//
// node build.mjs
//
// Outputs:
// static/vendor/codemirror.js — esbuild bundle (view+state+commands)
// static/vendor/cropper.esm.js — cropperjs ESM (dependency-free)
// static/vendor/cropper.min.css — cropperjs stylesheet
// static/vendor/tesseract/tesseract.esm.min.js — Tesseract.js main thread
// static/vendor/tesseract/worker.min.js — Tesseract.js worker
// static/vendor/tesseract/tesseract-core*.{js,wasm} — wasm OCR core
// static/vendor/tesseract/lang/<lang>.traineddata.gz — default language packs
import * as esbuild from "esbuild";
import { createRequire } from "node:module";
import { cp, mkdir, rm, readdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
const OUT = join(here, "..", "static", "vendor");
// Only Dutch is baked in — finrod's real use is OCR'ing Dutch source text.
// Any other OCR language falls back to on-demand download from Tesseract's
// default CDN. These packs are served locally but only fetched by the browser
// when OCR is actually used (the OCR code path dynamic-imports Tesseract).
const LANGS = ["nld"];
const TESSDATA_BASE = "https://tessdata.projectnaptha.com/4.0.0";
const pkgDir = (name) => dirname(require.resolve(`${name}/package.json`));
await rm(OUT, { recursive: true, force: true });
await mkdir(join(OUT, "tesseract", "lang"), { recursive: true });
// 1. CodeMirror — bundle the whole graph into one ESM file.
await esbuild.build({
entryPoints: [join(here, "src", "codemirror.js")],
bundle: true,
format: "esm",
minify: true,
legalComments: "none",
outfile: join(OUT, "codemirror.js"),
});
console.log("✓ codemirror.js");
// 2. cropperjs — ESM build + CSS, both dependency-free, just copy.
const cropper = pkgDir("cropperjs");
await cp(join(cropper, "dist", "cropper.esm.js"), join(OUT, "cropper.esm.js"));
await cp(join(cropper, "dist", "cropper.min.css"), join(OUT, "cropper.min.css"));
console.log("✓ cropperjs");
// 3. Tesseract.js — ship the prebuilt worker + wasm core (do NOT bundle; it
// spawns a Web Worker and streams wasm, which expects real dist files).
const tess = pkgDir("tesseract.js");
await cp(join(tess, "dist", "tesseract.esm.min.js"), join(OUT, "tesseract", "tesseract.esm.min.js"));
await cp(join(tess, "dist", "worker.min.js"), join(OUT, "tesseract", "worker.min.js"));
const core = pkgDir("tesseract.js-core");
for (const f of await readdir(core)) {
if (f.endsWith(".js") || f.endsWith(".wasm")) {
await cp(join(core, f), join(OUT, "tesseract", f));
}
}
console.log("✓ tesseract engine + core");
// Language packs — fetched at build time so the runtime image is self-contained.
for (const lang of LANGS) {
const url = `${TESSDATA_BASE}/${lang}.traineddata.gz`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
await writeFile(join(OUT, "tesseract", "lang", `${lang}.traineddata.gz`), buf);
console.log(`✓ tessdata ${lang} (${(buf.length / 1e6).toFixed(1)} MB)`);
}
console.log("vendor build complete →", OUT);

702
frontend/package-lock.json generated Normal file
View File

@@ -0,0 +1,702 @@
{
"name": "finrod-frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "finrod-frontend",
"dependencies": {
"@codemirror/commands": "6.7.1",
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.36.1",
"cropperjs": "1.6.2",
"tesseract.js": "5.1.1",
"tesseract.js-core": "5.1.1"
},
"devDependencies": {
"esbuild": "0.24.2"
}
},
"node_modules/@codemirror/commands": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.7.1.tgz",
"integrity": "sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.36.1",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.1.tgz",
"integrity": "sha512-miD1nyT4m4uopZaDdO2uXU/LLHliKNYL9kB1C1wJHrunHLm/rpkb5QVSokqgw9hFqEZakrdlb/VGWX8aYZTslQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/cropperjs": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/idb-keyval": {
"version": "6.2.6",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.6.tgz",
"integrity": "sha512-FY64UEhw+5liMzMQ1R9Mw6AF0+wyBrg1CIA1z4CjI/EvT5ty/SvQcWZgd8s9sgaNhX10Y8UzScTh89tEAls5nA==",
"license": "Apache-2.0"
},
"node_modules/is-electron": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz",
"integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==",
"license": "MIT"
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/tesseract.js": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-5.1.1.tgz",
"integrity": "sha512-lzVl/Ar3P3zhpUT31NjqeCo1f+D5+YfpZ5J62eo2S14QNVOmHBTtbchHm/YAbOOOzCegFnKf4B3Qih9LuldcYQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-electron": "^2.2.2",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^5.1.1",
"wasm-feature-detect": "^1.2.11",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-5.1.1.tgz",
"integrity": "sha512-KX3bYSU5iGcO1XJa+QGPbi+Zjo2qq6eBhNjSGR5E5q0JtzkoipJKOUQD7ph8kFyteCEfEQ0maWLu8MCXtvX5uQ==",
"license": "Apache-2.0"
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
}
}
}

20
frontend/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "finrod-frontend",
"private": true,
"type": "module",
"description": "Vendors finrod's browser dependencies into static/vendor so nothing loads from a CDN at runtime.",
"scripts": {
"build": "node build.mjs"
},
"dependencies": {
"@codemirror/commands": "6.7.1",
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.36.1",
"cropperjs": "1.6.2",
"tesseract.js": "5.1.1",
"tesseract.js-core": "5.1.1"
},
"devDependencies": {
"esbuild": "0.24.2"
}
}

View File

@@ -0,0 +1,6 @@
// Single entry point re-exporting every CodeMirror symbol index.html needs.
// esbuild bundles this (and the whole transitive @codemirror/@lezer graph)
// into one self-contained ESM file — no runtime CDN, no semver-range URLs.
export { EditorView, keymap, placeholder } from "@codemirror/view";
export { EditorState } from "@codemirror/state";
export { history, defaultKeymap, historyKeymap } from "@codemirror/commands";

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "finrod" name = "finrod"
version = "0.3.0" version = "0.5.0"
description = "Translator UI backed by Ollama" description = "Translator UI backed by Ollama"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -474,13 +474,6 @@
} }
.cm-scroller { padding: 0.6rem 0.75rem; } .cm-scroller { padding: 0.6rem 0.75rem; }
.cm-content { caret-color: var(--accent); } .cm-content { caret-color: var(--accent); }
/* vim's command-line / status hint inherits readable colors */
.cm-vim-panel {
background: var(--panel);
color: var(--fg);
border-top: 1px solid var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
</style> </style>
</head> </head>
@@ -550,10 +543,11 @@
</button> </button>
<script type="module"> <script type="module">
import { EditorView, keymap, placeholder } from "https://esm.sh/@codemirror/view@6"; import {
import { EditorState } from "https://esm.sh/@codemirror/state@6"; EditorView, keymap, placeholder,
import { history, defaultKeymap, historyKeymap } from "https://esm.sh/@codemirror/commands@6"; EditorState,
import { vim, Vim } from "https://esm.sh/@replit/codemirror-vim@6"; history, defaultKeymap, historyKeymap,
} from "/static/vendor/codemirror.js";
const LANGUAGES = [ const LANGUAGES = [
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish", "English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
@@ -576,7 +570,7 @@
const scanInput = document.getElementById("scan-input"); const scanInput = document.getElementById("scan-input");
const scanStatus = document.getElementById("scan-status"); const scanStatus = document.getElementById("scan-status");
// --- OCR via Tesseract.js (lazy-loaded on first use from esm.sh) -------- // --- OCR via Tesseract.js (vendored; lazy-loaded on first use) ----------
// Tesseract language codes are ISO 639-2 — map from our "From" dropdown. // Tesseract language codes are ISO 639-2 — map from our "From" dropdown.
const TESS_LANGS = { const TESS_LANGS = {
"English": "eng", "English": "eng",
@@ -618,7 +612,7 @@
scanStatus.classList.toggle("error", error); scanStatus.classList.toggle("error", error);
} }
// Crop + rotate modal. Lazy-loads cropperjs + its CSS from esm.sh on // Crop + rotate modal. Lazy-loads the vendored cropperjs + its CSS on
// first use. Resolves with a Blob (cropped, rotation baked in) or null // first use. Resolves with a Blob (cropped, rotation baked in) or null
// if the user cancelled. // if the user cancelled.
let cropperCssLoaded = false; let cropperCssLoaded = false;
@@ -626,11 +620,11 @@
if (!cropperCssLoaded) { if (!cropperCssLoaded) {
const link = document.createElement("link"); const link = document.createElement("link");
link.rel = "stylesheet"; link.rel = "stylesheet";
link.href = "https://esm.sh/cropperjs@1/dist/cropper.min.css"; link.href = "/static/vendor/cropper.min.css";
document.head.appendChild(link); document.head.appendChild(link);
cropperCssLoaded = true; cropperCssLoaded = true;
} }
const { default: Cropper } = await import("https://esm.sh/cropperjs@1"); const { default: Cropper } = await import("/static/vendor/cropper.esm.js");
return new Promise((resolve) => { return new Promise((resolve) => {
const backdrop = document.createElement("div"); const backdrop = document.createElement("div");
@@ -850,10 +844,15 @@
setScanStatus(`Preprocessed ${Math.max(w, h)}px (binarised)`); setScanStatus(`Preprocessed ${Math.max(w, h)}px (binarised)`);
} }
// Lazy import — first scan triggers the ~5 MB JS + language pack // Lazy import — the vendored engine, wasm core, and language pack are
// download; cached by the browser after that. // only fetched from the container on first scan, then browser-cached.
const { createWorker } = await import("https://esm.sh/tesseract.js@5"); // Everything is served locally (workerPath/corePath/langPath) so OCR
// never depends on a CDN. Only the Dutch pack (nld) is bundled.
const { createWorker } = await import("/static/vendor/tesseract/tesseract.esm.min.js");
const worker = await createWorker(lang, 1, { const worker = await createWorker(lang, 1, {
workerPath: "/static/vendor/tesseract/worker.min.js",
corePath: "/static/vendor/tesseract",
langPath: "/static/vendor/tesseract/lang",
logger: (m) => { logger: (m) => {
if (m.status === "recognizing text") { if (m.status === "recognizing text") {
setScanStatus(`OCR ${Math.round(m.progress * 100)}%`); setScanStatus(`OCR ${Math.round(m.progress * 100)}%`);
@@ -975,18 +974,13 @@
if (file) runOCR(file); if (file) runOCR(file);
}); });
// --- CodeMirror editor (vim mode on real keyboards only) ---------------- // --- CodeMirror editor --------------------------------------------------
const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches; const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches;
let typingTimer; let typingTimer;
const editor = new EditorView({ const editor = new EditorView({
parent: document.getElementById("input"), parent: document.getElementById("input"),
state: EditorState.create({ state: EditorState.create({
extensions: [ extensions: [
// vim() MUST be first per @replit/codemirror-vim docs so its keymap
// takes precedence. Skipped on touch devices — modal editing without
// a hardware Esc key on a virtual keyboard is unusable, and IME
// composition fights the vim keymap.
...(isCoarsePointer ? [] : [vim()]),
history(), history(),
keymap.of([ keymap.of([
{ key: "Mod-Enter", run: () => { translate(); return true; } }, { key: "Mod-Enter", run: () => { translate(); return true; } },
@@ -1214,10 +1208,8 @@
goBtn.addEventListener("click", translate); goBtn.addEventListener("click", translate);
// Shift-H / Shift-L cycle focus across controls (vim-style Shift-Tab / // Shift-H / Shift-L cycle focus across controls (vim-style Shift-Tab /
// Tab). Two paths kept in sync: // Tab), for focus on a select / button. Inside the editor they type
// 1. Document keydown listener — fires when focus is on a select / button // normally.
// 2. Vim normal-mode mapping (below) — fires when focus is in the editor
// Together they make Shift-H/L work everywhere, including escaping the editor.
const focusables = [ const focusables = [
{ el: sourceSel, focus: () => sourceSel.focus() }, { el: sourceSel, focus: () => sourceSel.focus() },
{ el: targetSel, focus: () => targetSel.focus() }, { el: targetSel, focus: () => targetSel.focus() },
@@ -1239,23 +1231,12 @@
if (!e.shiftKey) return; if (!e.shiftKey) return;
const k = e.key.toLowerCase(); const k = e.key.toLowerCase();
if (k !== "h" && k !== "l") return; if (k !== "h" && k !== "l") return;
// Inside the editor, vim's mapCommand handles H/L (see below). Letting // Inside the editor, let H/L type normally.
// the event bubble here would double-jump.
if (editor.dom.contains(document.activeElement)) return; if (editor.dom.contains(document.activeElement)) return;
e.preventDefault(); e.preventDefault();
moveFocus(k === "l" ? 1 : -1); moveFocus(k === "l" ? 1 : -1);
}); });
// Override vim's H/L (normally "viewport top/bottom") so they exit the
// editor to the previous/next control, matching the Shift-H/L contract
// the document handler uses elsewhere.
if (!isCoarsePointer) {
Vim.defineAction("finrodFocusPrev", () => moveFocus(-1));
Vim.defineAction("finrodFocusNext", () => moveFocus(1));
Vim.mapCommand("H", "action", "finrodFocusPrev", {}, { context: "normal" });
Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" });
}
// Side drawer (Settings) wiring — opens from the top-left hamburger. // Side drawer (Settings) wiring — opens from the top-left hamburger.
const drawerEl = document.getElementById("drawer"); const drawerEl = document.getElementById("drawer");
const drawerBackdrop = document.getElementById("drawer-backdrop"); const drawerBackdrop = document.getElementById("drawer-backdrop");