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:
commit
edea83228c
22 files changed
+3770
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.npm-cache/
|
||||||
|
.tmp/
|
||||||
|
*.log
|
||||||
|
site.env
|
||||||
+68
@@ -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
|
||||||
@@ -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
@@ -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.
|
||||||
Generated
+1766
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user