phone app: installable, opens offline, takes the share sheet onto the scratchpad, back lets go of one thing

A manifest with a 192, a 512 and a maskable icon, and a hand-written service
worker built beside the app by a small vite plugin: it keeps index.html, the
logo, the entry with its imports and the latin fonts, keeps each hashed asset
once fetched, asks the server for pages first and answers from the cache when
it is down, and never touches /api. The CSP names worker-src and manifest-src.

The share target posts to /share, which the worker catches: the text waits in
a cache and becomes a scratchpad note once someone is logged in, with the
drawer down on it. On a touch screen one guard entry in the history makes back
let go of one thing at a time — a prompt, the line, a sheet, the drawer, the
editor, a tile, then a folder up — and leave only at a space's root. The bar
and the band grow by the safe-area insets. The login no longer says the server
is unreachable once it answers again with the session gone.
This commit is contained in:
nvrl committed 2026-10-22 20:10:13 +02:00
1 parent 9363a0024d
commit a27d5de9c7
23 files changed
+377 -26

No files matched your search

+29 -4
View File
@@ -47,6 +47,30 @@ them, in one commit:
- `o` in a space with templates offers them on the line, `:new `; `Enter` on
nothing is still a blank document.
## The phone app, done
Written on 2026-10-22. The workspace installs on a phone as an app:
- A manifest (`web/public/manifest.webmanifest`) with a 192 and a 512 icon and
a maskable one, drawn from the favicon.
- A service worker (`web/src/sw.ts`, built by a small plugin in
`vite.config.ts`) keeps index.html, the logo, the entry with what it imports,
and the latin fonts, and every hashed asset once it has been fetched. Pages
are asked of the server first and answered from the cache when it is down.
It never touches `/api`. The CSP names `worker-src` and `manifest-src`.
- A share target, `POST /share`, caught by the worker and never sent to the
server: the text waits in the cache `share` and becomes a scratchpad note
once someone is logged in.
- Back, on a touch screen, lets go of one thing at a time through one guard
entry in the history (`back()` in `modes.ts`), and leaves the app at the
root of a space.
- The bar and the band grow by the safe-area insets, so an installed app keeps
them clear of the status bar and the gesture bar.
Not done, on purpose: reading documents offline, and sharing a photo or a file
— a file needs a document or a folder to belong to, and a note has neither.
iOS has no share target, so the iPhone keeps the Shortcut.
## Fixed before, on 2026-10-21
After the seven slices of the feature plan (trash `8ffad63`, revisions
@@ -83,9 +107,10 @@ tab. `.github/workflows/wiki.yml` replaces it on its first run.
The backlog, fitted to the model rather than given new surfaces:
- Install the app on a phone (a PWA: manifest plus a service worker for the
app shell, CSP `worker-src 'self'`), with a share target that posts into
capture as a note. The touch and desktop menus are already the same.
- Read on a phone with no network: the documents opened last, kept by the
service worker or in IndexedDB, and cleared on logout.
- Share a photo or a file into the app: it asks which document or folder it
belongs to.
- Task lists: toggle `- [ ]` in the reader, and the open ones of a space as a
section of the agenda rather than a `:tasks` command.
- A daily review, also as a section of the agenda: yesterday's entry, what was
@@ -95,4 +120,4 @@ The backlog, fitted to the model rather than given new surfaces:
Dropped: marks (`ma` … `mz`). The info sheet's headings and the tiles cover
what they were for, and `m` stays picking nodes up.
Suggested next: the PWA.
Suggested next: task lists and the daily review, as sections of the agenda.
+22 -3
View File
@@ -56,6 +56,9 @@ document per day. A file sits in a folder or belongs to its document.
card, or more of a document.
- **Capture.** A token per phone or script that puts a card in an inbox, a
note on the scratchpad or a line in today's entry, and nothing else.
- **Install.** On a phone the workspace installs as an app: it opens with no
network, sits in the share sheet, where whatever is shared to it lands on
the scratchpad, and back closes one thing at a time.
- **Share.** Any document behind an unguessable, revocable read-only link.
- **Mount.** The whole workspace over SFTP, for an editor on another machine.
- **Back up.** A mirror to Proton Drive, end-to-end encrypted, as a registered
@@ -223,6 +226,18 @@ has today's entry, an upload into the folder, the trash, a search across every s
and on the board the card moves, renames and deletes; the agenda has a done
button per card.
Over HTTPS the browser offers to install the workspace (Chrome's menu: *Install
app*, or *Add to Home screen*). Installed, it opens in a window of its own, from
its own icon, and with no network it still opens, to say the server cannot be
reached and try again. Back — the gesture or the button — lets go of one thing
at a time, the way `Esc` does: a sheet, the scratchpad, the editor, the
document, then a folder up; at the root of a space it leaves the app. On
Android the installed app is in the share sheet: a link or some text shared to
it becomes a note on the scratchpad, the title on its first line, and waits
there through a login if the session has run out. A service worker keeps the
app's own files and nothing else; it never stores a document, a card or a
note.
## Tiles
Several documents can be open at once. `L` (or `Shift-Enter`) on a row opens
@@ -416,7 +431,8 @@ On an iPhone, a Shortcut with *Ask for Input* followed by *Get Contents of
URL* does it: method `POST`, a header `Authorization` of `Bearer nwc_…`, and a
JSON body with `text` set to the input. On Android, *HTTP Shortcuts* does the
same with a text-input variable in the body. Either one goes on the home
screen, or behind the share sheet.
screen, or behind the share sheet. On Android the installed app is in the share
sheet itself, with no token, and puts what is shared on the scratchpad.
## Not losing things
@@ -951,7 +967,9 @@ sealed or not.
## Deploying
Put a TLS-terminating reverse proxy in front of it, point it at `WORKSPACE_BIND`,
and set `WORKSPACE_SECURE_COOKIE=1`. Nothing else is needed: no database server, no
and set `WORKSPACE_SECURE_COOKIE=1`. HTTPS is also what lets a phone install the
app: a browser runs a service worker only over HTTPS, or on `localhost`.
Nothing else is needed: no database server, no
runtime dependencies, no build tools on the host. The server will not start
while `WORKSPACE_SECRET` or `WORKSPACE_PASSWORD` is still the example from
`.env.example`: with the example secret anyone could sign themselves a session.
@@ -972,7 +990,8 @@ everyone out once.
The app sends its own `Content-Security-Policy`, `Referrer-Policy: no-referrer`,
`X-Content-Type-Options`, `X-Frame-Options`, `Cross-Origin-Opener-Policy`,
`Cross-Origin-Resource-Policy` and a `Permissions-Policy` that turns off camera,
microphone and location; API answers are `Cache-Control: no-store` unless they
microphone and location. The policy lets in one worker and one manifest, the
app's own (`/sw.js`, `/manifest.webmanifest`); API answers are `Cache-Control: no-store` unless they
say otherwise. A proxy that adds its own should not weaken them. `no-referrer` matters in particular, because a share token
lives in the URL and would otherwise leak to any site a shared entry links to.
Share links minted before the key existed are revoked by migration `0005`;
+5
View File
@@ -60,3 +60,8 @@ with a text-input variable, method `POST`, the same header, and a JSON body
`{"text": "{{input}}"}`.
Either one can sit on the home screen, or behind the share sheet.
**Android, without a token**: install the workspace from Chrome (*Install app*,
over HTTPS) and it is in the share sheet itself. What is shared to it — a link,
some text, a title — becomes a note on the scratchpad, from the session the app
is logged in with. See [On a phone](Guide.md#on-a-phone).
+3
View File
@@ -80,6 +80,9 @@ The rail of buttons on the right does the same as the keys, for a mouse.
On a phone the same things are buttons: the dock at the bottom has a new
document, a new folder, the board, today's entry, an upload and a search, and
the `⋯` on a row (or a long press on it) has what can be done to that row.
Over HTTPS the browser offers to install it as an app, which then sits in the
share sheet and puts what is shared on the scratchpad; see
[On a phone](Guide.md#on-a-phone).
![The workspace on a phone](../images/mobile-tree.png)
+15 -1
View File
@@ -205,7 +205,9 @@ curl -H "Authorization: Bearer nwc_…" -H 'content-type: application/json' \
-d '{"text":"call the bank"}' https://your-host/api/capture
```
The token can do this and nothing else. See [Capture](Capture.md).
The token can do this and nothing else. On Android the installed app needs no
token: share to it, and the text is a note on the scratchpad. See
[Capture](Capture.md) and [On a phone](#on-a-phone).
## Evening: the diary
@@ -251,3 +253,15 @@ has a done button on each card.
| ![The tree on a phone](../images/mobile-tree.png) | ![A document on a phone](../images/mobile-document.png) |
`:set vim` turns the vim keys on for a phone with a keyboard.
Over HTTPS the browser offers to install the workspace as an app (Chrome's
menu: *Install app*, or *Add to Home screen*). Installed, it opens from its own
icon, and with no network it still opens, to say the server cannot be reached.
Back lets go of one thing at a time, as `Esc` does — a sheet, the scratchpad,
the editor, the document, then a folder up — and only at the root of a space
leaves the app.
On Android the installed app is in the share sheet. A link or some text shared
to it becomes a note on the scratchpad, the title on its first line; the drawer
comes down with it on top, ready to be kept, made a card or put at the end of a
document. A share made while logged out waits for the login.
+4 -1
View File
@@ -406,9 +406,12 @@ fn compressible(
/// Content-Security-Policy for the app shell. `unsafe-inline` is granted to
/// styles only, because CodeMirror injects its theme as a <style> element at
/// runtime; scripts stay confined to the bundled files.
/// runtime; scripts stay confined to the bundled files. The service worker and
/// the manifest that make it an app on a phone are the shell's own, at `/sw.js`
/// and `/manifest.webmanifest`.
const CSP: &str = "default-src 'self'; img-src 'self' data:; media-src 'self'; \
style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; \
worker-src 'self'; manifest-src 'self'; \
frame-src 'self' https://verify.proton.me; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
type HeaderLayer = SetResponseHeaderLayer<HeaderValue>;
+2 -1
View File
@@ -2,12 +2,13 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0c0d0b" />
<title>~/workspace</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
</head>
<body>
<div id="app"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+22
View File
@@ -0,0 +1,22 @@
{
"id": "/",
"name": "workspace",
"short_name": "workspace",
"description": "A private workspace: documents, a plan, a scratchpad and a diary.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0c0d0b",
"theme_color": "#0c0d0b",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": { "title": "title", "text": "text", "url": "url" }
}
}
+31 -2
View File
@@ -3,7 +3,7 @@
import { openOverview } from './lib/actions'
import { hooks } from './lib/commands'
import { keysOf } from './lib/keymap'
import { dispatch } from './lib/modes'
import { back, dispatch } from './lib/modes'
import { embedSnippet } from './lib/markdown'
import Agenda from './lib/Agenda.svelte'
import Calendar from './lib/Calendar.svelte'
@@ -30,7 +30,7 @@
import Sidebar from './lib/Sidebar.svelte'
import StatusBar from './lib/StatusBar.svelte'
import { workspace, type Sheet } from './lib/store.svelte'
import { dwindle } from './lib/util'
import { dwindle, isTouchDevice } from './lib/util'
/*
* A share link is /s/<token>#<key>. The fragment never leaves the browser, so
@@ -181,6 +181,35 @@
return () => clearTimeout(timer)
})
/*
* On a phone, back lets go of one thing at a time rather than leaving the
* app. One entry is kept in the history over the page: back takes it off,
* `back()` lets go of something, and it goes on again. With nothing left to
* let go of it stays off, so the next back leaves. It is put on at a touch,
* since a browser skips an entry made without one.
*/
$effect(() => {
if (!isTouchDevice || shareToken) return
let guarded = false
const guard = () => {
if (guarded) return
history.pushState({ guard: true }, '')
guarded = true
}
const popstate = () => {
guarded = false
if (back()) guard()
// The entry under the guard still has the address the page opened on.
else workspace.tree.syncUrl(workspace.tree.path)
}
window.addEventListener('pointerdown', guard, true)
window.addEventListener('popstate', popstate)
return () => {
window.removeEventListener('pointerdown', guard, true)
window.removeEventListener('popstate', popstate)
}
})
// Guard against losing a half-written entry to a stray refresh.
$effect(() => {
if (!workspace.tiles.all.some((tile) => tile.dirty)) return
+11 -6
View File
@@ -41,10 +41,15 @@
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace;
/* The frame: a bar on top, a rail on the right, a band at the bottom, and a
rounded opening between them that the panes are seen through. */
--bar-h: 40px;
rounded opening between them that the panes are seen through. Installed
on a phone the app reaches under the status bar and the gesture bar
(`viewport-fit=cover`), so the bar and the band grow by what those cover
and keep their contents clear of them; in a browser both insets are 0. */
--inset-top: env(safe-area-inset-top, 0px);
--inset-bottom: env(safe-area-inset-bottom, 0px);
--bar-h: calc(40px + var(--inset-top));
--rail-w: 48px;
--status-h: 28px;
--status-h: calc(28px + var(--inset-bottom));
--frame: 8px;
--frame-radius: 18px;
@@ -69,7 +74,7 @@
@media (max-width: 767px) {
:root {
--rail-w: var(--frame);
--status-h: 40px;
--status-h: calc(40px + var(--inset-bottom));
--frame: 6px;
--frame-radius: 14px;
}
@@ -333,7 +338,7 @@ input::placeholder { color: var(--muted); }
display: flex;
align-items: center;
gap: 12px;
padding: 0 calc(var(--rail-w) + 4px) 0 calc(var(--frame) + 10px);
padding: var(--inset-top) calc(var(--rail-w) + 4px) 0 calc(var(--frame) + 10px);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
@@ -402,7 +407,7 @@ input::placeholder { color: var(--muted); }
display: flex;
align-items: center;
gap: 14px;
padding: 0 calc(var(--rail-w) + 4px) 0 calc(var(--frame) + 4px);
padding: 0 calc(var(--rail-w) + 4px) var(--inset-bottom) calc(var(--frame) + 4px);
font-size: 11px;
color: var(--subtle);
white-space: nowrap;
+1 -1
View File
@@ -356,7 +356,7 @@
.mobilebar {
display: flex;
gap: 6px;
padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
padding: 8px 12px;
border-top: 1px solid var(--rim);
flex: none;
}
+1 -1
View File
@@ -68,7 +68,7 @@
* doors.
*/
@media (max-width: 767px) {
.status { padding: 0 6px; gap: 6px; overflow: visible; }
.status { padding: 0 6px var(--inset-bottom); gap: 6px; overflow: visible; }
.hint, .file, .readout, .flag, .pending { display: none; }
.mode:not(.editing) { display: none; }
.tools { display: flex; flex: 1; height: 100%; }
+1 -1
View File
@@ -105,7 +105,7 @@ function tileKey(direction: Direction, shift: boolean) {
}
/** The drawer up, and the keys back where they were: in the editor, when a document was being written. */
async function raise() {
export async function raise() {
await workspace.scratch.close()
if (!workspace.scratch.open && workspace.editing && workspace.pane === 'document') hooks.focusEditor()
}
+31 -1
View File
@@ -16,7 +16,8 @@
* The `[y/N]` prompt sits over all of it, in App.svelte, and the command
* line keeps its own keys while it has them.
*/
import { actions, type Run } from './actions'
import { actions, raise, type Run } from './actions'
import { hooks, runCommand } from './commands'
import { bindings, type Binding, type Context } from './keymap'
import { workspace } from './store.svelte'
@@ -150,3 +151,32 @@ export function claim(event: KeyboardEvent): boolean {
event.stopPropagation()
return true
}
/*
* The phone's back gesture, or its back button: one thing let go of, the way
* Esc and q let go of it on a keyboard — a question, the line, a sheet, the
* drawer, the editor, a tile — and then up a folder. False when there is
* nothing left to leave, and the app itself may go.
*/
export function back(): boolean {
if (hooks.closeCommandLine()) return true
const step = leaving()
if (!step) return false
void step()
return true
}
function leaving(): (() => unknown) | null {
const { prompts, sheets, scratch, drafts, tree } = workspace
if (prompts.prompt) return () => prompts.prompt?.resolve(false)
if (prompts.secret) return () => prompts.secret?.resolve(null)
if (prompts.verify) return () => prompts.verify?.resolve(null)
if (sheets.shown !== 'none') return () => sheets.close()
if (scratch.open) return scratch.editing ? () => drafts.leaveWriting() : () => workspace.guard(raise)
if (drafts.writing || workspace.editing) return () => runCommand('q')
if (workspace.fullscreen) return () => (workspace.fullscreen = false)
if (workspace.focus !== 'list' && workspace.focus !== 'node') return () => runCommand('close')
if (workspace.open && workspace.pane === 'document') return () => runCommand('q')
if (tree.path.length > 1) return () => workspace.guard(() => tree.up())
return null
}
+22
View File
@@ -163,6 +163,28 @@ export class Scratchpad {
this.ws.say('scratched — Q shows the scratchpad')
}
/*
* What the phone's share sheet sent while the app was shut, kept by the
* service worker in the cache `share` (see sw.ts): each made a note, and
* the drawer down on the newest. One is let go of only once it is a note,
* so a share made with no network or no login waits for the next start.
*/
async takeShared() {
if (typeof caches === 'undefined') return
const cache = await caches.open('share')
const kept = [...(await cache.keys())].sort((a, b) => a.url.localeCompare(b.url, undefined, { numeric: true }))
if (!kept.length) return
for (const request of kept) {
const text = await (await cache.match(request))?.text()
if (text?.trim()) await api.createScratch(text)
await cache.delete(request)
}
if (this.open) this.notes = await api.scratch()
else await this.toggle()
this.cursor = 0
this.ws.say(kept.length === 1 ? 'shared — on the scratchpad' : `${kept.length} shared — on the scratchpad`)
}
/**
* `:keep [title]`: the note under the cursor made a document in the folder
* being browsed, and taken off the scratchpad. Without a title, the first
+2 -2
View File
@@ -109,10 +109,10 @@ export class Tree {
* outside that and shows matches from anywhere in the current space.
*/
/** Keep the address bar on the node being looked at, so it can be linked to. */
/** Keep the address bar on the node being looked at, so it can be linked to — the history entry, back's guard among them, kept as it is. */
syncUrl(path: Crumb[]) {
const slugs = path.map((crumb) => crumb.slug).join('/')
if (slugs) history.replaceState(null, '', `/n/${slugs}`)
if (slugs) history.replaceState(history.state, '', `/n/${slugs}`)
}
/** Open whatever `work/einarbeiten/01-was-ist-eebus` names. */
+6
View File
@@ -140,12 +140,16 @@ export class Workspace {
await this.cards.loadAgenda()
await this.vault.load()
await this.cards.openAgendaIfDue()
void this.guard(() => this.scratch.takeShared())
} catch (error) {
this.user = null
// Down is not the same as logged out: say so, and keep asking.
if (unreachable(error)) {
this.notice = 'the server cannot be reached — trying again'
setTimeout(() => void this.boot(), 5000)
} else if (this.notice.startsWith('the server cannot')) {
// Back, but the session did not last the wait.
this.notice = ''
}
} finally {
this.booting = false
@@ -170,6 +174,8 @@ export class Workspace {
await this.cards.loadAgenda()
await this.vault.load()
await this.cards.openAgendaIfDue()
// A share that came in while nobody was logged in has waited for this.
void this.guard(() => this.scratch.takeShared())
}
async logout(everywhere = false) {
+4
View File
@@ -5,3 +5,7 @@ import App from './App.svelte'
import './app.css'
export default mount(App, { target: document.getElementById('app')! })
// The worker that lets the app be installed and opened offline; see sw.ts.
// Not in development, where it would keep Vite's modules.
if (import.meta.env.PROD && 'serviceWorker' in navigator) void navigator.serviceWorker.register('/sw.js')
+117
View File
@@ -0,0 +1,117 @@
/*
* The service worker: what makes the workspace an app on a phone.
*
* - The app shell — index.html, the logo, and what they need to paint — is
* kept, so the app opens with no network and says the server cannot be
* reached, rather than the browser's error page.
* - Every hashed file under /assets is kept the first time it is fetched;
* they never change, so a lazy chunk is fetched once.
* - The share sheet posts to /share. The text is kept here, in the cache
* `share`, and the page takes it onto the scratchpad once someone is logged
* in (`Scratchpad.takeShared`). The post never reaches the server.
*
* Nothing from /api is touched or kept: the caches hold the public shell only,
* so logging out leaves nothing private behind.
*
* The worker is built by `serviceWorker` in vite.config.ts, which writes in
* the two names below. The DOM types have no worker events, so the few used
* here are spelled out; a module, so they stay its own.
*/
export {}
declare const __VERSION__: string
declare const __PRECACHE__: string[]
interface ExtendableEvent extends Event {
waitUntil(promise: Promise<unknown>): void
}
interface FetchEvent extends ExtendableEvent {
readonly request: Request
respondWith(response: Response | Promise<Response>): void
}
interface Scope {
skipWaiting(): Promise<void>
clients: { claim(): Promise<void> }
addEventListener(type: 'install' | 'activate', listener: (event: ExtendableEvent) => void): void
addEventListener(type: 'fetch', listener: (event: FetchEvent) => void): void
}
const worker = self as unknown as Scope
const SHELL = `shell-${__VERSION__}`
worker.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL)
await cache.addAll(['/index.html', '/favicon.svg', ...__PRECACHE__])
await worker.skipWaiting()
})(),
)
})
worker.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
for (const name of await caches.keys()) {
if (name.startsWith('shell-') && name !== SHELL) await caches.delete(name)
}
await worker.clients.claim()
})(),
)
})
worker.addEventListener('fetch', (event) => {
const request = event.request
const url = new URL(request.url)
if (url.origin !== location.origin) return
if (request.method === 'POST' && url.pathname === '/share') return event.respondWith(share(request))
if (request.method !== 'GET') return
if (url.pathname.startsWith('/assets/')) return event.respondWith(asset(request))
if (request.mode === 'navigate') return event.respondWith(fresh(request, '/index.html'))
if (url.pathname === '/favicon.svg') return event.respondWith(fresh(request, '/favicon.svg'))
})
/** A hashed file: from the cache, or fetched once and kept. */
async function asset(request: Request): Promise<Response> {
const cache = await caches.open(SHELL)
const kept = await cache.match(request)
if (kept) return kept
const response = await fetch(request)
if (response.ok) await cache.put(request, response.clone())
return response
}
/*
* A page — /, /n/…, /s/… — is index.html whatever the path, and the logo is
* the logo. The server's is newer, so it is asked first; the kept one answers
* when it cannot be reached, or a proxy in front of it says it is down.
*/
async function fresh(request: Request, kept: string): Promise<Response> {
try {
const response = await fetch(request)
if (response.status < 502) return response
return (await caches.match(kept)) ?? response
} catch (error) {
const copy = await caches.match(kept)
if (copy) return copy
throw error
}
}
/*
* The share sheet: a title, some text, a link — any of them may be missing,
* and Android often puts the link in the text as well. They become one note,
* the title on its first line.
*/
async function share(request: Request): Promise<Response> {
const form = await request.formData()
const field = (name: string) => String(form.get(name) ?? '').trim()
const [title, text, link] = [field('title'), field('text'), field('url')]
const lines = [title && title !== text ? title : '', text, link && !text.includes(link) ? link : ''].filter(Boolean)
if (lines.length) {
const cache = await caches.open('share')
await cache.put(`/share/${Date.now()}`, new Response(lines.join('\n\n')))
}
return Response.redirect('/', 303)
}
+48 -2
View File
@@ -1,6 +1,6 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import type { Plugin } from 'vite'
import type { Plugin, Rollup } from 'vite'
/*
* KaTeX lists each font as woff2, woff and ttf. Every browser the app runs in
@@ -15,8 +15,54 @@ const katexWoff2Only: Plugin = {
},
}
/*
* src/sw.ts, built beside the app as /sw.js — unhashed, since its address is
* what the browser remembers. Two names are written into it:
*
* __PRECACHE__ what index.html needs to paint: the entry, what it imports,
* their CSS, and the latin faces of the two fonts
* __VERSION__ a hash of every file name in the build, so a build that
* changes anything changes sw.js too, and the browser takes
* the new worker and drops the old shell
*/
const serviceWorker: Plugin = {
name: 'service-worker',
apply: 'build',
buildStart() {
this.emitFile({ type: 'chunk', id: '/src/sw.ts', fileName: 'sw.js' })
},
generateBundle(_, bundle) {
const chunks = Object.values(bundle).filter((file): file is Rollup.OutputChunk => file.type === 'chunk')
const entry = chunks.find((chunk) => chunk.isEntry && chunk.facadeModuleId?.endsWith('index.html'))
const sw = bundle['sw.js']
if (!entry || sw?.type !== 'chunk') return this.error('service worker: no entry, or no sw.js')
const shell = new Set<string>()
const walk = (name: string) => {
if (shell.has(name)) return
shell.add(name)
const chunk = bundle[name]
if (chunk?.type !== 'chunk') return
chunk.viteMetadata?.importedCss.forEach((css) => shell.add(css))
chunk.imports.forEach(walk)
}
walk(entry.fileName)
for (const name of Object.keys(bundle)) {
if (/\/(jetbrains-mono|noto-sans)-latin(-ext)?-wght-normal-[^/]+\.woff2$/.test(name)) shell.add(name)
}
// FNV-1a: only a change has to show, so nothing stronger is needed.
let hash = 0x811c9dc5
for (const char of Object.keys(bundle).sort().join('\n')) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193)
const version = (hash >>> 0).toString(16)
sw.code = sw.code
.replaceAll('__VERSION__', JSON.stringify(version))
.replaceAll('__PRECACHE__', JSON.stringify([...shell].map((name) => `/${name}`)))
},
}
export default defineConfig({
plugins: [svelte(), katexWoff2Only],
plugins: [svelte(), katexWoff2Only, serviceWorker],
build: { target: 'es2022', chunkSizeWarningLimit: 900 },
server: {
port: 4243,