Add ESLint and Prettier

Prettier owns formatting: .prettierrc.json, format and format:check scripts, and
a one-off pass over the repo so the check is green from the start. Embedded
language formatting is off deliberately - the renderers build HTML in html
tagged templates and letting Prettier reflow those would change the markup
customers receive. docs/ is excluded because it is hand-wrapped prose, and shell
scripts have no Prettier parser.

ESLint covers the JavaScript: the two browser scripts, the build and check
scripts, and its own config. no-var is scoped out for the browser scripts on
purpose; they are plain ES5-shaped IIFEs and a hoisting difference would only
show up in a browser.

ESLint cannot lint the TypeScript here: typescript@7 is the native port with no
JavaScript compiler API, and typescript-eslint requires a 4.8-6.1 peer, so it
refuses to install and forcing it through would leave a parser that cannot read
the compiler. tsc --noEmit stays the TypeScript gate; README records the two
ways to change that.

lint and format:check now run in deploy.sh, and VS Code formats on save with
Prettier and surfaces ESLint findings.
This commit is contained in:
tanshu committed 2026-09-22 23:15:08 +05:30
1 parent ce8079355b
commit e64f65642f
40 files changed
+1469 -172

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
node_modules
.dev
.tmp
.npm-cache
public/fonts
public/prototype
docs/design
package-lock.json
docs
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": true,
"semi": true,
"printWidth": 100,
"trailingComma": "all",
"arrowParens": "always",
"embeddedLanguageFormatting": "off"
}
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}
+3 -1
View File
@@ -19,5 +19,7 @@
"editor.insertSpaces": true,
"files.eol": "\n",
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true
"files.insertFinalNewline": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}
+20 -2
View File
@@ -7,6 +7,18 @@
"script": "dev:env",
"problemMatcher": []
},
{
"label": "format",
"type": "npm",
"script": "format",
"problemMatcher": []
},
{
"label": "lint",
"type": "npm",
"script": "lint",
"problemMatcher": ["$eslint-stylish"]
},
{
"label": "dev: seed placeholder menus",
"type": "npm",
@@ -23,14 +35,20 @@
"label": "test",
"type": "npm",
"script": "test",
"group": { "kind": "test", "isDefault": true },
"group": {
"kind": "test",
"isDefault": true
},
"problemMatcher": []
},
{
"label": "typecheck",
"type": "npm",
"script": "typecheck",
"group": { "kind": "build", "isDefault": true },
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": ["$tsc"]
},
{
+33 -7
View File
@@ -85,6 +85,32 @@ npm run verify # integrity of .dev/data
npm run check-site -- http://127.0.0.1:3000 # checks a running site from outside
```
### Linting and formatting
```sh
npm run lint # eslint
npm run lint:fix
npm run format # prettier --write
npm run format:check # what CI and deploy.sh run
```
Prettier owns formatting (`.prettierrc.json`); `docs/` is excluded because those
are hand-wrapped notes rather than code. `embeddedLanguageFormatting` is off on
purpose: the renderers build HTML inside `html` tagged template literals, and
letting Prettier reflow those would change the markup customers receive.
**ESLint does not lint the TypeScript, and cannot as things stand.** This project
uses the native TypeScript port (`typescript@7`), which ships no JavaScript
compiler API — and every ESLint TypeScript parser needs one. `typescript-eslint`
declares `typescript >=4.8.4 <6.1.0` as a peer and refuses to install; forcing it
through would leave a parser that cannot read the compiler it was given. The
TypeScript gate is therefore `npm run typecheck` (strict, with `noUnusedLocals`,
`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes` and
`erasableSyntaxOnly`), and ESLint covers the JavaScript. To get ESLint over the
`.ts` files as well, either pin `typescript@5.9` — which supports everything this
project uses — and add `typescript-eslint`, or add `oxlint`, which parses
TypeScript without the compiler API.
Dragging Pages is the one interaction that only a browser can check, so there is
a harness for it. It needs `geckodriver` on PATH and a running dev server:
@@ -98,18 +124,18 @@ npm run check-dnd -- <set-id> none # just reports affordances
`.vscode/` holds launch configurations and tasks.
| Configuration | What it does |
| --- | --- |
| **Dev server (debug)** | runs `src/server.ts` with the dev environment, breakpoints live |
| **Dev server (watch, no debugger)** | `npm run dev` in a terminal |
| **Debug the open test file** | runs the file you have open under `node --test`, with child processes attached so breakpoints inside a test bind |
| **Check the running site** | prompts for a URL and runs `scripts/check-site.ts` |
| Configuration | What it does |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **Dev server (debug)** | runs `src/server.ts` with the dev environment, breakpoints live |
| **Dev server (watch, no debugger)** | `npm run dev` in a terminal |
| **Debug the open test file** | runs the file you have open under `node --test`, with child processes attached so breakpoints inside a test bind |
| **Check the running site** | prompts for a URL and runs `scripts/check-site.ts` |
Breakpoints land in the `.ts` files themselves: Node strips types in place, so
line numbers are the ones you see. The debug configuration creates `.dev/site.env`
first (`preLaunchTask`), so a fresh clone serves a working admin immediately.
Tasks (Command Palette → *Run Task*) cover the same ground: seed, the dev server,
Tasks (Command Palette → _Run Task_) cover the same ground: seed, the dev server,
tests, typecheck, verify and repair. `.dev`, `.tmp` and `.npm-cache` are hidden
from the explorer, search and the file watcher, so a watched server does not
restart on its own data writes.
+2 -2
View File
@@ -7,11 +7,11 @@ services:
build: .
image: menu-admin:dev
ports:
- "3000:3000"
- '3000:3000'
environment:
MENU_ROOT: /data
MENU_SITE_CONFIG: /etc/menu-app/site.json
SECURE_COOKIES: "false"
SECURE_COOKIES: 'false'
LOG_LEVEL: info
volumes:
- ./config/hopsngrains.site.json:/etc/menu-app/site.json:ro
+4 -2
View File
@@ -20,9 +20,11 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
if [[ -z "${SKIP_TESTS:-}" ]]; then
echo "==> tests"
npm test
echo "==> lint, format and tests"
npm run lint
npm run format:check
npm run typecheck
npm test
fi
echo "==> building ${IMAGE}"
+56
View File
@@ -0,0 +1,56 @@
// ESLint, flat config. Lints the JavaScript: the two browser scripts the menus
// and the admin actually run, the build and check scripts, and this file.
//
// TypeScript is deliberately not linted here. The project's TypeScript is the
// native port (typescript@7), which ships no JavaScript compiler API, and every
// ESLint TypeScript parser needs one — typescript-eslint requires
// `typescript >=4.8.4 <6.1.0` and refuses to install. `npm run typecheck`
// (tsc --noEmit, strict, with noUnusedLocals, noUncheckedIndexedAccess,
// exactOptionalPropertyTypes and erasableSyntaxOnly) is the TypeScript gate.
// See README.md for the two ways to put ESLint on the .ts files as well.
import js from '@eslint/js';
import globals from 'globals';
export default [
{
ignores: [
'node_modules/**',
'.dev/**',
'.tmp/**',
'.npm-cache/**',
'public/fonts/**',
'public/prototype/**',
'docs/design/**',
],
},
js.configs.recommended,
{
// The browser scripts: old-fashioned IIFEs loaded with a plain <script>.
files: ['public/**/*.js'],
languageOptions: {
sourceType: 'script',
globals: globals.browser,
},
rules: {
// Deliberately plain ES5-shaped IIFEs loaded with a <script> tag, so `var`
// stays allowed: rewriting it to let/const would be cosmetic, and a
// hoisting difference here would only show up in a browser.
'no-console': 'error',
eqeqeq: ['error', 'smart'],
'prefer-const': 'error',
'no-implicit-globals': 'error',
'no-shadow-restricted-names': 'error',
},
},
{
files: ['scripts/**/*.{js,mjs}', 'eslint.config.js'],
languageOptions: {
sourceType: 'module',
globals: globals.node,
},
rules: {
'no-console': 'off',
},
},
];
+964
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -16,6 +16,10 @@
"verify": "node src/cli/verify.ts",
"repair": "node src/cli/repair.ts",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "node --test test/*.test.ts",
"check-dnd": "node scripts/check-dnd.mjs"
},
@@ -28,7 +32,11 @@
"sharp": "^0.35.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^26.6.2",
"eslint": "^10.11.0",
"globals": "^17.12.0",
"prettier": "^3.9.8",
"typescript": "^7.0.2"
}
}
-1
View File
@@ -117,5 +117,4 @@
openOverlay(link.getAttribute('href'), (image && image.alt) || 'Menu page');
});
});
})();
+9 -5
View File
@@ -10,8 +10,9 @@
font-weight: 400 700;
font-display: swap;
src: url('/assets/fonts/playfair-display-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
@@ -20,8 +21,9 @@
font-weight: 200 800;
font-display: swap;
src: url('/assets/fonts/plus-jakarta-sans-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root {
@@ -248,7 +250,9 @@ a {
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
transition: background 0.18s ease, color 0.18s ease;
transition:
background 0.18s ease,
color 0.18s ease;
}
.bar__link:hover {
+3 -1
View File
@@ -166,7 +166,9 @@ try {
console.log(`server order changed (after reload): ${persisted}`);
if (persisted) console.log(` server now: ${serverIds.slice(0, 3).join(', ')} ...`);
console.log(`\nVERDICT: ${domChanged && persisted ? 'PASS' : 'FAIL'} (drag reorders and persists)`);
console.log(
`\nVERDICT: ${domChanged && persisted ? 'PASS' : 'FAIL'} (drag reorders and persists)`,
);
} finally {
await cmd('DELETE', `/session/${sid}`);
}
+5 -2
View File
@@ -62,7 +62,8 @@ record('GET /definitely-not-here', missingPage.status === 404, `status ${missing
// --- Each Section a customer can reach, and its images -----------------------
const sections = landing === null ? [] : [...landing.matchAll(/class="row" href="([^"]+)"/g)].map((m) => m[1]!);
const sections =
landing === null ? [] : [...landing.matchAll(/class="row" href="([^"]+)"/g)].map((m) => m[1]!);
if (sections.length === 0) {
record('at least one Section has a Current Menu Set', false, 'the landing page lists none');
@@ -121,5 +122,7 @@ for (const result of results) {
}
const failures = results.filter((result) => !result.ok);
console.log(`\n${results.length - failures.length}/${results.length} checks passed against ${base}`);
console.log(
`\n${results.length - failures.length}/${results.length} checks passed against ${base}`,
);
if (failures.length > 0) process.exitCode = 1;
+30 -7
View File
@@ -15,23 +15,43 @@ import { SetStore } from '../src/storage/sets.ts';
import { SectionStore } from '../src/storage/sections.ts';
const root = path.resolve(process.env['MENU_ROOT'] ?? '.dev');
const configPath = path.resolve(
process.env['MENU_SITE_CONFIG'] ?? 'config/hopsngrains.site.json',
);
const configPath = path.resolve(process.env['MENU_SITE_CONFIG'] ?? 'config/hopsngrains.site.json');
const dataDir = path.join(root, 'data');
const config = parseSiteConfig(JSON.parse(await readFile(configPath, 'utf8')));
function menuPage(title: string, section: string, page: number, total: number, seed: number): string {
function menuPage(
title: string,
section: string,
page: number,
total: number,
seed: number,
): string {
const items = Array.from({ length: 7 }, (_, index) => {
const y = 420 + index * 130;
const width = 180 + ((seed * 37 + index * 53) % 220);
return `
<text x="120" y="${y}" font-family="Georgia, serif" font-size="34" fill="#1b1f22">${
['Wood-fired pizza', 'Smash burger', 'Tenderloin steak', 'Craft platter', 'Fish and chips', 'Risotto', 'Cheesecake'][index % 7]
[
'Wood-fired pizza',
'Smash burger',
'Tenderloin steak',
'Craft platter',
'Fish and chips',
'Risotto',
'Cheesecake',
][index % 7]
}</text>
<text x="120" y="${y + 34}" font-family="Georgia, serif" font-size="24" fill="#6b7075">${
['thin crust, basil', 'double patty, cheddar', 'pepper sauce', 'for two', 'tartare', 'wild mushroom', 'berry coulis'][index % 7]
[
'thin crust, basil',
'double patty, cheddar',
'pepper sauce',
'for two',
'tartare',
'wild mushroom',
'berry coulis',
][index % 7]
}</text>
<text x="1000" y="${y}" text-anchor="end" font-family="Georgia, serif" font-size="34" fill="#1b1f22">${
240 + ((seed * 91 + index * 71) % 620)
@@ -81,7 +101,10 @@ const store = new SetStore({
for (const [index, entry] of plan.entries()) {
const section = sections.bySlug(entry.section);
if (section === undefined) continue;
const set = await store.create(entry.section, { label: entry.label, validUntil: entry.validUntil });
const set = await store.create(entry.section, {
label: entry.label,
validUntil: entry.validUntil,
});
for (let page = 1; page <= entry.pages; page += 1) {
const svg = menuPage(entry.label, section.label, page, entry.pages, index + page);
await store.addPage(set.id, {
+14 -5
View File
@@ -46,7 +46,8 @@ export async function verifyPassword(password: string, stored: string): Promise<
const N = Number(parts[1]);
const r = Number(parts[2]);
const p = Number(parts[3]);
if (!Number.isSafeInteger(N) || !Number.isSafeInteger(r) || !Number.isSafeInteger(p)) return false;
if (!Number.isSafeInteger(N) || !Number.isSafeInteger(r) || !Number.isSafeInteger(p))
return false;
let salt: Buffer;
let expected: Buffer;
try {
@@ -79,7 +80,11 @@ export function createSessionToken(secret: string, ttlMs: number, now: Date = ne
return `${expiry}.${sign(expiry, secret)}`;
}
export function verifySessionToken(token: string | undefined, secret: string, now: Date = new Date()): boolean {
export function verifySessionToken(
token: string | undefined,
secret: string,
now: Date = new Date(),
): boolean {
if (!token) return false;
const separator = token.indexOf('.');
if (separator <= 0) return false;
@@ -94,7 +99,8 @@ export function verifySessionToken(token: string | undefined, secret: string, no
return Number.isSafeInteger(expiry) && expiry > now.getTime();
}
export interface ThrottleVerdict { allowed: boolean;
export interface ThrottleVerdict {
allowed: boolean;
retryAfterSeconds: number;
}
@@ -152,7 +158,9 @@ export class LoginThrottle {
* cannot produce it, because it cannot read the httpOnly cookie.
*/
export function csrfToken(sessionToken: string | undefined, secret: string): string {
return createHmac('sha256', secret).update(`csrf:${sessionToken ?? 'anonymous'}`).digest('base64url');
return createHmac('sha256', secret)
.update(`csrf:${sessionToken ?? 'anonymous'}`)
.digest('base64url');
}
export function verifyCsrf(
@@ -164,6 +172,7 @@ export function verifyCsrf(
const providedBuffer = Buffer.from(provided);
const expectedBuffer = Buffer.from(csrfToken(sessionToken, secret));
return (
providedBuffer.length === expectedBuffer.length && timingSafeEqual(providedBuffer, expectedBuffer)
providedBuffer.length === expectedBuffer.length &&
timingSafeEqual(providedBuffer, expectedBuffer)
);
}
+7 -4
View File
@@ -134,7 +134,9 @@ function parseSections(value: unknown): SectionConfig[] {
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`);
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`);
@@ -172,9 +174,10 @@ export function parseSiteConfig(raw: unknown): SiteConfig {
logo: optionalString(brandRaw['logo'], 'brand.logo'),
};
const limitsRaw = root['limits'] === undefined || root['limits'] === null
? {}
: requireObject(root['limits'], 'limits');
const limitsRaw =
root['limits'] === undefined || root['limits'] === null
? {}
: requireObject(root['limits'], 'limits');
return {
site,
+1 -3
View File
@@ -63,9 +63,7 @@ export function createImagePipeline(options: ImagePipelineOptions = {}): ImagePi
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`,
);
throw new UnsupportedImageError(`${originalName} is ${found}; upload a JPEG, PNG or WebP`);
}
// .rotate() with no angle auto-orients from EXIF; omitting withMetadata()
+56 -38
View File
@@ -34,11 +34,7 @@ function shell(input: AdminShellInput): Html {
});
}
function labelled(
label: string,
control: Html,
hint: string | null = null,
): Html {
function labelled(label: string, control: Html, hint: string | null = null): Html {
return html`<span class="field">
<label>${label}${control}</label>
${hint === null ? '' : html`<span class="field__hint">${hint}</span>`}
@@ -63,7 +59,11 @@ export function renderLogin(config: SiteConfig, error: string | null): Html {
});
}
function setRow(section: SectionConfig, set: StoredSet, kind: 'current' | 'draft' | 'archive'): Html {
function setRow(
section: SectionConfig,
set: StoredSet,
kind: 'current' | 'draft' | 'archive',
): Html {
const pill =
kind === 'current'
? html`<span class="pill pill--current">Current</span>`
@@ -128,12 +128,14 @@ function renderIdentity(input: DashboardInput): Html {
</label>
<button class="button" type="submit">Upload icon</button>
</form>
${identity.hasIcon
? html`<form method="post" action="/admin/identity/icon/delete">
${
identity.hasIcon
? html`<form method="post" action="/admin/identity/icon/delete">
<input type="hidden" name="csrf" value="${input.csrf}" />
<button class="button" type="submit">Remove the uploaded icon</button>
</form>`
: ''}
: ''
}
</details>`;
}
@@ -202,14 +204,16 @@ export function renderDashboard(input: DashboardInput): Html {
${current === undefined ? html`<li class="setlist__none">No menu published yet.</li>` : setRow(section, current, 'current')}
${join(drafts.map((set) => setRow(section, set, 'draft')))}
</ul>
${archives.length === 0
? ''
: html`<details class="fold">
${
archives.length === 0
? ''
: html`<details class="fold">
<summary>${archives.length === 1 ? '1 older menu' : `${archives.length} older menus`}</summary>
<ul class="setlist">
${join(archives.map((set) => setRow(section, set, 'archive')))}
</ul>
</details>`}
</details>`
}
<form class="newset" method="post" action="/admin/sets">
<input type="hidden" name="csrf" value="${input.csrf}" />
<input type="hidden" name="section" value="${section.slug}" />
@@ -273,8 +277,9 @@ export function renderEditor(input: EditorInput): Html {
<span class="page-admin__name">${page.originalName}</span>
<span class="page-admin__dims">${page.width} × ${page.height}</span>
</span>
${reorderable
? html`<span class="page-admin__actions">
${
reorderable
? html`<span class="page-admin__actions">
<form method="post" action="/admin/sets/${set.id}/move">
<input type="hidden" name="csrf" value="${input.csrf}" />
<input type="hidden" name="pageId" value="${page.id}" />
@@ -287,14 +292,17 @@ export function renderEditor(input: EditorInput): Html {
<input type="hidden" name="direction" value="down" />
<button class="button button--icon" type="submit" title="Move down">↓</button>
</form>
${draft
? html`<form method="post" action="/admin/sets/${set.id}/pages/${page.id}/delete">
${
draft
? html`<form method="post" action="/admin/sets/${set.id}/pages/${page.id}/delete">
<input type="hidden" name="csrf" value="${input.csrf}" />
<button class="button button--icon button--danger" type="submit" title="Remove page">✕</button>
</form>`
: ''}
: ''
}
</span>`
: ''}
: ''
}
</li>`;
});
@@ -312,25 +320,28 @@ export function renderEditor(input: EditorInput): Html {
${input.notice === null ? '' : html`<p class="notice">${input.notice}</p>`}
${input.error === null ? '' : html`<p class="notice notice--error">${input.error}</p>`}
${set.state === 'draft'
? ''
: input.current
? html`<p class="notice">
${
set.state === 'draft'
? ''
: input.current
? html`<p class="notice">
This is the menu customers are seeing at
<a href="/${section.slug}/">/${section.slug}/</a>. Its Pages are fixed, but you can drag them
into a different order and it takes effect straight away. To add, remove or replace a Page,
create a new Menu Set.
</p>`
: html`<p class="notice">
: html`<p class="notice">
This is an older menu, kept as it was at
<a href="/${section.slug}/${set.slug}/">/${section.slug}/${set.slug}/</a>. Archived menus cannot
be changed; press "Make current" to put it back in front of customers.
</p>`}
</p>`
}
<section class="block">
<h2 class="block__title">Details</h2>
${draft
? html`<form class="inline-form" method="post" action="/admin/sets/${set.id}/update">
${
draft
? html`<form class="inline-form" method="post" action="/admin/sets/${set.id}/update">
<input type="hidden" name="csrf" value="${input.csrf}" />
${labelled('Label', html`<input type="text" name="label" value="${set.manifest.label}" required />`)}
${labelled(
@@ -339,11 +350,13 @@ export function renderEditor(input: EditorInput): Html {
)}
<button class="button" type="submit">Save</button>
</form>`
: html`<p class="muted">${setDateLabel(set.manifest)}</p>`}
: html`<p class="muted">${setDateLabel(set.manifest)}</p>`
}
</section>
${draft
? html`<section class="block">
${
draft
? html`<section class="block">
<h2 class="block__title">Pages</h2>
<div class="drop" data-drop>
<input type="file" accept="image/jpeg,image/png,image/webp,image/*" multiple data-file-input hidden />
@@ -363,30 +376,35 @@ export function renderEditor(input: EditorInput): Html {
</ul>
<p class="drop__status" data-order-status role="status"></p>
</section>`
: html`<section class="block">
: html`<section class="block">
<h2 class="block__title">Pages</h2>
<ul class="pages-admin" ${reorderable ? html`data-pages-admin` : ''}>${join(pageItems)}</ul>
<p class="drop__status" data-order-status role="status"></p>
</section>`}
</section>`
}
<section class="block">
<h2 class="block__title">${draft ? 'Publish' : 'Manage'}</h2>
<div class="actions">
${draft
? html`<form method="post" action="/admin/sets/${set.id}/publish">
${
draft
? html`<form method="post" action="/admin/sets/${set.id}/publish">
<input type="hidden" name="csrf" value="${input.csrf}" />
<button class="button button--primary" type="submit" ${set.manifest.pages.length === 0 ? 'disabled' : ''}>
Publish (${pageCountLabel(set.manifest.pages.length)})
</button>
</form>
<a class="button" href="/admin/sets/${set.id}/preview">Preview</a>`
: html`<a class="button" href="/${section.slug}/${set.slug}/">View live</a>`}
${set.state === 'published' && !input.current
? html`<form method="post" action="/admin/sets/${set.id}/restore">
: html`<a class="button" href="/${section.slug}/${set.slug}/">View live</a>`
}
${
set.state === 'published' && !input.current
? html`<form method="post" action="/admin/sets/${set.id}/restore">
<input type="hidden" name="csrf" value="${input.csrf}" />
<button class="button" type="submit">Make current</button>
</form>`
: ''}
: ''
}
<form method="post" action="/admin/sets/${set.id}/delete" data-confirm="Delete this Menu Set and its Originals? This cannot be undone.">
<input type="hidden" name="csrf" value="${input.csrf}" />
<input type="hidden" name="confirm" value="yes" />
+5 -3
View File
@@ -26,9 +26,11 @@ export function renderDocument(input: DocumentInput): Html {
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>${title}</title>
${input.description === null
? ''
: html`<meta name="description" content="${input.description}" />`}
${
input.description === null
? ''
: html`<meta name="description" content="${input.description}" />`
}
${input.noindex ? html`<meta name="robots" content="noindex" />` : ''}
<meta name="theme-color" content="${theme.steel}" />
<link rel="icon" href="${logo}" />
+31 -17
View File
@@ -131,7 +131,11 @@ function renderPageIndex(total: number): Html {
</details>`;
}
function renderOlderMenus(section: SectionConfig, archives: readonly StoredSet[], currentHref: string): Html {
function renderOlderMenus(
section: SectionConfig,
archives: readonly StoredSet[],
currentHref: string,
): Html {
if (archives.length === 0) return Html.raw('');
return html`<details class="fold fold--older">
<summary>Older menus</summary>
@@ -173,11 +177,13 @@ export function renderLanding(input: LandingInput): Html {
${join(
input.live.map(
(entry) => html`<a class="shelf" href="/${entry.section.slug}/">
${entry.cover === null
? ''
: html`<span class="shelf__peek" aria-hidden="true"
${
entry.cover === null
? ''
: html`<span class="shelf__peek" aria-hidden="true"
><img src="${entry.cover}" alt="" loading="lazy" decoding="async"
/></span>`}
/></span>`
}
<span class="shelf__name">${entry.section.label}</span>
<span class="leader" aria-hidden="true"></span>
<span class="shelf__pages">${pageCountLabel(entry.pages)}</span>
@@ -197,9 +203,11 @@ export function renderLanding(input: LandingInput): Html {
body: html`<main class="landing">
<header class="masthead">
<h1 class="wordmark">${config.brand.name}</h1>
${config.brand.tagline === null
? ''
: html`<p class="masthead__tagline">${config.brand.tagline}</p>`}
${
config.brand.tagline === null
? ''
: html`<p class="masthead__tagline">${config.brand.tagline}</p>`
}
</header>
${rows}
</main>`,
@@ -224,21 +232,27 @@ export function renderSectionPage(input: SectionInput): Html {
const total = set.manifest.pages.length;
const body = html`${renderBar(config, section.slug, input.menu)}
${input.preview
? html`<p class="notice notice--admin">
${
input.preview
? html`<p class="notice notice--admin">
Preview of a Draft — customers cannot see this yet.
<a href="/admin/sets/${set.id}">Back to the editor</a>
</p>`
: ''}
: ''
}
<h2 class="section-heading">
<span class="section-heading__label">${set.manifest.label}</span>
${config.display.showDates
? html`<span class="section-heading__date">${setDateLabel(set.manifest)}</span>`
: ''}
${
config.display.showDates
? html`<span class="section-heading__date">${setDateLabel(set.manifest)}</span>`
: ''
}
</h2>
${input.current || input.preview
? ''
: html`<p class="notice">This is an older menu. <a href="${currentHref}">See the current menu</a>.</p>`}
${
input.current || input.preview
? ''
: html`<p class="notice">This is an older menu. <a href="${currentHref}">See the current menu</a>.</p>`
}
${renderPages(section, set.manifest.pages)}
<footer class="foot">
${renderPageIndex(total)}
+16 -5
View File
@@ -66,7 +66,10 @@ export function sameOrigin(request: FastifyRequest): boolean {
}
}
export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRouteDeps): Promise<void> {
export async function registerAdminRoutes(
app: FastifyInstance,
deps: AdminRouteDeps,
): Promise<void> {
const { sections, store } = deps;
const site = (): SiteConfig => deps.identity.apply(deps.config);
const throttle = deps.throttle ?? new LoginThrottle();
@@ -154,7 +157,10 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
.code(429)
.header('retry-after', String(verdict.retryAfterSeconds))
.type(HTML)
.send(renderLogin(site(), `Too many attempts. Try again in ${verdict.retryAfterSeconds}s.`).value);
.send(
renderLogin(site(), `Too many attempts. Try again in ${verdict.retryAfterSeconds}s.`)
.value,
);
}
const password = (request.body as { password?: string } | undefined)?.password ?? '';
const hash = deps.adminPasswordHash;
@@ -205,7 +211,10 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
app.post('/admin/sets', async (request, reply) => {
if (!authed(request)) return reply.redirect('/admin/login', 303);
if (!sameOrigin(request) || !csrfOk(request)) {
return reply.code(403).type(HTML).send('That request was rejected. Reload the page and try again.');
return reply
.code(403)
.type(HTML)
.send('That request was rejected. Reload the page and try again.');
}
const body = request.body as { section?: string; label?: string; validUntil?: string };
const section = sections.bySlug(body.section ?? '');
@@ -213,7 +222,8 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
try {
const created = await store.create(section.slug, {
label: body.label ?? `${section.label} Menu`,
validUntil: body.validUntil === undefined || body.validUntil === '' ? null : body.validUntil,
validUntil:
body.validUntil === undefined || body.validUntil === '' ? null : body.validUntil,
});
return reply.redirect(`/admin/sets/${created.id}`, 303);
} catch (error) {
@@ -437,7 +447,8 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
}
if (!authorised) return reply.code(403).send('Rejected.');
if (tooLarge) return backWith(request, reply, { error: 'That file is larger than the upload limit.' });
if (tooLarge)
return backWith(request, reply, { error: 'That file is larger than the upload limit.' });
if (uploaded === null) return backWith(request, reply, { error: 'Choose an SVG file first.' });
try {
+9 -8
View File
@@ -9,12 +9,7 @@ import type { SectionStore } from '../storage/sections.ts';
import { SESSION_COOKIE, verifySessionToken } from '../auth.ts';
import { variantFileNames } from '../storage/manifest.ts';
import type { SetStore } from '../storage/sets.ts';
import {
menuLinks,
renderLanding,
renderNotFound,
renderSectionPage,
} from '../render/pages.ts';
import { menuLinks, renderLanding, renderNotFound, renderSectionPage } from '../render/pages.ts';
const HTML = 'text/html; charset=utf-8';
const MEDIA_NAME = /^(thumb-)?([0-9a-f-]{36})\.([0-9a-f]{8,64})\.(webp|jpg)$/;
@@ -158,7 +153,10 @@ export async function registerPublicRoutes(
if (draft && !isAuthed(request)) return reply.code(404).send();
const names = variantFileNames(entry.page);
const file = path.join(entry.set.dir, name.thumb ? names.thumb : name.ext === 'webp' ? names.webp : names.jpeg);
const file = path.join(
entry.set.dir,
name.thumb ? names.thumb : name.ext === 'webp' ? names.webp : names.jpeg,
);
let size: number;
try {
size = (await stat(file)).size;
@@ -167,7 +165,10 @@ export async function registerPublicRoutes(
}
return reply
.header('cache-control', draft ? 'private, max-age=60' : 'public, max-age=31536000, immutable')
.header(
'cache-control',
draft ? 'private, max-age=60' : 'public, max-age=31536000, immutable',
)
.header('etag', `"${entry.page.hash}-${name.thumb ? 'thumb' : name.ext}"`)
.header('content-length', String(size))
.type(name.thumb || name.ext === 'webp' ? 'image/webp' : 'image/jpeg')
+2 -1
View File
@@ -136,7 +136,8 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
app.setErrorHandler(async (error: FastifyError, request, reply) => {
request.log.error({ err: error }, 'request failed');
const status = error.statusCode !== undefined && error.statusCode < 500 ? error.statusCode : 500;
const status =
error.statusCode !== undefined && error.statusCode < 500 ? error.statusCode : 500;
return reply.code(status).type('text/plain; charset=utf-8').send('Something went wrong.');
});
+5 -1
View File
@@ -145,7 +145,11 @@ export class IdentityStore {
};
}
async update(patch: { name?: string; tagline?: string | null; showDates?: boolean }): Promise<SiteIdentity> {
async update(patch: {
name?: string;
tagline?: string | null;
showDates?: boolean;
}): Promise<SiteIdentity> {
const current = this.require();
const next: StoredIdentity = {
name: patch.name === undefined ? current.name : cleanText(patch.name, 'name'),
+2 -1
View File
@@ -91,7 +91,8 @@ function parsePage(raw: unknown, where: string): PageRecord {
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`);
if (typeof originalName !== 'string')
throw new ManifestError(`${where}.originalName must be a string`);
return { id, sourceExt: sourceExt as ImageExt, hash, width, height, originalName };
}
+3 -1
View File
@@ -55,7 +55,9 @@ export async function repairSite(options: RepairOptions): Promise<RepairReport>
const originalPath = path.join(originals, originalFileName(page));
if (!(await exists(originalPath))) {
report.unresolved.push(`${where}: ${missing.length} file(s) missing and the Original is gone`);
report.unresolved.push(
`${where}: ${missing.length} file(s) missing and the Original is gone`,
);
continue;
}
+8 -4
View File
@@ -67,10 +67,14 @@ function parse(raw: unknown): SectionConfig[] {
return {
slug,
label,
shortLabel: typeof candidate['shortLabel'] === 'string' && candidate['shortLabel'] !== ''
? candidate['shortLabel']
: label,
note: typeof candidate['note'] === 'string' && candidate['note'] !== '' ? candidate['note'] : null,
shortLabel:
typeof candidate['shortLabel'] === 'string' && candidate['shortLabel'] !== ''
? candidate['shortLabel']
: label,
note:
typeof candidate['note'] === 'string' && candidate['note'] !== ''
? candidate['note']
: null,
order,
} satisfies SectionConfig;
});
+8 -6
View File
@@ -105,7 +105,10 @@ async function listDirectoryNames(dir: string): Promise<string[]> {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}
async function rmrf(target: string): Promise<void> {
@@ -227,10 +230,7 @@ export class SetStore {
});
}
async addPage(
setId: string,
input: { data: Buffer; originalName: string },
): Promise<PageRecord> {
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) {
@@ -343,7 +343,9 @@ export class SetStore {
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 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}`;
+12 -3
View File
@@ -31,7 +31,10 @@ async function exists(target: string): Promise<boolean> {
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();
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
@@ -80,7 +83,10 @@ export async function validateSite(options: ValidateOptions): Promise<Problem[]>
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` });
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` });
@@ -119,7 +125,10 @@ export async function validateSite(options: ValidateOptions): Promise<Problem[]>
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}` });
problems.push({
level: 'fatal',
message: `two published sets share the URL ${key}/${set.slug}`,
});
}
seen.add(set.slug);
slugs.set(key, seen);
+44 -9
View File
@@ -3,7 +3,13 @@ import { rm } from 'node:fs/promises';
import path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { FastifyInstance } from 'fastify';
import { LoginThrottle, SESSION_COOKIE, createSessionToken, csrfToken, hashPassword } from '../src/auth.ts';
import {
LoginThrottle,
SESSION_COOKIE,
createSessionToken,
csrfToken,
hashPassword,
} from '../src/auth.ts';
import { parseSiteConfig } from '../src/config.ts';
import { buildApp } from '../src/server.ts';
import { FakePipeline, pngBytes, tempDir, testClock } from './support/harness.ts';
@@ -237,7 +243,11 @@ describe('the editor', () => {
assert.equal(response.statusCode, 303);
assert.equal(response.headers['location'], `/admin/sets/${id}`);
const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() });
const editor = await app.inject({
method: 'GET',
url: `/admin/sets/${id}`,
cookies: cookies(),
});
assert.equal(editor.statusCode, 200);
assert.match(editor.body, /data-drop/);
assert.match(editor.body, /data-pages-admin/);
@@ -281,7 +291,11 @@ describe('uploading Pages', () => {
assert.equal(response.statusCode, 200);
assert.deepEqual(response.json(), { added: 1, failed: [] });
const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() });
const editor = await app.inject({
method: 'GET',
url: `/admin/sets/${id}`,
cookies: cookies(),
});
assert.match(editor.body, /IMG_0001\.JPG/);
assert.match(editor.body, /\/media\/thumb-/);
});
@@ -566,7 +580,11 @@ describe('forms as a browser sends them', () => {
it('publishes from the editor form, with no CSRF header', async () => {
const { id } = await newSet();
await upload(id, 'one.jpg');
const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() });
const editor = await app.inject({
method: 'GET',
url: `/admin/sets/${id}`,
cookies: cookies(),
});
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/publish`,
@@ -582,7 +600,11 @@ describe('forms as a browser sends them', () => {
await upload(id, 'one.jpg');
await upload(id, 'two.jpg');
const before = (await app.menu.store.get(id)).manifest.pages.map((page) => page.id);
const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() });
const editor = await app.inject({
method: 'GET',
url: `/admin/sets/${id}`,
cookies: cookies(),
});
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/move`,
@@ -606,7 +628,11 @@ describe('forms as a browser sends them', () => {
it('accepts the token as a multipart field, the way the no-JS upload form sends it', async () => {
const { id } = await newSet();
const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() });
const editor = await app.inject({
method: 'GET',
url: `/admin/sets/${id}`,
cookies: cookies(),
});
const token = csrfFrom(editor.body);
const boundary = '----formboundary';
@@ -661,9 +687,14 @@ describe('forms as a browser sends them', () => {
const { id } = await newSet('bar', 'Bar Menu');
await upload(id, 'one.jpg');
const dashboard = (await app.inject({ method: 'GET', url: '/admin', cookies: cookies() })).body;
const editor = (await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() })).body;
const editor = (
await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() })
).body;
for (const [where, body] of [['dashboard', dashboard], ['editor', editor]] as const) {
for (const [where, body] of [
['dashboard', dashboard],
['editor', editor],
] as const) {
const forms = [...body.matchAll(/<form\b[^>]*>[\s\S]*?<\/form>/g)].map((match) => match[0]);
assert.ok(forms.length > 0, `no forms found in the ${where}`);
for (const form of forms) {
@@ -691,7 +722,11 @@ describe('reordering', () => {
it('offers dragging on the Current set and the new order reaches customers', async () => {
const id = await publishBar('Bar Menu', '2026-06-26');
const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() });
const editor = await app.inject({
method: 'GET',
url: `/admin/sets/${id}`,
cookies: cookies(),
});
assert.equal(editor.statusCode, 200);
assert.match(editor.body, /data-pages-admin/);
assert.match(editor.body, /page-admin__handle/);
+12 -2
View File
@@ -28,7 +28,13 @@ describe('the admin password', () => {
});
it('rejects malformed stored values instead of throwing', async () => {
for (const stored of ['', 'plaintext', 'scrypt$1$2$3', 'bcrypt$1$2$3$4$5', 'scrypt$x$8$1$aa$bb']) {
for (const stored of [
'',
'plaintext',
'scrypt$1$2$3',
'bcrypt$1$2$3$4$5',
'scrypt$x$8$1$aa$bb',
]) {
assert.equal(await verifyPassword('anything', stored), false);
}
});
@@ -63,7 +69,11 @@ describe('the session token', () => {
describe('the login throttle', () => {
it('blocks the sixth failure inside the window and reports how long to wait', () => {
let time = NOW.getTime();
const throttle = new LoginThrottle({ maxFailures: 5, windowMs: 60_000, now: () => new Date(time) });
const throttle = new LoginThrottle({
maxFailures: 5,
windowMs: 60_000,
now: () => new Date(time),
});
for (let attempt = 0; attempt < 5; attempt += 1) {
assert.equal(throttle.check('1.2.3.4').allowed, true);
+5 -2
View File
@@ -33,7 +33,9 @@ describe('parseSiteConfig', () => {
const config = parseSiteConfig(
minimal({
theme: { copper: '#b2703b' },
sections: [{ slug: 'wine', label: 'Wine', shortLabel: 'Wine', note: 'By the glass', order: 1 }],
sections: [
{ slug: 'wine', label: 'Wine', shortLabel: 'Wine', note: 'By the glass', order: 1 },
],
}),
);
assert.equal(config.theme.copper, '#b2703b');
@@ -77,7 +79,8 @@ describe('parseSiteConfig', () => {
it('rejects a malformed slug', () => {
assert.throws(
() => parseSiteConfig(minimal({ sections: [{ slug: 'Straight Drinks', label: 'x', order: 1 }] })),
() =>
parseSiteConfig(minimal({ sections: [{ slug: 'Straight Drinks', label: 'x', order: 1 }] })),
ConfigError,
);
});
+27 -6
View File
@@ -9,9 +9,14 @@ import { ICON_URL, IdentityError, IdentityStore, sanitiseIcon } from '../src/sto
import { buildApp } from '../src/server.ts';
import { FakePipeline, tempDir } from './support/harness.ts';
const BRAND = { name: 'Hops n Grains', tagline: 'Handcrafted Microbrewery', logo: '/assets/favicon.svg' };
const BRAND = {
name: 'Hops n Grains',
tagline: 'Handcrafted Microbrewery',
logo: '/assets/favicon.svg',
};
const SECRET = 'identity-test-secret';
const SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>';
const SVG =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>';
let dataDir: string;
@@ -58,15 +63,29 @@ describe('sanitiseIcon', () => {
describe('IdentityStore', () => {
it('seeds from the config the first time and writes the file', async () => {
const store = await loaded();
assert.deepEqual(store.view(), { name: BRAND.name, tagline: BRAND.tagline, showDates: true, hasIcon: false });
assert.deepEqual(store.view(), {
name: BRAND.name,
tagline: BRAND.tagline,
showDates: true,
hasIcon: false,
});
const file = JSON.parse(await readFile(path.join(dataDir, 'site.json'), 'utf8'));
assert.equal(file.name, BRAND.name);
});
it('changes the name, tagline and date switch without touching the config', async () => {
const store = await loaded();
const updated = await store.update({ name: ' Hops and Grains ', tagline: '', showDates: false });
assert.deepEqual(updated, { name: 'Hops and Grains', tagline: null, showDates: false, hasIcon: false });
const updated = await store.update({
name: ' Hops and Grains ',
tagline: '',
showDates: false,
});
assert.deepEqual(updated, {
name: 'Hops and Grains',
tagline: null,
showDates: false,
hasIcon: false,
});
const applied = store.apply(config());
assert.equal(applied.brand.name, 'Hops and Grains');
@@ -136,7 +155,9 @@ describe('identity through the app', () => {
async function iconBody(token: string, filename: string, data: Buffer) {
const boundary = '----iconboundary';
const payload = Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="csrf"\r\n\r\n${token}\r\n`),
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="csrf"\r\n\r\n${token}\r\n`,
),
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="icon"; filename="${filename}"\r\nContent-Type: image/svg+xml\r\n\r\n`,
),
+5 -3
View File
@@ -58,7 +58,10 @@ describe('createImagePipeline', () => {
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.equal(
(await pipeline.process(await photo(800, 600, 'webp'), 'a.webp')).sourceExt,
'webp',
);
assert.deepEqual([...ACCEPTED_MIME_TYPES], ['image/jpeg', 'image/png', 'image/webp']);
});
@@ -105,8 +108,7 @@ describe('createImagePipeline', () => {
.toBuffer();
await assert.rejects(
() => pipeline.process(gif, 'anim.gif'),
(error: unknown) =>
error instanceof UnsupportedImageError && error.message.includes('gif'),
(error: unknown) => error instanceof UnsupportedImageError && error.message.includes('gif'),
);
});
+4 -1
View File
@@ -28,7 +28,10 @@ async function publishedSetWithPages(count = 2) {
}
function validate() {
return validateSite({ dataDir, sections: { has: (slug) => (SECTIONS as readonly string[]).includes(slug) } });
return validateSite({
dataDir,
sections: { has: (slug) => (SECTIONS as readonly string[]).includes(slug) },
});
}
describe('repairSite', () => {
+17 -4
View File
@@ -30,7 +30,9 @@ function config(overrides: Record<string, unknown> = {}) {
});
}
async function build(options: { config?: ReturnType<typeof config> } = {}): Promise<FastifyInstance> {
async function build(
options: { config?: ReturnType<typeof config> } = {},
): Promise<FastifyInstance> {
const built = await buildApp({
config: options.config ?? config(),
dataDir,
@@ -92,7 +94,11 @@ describe('the landing page', () => {
assert.match(response.body, /class="shelf" href="\/bar\/"/);
assert.match(response.body, /<span class="shelf__name">Bar<\/span>/);
assert.match(response.body, /2 pages/);
assert.match(response.body, /class="shelf__peek"/, 'the shelf shows a sliver of the menu paper');
assert.match(
response.body,
/class="shelf__peek"/,
'the shelf shows a sliver of the menu paper',
);
assert.doesNotMatch(response.body, /shelf__name">Wine/);
});
@@ -131,7 +137,11 @@ describe('a Section page', () => {
assert.doesNotMatch(body, /This is an older menu/);
assert.match(body, /src="\/assets\/app\.js"/);
assert.match(body, /class="bar__nav"/, 'the bar links the other menus');
assert.match(body, /class="bar__link is-current"[^>]*aria-current="page"/s, 'the current one is marked');
assert.match(
body,
/class="bar__link is-current"[^>]*aria-current="page"/s,
'the current one is marked',
);
});
it('serves both the trailing-slash and bare URLs', async () => {
@@ -283,7 +293,10 @@ describe('site furniture', () => {
const css = await app.inject({ method: 'GET', url: '/assets/styles.css' });
assert.equal(css.statusCode, 200);
assert.match(css.headers['content-type'] ?? '', /text\/css/);
const font = await app.inject({ method: 'GET', url: '/assets/fonts/playfair-display-latin.woff2' });
const font = await app.inject({
method: 'GET',
url: '/assets/fonts/playfair-display-latin.woff2',
});
assert.equal(font.statusCode, 200);
});
+14 -11
View File
@@ -27,10 +27,7 @@ describe('creating a Menu Set', () => {
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.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();
@@ -138,10 +135,7 @@ describe('publishing', () => {
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.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']);
@@ -169,7 +163,10 @@ describe('publishing', () => {
await store.publish(first.id);
clock.advance(60_000);
const second = await store.create('bar', { label: 'Bar Menu, revised', validUntil: '2026-06-26' });
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);
@@ -200,7 +197,10 @@ describe('publishing', () => {
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.addPage(set.id, { data: pngBytes('two'), originalName: 'b.jpg' }),
StoreError,
);
await assert.rejects(() => store.update(set.id, { label: 'New' }), StoreError);
await assert.rejects(() => store.removePage(set.id, page.id), StoreError);
});
@@ -296,7 +296,10 @@ describe('page index', () => {
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 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',
+5 -4
View File
@@ -18,7 +18,10 @@ afterEach(async () => {
});
function validate() {
return validateSite({ dataDir, sections: { has: (slug) => (SECTIONS as readonly string[]).includes(slug) } });
return validateSite({
dataDir,
sections: { has: (slug) => (SECTIONS as readonly string[]).includes(slug) },
});
}
describe('validateSite', () => {
@@ -62,9 +65,7 @@ describe('validateSite', () => {
const problems = await validate();
assert.equal(hasFatal(problems), true);
assert.ok(
problems.some((problem) => problem.message.includes('does not match its directory')),
);
assert.ok(problems.some((problem) => problem.message.includes('does not match its directory')));
});
it('fails on a manifest that cannot be parsed', async () => {