feat: the editor installs and runs with the network gone

Nothing here talks to a server at runtime, so a cached shell is the whole
editor. The worker reads the build's hashed asset names out of the entry
document, which precaches without a build step; the document itself stays
network-first, or it would keep naming the assets of the build it was
cached with. Matching ignores Vary: a precached asset is fetched without
the page's Origin, and a server answering "Vary: Origin" never matches it.

Verified in a headless Chromium with the network cut.
This commit is contained in:
Steffen Becker committed 2026-08-14 22:11:49 +02:00
1 parent 147342655d
commit b19409aacb
7 files changed
+101 -1

No files matched your search

+2 -1
View File
@@ -133,6 +133,7 @@
- Captions (References ▸ Caption, LibreOffice's Insert ▸ Caption): a caption paragraph in the `Caption` style with a running number that counts itself — one counter per category (figure / table), in document order, renumbering as the document is edited. Round-trips as an ODF `<text:sequence>` (with LibreOffice's own `ooow:Illustration+1` formula) and a Word `SEQ Figure \* ARABIC` field
- Word (.docx) export and import — round-trips the editor's formatting (text, fonts, lists, tables, images, headers/footers, page geometry) and opens real Word documents
- Document properties (title, subject, author, keywords, comments) in the File menu — LibreOffice's File ▸ Properties / Word's File ▸ Info. Round-trips through ODF `meta.xml` (one `meta:keyword` per keyword) and DOCX `docProps/core.xml`
- Installable and offline (`public/manifest.webmanifest`, `public/sw.js`): nothing here talks to a server at runtime, so once the app's files are cached it is the whole working editor with the network gone — spell checker, fonts and all. The service worker reads the build's own hashed asset names out of the entry document, which precaches the shell with no build step; what the app loads lazily (a dictionary, the speller's WASM) is kept as it is asked for, so it joins from the second visit on. The document itself is network-first, or a cached one would keep naming the assets of the build it was cached with. Verified in a headless Chromium with the network cut: the app reloads, styles and all, and the manifest validates clean
- PDF export — Raster (pixel-exact copy of the editor with a selectable text layer) and Vector (crisp, fonts embedded, via the browser print dialog)
- Print (printer button / Ctrl+P) — opens the browser print dialog with a pixel-exact raster of the document (tables, headers/footers and page breaks intact)
@@ -160,7 +161,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
- Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby)
- Password-protected ODT/DOCX; digital signatures
- Navigator / outline view, split view
- PWA / offline support
- File handlers, so the OS can offer the installed app for a double-clicked `.odt`/`.docx` (Chrome's `file_handlers` + `launchQueue`)
- A committed corpus of documents we author ourselves, so CI can test against real files
(`.gitignore` excludes all of `tests/render-parity/fixtures/` today)
+4
View File
@@ -5,6 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EdenText</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="theme-color" content="#e6e9eb" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#2f2f31" media="(prefers-color-scheme: dark)" />
</head>
<body>
<div id="app"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"name": "EdenText",
"short_name": "EdenText",
"description": "A word processor that runs in the browser and saves .odt and .docx. No account, no server: the document never leaves the machine.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#e6e9eb",
"theme_color": "#52748b",
"categories": ["productivity", "utilities"],
"icons": [
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+68
View File
@@ -0,0 +1,68 @@
// Offline support. Nothing here talks to a server at runtime — the document lives in
// localStorage/IndexedDB — so a cached shell is the whole working editor: dictionaries,
// fonts and the WASM speller included, each kept the first time it is asked for.
const CACHE = 'edentext-v1';
// The entry document under one key, whichever path asked for it.
const SHELL = '/';
// The build's asset names carry a content hash, and the entry document is the only
// place they are written down — so reading them out of it precaches the shell with no
// build step. What the app loads later (the speller, a dictionary, a picture decoder)
// is kept as it is asked for, which is from the **second** visit on: on the first the
// worker is still installing while those requests go out.
async function precache(cache) {
const response = await fetch(SHELL, { cache: 'reload' });
await cache.put(SHELL, response.clone());
const html = await response.text();
const assets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"]+)"/g)].map((m) => m[1]);
await cache.addAll([...new Set(assets), '/favicon.svg', '/manifest.webmanifest']);
}
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE).then(precache).then(() => self.skipWaiting()));
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
// The document is network-first: served from the cache it would keep naming the assets
// of the build it was cached with, and a new one would never be fetched.
async function documentFrom(request) {
try {
const response = await fetch(request);
if (response.ok) (await caches.open(CACHE)).put(SHELL, response.clone());
return response;
} catch {
const cached = await caches.match(SHELL, { ignoreVary: true });
if (cached) return cached;
throw new Error('offline and no cached shell');
}
}
// Everything else is cache-first: the build's own files carry a content hash, so a
// cached one can never be stale.
// ponytail: the cache is never version-swept, so a redeploy's old assets stay in it
// until the browser evicts them. Sweeping needs the build id, which needs a build step.
async function assetFrom(request) {
// ignoreVary: a precached asset was fetched without the page's `Origin`, and a
// static server that answers `Vary: Origin` would make the two never match.
const cached = await caches.match(request, { ignoreVary: true });
if (cached) return cached;
const response = await fetch(request);
// An opaque response hides its status, so only a real same-origin one is kept.
if (response.ok && response.type === 'basic') (await caches.open(CACHE)).put(request, response.clone());
return response;
}
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
if (new URL(request.url).origin !== self.location.origin) return;
event.respondWith(request.mode === 'navigate' ? documentFrom(request) : assetFrom(request));
});
+8
View File
@@ -15,6 +15,14 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ()
if (loadTheme() === 'auto') applyTheme('auto');
});
// Offline support (public/sw.js). Not in dev, where a worker would serve the cached
// build over the one being edited.
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
const app = mount(App, { target: document.getElementById('app')! });
export default app;