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:
1 parent
ce8079355b
commit
e64f65642f
40 files changed
+1469
-172
No files matched your search
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.dev
|
||||
.tmp
|
||||
.npm-cache
|
||||
public/fonts
|
||||
public/prototype
|
||||
docs/design
|
||||
package-lock.json
|
||||
docs
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "always",
|
||||
"embeddedLanguageFormatting": "off"
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
Vendored
+3
-1
@@ -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
|
||||
}
|
||||
Vendored
+20
-2
@@ -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"]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
Generated
+964
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -117,5 +117,4 @@
|
||||
openOverlay(link.getAttribute('href'), (image && image.alt) || 'Menu page');
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
+9
-5
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
@@ -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.');
|
||||
});
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`,
|
||||
),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user