Menu Set store, image pipeline and Site config

The store keeps a Menu Set's state in the directory layout (drafts/ versus
published/) and its metadata in a manifest, with publishing as a single
directory rename. Editing is allowed on Drafts only, so a published menu can
never change under a customer. Stages 2 and 3 of docs/plan.md, with tests.
This commit is contained in:
tanshu committed 2026-09-22 21:15:00 +05:30
commit edea83228c
22 files changed
+3770

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
node_modules/
.npm-cache/
.tmp/
*.log
site.env
+68
View File
@@ -0,0 +1,68 @@
# Menu Publishing
Publishing the customer-facing menus of several brewery-restaurant Sites. A Site serves photographed pages of its printed menus, grouped into Sections and published as Menu Sets.
## Language
### Structure
**Site**:
One restaurant's customer-facing menu deployment, with its own brand, Sections and content. Each Site runs its own instance of the app.
_Avoid_: brand, tenant, deployment, instance
**Section**:
A top-level category of menu content that customers choose between, such as Food, Bar or What's On.
_Avoid_: type, category, tab, folder
**Menu Set**:
One complete set of Pages published together for a Section, with a label and an optional valid-until date.
_Avoid_: menu, version, batch, upload, album
**Page**:
One image within a Menu Set, in a deliberate order; usually a photographed page of a printed menu.
_Avoid_: sheet, slide, image, photo
**Poster**:
A Page whose content is an event or seasonal creative rather than a menu, published in the What's On Section.
_Avoid_: flyer, banner, creative
### Publication
**Draft**:
A Menu Set that has been uploaded but is not visible to customers.
**Current**:
The single published Menu Set per Section that customers see.
_Avoid_: live, active, main
**Archive**:
A Menu Set that was once Current and has been superseded. Reachable by direct URL, never listed in customer navigation.
_Avoid_: old menu, history, expired, past
**Publish**:
The act of making a Draft Current, which archives the Menu Set that was Current before it.
_Avoid_: release, deploy, go live
### Images
**Original**:
The uploaded image as stored, kept so that viewing images and thumbnails can be regenerated. Never served to customers.
_Avoid_: source, master, full-size
**Viewing image**:
The wide image shown on a Section page and opened by tap-to-zoom.
_Avoid_: preview, display image
**Thumbnail**:
The small image used only inside the admin.
_Avoid_: preview, grid image
### Surfaces
**Landing page**:
The Site root, listing the Sections that have a Current Menu Set.
_Avoid_: home, index
**Admin**:
The password-protected area where a Menu Set is uploaded, ordered, previewed and published.
_Avoid_: dashboard, CMS, back office
+48
View File
@@ -0,0 +1,48 @@
# 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 so far: Site config parsing, the Menu Set store and the image pipeline,
with tests. Not yet built: the public renderer, the admin, the container and the
Ansible role.
## Layout
```
src/config.ts Site config: brand, theme, Sections, limits
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
test/ node:test suites
docs/plan.md The agreed plan
docs/adr/ Decisions worth remembering
```
## Working on it
Node 24 or newer runs the TypeScript sources directly.
```sh
npm install
npm test # node:test
npm run typecheck # tsc --noEmit
```
## 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.
@@ -0,0 +1,5 @@
# Menus are served by the app, not by nginx
Each Site's `nginx:alpine` container and its read-only bind mount are retired: the app renders the menu HTML and serves the images itself, with Caddy reverse-proxying `menu.<site>` to it. The read-only mount was the reason menus could only be changed by `scp`, and keeping a file server alongside a dynamic renderer would leave two views of one directory free to drift.
**Considered Options**: a hybrid where Caddy routes HTML to the app and image paths to nginx; and an app that renders HTML only and proxies image bytes to nginx. Both were rejected for preserving a second moving part whose only job is reading files the app already owns.
@@ -0,0 +1,5 @@
# Menu metadata lives in JSON manifests, not a database
Menu Sets, their Page order, labels, dates and state are recorded in JSON manifests beside the images rather than in SQLite. The state is small and single-writer, and a manifest the owner can read and repair by hand was preferred over a database file that needs a tool to inspect.
**Consequences**: invariants such as "exactly one Current Menu Set per Section" are enforced by the app at boot rather than by a storage engine, and `publish`/`archive` serialize behind an in-process lock instead of a transaction. Publication state is carried by the directory layout, so manifests can be rebuilt from disk if one is lost.
@@ -0,0 +1,5 @@
# Menu pages are rendered per request
The landing page and Section pages are rendered by the app on each request instead of being generated as static HTML at publish time. Publication becomes a single directory rename, and a Draft is previewed through the same renderer customers use, at the cost of every customer page view depending on the app being up.
**Considered Options**: static generation at publish time, rejected because it would mean two renderers to keep in step and a separate path for previewing Drafts.
@@ -0,0 +1,5 @@
# One app instance per Site
The same app is deployed once per Site — hopsngrains, hngmohali and greatbear — each with its own configuration, admin password and data directory, rather than one instance serving all Sites with a Site dimension in its data model. The three menu deployments share a serving pattern but not their brands, Sections or content, and separate instances keep one Site's mistake away from the others.
**Considered Options**: a single multi-Site app with Site above Section in the data model, rejected as a retrofit risk with no benefit at three Sites.
@@ -0,0 +1,5 @@
# Sections come from configuration, not from the admin
A Site's Sections are declared in its config file and change only with a redeploy; the admin manages the Menu Sets inside Sections and cannot create, rename or reorder Sections. Sections are few and stable, and keeping them out of the admin removed a whole class of editing, reordering and deletion edge cases from the content model.
**Consequences**: adding a Section to a live Site is a tracked change in `ansible-playbooks` rather than a click, and the admin's surface stays limited to publishing content.
+136
View File
@@ -0,0 +1,136 @@
# Menu app — agreed plan
Replaces the `scp`-a-JPG workflow for `menu.hopsngrains.com` with a small self-hosted app that uploads, orders, previews and publishes Menu Sets. Verified against the current environment by read-only reconnaissance of `monoco`.
## What exists today
- Caddy (80/443, TLS) → `reverse_proxy nginx-menu.hopsngrains.com:80` → `nginx:alpine` with `-v /var/lib/menu.hopsngrains.com:/usr/share/nginx/html:ro`.
- Each Section is a hand-written `index.html` listing one `<img>` per JPG, with a vendored copy of Materialize CSS/JS in every Section folder.
- The read-only mount is why content can only be changed over SSH. `static-menu/Caddyfile` is dead config from the retired `caddy-menu` playbook and is read by nothing.
- The same playbook serves three Sites: `menu.hopsngrains.com` (319M, this folder), `menu.hngmohali.com` (208M), `menu.greatbear.in` (405M). Remote disk: 15G free, 82% used.
- `nginx-menu/` is untracked in the `ansible-playbooks` repo (`git.tanshu.com:tanshu/playbooks`). Sibling projects build an image locally, push to `registry.tanshu.com`, then run `ansible-playbook -e tag=`.
## Decisions
| Question | Decision |
| --- | --- |
| Upload path | Small self-hosted app on the same host, replacing `scp` |
| Serving | The app serves HTML **and** images; nginx is retired per Site (ADR 0001) |
| Rendering | Per request, server-rendered HTML (ADR 0003) |
| Stack | Node + Fastify + `sharp`, one container |
| Metadata | JSON manifests, state encoded in the directory layout (ADR 0002) |
| Scope | One app instance per Site (ADR 0004); other two Sites migrate later |
| Sections | Per-Site config in git; not editable in the admin (ADR 0005) |
| Lifecycle | Draft → Current → Archive; publishing auto-archives the previous set; archives unlisted but reachable |
| Auth | Single shared password per Site; app login; hash + session secret in an untracked `.env` on the server |
| Images | 1600px viewing WebP + JPEG fallback, 480px admin-only thumbnails, Originals kept and never served |
| Logging | JSON to stdout only; no Prometheus, no Promtail |
| Backups | Owner's existing backups; out of scope |
## Domain
`CONTEXT.md` is the glossary. Sections for this Site: Food, Bar, Wine, Straight Drinks, Delivery, What's On. One Current Menu Set per Section; a Section with no Current set does not appear on the landing page.
## Architecture
### Topology
```
caddy (80/443, TLS) ──reverse_proxy──▶ menu-app.hopsngrains.com:3000
├── renders landing, Section, archive pages
└── serves viewing images
(nginx-menu.hopsngrains.com retired after cutover)
```
### Storage
```
/var/lib/menu-app.hopsngrains.com/
├── site.env # ADMIN_PASSWORD_HASH, SESSION_SECRET (mode 600, untracked)
└── data/
├── originals/<section>/<set-id>/<page-id>.<hash>.<ext> # never served
├── drafts/<section>/<set-id>/ # manifest.json + variants
└── published/<section>/<slug>/ # manifest.json + variants
```
- `<slug>` is the valid-until date when set, else the publication date (`YYYY-MM-DD`), suffixed `-2` on collision.
- Directory encodes state: `drafts/` is invisible to customers, `published/` is reachable.
- `manifest.json` holds `id`, `section`, `label`, `validUntil`, `createdAt`, `publishedAt` and the ordered Page list; each Page carries its own `id`, content hash, source extension, the Viewing image's dimensions and the uploaded filename.
- **Current** is the published set for a Section with the newest `publishedAt`; everything else published is an Archive. No separate current pointer exists to drift.
- Writes are temp file + `fsync` + rename, and publishing is a directory rename, so a crash leaves either a Draft or a published set. `publish`/`archive` serialize behind one in-process lock. Boot validation checks that every manifest matches its directory and that Page files exist.
### Routes
| Route | Purpose |
| --- | --- |
| `GET /` | Landing page: Sections with a Current set, text rows |
| `GET /<section>/` | Current Menu Set |
| `GET /<section>/<slug>/` | Archived Menu Set (unlisted, still reachable) |
| `GET /<section>/#page-N` | Deep link to one Page |
| `GET /media/<page-id>.<hash>.webp\|.jpg` | Viewing image, `immutable` cache; keyed by Page id so the URL survives publish |
| `GET /admin/*` | Admin, session required, `noindex` |
| `GET /admin/sets/<id>/preview` | Draft rendered exactly as customers would see it |
Unknown Sections and Sections without a Current set 404 rather than redirect, so a stale printed link fails loudly instead of showing the wrong menu. Media for a Draft is served only to a session holder, which is what keeps Drafts unreachable in practice.
### Image pipeline
- Accept JPEG/PNG/WebP up to 30MB, up to 40 Pages per set; anything else is rejected with a message naming the file.
- The browser pre-shrinks to 3000px on the long edge before upload (fast over restaurant wifi); if the browser cannot decode the file, the file is uploaded untouched.
- Server: apply EXIF orientation, strip all other metadata, write the Original, generate a 1600px WebP (q80) + JPEG (q82) Viewing image and a 480px WebP Thumbnail.
- Variant filenames carry a short content hash and are keyed by Page id, so a replaced Page cannot be served from a stale cache and a published URL keeps working across a re-publish.
### Admin
Log in; see each Section with its Current set and state; create a Menu Set (choose Section, add Pages, drag to reorder, set label and optional valid-until, thumbnail grid); preview; publish (archives the previous set); restore an Archive as Current; delete a Draft or Archive. No user management, no scheduling, no Section editing, no password-change flow (edit `site.env` and restart).
Protection: a `scrypt` hash (from `node:crypto`, so no native build dependency) in `site.env`, httpOnly + Secure + SameSite=Lax session cookie, login throttled per IP, `noindex` on all admin routes. Originals and Drafts have no public route at all.
### Customer design
Text-led landing page; Sections as tap-list rows with copper hairlines; no hero photograph, no cards, no shadows.
```
┌────────────────────────────┐ ┌────────────────────────────┐
│ HOPS N GRAINS │ │ HOPS N GRAINS ⌂ │
│ Food 12 pages │ │ Bar Food Wine Drinks │
│ ────────────────────────── │ │ ▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░ │
│ Bar 20 pages │ ├────────────────────────────┤
│ ────────────────────────── │ │ ┌──────────────────────┐ │
│ Wine │ │ │ menu page photo │ │
└────────────────────────────┘ │ └──────────────────────┘ │
│ 3 / 12 │
└────────────────────────────┘
```
- Colour: `#14181b` steel, `#1f262b` rail, `#f2efe9` paper, `#0088aa` logo teal, `#b2703b` copper.
- Type: self-hosted variable **Archivo** — expanded 600 for the brand and Section names, normal width for body and UI, tabular numerals for the counter.
- The one memorable element: a copper "pour" line in the sticky Section rail that fills as you scroll, doubling as position in a 20-page menu.
- Tapping the page counter opens a text page index for jumping; a quiet "Older menus" line at the foot of a Section page lists Archives.
- Roughly 2KB of progressive-enhancement JS for the lightbox and counter. Visible focus, `alt` text ("Bar menu, page 3 of 12"), `prefers-reduced-motion` respected, self-hosted fonts so it works on restaurant wifi.
## Repos, build and deploy
- New repo `menu-admin` on `git.tanshu.com`: app source, `Dockerfile`, `compose.yaml` for local dev against a `fixtures/` folder, `docs/` (this plan, `CONTEXT.md`, the ADRs), and `node --test` tests for the storage and image layers.
- Build locally and push `registry.tanshu.com/menu-admin:<tag>`, matching the sibling `deploy.sh` / `make build-production TAG=` convention.
- New `menu-admin` playbook in `ansible-playbooks` with per-Site vars (`vars/hopsngrains.yml` first, the other two stubbed): roles `network`, `menu-app` (pull image, create `/var/lib/menu-app.<site>`, start container), `caddy` (snippet upsert).
- The existing `roles/caddy` only *adds* a snippet when missing, which cannot change a proxy target; it is fixed to update an existing managed block in place.
- One-time human step on the server: a `scripts/provision-site-env.sh` prompts for the admin password, hashes it, and writes `/var/lib/menu-app.<site>/site.env` (mode 600, never in git).
- A small CLI: `verify` reports integrity problems in the data directory, `repair` regenerates missing variants from the Originals.
## Sequence
1. Commit `nginx-menu/` to git so the current state is reproducible and revertible; create the `menu-admin` repo and move `CONTEXT.md`, `docs/`, and the ADRs into it.
2. Storage layer: config, manifest read/write, atomic writes, boot validation, tests.
3. Image pipeline: sharp variants, content-hashed filenames, tests.
4. Public renderer: landing, Section and archive routes, design tokens and CSS.
5. Admin: login, upload with browser pre-shrink, ordering, preview, publish/restore/delete.
6. Container, compose dev fixtures, Ansible role, deploy to `menu-new.hopsngrains.com`, verify from a phone on restaurant wifi.
7. Cutover: re-upload the current Food, Bar and Delivery menus, create What's On from the posters, switch `menu.hopsngrains.com` to the app, stop the old nginx container.
8. After a week: remove the old container and `/var/lib/menu.hopsngrains.com`, delete the dated folders, drop the dead `Caddyfile`, and reduce this folder to dev fixtures.
Deleting the old 319MB directory is the only irreversible step, so it is last.
## Out of scope
Backups (owner already runs them), Prometheus metrics, Promtail/Loki integration, multi-user accounts, scheduled publishing, admin-editable Sections, migrating `menu.hngmohali.com` and `menu.greatbear.in`, and git history for the image content.
+1766
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "menu-admin",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"scripts": {
"start": "node src/server.ts",
"dev": "node --watch src/server.ts",
"typecheck": "tsc --noEmit",
"test": "node --test test/*.test.ts"
},
"dependencies": {
"@fastify/cookie": "^11.1.2",
"@fastify/multipart": "^10.1.2",
"fastify": "^5.12.5",
"sharp": "^0.35.4"
},
"devDependencies": {
"@types/node": "^26.6.2",
"typescript": "^7.0.2"
}
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Site configuration: what makes one deployment of this app a particular
* restaurant's menu site. Sections come from here and never from the admin.
*/
export interface SectionConfig {
slug: string;
label: string;
shortLabel: string;
note: string | null;
order: number;
}
export interface ThemeConfig {
steel: string;
rail: string;
paper: string;
accent: string;
copper: string;
}
export interface BrandConfig {
name: string;
tagline: string | null;
logo: string | null;
}
export interface LimitsConfig {
maxUploadBytes: number;
maxPagesPerSet: number;
}
export interface SiteConfig {
site: string;
brand: BrandConfig;
theme: ThemeConfig;
sections: SectionConfig[];
limits: LimitsConfig;
}
export class ConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConfigError';
}
}
/** Slugs the router claims for itself; a Section may not use one. */
export const RESERVED_SLUGS = new Set([
'admin',
'media',
'assets',
'healthz',
'favicon.ico',
'robots.txt',
]);
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const HEX_COLOUR = /^#[0-9a-f]{6}$/i;
const DEFAULT_LIMITS: LimitsConfig = {
maxUploadBytes: 30 * 1024 * 1024,
maxPagesPerSet: 40,
};
const DEFAULT_THEME: ThemeConfig = {
steel: '#14181b',
rail: '#1f262b',
paper: '#f2efe9',
accent: '#0088aa',
copper: '#b2703b',
};
function fail(message: string): never {
throw new ConfigError(message);
}
function requireString(value: unknown, where: string): string {
if (typeof value !== 'string' || value.trim() === '') {
fail(`${where} must be a non-empty string`);
}
return value;
}
function optionalString(value: unknown, where: string): string | null {
if (value === undefined || value === null) return null;
if (typeof value !== 'string') fail(`${where} must be a string when present`);
return value;
}
function requireObject(value: unknown, where: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
fail(`${where} must be an object`);
}
return value as Record<string, unknown>;
}
function optionalPositiveInt(value: unknown, where: string, fallback: number): number {
if (value === undefined || value === null) return fallback;
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
fail(`${where} must be a positive whole number`);
}
return value;
}
function parseTheme(value: unknown): ThemeConfig {
if (value === undefined || value === null) return { ...DEFAULT_THEME };
const raw = requireObject(value, 'theme');
const theme = { ...DEFAULT_THEME };
for (const key of Object.keys(DEFAULT_THEME) as (keyof ThemeConfig)[]) {
const candidate = raw[key];
if (candidate === undefined || candidate === null) continue;
if (typeof candidate !== 'string' || !HEX_COLOUR.test(candidate)) {
fail(`theme.${key} must be a #rrggbb colour`);
}
theme[key] = candidate;
}
return theme;
}
function parseSections(value: unknown): SectionConfig[] {
if (!Array.isArray(value) || value.length === 0) {
fail('sections must be a non-empty array');
}
const seenSlugs = new Set<string>();
const seenOrders = new Set<number>();
const sections = value.map((entry, index) => {
const raw = requireObject(entry, `sections[${index}]`);
const slug = requireString(raw['slug'], `sections[${index}].slug`);
if (!SLUG.test(slug)) {
fail(`sections[${index}].slug ${JSON.stringify(slug)} must be lower-case words joined by dashes`);
}
if (RESERVED_SLUGS.has(slug)) {
fail(`sections[${index}].slug ${JSON.stringify(slug)} is reserved by the router`);
}
if (seenSlugs.has(slug)) fail(`duplicate Section slug ${JSON.stringify(slug)}`);
seenSlugs.add(slug);
const label = requireString(raw['label'], `sections[${index}].label`);
const order = raw['order'];
if (typeof order !== 'number' || !Number.isSafeInteger(order)) {
fail(`sections[${index}].order must be a whole number`);
}
if (seenOrders.has(order)) fail(`duplicate Section order ${order}`);
seenOrders.add(order);
return {
slug,
label,
shortLabel: optionalString(raw['shortLabel'], `sections[${index}].shortLabel`) ?? label,
note: optionalString(raw['note'], `sections[${index}].note`),
order,
};
});
return sections.sort((a, b) => a.order - b.order);
}
export function parseSiteConfig(raw: unknown): SiteConfig {
const root = requireObject(raw, 'config');
const site = requireString(root['site'], 'site');
const brandRaw = requireObject(root['brand'], 'brand');
const brand: BrandConfig = {
name: requireString(brandRaw['name'], 'brand.name'),
tagline: optionalString(brandRaw['tagline'], 'brand.tagline'),
logo: optionalString(brandRaw['logo'], 'brand.logo'),
};
const limitsRaw = root['limits'] === undefined || root['limits'] === null
? {}
: requireObject(root['limits'], 'limits');
return {
site,
brand,
theme: parseTheme(root['theme']),
sections: parseSections(root['sections']),
limits: {
maxUploadBytes: optionalPositiveInt(
limitsRaw['maxUploadBytes'],
'limits.maxUploadBytes',
DEFAULT_LIMITS.maxUploadBytes,
),
maxPagesPerSet: optionalPositiveInt(
limitsRaw['maxPagesPerSet'],
'limits.maxPagesPerSet',
DEFAULT_LIMITS.maxPagesPerSet,
),
},
};
}
export function sectionBySlug(config: SiteConfig, slug: string): SectionConfig | undefined {
return config.sections.find((section) => section.slug === slug);
}
+105
View File
@@ -0,0 +1,105 @@
/**
* The image pipeline: turn an uploaded photo into the three representations a
* Page needs — the Original (kept, never served), a Viewing image and a
* Thumbnail. Metadata is stripped; EXIF orientation is applied first so the
* pixels are already upright.
*/
import { createHash } from 'node:crypto';
import sharp from 'sharp';
import type { ImageExt } from '../storage/manifest.ts';
import type { ImageDerivatives, ImagePipeline } from '../storage/sets.ts';
export const VIEW_WIDTH = 1600;
export const THUMB_WIDTH = 480;
export const MAX_SOURCE_EDGE = 3000;
/** Accepted upload formats, mapped to the extension we store. */
export const ACCEPTED_FORMATS: Readonly<Record<string, ImageExt>> = {
jpeg: 'jpg',
png: 'png',
webp: 'webp',
};
export const ACCEPTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;
export class UnsupportedImageError extends Error {
constructor(message: string) {
super(message);
this.name = 'UnsupportedImageError';
}
}
export interface ImagePipelineOptions {
viewWidth?: number;
thumbWidth?: number;
webpQuality?: number;
jpegQuality?: number;
thumbQuality?: number;
}
export function contentHash(data: Buffer): string {
return createHash('sha256').update(data).digest('hex').slice(0, 12);
}
export function createImagePipeline(options: ImagePipelineOptions = {}): ImagePipeline {
const viewWidth = options.viewWidth ?? VIEW_WIDTH;
const thumbWidth = options.thumbWidth ?? THUMB_WIDTH;
const webpQuality = options.webpQuality ?? 80;
const jpegQuality = options.jpegQuality ?? 82;
const thumbQuality = options.thumbQuality ?? 75;
return {
async process(data: Buffer, originalName: string): Promise<ImageDerivatives> {
const source = sharp(data, { failOn: 'error' });
let format: string | undefined;
try {
format = (await source.metadata()).format;
} catch (error) {
throw new UnsupportedImageError(
`${originalName} could not be read as an image: ${(error as Error).message}`,
);
}
const sourceExt = format === undefined ? undefined : ACCEPTED_FORMATS[format];
if (sourceExt === undefined) {
const found = format ?? 'unknown';
throw new UnsupportedImageError(
`${originalName} is ${found}; upload a JPEG, PNG or WebP`,
);
}
// .rotate() with no angle auto-orients from EXIF; omitting withMetadata()
// strips the tag afterwards.
const upright = sharp(data, { failOn: 'error' }).rotate();
const view = await upright
.clone()
.resize({ width: viewWidth, withoutEnlargement: true })
.webp({ quality: webpQuality })
.toBuffer({ resolveWithObject: true });
const jpeg = await sharp(data, { failOn: 'error' })
.rotate()
.resize({ width: viewWidth, withoutEnlargement: true })
.jpeg({ quality: jpegQuality, mozjpeg: true })
.toBuffer();
const thumb = await sharp(data, { failOn: 'error' })
.rotate()
.resize({ width: thumbWidth, withoutEnlargement: true })
.webp({ quality: thumbQuality })
.toBuffer();
return {
sourceExt,
hash: contentHash(data),
// The Viewing image's dimensions: what a browser needs to reserve space.
width: view.info.width,
height: view.info.height,
original: data,
webp: view.data,
jpeg,
thumb,
};
},
};
}
+194
View File
@@ -0,0 +1,194 @@
/**
* Manifests are the JSON half of the storage model: the directory layout
* carries a Menu Set's state, the manifest carries its metadata.
*/
import { open, readFile, rename } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
export const MANIFEST_FILE = 'manifest.json';
export type ImageExt = 'jpg' | 'png' | 'webp';
export interface PageRecord {
/** Stable across publish: media URLs are keyed by it. */
id: string;
sourceExt: ImageExt;
/** Short content hash, so a replaced Page cannot be served from a stale cache. */
hash: string;
width: number;
height: number;
originalName: string;
}
export interface SetManifest {
id: string;
section: string;
label: string;
validUntil: string | null;
createdAt: string;
/** Set when published; the newest publishedAt per Section is Current. Null while a Draft. */
publishedAt: string | null;
pages: PageRecord[];
}
export class ManifestError extends Error {
constructor(message: string) {
super(message);
this.name = 'ManifestError';
}
}
export function newId(): string {
return randomUUID();
}
export function variantFileNames(page: PageRecord): { webp: string; jpeg: string; thumb: string } {
return {
webp: `${page.id}.${page.hash}.webp`,
jpeg: `${page.id}.${page.hash}.jpg`,
thumb: `thumb-${page.id}.${page.hash}.webp`,
};
}
export function originalFileName(page: PageRecord): string {
return `${page.id}.${page.hash}.${page.sourceExt}`;
}
function isIsoDate(value: unknown): value is string {
return typeof value === 'string' && !Number.isNaN(Date.parse(value));
}
function isCalendarDate(value: unknown): value is string {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
return !Number.isNaN(Date.parse(`${value}T00:00:00Z`));
}
const EXTENSIONS: readonly ImageExt[] = ['jpg', 'png', 'webp'];
function parsePage(raw: unknown, where: string): PageRecord {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
throw new ManifestError(`${where} must be an object`);
}
const page = raw as Record<string, unknown>;
const id = page['id'];
const hash = page['hash'];
const sourceExt = page['sourceExt'];
const width = page['width'];
const height = page['height'];
const originalName = page['originalName'];
if (typeof id !== 'string' || id === '') throw new ManifestError(`${where}.id must be a string`);
if (typeof hash !== 'string' || !/^[0-9a-f]{8,}$/.test(hash)) {
throw new ManifestError(`${where}.hash must be a lowercase hex digest`);
}
if (typeof sourceExt !== 'string' || !EXTENSIONS.includes(sourceExt as ImageExt)) {
throw new ManifestError(`${where}.sourceExt must be one of ${EXTENSIONS.join(', ')}`);
}
if (typeof width !== 'number' || !Number.isSafeInteger(width) || width <= 0) {
throw new ManifestError(`${where}.width must be a positive whole number`);
}
if (typeof height !== 'number' || !Number.isSafeInteger(height) || height <= 0) {
throw new ManifestError(`${where}.height must be a positive whole number`);
}
if (typeof originalName !== 'string') throw new ManifestError(`${where}.originalName must be a string`);
return { id, sourceExt: sourceExt as ImageExt, hash, width, height, originalName };
}
export function parseManifest(raw: unknown, where: string): SetManifest {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
throw new ManifestError(`${where} must be an object`);
}
const manifest = raw as Record<string, unknown>;
const id = manifest['id'];
const section = manifest['section'];
const label = manifest['label'];
const validUntil = manifest['validUntil'];
const createdAt = manifest['createdAt'];
const publishedAt = manifest['publishedAt'];
const pages = manifest['pages'];
if (typeof id !== 'string' || id === '') throw new ManifestError(`${where}.id must be a string`);
if (typeof section !== 'string' || section === '') {
throw new ManifestError(`${where}.section must be a string`);
}
if (typeof label !== 'string' || label.trim() === '') {
throw new ManifestError(`${where}.label must be a non-empty string`);
}
if (validUntil !== null && !isCalendarDate(validUntil)) {
throw new ManifestError(`${where}.validUntil must be null or a YYYY-MM-DD date`);
}
if (!isIsoDate(createdAt)) throw new ManifestError(`${where}.createdAt must be an ISO timestamp`);
if (publishedAt !== null && !isIsoDate(publishedAt)) {
throw new ManifestError(`${where}.publishedAt must be null or an ISO timestamp`);
}
if (!Array.isArray(pages)) throw new ManifestError(`${where}.pages must be an array`);
const parsedPages = pages.map((page, index) => parsePage(page, `${where}.pages[${index}]`));
const ids = new Set<string>();
for (const page of parsedPages) {
if (ids.has(page.id)) throw new ManifestError(`${where} has two Pages with id ${page.id}`);
ids.add(page.id);
}
return {
id,
section,
label,
validUntil: validUntil === null ? null : validUntil,
createdAt,
publishedAt: publishedAt === null ? null : publishedAt,
pages: parsedPages,
};
}
export async function readManifest(dir: string): Promise<SetManifest> {
const file = path.join(dir, MANIFEST_FILE);
let text: string;
try {
text = await readFile(file, 'utf8');
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') throw new ManifestError(`${file} does not exist`);
throw error;
}
let raw: unknown;
try {
raw = JSON.parse(text);
} catch (error) {
throw new ManifestError(`${file} is not valid JSON: ${(error as Error).message}`);
}
return parseManifest(raw, file);
}
/**
* Writes JSON beside the target and renames it into place, so a reader never
* sees a half-written manifest. The directory fsync is best-effort: some
* filesystems refuse to open a directory for this.
*/
export async function writeJsonAtomic(file: string, value: unknown): Promise<void> {
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
const handle = await open(tmp, 'w');
try {
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`);
await handle.sync();
} finally {
await handle.close();
}
await rename(tmp, file);
const dir = path.dirname(file);
try {
const dirHandle = await open(dir, 'r');
try {
await dirHandle.sync();
} finally {
await dirHandle.close();
}
} catch {
// Directory fsync is a durability nicety, not a correctness requirement.
}
}
export async function writeManifest(dir: string, manifest: SetManifest): Promise<void> {
await writeJsonAtomic(path.join(dir, MANIFEST_FILE), manifest);
}
+405
View File
@@ -0,0 +1,405 @@
/**
* The Menu Set store. State lives in the directory layout (`drafts/` is
* invisible to customers, `published/` is reachable); the manifest carries
* metadata only. Editing is allowed on Drafts alone, so a published URL can
* never change under a customer's feet.
*/
import { mkdir, readdir, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
MANIFEST_FILE,
ManifestError,
newId,
originalFileName,
readManifest,
variantFileNames,
writeManifest,
type ImageExt,
type PageRecord,
type SetManifest,
} from './manifest.ts';
export type SetState = 'draft' | 'published';
export class StoredSet {
readonly manifest: SetManifest;
readonly state: SetState;
readonly dir: string;
readonly slug: string | null;
constructor(manifest: SetManifest, state: SetState, dir: string, slug: string | null) {
this.manifest = manifest;
this.state = state;
this.dir = dir;
this.slug = slug;
}
get id(): string {
return this.manifest.id;
}
get section(): string {
return this.manifest.section;
}
}
export interface ImageDerivatives {
sourceExt: ImageExt;
hash: string;
width: number;
height: number;
original: Buffer;
webp: Buffer;
jpeg: Buffer;
thumb: Buffer;
}
export interface ImagePipeline {
process(data: Buffer, originalName: string): Promise<ImageDerivatives>;
}
export interface SitePaths {
dataDir: string;
originals: string;
drafts: string;
published: string;
}
export function sitePaths(dataDir: string): SitePaths {
return {
dataDir,
originals: path.join(dataDir, 'originals'),
drafts: path.join(dataDir, 'drafts'),
published: path.join(dataDir, 'published'),
};
}
export class StoreError extends Error {
readonly status: number;
constructor(message: string, status = 400) {
super(message);
this.name = 'StoreError';
this.status = status;
}
}
export interface SetStoreOptions {
dataDir: string;
pipeline: ImagePipeline;
/** Configured Section slugs; a set may only belong to one of these. */
sections: readonly string[];
maxPagesPerSet: number;
timeZone?: string;
now?: () => Date;
}
const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/;
async function listDirectoryNames(dir: string): Promise<string[]> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
}
async function rmrf(target: string): Promise<void> {
await rm(target, { recursive: true, force: true });
}
export class SetStore {
readonly paths: SitePaths;
private readonly pipeline: ImagePipeline;
private readonly sections: readonly string[];
private readonly maxPagesPerSet: number;
private readonly timeZone: string;
private readonly now: () => Date;
private lock: Promise<unknown> = Promise.resolve();
constructor(options: SetStoreOptions) {
this.paths = sitePaths(options.dataDir);
this.pipeline = options.pipeline;
this.sections = options.sections;
this.maxPagesPerSet = options.maxPagesPerSet;
this.timeZone = options.timeZone ?? 'Asia/Kolkata';
this.now = options.now ?? (() => new Date());
}
/** Serialises every mutation; the app is single-process, so this is enough. */
private exclusive<T>(fn: () => Promise<T>): Promise<T> {
const run = this.lock.then(fn, fn);
this.lock = run.then(
() => undefined,
() => undefined,
);
return run;
}
calendarDate(date: Date = this.now()): string {
// en-CA formats as YYYY-MM-DD.
return new Intl.DateTimeFormat('en-CA', {
timeZone: this.timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
}
async list(): Promise<StoredSet[]> {
const sets: StoredSet[] = [];
for (const state of ['draft', 'published'] as const) {
const base = state === 'draft' ? this.paths.drafts : this.paths.published;
for (const section of await listDirectoryNames(base)) {
for (const key of await listDirectoryNames(path.join(base, section))) {
const dir = path.join(base, section, key);
sets.push(
new StoredSet(await readManifest(dir), state, dir, state === 'published' ? key : null),
);
}
}
}
return sets.sort((a, b) => a.manifest.createdAt.localeCompare(b.manifest.createdAt));
}
async get(setId: string): Promise<StoredSet> {
const found = (await this.list()).find((set) => set.manifest.id === setId);
if (!found) throw new StoreError(`no such Menu Set: ${setId}`, 404);
return found;
}
/** The Current Menu Set per Section: the published set with the newest publishedAt. */
async currentBySection(): Promise<Map<string, StoredSet>> {
const current = new Map<string, StoredSet>();
for (const set of await this.list()) {
if (set.state !== 'published') continue;
const existing = current.get(set.manifest.section);
if (!existing || newerThan(set, existing)) current.set(set.manifest.section, set);
}
return current;
}
async archives(section: string): Promise<StoredSet[]> {
const current = (await this.currentBySection()).get(section);
return (await this.list())
.filter(
(set) =>
set.state === 'published' &&
set.manifest.section === section &&
set.manifest.id !== current?.manifest.id,
)
.sort((a, b) => (b.manifest.publishedAt ?? '').localeCompare(a.manifest.publishedAt ?? ''));
}
async create(
section: string,
input: { label: string; validUntil?: string | null },
): Promise<StoredSet> {
if (!this.sections.includes(section)) {
throw new StoreError(`unknown Section: ${section}`);
}
const label = input.label.trim();
if (label === '') throw new StoreError('a Menu Set needs a label');
const validUntil = input.validUntil ?? null;
if (validUntil !== null && !CALENDAR_DATE.test(validUntil)) {
throw new StoreError('validUntil must be a YYYY-MM-DD date');
}
return this.exclusive(async () => {
const id = newId();
const dir = path.join(this.paths.drafts, section, id);
await mkdir(dir, { recursive: true });
const manifest: SetManifest = {
id,
section,
label,
validUntil,
createdAt: this.now().toISOString(),
publishedAt: null,
pages: [],
};
await writeManifest(dir, manifest);
return new StoredSet(manifest, 'draft', dir, null);
});
}
async addPage(
setId: string,
input: { data: Buffer; originalName: string },
): Promise<PageRecord> {
return this.exclusive(async () => {
const set = await this.requireDraft(setId);
if (set.manifest.pages.length >= this.maxPagesPerSet) {
throw new StoreError(`a Menu Set holds at most ${this.maxPagesPerSet} Pages`, 409);
}
const derivatives = await this.pipeline.process(input.data, input.originalName);
const page: PageRecord = {
id: newId(),
sourceExt: derivatives.sourceExt,
hash: derivatives.hash,
width: derivatives.width,
height: derivatives.height,
originalName: input.originalName,
};
const names = variantFileNames(page);
await mkdir(path.join(set.dir), { recursive: true });
await writeFile(path.join(set.dir, names.webp), derivatives.webp);
await writeFile(path.join(set.dir, names.jpeg), derivatives.jpeg);
await writeFile(path.join(set.dir, names.thumb), derivatives.thumb);
const originals = this.originalsDir(set);
await mkdir(originals, { recursive: true });
await writeFile(path.join(originals, originalFileName(page)), derivatives.original);
set.manifest.pages.push(page);
await writeManifest(set.dir, set.manifest);
return page;
});
}
async removePage(setId: string, pageId: string): Promise<void> {
await this.exclusive(async () => {
const set = await this.requireDraft(setId);
const page = set.manifest.pages.find((candidate) => candidate.id === pageId);
if (!page) throw new StoreError(`no such Page: ${pageId}`, 404);
set.manifest.pages = set.manifest.pages.filter((candidate) => candidate.id !== pageId);
const names = variantFileNames(page);
for (const name of [names.webp, names.jpeg, names.thumb]) {
await rmrf(path.join(set.dir, name));
}
await rmrf(path.join(this.originalsDir(set), originalFileName(page)));
await writeManifest(set.dir, set.manifest);
});
}
async reorder(setId: string, pageIds: readonly string[]): Promise<void> {
await this.exclusive(async () => {
const set = await this.requireDraft(setId);
const before = set.manifest.pages.map((page) => page.id);
const after = [...pageIds];
if (before.length !== after.length) {
throw new StoreError('reorder must name every Page in the set exactly once');
}
const seen = new Set<string>();
for (const id of after) {
if (!before.includes(id)) throw new StoreError(`no such Page: ${id}`, 404);
if (seen.has(id)) throw new StoreError(`Page named twice: ${id}`);
seen.add(id);
}
const byId = new Map(set.manifest.pages.map((page) => [page.id, page]));
set.manifest.pages = after.map((id) => byId.get(id)!);
await writeManifest(set.dir, set.manifest);
});
}
async update(
setId: string,
patch: { label?: string; validUntil?: string | null },
): Promise<void> {
await this.exclusive(async () => {
const set = await this.requireDraft(setId);
if (patch.label !== undefined) {
const label = patch.label.trim();
if (label === '') throw new StoreError('a Menu Set needs a label');
set.manifest.label = label;
}
if (patch.validUntil !== undefined) {
if (patch.validUntil !== null && !CALENDAR_DATE.test(patch.validUntil)) {
throw new StoreError('validUntil must be a YYYY-MM-DD date');
}
set.manifest.validUntil = patch.validUntil;
}
await writeManifest(set.dir, set.manifest);
});
}
/**
* Makes a Draft Current. The manifest is written first and the directory
* rename is the commit point, so a crash leaves either a Draft or a
* published set, never a published set with no publishedAt.
*/
async publish(setId: string): Promise<StoredSet> {
return this.exclusive(async () => {
const set = await this.requireDraft(setId);
if (set.manifest.pages.length === 0) {
throw new StoreError('a Menu Set needs at least one Page before it can be published', 409);
}
const publishedAt = this.now();
const taken = new Set(await listDirectoryNames(path.join(this.paths.published, set.manifest.section)));
const base = set.manifest.validUntil ?? this.calendarDate(publishedAt);
let slug = base;
for (let suffix = 2; taken.has(slug); suffix += 1) slug = `${base}-${suffix}`;
set.manifest.publishedAt = publishedAt.toISOString();
await writeManifest(set.dir, set.manifest);
const target = path.join(this.paths.published, set.manifest.section, slug);
await mkdir(path.dirname(target), { recursive: true });
await rename(set.dir, target);
return new StoredSet(set.manifest, 'published', target, slug);
});
}
/** Makes an archived set Current again, keeping the URL it already has. */
async restore(setId: string): Promise<StoredSet> {
return this.exclusive(async () => {
const set = await this.get(setId);
if (set.state !== 'published') throw new StoreError('only a published set can be restored');
set.manifest.publishedAt = this.now().toISOString();
await writeManifest(set.dir, set.manifest);
return set;
});
}
async remove(setId: string, options: { confirmCurrent?: boolean } = {}): Promise<void> {
await this.exclusive(async () => {
const set = await this.get(setId);
if (set.state === 'published') {
const current = (await this.currentBySection()).get(set.manifest.section);
if (current?.manifest.id === set.manifest.id && options.confirmCurrent !== true) {
throw new StoreError(
'this is the Current Menu Set; publish another set first or confirm the deletion',
409,
);
}
}
await rmrf(set.dir);
await rmrf(this.originalsDir(set));
});
}
/** Page id to owning set, for the media route. */
async pageIndex(): Promise<Map<string, { set: StoredSet; page: PageRecord }>> {
const index = new Map<string, { set: StoredSet; page: PageRecord }>();
for (const set of await this.list()) {
for (const page of set.manifest.pages) index.set(page.id, { set, page });
}
return index;
}
originalsDir(set: StoredSet): string {
return path.join(this.paths.originals, set.manifest.section, set.manifest.id);
}
manifestPath(set: StoredSet): string {
return path.join(set.dir, MANIFEST_FILE);
}
private async requireDraft(setId: string): Promise<StoredSet> {
const set = await this.get(setId);
if (set.state !== 'draft') {
throw new StoreError('a published Menu Set is immutable; create a new set instead', 409);
}
return set;
}
}
function newerThan(candidate: StoredSet, existing: StoredSet): boolean {
return (candidate.manifest.publishedAt ?? '') > (existing.manifest.publishedAt ?? '');
}
export { ManifestError };
+119
View File
@@ -0,0 +1,119 @@
/**
* Boot validation. Fatal problems stop the app from serving a menu it knows
* is wrong; warnings are logged and the broken Page is skipped at render.
*/
import { access, readdir } from 'node:fs/promises';
import path from 'node:path';
import { ManifestError, readManifest, originalFileName, variantFileNames } from './manifest.ts';
import { StoredSet, sitePaths } from './sets.ts';
export interface Problem {
level: 'fatal' | 'warning';
message: string;
}
export interface ValidateOptions {
dataDir: string;
sections: readonly string[];
}
async function exists(target: string): Promise<boolean> {
try {
await access(target);
return true;
} catch {
return false;
}
}
async function listDirectoryNames(dir: string): Promise<string[]> {
try {
const entries = await readdir(dir, { withFileTypes: true });
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
}
export async function validateSite(options: ValidateOptions): Promise<Problem[]> {
const paths = sitePaths(options.dataDir);
const problems: Problem[] = [];
const sets: StoredSet[] = [];
for (const state of ['draft', 'published'] as const) {
const base = state === 'draft' ? paths.drafts : paths.published;
for (const sectionDir of await listDirectoryNames(base)) {
for (const key of await listDirectoryNames(path.join(base, sectionDir))) {
const dir = path.join(base, sectionDir, key);
let manifest;
try {
manifest = await readManifest(dir);
} catch (error) {
const detail = error instanceof ManifestError ? error.message : String(error);
problems.push({ level: 'fatal', message: `${dir}: ${detail}` });
continue;
}
sets.push(new StoredSet(manifest, state, dir, state === 'published' ? key : null));
}
}
}
for (const set of sets) {
const where = `${set.state}/${set.manifest.section}/${set.slug ?? set.manifest.id}`;
if (!options.sections.includes(set.manifest.section)) {
problems.push({ level: 'fatal', message: `${where}: Section is not in the Site config` });
}
if (set.manifest.section !== path.basename(path.dirname(set.dir))) {
problems.push({ level: 'fatal', message: `${where}: manifest section does not match its directory` });
}
if (set.state === 'published' && set.manifest.publishedAt === null) {
problems.push({ level: 'fatal', message: `${where}: published set has no publishedAt` });
}
if (set.state === 'draft' && set.manifest.publishedAt !== null) {
problems.push({ level: 'fatal', message: `${where}: Draft has a publishedAt` });
}
if (set.manifest.pages.length === 0) {
problems.push({
level: set.state === 'published' ? 'fatal' : 'warning',
message: `${where}: no Pages`,
});
}
const originals = path.join(paths.originals, set.manifest.section, set.manifest.id);
for (const [index, page] of set.manifest.pages.entries()) {
const names = variantFileNames(page);
const missing: string[] = [];
if (!(await exists(path.join(set.dir, names.webp)))) missing.push('viewing webp');
if (!(await exists(path.join(set.dir, names.jpeg)))) missing.push('viewing jpeg');
if (!(await exists(path.join(set.dir, names.thumb)))) missing.push('thumbnail');
if (!(await exists(path.join(originals, originalFileName(page))))) missing.push('original');
if (missing.length > 0) {
problems.push({
level: set.state === 'published' ? 'fatal' : 'warning',
message: `${where}: page ${index + 1} (${page.id}) is missing its ${missing.join(', ')}`,
});
}
}
}
// Two published sets in one Section may not share a URL segment.
const slugs = new Map<string, Set<string>>();
for (const set of sets) {
if (set.state !== 'published' || set.slug === null) continue;
const key = set.manifest.section;
const seen = slugs.get(key) ?? new Set<string>();
if (seen.has(set.slug)) {
problems.push({ level: 'fatal', message: `two published sets share the URL ${key}/${set.slug}` });
}
seen.add(set.slug);
slugs.set(key, seen);
}
return problems;
}
export function hasFatal(problems: readonly Problem[]): boolean {
return problems.some((problem) => problem.level === 'fatal');
}
+101
View File
@@ -0,0 +1,101 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { ConfigError, parseSiteConfig, sectionBySlug } from '../src/config.ts';
function minimal(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
site: 'menu.hopsngrains.com',
brand: { name: 'Hops n Grains' },
sections: [
{ slug: 'food', label: 'Food', order: 2 },
{ slug: 'bar', label: 'Bar', order: 1 },
],
...overrides,
};
}
describe('parseSiteConfig', () => {
it('applies defaults and sorts Sections by order', () => {
const config = parseSiteConfig(minimal());
assert.deepEqual(
config.sections.map((section) => section.slug),
['bar', 'food'],
);
assert.equal(config.sections[0]?.shortLabel, 'Bar');
assert.equal(config.sections[0]?.note, null);
assert.equal(config.brand.tagline, null);
assert.equal(config.theme.accent, '#0088aa');
assert.equal(config.limits.maxPagesPerSet, 40);
assert.equal(config.limits.maxUploadBytes, 30 * 1024 * 1024);
});
it('keeps an explicit theme, short label and note', () => {
const config = parseSiteConfig(
minimal({
theme: { copper: '#b2703b' },
sections: [{ slug: 'wine', label: 'Wine', shortLabel: 'Wine', note: 'By the glass', order: 1 }],
}),
);
assert.equal(config.theme.copper, '#b2703b');
assert.equal(config.theme.steel, '#14181b');
assert.equal(config.sections[0]?.note, 'By the glass');
});
it('rejects a slug the router claims', () => {
assert.throws(
() => parseSiteConfig(minimal({ sections: [{ slug: 'admin', label: 'Admin', order: 1 }] })),
ConfigError,
);
});
it('rejects duplicate slugs and duplicate orders', () => {
assert.throws(
() =>
parseSiteConfig(
minimal({
sections: [
{ slug: 'bar', label: 'Bar', order: 1 },
{ slug: 'bar', label: 'Bar again', order: 2 },
],
}),
),
ConfigError,
);
assert.throws(
() =>
parseSiteConfig(
minimal({
sections: [
{ slug: 'bar', label: 'Bar', order: 1 },
{ slug: 'food', label: 'Food', order: 1 },
],
}),
),
ConfigError,
);
});
it('rejects a malformed slug', () => {
assert.throws(
() => parseSiteConfig(minimal({ sections: [{ slug: 'Straight Drinks', label: 'x', order: 1 }] })),
ConfigError,
);
});
it('rejects a colour that is not #rrggbb', () => {
assert.throws(() => parseSiteConfig(minimal({ theme: { steel: 'black' } })), ConfigError);
});
it('rejects a bad limit and a missing brand', () => {
assert.throws(() => parseSiteConfig(minimal({ limits: { maxPagesPerSet: 0 } })), ConfigError);
assert.throws(() => parseSiteConfig({ site: 'x', sections: [] }), ConfigError);
});
});
describe('sectionBySlug', () => {
it('finds a configured Section and reports an unknown one as undefined', () => {
const config = parseSiteConfig(minimal());
assert.equal(sectionBySlug(config, 'bar')?.label, 'Bar');
assert.equal(sectionBySlug(config, 'wine'), undefined);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import sharp from 'sharp';
import {
ACCEPTED_MIME_TYPES,
UnsupportedImageError,
createImagePipeline,
} from '../src/images/pipeline.ts';
const pipeline = createImagePipeline();
async function photo(width: number, height: number, format: 'jpeg' | 'png' | 'webp' = 'jpeg') {
const base = sharp({
create: { width, height, channels: 3, background: { r: 20, g: 120, b: 170 } },
});
if (format === 'png') return base.png().toBuffer();
if (format === 'webp') return base.webp().toBuffer();
return base.jpeg().toBuffer();
}
describe('createImagePipeline', () => {
it('produces a Viewing image, a JPEG fallback and a Thumbnail', async () => {
const derivatives = await pipeline.process(await photo(3000, 2000), 'IMG_0001.JPG');
assert.equal(derivatives.sourceExt, 'jpg');
assert.match(derivatives.hash, /^[0-9a-f]{12}$/);
assert.equal(derivatives.webp.subarray(8, 12).toString(), 'WEBP');
const view = await sharp(derivatives.webp).metadata();
assert.equal(view.width, 1600);
assert.equal(view.height, 1067);
assert.equal(view.format, 'webp');
assert.equal(derivatives.width, 1600);
assert.equal(derivatives.height, 1067);
const jpeg = await sharp(derivatives.jpeg).metadata();
assert.equal(jpeg.width, 1600);
assert.equal(jpeg.format, 'jpeg');
const thumb = await sharp(derivatives.thumb).metadata();
assert.equal(thumb.width, 480);
assert.equal(thumb.format, 'webp');
assert.equal(derivatives.original.length > 0, true);
});
it('keeps the Original bytes untouched and hashes them', async () => {
const input = await photo(2400, 1600);
const derivatives = await pipeline.process(input, 'a.jpg');
assert.equal(Buffer.compare(derivatives.original, input), 0);
const again = await pipeline.process(input, 'a.jpg');
assert.equal(again.hash, derivatives.hash);
const different = await pipeline.process(await photo(2400, 1600, 'png'), 'b.png');
assert.notEqual(different.hash, derivatives.hash);
});
it('accepts PNG and WebP as well as JPEG', async () => {
assert.equal((await pipeline.process(await photo(800, 600, 'png'), 'a.png')).sourceExt, 'png');
assert.equal((await pipeline.process(await photo(800, 600, 'webp'), 'a.webp')).sourceExt, 'webp');
assert.deepEqual([...ACCEPTED_MIME_TYPES], ['image/jpeg', 'image/png', 'image/webp']);
});
it('does not enlarge an image smaller than the Viewing width', async () => {
const derivatives = await pipeline.process(await photo(200, 150), 'small.jpg');
assert.equal(derivatives.width, 200);
assert.equal(derivatives.height, 150);
const thumb = await sharp(derivatives.thumb).metadata();
assert.equal(thumb.width, 200);
});
it('applies EXIF orientation so the pixels are upright', async () => {
// 1200x600 with orientation 6 (rotate 90 CW) is 600x1200 once upright.
const rotated = await sharp({
create: { width: 1200, height: 600, channels: 3, background: { r: 200, g: 30, b: 30 } },
})
.withMetadata({ orientation: 6 })
.jpeg()
.toBuffer();
const derivatives = await pipeline.process(rotated, 'rotated.jpg');
assert.equal(derivatives.width, 600);
assert.equal(derivatives.height, 1200);
const view = await sharp(derivatives.webp).metadata();
assert.equal(view.width, 600);
assert.equal(view.height, 1200);
assert.equal(view.orientation, undefined);
});
it('rejects a file that is not an accepted image, naming the file', async () => {
await assert.rejects(
() => pipeline.process(Buffer.from('not an image at all'), 'notes.pdf'),
(error: unknown) =>
error instanceof UnsupportedImageError && error.message.includes('notes.pdf'),
);
});
it('rejects a GIF, which sharp can read but we do not accept', async () => {
const gif = await sharp({
create: { width: 40, height: 40, channels: 3, background: '#fff' },
})
.gif()
.toBuffer();
await assert.rejects(
() => pipeline.process(gif, 'anim.gif'),
(error: unknown) =>
error instanceof UnsupportedImageError && error.message.includes('gif'),
);
});
});
+276
View File
@@ -0,0 +1,276 @@
import { strict as assert } from 'node:assert';
import { readdir, readFile, rm } from 'node:fs/promises';
import path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { StoreError, sitePaths } from '../src/storage/sets.ts';
import { makeStore, pngBytes, tempDir, testClock } from './support/harness.ts';
let dataDir: string;
beforeEach(async () => {
dataDir = await tempDir('sets');
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
async function filesIn(dir: string): Promise<string[]> {
return (await readdir(dir)).sort();
}
describe('creating a Menu Set', () => {
it('writes a Draft manifest under its Section', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
assert.equal(set.state, 'draft');
assert.equal(set.manifest.publishedAt, null);
assert.deepEqual(set.manifest.pages, []);
assert.equal(
set.dir,
path.join(sitePaths(dataDir).drafts, 'bar', set.manifest.id),
);
assert.deepEqual(await filesIn(set.dir), ['manifest.json']);
const listed = await store.list();
assert.equal(listed.length, 1);
assert.equal(listed[0]?.manifest.label, 'Bar Menu');
});
it('refuses an unknown Section, an empty label and a bad date', async () => {
const { store } = makeStore(dataDir);
await assert.rejects(() => store.create('wine', { label: 'Wine' }), StoreError);
await assert.rejects(() => store.create('bar', { label: ' ' }), StoreError);
await assert.rejects(
() => store.create('bar', { label: 'Bar', validUntil: '26-06-2026' }),
StoreError,
);
});
});
describe('adding Pages', () => {
it('stores both variants and the Original, and records the Page', async () => {
const { store, pipeline } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' });
const page = await store.addPage(set.id, {
data: pngBytes('one'),
originalName: 'IMG_0001.JPG',
});
assert.equal(pipeline.calls, 1);
assert.equal(page.sourceExt, 'jpg');
assert.equal(page.width, 3000);
assert.equal(page.originalName, 'IMG_0001.JPG');
const names = (await filesIn(set.dir)).filter((name) => name !== 'manifest.json');
assert.equal(names.length, 3);
assert.ok(names.some((name) => name.endsWith('.webp') && !name.startsWith('thumb-')));
assert.ok(names.some((name) => name.endsWith('.jpg')));
assert.ok(names.some((name) => name.startsWith('thumb-') && name.endsWith('.webp')));
const originals = await filesIn(path.join(sitePaths(dataDir).originals, 'bar', set.id));
assert.deepEqual(originals, [`${page.id}.${page.hash}.jpg`]);
assert.deepEqual(
await readFile(path.join(sitePaths(dataDir).originals, 'bar', set.id, originals[0]!)),
pngBytes('one'),
);
const reread = await store.get(set.id);
assert.equal(reread.manifest.pages.length, 1);
assert.equal(reread.manifest.pages[0]?.id, page.id);
});
it('enforces the per-set Page limit', async () => {
const { store } = makeStore(dataDir, { maxPagesPerSet: 2 });
const set = await store.create('bar', { label: 'Bar Menu' });
await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
await store.addPage(set.id, { data: pngBytes('two'), originalName: 'b.jpg' });
await assert.rejects(
() => store.addPage(set.id, { data: pngBytes('three'), originalName: 'c.jpg' }),
(error: unknown) => error instanceof StoreError && error.status === 409,
);
});
it('reorders Pages and refuses an incomplete order', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
const first = await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
const second = await store.addPage(set.id, { data: pngBytes('two'), originalName: 'b.jpg' });
await store.reorder(set.id, [second.id, first.id]);
assert.deepEqual(
(await store.get(set.id)).manifest.pages.map((page) => page.id),
[second.id, first.id],
);
await assert.rejects(() => store.reorder(set.id, [second.id]), StoreError);
await assert.rejects(() => store.reorder(set.id, [second.id, second.id]), StoreError);
await assert.rejects(() => store.reorder(set.id, [second.id, 'nope']), StoreError);
});
it('removes a Page and its files', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
const page = await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
await store.removePage(set.id, page.id);
assert.deepEqual((await store.get(set.id)).manifest.pages, []);
assert.deepEqual(await filesIn(set.dir), ['manifest.json']);
assert.deepEqual(await filesIn(path.join(sitePaths(dataDir).originals, 'bar', set.id)), []);
});
it('leaves no temporary manifest files behind', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
const leftovers = (await filesIn(set.dir)).filter((name) => name.endsWith('.tmp'));
assert.deepEqual(leftovers, []);
});
});
describe('publishing', () => {
it('moves the Draft to its date URL and makes it Current', async () => {
const clock = testClock('2026-01-05T04:00:00.000Z');
const { store } = makeStore(dataDir, { clock });
const set = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' });
await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
const published = await store.publish(set.id);
assert.equal(published.slug, '2026-06-26');
assert.equal(
published.dir,
path.join(sitePaths(dataDir).published, 'bar', '2026-06-26'),
);
assert.equal(published.manifest.publishedAt, '2026-01-05T04:00:00.000Z');
assert.deepEqual(await filesIn(sitePaths(dataDir).drafts), ['bar']);
const current = (await store.currentBySection()).get('bar');
assert.equal(current?.manifest.id, set.id);
});
it('falls back to the publication date, in the Site time zone', async () => {
// 20:00 UTC is already the next day in Asia/Kolkata.
const clock = testClock('2026-03-01T20:00:00.000Z');
const { store } = makeStore(dataDir, { clock });
const set = await store.create('food', { label: 'Food Menu' });
await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
const published = await store.publish(set.id);
assert.equal(published.slug, '2026-03-02');
});
it('suffixes a colliding date URL', async () => {
const clock = testClock('2026-01-05T04:00:00.000Z');
const { store } = makeStore(dataDir, { clock });
const first = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' });
await store.addPage(first.id, { data: pngBytes('one'), originalName: 'a.jpg' });
await store.publish(first.id);
clock.advance(60_000);
const second = await store.create('bar', { label: 'Bar Menu, revised', validUntil: '2026-06-26' });
await store.addPage(second.id, { data: pngBytes('two'), originalName: 'b.jpg' });
const published = await store.publish(second.id);
assert.equal(published.slug, '2026-06-26-2');
const current = (await store.currentBySection()).get('bar');
assert.equal(current?.manifest.id, second.id);
const archives = await store.archives('bar');
assert.deepEqual(
archives.map((set) => set.manifest.id),
[first.id],
);
});
it('refuses to publish an empty set or one with no Pages', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
await assert.rejects(
() => store.publish(set.id),
(error: unknown) => error instanceof StoreError && error.status === 409,
);
await assert.rejects(() => store.publish('missing'), StoreError);
});
it('freezes a published set against editing', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
const page = await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
await store.publish(set.id);
await assert.rejects(() => store.addPage(set.id, { data: pngBytes('two'), originalName: 'b.jpg' }), StoreError);
await assert.rejects(() => store.reorder(set.id, [page.id]), StoreError);
await assert.rejects(() => store.update(set.id, { label: 'New' }), StoreError);
await assert.rejects(() => store.removePage(set.id, page.id), StoreError);
});
it('restores an archived set to Current without changing its URL', async () => {
const clock = testClock('2026-01-05T04:00:00.000Z');
const { store } = makeStore(dataDir, { clock });
const first = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' });
await store.addPage(first.id, { data: pngBytes('one'), originalName: 'a.jpg' });
await store.publish(first.id);
clock.advance(60_000);
const second = await store.create('bar', { label: 'Bar Menu, revised' });
await store.addPage(second.id, { data: pngBytes('two'), originalName: 'b.jpg' });
await store.publish(second.id);
assert.equal((await store.currentBySection()).get('bar')?.manifest.id, second.id);
clock.advance(60_000);
const restored = await store.restore(first.id);
assert.equal(restored.slug, '2026-06-26');
assert.equal((await store.currentBySection()).get('bar')?.manifest.id, first.id);
assert.deepEqual(
(await store.archives('bar')).map((set) => set.manifest.id),
[second.id],
);
});
});
describe('deleting', () => {
it('refuses the Current set unless the deletion is confirmed, then removes everything', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
await store.publish(set.id);
await assert.rejects(
() => store.remove(set.id),
(error: unknown) => error instanceof StoreError && error.status === 409,
);
await store.remove(set.id, { confirmCurrent: true });
assert.deepEqual(await store.list(), []);
assert.deepEqual(await filesIn(path.join(sitePaths(dataDir).published, 'bar')), []);
assert.deepEqual(await filesIn(path.join(sitePaths(dataDir).originals, 'bar')), []);
});
it('deletes a Draft without confirmation', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('food', { label: 'Food Menu' });
await store.remove(set.id);
assert.deepEqual(await store.list(), []);
});
});
describe('page index', () => {
it('maps every Page to its owning set, draft or published', async () => {
const { store } = makeStore(dataDir);
const draft = await store.create('bar', { label: 'Bar Menu' });
const published = await store.create('food', { label: 'Food Menu' });
const draftPage = await store.addPage(draft.id, { data: pngBytes('one'), originalName: 'a.jpg' });
const publishedPage = await store.addPage(published.id, {
data: pngBytes('two'),
originalName: 'b.jpg',
});
await store.publish(published.id);
const index = await store.pageIndex();
assert.equal(index.get(draftPage.id)?.set.state, 'draft');
assert.equal(index.get(publishedPage.id)?.set.state, 'published');
assert.equal(index.get(publishedPage.id)?.set.slug, '2026-01-01');
});
});
+66
View File
@@ -0,0 +1,66 @@
import { mkdir, mkdtemp } from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { SetStore, type ImagePipeline } from '../../src/storage/sets.ts';
export const SECTIONS = ['bar', 'food'] as const;
/** Deterministic stand-in for sharp, so storage tests need no image work. */
export class FakePipeline implements ImagePipeline {
calls = 0;
async process(data: Buffer, _originalName: string) {
this.calls += 1;
const hash = createHash('sha256').update(data).digest('hex').slice(0, 8);
return {
sourceExt: 'jpg' as const,
hash,
width: 3000,
height: 2000,
original: data,
webp: Buffer.from(`webp:${hash}`),
jpeg: Buffer.from(`jpeg:${hash}`),
thumb: Buffer.from(`thumb:${hash}`),
};
}
}
export function testClock(start = '2026-01-01T00:00:00.000Z') {
let time = Date.parse(start);
return {
now: () => new Date(time),
advance: (ms: number) => {
time += ms;
},
set: (iso: string) => {
time = Date.parse(iso);
},
};
}
export async function tempDir(prefix: string): Promise<string> {
const base = path.join(process.cwd(), '.tmp');
await mkdir(base, { recursive: true });
return mkdtemp(path.join(base, `${prefix}-`));
}
export function makeStore(
dataDir: string,
options: { clock?: ReturnType<typeof testClock>; maxPagesPerSet?: number } = {},
) {
const pipeline = new FakePipeline();
const clock = options.clock ?? testClock();
const store = new SetStore({
dataDir,
pipeline,
sections: [...SECTIONS],
maxPagesPerSet: options.maxPagesPerSet ?? 40,
timeZone: 'Asia/Kolkata',
now: clock.now,
});
return { store, pipeline, clock };
}
export function pngBytes(label: string): Buffer {
return Buffer.from(`fake-image:${label}`);
}
+102
View File
@@ -0,0 +1,102 @@
import { strict as assert } from 'node:assert';
import { readFile, readdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { MANIFEST_FILE } from '../src/storage/manifest.ts';
import { sitePaths } from '../src/storage/sets.ts';
import { hasFatal, validateSite } from '../src/storage/validate.ts';
import { SECTIONS, makeStore, pngBytes, tempDir } from './support/harness.ts';
let dataDir: string;
beforeEach(async () => {
dataDir = await tempDir('validate');
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
function validate() {
return validateSite({ dataDir, sections: [...SECTIONS] });
}
describe('validateSite', () => {
it('passes an empty Site and a healthy published set', async () => {
const { store } = makeStore(dataDir);
assert.deepEqual(await validate(), []);
const set = await store.create('bar', { label: 'Bar Menu' });
await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
await store.publish(set.id);
assert.deepEqual(await validate(), []);
});
it('warns about a broken Draft but fails on a broken published set', async () => {
const { store } = makeStore(dataDir);
const draft = await store.create('bar', { label: 'Bar Menu' });
const page = await store.addPage(draft.id, { data: pngBytes('one'), originalName: 'a.jpg' });
const names = await readdir(draft.dir);
const thumbnail = names.find((name) => name.startsWith('thumb-'))!;
await rm(path.join(draft.dir, thumbnail));
const draftProblems = await validate();
assert.equal(draftProblems.length, 1);
assert.equal(draftProblems[0]?.level, 'warning');
assert.equal(hasFatal(draftProblems), false);
await store.publish(draft.id);
const publishedProblems = await validate();
assert.equal(publishedProblems[0]?.level, 'fatal');
assert.equal(hasFatal(publishedProblems), true);
assert.match(publishedProblems[0]?.message ?? '', new RegExp(page.id));
});
it('fails when a manifest disagrees with its own directory', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
const file = path.join(set.dir, MANIFEST_FILE);
const manifest = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>;
manifest['section'] = 'food';
await writeFile(file, JSON.stringify(manifest));
const problems = await validate();
assert.equal(hasFatal(problems), true);
assert.ok(
problems.some((problem) => problem.message.includes('does not match its directory')),
);
});
it('fails on a manifest that cannot be parsed', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
await writeFile(path.join(set.dir, MANIFEST_FILE), '{ not json');
const problems = await validate();
assert.equal(hasFatal(problems), true);
assert.ok(problems.some((problem) => problem.message.includes('not valid JSON')));
});
it('fails on a published set with no Pages', async () => {
const { store } = makeStore(dataDir);
const set = await store.create('bar', { label: 'Bar Menu' });
await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
const published = await store.publish(set.id);
const manifestPath = path.join(published.dir, MANIFEST_FILE);
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record<string, unknown>;
manifest['pages'] = [];
await writeFile(manifestPath, JSON.stringify(manifest));
const problems = await validate();
assert.equal(hasFatal(problems), true);
assert.ok(problems.some((problem) => problem.message.includes('no Pages')));
});
it('reports the data directory layout it inspected', async () => {
const paths = sitePaths(dataDir);
assert.equal(paths.drafts, path.join(dataDir, 'drafts'));
assert.equal(paths.published, path.join(dataDir, 'published'));
assert.equal(paths.originals, path.join(dataDir, 'originals'));
});
});
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "es2024",
"lib": ["es2024"],
"module": "nodenext",
"moduleResolution": "nodenext",
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}