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>
This commit is contained in:
@@ -3,7 +3,7 @@ import path from 'node:path';
|
||||
import { env } from '../env';
|
||||
import { resolveAuthSecret } from '../secret';
|
||||
import { resolveSiteUrl } from '../siteUrl';
|
||||
import { getInstallCredentials } from '../install';
|
||||
import { getInstallCredentials, installFilePath } from '../install';
|
||||
import { loadYaml, clearYamlCache, validateUuid } from './parser';
|
||||
import { resolveTheme, VALID_LAYOUTS, DEFAULT_PRESET } from './theme';
|
||||
import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort';
|
||||
@@ -30,14 +30,23 @@ import {
|
||||
CONTACT_RETENTION_DEFAULT,
|
||||
CONTACT_RETENTION_MAX,
|
||||
isHttpUrl,
|
||||
isPageRef,
|
||||
type GallerySubpageYaml,
|
||||
} from './schema';
|
||||
import { isValidSlug } from '../journal';
|
||||
import { getDictionary, resolveLocale } from '../i18n';
|
||||
import type { NavEntry } from '../pages';
|
||||
|
||||
export * from './schema';
|
||||
export * from './theme';
|
||||
|
||||
/** The last derived config and the inputs it was derived from; see getConfig(). */
|
||||
let derived: { inputs: string; config: AppConfig } | null = null;
|
||||
|
||||
/** Invalidate the cached YAML so the next getConfig() call re-parses the files. */
|
||||
export function invalidateConfigCache(): void {
|
||||
clearYamlCache();
|
||||
derived = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,6 +159,20 @@ export function resolveProofing(
|
||||
return subpage?.proofing ?? globalEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipient of the proofing dialog's "email to photographer" (#736):
|
||||
* `proofing.email` when set, else the footer contact email. The first
|
||||
* non-empty value, trimmed — an address saved as only whitespace is not
|
||||
* configured. Undefined means neither is set, and the dialog hides the button
|
||||
* rather than opening a compose with an empty To:.
|
||||
*/
|
||||
export function resolveProofingEmail(
|
||||
proofingEmail?: string,
|
||||
footerEmail?: string,
|
||||
): string | undefined {
|
||||
return (proofingEmail ?? '').trim() || (footerEmail ?? '').trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL: keep only nav links that are safe to render as header <a>
|
||||
* tags. Non-http(s) schemes (javascript:, data:) and incomplete entries are
|
||||
@@ -244,6 +267,7 @@ export interface GalleryDerivation {
|
||||
albums: string[];
|
||||
standaloneAlbums: string[];
|
||||
subpages: SubpageConfig[];
|
||||
nav: NavEntry[];
|
||||
albumOverrides: Record<string, string>;
|
||||
albumDescriptions: Record<string, string>;
|
||||
albumPasswords: Record<string, string>;
|
||||
@@ -408,9 +432,35 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
const standaloneAlbumIds = processAlbumEntries(gallery.albums ?? [], 'gallery.yaml albums');
|
||||
|
||||
let subpages: SubpageConfig[] = [];
|
||||
/** Menu order, subpages and `- page:` references interleaved (#722). */
|
||||
const nav: NavEntry[] = [];
|
||||
|
||||
if (Array.isArray(gallery.subpages)) {
|
||||
subpages = gallery.subpages.map((sp) => {
|
||||
const pageRefs = new Set<string>();
|
||||
const subpageEntries: GallerySubpageYaml[] = [];
|
||||
for (const entry of gallery.subpages) {
|
||||
if (isPageRef(entry)) {
|
||||
const slug = entry.page.trim();
|
||||
// Thrown like every other structure error: the admin PUT turns it into
|
||||
// a 400, and a hand-edited file degrades to the setup screen.
|
||||
if (!isValidSlug(slug)) {
|
||||
throw new Error(
|
||||
`gallery.yaml subpages: "page: ${entry.page}" is not a valid page slug ` +
|
||||
`(letters, digits, "-" and "_" only).`,
|
||||
);
|
||||
}
|
||||
if (pageRefs.has(slug)) {
|
||||
throw new Error(`gallery.yaml subpages: page "${slug}" is listed more than once.`);
|
||||
}
|
||||
pageRefs.add(slug);
|
||||
nav.push({ type: 'page', slug });
|
||||
} else {
|
||||
subpageEntries.push(entry);
|
||||
// Placeholder, filled in with the derived slug below.
|
||||
nav.push({ type: 'subpage', slug: '' });
|
||||
}
|
||||
}
|
||||
subpages = subpageEntries.map((sp) => {
|
||||
if (!sp.name) {
|
||||
throw new Error(`Subpage "(unnamed)" must have a name`);
|
||||
}
|
||||
@@ -467,6 +517,10 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
...buildSubpageGrids(sp.grid, sp.coverGrid),
|
||||
};
|
||||
});
|
||||
let next = 0;
|
||||
for (const entry of nav) {
|
||||
if (entry.type === 'subpage') entry.slug = subpages[next++].slug;
|
||||
}
|
||||
} else if (gallery.subpages) {
|
||||
subpages = Object.entries(gallery.subpages).map(([name, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
@@ -500,6 +554,10 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(gallery.subpages)) {
|
||||
for (const sp of subpages) nav.push({ type: 'subpage', slug: sp.slug });
|
||||
}
|
||||
|
||||
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
|
||||
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
|
||||
const standaloneAlbums = standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id));
|
||||
@@ -550,6 +608,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
albums: allAlbumIds,
|
||||
standaloneAlbums,
|
||||
subpages,
|
||||
nav,
|
||||
albumOverrides,
|
||||
albumDescriptions,
|
||||
albumPasswords,
|
||||
@@ -572,11 +631,54 @@ function aboutContentExists(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything deriveConfig() reads, as one string: the path and mtime of each
|
||||
* file (or `-` when it is absent) and the parsed environment.
|
||||
*/
|
||||
function configInputs(): string {
|
||||
const content = path.join(process.cwd(), 'content');
|
||||
const files = [
|
||||
installFilePath(),
|
||||
path.join(content, 'gallery.yaml'),
|
||||
path.join(content, 'settings.yaml'),
|
||||
path.join(content, 'about.md'),
|
||||
];
|
||||
// throwIfNoEntry: a missing file is the normal case for install.json and
|
||||
// about.md, and building an ENOENT error costs more than the stat.
|
||||
// turbopackIgnore: `file` is not statically resolvable, so without the
|
||||
// comment Turbopack traces the whole project into .next/standalone. Only
|
||||
// the mtime is read here; the files themselves are loaded elsewhere.
|
||||
const stamps = files.map(
|
||||
(file) =>
|
||||
`${file}@${fs.statSync(/* turbopackIgnore: true */ file, { throwIfNoEntry: false })?.mtimeMs ?? '-'}`,
|
||||
);
|
||||
return `${stamps.join('|')}|${JSON.stringify(env)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The site configuration.
|
||||
*
|
||||
* Admin saves can land in a different worker or process than page rendering,
|
||||
* so this cannot be a per-process singleton: freshness comes from the files'
|
||||
* mtimes, re-checked on every call (a statSync each). What is cached is the
|
||||
* derivation. A render and every image request call this many times over
|
||||
* (`immich.config` is a getter), and rebuilding the whole config from the
|
||||
* YAML each time cost more than the stats.
|
||||
*
|
||||
* The inputs are the ones loadYaml() and readInstallFile() already key their
|
||||
* own caches on, so this is never staler than they are. Each caller still
|
||||
* gets its own copy, exactly as before: nothing can mutate the cached one.
|
||||
* A config that throws is not cached, so it throws again on the next call.
|
||||
*/
|
||||
export function getConfig(): AppConfig {
|
||||
// No in-memory config cache: admin saves can land in a different
|
||||
// worker/process than page rendering, so a per-worker cache goes stale
|
||||
// until restart. Freshness comes from the mtime-checked YAML cache in
|
||||
// loadYaml() — a statSync per file, negligible next to Immich API calls.
|
||||
const inputs = configInputs();
|
||||
if (derived?.inputs !== inputs) {
|
||||
derived = { inputs, config: deriveConfig() };
|
||||
}
|
||||
return structuredClone(derived.config);
|
||||
}
|
||||
|
||||
function deriveConfig(): AppConfig {
|
||||
const { apiUrl, apiKey } = getInstallCredentials();
|
||||
const authSecret = resolveAuthSecret();
|
||||
|
||||
@@ -604,6 +706,7 @@ export function getConfig(): AppConfig {
|
||||
albums: [],
|
||||
standaloneAlbums: [],
|
||||
subpages: [],
|
||||
nav: [],
|
||||
siteTitle: env.SITE_TITLE || 'Immich Folio',
|
||||
siteSubtitle: env.SITE_SUBTITLE || 'Setup Required',
|
||||
lang: 'en',
|
||||
@@ -665,6 +768,7 @@ export function getConfig(): AppConfig {
|
||||
albums: allAlbumIds,
|
||||
standaloneAlbums,
|
||||
subpages,
|
||||
nav,
|
||||
albumOverrides,
|
||||
albumDescriptions,
|
||||
albumPasswords,
|
||||
@@ -677,7 +781,10 @@ export function getConfig(): AppConfig {
|
||||
albumLocationPrecision,
|
||||
} = deriveGallery(gallery);
|
||||
|
||||
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
|
||||
// Last-resort title in the site's own language rather than a hardcoded
|
||||
// English "Gallery" (#696).
|
||||
const defaultTitle = env.SITE_TITLE || getDictionary(resolveLocale(settings.lang)).common.gallery;
|
||||
const siteSeoTitle = settings.seo?.title || settings.title || defaultTitle;
|
||||
|
||||
return {
|
||||
immich: { apiUrl: immichApiUrl, apiKey },
|
||||
@@ -685,7 +792,8 @@ export function getConfig(): AppConfig {
|
||||
albums: allAlbumIds,
|
||||
standaloneAlbums,
|
||||
subpages,
|
||||
siteTitle: settings.title ?? env.SITE_TITLE,
|
||||
nav,
|
||||
siteTitle: settings.title ?? defaultTitle,
|
||||
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
|
||||
lang: settings.lang ?? 'en',
|
||||
// Env wins, so a deployment can rotate the site password without touching
|
||||
@@ -746,6 +854,9 @@ export function getConfig(): AppConfig {
|
||||
proofing: {
|
||||
enabled: settings.proofing?.enabled !== false,
|
||||
allowMailto: settings.proofing?.allowMailto !== false,
|
||||
// `proofing.email`, else the footer contact email (#736); absent hides
|
||||
// the dialog's email button.
|
||||
email: resolveProofingEmail(settings.proofing?.email, settings.footer?.email),
|
||||
},
|
||||
protection: settings.protection,
|
||||
watermark: settings.watermark,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AlbumSortMode } from '../albumSort';
|
||||
import type { LocationPrecision } from '../mapPrecision';
|
||||
import type { NavEntry } from '../pages';
|
||||
|
||||
export interface SubpageSectionConfig {
|
||||
title: string;
|
||||
@@ -217,6 +218,11 @@ export interface AppConfig {
|
||||
albums: string[];
|
||||
standaloneAlbums: string[];
|
||||
subpages: SubpageConfig[];
|
||||
/**
|
||||
* The header menu in gallery.yaml order: every subpage (hidden and disabled
|
||||
* ones included — the menu filters) and every `- page:` reference (#722).
|
||||
*/
|
||||
nav: NavEntry[];
|
||||
siteTitle: string;
|
||||
siteSubtitle: string;
|
||||
lang: string;
|
||||
@@ -264,6 +270,13 @@ export interface AppConfig {
|
||||
proofing: {
|
||||
enabled: boolean;
|
||||
allowMailto: boolean;
|
||||
/**
|
||||
* Effective "email to photographer" recipient: `proofing.email` from
|
||||
* settings when set, otherwise the footer contact email. Undefined means
|
||||
* neither is configured, and the proofing dialog hides the email action
|
||||
* rather than opening a compose with an empty To: (#736).
|
||||
*/
|
||||
email?: string;
|
||||
};
|
||||
aboutEnabled: boolean;
|
||||
albumOverrides: Record<string, string>;
|
||||
@@ -352,43 +365,63 @@ export interface AlbumEntryObject {
|
||||
location?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A content page placed in the menu (#722): `- page: <slug>` among the
|
||||
* subpages. The page itself lives in content/pages/<slug>.md.
|
||||
*/
|
||||
export interface PageRefYaml {
|
||||
page: string;
|
||||
}
|
||||
|
||||
/** Whether a raw `subpages:` list entry is a page reference rather than a subpage. */
|
||||
export function isPageRef(entry: unknown): entry is PageRefYaml {
|
||||
return (
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
'page' in entry &&
|
||||
typeof (entry as { page: unknown }).page === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export interface GalleryYaml {
|
||||
hero?: string | string[];
|
||||
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
|
||||
subpages?:
|
||||
| Record<string, string[] | Array<string | Record<string, string>>>
|
||||
| Array<{
|
||||
name: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
|
||||
sections?: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
albums: Array<string | Record<string, string | AlbumEntryObject>>;
|
||||
}>;
|
||||
password?: string;
|
||||
proofing?: boolean;
|
||||
essayFile?: string;
|
||||
essayText?: string;
|
||||
enabled?: boolean;
|
||||
hidden?: boolean;
|
||||
/** Map precision inherited by every album here (#469). */
|
||||
location?: string;
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
/** Album covers only; falls back to `grid` when unset (#523). */
|
||||
coverGrid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
}>;
|
||||
| Array<GallerySubpageYaml | PageRefYaml>;
|
||||
}
|
||||
|
||||
export interface GallerySubpageYaml {
|
||||
name: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
|
||||
sections?: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
albums: Array<string | Record<string, string | AlbumEntryObject>>;
|
||||
}>;
|
||||
password?: string;
|
||||
proofing?: boolean;
|
||||
essayFile?: string;
|
||||
essayText?: string;
|
||||
enabled?: boolean;
|
||||
hidden?: boolean;
|
||||
/** Map precision inherited by every album here (#469). */
|
||||
location?: string;
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
/** Album covers only; falls back to `grid` when unset (#523). */
|
||||
coverGrid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SettingsYaml {
|
||||
@@ -417,6 +450,8 @@ export interface SettingsYaml {
|
||||
proofing?: {
|
||||
enabled?: boolean;
|
||||
allowMailto?: boolean;
|
||||
/** Overrides the footer contact email as the proofing recipient. */
|
||||
email?: string;
|
||||
};
|
||||
theme?:
|
||||
| string
|
||||
@@ -644,3 +679,44 @@ export function normalizeSlug(slug: string): string {
|
||||
}
|
||||
return decoded.normalize('NFC');
|
||||
}
|
||||
|
||||
/**
|
||||
* The albums the public site shows: the standalone ones and those of every
|
||||
* subpage that is not offline. `albums` is the allowlist for loading an album
|
||||
* and still holds the albums of a subpage taken offline with `enabled: false`
|
||||
* (its configuration is kept), so it does not answer "is this published".
|
||||
* Anything that lists albums or serves their photos outside a page route asks
|
||||
* this instead.
|
||||
*/
|
||||
export function onlineAlbumIds(config: {
|
||||
standaloneAlbums?: readonly string[];
|
||||
subpages: ReadonlyArray<{ albumIds: readonly string[]; enabled?: boolean }>;
|
||||
}): Set<string> {
|
||||
return new Set([
|
||||
...(config.standaloneAlbums ?? []),
|
||||
...config.subpages.filter((sp) => sp.enabled !== false).flatMap((sp) => sp.albumIds),
|
||||
]);
|
||||
}
|
||||
|
||||
/** A subpage that appears in listings: online and not `hidden`. */
|
||||
export function isListedSubpage(sp: { enabled?: boolean; hidden?: boolean }): boolean {
|
||||
return sp.enabled !== false && sp.hidden !== true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The albums a public listing may name: the standalone ones and those of
|
||||
* every subpage that is online and not `hidden`. Narrower than
|
||||
* {@link onlineAlbumIds} — a hidden subpage is reachable by direct link only,
|
||||
* so its photos are still served (publishedAssets asks `onlineAlbumIds`), but
|
||||
* an overview such as the map must not advertise them. An album that a listed
|
||||
* subpage or the standalone list also carries stays.
|
||||
*/
|
||||
export function listedAlbumIds(config: {
|
||||
standaloneAlbums?: readonly string[];
|
||||
subpages: ReadonlyArray<{ albumIds: readonly string[]; enabled?: boolean; hidden?: boolean }>;
|
||||
}): Set<string> {
|
||||
return new Set([
|
||||
...(config.standaloneAlbums ?? []),
|
||||
...config.subpages.filter(isListedSubpage).flatMap((sp) => sp.albumIds),
|
||||
]);
|
||||
}
|
||||
|
||||
71
lib/config/settingValues.ts
Normal file
71
lib/config/settingValues.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Values in settings.yaml that the site would silently ignore (QA A-14).
|
||||
*
|
||||
* `validateSettings` (settingsSchema.ts) checks types and structure and
|
||||
* deliberately leaves values to the resolvers, which clamp or fall back so
|
||||
* hand-edited YAML never takes the site down. For the fields the panel offers
|
||||
* an input for, that meant a save of `accent: rot` or nine columns in a 1–6
|
||||
* field was reported as "Saved" while the site showed something else.
|
||||
*
|
||||
* This checks exactly those fields — the site URL, the accent, the grid
|
||||
* columns and gap — with the resolvers' own rules and bounds, so it can never
|
||||
* be stricter than what renders. Kept apart from settingsSchema.ts so the
|
||||
* settings form can run it as you type without bundling zod; the save route
|
||||
* runs it before writing, and the doctor over settings.yaml as it is.
|
||||
*/
|
||||
|
||||
import { normaliseSiteUrl } from '../siteUrl';
|
||||
import { isAccentHex } from './theme';
|
||||
import { PHOTO_GRID_COLUMNS_MAX, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_GAP_MAX } from './schema';
|
||||
import type { SettingsFieldError } from './settingsSchema';
|
||||
|
||||
function inRange(value: unknown, min: number, max: number): boolean {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;
|
||||
}
|
||||
|
||||
/** One error per offending field; an empty string counts as unset, as in the resolvers. */
|
||||
export function validateSettingValues(input: unknown): SettingsFieldError[] {
|
||||
if (!input || typeof input !== 'object') return [];
|
||||
const s = input as {
|
||||
url?: unknown;
|
||||
theme?: unknown;
|
||||
grid?: { columns?: unknown; gap?: unknown } | null;
|
||||
};
|
||||
const errors: SettingsFieldError[] = [];
|
||||
|
||||
// resolveSiteUrl: an unusable value falls back to SITE_URL, or to nothing,
|
||||
// and the sitemap, feed and structured data go out without the address.
|
||||
if (typeof s.url === 'string' && s.url.trim() !== '' && !normaliseSiteUrl(s.url)) {
|
||||
errors.push({
|
||||
field: 'url',
|
||||
message: 'Must be a full http(s) address, e.g. https://photos.example.com.',
|
||||
});
|
||||
}
|
||||
|
||||
// resolveTheme: anything but hex falls back to the preset's accent.
|
||||
const accent =
|
||||
s.theme && typeof s.theme === 'object' ? (s.theme as { accent?: unknown }).accent : undefined;
|
||||
if (typeof accent === 'string' && accent.trim() !== '' && !isAccentHex(accent)) {
|
||||
errors.push({ field: 'theme.accent', message: 'Must be a hex colour such as #e60012.' });
|
||||
}
|
||||
|
||||
// getConfig clamps both into these bounds (lib/config/index.ts).
|
||||
const grid = s.grid && typeof s.grid === 'object' ? s.grid : null;
|
||||
if (
|
||||
grid?.columns != null &&
|
||||
!inRange(grid.columns, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_COLUMNS_MAX)
|
||||
) {
|
||||
errors.push({
|
||||
field: 'grid.columns',
|
||||
message: `Must be a whole number from ${PHOTO_GRID_COLUMNS_MIN} to ${PHOTO_GRID_COLUMNS_MAX}.`,
|
||||
});
|
||||
}
|
||||
if (grid?.gap != null && !inRange(grid.gap, 0, PHOTO_GRID_GAP_MAX)) {
|
||||
errors.push({
|
||||
field: 'grid.gap',
|
||||
message: `Must be a whole number from 0 to ${PHOTO_GRID_GAP_MAX}.`,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -29,6 +29,10 @@
|
||||
* to the preset's own value for anything that is not the right type, which
|
||||
* covers hand-edited YAML as well as a save from this panel.
|
||||
*
|
||||
* **Values the panel offers an input for** are the exception, checked with the
|
||||
* resolvers' own rules in `settingValues.ts`: the site URL, the accent and the
|
||||
* grid columns and gap, which a save used to accept and the site then ignored.
|
||||
*
|
||||
* **Unknown keys pass through.** There is no `schemaVersion` and no migration
|
||||
* code, so configurations older and newer than this build are both in the wild.
|
||||
* A key this version has never heard of is not evidence of a mistake, and
|
||||
@@ -84,7 +88,7 @@ export const settingsSchema = z.looseObject({
|
||||
scrollToTop: bool,
|
||||
analytics: bool,
|
||||
|
||||
proofing: z.looseObject({ enabled: bool, allowMailto: bool }).optional(),
|
||||
proofing: z.looseObject({ enabled: bool, allowMailto: bool, email: str }).optional(),
|
||||
|
||||
// Checked separately in validateSettings — see themeObjectSchema.
|
||||
theme: z.unknown().optional(),
|
||||
|
||||
@@ -130,6 +130,11 @@ function resolveRadius(raw: unknown, fallback: number): number {
|
||||
*/
|
||||
const ACCENT_HEX = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
|
||||
/** Whether `resolveTheme` keeps an accent rather than falling back to the preset's. */
|
||||
export function isAccentHex(value: string): boolean {
|
||||
return ACCENT_HEX.test(value.trim());
|
||||
}
|
||||
|
||||
/** Trim, case-fold and widen to `#rrggbb`; refuse anything that is not hex. */
|
||||
function resolveAccent(raw: unknown, fallback: string): string {
|
||||
if (typeof raw !== 'string') return fallback;
|
||||
|
||||
Reference in New Issue
Block a user