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>
This commit is contained in:
João Pedro Battistella Nadas
2026-07-05 11:59:27 +02:00
parent fd8f0b74aa
commit 53796f3b03
10 changed files with 859 additions and 41 deletions

View File

@@ -474,13 +474,6 @@
}
.cm-scroller { padding: 0.6rem 0.75rem; }
.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>
</head>
@@ -550,10 +543,11 @@
</button>
<script type="module">
import { EditorView, keymap, placeholder } from "https://esm.sh/@codemirror/view@6";
import { EditorState } from "https://esm.sh/@codemirror/state@6";
import { history, defaultKeymap, historyKeymap } from "https://esm.sh/@codemirror/commands@6";
import { vim, Vim } from "https://esm.sh/@replit/codemirror-vim@6";
import {
EditorView, keymap, placeholder,
EditorState,
history, defaultKeymap, historyKeymap,
} from "/static/vendor/codemirror.js";
const LANGUAGES = [
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
@@ -576,7 +570,7 @@
const scanInput = document.getElementById("scan-input");
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.
const TESS_LANGS = {
"English": "eng",
@@ -618,7 +612,7 @@
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
// if the user cancelled.
let cropperCssLoaded = false;
@@ -626,11 +620,11 @@
if (!cropperCssLoaded) {
const link = document.createElement("link");
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);
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) => {
const backdrop = document.createElement("div");
@@ -850,10 +844,15 @@
setScanStatus(`Preprocessed ${Math.max(w, h)}px (binarised)`);
}
// Lazy import — first scan triggers the ~5 MB JS + language pack
// download; cached by the browser after that.
const { createWorker } = await import("https://esm.sh/tesseract.js@5");
// Lazy import — the vendored engine, wasm core, and language pack are
// only fetched from the container on first scan, then browser-cached.
// 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, {
workerPath: "/static/vendor/tesseract/worker.min.js",
corePath: "/static/vendor/tesseract",
langPath: "/static/vendor/tesseract/lang",
logger: (m) => {
if (m.status === "recognizing text") {
setScanStatus(`OCR ${Math.round(m.progress * 100)}%`);
@@ -975,18 +974,13 @@
if (file) runOCR(file);
});
// --- CodeMirror editor (vim mode on real keyboards only) ----------------
// --- CodeMirror editor --------------------------------------------------
const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches;
let typingTimer;
const editor = new EditorView({
parent: document.getElementById("input"),
state: EditorState.create({
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(),
keymap.of([
{ key: "Mod-Enter", run: () => { translate(); return true; } },
@@ -1214,10 +1208,8 @@
goBtn.addEventListener("click", translate);
// Shift-H / Shift-L cycle focus across controls (vim-style Shift-Tab /
// Tab). Two paths kept in sync:
// 1. Document keydown listener — fires when focus is on a select / button
// 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.
// Tab), for focus on a select / button. Inside the editor they type
// normally.
const focusables = [
{ el: sourceSel, focus: () => sourceSel.focus() },
{ el: targetSel, focus: () => targetSel.focus() },
@@ -1239,23 +1231,12 @@
if (!e.shiftKey) return;
const k = e.key.toLowerCase();
if (k !== "h" && k !== "l") return;
// Inside the editor, vim's mapCommand handles H/L (see below). Letting
// the event bubble here would double-jump.
// Inside the editor, let H/L type normally.
if (editor.dom.contains(document.activeElement)) return;
e.preventDefault();
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.
const drawerEl = document.getElementById("drawer");
const drawerBackdrop = document.getElementById("drawer-backdrop");