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:
114
lib/journal.ts
114
lib/journal.ts
@@ -6,6 +6,8 @@
|
||||
export interface JournalFrontmatter {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
/** SEO description. Content pages use it (#722); journal entries ignore it. */
|
||||
description?: string;
|
||||
date?: string;
|
||||
author?: string;
|
||||
coverAssetId?: string;
|
||||
@@ -184,14 +186,28 @@ export function isValidSlug(slug: string): boolean {
|
||||
}
|
||||
|
||||
export function sanitizeSlug(input: string): string {
|
||||
return (
|
||||
input
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'untitled'
|
||||
);
|
||||
return slugFieldValue(input.trim()).replace(/-$/, '') || 'untitled';
|
||||
}
|
||||
|
||||
/**
|
||||
* A slug field's value while it is being typed: sanitizeSlug() without the two
|
||||
* steps that only suit a finished slug. Dropping the trailing "-" on every
|
||||
* keystroke made a hyphen impossible to type ("about-" became "about", and the
|
||||
* next key gave "aboutu"), and an emptied field jumped to "untitled". Pass the
|
||||
* result through sanitizeSlug() before using it as a slug.
|
||||
*
|
||||
* Accented letters keep their base letter ("für" → "fur", "Straße" →
|
||||
* "strasse"), as subpage slugs do; before, each one split the word ("f-r").
|
||||
*/
|
||||
export function slugFieldValue(input: string): string {
|
||||
return input
|
||||
.replace(/ß/g, 'ss')
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-/, '');
|
||||
}
|
||||
|
||||
/** Simple Markdown inline formatting (bold, italic, links) */
|
||||
@@ -216,8 +232,11 @@ export function renderInlineMarkdown(text: string): string {
|
||||
// Italic: *text* or _text_, same intraword rule for `_`.
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/(?<![\w])_(.*?)_(?![\w])/g, '<em>$1</em>');
|
||||
// Links: [label](url)
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => {
|
||||
// Links: [label](url). The URL may hold one level of balanced parentheses,
|
||||
// as Wikipedia-style URLs do (`…/Foo_(bar)`); it used to end at the first
|
||||
// `)` and leave the rest as text. The two alternatives start on disjoint
|
||||
// characters, so this stays linear.
|
||||
html = html.replace(/\[([^\]]+)\]\(((?:[^()]|\([^()]*\))+)\)/g, (match, label, url) => {
|
||||
const trimmed = url.trim();
|
||||
if (!isSafeUrl(trimmed)) return label;
|
||||
return `<a href="${trimmed}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||
@@ -365,6 +384,7 @@ export function parseFrontmatter(content: string): {
|
||||
|
||||
if (key === 'title') frontmatter.title = val;
|
||||
else if (key === 'subtitle') frontmatter.subtitle = val;
|
||||
else if (key === 'description') frontmatter.description = val;
|
||||
else if (key === 'date') frontmatter.date = val;
|
||||
else if (key === 'author') frontmatter.author = val;
|
||||
else if (key === 'coverAssetId') frontmatter.coverAssetId = val;
|
||||
@@ -376,6 +396,63 @@ export function parseFrontmatter(content: string): {
|
||||
return { frontmatter, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a chunk before every line that starts a photo reference (`![`).
|
||||
*
|
||||
* Paragraphs are separated by blank lines, so two photo lines written without
|
||||
* one, or a heading or sentence directly above a photo, used to arrive as a
|
||||
* single chunk. No block pattern matched it and it fell through to the
|
||||
* paragraph renderer, which printed the raw asset ids on the public page.
|
||||
* Directive chunks (`::map`, `::facts`, `::album`) are left alone: their
|
||||
* lines are key/value pairs, never photo references.
|
||||
*/
|
||||
function splitAtPhotoLines(chunk: string): string[] {
|
||||
if (chunk.startsWith('::') || !/(?:^|\n)[ \t]*!\[/.test(chunk)) return [chunk];
|
||||
const segments: string[] = [];
|
||||
let current: string[] = [];
|
||||
for (const line of chunk.split(/\r?\n/)) {
|
||||
if (line.trimStart().startsWith('![') && current.length > 0) {
|
||||
segments.push(current.join('\n').trim());
|
||||
current = [];
|
||||
}
|
||||
current.push(line);
|
||||
}
|
||||
segments.push(current.join('\n').trim());
|
||||
return segments.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a photo line, ``, into target and caption.
|
||||
*
|
||||
* The target ends at the first `]` on the line: asset ids, layouts and commas
|
||||
* never contain one. The caption is everything after `](` up to the chunk's
|
||||
* **last** `)`, so it may itself contain parentheses and links (`Gipfel
|
||||
* (560 m)`, `see [map](https://…)`) and needs no escaping. The previous
|
||||
* pattern, `\(([^)]*)\)$`, stopped at the first `)`. The serializer wrote such
|
||||
* captions verbatim, the next parse rejected the line, the public page printed
|
||||
* it as a paragraph with the raw asset ids, and the editor turned the photo
|
||||
* into a text block. A line that pattern accepted has no `)` inside its
|
||||
* caption, so it parses exactly as before.
|
||||
*
|
||||
* Every chunk starting with `![` is a photo line, even a malformed one (no
|
||||
* `(`, text after the last `)`): it becomes a photo block whose caption keeps
|
||||
* the stray text, never a paragraph that would print the target.
|
||||
*/
|
||||
function parsePhotoLine(chunk: string): { target: string; caption: string } | null {
|
||||
if (!chunk.startsWith('![')) return null;
|
||||
const firstLineEnd = chunk.indexOf('\n');
|
||||
const firstLine = firstLineEnd === -1 ? chunk : chunk.slice(0, firstLineEnd);
|
||||
const close = firstLine.indexOf(']');
|
||||
const targetEnd = close === -1 ? firstLine.length : close;
|
||||
const target = chunk.slice(2, targetEnd).trim();
|
||||
let rest = chunk.slice(close === -1 ? targetEnd : targetEnd + 1);
|
||||
if (rest.startsWith('(')) {
|
||||
rest = rest.slice(1);
|
||||
if (rest.endsWith(')')) rest = rest.slice(0, -1);
|
||||
}
|
||||
return { target, caption: rest.trim() };
|
||||
}
|
||||
|
||||
/** Parse Journal Markdown content into structured blocks */
|
||||
export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
const { frontmatter, body } = parseFrontmatter(rawContent);
|
||||
@@ -385,7 +462,8 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
const chunks = body
|
||||
.split(/\r?\n\s*\r?\n/)
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
.filter(Boolean)
|
||||
.flatMap(splitAtPhotoLines);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
// 0a. Map: `::map Caption`, then one line per pin in drawing order —
|
||||
@@ -526,10 +604,12 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
// unfilled placeholder — `assetId: ''`, serialized as `` or
|
||||
// `` — still parses back into a `photo` block instead of
|
||||
// silently degrading into a text paragraph on the next load.
|
||||
const imgMatch = chunk.match(/^!\[([^\]]*)\]\(([^)]*)\)$/);
|
||||
if (imgMatch) {
|
||||
const rawTarget = imgMatch[1].trim();
|
||||
const caption = imgMatch[2].trim() ? renderInlineMarkdown(imgMatch[2].trim()) : undefined;
|
||||
//
|
||||
// parsePhotoLine() has the caption run to the last `)`, see there.
|
||||
const photoLine = parsePhotoLine(chunk);
|
||||
if (photoLine) {
|
||||
const rawTarget = photoLine.target;
|
||||
const caption = photoLine.caption ? renderInlineMarkdown(photoLine.caption) : undefined;
|
||||
|
||||
// Two ids are a side-by-side pair, three or more a grid. The pair used
|
||||
// to take the first two of any count and drop the rest on the next save.
|
||||
@@ -704,7 +784,9 @@ export function serializeJournalMarkdown(journal: ParsedJournal): string {
|
||||
export function calculateReadingTime(text: string): { words: number; minutes: number } {
|
||||
const plainText = text
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/!\[.*?\]\(.*?\)/g, ' ')
|
||||
// A photo line is a reference and its caption, to the end of the line:
|
||||
// the caption may itself contain `)`.
|
||||
.replace(/^[ \t]*!\[.*$/gm, ' ')
|
||||
// Directive lines (`::facts`, `::map …`) are structure, not reading.
|
||||
.replace(/^::\w+.*$/gm, ' ');
|
||||
const words = plainText.trim().split(/\s+/).filter(Boolean).length;
|
||||
|
||||
Reference in New Issue
Block a user