Files
immich-folio/lib/pages.ts
Ralksta ad8b09700b release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-29 22:50:30 +02:00

141 lines
4.8 KiB
TypeScript

/**
* Content pages (#722): timeless pages such as pricing, workshops or a booking
* FAQ, stored one per file in `content/pages/<slug>.md` and served at
* `/<slug>`.
*
* Client-safe (no `fs`) — the admin UI imports the slug rules from here. The
* file access lives in lib/admin/pages-service.ts.
*
* A page is block markdown in the journal format (lib/journal.ts), with a
* smaller frontmatter: `title`, `description` (SEO), `password`, `draft`.
* Whether it appears in the menu is not a property of the file: it is decided
* by a `- page: <slug>` entry among the subpages in gallery.yaml, so the menu
* order lives in exactly one place.
*/
import { isValidSlug, type JournalFrontmatter } from './journal';
export type PageFrontmatter = Pick<
JournalFrontmatter,
'title' | 'description' | 'password' | 'draft'
>;
export interface PageSummary {
slug: string;
frontmatter: PageFrontmatter;
}
/**
* First path segments the app serves itself. A page slugged like one of them
* would never be reached — the fixed route wins — so it is refused outright.
* `proof` and `contact` are fixed routes too, beyond the list in the issue.
*/
export const RESERVED_PAGE_SLUGS: readonly string[] = [
'about',
'map',
'journal',
'impressum',
'privacy',
'install',
'admin',
'gate',
'api',
'proof',
'contact',
];
/** Everything a page slug must not collide with. */
export interface SlugTakenBy {
subpages: readonly string[];
albums: readonly string[];
journal: readonly string[];
}
export type SlugCollision =
| { kind: 'invalid' }
| { kind: 'reserved' }
| { kind: 'subpage' }
| { kind: 'album' }
| { kind: 'journal' };
/**
* Why `slug` cannot be a page's URL, or null when it can.
*
* Journal entries live under /journal/ and cannot shadow a page, but they are
* checked all the same (#722): a page and an entry of the same name are two
* things a visitor would expect at one address.
*/
export function pageSlugCollision(slug: string, taken: SlugTakenBy): SlugCollision | null {
if (!isValidSlug(slug)) return { kind: 'invalid' };
const wanted = slug.toLowerCase();
if (RESERVED_PAGE_SLUGS.includes(wanted)) return { kind: 'reserved' };
if (taken.subpages.some((s) => s.toLowerCase() === wanted)) return { kind: 'subpage' };
if (taken.albums.some((s) => s.toLowerCase() === wanted)) return { kind: 'album' };
if (taken.journal.some((s) => s.toLowerCase() === wanted)) return { kind: 'journal' };
return null;
}
/** A sentence for the admin UI and the doctor. */
export function describeCollision(slug: string, collision: SlugCollision): string {
switch (collision.kind) {
case 'invalid':
return `"${slug}" is not a valid slug: use letters, digits, "-" and "_" only.`;
case 'reserved':
return `/${slug} is a built-in route of the site.`;
case 'subpage':
return `/${slug} is already a subpage.`;
case 'album':
return `/${slug} is already a standalone album.`;
case 'journal':
return `A journal entry already uses the slug "${slug}".`;
}
}
/** One entry of the header menu, in gallery.yaml order. */
export type NavEntry = { type: 'subpage'; slug: string } | { type: 'page'; slug: string };
/** The page slugs referenced from gallery.yaml, i.e. the pages shown in the menu. */
export function menuPageSlugs(nav: readonly NavEntry[]): string[] {
return nav.filter((e) => e.type === 'page').map((e) => e.slug);
}
export interface MenuItem {
key: string;
href: string;
label: string;
}
/**
* The header menu's subpage and page links, in gallery.yaml order.
*
* `visibleSubpages` is what the menu may show (hidden and disabled subpages
* already dropped). A page reference whose file is missing, or that is still a
* draft, is left out the same way. A password-protected page stays listed, as
* a protected subpage does: the gate is at the page, not in the menu.
*/
export function resolveMenu(
nav: readonly NavEntry[],
visibleSubpages: ReadonlyArray<{ slug: string; name: string; title?: string }>,
pages: readonly PageSummary[],
): MenuItem[] {
const subpageBySlug = new Map(visibleSubpages.map((sp) => [sp.slug, sp]));
const pageBySlug = new Map(pages.map((p) => [p.slug, p]));
return nav.flatMap((entry): MenuItem[] => {
if (entry.type === 'subpage') {
const sp = subpageBySlug.get(entry.slug);
// The title a visitor sees on the page itself; `name` is the key the
// slug is made from, and only stands in when no title is set.
return sp ? [{ key: `sp-${sp.slug}`, href: `/${sp.slug}`, label: sp.title || sp.name }] : [];
}
const page = pageBySlug.get(entry.slug);
if (!page || page.frontmatter.draft) return [];
return [
{
key: `page-${page.slug}`,
href: `/${page.slug}`,
label: page.frontmatter.title || page.slug,
},
];
});
}