# menu-admin Uploads, orders and publishes the customer-facing menus of a restaurant Site as photographed Pages, replacing the `scp`-a-JPG workflow. One instance per Site; `docs/plan.md` is the agreed design and `CONTEXT.md` is the glossary. ## Status Built: Site config, the Menu Set store, the image pipeline, the customer-facing renderer (landing page, Section pages, archived sets, media route) with the approved design, the admin (sign in, create a set, upload and reorder Pages, preview, publish, restore, delete), the `verify`/`repair` CLI, the container and the Ansible deployment as rootless Podman Quadlet units on `emily`, behind the shared Caddy unit. Not yet done: copying each Site's data across from `monoco` and moving DNS, which needs a human present. ## Deploying ```sh ./deploy.sh 2026-09-22 # format, types, tests, podman build+push, deploy SKIP_DEPLOY=1 ./deploy.sh 2026-09-22 # build and push only ``` The image is built with **podman** as a multi-arch manifest and pushed to `registry.tanshu.com/menu-admin` (`Makefile`, mirrored from the other apps on this machine). The Ansible deployment lives in `ansible/` in this repo, with one inventory entry per Site. It writes a rootless Podman **Quadlet** unit per Site and appends the Site's block to the shared Caddyfile, exactly as the sibling services in `ansible-playbooks` do; see `ansible/readme.md` for how a deploy runs and how to roll one back. ## Layout ``` src/config.ts Site config: brand, theme, seed Sections, limits src/storage/sections.ts Categories: the Site's own list, edited in the admin src/storage/identity.ts Name, tagline, icon and display switches, edited in the admin src/storage/manifest.ts Manifest type, validation, atomic JSON writes src/storage/sets.ts The Menu Set store: create, add Pages, publish, restore, delete src/storage/validate.ts Boot validation: fatal problems and warnings src/images/pipeline.ts sharp variants: Original, Viewing image, Thumbnail src/auth.ts scrypt password hash, session cookie, login throttle, CSRF src/render/ HTML builder, document shell, the customer pages, the admin src/routes/public.ts Landing, Section, archive and media routes src/routes/admin.ts Sign in, dashboard, editor, upload, publish, restore, delete src/server.ts buildApp (what tests drive) and main (what the container runs) public/ Hand-written CSS, ~2KB of progressive-enhancement JS, Archivo config/ Per-Site brand, theme, limits and the first category list test/ node:test suites docs/plan.md The agreed plan docs/operating.md Publishing, fixing and rolling back, for whoever operates it docs/adr/ Decisions worth remembering Containerfile The image, built by podman compose.yaml Local dev: podman compose deploy.sh Tests, build, push, deploy ansible/ The deployment: one rootless Quadlet unit per Site ``` The deployment is in `ansible/` here, beside the app it deploys, and is run from that directory by `deploy.sh`. ## Running it locally Node 24 or newer runs the TypeScript sources directly; there is no build step. ```sh npm install npm run seed # placeholder Pages in .dev (wipes .dev/data; for looking at the design) npm run dev # http://localhost:3000 against .dev ``` `npm run dev` first runs `scripts/dev-env.ts`, which creates `.dev/site.env` on first use with the password `menu-dev-password` (override with `DEV_ADMIN_PASSWORD=...`) and a random session secret, then starts the server with `--watch`. Sign in at . The file is gitignored and development only: the dev server runs with `SECURE_COOKIES=false` over http, and that password is a known constant. On the server the same file is written by `scripts/provision-site-env.sh` with a hash of the real password and mode 600. Tests and checks: ```sh npm test # node:test npm run typecheck # tsc --noEmit, scripts/ included npm run verify # integrity of .dev/data npm run check-site -- http://127.0.0.1:3000 # checks a running site from outside ``` ### Linting and formatting ```sh npm run lint # oxlint npm run lint:fix npm run format # prettier --write npm run format:check # what deploy.sh runs ``` **oxlint** lints the TypeScript and the JavaScript together. This project's `typescript@7` is the native port with no JavaScript compiler API, which every ESLint TypeScript parser needs — `typescript-eslint` requires a `>=4.8.4 <6.1.0` peer and refuses to install — and oxlint parses TypeScript without one, so it is the linter that works here. It runs the correctness category and takes about 20 ms over the whole repo. `.oxlintrc.json` turns four opinionated rules off, and oxlint's config takes no comments, so the reasoning lives here: `no-await-in-loop` because the loops that await are sequential on purpose (one photo at a time, memory bounded); `unicorn/consistent-function-scoping` because the helpers close over their route's dependencies; `unicorn/no-array-sort` because `toSorted()` is worth adopting but that is a refactor rather than a bug; and `oxc/no-map-spread` because clarity beats the micro-optimisation. Delete the line to see what a rule would say. `npm run typecheck` remains the deeper TypeScript gate (strict, with `noUnusedLocals`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes` and `erasableSyntaxOnly`). **Prettier** owns formatting (`.prettierrc.json`); `docs/` is excluded because those are hand-wrapped notes rather than code. `embeddedLanguageFormatting` is off on purpose: the renderers build HTML inside `html` tagged template literals, and letting Prettier reflow those would change the markup customers receive. Dragging Pages is the one interaction that only a browser can check, so there is a harness for it. It needs `geckodriver` on PATH and a running dev server: ```sh geckodriver --port 4444 & npm run check-dnd -- native # drags and asserts it persisted npm run check-dnd -- none # just reports affordances ``` ### In VS Code `.vscode/` holds launch configurations and tasks. | Configuration | What it does | | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | **Dev server (debug)** | runs `src/server.ts` with the dev environment, breakpoints live | | **Dev server (watch, no debugger)** | `npm run dev` in a terminal | | **Debug the open test file** | runs the file you have open under `node --test`, with child processes attached so breakpoints inside a test bind | | **Check the running site** | prompts for a URL and runs `scripts/check-site.ts` | Breakpoints land in the `.ts` files themselves: Node strips types in place, so line numbers are the ones you see. The debug configuration creates `.dev/site.env` first (`preLaunchTask`), so a fresh clone serves a working admin immediately. Tasks (Command Palette → _Run Task_) cover the same ground: seed, the dev server, tests, typecheck, verify and repair. `.dev`, `.tmp` and `.npm-cache` are hidden from the explorer, search and the file watcher, so a watched server does not restart on its own data writes. ## Who can do what `/` and `/
/` are public. `/admin` needs the password; a Draft's Pages are served only to a signed-in session, and Originals have no route at all. ## Storage model ``` data/ ├── originals/
//.. # kept, never served ├── drafts/
// # manifest.json + variants └── published/
// # manifest.json + variants ``` The directory decides a Menu Set's state; the manifest carries metadata only. `` is the valid-until date, else the publication date. The Current Menu Set for a Section is the published one with the newest `publishedAt`. Published sets are immutable: changing a menu means publishing a new set.