Let the owner manage categories, and give the menus a shelf
Categories were baked into config/<site>.site.json, so adding one meant a rebuild and a redeploy. They are now data: data/sections.json, seeded from the config the first time, and managed in the admin (add, rename, reorder, remove). A category's web address is fixed when it is created because that is what a QR code points at, and a category cannot be removed while it still holds menus. The landing and the top bar were the ugliest parts. The landing was a column of hairlines with tiny meta — a broadsheet, and the paper that makes this app worth looking at was nowhere on it. It is now a shelf: each category is a row whose right edge is a sliver of that menu's first page, bleeding off the screen, with the shelf edge in brass and the name in heavy tight Archivo. The bar was a row of teal pills that would not survive more categories: it now names the menu you are reading and opens a sheet listing the rest, each with its own paper sliver, and the current one is the only teal thing on screen. The sheet is a link to the landing page, so navigating still works without the script. Also fixes the category manager's own layout, which I shipped unstyled, and the markup contract the route tests described.
This commit is contained in:
1 parent
7bf67cc299
commit
c31b73d553
25 files changed
+1225
-190
No files matched your search
+4
-2
@@ -11,8 +11,10 @@ One restaurant's customer-facing menu deployment, with its own brand, Sections a
|
|||||||
_Avoid_: brand, tenant, deployment, instance
|
_Avoid_: brand, tenant, deployment, instance
|
||||||
|
|
||||||
**Section**:
|
**Section**:
|
||||||
A top-level category of menu content that customers choose between, such as Food, Bar or What's On.
|
A top-level category of menu content that customers choose between, such as Food, Bar or What's On. Sections are content: the owner adds and renames them in the admin.
|
||||||
_Avoid_: type, category, tab, folder
|
_Avoid_: type, tab, folder
|
||||||
|
|
||||||
|
The admin, and the owner, say **category** where the code and this glossary say Section. The two words mean the same thing; the code keeps Section so the storage layout, routes and manifests stay consistent.
|
||||||
|
|
||||||
**Menu Set**:
|
**Menu Set**:
|
||||||
One complete set of Pages published together for a Section, with a label and an optional valid-until date.
|
One complete set of Pages published together for a Section, with a label and an optional valid-until date.
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ npm run check-site -- https://menu-new.hopsngrains.com
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/config.ts Site config: brand, theme, Sections, limits
|
src/config.ts Site config: brand, theme, seed Sections, limits
|
||||||
|
src/storage/sections.ts Categories: the Site's own list, edited in the admin
|
||||||
src/storage/manifest.ts Manifest type, validation, atomic JSON writes
|
src/storage/manifest.ts Manifest type, validation, atomic JSON writes
|
||||||
src/storage/sets.ts The Menu Set store: create, add Pages, publish, restore, delete
|
src/storage/sets.ts The Menu Set store: create, add Pages, publish, restore, delete
|
||||||
src/storage/validate.ts Boot validation: fatal problems and warnings
|
src/storage/validate.ts Boot validation: fatal problems and warnings
|
||||||
@@ -43,7 +44,7 @@ src/routes/public.ts Landing, Section, archive and media routes
|
|||||||
src/routes/admin.ts Sign in, dashboard, editor, upload, publish, restore, delete
|
src/routes/admin.ts Sign in, dashboard, editor, upload, publish, restore, delete
|
||||||
src/server.ts buildApp (what tests drive) and main (what the container runs)
|
src/server.ts buildApp (what tests drive) and main (what the container runs)
|
||||||
public/ Hand-written CSS, ~2KB of progressive-enhancement JS, Archivo
|
public/ Hand-written CSS, ~2KB of progressive-enhancement JS, Archivo
|
||||||
config/ Per-Site config, one file per Site
|
config/ Per-Site brand, theme, limits and the first category list
|
||||||
test/ node:test suites
|
test/ node:test suites
|
||||||
docs/plan.md The agreed plan
|
docs/plan.md The agreed plan
|
||||||
docs/operating.md Publishing, fixing and rolling back, for whoever operates it
|
docs/operating.md Publishing, fixing and rolling back, for whoever operates it
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
**Status**: superseded by [ADR 0007](./0007-categories-are-content.md).
|
||||||
|
|
||||||
# Sections come from configuration, not from the admin
|
# Sections come from configuration, not from the admin
|
||||||
|
|
||||||
A Site's Sections are declared in its config file and change only with a redeploy; the admin manages the Menu Sets inside Sections and cannot create, rename or reorder Sections. Sections are few and stable, and keeping them out of the admin removed a whole class of editing, reordering and deletion edge cases from the content model.
|
A Site's Sections are declared in its config file and change only with a redeploy; the admin manages the Menu Sets inside Sections and cannot create, rename or reorder Sections. Sections are few and stable, and keeping them out of the admin removed a whole class of editing, reordering and deletion edge cases from the content model.
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Categories are content, not configuration
|
||||||
|
|
||||||
|
Sections moved out of `config/<site>.site.json` and into the data directory as `data/sections.json`, managed from the admin: add, rename, reorder and remove. The config's Section list is now only the seed for a Site that has never been opened, which keeps a fresh deployment useful without making a redeploy the price of adding a category. This supersedes ADR 0005.
|
||||||
|
|
||||||
|
Two rules make the change safe to live with. A Section's **slug is fixed once created** — the label can change at any time, but the web address cannot, because it is what a table card or a printed QR code points at. And a Section **cannot be removed while it still holds menus**, so a category cannot silently orphan or delete content; the admin says how many sets are in the way.
|
||||||
|
|
||||||
|
**Considered Options**: keeping Sections in the config and adding an admin that edits the file (rejected: the config is mounted read-only into the container, and every change would still need a redeploy); storing them in the manifests (rejected: a Section exists before it has any Menu Set, so it needs a home of its own).
|
||||||
+11
-4
@@ -12,7 +12,8 @@ For whoever is on the other end of "the menu is wrong". The design is in
|
|||||||
| Menu content | `/var/lib/menu-app.<site>/data` — `originals/`, `drafts/`, `published/` |
|
| Menu content | `/var/lib/menu-app.<site>/data` — `originals/`, `drafts/`, `published/` |
|
||||||
| Admin | `https://<vhost>/admin` |
|
| Admin | `https://<vhost>/admin` |
|
||||||
| The password | `/var/lib/menu-app.<site>/site.env` (mode 600, never in git) |
|
| The password | `/var/lib/menu-app.<site>/site.env` (mode 600, never in git) |
|
||||||
| Sections and branding | `config/<site>.site.json` in this repo, baked into the image |
|
| Categories | `data/sections.json`, edited in the admin |
|
||||||
|
| Brand and theme | `config/<site>.site.json` in this repo, baked into the image |
|
||||||
|
|
||||||
## Everyday tasks
|
## Everyday tasks
|
||||||
|
|
||||||
@@ -31,9 +32,15 @@ bash /tmp/provision-site-env.sh /var/lib/menu-app.hopsngrains.com --force
|
|||||||
docker restart menu-app.hopsngrains.com
|
docker restart menu-app.hopsngrains.com
|
||||||
```
|
```
|
||||||
|
|
||||||
**Add, rename or reorder a Section.** Edit `config/<site>.site.json`, rebuild and
|
**Add, rename, reorder or remove a category.** In the admin, open the
|
||||||
push (`./deploy.sh <tag>`), then run the playbook with the new tag. Sections are
|
**Categories** fold at the bottom of the dashboard. A new category starts empty,
|
||||||
deliberately not editable from the admin (ADR 0005).
|
so customers do not see it until you publish a menu into it. Reordering there is
|
||||||
|
the order customers see. Two rules: a category's web address is fixed when it is
|
||||||
|
created (rename the name as often as you like — a printed QR code keeps working),
|
||||||
|
and a category can only be removed once it holds no menus.
|
||||||
|
|
||||||
|
**Change brand or theme.** Edit `config/<site>.site.json`, rebuild and push
|
||||||
|
(`./deploy.sh <tag>`), then run the playbook with the new tag.
|
||||||
|
|
||||||
**Fix a missing image.** If a Page's thumbnail or viewing image is lost while its
|
**Fix a missing image.** If a Page's thumbnail or viewing image is lost while its
|
||||||
Original survives, the app can rebuild them:
|
Original survives, the app can rebuild them:
|
||||||
|
|||||||
+6
-6
@@ -20,7 +20,7 @@ Replaces the `scp`-a-JPG workflow for `menu.hopsngrains.com` with a small self-h
|
|||||||
| Stack | Node + Fastify + `sharp`, one container |
|
| Stack | Node + Fastify + `sharp`, one container |
|
||||||
| Metadata | JSON manifests, state encoded in the directory layout (ADR 0002) |
|
| Metadata | JSON manifests, state encoded in the directory layout (ADR 0002) |
|
||||||
| Scope | One app instance per Site (ADR 0004); other two Sites migrate later |
|
| Scope | One app instance per Site (ADR 0004); other two Sites migrate later |
|
||||||
| Sections | Per-Site config in git; not editable in the admin (ADR 0005) |
|
| Categories | Content, not configuration: added, renamed, reordered and removed in the admin (ADR 0007, superseding ADR 0005) |
|
||||||
| Lifecycle | Draft → Current → Archive; publishing auto-archives the previous set; archives unlisted but reachable. A published set's Pages are fixed, but the Current set's order can be corrected (ADR 0006) |
|
| Lifecycle | Draft → Current → Archive; publishing auto-archives the previous set; archives unlisted but reachable. A published set's Pages are fixed, but the Current set's order can be corrected (ADR 0006) |
|
||||||
| Auth | Single shared password per Site; app login; hash + session secret in an untracked `.env` on the server |
|
| Auth | Single shared password per Site; app login; hash + session secret in an untracked `.env` on the server |
|
||||||
| Images | 1600px viewing WebP + JPEG fallback, 480px admin-only thumbnails, Originals kept and never served |
|
| Images | 1600px viewing WebP + JPEG fallback, 480px admin-only thumbnails, Originals kept and never served |
|
||||||
@@ -29,7 +29,7 @@ Replaces the `scp`-a-JPG workflow for `menu.hopsngrains.com` with a small self-h
|
|||||||
|
|
||||||
## Domain
|
## Domain
|
||||||
|
|
||||||
`CONTEXT.md` is the glossary. Sections for this Site: Food, Bar, Wine, Straight Drinks, Delivery, What's On. One Current Menu Set per Section; a Section with no Current set does not appear on the landing page.
|
`CONTEXT.md` is the glossary. This Site starts with Food, Bar, Wine, Straight Drinks, Delivery and What's On; the owner adds and renames categories from the admin. One Current Menu Set per Section, and a Section with no Current set does not appear on the landing page.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ caddy (80/443, TLS) ──reverse_proxy──▶ menu-app.hopsngrains.com:3000
|
|||||||
|
|
||||||
| Route | Purpose |
|
| Route | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `GET /` | Landing page: Sections with a Current set, text rows |
|
| `GET /` | Landing page: the shelves, one per Section with a Current set |
|
||||||
| `GET /<section>/` | Current Menu Set |
|
| `GET /<section>/` | Current Menu Set |
|
||||||
| `GET /<section>/<slug>/` | Archived Menu Set (unlisted, still reachable) |
|
| `GET /<section>/<slug>/` | Archived Menu Set (unlisted, still reachable) |
|
||||||
| `GET /<section>/#page-N` | Deep link to one Page |
|
| `GET /<section>/#page-N` | Deep link to one Page |
|
||||||
@@ -89,7 +89,7 @@ Protection: a `scrypt` hash (from `node:crypto`, so no native build dependency)
|
|||||||
|
|
||||||
### Customer design
|
### Customer design
|
||||||
|
|
||||||
Text-led landing page; Sections as tap-list rows with copper hairlines; no hero photograph, no cards, no shadows.
|
The chrome is the shelf and the menus are the paper on it: each Section is a shelf row whose right edge is a sliver of that menu's first page, bleeding off the screen. The bar names the menu you are reading and opens a sheet listing the rest, which scales to any number of categories; a 2px brass line under it tracks how far through the pages you are.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────────────────┐ ┌────────────────────────────┐
|
┌────────────────────────────┐ ┌────────────────────────────┐
|
||||||
@@ -114,7 +114,7 @@ Text-led landing page; Sections as tap-list rows with copper hairlines; no hero
|
|||||||
|
|
||||||
- New repo `menu-admin` on `git.tanshu.com`: app source, `Dockerfile`, `compose.yaml` for local dev, `docs/` (this plan, `CONTEXT.md`, the ADRs), `node --test` tests, and `deploy.sh` (tests, build, push).
|
- New repo `menu-admin` on `git.tanshu.com`: app source, `Dockerfile`, `compose.yaml` for local dev, `docs/` (this plan, `CONTEXT.md`, the ADRs), `node --test` tests, and `deploy.sh` (tests, build, push).
|
||||||
- The container is `node:24-alpine` running TypeScript directly, so there is no build step; sharp's musl binaries come from `npm ci` on the image's own platform. It writes only under `MENU_ROOT` and healthchecks its own `/healthz`.
|
- The container is `node:24-alpine` running TypeScript directly, so there is no build step; sharp's musl binaries come from `npm ci` on the image's own platform. It writes only under `MENU_ROOT` and healthchecks its own `/healthz`.
|
||||||
- The Site's Sections, brand and theme live in `config/<site>.site.json`, baked into the image and selected with `MENU_SITE_CONFIG`. Changing Sections is therefore a commit, a new image and a redeploy; the playbook only names the Site.
|
- The Site's brand and theme live in `config/<site>.site.json`, baked into the image and selected with `MENU_SITE_CONFIG`; its Section list only seeds `data/sections.json` the first time. Categories are edited in the admin from then on (ADR 0007), so adding one does not need a redeploy.
|
||||||
- Build locally and push `registry.tanshu.com/menu-admin:<tag>`, matching the sibling `deploy.sh` / `make build-production TAG=` convention.
|
- Build locally and push `registry.tanshu.com/menu-admin:<tag>`, matching the sibling `deploy.sh` / `make build-production TAG=` convention.
|
||||||
- New `menu-admin` playbook in `ansible-playbooks` with per-Site vars (`vars/hopsngrains.yml` first, the other two stubbed): roles `network`, `menu-app` (pull image, create `/var/lib/menu-app.<site>`, start container, health check, then `verify` the data directory) and `caddy` (snippet upsert).
|
- New `menu-admin` playbook in `ansible-playbooks` with per-Site vars (`vars/hopsngrains.yml` first, the other two stubbed): roles `network`, `menu-app` (pull image, create `/var/lib/menu-app.<site>`, start container, health check, then `verify` the data directory) and `caddy` (snippet upsert).
|
||||||
- The existing `roles/caddy` only *adds* a snippet when its exact text is missing, which cannot change a proxy target; the new playbook's role uses a stable marker so `blockinfile` updates in place, and removes the old role's per-hostname marker at cutover so Caddy never holds two blocks for one hostname.
|
- The existing `roles/caddy` only *adds* a snippet when its exact text is missing, which cannot change a proxy target; the new playbook's role uses a stable marker so `blockinfile` updates in place, and removes the old role's per-hostname marker at cutover so Caddy never holds two blocks for one hostname.
|
||||||
@@ -136,4 +136,4 @@ Deleting the old 319MB directory is the only irreversible step, so it is last.
|
|||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
Backups (owner already runs them), Prometheus metrics, Promtail/Loki integration, multi-user accounts, scheduled publishing, admin-editable Sections, migrating `menu.hngmohali.com` and `menu.greatbear.in`, and git history for the image content.
|
Backups (owner already runs them), Prometheus metrics, Promtail/Loki integration, multi-user accounts, scheduled publishing, per-Section theming, migrating `menu.hngmohali.com` and `menu.greatbear.in`, and git history for the image content.
|
||||||
@@ -320,3 +320,60 @@
|
|||||||
.login input {
|
.login input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Categories ---------- */
|
||||||
|
|
||||||
|
.catlist {
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catrow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem 0.6rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--hairline-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catrow__name {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 13rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catrow__name input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
background: var(--steel);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catrow__url {
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catrow__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold--categories > summary {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold--categories .muted {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
@@ -117,4 +117,52 @@
|
|||||||
openOverlay(link.getAttribute('href'), (image && image.alt) || 'Menu page');
|
openOverlay(link.getAttribute('href'), (image && image.alt) || 'Menu page');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ---------- The menu switcher ----------
|
||||||
|
The bar names the menu you are reading; tapping it lists the rest. The
|
||||||
|
switcher is a link to the landing page, so it still works without this
|
||||||
|
file — the script just opens the sheet instead of navigating. */
|
||||||
|
var switcher = document.querySelector('[data-switcher]');
|
||||||
|
var sheet = document.querySelector('[data-sheet]');
|
||||||
|
var sheetClose = document.querySelector('[data-sheet-close]');
|
||||||
|
|
||||||
|
function closeSheet() {
|
||||||
|
if (!sheet) return;
|
||||||
|
sheet.hidden = true;
|
||||||
|
if (switcher) {
|
||||||
|
switcher.setAttribute('aria-expanded', 'false');
|
||||||
|
switcher.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSheet() {
|
||||||
|
if (!sheet) return;
|
||||||
|
sheet.hidden = false;
|
||||||
|
if (switcher) switcher.setAttribute('aria-expanded', 'true');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
var target = sheet.querySelector('.sheet__item') || sheetClose;
|
||||||
|
if (target) target.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (switcher && sheet) {
|
||||||
|
switcher.addEventListener('click', function (event) {
|
||||||
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (sheet.hidden) openSheet();
|
||||||
|
else closeSheet();
|
||||||
|
});
|
||||||
|
|
||||||
|
sheet.addEventListener('click', function (event) {
|
||||||
|
if (event.target === sheet) closeSheet();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sheetClose) sheetClose.addEventListener('click', closeSheet);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key === 'Escape' && !sheet.hidden) closeSheet();
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
+266
-94
@@ -1,5 +1,7 @@
|
|||||||
/* Hops n Grains menu. Steel is the taproom, paper is the menu: the interface
|
/* Hops n Grains menus.
|
||||||
stays quiet so the photographed pages are the only loud thing. */
|
The chrome is the shelf; the menus are the paper standing on it. Brass is the
|
||||||
|
taproom's metal, the logo's teal is the only active colour, and the paper of
|
||||||
|
the photographed menus is the brightest thing on the page. */
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Archivo';
|
font-family: 'Archivo';
|
||||||
@@ -25,22 +27,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--steel: #14181b;
|
--steel: #101719;
|
||||||
--rail: #1f262b;
|
--rail: #182125;
|
||||||
--paper: #f2efe9;
|
--paper: #efeae0;
|
||||||
--accent: #0088aa;
|
--accent: #0088aa;
|
||||||
--copper: #b2703b;
|
--copper: #c08544;
|
||||||
--ink: #e8e6e1;
|
--ink: #e8e5dd;
|
||||||
--ink-dim: #99a2a8;
|
--ink-dim: #8f979a;
|
||||||
--ink-on-paper: #1b1f22;
|
--ink-on-paper: #1b1f22;
|
||||||
--hairline: color-mix(in srgb, var(--copper) 40%, transparent);
|
--brass-edge: color-mix(in srgb, var(--copper) 42%, transparent);
|
||||||
--hairline-soft: color-mix(in srgb, var(--copper) 22%, transparent);
|
--hairline: color-mix(in srgb, var(--copper) 26%, transparent);
|
||||||
|
--hairline-soft: color-mix(in srgb, var(--copper) 14%, transparent);
|
||||||
|
--sheet-shadow: color-mix(in srgb, #000 45%, transparent);
|
||||||
--step-0: 1rem;
|
--step-0: 1rem;
|
||||||
--step-1: 1.25rem;
|
--step-1: 1.25rem;
|
||||||
--step-2: 1.55rem;
|
--step-2: 1.55rem;
|
||||||
--step-3: 2.4rem;
|
|
||||||
--page-max: 780px;
|
--page-max: 780px;
|
||||||
--content-max: 56rem;
|
--content-max: 52rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
@@ -60,6 +63,7 @@ body {
|
|||||||
font-family: 'Archivo', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Archivo', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
font-size: var(--step-0);
|
font-size: var(--step-0);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -72,73 +76,118 @@ a {
|
|||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Landing ---------- */
|
/* ---------- Landing: shelves holding menus ---------- */
|
||||||
|
|
||||||
.landing {
|
.landing {
|
||||||
max-width: var(--content-max);
|
max-width: var(--content-max);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 1.25rem 3rem;
|
padding: 0 1.1rem 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.masthead {
|
.masthead {
|
||||||
padding: clamp(2.5rem, 11vh, 5.5rem) 0 1.75rem;
|
padding: clamp(2.2rem, 10vh, 4.5rem) 0 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wordmark {
|
.wordmark {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(1.9rem, 8vw, 2.9rem);
|
font-size: clamp(1.95rem, 8.5vw, 2.9rem);
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-stretch: 122%;
|
font-stretch: 118%;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.025em;
|
||||||
line-height: 1.05;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.masthead__tagline {
|
.masthead__tagline {
|
||||||
margin: 0.4rem 0 0;
|
margin: 0.5rem 0 0;
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
font-size: var(--step-1);
|
font-size: 0.98rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rows {
|
.shelves {
|
||||||
border-top: 1px solid var(--hairline);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
margin-right: -1.1rem;
|
||||||
gap: 1rem;
|
|
||||||
padding: 1.1rem 0.15rem;
|
|
||||||
border-bottom: 1px solid var(--hairline);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.row__label {
|
.shelf {
|
||||||
font-size: clamp(1.45rem, 6.5vw, 2rem);
|
position: relative;
|
||||||
font-weight: 600;
|
display: flex;
|
||||||
font-stretch: 116%;
|
flex-direction: column;
|
||||||
line-height: 1.15;
|
justify-content: flex-end;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-height: 6.6rem;
|
||||||
|
padding: 1.2rem 6.9rem 1.25rem 0.1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-top: 1px solid var(--brass-edge);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row__meta {
|
.shelf:last-child {
|
||||||
color: var(--ink-dim);
|
border-bottom: 1px solid var(--brass-edge);
|
||||||
font-size: 0.95rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.row:hover .row__label,
|
/* the shadow the shelf above casts onto what it holds */
|
||||||
.row:focus-visible .row__label {
|
.shelf::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 1.3rem;
|
||||||
|
background: linear-gradient(var(--sheet-shadow), transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelf__peek {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4.9rem;
|
||||||
|
box-shadow: -1px 0 0 var(--sheet-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelf__peek img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top center;
|
||||||
|
transform: translateY(4%) scale(1.02);
|
||||||
|
transition: transform 0.35s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelf:hover .shelf__peek img,
|
||||||
|
.shelf:focus-visible .shelf__peek img {
|
||||||
|
transform: translateY(0) scale(1.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelf__name {
|
||||||
|
font-size: clamp(1.5rem, 6.4vw, 2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
font-stretch: 112%;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelf:hover .shelf__name,
|
||||||
|
.shelf:focus-visible .shelf__name {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shelf__pages {
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
max-width: 34ch;
|
max-width: 34ch;
|
||||||
margin: 0;
|
margin: 1rem 0 0;
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
font-size: var(--step-1);
|
font-size: var(--step-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Section page: the rail and the pour line ---------- */
|
/* ---------- Section page: the bar and the switcher ---------- */
|
||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -146,42 +195,50 @@ a {
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.85rem;
|
gap: 0.7rem;
|
||||||
padding: 0.5rem 0.9rem 0.4rem;
|
padding: 0.5rem 0.85rem 0.45rem;
|
||||||
background: var(--rail);
|
background: color-mix(in srgb, var(--rail) 94%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar__brand {
|
.bar__brand {
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-stretch: 118%;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 auto;
|
align-items: center;
|
||||||
gap: 0.15rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail::-webkit-scrollbar {
|
.bar__brand img {
|
||||||
display: none;
|
display: block;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail__item {
|
.switcher {
|
||||||
padding: 0.35rem 0.6rem;
|
display: inline-flex;
|
||||||
color: var(--ink-dim);
|
align-items: center;
|
||||||
font-size: 0.95rem;
|
gap: 0.45rem;
|
||||||
white-space: nowrap;
|
padding: 0.3rem 0.5rem;
|
||||||
}
|
border: 1px solid transparent;
|
||||||
|
color: var(--ink);
|
||||||
.rail__item.is-current {
|
font-size: 1.05rem;
|
||||||
background: var(--accent);
|
|
||||||
color: var(--steel);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher:hover {
|
||||||
|
border-color: var(--brass-edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher__caret {
|
||||||
|
width: 0.45rem;
|
||||||
|
height: 0.45rem;
|
||||||
|
border-right: 1.5px solid var(--copper);
|
||||||
|
border-bottom: 1.5px solid var(--copper);
|
||||||
|
transform: rotate(45deg) translate(-0.1rem, -0.1rem);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher[aria-expanded='true'] .switcher__caret {
|
||||||
|
transform: rotate(-135deg) translate(-0.05rem, -0.05rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pour {
|
.pour {
|
||||||
@@ -200,25 +257,130 @@ a {
|
|||||||
background: var(--copper);
|
background: var(--copper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- The switcher sheet ---------- */
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
background: color-mix(in srgb, #05090a 74%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__inner {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 88vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.1rem 1rem calc(1.2rem + env(safe-area-inset-bottom));
|
||||||
|
background: var(--rail);
|
||||||
|
border-top: 2px solid var(--copper);
|
||||||
|
animation: sheet-rise 0.24s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sheet-rise {
|
||||||
|
from {
|
||||||
|
transform: translateY(1.6rem);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2.4rem 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.5rem 0.4rem;
|
||||||
|
border-bottom: 1px solid var(--hairline-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__item.is-current {
|
||||||
|
background: color-mix(in srgb, var(--accent) 13%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__peek {
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 3rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__peek img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__name {
|
||||||
|
font-size: 1.12rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__state {
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__item.is-current .sheet__state {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__close {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
padding: 0.5rem 0.85rem;
|
||||||
|
border: 1px solid var(--brass-edge);
|
||||||
|
background: none;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- The pages themselves ---------- */
|
||||||
|
|
||||||
.section-heading {
|
.section-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
max-width: var(--page-max);
|
max-width: var(--page-max);
|
||||||
margin: 1.35rem auto 0.85rem;
|
margin: 1.2rem auto 0.75rem;
|
||||||
padding: 0 1rem;
|
padding: 0 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-heading__label {
|
.section-heading__label {
|
||||||
font-size: var(--step-2);
|
font-size: var(--step-2);
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-stretch: 116%;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-heading__date {
|
.section-heading__date {
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
font-size: 0.9rem;
|
font-size: 0.88rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,20 +401,19 @@ a {
|
|||||||
.notice--admin {
|
.notice--admin {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
border-left-color: var(--accent);
|
border-left-color: var(--accent);
|
||||||
background: color-mix(in srgb, var(--accent) 22%, var(--rail));
|
background: color-mix(in srgb, var(--accent) 20%, var(--rail));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- The Pages ---------- */
|
|
||||||
|
|
||||||
.pages {
|
.pages {
|
||||||
max-width: var(--page-max);
|
max-width: var(--page-max);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 0.75rem 1.25rem;
|
padding: 0 0.7rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
margin: 0 0 0.7rem;
|
margin: 0 0 0.6rem;
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
|
box-shadow: 0 1px 0 color-mix(in srgb, #000 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__zoom {
|
.page__zoom {
|
||||||
@@ -270,7 +431,7 @@ a {
|
|||||||
.foot {
|
.foot {
|
||||||
max-width: var(--page-max);
|
max-width: var(--page-max);
|
||||||
margin: 0 auto 4.5rem;
|
margin: 0 auto 4.5rem;
|
||||||
padding: 0 1rem 1rem;
|
padding: 0 0.9rem 1rem;
|
||||||
border-top: 1px solid var(--hairline-soft);
|
border-top: 1px solid var(--hairline-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +442,7 @@ a {
|
|||||||
|
|
||||||
.fold summary {
|
.fold summary {
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
font-size: 0.95rem;
|
font-size: 0.92rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +493,7 @@ a {
|
|||||||
|
|
||||||
.older__date {
|
.older__date {
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
font-size: 0.9rem;
|
font-size: 0.88rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,18 +510,18 @@ a {
|
|||||||
|
|
||||||
.counter {
|
.counter {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 0.9rem;
|
right: 0.85rem;
|
||||||
bottom: calc(0.9rem + env(safe-area-inset-bottom));
|
bottom: calc(0.85rem + env(safe-area-inset-bottom));
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
padding: 0.45rem 0.7rem;
|
padding: 0.45rem 0.7rem;
|
||||||
border: 1px solid var(--hairline);
|
border: 1px solid var(--brass-edge);
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
color: var(--ink-on-paper);
|
color: var(--ink-on-paper);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.9rem;
|
font-size: 0.88rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -376,9 +537,9 @@ a {
|
|||||||
.zoom {
|
.zoom {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 40;
|
z-index: 50;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: rgba(8, 10, 12, 0.95);
|
background: rgba(6, 9, 10, 0.96);
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +556,7 @@ a {
|
|||||||
right: 0.6rem;
|
right: 0.6rem;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border: 1px solid var(--hairline);
|
border: 1px solid var(--brass-edge);
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
color: var(--ink-on-paper);
|
color: var(--ink-on-paper);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -404,7 +565,17 @@ a {
|
|||||||
|
|
||||||
/* ---------- Wider screens ---------- */
|
/* ---------- Wider screens ---------- */
|
||||||
|
|
||||||
@media (min-width: 60rem) {
|
@media (min-width: 62rem) {
|
||||||
|
.shelves {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
column-gap: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelf {
|
||||||
|
padding-right: 5.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pages {
|
.pages {
|
||||||
max-width: 820px;
|
max-width: 820px;
|
||||||
}
|
}
|
||||||
@@ -412,6 +583,7 @@ a {
|
|||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.bar,
|
.bar,
|
||||||
|
.sheet,
|
||||||
.counter,
|
.counter,
|
||||||
.fold,
|
.fold,
|
||||||
.notice {
|
.notice {
|
||||||
|
|||||||
+10
-3
@@ -12,6 +12,7 @@ import sharp from 'sharp';
|
|||||||
import { parseSiteConfig } from '../src/config.ts';
|
import { parseSiteConfig } from '../src/config.ts';
|
||||||
import { createImagePipeline } from '../src/images/pipeline.ts';
|
import { createImagePipeline } from '../src/images/pipeline.ts';
|
||||||
import { SetStore } from '../src/storage/sets.ts';
|
import { SetStore } from '../src/storage/sets.ts';
|
||||||
|
import { SectionStore } from '../src/storage/sections.ts';
|
||||||
|
|
||||||
const root = path.resolve(process.env['MENU_ROOT'] ?? '.dev');
|
const root = path.resolve(process.env['MENU_ROOT'] ?? '.dev');
|
||||||
const configPath = path.resolve(
|
const configPath = path.resolve(
|
||||||
@@ -61,18 +62,24 @@ const plan: { section: string; label: string; validUntil: string | null; pages:
|
|||||||
{ section: 'whats-on', label: "What's On", validUntil: null, pages: 2 },
|
{ section: 'whats-on', label: "What's On", validUntil: null, pages: 2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
await rm(dataDir, { recursive: true, force: true });
|
|
||||||
await mkdir(dataDir, { recursive: true });
|
await mkdir(dataDir, { recursive: true });
|
||||||
|
for (const dir of ['drafts', 'published', 'originals']) {
|
||||||
|
await rm(path.join(dataDir, dir), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seeding replaces the menus, not the category list.
|
||||||
|
const sections = new SectionStore({ dataDir, seed: config.sections });
|
||||||
|
await sections.load();
|
||||||
|
|
||||||
const store = new SetStore({
|
const store = new SetStore({
|
||||||
dataDir,
|
dataDir,
|
||||||
pipeline: createImagePipeline(),
|
pipeline: createImagePipeline(),
|
||||||
sections: config.sections.map((section) => section.slug),
|
sections,
|
||||||
maxPagesPerSet: config.limits.maxPagesPerSet,
|
maxPagesPerSet: config.limits.maxPagesPerSet,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [index, entry] of plan.entries()) {
|
for (const [index, entry] of plan.entries()) {
|
||||||
const section = config.sections.find((candidate) => candidate.slug === entry.section);
|
const section = sections.bySlug(entry.section);
|
||||||
if (section === undefined) continue;
|
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) {
|
for (let page = 1; page <= entry.pages; page += 1) {
|
||||||
|
|||||||
+5
-4
@@ -6,13 +6,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { hasFatal, validateSite } from '../storage/validate.ts';
|
import { hasFatal, validateSite } from '../storage/validate.ts';
|
||||||
|
import { SectionStore } from '../storage/sections.ts';
|
||||||
import { loadCliSite } from './site.ts';
|
import { loadCliSite } from './site.ts';
|
||||||
|
|
||||||
const { config, dataDir } = await loadCliSite();
|
const { config, dataDir } = await loadCliSite();
|
||||||
const problems = await validateSite({
|
const sections = new SectionStore({ dataDir, seed: config.sections });
|
||||||
dataDir,
|
await sections.load();
|
||||||
sections: config.sections.map((section) => section.slug),
|
|
||||||
});
|
const problems = await validateSite({ dataDir, sections });
|
||||||
|
|
||||||
if (problems.length === 0) {
|
if (problems.length === 0) {
|
||||||
console.log(`ok: ${dataDir}`);
|
console.log(`ok: ${dataDir}`);
|
||||||
|
|||||||
+53
-1
@@ -88,17 +88,67 @@ function setRow(section: SectionConfig, set: StoredSet, kind: 'current' | 'draft
|
|||||||
|
|
||||||
export interface DashboardInput {
|
export interface DashboardInput {
|
||||||
config: SiteConfig;
|
config: SiteConfig;
|
||||||
|
/** Every Section, in order: the Site's own categories, not configuration. */
|
||||||
|
sections: readonly SectionConfig[];
|
||||||
sets: readonly StoredSet[];
|
sets: readonly StoredSet[];
|
||||||
current: ReadonlyMap<string, StoredSet>;
|
current: ReadonlyMap<string, StoredSet>;
|
||||||
csrf: string;
|
csrf: string;
|
||||||
notice: string | null;
|
notice: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCategories(input: DashboardInput): Html {
|
||||||
|
const rows = input.sections.map(
|
||||||
|
(section, index) => html`<li class="catrow">
|
||||||
|
<form class="catrow__name" method="post" action="/admin/sections/${section.slug}/rename">
|
||||||
|
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||||
|
<input type="text" name="label" value="${section.label}" aria-label="Name shown for ${section.label}" required />
|
||||||
|
<button class="button button--icon" type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
<span class="catrow__url">/${section.slug}/</span>
|
||||||
|
<span class="catrow__actions">
|
||||||
|
<form method="post" action="/admin/sections/${section.slug}/move">
|
||||||
|
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||||
|
<input type="hidden" name="direction" value="up" />
|
||||||
|
<button class="button button--icon" type="submit" title="Move up" ${index === 0 ? 'disabled' : ''}>↑</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/sections/${section.slug}/move">
|
||||||
|
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||||
|
<input type="hidden" name="direction" value="down" />
|
||||||
|
<button class="button button--icon" type="submit" title="Move down" ${index === input.sections.length - 1 ? 'disabled' : ''}>↓</button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/admin/sections/${section.slug}/delete"
|
||||||
|
data-confirm="Remove the ${section.label} category?"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||||
|
<button class="button button--icon button--danger" type="submit" title="Remove category">✕</button>
|
||||||
|
</form>
|
||||||
|
</span>
|
||||||
|
</li>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return html`<details class="fold fold--categories">
|
||||||
|
<summary>Categories (${input.sections.length})</summary>
|
||||||
|
<p class="muted">
|
||||||
|
The order here is the order customers see. A name can change at any time; the web address
|
||||||
|
beside it cannot, because that is what a table card or QR code points at.
|
||||||
|
</p>
|
||||||
|
<ul class="catlist">${join(rows)}</ul>
|
||||||
|
<form class="newset" method="post" action="/admin/sections">
|
||||||
|
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||||
|
${labelled('New category', html`<input type="text" name="label" placeholder="Desserts" required />`)}
|
||||||
|
<button class="button" type="submit">Add category</button>
|
||||||
|
</form>
|
||||||
|
</details>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderDashboard(input: DashboardInput): Html {
|
export function renderDashboard(input: DashboardInput): Html {
|
||||||
const { config } = input;
|
const { config } = input;
|
||||||
const bySection = (slug: string) => input.sets.filter((set) => set.section === slug);
|
const bySection = (slug: string) => input.sets.filter((set) => set.section === slug);
|
||||||
|
|
||||||
const blocks = config.sections.map((section) => {
|
const blocks = input.sections.map((section) => {
|
||||||
const all = bySection(section.slug);
|
const all = bySection(section.slug);
|
||||||
const current = input.current.get(section.slug);
|
const current = input.current.get(section.slug);
|
||||||
const drafts = all.filter((set) => set.state === 'draft');
|
const drafts = all.filter((set) => set.state === 'draft');
|
||||||
@@ -147,7 +197,9 @@ export function renderDashboard(input: DashboardInput): Html {
|
|||||||
</form>
|
</form>
|
||||||
</header>
|
</header>
|
||||||
${input.notice === null ? '' : html`<p class="notice">${input.notice}</p>`}
|
${input.notice === null ? '' : html`<p class="notice">${input.notice}</p>`}
|
||||||
|
${input.error === null ? '' : html`<p class="notice notice--error">${input.error}</p>`}
|
||||||
${blocks}
|
${blocks}
|
||||||
|
${renderCategories(input)}
|
||||||
</main>`,
|
</main>`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-29
@@ -24,6 +24,23 @@ export function mediaUrls(page: PageRecord): MediaUrls {
|
|||||||
return { webp, jpeg, thumb: `/media/thumb-${page.id}.${page.hash}.webp`, view: webp };
|
return { webp, jpeg, thumb: `/media/thumb-${page.id}.${page.hash}.webp`, view: webp };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function menuLinks(
|
||||||
|
sections: readonly SectionConfig[],
|
||||||
|
current: ReadonlyMap<string, StoredSet>,
|
||||||
|
): MenuLink[] {
|
||||||
|
return sections
|
||||||
|
.filter((section) => current.has(section.slug))
|
||||||
|
.map((section) => {
|
||||||
|
const set = current.get(section.slug)!;
|
||||||
|
return { section, cover: coverUrl(set), pages: set.manifest.pages.length };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coverUrl(set: StoredSet): string | null {
|
||||||
|
const first = set.manifest.pages[0];
|
||||||
|
return first === undefined ? null : mediaUrls(first).thumb;
|
||||||
|
}
|
||||||
|
|
||||||
export function pageCountLabel(count: number): string {
|
export function pageCountLabel(count: number): string {
|
||||||
return count === 1 ? '1 page' : `${count} pages`;
|
return count === 1 ? '1 page' : `${count} pages`;
|
||||||
}
|
}
|
||||||
@@ -47,22 +64,60 @@ export function setDateLabel(manifest: SetManifest): string {
|
|||||||
return 'not published';
|
return 'not published';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRail(config: SiteConfig, currentSection: string, published: ReadonlySet<string>): Html {
|
interface SheetEntry {
|
||||||
return html`<nav class="rail" aria-label="Menus">
|
section: SectionConfig;
|
||||||
${join(
|
current: boolean;
|
||||||
config.sections
|
cover: string | null;
|
||||||
.filter((section) => published.has(section.slug))
|
pages: number;
|
||||||
.map(
|
}
|
||||||
(section) =>
|
|
||||||
html`<a
|
/**
|
||||||
class="rail__item${section.slug === currentSection ? ' is-current' : ''}"
|
* The bar names the menu you are reading and opens a sheet with the rest. A row
|
||||||
href="/${section.slug}/"
|
* of tabs was unreadable at six categories and would not survive more.
|
||||||
${section.slug === currentSection ? html`aria-current="page"` : ''}
|
* The switcher is a link to the landing page, so it still works without the
|
||||||
>${section.shortLabel}</a
|
* script; the script opens the sheet instead of navigating.
|
||||||
>`,
|
*/
|
||||||
),
|
function renderBar(config: SiteConfig, currentLabel: string): Html {
|
||||||
)}
|
return html`<header class="bar">
|
||||||
</nav>`;
|
<a class="bar__brand" href="/" aria-label="${config.brand.name} — all menus">
|
||||||
|
<img src="${config.brand.logo ?? '/assets/favicon.svg'}" alt="" width="24" height="24" />
|
||||||
|
</a>
|
||||||
|
<a class="switcher" href="/" data-switcher aria-expanded="false" aria-controls="menu-sheet">
|
||||||
|
<span class="switcher__label">${currentLabel}</span>
|
||||||
|
<span class="switcher__caret" aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
<div class="pour" aria-hidden="true"><span class="pour__fill" data-pour></span></div>
|
||||||
|
</header>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSheet(config: SiteConfig, entries: readonly SheetEntry[]): Html {
|
||||||
|
return html`<section class="sheet" id="menu-sheet" data-sheet hidden aria-label="Menus">
|
||||||
|
<div class="sheet__inner">
|
||||||
|
<p class="sheet__title">${config.brand.name}</p>
|
||||||
|
<ul class="sheet__list">
|
||||||
|
${join(
|
||||||
|
entries.map(
|
||||||
|
(entry) => html`<li>
|
||||||
|
<a
|
||||||
|
class="sheet__item${entry.current ? ' is-current' : ''}"
|
||||||
|
href="/${entry.section.slug}/"
|
||||||
|
${entry.current ? html`aria-current="page"` : ''}
|
||||||
|
>
|
||||||
|
<span class="sheet__peek" aria-hidden="true">
|
||||||
|
${entry.cover === null
|
||||||
|
? ''
|
||||||
|
: html`<img src="${entry.cover}" alt="" loading="lazy" decoding="async" />`}
|
||||||
|
</span>
|
||||||
|
<span class="sheet__name">${entry.section.label}</span>
|
||||||
|
<span class="sheet__state">${entry.current ? 'Showing' : pageCountLabel(entry.pages)}</span>
|
||||||
|
</a>
|
||||||
|
</li>`,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<button class="sheet__close" type="button" data-sheet-close>Close</button>
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPages(section: SectionConfig, pages: readonly PageRecord[]): Html {
|
function renderPages(section: SectionConfig, pages: readonly PageRecord[]): Html {
|
||||||
@@ -125,10 +180,17 @@ function renderOlderMenus(section: SectionConfig, archives: readonly StoredSet[]
|
|||||||
</details>`;
|
</details>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MenuLink {
|
||||||
|
section: SectionConfig;
|
||||||
|
/** Top of the first Page, for the paper sliver. */
|
||||||
|
cover: string | null;
|
||||||
|
pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LandingInput {
|
export interface LandingInput {
|
||||||
config: SiteConfig;
|
config: SiteConfig;
|
||||||
/** Sections that have a Current Menu Set, in config order. */
|
/** Sections that have a Current Menu Set, in the order customers should see. */
|
||||||
live: readonly { section: SectionConfig; set: StoredSet }[];
|
live: readonly MenuLink[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderLanding(input: LandingInput): Html {
|
export function renderLanding(input: LandingInput): Html {
|
||||||
@@ -136,12 +198,17 @@ export function renderLanding(input: LandingInput): Html {
|
|||||||
const rows =
|
const rows =
|
||||||
input.live.length === 0
|
input.live.length === 0
|
||||||
? html`<p class="empty">The menus are being updated. Please ask a member of staff.</p>`
|
? html`<p class="empty">The menus are being updated. Please ask a member of staff.</p>`
|
||||||
: html`<nav class="rows" aria-label="Menus">
|
: html`<nav class="shelves" aria-label="Menus">
|
||||||
${join(
|
${join(
|
||||||
input.live.map(
|
input.live.map(
|
||||||
({ section, set }) => html`<a class="row" href="/${section.slug}/">
|
(entry) => html`<a class="shelf" href="/${entry.section.slug}/">
|
||||||
<span class="row__label">${section.label}</span>
|
${entry.cover === null
|
||||||
<span class="row__meta">${pageCountLabel(set.manifest.pages.length)}</span>
|
? ''
|
||||||
|
: html`<span class="shelf__peek" aria-hidden="true"
|
||||||
|
><img src="${entry.cover}" alt="" loading="lazy" decoding="async"
|
||||||
|
/></span>`}
|
||||||
|
<span class="shelf__name">${entry.section.label}</span>
|
||||||
|
<span class="shelf__pages">${pageCountLabel(entry.pages)}</span>
|
||||||
</a>`,
|
</a>`,
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@@ -171,8 +238,8 @@ export interface SectionInput {
|
|||||||
config: SiteConfig;
|
config: SiteConfig;
|
||||||
section: SectionConfig;
|
section: SectionConfig;
|
||||||
set: StoredSet;
|
set: StoredSet;
|
||||||
/** Section slugs that have a Current Menu Set, for the rail. */
|
/** Every Section with a Current Menu Set, in order, for the switcher. */
|
||||||
published: ReadonlySet<string>;
|
menu: readonly MenuLink[];
|
||||||
current: boolean;
|
current: boolean;
|
||||||
archives: readonly StoredSet[];
|
archives: readonly StoredSet[];
|
||||||
/** Draft preview for the admin: adds a banner and skips the archive fold. */
|
/** Draft preview for the admin: adds a banner and skips the archive fold. */
|
||||||
@@ -184,11 +251,16 @@ export function renderSectionPage(input: SectionInput): Html {
|
|||||||
const currentHref = `/${section.slug}/`;
|
const currentHref = `/${section.slug}/`;
|
||||||
const total = set.manifest.pages.length;
|
const total = set.manifest.pages.length;
|
||||||
|
|
||||||
const body = html`<header class="bar">
|
const body = html`${renderBar(config, section.label)}
|
||||||
<a class="bar__brand" href="/">${config.brand.name}</a>
|
${renderSheet(
|
||||||
${renderRail(config, section.slug, input.published)}
|
config,
|
||||||
<div class="pour" aria-hidden="true"><span class="pour__fill" data-pour></span></div>
|
input.menu.map((entry) => ({
|
||||||
</header>
|
section: entry.section,
|
||||||
|
current: entry.section.slug === section.slug,
|
||||||
|
cover: entry.cover,
|
||||||
|
pages: entry.pages,
|
||||||
|
})),
|
||||||
|
)}
|
||||||
${input.preview
|
${input.preview
|
||||||
? html`<p class="notice notice--admin">
|
? html`<p class="notice notice--admin">
|
||||||
Preview of a Draft — customers cannot see this yet.
|
Preview of a Draft — customers cannot see this yet.
|
||||||
|
|||||||
+102
-12
@@ -15,13 +15,14 @@ import {
|
|||||||
verifyPassword,
|
verifyPassword,
|
||||||
verifySessionToken,
|
verifySessionToken,
|
||||||
} from '../auth.ts';
|
} from '../auth.ts';
|
||||||
import { sectionBySlug, type SiteConfig } from '../config.ts';
|
import type { SiteConfig } from '../config.ts';
|
||||||
import { renderDashboard, renderEditor, renderLogin } from '../render/admin.ts';
|
import { renderDashboard, renderEditor, renderLogin } from '../render/admin.ts';
|
||||||
import { renderSectionPage } from '../render/pages.ts';
|
import { menuLinks, renderSectionPage } from '../render/pages.ts';
|
||||||
import { UnsupportedImageError } from '../images/pipeline.ts';
|
import { UnsupportedImageError } from '../images/pipeline.ts';
|
||||||
import type { SetStore, StoredSet } from '../storage/sets.ts';
|
import type { SetStore, StoredSet } from '../storage/sets.ts';
|
||||||
import { StoreError } from '../storage/sets.ts';
|
import { StoreError } from '../storage/sets.ts';
|
||||||
import { sessionCookieAttributes, SESSION_TTL_MS } from '../server.ts';
|
import { sessionCookieAttributes, SESSION_TTL_MS } from '../server.ts';
|
||||||
|
import type { SectionStore } from '../storage/sections.ts';
|
||||||
|
|
||||||
const HTML = 'text/html; charset=utf-8';
|
const HTML = 'text/html; charset=utf-8';
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ export function describeBytes(bytes: number): string {
|
|||||||
|
|
||||||
export interface AdminRouteDeps {
|
export interface AdminRouteDeps {
|
||||||
config: SiteConfig;
|
config: SiteConfig;
|
||||||
|
sections: SectionStore;
|
||||||
store: SetStore;
|
store: SetStore;
|
||||||
sessionSecret: string;
|
sessionSecret: string;
|
||||||
adminPasswordHash: string | null;
|
adminPasswordHash: string | null;
|
||||||
@@ -63,7 +65,7 @@ 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 { config, store } = deps;
|
const { config, sections, store } = deps;
|
||||||
const throttle = deps.throttle ?? new LoginThrottle();
|
const throttle = deps.throttle ?? new LoginThrottle();
|
||||||
|
|
||||||
const sessionToken = (request: FastifyRequest): string | undefined =>
|
const sessionToken = (request: FastifyRequest): string | undefined =>
|
||||||
@@ -82,13 +84,23 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
|||||||
const wantsJson = (request: FastifyRequest): boolean =>
|
const wantsJson = (request: FastifyRequest): boolean =>
|
||||||
(request.headers['accept'] ?? '').includes('application/json');
|
(request.headers['accept'] ?? '').includes('application/json');
|
||||||
|
|
||||||
const back = (request: FastifyRequest, reply: FastifyReply, notice: string | null) => {
|
const backWith = (
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
message: { notice?: string | undefined; error?: string | undefined },
|
||||||
|
) => {
|
||||||
const target = request.headers['referer'];
|
const target = request.headers['referer'];
|
||||||
const base = typeof target === 'string' ? new URL(target, 'http://x').pathname : '/admin';
|
const base = typeof target === 'string' ? new URL(target, 'http://x').pathname : '/admin';
|
||||||
const url = notice === null ? base : `${base}?notice=${encodeURIComponent(notice)}`;
|
const query = new URLSearchParams();
|
||||||
return reply.redirect(url, 303);
|
if (message.notice !== undefined) query.set('notice', message.notice);
|
||||||
|
if (message.error !== undefined) query.set('error', message.error);
|
||||||
|
const suffix = query.toString();
|
||||||
|
return reply.redirect(suffix === '' ? base : `${base}?${suffix}`, 303);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const back = (request: FastifyRequest, reply: FastifyReply, notice: string | null) =>
|
||||||
|
backWith(request, reply, notice === null ? {} : { notice });
|
||||||
|
|
||||||
const guard = async (
|
const guard = async (
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
@@ -169,15 +181,17 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
|||||||
|
|
||||||
app.get('/admin/', async (request, reply) => {
|
app.get('/admin/', async (request, reply) => {
|
||||||
if (!authed(request)) return reply.redirect('/admin/login', 303);
|
if (!authed(request)) return reply.redirect('/admin/login', 303);
|
||||||
const notice = (request.query as { notice?: string }).notice ?? null;
|
const query = request.query as { notice?: string; error?: string };
|
||||||
return sendHtml(
|
return sendHtml(
|
||||||
reply,
|
reply,
|
||||||
renderDashboard({
|
renderDashboard({
|
||||||
config,
|
config,
|
||||||
|
sections: sections.list(),
|
||||||
sets: await store.list(),
|
sets: await store.list(),
|
||||||
current: await store.currentBySection(),
|
current: await store.currentBySection(),
|
||||||
csrf: csrfToken(sessionToken(request), deps.sessionSecret),
|
csrf: csrfToken(sessionToken(request), deps.sessionSecret),
|
||||||
notice,
|
notice: query.notice ?? null,
|
||||||
|
error: query.error ?? null,
|
||||||
}).value,
|
}).value,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -190,7 +204,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
|||||||
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 body = request.body as { section?: string; label?: string; validUntil?: string };
|
||||||
const section = sectionBySlug(config, body.section ?? '');
|
const section = sections.bySlug(body.section ?? '');
|
||||||
if (section === undefined) return reply.code(400).send('Unknown Section.');
|
if (section === undefined) return reply.code(400).send('Unknown Section.');
|
||||||
try {
|
try {
|
||||||
const created = await store.create(section.slug, {
|
const created = await store.create(section.slug, {
|
||||||
@@ -211,7 +225,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
|||||||
} catch {
|
} catch {
|
||||||
return reply.code(404).type(HTML).send('That Menu Set no longer exists.');
|
return reply.code(404).type(HTML).send('That Menu Set no longer exists.');
|
||||||
}
|
}
|
||||||
const section = sectionBySlug(config, set.section);
|
const section = sections.bySlug(set.section);
|
||||||
if (section === undefined) return reply.code(404).send('Unknown Section.');
|
if (section === undefined) return reply.code(404).send('Unknown Section.');
|
||||||
const query = request.query as { notice?: string; error?: string };
|
const query = request.query as { notice?: string; error?: string };
|
||||||
const current = (await store.currentBySection()).get(set.section);
|
const current = (await store.currentBySection()).get(set.section);
|
||||||
@@ -232,7 +246,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
|||||||
app.get<{ Params: { id: string } }>('/admin/sets/:id/preview', async (request, reply) => {
|
app.get<{ Params: { id: string } }>('/admin/sets/:id/preview', async (request, reply) => {
|
||||||
if (!authed(request)) return reply.redirect('/admin/login', 303);
|
if (!authed(request)) return reply.redirect('/admin/login', 303);
|
||||||
const set = await store.get(request.params.id);
|
const set = await store.get(request.params.id);
|
||||||
const section = sectionBySlug(config, set.section);
|
const section = sections.bySlug(set.section);
|
||||||
if (section === undefined) return reply.code(404).send('Unknown Section.');
|
if (section === undefined) return reply.code(404).send('Unknown Section.');
|
||||||
const current = (await store.currentBySection()).get(section.slug);
|
const current = (await store.currentBySection()).get(section.slug);
|
||||||
return sendHtml(
|
return sendHtml(
|
||||||
@@ -241,7 +255,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
|||||||
config,
|
config,
|
||||||
section,
|
section,
|
||||||
set,
|
set,
|
||||||
published: new Set((await store.currentBySection()).keys()),
|
menu: menuLinks(sections.list(), await store.currentBySection()),
|
||||||
current: current?.id === set.id,
|
current: current?.id === set.id,
|
||||||
preview: set.state === 'draft',
|
preview: set.state === 'draft',
|
||||||
archives: [],
|
archives: [],
|
||||||
@@ -299,6 +313,82 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
|||||||
return reply.redirect('/admin', 303);
|
return reply.redirect('/admin', 303);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- Categories ----------
|
||||||
|
|
||||||
|
const guardAdmin = (request: FastifyRequest, reply: FastifyReply): boolean => {
|
||||||
|
if (!authed(request)) {
|
||||||
|
reply.redirect('/admin/login', 303);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!sameOrigin(request) || !csrfOk(request)) {
|
||||||
|
reply.code(403).type(HTML).send('That request was rejected. Reload the page and try again.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const reason = (error: unknown): string =>
|
||||||
|
error instanceof Error ? error.message : 'That did not work.';
|
||||||
|
|
||||||
|
app.post('/admin/sections', async (request, reply) => {
|
||||||
|
if (!guardAdmin(request, reply)) return reply;
|
||||||
|
const body = request.body as { label?: string };
|
||||||
|
try {
|
||||||
|
const created = await sections.create({ label: body.label ?? '' });
|
||||||
|
return backWith(request, reply, {
|
||||||
|
notice: `${created.label} added. Its web address is /${created.slug}/ — that stays put even if you rename it.`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return backWith(request, reply, { error: reason(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { slug: string } }>('/admin/sections/:slug/rename', async (request, reply) => {
|
||||||
|
if (!guardAdmin(request, reply)) return reply;
|
||||||
|
const body = request.body as { label?: string };
|
||||||
|
try {
|
||||||
|
const renamed = await sections.rename(request.params.slug, { label: body.label ?? '' });
|
||||||
|
return backWith(request, reply, {
|
||||||
|
notice: `${renamed.label} renamed. Its web address is still /${renamed.slug}/.`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return backWith(request, reply, { error: reason(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { slug: string } }>('/admin/sections/:slug/move', async (request, reply) => {
|
||||||
|
if (!guardAdmin(request, reply)) return reply;
|
||||||
|
const body = request.body as { direction?: string };
|
||||||
|
try {
|
||||||
|
await sections.move(request.params.slug, body.direction === 'up' ? 'up' : 'down');
|
||||||
|
} catch (error) {
|
||||||
|
return backWith(request, reply, { error: reason(error) });
|
||||||
|
}
|
||||||
|
return backWith(request, reply, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { slug: string } }>('/admin/sections/:slug/delete', async (request, reply) => {
|
||||||
|
if (!guardAdmin(request, reply)) return reply;
|
||||||
|
const existing = sections.bySlug(request.params.slug);
|
||||||
|
if (existing === undefined) {
|
||||||
|
return backWith(request, reply, { error: `No such category: ${request.params.slug}` });
|
||||||
|
}
|
||||||
|
const affected = (await store.list()).filter((set) => set.section === existing.slug);
|
||||||
|
if (affected.length > 0) {
|
||||||
|
return backWith(request, reply, {
|
||||||
|
error:
|
||||||
|
`${existing.label} still holds ${affected.length} menu ` +
|
||||||
|
`${affected.length === 1 ? 'set' : 'sets'}. Delete those first, or rename the category instead.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await sections.remove(existing.slug);
|
||||||
|
} catch (error) {
|
||||||
|
return backWith(request, reply, { error: reason(error) });
|
||||||
|
}
|
||||||
|
return backWith(request, reply, { notice: `${existing.label} removed.` });
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- Pages ----------
|
// ---------- Pages ----------
|
||||||
|
|
||||||
app.post<{ Params: { id: string } }>('/admin/sets/:id/pages', async (request, reply) => {
|
app.post<{ Params: { id: string } }>('/admin/sets/:id/pages', async (request, reply) => {
|
||||||
|
|||||||
+18
-16
@@ -2,17 +2,24 @@ import { createReadStream } from 'node:fs';
|
|||||||
import { stat } from 'node:fs/promises';
|
import { stat } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { sectionBySlug, type SiteConfig } from '../config.ts';
|
import type { SiteConfig } from '../config.ts';
|
||||||
|
import type { SectionStore } from '../storage/sections.ts';
|
||||||
import { SESSION_COOKIE, verifySessionToken } from '../auth.ts';
|
import { SESSION_COOKIE, verifySessionToken } from '../auth.ts';
|
||||||
import { variantFileNames } from '../storage/manifest.ts';
|
import { variantFileNames } from '../storage/manifest.ts';
|
||||||
import type { SetStore } from '../storage/sets.ts';
|
import type { SetStore } from '../storage/sets.ts';
|
||||||
import { renderLanding, renderNotFound, renderSectionPage } from '../render/pages.ts';
|
import {
|
||||||
|
menuLinks,
|
||||||
|
renderLanding,
|
||||||
|
renderNotFound,
|
||||||
|
renderSectionPage,
|
||||||
|
} from '../render/pages.ts';
|
||||||
|
|
||||||
const HTML = 'text/html; charset=utf-8';
|
const HTML = 'text/html; charset=utf-8';
|
||||||
const MEDIA_NAME = /^(thumb-)?([0-9a-f-]{36})\.([0-9a-f]{8,64})\.(webp|jpg)$/;
|
const MEDIA_NAME = /^(thumb-)?([0-9a-f-]{36})\.([0-9a-f]{8,64})\.(webp|jpg)$/;
|
||||||
|
|
||||||
export interface PublicRouteDeps {
|
export interface PublicRouteDeps {
|
||||||
config: SiteConfig;
|
config: SiteConfig;
|
||||||
|
sections: SectionStore;
|
||||||
store: SetStore;
|
store: SetStore;
|
||||||
sessionSecret: string;
|
sessionSecret: string;
|
||||||
}
|
}
|
||||||
@@ -43,13 +50,12 @@ export async function registerPublicRoutes(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
deps: PublicRouteDeps,
|
deps: PublicRouteDeps,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { config, store } = deps;
|
const { config, sections, store } = deps;
|
||||||
|
|
||||||
const isAuthed = (request: FastifyRequest): boolean =>
|
const isAuthed = (request: FastifyRequest): boolean =>
|
||||||
verifySessionToken(request.cookies[SESSION_COOKIE], deps.sessionSecret);
|
verifySessionToken(request.cookies[SESSION_COOKIE], deps.sessionSecret);
|
||||||
|
|
||||||
const publishedSections = async (): Promise<Set<string>> =>
|
const menu = async () => menuLinks(sections.list(), await store.currentBySection());
|
||||||
new Set((await store.currentBySection()).keys());
|
|
||||||
|
|
||||||
app.get('/healthz', async (_request, reply) => reply.type('text/plain').send('ok'));
|
app.get('/healthz', async (_request, reply) => reply.type('text/plain').send('ok'));
|
||||||
|
|
||||||
@@ -58,21 +64,17 @@ export async function registerPublicRoutes(
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.get('/', async (_request, reply) => {
|
app.get('/', async (_request, reply) => {
|
||||||
const current = await store.currentBySection();
|
return sendHtml(reply, renderLanding({ config, live: await menu() }).value);
|
||||||
const live = config.sections
|
|
||||||
.filter((section) => current.has(section.slug))
|
|
||||||
.map((section) => ({ section, set: current.get(section.slug)! }));
|
|
||||||
return sendHtml(reply, renderLanding({ config, live }).value);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Params: { section: string } }>('/:section/', async (request, reply) => {
|
app.get<{ Params: { section: string } }>('/:section/', async (request, reply) => {
|
||||||
const section = sectionBySlug(config, request.params.section);
|
const section = sections.bySlug(request.params.section);
|
||||||
if (section === undefined) {
|
if (section === undefined) {
|
||||||
return sendHtml(reply, renderNotFound(config, []).value, 404);
|
return sendHtml(reply, renderNotFound(config, []).value, 404);
|
||||||
}
|
}
|
||||||
const current = (await store.currentBySection()).get(section.slug);
|
const current = (await store.currentBySection()).get(section.slug);
|
||||||
if (current === undefined) {
|
if (current === undefined) {
|
||||||
return sendHtml(reply, renderNotFound(config, config.sections).value, 404);
|
return sendHtml(reply, renderNotFound(config, sections.list()).value, 404);
|
||||||
}
|
}
|
||||||
return sendHtml(
|
return sendHtml(
|
||||||
reply,
|
reply,
|
||||||
@@ -80,7 +82,7 @@ export async function registerPublicRoutes(
|
|||||||
config,
|
config,
|
||||||
section,
|
section,
|
||||||
set: current,
|
set: current,
|
||||||
published: await publishedSections(),
|
menu: await menu(),
|
||||||
current: true,
|
current: true,
|
||||||
preview: false,
|
preview: false,
|
||||||
archives: await store.archives(section.slug),
|
archives: await store.archives(section.slug),
|
||||||
@@ -91,7 +93,7 @@ export async function registerPublicRoutes(
|
|||||||
app.get<{ Params: { section: string; slug: string } }>(
|
app.get<{ Params: { section: string; slug: string } }>(
|
||||||
'/:section/:slug/',
|
'/:section/:slug/',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const section = sectionBySlug(config, request.params.section);
|
const section = sections.bySlug(request.params.section);
|
||||||
if (section === undefined) {
|
if (section === undefined) {
|
||||||
return sendHtml(reply, renderNotFound(config, []).value, 404);
|
return sendHtml(reply, renderNotFound(config, []).value, 404);
|
||||||
}
|
}
|
||||||
@@ -102,7 +104,7 @@ export async function registerPublicRoutes(
|
|||||||
candidate.slug === request.params.slug,
|
candidate.slug === request.params.slug,
|
||||||
);
|
);
|
||||||
if (set === undefined) {
|
if (set === undefined) {
|
||||||
return sendHtml(reply, renderNotFound(config, config.sections).value, 404);
|
return sendHtml(reply, renderNotFound(config, sections.list()).value, 404);
|
||||||
}
|
}
|
||||||
const current = (await store.currentBySection()).get(section.slug);
|
const current = (await store.currentBySection()).get(section.slug);
|
||||||
const archives = (await store.archives(section.slug)).filter(
|
const archives = (await store.archives(section.slug)).filter(
|
||||||
@@ -114,7 +116,7 @@ export async function registerPublicRoutes(
|
|||||||
config,
|
config,
|
||||||
section,
|
section,
|
||||||
set,
|
set,
|
||||||
published: await publishedSections(),
|
menu: await menu(),
|
||||||
current: current?.id === set.id,
|
current: current?.id === set.id,
|
||||||
preview: false,
|
preview: false,
|
||||||
archives,
|
archives,
|
||||||
|
|||||||
+19
-6
@@ -15,6 +15,7 @@ import { parseSiteConfig, type SiteConfig } from './config.ts';
|
|||||||
import { SESSION_COOKIE, sessionTtl, type LoginThrottle } from './auth.ts';
|
import { SESSION_COOKIE, sessionTtl, type LoginThrottle } from './auth.ts';
|
||||||
import { createImagePipeline } from './images/pipeline.ts';
|
import { createImagePipeline } from './images/pipeline.ts';
|
||||||
import { SetStore, type ImagePipeline } from './storage/sets.ts';
|
import { SetStore, type ImagePipeline } from './storage/sets.ts';
|
||||||
|
import { SectionStore } from './storage/sections.ts';
|
||||||
import { hasFatal, validateSite } from './storage/validate.ts';
|
import { hasFatal, validateSite } from './storage/validate.ts';
|
||||||
import { renderNotFound } from './render/pages.ts';
|
import { renderNotFound } from './render/pages.ts';
|
||||||
import { registerPublicRoutes } from './routes/public.ts';
|
import { registerPublicRoutes } from './routes/public.ts';
|
||||||
@@ -26,6 +27,7 @@ export const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
|||||||
/** What every route reaches for: the store, the session secret, the admin hash. */
|
/** What every route reaches for: the store, the session secret, the admin hash. */
|
||||||
export interface MenuContext {
|
export interface MenuContext {
|
||||||
store: SetStore;
|
store: SetStore;
|
||||||
|
sections: SectionStore;
|
||||||
sessionSecret: string;
|
sessionSecret: string;
|
||||||
sessionTtlMs: number;
|
sessionTtlMs: number;
|
||||||
adminPasswordHash: string | null;
|
adminPasswordHash: string | null;
|
||||||
@@ -53,6 +55,8 @@ export interface AppOptions {
|
|||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
/** Test seam: a shared throttle so rate-limit tests are deterministic. */
|
/** Test seam: a shared throttle so rate-limit tests are deterministic. */
|
||||||
throttle?: LoginThrottle;
|
throttle?: LoginThrottle;
|
||||||
|
/** Created and loaded by main so boot validation sees the same list. */
|
||||||
|
sectionsStore?: SectionStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
||||||
@@ -80,22 +84,29 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
|||||||
maxAge: '5m',
|
maxAge: '5m',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sections =
|
||||||
|
options.sectionsStore ??
|
||||||
|
new SectionStore({ dataDir: options.dataDir, seed: options.config.sections });
|
||||||
|
await sections.load();
|
||||||
|
|
||||||
const store = new SetStore({
|
const store = new SetStore({
|
||||||
dataDir: options.dataDir,
|
dataDir: options.dataDir,
|
||||||
pipeline: options.pipeline ?? createImagePipeline(),
|
pipeline: options.pipeline ?? createImagePipeline(),
|
||||||
sections: options.config.sections.map((section) => section.slug),
|
sections,
|
||||||
maxPagesPerSet: options.config.limits.maxPagesPerSet,
|
maxPagesPerSet: options.config.limits.maxPagesPerSet,
|
||||||
now: options.now,
|
now: options.now,
|
||||||
});
|
});
|
||||||
|
|
||||||
await registerPublicRoutes(app, {
|
await registerPublicRoutes(app, {
|
||||||
config: options.config,
|
config: options.config,
|
||||||
|
sections,
|
||||||
store,
|
store,
|
||||||
sessionSecret: options.sessionSecret,
|
sessionSecret: options.sessionSecret,
|
||||||
});
|
});
|
||||||
|
|
||||||
await registerAdminRoutes(app, {
|
await registerAdminRoutes(app, {
|
||||||
config: options.config,
|
config: options.config,
|
||||||
|
sections,
|
||||||
store,
|
store,
|
||||||
sessionSecret: options.sessionSecret,
|
sessionSecret: options.sessionSecret,
|
||||||
adminPasswordHash: options.adminPasswordHash,
|
adminPasswordHash: options.adminPasswordHash,
|
||||||
@@ -106,7 +117,7 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
|||||||
|
|
||||||
app.setNotFoundHandler(async (_request, reply) => {
|
app.setNotFoundHandler(async (_request, reply) => {
|
||||||
const current = await store.currentBySection();
|
const current = await store.currentBySection();
|
||||||
const live = options.config.sections.filter((section) => current.has(section.slug));
|
const live = sections.list().filter((section) => current.has(section.slug));
|
||||||
return reply
|
return reply
|
||||||
.code(404)
|
.code(404)
|
||||||
.type('text/html; charset=utf-8')
|
.type('text/html; charset=utf-8')
|
||||||
@@ -121,6 +132,7 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
|||||||
|
|
||||||
app.decorate('menu', {
|
app.decorate('menu', {
|
||||||
store,
|
store,
|
||||||
|
sections,
|
||||||
sessionSecret: options.sessionSecret,
|
sessionSecret: options.sessionSecret,
|
||||||
sessionTtlMs: SESSION_TTL_MS,
|
sessionTtlMs: SESSION_TTL_MS,
|
||||||
adminPasswordHash: options.adminPasswordHash,
|
adminPasswordHash: options.adminPasswordHash,
|
||||||
@@ -162,10 +174,10 @@ async function main(): Promise<void> {
|
|||||||
console.warn('ADMIN_PASSWORD_HASH is not set; the admin is disabled');
|
console.warn('ADMIN_PASSWORD_HASH is not set; the admin is disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
const problems = await validateSite({
|
const sections = new SectionStore({ dataDir, seed: config.sections });
|
||||||
dataDir,
|
await sections.load();
|
||||||
sections: config.sections.map((section) => section.slug),
|
|
||||||
});
|
const problems = await validateSite({ dataDir, sections });
|
||||||
for (const problem of problems) {
|
for (const problem of problems) {
|
||||||
const line = `${problem.level}: ${problem.message}`;
|
const line = `${problem.level}: ${problem.message}`;
|
||||||
if (problem.level === 'fatal') console.error(line);
|
if (problem.level === 'fatal') console.error(line);
|
||||||
@@ -179,6 +191,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
config,
|
config,
|
||||||
|
sectionsStore: sections,
|
||||||
dataDir,
|
dataDir,
|
||||||
sessionSecret,
|
sessionSecret,
|
||||||
adminPasswordHash,
|
adminPasswordHash,
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Sections are Site *content*, not Site configuration: they can be added,
|
||||||
|
* renamed, reordered and removed from the admin. The list lives in the data
|
||||||
|
* directory, so it is covered by the same backup as the menus, and the config
|
||||||
|
* file's Section list is only the seed for a Site that has never been opened.
|
||||||
|
*
|
||||||
|
* A Section's URL is fixed once created: labels change, slugs do not, because
|
||||||
|
* the URL is what gets printed on a table card or a QR code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { RESERVED_SLUGS, type SectionConfig } from '../config.ts';
|
||||||
|
import { writeJsonAtomic } from './manifest.ts';
|
||||||
|
|
||||||
|
export class SectionError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
|
||||||
|
constructor(message: string, status = 400) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SectionError';
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "What's On" -> "whats-on", "Straight Drinks" -> "straight-drinks" */
|
||||||
|
export function slugify(label: string): string {
|
||||||
|
return label
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/['’`]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanLabel(label: unknown, where = 'label'): string {
|
||||||
|
if (typeof label !== 'string' || label.trim() === '') {
|
||||||
|
throw new SectionError(`${where} cannot be empty`);
|
||||||
|
}
|
||||||
|
return label.trim().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(raw: unknown): SectionConfig[] {
|
||||||
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||||
|
throw new SectionError('sections.json must be an object');
|
||||||
|
}
|
||||||
|
const sections = (raw as { sections?: unknown }).sections;
|
||||||
|
if (!Array.isArray(sections)) throw new SectionError('sections.json must hold a sections array');
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const parsed = sections.map((entry, index) => {
|
||||||
|
if (typeof entry !== 'object' || entry === null) {
|
||||||
|
throw new SectionError(`sections[${index}] must be an object`);
|
||||||
|
}
|
||||||
|
const candidate = entry as Record<string, unknown>;
|
||||||
|
const slug = candidate['slug'];
|
||||||
|
if (typeof slug !== 'string' || slug === '') {
|
||||||
|
throw new SectionError(`sections[${index}].slug must be a string`);
|
||||||
|
}
|
||||||
|
if (seen.has(slug)) throw new SectionError(`two Sections share the slug ${slug}`);
|
||||||
|
seen.add(slug);
|
||||||
|
const order = candidate['order'];
|
||||||
|
if (typeof order !== 'number' || !Number.isSafeInteger(order)) {
|
||||||
|
throw new SectionError(`sections[${index}].order must be a whole number`);
|
||||||
|
}
|
||||||
|
const label = cleanLabel(candidate['label'], `sections[${index}].label`);
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
label,
|
||||||
|
shortLabel: typeof candidate['shortLabel'] === 'string' && candidate['shortLabel'] !== ''
|
||||||
|
? candidate['shortLabel']
|
||||||
|
: label,
|
||||||
|
note: typeof candidate['note'] === 'string' && candidate['note'] !== '' ? candidate['note'] : null,
|
||||||
|
order,
|
||||||
|
} satisfies SectionConfig;
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsed.sort((a, b) => a.order - b.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionStoreOptions {
|
||||||
|
dataDir: string;
|
||||||
|
/** Used only when the Site has no sections.json yet. */
|
||||||
|
seed: readonly SectionConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SectionStore {
|
||||||
|
readonly file: string;
|
||||||
|
|
||||||
|
private readonly seed: readonly SectionConfig[];
|
||||||
|
private sections: SectionConfig[] | null = null;
|
||||||
|
|
||||||
|
constructor(options: SectionStoreOptions) {
|
||||||
|
this.file = path.join(options.dataDir, 'sections.json');
|
||||||
|
this.seed = options.seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the list once; every later access is an in-memory read. */
|
||||||
|
async load(): Promise<void> {
|
||||||
|
if (this.sections !== null) return;
|
||||||
|
try {
|
||||||
|
this.sections = parse(JSON.parse(await readFile(this.file, 'utf8')));
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
throw new SectionError(
|
||||||
|
`${this.file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.sections = [...this.seed].sort((a, b) => a.order - b.order);
|
||||||
|
if (this.sections.length > 0) await this.save(this.sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): readonly SectionConfig[] {
|
||||||
|
if (this.sections === null) throw new Error('SectionStore.load() has not been awaited');
|
||||||
|
return this.sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
bySlug(slug: string): SectionConfig | undefined {
|
||||||
|
return this.list().find((section) => section.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
has(slug: string): boolean {
|
||||||
|
return this.bySlug(slug) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: { label: string }): Promise<SectionConfig> {
|
||||||
|
const label = cleanLabel(input.label);
|
||||||
|
const slug = this.uniqueSlug(slugify(label));
|
||||||
|
if (slug === '') throw new SectionError(`"${label}" does not make a usable web address`);
|
||||||
|
const order = this.list().reduce((max, section) => Math.max(max, section.order), 0) + 1;
|
||||||
|
const created: SectionConfig = { slug, label, shortLabel: label, note: null, order };
|
||||||
|
await this.save([...this.list(), created]);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rename(slug: string, input: { label: string }): Promise<SectionConfig> {
|
||||||
|
const label = cleanLabel(input.label);
|
||||||
|
const existing = this.require(slug);
|
||||||
|
const renamed: SectionConfig = { ...existing, label, shortLabel: label };
|
||||||
|
await this.save(this.list().map((section) => (section.slug === slug ? renamed : section)));
|
||||||
|
return renamed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async move(slug: string, direction: 'up' | 'down'): Promise<void> {
|
||||||
|
this.require(slug);
|
||||||
|
const ordered = [...this.list()];
|
||||||
|
const index = ordered.findIndex((section) => section.slug === slug);
|
||||||
|
const target = direction === 'up' ? index - 1 : index + 1;
|
||||||
|
if (target < 0 || target >= ordered.length) return;
|
||||||
|
const [moved] = ordered.splice(index, 1);
|
||||||
|
ordered.splice(target, 0, moved!);
|
||||||
|
await this.save(ordered.map((section, position) => ({ ...section, order: position + 1 })));
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(slug: string): Promise<void> {
|
||||||
|
this.require(slug);
|
||||||
|
await this.save(
|
||||||
|
this.list()
|
||||||
|
.filter((section) => section.slug !== slug)
|
||||||
|
.map((section, position) => ({ ...section, order: position + 1 })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private require(slug: string): SectionConfig {
|
||||||
|
const found = this.bySlug(slug);
|
||||||
|
if (found === undefined) throw new SectionError(`no such Section: ${slug}`, 404);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
private uniqueSlug(base: string): string {
|
||||||
|
if (!RESERVED_SLUGS.has(base)) {
|
||||||
|
const taken = this.list().some((section) => section.slug === base);
|
||||||
|
if (!taken) return base;
|
||||||
|
}
|
||||||
|
for (let suffix = 2; suffix < 100; suffix += 1) {
|
||||||
|
const candidate = `${base}-${suffix}`;
|
||||||
|
if (!RESERVED_SLUGS.has(candidate) && !this.list().some((s) => s.slug === candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new SectionError(`could not find a free web address for "${base}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async save(sections: readonly SectionConfig[]): Promise<void> {
|
||||||
|
const sorted = [...sections].sort((a, b) => a.order - b.order);
|
||||||
|
await writeJsonAtomic(this.file, { sections: sorted });
|
||||||
|
this.sections = sorted;
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -88,8 +88,8 @@ export class StoreError extends Error {
|
|||||||
export interface SetStoreOptions {
|
export interface SetStoreOptions {
|
||||||
dataDir: string;
|
dataDir: string;
|
||||||
pipeline: ImagePipeline;
|
pipeline: ImagePipeline;
|
||||||
/** Configured Section slugs; a set may only belong to one of these. */
|
/** The Sections that exist; a Menu Set may only belong to one of these. */
|
||||||
sections: readonly string[];
|
sections: { has(slug: string): boolean };
|
||||||
maxPagesPerSet: number;
|
maxPagesPerSet: number;
|
||||||
timeZone?: string | undefined;
|
timeZone?: string | undefined;
|
||||||
now?: (() => Date) | undefined;
|
now?: (() => Date) | undefined;
|
||||||
@@ -116,7 +116,7 @@ export class SetStore {
|
|||||||
readonly paths: SitePaths;
|
readonly paths: SitePaths;
|
||||||
|
|
||||||
private readonly pipeline: ImagePipeline;
|
private readonly pipeline: ImagePipeline;
|
||||||
private readonly sections: readonly string[];
|
private readonly sections: { has(slug: string): boolean };
|
||||||
private readonly maxPagesPerSet: number;
|
private readonly maxPagesPerSet: number;
|
||||||
private readonly timeZone: string;
|
private readonly timeZone: string;
|
||||||
private readonly now: () => Date;
|
private readonly now: () => Date;
|
||||||
@@ -200,7 +200,7 @@ export class SetStore {
|
|||||||
section: string,
|
section: string,
|
||||||
input: { label: string; validUntil?: string | null },
|
input: { label: string; validUntil?: string | null },
|
||||||
): Promise<StoredSet> {
|
): Promise<StoredSet> {
|
||||||
if (!this.sections.includes(section)) {
|
if (!this.sections.has(section)) {
|
||||||
throw new StoreError(`unknown Section: ${section}`);
|
throw new StoreError(`unknown Section: ${section}`);
|
||||||
}
|
}
|
||||||
const label = input.label.trim();
|
const label = input.label.trim();
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export interface Problem {
|
|||||||
|
|
||||||
export interface ValidateOptions {
|
export interface ValidateOptions {
|
||||||
dataDir: string;
|
dataDir: string;
|
||||||
sections: readonly string[];
|
/** Answers whether a slug still exists, so a removed Section is a problem. */
|
||||||
|
sections: { has(slug: string): boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exists(target: string): Promise<boolean> {
|
async function exists(target: string): Promise<boolean> {
|
||||||
@@ -75,7 +76,7 @@ export async function validateSite(options: ValidateOptions): Promise<Problem[]>
|
|||||||
|
|
||||||
for (const set of sets) {
|
for (const set of sets) {
|
||||||
const where = `${set.state}/${set.manifest.section}/${set.slug ?? set.manifest.id}`;
|
const where = `${set.state}/${set.manifest.section}/${set.slug ?? set.manifest.id}`;
|
||||||
if (!options.sections.includes(set.manifest.section)) {
|
if (!options.sections.has(set.manifest.section)) {
|
||||||
problems.push({ level: 'fatal', message: `${where}: Section is not in the Site config` });
|
problems.push({ level: 'fatal', message: `${where}: Section is not in the Site config` });
|
||||||
}
|
}
|
||||||
if (set.manifest.section !== path.basename(path.dirname(set.dir))) {
|
if (set.manifest.section !== path.basename(path.dirname(set.dir))) {
|
||||||
|
|||||||
@@ -751,6 +751,128 @@ describe('reordering', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('managing categories', () => {
|
||||||
|
const form = (fields: Record<string, string>, referer?: string) => ({
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
|
...(referer === undefined ? {} : { referer }),
|
||||||
|
},
|
||||||
|
payload: new URLSearchParams(fields).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function token(): Promise<string> {
|
||||||
|
const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
|
||||||
|
const match = dashboard.body.match(/name="csrf" value="([^"]+)"/);
|
||||||
|
assert.ok(match, 'the dashboard carries no CSRF field');
|
||||||
|
return match[1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugs = () => app.menu.sections.list().map((section) => section.slug);
|
||||||
|
|
||||||
|
it('lists the categories with the web address each one keeps', async () => {
|
||||||
|
const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
|
||||||
|
assert.match(dashboard.body, /Categories \(2\)/);
|
||||||
|
assert.match(dashboard.body, /\/food\//);
|
||||||
|
assert.match(dashboard.body, /\/bar\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a category, slugged from its name, and customers do not see it until it has a menu', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/sections',
|
||||||
|
cookies: cookies(),
|
||||||
|
...form({ csrf: await token(), label: 'Straight Drinks' }),
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded', referer: '/admin' },
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 303);
|
||||||
|
assert.match(String(response.headers['location']), /notice=/);
|
||||||
|
assert.deepEqual(slugs(), ['food', 'bar', 'straight-drinks']);
|
||||||
|
|
||||||
|
const landing = await app.inject({ method: 'GET', url: '/' });
|
||||||
|
assert.doesNotMatch(landing.body, /straight-drinks/);
|
||||||
|
assert.equal((await app.inject({ method: 'GET', url: '/straight-drinks/' })).statusCode, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renames a category without moving the web address', async () => {
|
||||||
|
const referer = '/admin';
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/sections/bar/rename',
|
||||||
|
cookies: cookies(),
|
||||||
|
...form({ csrf: await token(), label: 'Bar & Cocktails' }, referer),
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 303);
|
||||||
|
assert.equal(app.menu.sections.bySlug('bar')?.label, 'Bar & Cocktails');
|
||||||
|
assert.equal(app.menu.sections.bySlug('bar-and-cocktails'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorders categories', async () => {
|
||||||
|
assert.deepEqual(slugs(), ['food', 'bar']);
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/sections/bar/move',
|
||||||
|
cookies: cookies(),
|
||||||
|
...form({ csrf: await token(), direction: 'up' }, '/admin'),
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 303);
|
||||||
|
assert.deepEqual(slugs(), ['bar', 'food']);
|
||||||
|
|
||||||
|
const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
|
||||||
|
assert.ok(
|
||||||
|
dashboard.body.indexOf('value="Bar"') < dashboard.body.indexOf('value="Food"'),
|
||||||
|
'the manager lists them in the new order',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to remove a category that still holds menus, then allows it once empty', async () => {
|
||||||
|
const { id } = await newSet('bar', 'Bar Menu');
|
||||||
|
await upload(id, 'one.jpg');
|
||||||
|
|
||||||
|
const blocked = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/sections/bar/delete',
|
||||||
|
cookies: cookies(),
|
||||||
|
...form({ csrf: await token() }, '/admin'),
|
||||||
|
});
|
||||||
|
assert.equal(blocked.statusCode, 303);
|
||||||
|
const blockedUrl = String(blocked.headers['location']);
|
||||||
|
assert.match(blockedUrl, /error=/);
|
||||||
|
assert.deepEqual(slugs(), ['food', 'bar']);
|
||||||
|
|
||||||
|
const dashboard = await app.inject({ method: 'GET', url: blockedUrl, cookies: cookies() });
|
||||||
|
assert.match(dashboard.body, /still holds 1 menu set/);
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/admin/sets/${id}/delete`,
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf },
|
||||||
|
payload: { confirm: 'yes' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const removed = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/sections/bar/delete',
|
||||||
|
cookies: cookies(),
|
||||||
|
...form({ csrf: await token() }, '/admin'),
|
||||||
|
});
|
||||||
|
assert.match(String(removed.headers['location']), /notice=/);
|
||||||
|
assert.deepEqual(slugs(), ['food']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('needs a token to change the categories', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/sections',
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
payload: 'label=Desserts',
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 403);
|
||||||
|
assert.deepEqual(slugs(), ['food', 'bar']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('previewing a Draft', () => {
|
describe('previewing a Draft', () => {
|
||||||
it('renders the customer page with a banner, and serves its media to the session', async () => {
|
it('renders the customer page with a banner, and serves its media to the session', async () => {
|
||||||
const { id } = await newSet('bar', 'Bar Menu', '2026-06-26');
|
const { id } = await newSet('bar', 'Bar Menu', '2026-06-26');
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ async function publishedSetWithPages(count = 2) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validate() {
|
function validate() {
|
||||||
return validateSite({ dataDir, sections: [...SECTIONS] });
|
return validateSite({ dataDir, sections: { has: (slug) => (SECTIONS as readonly string[]).includes(slug) } });
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('repairSite', () => {
|
describe('repairSite', () => {
|
||||||
|
|||||||
+32
-2
@@ -89,9 +89,11 @@ describe('the landing page', () => {
|
|||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
assert.match(response.body, /Hops n Grains/);
|
assert.match(response.body, /Hops n Grains/);
|
||||||
assert.match(response.body, /Handcrafted Microbrewery/);
|
assert.match(response.body, /Handcrafted Microbrewery/);
|
||||||
assert.match(response.body, /<span class="row__label">Bar<\/span>/);
|
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, /2 pages/);
|
||||||
assert.doesNotMatch(response.body, /row__label">Wine/);
|
assert.match(response.body, /class="shelf__peek"/, 'the shelf shows a sliver of the menu paper');
|
||||||
|
assert.doesNotMatch(response.body, /shelf__name">Wine/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('escapes a label that tries to inject markup', async () => {
|
it('escapes a label that tries to inject markup', async () => {
|
||||||
@@ -128,6 +130,9 @@ describe('a Section page', () => {
|
|||||||
assert.doesNotMatch(body, /Older menus/);
|
assert.doesNotMatch(body, /Older menus/);
|
||||||
assert.doesNotMatch(body, /This is an older menu/);
|
assert.doesNotMatch(body, /This is an older menu/);
|
||||||
assert.match(body, /src="\/assets\/app\.js"/);
|
assert.match(body, /src="\/assets\/app\.js"/);
|
||||||
|
assert.match(body, /class="switcher"/, 'the bar names the menu and opens the switcher');
|
||||||
|
assert.match(body, /<span class="switcher__label">Bar<\/span>/);
|
||||||
|
assert.match(body, /id="menu-sheet"/, 'the sheet exists for the script to open');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serves both the trailing-slash and bare URLs', async () => {
|
it('serves both the trailing-slash and bare URLs', async () => {
|
||||||
@@ -142,6 +147,31 @@ describe('a Section page', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the menu switcher', () => {
|
||||||
|
it('lists every Section that has a menu, in category order, marking the one being read', async () => {
|
||||||
|
await seedPublishedBar();
|
||||||
|
const store = app.menu.store;
|
||||||
|
const food = await store.create('food', { label: 'Food Menu' });
|
||||||
|
await store.addPage(food.id, { data: pngBytes('food'), originalName: 'f.jpg' });
|
||||||
|
await store.publish(food.id);
|
||||||
|
|
||||||
|
const page = await app.inject({ method: 'GET', url: '/bar/' });
|
||||||
|
const listed = [...page.body.matchAll(/class="sheet__item[^"]*"\s+href="\/([a-z-]+)\//g)].map(
|
||||||
|
(match) => match[1],
|
||||||
|
);
|
||||||
|
assert.deepEqual(listed, ['food', 'bar']);
|
||||||
|
assert.equal((page.body.match(/sheet__item is-current/g) ?? []).length, 1);
|
||||||
|
assert.match(page.body, />Showing</);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a category with no menu out until it has one', async () => {
|
||||||
|
await seedPublishedBar();
|
||||||
|
const page = await app.inject({ method: 'GET', url: '/bar/' });
|
||||||
|
assert.doesNotMatch(page.body, /sheet__name">Wine/);
|
||||||
|
assert.equal((await app.inject({ method: 'GET', url: '/wine/' })).statusCode, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('an archived Menu Set', () => {
|
describe('an archived Menu Set', () => {
|
||||||
it('is reachable at its date URL, marked as older, and does not list itself', async () => {
|
it('is reachable at its date URL, marked as older, and does not list itself', async () => {
|
||||||
const store = app.menu.store;
|
const store = app.menu.store;
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||||
|
import { SectionError, SectionStore, slugify } from '../src/storage/sections.ts';
|
||||||
|
import { tempDir } from './support/harness.ts';
|
||||||
|
|
||||||
|
const SEED = [
|
||||||
|
{ slug: 'food', label: 'Food', shortLabel: 'Food', note: null, order: 1 },
|
||||||
|
{ slug: 'bar', label: 'Bar', shortLabel: 'Bar', note: null, order: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
let dataDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dataDir = await tempDir('sections');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loaded(seed = SEED) {
|
||||||
|
const store = new SectionStore({ dataDir, seed });
|
||||||
|
await store.load();
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('slugify', () => {
|
||||||
|
it('turns the labels people type into stable web addresses', () => {
|
||||||
|
assert.equal(slugify('Straight Drinks'), 'straight-drinks');
|
||||||
|
assert.equal(slugify("What's On"), 'whats-on');
|
||||||
|
assert.equal(slugify('Desserts & Coffee'), 'desserts-coffee');
|
||||||
|
assert.equal(slugify(' Bar '), 'bar');
|
||||||
|
assert.equal(slugify('Wine / Beer'), 'wine-beer');
|
||||||
|
assert.equal(slugify('----'), '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SectionStore', () => {
|
||||||
|
it('seeds from the config the first time and writes the file', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
assert.deepEqual(
|
||||||
|
store.list().map((section) => section.slug),
|
||||||
|
['food', 'bar'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const file = JSON.parse(await readFile(path.join(dataDir, 'sections.json'), 'utf8'));
|
||||||
|
assert.equal(file.sections.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the file as the source of truth afterwards, ignoring the seed', async () => {
|
||||||
|
await loaded();
|
||||||
|
const second = new SectionStore({ dataDir, seed: [] });
|
||||||
|
await second.load();
|
||||||
|
assert.deepEqual(
|
||||||
|
second.list().map((section) => section.slug),
|
||||||
|
['food', 'bar'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to serve an unreadable file rather than silently reseeding', async () => {
|
||||||
|
await writeFile(path.join(dataDir, 'sections.json'), '{ not json');
|
||||||
|
const store = new SectionStore({ dataDir, seed: SEED });
|
||||||
|
await assert.rejects(() => store.load(), SectionError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('complains if it is read before loading', () => {
|
||||||
|
const store = new SectionStore({ dataDir, seed: SEED });
|
||||||
|
assert.throws(() => store.list(), /load\(\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a category at the end, with a slug from the name', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
const created = await store.create({ label: 'Straight Drinks' });
|
||||||
|
assert.equal(created.slug, 'straight-drinks');
|
||||||
|
assert.equal(created.order, 3);
|
||||||
|
assert.deepEqual(
|
||||||
|
store.list().map((section) => section.label),
|
||||||
|
['Food', 'Bar', 'Straight Drinks'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes a slug unique, and steers around the names the app reserves', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
await store.create({ label: 'Desserts' });
|
||||||
|
assert.equal((await store.create({ label: 'Desserts' })).slug, 'desserts-2');
|
||||||
|
assert.equal((await store.create({ label: 'Admin' })).slug, 'admin-2');
|
||||||
|
assert.equal((await store.create({ label: 'Media' })).slug, 'media-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty name and a name that makes no web address', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
await assert.rejects(() => store.create({ label: ' ' }), SectionError);
|
||||||
|
await assert.rejects(() => store.create({ label: '!!!' }), SectionError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renames without moving the web address customers already have', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
const renamed = await store.rename('bar', { label: 'Bar & Cocktails' });
|
||||||
|
assert.equal(renamed.label, 'Bar & Cocktails');
|
||||||
|
assert.equal(renamed.slug, 'bar');
|
||||||
|
assert.equal(store.bySlug('bar')?.label, 'Bar & Cocktails');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a category and renumbers the rest', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
await store.move('bar', 'up');
|
||||||
|
assert.deepEqual(
|
||||||
|
store.list().map((section) => section.slug),
|
||||||
|
['bar', 'food'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
store.list().map((section) => section.order),
|
||||||
|
[1, 2],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Past either end is a no-op, not an error.
|
||||||
|
await store.move('bar', 'up');
|
||||||
|
assert.deepEqual(
|
||||||
|
store.list().map((section) => section.slug),
|
||||||
|
['bar', 'food'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a category and closes the gap in the order', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
await store.create({ label: 'Wine' });
|
||||||
|
await store.remove('bar');
|
||||||
|
assert.deepEqual(
|
||||||
|
store.list().map((section) => [section.slug, section.order]),
|
||||||
|
[
|
||||||
|
['food', 1],
|
||||||
|
['wine', 2],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unknown slug rather than inventing one', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
await assert.rejects(() => store.rename('pizza', { label: 'Pizza' }), SectionError);
|
||||||
|
await assert.rejects(() => store.remove('pizza'), SectionError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives add, rename, move and remove being read by a second instance', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
const created = await store.create({ label: 'Desserts' });
|
||||||
|
await store.rename(created.slug, { label: 'Puddings' });
|
||||||
|
await store.move(created.slug, 'up');
|
||||||
|
|
||||||
|
const reopened = new SectionStore({ dataDir, seed: [] });
|
||||||
|
await reopened.load();
|
||||||
|
assert.deepEqual(
|
||||||
|
reopened.list().map((section) => section.label),
|
||||||
|
['Food', 'Puddings', 'Bar'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,7 +53,7 @@ export function makeStore(
|
|||||||
const store = new SetStore({
|
const store = new SetStore({
|
||||||
dataDir,
|
dataDir,
|
||||||
pipeline,
|
pipeline,
|
||||||
sections: [...SECTIONS],
|
sections: { has: (slug: string) => (SECTIONS as readonly string[]).includes(slug) },
|
||||||
maxPagesPerSet: options.maxPagesPerSet ?? 40,
|
maxPagesPerSet: options.maxPagesPerSet ?? 40,
|
||||||
timeZone: 'Asia/Kolkata',
|
timeZone: 'Asia/Kolkata',
|
||||||
now: clock.now,
|
now: clock.now,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function validate() {
|
function validate() {
|
||||||
return validateSite({ dataDir, sections: [...SECTIONS] });
|
return validateSite({ dataDir, sections: { has: (slug) => (SECTIONS as readonly string[]).includes(slug) } });
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('validateSite', () => {
|
describe('validateSite', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user