Prettier owns formatting: .prettierrc.json, format and format:check scripts, and a one-off pass over the repo so the check is green from the start. Embedded language formatting is off deliberately - the renderers build HTML in html tagged templates and letting Prettier reflow those would change the markup customers receive. docs/ is excluded because it is hand-wrapped prose, and shell scripts have no Prettier parser. ESLint covers the JavaScript: the two browser scripts, the build and check scripts, and its own config. no-var is scoped out for the browser scripts on purpose; they are plain ES5-shaped IIFEs and a hoisting difference would only show up in a browser. ESLint cannot lint the TypeScript here: typescript@7 is the native port with no JavaScript compiler API, and typescript-eslint requires a 4.8-6.1 peer, so it refuses to install and forcing it through would leave a parser that cannot read the compiler. tsc --noEmit stays the TypeScript gate; README records the two ways to change that. lint and format:check now run in deploy.sh, and VS Code formats on save with Prettier and surfaces ESLint findings.
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 playbook. Not yet done: the parallel-run deployment on monoco,
which needs a human present.
Deploying
./deploy.sh 2026-09-22 # tests, build, push to registry.tanshu.com
then the playbook in ansible-playbooks/menu-admin/ — its readme covers the
parallel run, the site.env step that gives the admin a password, and cutover.
After deploying, check the running site from the outside (read-only, exits non-zero on failure, so it can gate a cutover):
npm run check-site -- https://menu-new.hopsngrains.com
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
deploy.sh Tests, build, push
The Ansible playbook lives in ansible-playbooks/menu-admin/, beside the other
playbooks, because that is where it is run from.
Running it locally
Node 24 or newer runs the TypeScript sources directly; there is no build step.
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 http://localhost:3000/admin.
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:
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
npm run lint # eslint
npm run lint:fix
npm run format # prettier --write
npm run format:check # what CI and deploy.sh run
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.
ESLint does not lint the TypeScript, and cannot as things stand. This project
uses the native TypeScript port (typescript@7), which ships no JavaScript
compiler API — and every ESLint TypeScript parser needs one. typescript-eslint
declares typescript >=4.8.4 <6.1.0 as a peer and refuses to install; forcing it
through would leave a parser that cannot read the compiler it was given. The
TypeScript gate is therefore npm run typecheck (strict, with noUnusedLocals,
noUncheckedIndexedAccess, exactOptionalPropertyTypes and
erasableSyntaxOnly), and ESLint covers the JavaScript. To get ESLint over the
.ts files as well, either pin typescript@5.9 — which supports everything this
project uses — and add typescript-eslint, or add oxlint, which parses
TypeScript without the compiler API.
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:
geckodriver --port 4444 &
npm run check-dnd -- <set-id> native # drags and asserts it persisted
npm run check-dnd -- <set-id> 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 /<section>/ 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/<section>/<set-id>/<page-id>.<hash>.<ext> # kept, never served
├── drafts/<section>/<set-id>/ # manifest.json + variants
└── published/<section>/<slug>/ # manifest.json + variants
The directory decides a Menu Set's state; the manifest carries metadata only.
<slug> 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.