diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 630980c..c754034 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,6 +8,8 @@ This document describes the backend architecture for Editable Website v2. Editable Website is a SvelteKit application that lets site owners edit content directly in the browser. The editor (Svedit) works with a graph-based document model — a flat map of nodes with references between them. The backend stores these documents in SQLite and serves them to the frontend, stitching together shared content (nav, footer) with page-specific content into a single document that Svedit can edit locally. +The production architecture is database-backed and supports multiple pages, but the project must also continue to support static preview/local development mode (for example `VERCEL=1`) where the app falls back to the demo document. In that mode, only the `/` route needs to work, multi-page features are disabled, authentication is disabled, and code paths must avoid hard dependencies on server-only runtime features that would break static deployments. + ## SvelteKit configuration The app uses Svelte's experimental async features and SvelteKit's remote functions. Both are enabled in `svelte.config.js`: @@ -46,6 +48,30 @@ export async function init() { The `handle` hook runs on every request and is where session validation and `event.locals.user` assignment will happen once authentication is implemented. +## Static / Vercel compatibility mode + +Editable Website must preserve a lightweight static-compatible mode for preview deployments and single-page local development. This mode is currently used when the app runs in a Vercel-style environment (`VERCEL=1`) and should keep working even as the full multi-page setup is introduced. + +**Requirements:** + +- Only the `/` route must support static/Vercel mode. +- In static/Vercel mode, `/` renders from the existing demo document instead of the database. +- multi-page features are disabled in this mode at the **UI / integration** level: + - no pages drawer + - no links or flows that send the user to `/new` + - no links or flows that send the user to dynamic `/:page_id` pages +- the multi-page routes may still exist in the codebase and may assume a full Node + database runtime; they just must not be linked to or otherwise used from the `VERCEL=1` branch. +- authentication is disabled in this mode. +- Client code must not hardwire imports or execution paths that force server-only database/remote-function behavior for the `/` route in static/Vercel mode. +- Be especially careful with top-level async imports and route setup so the static adapter / preview deployment path remains viable. + +This means the app effectively has two operating modes: + +1. **Full runtime mode** — database-backed, multi-page, shared nav/footer, authentication-ready +2. **Static/Vercel mode** — single-page demo-doc fallback on `/`, while multi-page routes may still exist but are not surfaced or used + +The static/Vercel mode is a compatibility constraint on all future multi-page work. + ## Data storage All persistent data lives in a single directory, controlled by the `DATA_DIR` environment variable (defaults to `data/`): @@ -125,10 +151,12 @@ A simple key-value table for site-wide configuration. Currently stores: **`document_refs`** -Tracks which documents link to which other documents. Updated on save — the server scans the document's nodes for internal links (annotations on text nodes that point to other pages) and diffs against the existing rows. Same pattern as `asset_refs`. +Tracks which documents link to which other documents. Updated on save — the server scans the document's nodes for internal links (annotations on text nodes that point to other pages) and rewrites the rows for that source document. Same pattern as `asset_refs`. This table tracks links from all document types — pages, nav, and footer. Since nav and footer are stitched into every page, their links are always live. This is the basis for determining page reachability (see "Page reachability" below). +`document_refs` must also preserve the **first-seen link order** for each `source_document_id`, because the page browser sitemap uses that order when projecting the reachable graph into a tree. In other words, if a page body links to pages in the order A, then B, then C, the stored outgoing refs for that page must preserve A → B → C. Duplicate links to the same target are collapsed to the first occurrence only. + **`asset_refs`** Tracks which assets are referenced by which documents. The compound primary key `(asset_id, document_id)` naturally deduplicates — a document referencing the same image five times still produces one row. @@ -193,6 +221,23 @@ page document nav document footer document This means changes to the nav or footer made on any page are persisted to the shared document and will be reflected on all pages. +### New pages (`/new`) + +The `/new` route uses an **ephemeral client-created document**. When the user opens `/new`, the client creates a fresh page document locally using the existing nanoid generator. The generated id is used immediately for both: + +- the document's `document_id` +- the root page node's `id` + +This id is stable from the beginning, even before the document is persisted. The page remains ephemeral only in the sense that it is not stored in the database until the first save. + +The transient `/new` document must be composed from the **current shared nav and footer documents in the database**, not from the static demo document. This ensures that if shared nav or footer content has been edited elsewhere, the new page starts from that latest shared state. + +On first save, the client sends that already-generated id to the server with `create: true`. The server persists the page under that id if it does not already exist. No server-side id allocation or root-id rewrite is needed. + +The `/new` route starts in edit mode immediately. + +If editing is cancelled on `/new`, the ephemeral document is discarded and the app returns to `/`. + ## Assets ### Media node types @@ -514,6 +559,26 @@ This can also run on save if a document previously referenced assets it no longe - `GET /api/documents/:id` — load a document (with shared documents stitched in) - `PUT /api/documents/:id` — save a document (server splits shared nodes back out, updates `asset_refs`) +- First save from `/new` uses the same save path, but with `create: true`. The page id is already client-generated via nanoid, so the server persists that exact id instead of allocating a new one. + +### Internal page href rules + +Internal page links use the dynamic `/:page_id` route shape. + +**Valid internal page hrefs:** + +- `/${page_id}` — a direct link to another page document +- `/` — the configured home page +- `/${page_id}#section` — counts as a link to `${page_id}` for reachability and `document_refs`; the fragment is ignored for graph purposes +- `/#section` — counts as a link to the home page only if it is used from a different page; when used on the home page itself, it is just an intra-page anchor and does not create a document reference + +**Not internal page links:** + +- pure same-page anchors (for example `#section`, or `/#section` on the home page, or `/${current_page_id}#section` on that same page) +- external URLs +- any other href that does not resolve to `/` or `/:page_id` + +When extracting `document_refs`, fragments are stripped before evaluating the target page. The graph tracks document-to-document references only, never section-level anchors. ### Assets @@ -542,6 +607,8 @@ The only admin interface is a **site map** — a listing of all pages plus draft A page is **reachable** (and appears in `sitemap.xml`) if it can be reached by following links starting from the home page, nav, or footer. This is a transitive check — a page linked only from a draft is still a draft, because the draft itself isn't reachable. +This reachability logic only applies in the full database-backed multi-page runtime. In static/Vercel compatibility mode there is no live multi-page graph, no sitemap drawer, and no draft/public distinction — the app simply serves the demo document at `/`. + The traversal starts from three roots: 1. The home page (`home_page_id` from `site_settings`) @@ -576,6 +643,77 @@ This query is cheap — most sites have tens to low hundreds of pages. It runs o When the home page is changed via `site_settings`, pages that were only reachable through the old home page's link tree may become drafts. This is expected — they're still visible in the admin site map and can be re-linked or deleted. +### Sitemap tree construction + +The admin page browser needs not just a reachable/unreachable split, but also a deterministic **tree projection** of the reachable page graph. + +The tree is built with these rules: + +- **No duplicates in the tree** — each reachable page appears at most once +- **First occurrence wins** — if a page is referenced multiple times, its canonical position is the first position where it is encountered during traversal +- **Top-level ordering:** traverse references from the home page in this order: + 1. shared nav links + 2. home page body links + 3. shared footer links +- **Recursive ordering:** once a child page has been placed in the tree, recurse into that page using **body links only** +- **Within each source document, preserve author order:** outgoing refs are consumed in the same order they appear in the source document, with duplicates removed by first occurrence + +This means the sitemap is not a full graph visualization. It is a stable, editor-friendly tree derived from the reachable graph, where shared navigation and footer establish the top-level site structure, and deeper nesting comes from contextual links inside page content. + +If a page is linked from multiple places, later occurrences are ignored for tree placement. This keeps the page browser compact and avoids crowded duplicates. If needed in the future, secondary references can be surfaced separately (for example as “also linked from…” metadata), but they are not duplicated in the primary tree. + + + +### Page summaries for the drawer + +The page drawer needs lightweight summaries for each page: + +- a display title +- an optional preview image + +For the initial implementation, these summaries are extracted **on the fly** in a server-side helper used by the page-browser query. They are **not** cached in the database yet. This keeps the system simple and avoids introducing additional summary columns or synchronization logic before there is evidence that summary extraction is a performance problem. + +Summary extraction should only inspect the **page-local body content**. Shared `nav` and `footer` content must not influence a page's summary, because that would cause many pages to inherit the same logo, links, or other shared content as their title/preview. + +**Title extraction order:** + +1. explicit `page.title` if that field exists and is non-empty +2. otherwise, the first heading-like `text` node found in body order +3. otherwise, the first meaningful body text content +4. fallback to `"Untitled page"` + +The exact heading-like layouts are defined by the page schema / text node semantics in the current implementation. The important part is that heading-like content is preferred over arbitrary text properties. + +**Preview image extraction order:** + +1. explicit page-level preview image field if one is added in the future +2. otherwise, the first image or video found while traversing the page body in document order +3. fallback to `null` + +Because the drawer already has a strong illustrated page fallback, `null` is perfectly acceptable and does not require a placeholder asset. + +If this on-the-fly extraction later proves too costly, the same extraction helper can become the canonical summary generator for a cached summary written on save. But caching is an optimization step for later, not part of the initial multi-page implementation. + +### Page deletion from the drawer + +The page drawer supports per-page actions via an anchored ellipsis menu. For now the menu contains: + +- `Open in new tab` +- `Delete` + +Deleting a page is intentionally simple and does **not** attempt to repair incoming links. If a reachable page is deleted, other pages may still contain internal links pointing to its old route. Those links become dead links until the author updates or removes them. + +Deletion requires confirmation, with copy depending on whether the page is a draft or a reachable page: + +1. Draft: `Are you sure you want to delete this draft?` +2. Reachable page: `Are you sure you want to delete this page? You'll leave some dead links on the page.` + +The configured home page cannot be deleted. In the drawer UI this is the first page in the page listing and its delete action is unavailable. + +If the currently open page is deleted from the drawer, the client should navigate to the home page after the delete succeeds. + +The ellipsis menu is implemented as a dialog using anchor positioning. It can be dismissed with `Escape` or by clicking the backdrop, matching the interaction model used by other anchored dialogs in the editor. + ## Authentication Editable Website is a **single-user application**. There is one admin account. No user registration, no roles, no multi-tenancy. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 8326069..38848e1 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -2,717 +2,718 @@ This document tracks what to implement next. One step at a time. All implementation must conform to the design decisions in [ARCHITECTURE.md](ARCHITECTURE.md) — if a conflict arises, update the architecture first, then implement. -## Step 1: database, seed data, and page rendering - -**Goal:** the home page (`page_1`) renders at `/` and saving changes persists them to the database. No assets, no authentication — those come in later steps. - -- **`src/lib/server/db.js`** — database connection using `node:sqlite`, exports the db instance. Uses `DATA_DIR` for the database path. -- **`src/lib/server/migrations.js`** — exports an array of migration steps. For now, a single `initial_schema` step that creates the `documents`, `site_settings`, and `document_refs` tables, and seeds: - - `page_1` (type `page`) — the home page, resembling the current demo document - - `nav_1` (type `nav`) — the navigation document - - `footer_1` (type `footer`) — the footer document - - `home_page_id` setting → `page_1` -- **`src/lib/server/migrate.js`** — runs pending migrations from `migrations.js` against the database -- **`src/hooks.server.js`** — uncomment the `migrate()` call in `init()` so migrations run on server startup -- **`svelte.config.js`** — uncomment experimental async and remote functions -- **`src/lib/api.remote.js`** — uncomment/wire up `get_document` (query) and `save_document` (action) to read from and write to the database -- **Page rendering** — `/` loads `page_1` via `get_document`, stitches in `nav_1` and `footer_1`, and renders with Svedit. Saving calls `save_document` which persists changes back to SQLite. - -For now, everything stays in the `initial_schema` migration step. While iterating on the schema, it's fine to wipe the database and re-run — real incremental migrations will be added later. - -No authentication for now — it slows down development. Auth will be added as a later step. No asset handling yet — media uploads and serving come in a later step. - -## Step 2: asset processing and upload (images only) - -**Goal:** users can paste or drop images into the editor, images are processed client-side (resized, converted to WebP, variants generated), uploaded to the server, and served from disk. The save flow uploads all pending assets before saving the document. No videos, audio, or authentication yet — those come in later steps. - -This step covers static raster images (JPEG, PNG, WebP, HEIC/HEIF) which get client-side WASM processing, plus animated GIFs and SVGs which pass through unprocessed. The image-resize project serves as the reference implementation for the WASM processing pipeline and upload protocol. - -**Modularization principle:** asset processing and upload logic should be extracted into dedicated modules rather than inlined into existing files like `+page.svelte`. The save flow, paste/drop handling, and upload protocol each get their own module. Existing files should only import and call into the new modules — minimal changes to existing code, maximum isolation of new functionality. - -### Dependencies - -Install `@jsquash/webp` and `@jsquash/resize` for client-side WASM-based image processing: - -``` -npm install @jsquash/webp @jsquash/resize -``` - -### Vite configuration - -Add `optimizeDeps.exclude` for the WASM packages and set worker format to ES modules: - -```js -// vite.config.js -optimizeDeps: { - exclude: ['@jsquash/webp', '@jsquash/resize'] -}, -worker: { - format: 'es' -} -``` - -### Shared configuration - -**`src/lib/config.js`** — add asset-related constants alongside the existing `DATA_DIR`, `DB_PATH`, `ASSET_PATH`: - -The asset constants must be importable from both the server and the client (including Web Workers), so they go in a separate file with no Node.js imports: - -**`src/lib/asset-config.js`** — universal asset constants (no Node.js imports): - -```js -export const VARIANT_WIDTHS = [320, 640, 1024, 1536, 2048, 3072, 4096]; -export const VARIANT_WIDTHS_SET = new Set(VARIANT_WIDTHS); -export const MAX_IMAGE_WIDTH = VARIANT_WIDTHS[VARIANT_WIDTHS.length - 1]; -export const ASSET_BASE = '/assets'; -``` - -`src/lib/config.js` (which imports from `node:path`) remains server-only and unchanged. The Web Worker and client code import from `asset-config.js`. - -`ASSET_BASE` is the URL prefix used to construct asset URLs on the client. All `src` values in saved documents are bare asset ids (e.g. `c4b519da...fabdb.webp`). Components prefix them with `ASSET_BASE` at render time to build the full URL. This is the only valid source for saved images — no absolute URLs, no external URLs, no relative paths. A `src` value is either: -- `blob:...` — unsaved, only valid during the current editing session -- A bare asset id — saved, rendered as `{ASSET_BASE}/{asset_id}` - -### Client-side image processing - -Adapted from the image-resize project's WASM pipeline. Two new files: - -**`src/lib/client/asset-processor.js`** — a Web Worker that handles image processing off the main thread. Receives a `File`, decodes it to `ImageData` via `createImageBitmap` + `OffscreenCanvas`, resizes if wider than `MAX_IMAGE_WIDTH` using `@jsquash/resize` (Lanczos3), encodes as WebP (quality 80) using `@jsquash/webp`, generates all applicable width variants, and transfers the resulting `ArrayBuffer`s back to the main thread. Posts `status` messages during processing for UI feedback. - -**`src/lib/client/process-asset.js`** — main-thread wrapper that spawns the worker, sends the file + config, and returns a `Promise`: - -```js -/** @typedef {{ - * original: { blob: Blob, width: number, height: number }, - * variants: Array<{ width: number, blob: Blob }> - * }} ProcessedAsset */ -``` - -Takes an `onStatus` callback for progress reporting. Terminates the worker after completion. - -### SHA-256 content hashing - -The client computes the SHA-256 hex hash of the **source file** (before any processing) using `crypto.subtle.digest`. This hash plus the stored format's file extension becomes the asset id. For static images the extension is `.webp` (since they're converted). For animated GIFs it's `.gif`. For SVGs it's `.svg`. - -```js -async function hash_blob(blob) { - const buffer = await blob.arrayBuffer(); - const hash_buffer = await crypto.subtle.digest('SHA-256', buffer); - const hash_array = Array.from(new Uint8Array(hash_buffer)); - return hash_array.map(b => b.toString(16).padStart(2, '0')).join(''); -} +## Existing implementation steps (compacted history) + +These older steps are kept in compact form as historical context. The durable source of truth is still [ARCHITECTURE.md](ARCHITECTURE.md); this section only captures how the current system got here and which high-level implementation moves were already made. + +### Step 1 — database, seed data, and page rendering +- Introduced SQLite-backed document persistence using `node:sqlite` +- Added migrations + startup migration hook +- Seeded: + - `page_1` + - `nav_1` + - `footer_1` + - `home_page_id` +- Wired `get_document` / `save_document` +- `/` renders `page_1` by loading the page document and stitching in shared nav/footer + +### Step 2 — asset processing and upload +- Added client-side image processing with WASM (`@jsquash/webp`, `@jsquash/resize`) +- Added asset hashing, upload, variant generation, and asset serving +- Established the `blob:` (unsaved) → asset id (saved) transition model +- Added `asset_refs` tracking +- Kept the save flow “upload assets first, then persist document” +- Preserved the rule that all persisted media sources are local asset ids + +### Step 3 — deployment / operationalization +- Deployment planning existed for Fly.io / Node adapter / persistent storage +- This is now mostly archival context; architecture is the canonical reference for storage/runtime assumptions + +### Step 4 — media evolution +- Added video node support and unified media handling direction +- Introduced / documented `MediaControls` +- Moved toward the `media` abstraction instead of hard-coded image-only thinking +- The architecture now captures the final intended media model more reliably than the old step-by-step notes +# Multi-page implementation analysis + +## Goal + +Turn the current single-page editable site into a true multi-page setup with: + +- `/new` — an ephemeral unsaved page editor that becomes a real page on first save +- `/:page_id` — dynamic page loading and editing +- `/` — still renders the configured home page +- a real pages drawer populated asynchronously when opened +- drafts and linked pages derived from persistent site data +- no authentication checks yet beyond assuming the current user is effectively an admin + +This step must preserve the current strengths of the app: + +- shared `nav` and `footer` composition +- existing save flow including asset processing/upload/replacement +- current document splitting and asset reference tracking +- editable-in-place page editing with the same session and toolbar behavior +- static/Vercel compatibility for the `/` route using the demo document fallback + +In addition, the multi-page work must preserve the current static preview / local single-page mode: + +- when running in static/Vercel-style mode (for example `VERCEL=1`), only `/` needs to work +- `/` should continue to render from the demo document in that mode +- multi-page features are disabled in that mode from the `/` route's point of view: + - no pages drawer + - no linking into `/new` + - no linking into dynamic `/:page_id` +- the multi-page routes themselves may still exist and assume the full Node + database runtime; they just must not be used from the `VERCEL=1` branch +- authentication is also disabled in that mode +- implementation must avoid hardwiring server-only runtime assumptions into the `/` route that would break static deployments +- be especially careful with top-level async imports and route setup, as already noted in the current `+page.svelte` flow + +## Key observations from current codebase + +### 1. The database model is already close to supporting multi-page + +Current `documents` table: + +```sql +CREATE TABLE documents ( + document_id TEXT NOT NULL PRIMARY KEY, + type TEXT NOT NULL, + data TEXT +); ``` -The hash is always computed from the **original source file** the user provides, not from the processed WebP output. This ensures the same source file always produces the same asset id regardless of encoder version or settings. - -### Asset upload endpoints - -Three new SvelteKit API routes, adapted from image-resize but using the ARCHITECTURE's asset path conventions (content-addressed filenames in a flat `ASSET_PATH` directory, not `{id}/original.{ext}`): - -**`src/routes/api/assets/+server.js`** — `POST` upload an original asset. +This already allows storing many documents of type `page`. No schema change is required just to store multiple pages. -- Request headers: `X-Content-Hash` (SHA-256 hex), `Content-Type`, `X-Asset-Width`, `X-Asset-Height` -- Request body: the processed blob (WebP for static images, original bytes for GIFs/SVGs) -- Constructs the asset id: `{hash}.{ext}` where ext is determined by content type (`webp` for static images, `gif` for GIFs, `svg` for SVGs) -- **Deduplication:** if a file already exists at `ASSET_PATH/{asset_id}`, skip the upload — drain the request body and return the existing metadata with `deduplicated: true` -- Streams the body to `ASSET_PATH/{asset_id}` — no buffering the whole file in memory -- On write failure, cleans up the partial file -- Returns `{ asset_id, width, height, deduplicated }` on success -- No database writes — assets are files on disk, not database rows. The `asset_refs` table is updated during document save, not during upload. +Current `site_settings` table: -**`src/routes/api/assets/[asset_id]/variants/+server.js`** — `POST` upload a width variant. +- already stores `home_page_id` +- can remain the source of truth for `/` -- Request headers: `X-Variant-Width`, `Content-Type: image/webp` -- Request body: WebP blob -- Validates the asset id exists on disk (the original must have been uploaded first) -- Validates the width is in `VARIANT_WIDTHS_SET` -- The server trusts the client to only upload variants that are smaller than the original — the client already has the original dimensions and only generates applicable variants -- Extracts the stem from the asset id (strip the extension), writes to `ASSET_PATH/{stem}/w{width}.webp` -- Creates the variant directory if needed -- On failure: the caller (client save flow) sends `DELETE /api/assets/{asset_id}` to clean up -- Returns `{ ok: true, variant: "w{width}.webp" }` on success +### 2. `get_document(document_id)` and `save_document(combined_doc)` are already page-id driven -**`src/routes/api/assets/[asset_id]/+server.js`** — `DELETE` remove an asset and its variants. - -- `DELETE`: remove the original file and the variant directory (`ASSET_PATH/{stem}/`). Used by the client to clean up after a failed variant upload. Returns `{ ok: true }`. - -### Asset serving - -**`src/routes/assets/[...path]/+server.js`** — serves asset files from `ASSET_PATH`. - -- Matches two patterns: - - `GET /assets/{asset_id}` — serve the original (e.g. `/assets/c4b519da...fabdb.webp`) - - `GET /assets/{stem}/w{width}.webp` — serve a width variant (e.g. `/assets/c4b519da...fabdb/w320.webp`) -- Reads the file from `ASSET_PATH` and streams it as the response -- Sets `Cache-Control: public, max-age=31536000, immutable` (content-addressed = cacheable forever) -- Sets `Content-Type` based on file extension (`.webp` → `image/webp`, `.gif` → `image/gif`, `.svg` → `image/svg+xml`) -- Sets `Content-Disposition: inline; filename="{first_8_hex}.{ext}"` using the first 8 hex characters of the hash -- Returns `404` if the file doesn't exist -- No database lookups — purely filesystem-based serving - -### Server-side storage helpers - -**`src/lib/server/asset-storage.js`** — filesystem operations for assets, adapted from image-resize's `storage.js` but using the ARCHITECTURE's flat file layout: - -``` -ASSET_PATH/ -├── {hash}.webp # original (static image) -├── {hash}/ # variant directory (same name as original without extension) -│ ├── w320.webp -│ ├── w640.webp -│ └── ... -├── {hash}.gif # animated GIF (no variants) -├── {hash}.svg # SVG (no variants) -``` +In `src/lib/api.remote.js`, `get_document` already accepts a `document_id`. +This is a strong foundation for `/:page_id`. -Functions: -- `asset_path(asset_id)` — full path for an original: `join(ASSET_PATH, asset_id)` -- `variant_dir(asset_id)` — directory for variants: strip extension from asset_id, `join(ASSET_PATH, stem)` -- `variant_path(asset_id, width)` — full path for a variant: `join(variant_dir(asset_id), 'w' + width + '.webp')` -- `write_asset(asset_id, data)` — stream data to `asset_path(asset_id)`, returns bytes written -- `write_variant(asset_id, width, data)` — create variant dir, stream data to `variant_path(asset_id, width)` -- `asset_exists(asset_id)` — check if original exists on disk -- `delete_asset(asset_id)` — remove original file + variant directory -- `create_asset_read_stream(asset_id)` — returns a Node.js ReadStream for the original -- `create_variant_read_stream(asset_id, width)` — returns a ReadStream for a variant -- `asset_size(asset_id)` — returns file size in bytes +Current limitations: +- no route yet passes arbitrary page ids +- no helper exists to list page documents +- no helper exists to create a brand-new page document id on first save +- `save_document` always upserts the provided page id, but assumes the page already conceptually exists -Ensure `ASSET_PATH` directory exists (create on module load, same pattern as `db.js` creating `DATA_DIR`). +### 3. Shared-document splitting is already the right design -### Paste/drop handling +`save_document` currently: -Svedit already has a `handle_image_paste` callback in `create_session.js` that fires when the user pastes or drops image files. Currently it creates image nodes with `src` set to a `data_url`. We extend this to: +- treats the root document as the page +- splits out `nav` subtree and `footer` subtree +- writes each document separately -1. Create image nodes with `src` set to a `blob:` URL (via `URL.createObjectURL`) of the **source file** instead of the data URL. The image displays instantly. -2. Start background processing for the pasted file (runs concurrently, does not block the editor): - a. Compute the SHA-256 hash of the source file. - b. Determine the asset type: - - **SVG** (`image/svg+xml`): passthrough — no WASM processing. Extract dimensions via `` element. The blob is the original file. Extension: `.svg`. - - **Animated GIF** (`image/gif` with multiple frames): passthrough — no WASM processing. Extract dimensions via `` element. The blob is the original file. Extension: `.gif`. - - **Static raster image** (JPEG, PNG, WebP, HEIC, HEIF, still GIF): process via WASM worker — decode, resize if > `MAX_IMAGE_WIDTH`, encode as WebP, generate all applicable width variants. Extension: `.webp`. - c. Store the processing result (hash, original blob, variants, dimensions) keyed by the blob URL in a `Map`. This map is consulted during the save flow. -3. The user can continue editing. Processing happens in the background. +This aligns with the architecture and should remain unchanged. -The processing/upload map and its logic should live in a dedicated module (e.g. `src/lib/client/asset-upload.js`), not inline in `create_session.js` or `+page.svelte`. +The multi-page work should **not** move away from: +- page document + shared nav + shared footer composition -For animated GIF detection, scan the binary for multiple Graphic Control Extension blocks (`0x21 0xF9`), same approach as image-resize. +### 4. `/new` should be ephemeral and not create junk rows -### Save flow changes +Per product direction, `/new` should not immediately insert a page row. +Instead: -Extend the existing `SaveCommand` in `+page.svelte` to handle asset uploads before saving the document: +- the client creates a transient in-memory document +- first save persists it as a real page document +- then navigation should transition from `/new` to `/:page_id` -1. **Collect pending assets.** Walk the document nodes, find all image nodes whose `src` starts with `blob:`. These are unsaved assets. +This is preferable to eagerly inserting a draft page into the database. -2. **Wait for processing.** If any of these assets are still being processed in the background, wait for them to finish. Show a status indication (e.g. in the save button or via a simple message). +### 5. The current save API needs a create-vs-update distinction -3. **Upload assets sequentially.** For each pending asset: - a. Construct the asset id: `{sha256_hash}.{ext}` - b. Upload the original: `POST /api/assets` with the processed blob, `X-Content-Hash`, `Content-Type`, `X-Asset-Width`, `X-Asset-Height`. If the server returns `deduplicated: true`, skip variant uploads. - c. Upload variants sequentially: `POST /api/assets/{asset_id}/variants` with each variant blob + `X-Variant-Width`. If any variant upload fails, `DELETE /api/assets/{asset_id}` to clean up, then abort the save with an error. - d. Record the mapping: `blob_url → { asset_id, width, height }` +Today `save_document` just upserts the given document id. -4. **Replace blob URLs.** Walk all image nodes. For every `src` that is a blob URL, replace it with the asset id. Update `width` and `height` to the processed dimensions (which may differ from the source if the image was resized down to `MAX_IMAGE_WIDTH`). +For `/new`, we need a server-side path that: +- accepts the already-generated client page id +- save the new page under that same id +- preserve shared nav/footer references +- return the final page id to the client -5. **Save the document.** Call `save_document()` as before. The document now contains only asset ids, no blob URLs. +So save needs to support: +- **update existing page** +- **create new page from transient draft** -6. **Error handling.** If any asset upload fails entirely (and cleanup succeeds), abort the save with an alert. The user stays in edit mode with their changes intact — blob URLs are not replaced until all assets are uploaded. Successfully uploaded assets from earlier in the sequence are left on the server (they're complete and valid; deduplication will skip them on retry). +### 6. The drawer needs two derived datasets, not one -Use XHR (not `fetch`) for uploads so we get `upload.onprogress` events for progress tracking. +The page browser is not just “all pages”. -### `asset_refs` tracking +It needs: -Update `save_document` in `api.remote.js` to track asset references as part of the save transaction: +1. **Drafts** + Flat list of page documents that are not reachable from the live site structure -1. Add the `asset_refs` table in a new migration step (`add_asset_refs`): - ```sql - CREATE TABLE asset_refs ( - asset_id TEXT NOT NULL, - document_id TEXT NOT NULL, - PRIMARY KEY (asset_id, document_id) - ); - ``` +2. **Site structure / sitemap** + Tree rooted at the current home page -2. During `save_document`, after splitting the combined document into page/nav/footer, for each sub-document: - a. Walk its nodes, collect all `src` values from image nodes → the current set of asset ids - b. Delete all existing `asset_refs` rows for that `document_id` - c. Insert the new `(asset_id, document_id)` pairs - -3. This happens inside the existing transaction, so it's atomic with the document save. - -### `Image.svelte` rendering - -The Svedit image component needs to handle two `src` modes: - -- If `src` starts with `blob:` — use it directly as the `` src (editing session, not yet saved) -- Otherwise — prefix with `ASSET_BASE` to construct the URL, and build a `srcset` from the variant widths: - -```html - -``` - -Only include variants for widths strictly smaller than the image's `width`. The original always serves as the largest entry in `srcset`. SVGs and animated GIFs get no `srcset` (original only). The default `sizes` is `50vw` — individual components can override this if they know their layout constraints. - -### File summary - -New files: -- `src/lib/client/asset-processor.js` — Web Worker for WASM image processing -- `src/lib/client/process-asset.js` — main-thread wrapper for the worker -- `src/lib/server/asset-storage.js` — filesystem operations for assets -- `src/routes/api/assets/+server.js` — POST upload original -- `src/lib/client/asset-upload.js` — pending asset map, upload orchestration -- `src/lib/asset-config.js` — universal asset constants (VARIANT_WIDTHS, ASSET_BASE, etc.) -- `src/routes/api/assets/[asset_id]/+server.js` — DELETE cleanup -- `src/routes/api/assets/[asset_id]/variants/+server.js` — POST upload variant -- `src/routes/assets/[...path]/+server.js` — GET serve assets from disk - -Modified files: -- `vite.config.js` — add `optimizeDeps.exclude` and `worker.format` -- `package.json` — add `@jsquash/webp` and `@jsquash/resize` dependencies -- `src/lib/server/migrations.js` — add `add_asset_refs` migration step -- `src/lib/api.remote.js` — update `save_document` to track `asset_refs` -- `src/routes/create_session.js` — update `handle_image_paste` to use blob URLs and trigger background processing -- `src/routes/+page.svelte` — extend save flow with asset upload (delegates to `asset-upload.js`) -- `src/routes/components/Image.svelte` — handle `blob:` vs asset id `src`, build `srcset` - -No video, audio, or authentication in this step. - -## Step 3: Fly.io deployment - -**Goal:** deploy the app to Fly.io as a Docker-based Node.js service with persistent storage for the SQLite database and uploaded assets. Adapted from the image-resize project's deployment infrastructure. - -### SvelteKit adapter - -Switch from `@sveltejs/adapter-auto` to `@sveltejs/adapter-node` so the build produces a standalone Node.js server: - -```js -// svelte.config.js -import adapter from '@sveltejs/adapter-node'; -``` +That means we need: +- page listing +- document reference analysis +- reachability traversal -Install as a dependency (not devDependency — needed at build time in Docker): +This matches the architecture section on page reachability. -``` -npm install @sveltejs/adapter-node -``` +### 7. Existing `document_refs` table is currently unused for page browser logic -Remove `@sveltejs/adapter-auto` from devDependencies. +The architecture describes `document_refs`, but the current `save_document` implementation shown in the code excerpt does not yet update it. -### Dockerfile +This is a major gap. -Adapted from image-resize. Multi-stage build: +For a real sitemap/drafts implementation, we need: +- internal page links extracted on save +- `document_refs` updated on save for pages/nav/footer +- a reachability algorithm that starts from: + - `home_page_id` + - plus links coming from shared documents like nav/footer because those are part of every page render -1. **Base stage** — `node:24-slim`, sets `NODE_ENV=production`, working directory `/app`. -2. **Build stage** — installs all dependencies (`npm ci --include=dev`), copies source, runs `npm run build`, then prunes dev dependencies (`npm prune --omit=dev`). -3. **Final stage** — installs runtime packages (`sqlite3`, `procps`, `curl`, `nano`, `less`, `ca-certificates`). No Litestream for now — backup strategy comes later. Copies the built app from the build stage. Creates `/data` volume mount point. Exposes port 3000. +### 8. The drawer should load async-on-open, not up front -Entry point: `node /app/scripts/start-app.js` (no shell wrapper needed — the image-resize shell script's only active responsibility is copying `.sqliterc` and launching the Node process; we simplify by running Node directly and copying `.sqliterc` in the Dockerfile). +That means: +- do not fetch page browser data during normal page load +- fetch only once the drawer is opened +- probably cache while open / until page changes -```dockerfile -# syntax = docker/dockerfile:1 +This is a good use for Svelte async patterns and keeps the main editor lightweight. -ARG NODE_VERSION=24.14.0 -FROM node:${NODE_VERSION}-slim as base +## Design decisions for this step -LABEL fly_launch_runtime="Node.js" +## Decision 1: keep `/` as a dedicated home-page route -WORKDIR /app +- `/` continues to resolve `home_page_id` from site settings in the full runtime +- it loads that page using the same dynamic page loader used by `/:page_id` +- however, `/` must also retain a static/Vercel fallback mode that renders the demo document without requiring the database or multi-page runtime -ENV NODE_ENV="production" +This avoids duplicating page rendering logic while preserving a clean homepage URL and keeping preview/static deployments viable. -# Build stage -FROM base as build +## Decision 2: introduce a dynamic `[page_id]` route -COPY --link .npmrc package-lock.json package.json ./ -RUN npm ci --include=dev +- `src/routes/[page_id]/+page.svelte` becomes the canonical page renderer/editor +- `/` should reuse the same page shell/component internally rather than duplicating editor logic -COPY --link . . +Best structure: -RUN mkdir /data && npm run build +- create a shared `PageEditor.svelte` or similar component that accepts: + - loaded document + - route mode (`new` vs existing) + - maybe initial page id state +- use it from: + - `/+page.svelte` + - `/[page_id]/+page.svelte` + - `/new/+page.svelte` -RUN npm prune --omit=dev +This keeps the editor implementation single-sourced. -# Final stage -FROM base +## Decision 3: `/new` uses a client-generated page id from the start -RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y sqlite3 procps curl nano less ca-certificates && \ - rm -rf /var/lib/apt/lists /var/cache/apt/archives +For `/new`, create a fresh transient page document on the client via a `create_empty_doc()` helper (or equivalent) that generates a new `page_id` / `document_id` using the existing nanoid setup. -COPY --from=build /app /app +This means: +- the root page node id and the document id are the same from the beginning +- the id is unique immediately, even before the document is persisted +- there is no need for a server roundtrip just to allocate a page id +- the page is still ephemeral in the sense that it is only stored once the user saves -# Copy .sqliterc for convenient sqlite3 CLI usage -COPY --from=build /app/.sqliterc /root/.sqliterc +On first save: +- the client sends the already-generated document id +- the server persists the document under that id +- no root-id rewrite is needed during save +- the client can navigate to `/${page_id}` after save (or continue there if already routed consistently) -RUN mkdir -p /data -VOLUME /data +The transient document should still reference: +- existing shared `nav` +- existing shared `footer` -EXPOSE 3000 +so the editing experience matches real pages immediately. -CMD ["node", "/app/scripts/start-app.js"] -``` +## Decision 4: add a dedicated “save page” remote command that can create pages -### Graceful shutdown script +Instead of overloading current `save_document` too implicitly, define the API around page saving clearly. -**`scripts/start-app.js`** — starts the SvelteKit Node server and handles graceful shutdown signals from the Fly platform. Identical to the image-resize version: +Two possible shapes: +### Option A — extend `save_document` +Input: ```js -import { server as app } from '/app/build/index.js'; - -function shutdownServer() { - console.log('Server doing graceful shutdown'); - app.server.close(); +{ + document_id, + nodes, + create: boolean } - -process.on('SIGINT', shutdownServer); -process.on('SIGTERM', shutdownServer); -``` - -This ensures in-flight requests complete and SQLite connections close cleanly on deploy or restart. - -### fly.toml - -Adapted from image-resize. Key settings: - -- `DATA_DIR=/data` environment variable — matches the app's `config.js` which reads `process.env.DATA_DIR || 'data'` -- Persistent volume mounted at `/data` — stores both the SQLite database and uploaded assets -- `deploy.strategy = "immediate"` — single-instance app, no rolling deploys needed -- Port 3000, force HTTPS -- Start with `auto_stop_machines = "suspend"` (scale-to-zero) since it's a low-traffic site - -```toml -[build] - dockerfile = "Dockerfile" - -[env] - DATA_DIR = "/data" - -[deploy] - strategy = "immediate" - -[mounts] - source = "data" - destination = "/data" - auto_extend_size_threshold = 80 - auto_extend_size_increment = "1GB" - auto_extend_size_limit = "5GB" - -[http_service] - internal_port = 3000 - force_https = true - auto_start_machines = true - auto_stop_machines = "suspend" - min_machines_running = 0 - processes = ["app"] -``` - -The volume auto-extends when it reaches 80% capacity, up to 5GB. This accommodates growing asset storage without manual intervention. - -### .dockerignore - -Prevents unnecessary files from being copied into the Docker build context: - -``` -/.git -/.svelte-kit -/build -/node_modules -/data -.dockerignore -.DS_Store -.env* -Dockerfile -fly.toml -vite.config.js.timestamp-* -``` - -### .sqliterc - -Convenience config for the `sqlite3` CLI when SSH-ing into the Fly machine for debugging: - -``` -.headers ON -.mode box -.width 0 -.nullvalue NULL -.timer ON -``` - -### .npmrc - -Ensure the `.npmrc` file exists (it may already). The Dockerfile expects it in the `COPY` step. If it doesn't exist, create one with: - -``` -engine-strict=true -``` - -### Deployment commands - -First-time setup: - -```sh -fly launch --no-deploy # creates the app on Fly -fly volumes create data --size 1 # creates a 1GB persistent volume -fly deploy # builds and deploys ``` -Subsequent deploys: - -```sh -fly deploy -``` - -Useful operations: - -```sh -fly ssh console # SSH into the running machine -fly logs # tail logs -sqlite3 /data/db.sqlite3 # access the database (from SSH) -ls /data/assets/ # inspect uploaded assets (from SSH) -``` - -### File summary - -New files: -- `Dockerfile` — multi-stage Docker build -- `fly.toml` — Fly.io configuration -- `.dockerignore` — Docker build context exclusions -- `.sqliterc` — SQLite CLI convenience config -- `scripts/start-app.js` — Node.js entry point with graceful shutdown - -Modified files: -- `svelte.config.js` — switch to `@sveltejs/adapter-node` -- `package.json` — add `@sveltejs/adapter-node`, remove `@sveltejs/adapter-auto` +Behavior: +- if `create === true` + - assert that the provided document id does not already exist + - persist as new page using that already-generated client id + - return `{ ok: true, document_id, created: true }` +- else: + - normal update -No application logic changes in this step — purely deployment infrastructure. +### Option B — add `create_document` and keep `save_document` +- `create_document(combined_doc)` +- `save_document(combined_doc)` -## Step 4a: video node type +**Preferred:** Option A +Reason: the save flow in the app is already unified. “First save creates, later saves update” maps naturally to a single save entry point, and with a client-generated nanoid there is no need for a separate id-allocation roundtrip. -**Goal:** users can paste or drop `.mp4` files into the editor. Videos display inline (autoplay, muted, looping) and go through the same asset pipeline as images — passthrough (no client-side processing), upload on save, serve from disk. The existing `image` property on container nodes is widened to accept `['image', 'video']` but **not renamed yet** — the property rename (`image` → `media`) is a separate step (4b) to keep this step focused. +## Decision 5: page browser data should come from a dedicated query -**Reference implementation:** the `image-resize` project already has working video upload and display. Use it as a reference for: +Add a new remote query, something like: -- **`src/lib/components/Video.svelte`** — autoplay with retry strategies (handles late hydration, multiple readiness events), click-to-fullscreen with unmute, iOS Safari fullscreen exit handling (`webkitbeginfullscreen`/`webkitendfullscreen`), and automatic resume to muted inline playback after exiting fullscreen. The `cursor: zoom-in` on inline-playing videos is a nice touch to adopt. -- **`src/routes/+page.svelte`** — `getVideoDimensions(file)` using `