release: v0.16.0

See CHANGELOG.md for the full v0.16.0 entry.

---------

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>
This commit is contained in:
Ralksta
2026-09-22 14:12:13 +02:00
committed by GitHub
parent c3f92ca4ec
commit a3a6319c29
120 changed files with 6818 additions and 975 deletions

View File

@@ -159,10 +159,19 @@ export function buildCoverGridVars(
return vars;
}
function clamp(value: number, min: number, max: number): number {
/**
* `unknown` rather than `number`: this also guards the site-wide and
* per-subpage/album grid columns and gap (lib/config/index.ts), which a
* hand-edited gallery.yaml or settings.yaml can hand a string, `null`, or
* anything else. `typeof value !== 'number'` catches those before
* `Number.isFinite` gets a chance to — it would otherwise coerce nothing and
* just report `false` for a non-number, which happens to be what we want here
* too, but checking the type first makes that not an accident.
*/
export function clamp(value: unknown, min: number, max: number): number {
// NaN from a malformed YAML value would survive Math.min/Math.max, and
// `repeat(NaN, 1fr)` is an invalid declaration that drops the whole rule.
if (!Number.isFinite(value)) return min;
if (typeof value !== 'number' || !Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, Math.round(value)));
}
@@ -539,6 +548,36 @@ export function albumSlug(name: string, id: string): string {
return slugify(name) || id;
}
/**
* Groups of albums whose slug collides, among a set that will all be
* reachable under one URL prefix — the standalone albums, or one subpage's
* albums (top-level and sectioned together, since they share a route).
*
* Every consumer that resolves a slug — getAlbumBySlug, the sitemap, the page
* builder — takes the first match, so an unflagged collision makes the second
* album unreachable and has its settings (password, sort, grid…) resolved
* from the first instead (#632).
*
* An empty slug (a name with no letters or digits at all) is excluded:
* albumSlug() falls back to the album id for that case, which is unique by
* construction, so it can never actually collide.
*/
export function findAlbumSlugCollisions(
albums: Array<{ id: string; name: string }>,
): Array<{ slug: string; albums: Array<{ id: string; name: string }> }> {
const bySlug = new Map<string, Array<{ id: string; name: string }>>();
for (const album of albums) {
const slug = slugify(album.name);
if (!slug) continue;
const group = bySlug.get(slug) ?? [];
group.push(album);
bySlug.set(slug, group);
}
return Array.from(bySlug.entries())
.filter(([, group]) => group.length > 1)
.map(([slug, group]) => ({ slug, albums: group }));
}
/**
* Normalize a slug that came in from a URL before comparing it to a stored one.
*