2026-09-29 22:50:30 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
|
|
|
|
|
*
|
2026-10-02 18:14:23 +02:00
|
|
|
|
* This checks the values a resolver bends — the site URL, the accent, the
|
|
|
|
|
|
* corner radius, the grid columns and gap, the message retention and the
|
|
|
|
|
|
* header links — with the resolvers' own rules and bounds, so it can never be
|
|
|
|
|
|
* stricter than what renders. Kept apart from settingsSchema.ts so the
|
2026-09-29 22:50:30 +02:00
|
|
|
|
* settings form can run it as you type without bundling zod; the save route
|
2026-10-02 18:14:23 +02:00
|
|
|
|
* runs it before writing, and both doctors over settings.yaml as it is.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `npm run doctor` imports this file under Node's own type stripping
|
|
|
|
|
|
* (scripts/doctor.mts), which resolves no extensionless paths: every relative
|
|
|
|
|
|
* import here carries its `.ts`, and the modules it pulls in may only
|
|
|
|
|
|
* `import type` from elsewhere. scripts/__tests__/doctor-cli.test.ts loads the
|
|
|
|
|
|
* graph in a real Node process and fails when that breaks.
|
2026-09-29 22:50:30 +02:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-10-02 18:14:23 +02:00
|
|
|
|
import { normaliseSiteUrl } from '../siteUrl.ts';
|
|
|
|
|
|
import { isAccentHex, THEME_RADIUS_MAX } from './theme.ts';
|
|
|
|
|
|
import {
|
|
|
|
|
|
CONTACT_RETENTION_MAX,
|
|
|
|
|
|
PHOTO_GRID_COLUMNS_MAX,
|
|
|
|
|
|
PHOTO_GRID_COLUMNS_MIN,
|
|
|
|
|
|
PHOTO_GRID_GAP_MAX,
|
|
|
|
|
|
} from './schema.ts';
|
2026-09-29 22:50:30 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 18:14:23 +02:00
|
|
|
|
/** What sanitizeNavLinks (lib/config/index.ts) keeps as a link target. */
|
|
|
|
|
|
const NAV_LINK_URL = /^https?:\/\//i;
|
|
|
|
|
|
|
2026-09-29 22:50:30 +02:00
|
|
|
|
/** 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;
|
2026-10-02 18:14:23 +02:00
|
|
|
|
contact?: { retentionDays?: unknown } | null;
|
|
|
|
|
|
navLinks?: unknown;
|
2026-09-29 22:50:30 +02:00
|
|
|
|
};
|
|
|
|
|
|
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.',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 18:14:23 +02:00
|
|
|
|
const theme =
|
|
|
|
|
|
s.theme && typeof s.theme === 'object'
|
|
|
|
|
|
? (s.theme as { accent?: unknown; radius?: unknown })
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
2026-09-29 22:50:30 +02:00
|
|
|
|
// resolveTheme: anything but hex falls back to the preset's accent.
|
2026-10-02 18:14:23 +02:00
|
|
|
|
const accent = theme?.accent;
|
2026-09-29 22:50:30 +02:00
|
|
|
|
if (typeof accent === 'string' && accent.trim() !== '' && !isAccentHex(accent)) {
|
|
|
|
|
|
errors.push({ field: 'theme.accent', message: 'Must be a hex colour such as #e60012.' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 18:14:23 +02:00
|
|
|
|
// resolveRadius clamps into 0–THEME_RADIUS_MAX. The panel has no input for
|
|
|
|
|
|
// it, so the message says where the value lives.
|
|
|
|
|
|
const radius = theme?.radius;
|
|
|
|
|
|
if (
|
|
|
|
|
|
radius != null &&
|
|
|
|
|
|
(typeof radius !== 'number' ||
|
|
|
|
|
|
!Number.isFinite(radius) ||
|
|
|
|
|
|
radius < 0 ||
|
|
|
|
|
|
radius > THEME_RADIUS_MAX)
|
|
|
|
|
|
) {
|
|
|
|
|
|
errors.push({
|
|
|
|
|
|
field: 'theme.radius',
|
|
|
|
|
|
message: `Must be a number from 0 to ${THEME_RADIUS_MAX} (theme.radius in settings.yaml).`,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 22:50:30 +02:00
|
|
|
|
// 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}.`,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 18:14:23 +02:00
|
|
|
|
// resolveContact rounds and clamps into 1–CONTACT_RETENTION_MAX.
|
|
|
|
|
|
const contact = s.contact && typeof s.contact === 'object' ? s.contact : null;
|
|
|
|
|
|
if (contact?.retentionDays != null && !inRange(contact.retentionDays, 1, CONTACT_RETENTION_MAX)) {
|
|
|
|
|
|
errors.push({
|
|
|
|
|
|
field: 'contact.retentionDays',
|
|
|
|
|
|
message: `Must be a whole number of days from 1 to ${CONTACT_RETENTION_MAX}.`,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// sanitizeNavLinks drops an entry without a label or without an http(s)
|
|
|
|
|
|
// URL, whole — so each half is reported against its own input.
|
|
|
|
|
|
if (Array.isArray(s.navLinks)) {
|
|
|
|
|
|
s.navLinks.forEach((entry: unknown, i) => {
|
|
|
|
|
|
const link = entry && typeof entry === 'object' ? (entry as Record<string, unknown>) : {};
|
|
|
|
|
|
const label = typeof link.label === 'string' ? link.label.trim() : '';
|
|
|
|
|
|
const url = typeof link.url === 'string' ? link.url.trim() : '';
|
|
|
|
|
|
if (!label) {
|
|
|
|
|
|
errors.push({
|
|
|
|
|
|
field: `navLinks.${i}.label`,
|
|
|
|
|
|
message: 'Needs a label, or the link is left out.',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!NAV_LINK_URL.test(url)) {
|
|
|
|
|
|
errors.push({
|
|
|
|
|
|
field: `navLinks.${i}.url`,
|
|
|
|
|
|
message: 'Must be a full http(s) address, or the link is left out.',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 22:50:30 +02:00
|
|
|
|
return errors;
|
|
|
|
|
|
}
|