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>
482 lines
16 KiB
TypeScript
482 lines
16 KiB
TypeScript
/**
|
|
* Journal & Storytelling Types, Parser and Serializer.
|
|
* Client-safe pure TypeScript module (no Node.js fs dependencies).
|
|
*/
|
|
|
|
export interface JournalFrontmatter {
|
|
title?: string;
|
|
subtitle?: string;
|
|
date?: string;
|
|
author?: string;
|
|
coverAssetId?: string;
|
|
layout?: string;
|
|
password?: string;
|
|
draft?: boolean;
|
|
}
|
|
|
|
export type JournalBlock =
|
|
| { type: 'heading'; level: number; text: string }
|
|
| { type: 'paragraph'; html: string }
|
|
| { type: 'quote'; text: string; author?: string }
|
|
| { type: 'photo'; assetId: string; caption?: string; layout: 'fullbleed' | 'wide' | 'contained' }
|
|
| { type: 'photo-pair'; assetIds: [string, string]; caption?: string };
|
|
|
|
export interface ParsedJournal {
|
|
frontmatter: JournalFrontmatter;
|
|
blocks: JournalBlock[];
|
|
referencedAssetIds: string[];
|
|
}
|
|
|
|
export interface JournalEntrySummary {
|
|
slug: string;
|
|
filename: string;
|
|
frontmatter: JournalFrontmatter;
|
|
excerpt: string;
|
|
wordCount: number;
|
|
readingTimeMinutes: number;
|
|
}
|
|
|
|
const HTML_ESCAPES: Record<string, string> = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
};
|
|
|
|
/**
|
|
* Make an author's text inert as HTML.
|
|
*
|
|
* This escapes rather than filters. The previous implementation stripped
|
|
* `<script>` tags, `on*="..."` handlers and the literal string `javascript:`,
|
|
* which is a denylist and was trivially bypassable: `<img src=x onerror=alert(1)>`
|
|
* (unquoted handler), `onerror='...'` (single quotes), `<svg onload=...>` (not a
|
|
* script tag) and `javasjavascript:cript:` (the replacement recombines) all
|
|
* passed through into `dangerouslySetInnerHTML`.
|
|
*
|
|
* With escaping there is nothing to enumerate: the only tags in the output are
|
|
* the ones renderInlineMarkdown emits itself.
|
|
*/
|
|
export function sanitizeHtml(input: string): string {
|
|
return input.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
|
|
}
|
|
|
|
/**
|
|
* Reverse of sanitizeHtml, for turning rendered block HTML back into Markdown.
|
|
* Without this a save would write `<` into the file and the next parse would
|
|
* escape the `&` again, corrupting the text a little more on every round trip.
|
|
*/
|
|
export function decodeHtmlEntities(input: string): string {
|
|
return input
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&');
|
|
}
|
|
|
|
/**
|
|
* Schemes an author may link to. Everything else — `javascript:`, `data:`,
|
|
* `vbscript:` — renders as plain text instead of an anchor.
|
|
*/
|
|
const SAFE_URL = /^(?:https?:\/\/|mailto:|tel:|[./#])/i;
|
|
|
|
function isSafeUrl(url: string): boolean {
|
|
// Browsers ignore control characters and whitespace inside a scheme, so
|
|
// `java\tscript:` would run. Strip them before deciding.
|
|
return SAFE_URL.test(url.replace(/[\u0000-\u0020]/g, ''));
|
|
}
|
|
|
|
/** Sanitize a slug to only allow safe URL and filesystem characters */
|
|
export function isValidSlug(slug: string): boolean {
|
|
return /^[a-zA-Z0-9_-]+$/.test(slug) && !slug.includes('..');
|
|
}
|
|
|
|
export function sanitizeSlug(input: string): string {
|
|
return (
|
|
input
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^a-z0-9_-]/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '') || 'untitled'
|
|
);
|
|
}
|
|
|
|
/** Simple Markdown inline formatting (bold, italic, links) */
|
|
export function renderInlineMarkdown(text: string): string {
|
|
// Escape first: from here on every `<` and `"` in the string is the author's
|
|
// literal text, so the markdown rules below can only ever add the tags they
|
|
// build themselves, and a URL cannot break out of its href attribute.
|
|
let html = sanitizeHtml(text);
|
|
// Bold: **text** or __text__
|
|
//
|
|
// CodeQL flags the lazy group here as polynomial backtracking. It is left as
|
|
// is on purpose: rewriting it with a negated class (`\*\*([^*]+)\*\*`) removes
|
|
// the backtracking but also breaks nesting — `**bold *and italic* here**`
|
|
// then renders as `*<em>bold </em>and italic<em> here</em>*`. The input is
|
|
// Markdown written by an authenticated admin, never visitor input, so the
|
|
// worst case is an author slowing down their own page.
|
|
// `**bold**` works anywhere; `__bold__` only outside a word, matching
|
|
// CommonMark's intraword rule for `_` — otherwise `my_file_name` round-trips
|
|
// through `<em>` and comes back with asterisks in the middle of a word.
|
|
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
|
html = html.replace(/(?<![\w])__(.*?)__(?![\w])/g, '<strong>$1</strong>');
|
|
// 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) => {
|
|
const trimmed = url.trim();
|
|
if (!isSafeUrl(trimmed)) return label;
|
|
return `<a href="${trimmed}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
|
});
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* Inverse of renderInlineMarkdown: walks the `<strong>`/`<em>`/`<a href>` tags
|
|
* it produces (and only those — anything else is dropped, keeping its
|
|
* content, matching what the previous flat regex did for unknown tags) back
|
|
* into their Markdown source, recursing so a link around bold text, or bold
|
|
* text around a link, both come back correctly. Entities are decoded once at
|
|
* the end, since every text run and the `href` value are the only places
|
|
* they appear — the markdown syntax this function adds is never itself
|
|
* entity-escaped.
|
|
*/
|
|
function inlineHtmlToMarkdownRaw(html: string): string {
|
|
let out = '';
|
|
let i = 0;
|
|
while (i < html.length) {
|
|
if (html[i] !== '<') {
|
|
out += html[i];
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
const openMatch = /^<(\w+)([^>]*)>/.exec(html.slice(i));
|
|
if (!openMatch) {
|
|
out += html[i];
|
|
i++;
|
|
continue;
|
|
}
|
|
const [full, tagName, attrs] = openMatch;
|
|
|
|
// Find this tag's matching close, tracking nested opens of the same tag.
|
|
const closeTag = `</${tagName}>`;
|
|
let depth = 1;
|
|
let scan = i + full.length;
|
|
let closeAt = -1;
|
|
while (scan < html.length) {
|
|
const nextClose = html.indexOf(closeTag, scan);
|
|
if (nextClose === -1) break;
|
|
const nextOpen = html.indexOf(`<${tagName}`, scan);
|
|
if (nextOpen !== -1 && nextOpen < nextClose) {
|
|
depth++;
|
|
scan = nextOpen + 1;
|
|
} else {
|
|
depth--;
|
|
scan = nextClose + closeTag.length;
|
|
if (depth === 0) {
|
|
closeAt = nextClose;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (closeAt === -1) {
|
|
// Unmatched tag: not something this renderer produces, pass it through
|
|
// as literal text rather than losing it.
|
|
out += html[i];
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
const innerMd = inlineHtmlToMarkdownRaw(html.slice(i + full.length, closeAt));
|
|
if (tagName === 'strong' || tagName === 'b') out += `**${innerMd}**`;
|
|
else if (tagName === 'em' || tagName === 'i') out += `*${innerMd}*`;
|
|
else if (tagName === 'a') {
|
|
const href = /\shref="([^"]*)"/.exec(attrs)?.[1] ?? '';
|
|
out += `[${innerMd}](${href})`;
|
|
} else {
|
|
out += innerMd;
|
|
}
|
|
i = closeAt + closeTag.length;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function inlineHtmlToMarkdown(html: string): string {
|
|
return decodeHtmlEntities(inlineHtmlToMarkdownRaw(html));
|
|
}
|
|
|
|
const isWhitespace = (char: string) => char.trim() === '';
|
|
|
|
/**
|
|
* Split `Quote -- Author` at the first separator surrounded by whitespace.
|
|
*
|
|
* A plain scan rather than a pattern. The original `/^(.*?)(?:\s+--\s+(.+))?$/`
|
|
* wrapped a lazy group around an optional one — the quadratic case — and its
|
|
* first replacement, `/\s+--\s+/`, was reported as polynomial backtracking in
|
|
* turn. This is linear and needs no reasoning about the engine at all.
|
|
*/
|
|
export function splitQuoteAuthor(text: string): { body: string; author?: string } {
|
|
for (
|
|
let hyphens = text.indexOf('--');
|
|
hyphens !== -1;
|
|
hyphens = text.indexOf('--', hyphens + 2)
|
|
) {
|
|
let start = hyphens;
|
|
while (start > 0 && isWhitespace(text[start - 1])) start--;
|
|
|
|
let end = hyphens + 2;
|
|
while (end < text.length && isWhitespace(text[end])) end++;
|
|
|
|
// Whitespace is required on both sides, so `a--b` is not a separator.
|
|
if (start === hyphens || end === hyphens + 2) continue;
|
|
|
|
return { body: text.slice(0, start), author: text.slice(end).trim() || undefined };
|
|
}
|
|
|
|
return { body: text };
|
|
}
|
|
|
|
/** Parse frontmatter (YAML block delimited by ---) */
|
|
export function parseFrontmatter(content: string): {
|
|
frontmatter: JournalFrontmatter;
|
|
body: string;
|
|
} {
|
|
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
if (!frontmatterMatch) {
|
|
return { frontmatter: {}, body: content };
|
|
}
|
|
|
|
const rawYaml = frontmatterMatch[1];
|
|
const body = content.slice(frontmatterMatch[0].length);
|
|
const frontmatter: JournalFrontmatter = {};
|
|
|
|
for (const line of rawYaml.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const colonIdx = trimmed.indexOf(':');
|
|
if (colonIdx === -1) continue;
|
|
|
|
const key = trimmed.slice(0, colonIdx).trim();
|
|
let val = trimmed.slice(colonIdx + 1).trim();
|
|
if (val.length >= 2 && val.startsWith('"') && val.endsWith('"')) {
|
|
// Undo the escaping serializeJournalMarkdown applies. Without this a title
|
|
// containing a quote or a backslash gained one more backslash on every
|
|
// save/load cycle.
|
|
val = val.slice(1, -1).replace(/\\([\\"])/g, '$1');
|
|
} else if (val.length >= 2 && val.startsWith("'") && val.endsWith("'")) {
|
|
val = val.slice(1, -1);
|
|
}
|
|
|
|
if (key === 'title') frontmatter.title = val;
|
|
else if (key === 'subtitle') frontmatter.subtitle = val;
|
|
else if (key === 'date') frontmatter.date = val;
|
|
else if (key === 'author') frontmatter.author = val;
|
|
else if (key === 'coverAssetId') frontmatter.coverAssetId = val;
|
|
else if (key === 'layout') frontmatter.layout = val;
|
|
else if (key === 'password') frontmatter.password = val;
|
|
else if (key === 'draft') frontmatter.draft = val === 'true' || val === '1';
|
|
}
|
|
|
|
return { frontmatter, body };
|
|
}
|
|
|
|
/** Parse Journal Markdown content into structured blocks */
|
|
export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
|
const { frontmatter, body } = parseFrontmatter(rawContent);
|
|
const blocks: JournalBlock[] = [];
|
|
const referencedAssetIds = new Set<string>();
|
|
|
|
if (frontmatter.coverAssetId) {
|
|
referencedAssetIds.add(frontmatter.coverAssetId);
|
|
}
|
|
|
|
// Split body into paragraph chunks separated by blank lines
|
|
const chunks = body
|
|
.split(/\r?\n\s*\r?\n/)
|
|
.map((c) => c.trim())
|
|
.filter(Boolean);
|
|
|
|
for (const chunk of chunks) {
|
|
// 1. Headings (# H1, ## H2, ### H3)
|
|
// `[ \t]+` rather than `\s+`: next to `(.+)` the two overlap, and the
|
|
// engine has to try every split of the whitespace run before failing.
|
|
const headingMatch = chunk.match(/^(#{1,6})[ \t]+(\S.*)$/);
|
|
if (headingMatch) {
|
|
blocks.push({
|
|
type: 'heading',
|
|
level: headingMatch[1].length,
|
|
text: headingMatch[2].trim(),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// 2. Blockquote (> Quote text)
|
|
if (chunk.startsWith('>')) {
|
|
const quoteText = chunk
|
|
.split('\n')
|
|
.map((l) => l.replace(/^>\s?/, ''))
|
|
.join(' ')
|
|
.trim();
|
|
|
|
const { body: quoteBody, author: quoteAuthor } = splitQuoteAuthor(quoteText);
|
|
|
|
blocks.push({
|
|
type: 'quote',
|
|
text: renderInlineMarkdown(quoteBody),
|
|
author: quoteAuthor,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// 3. Image syntax:  or 
|
|
const imgMatch = chunk.match(/^!\[([^\]]+)\]\(([^)]*)\)$/);
|
|
if (imgMatch) {
|
|
const rawTarget = imgMatch[1].trim();
|
|
const caption = imgMatch[2].trim() ? renderInlineMarkdown(imgMatch[2].trim()) : undefined;
|
|
|
|
// Side-by-side pair: 
|
|
if (rawTarget.includes(',')) {
|
|
const parts = rawTarget.split(',').map((s) => s.trim());
|
|
if (parts.length >= 2) {
|
|
const id1 = parts[0];
|
|
const id2 = parts[1];
|
|
referencedAssetIds.add(id1);
|
|
referencedAssetIds.add(id2);
|
|
blocks.push({
|
|
type: 'photo-pair',
|
|
assetIds: [id1, id2],
|
|
caption,
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Single photo: 
|
|
let assetId = rawTarget;
|
|
let layout: 'fullbleed' | 'wide' | 'contained' = 'contained';
|
|
|
|
if (rawTarget.includes(':')) {
|
|
const [id, l] = rawTarget.split(':');
|
|
assetId = id.trim();
|
|
const normLayout = l.trim().toLowerCase();
|
|
if (normLayout === 'fullbleed') layout = 'fullbleed';
|
|
else if (normLayout === 'wide') layout = 'wide';
|
|
}
|
|
|
|
referencedAssetIds.add(assetId);
|
|
blocks.push({
|
|
type: 'photo',
|
|
assetId,
|
|
caption,
|
|
layout,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// 4. Standard Text Paragraph
|
|
blocks.push({
|
|
type: 'paragraph',
|
|
html: renderInlineMarkdown(chunk.replace(/\r?\n/g, ' ')),
|
|
});
|
|
}
|
|
|
|
return {
|
|
frontmatter,
|
|
blocks,
|
|
referencedAssetIds: Array.from(referencedAssetIds),
|
|
};
|
|
}
|
|
|
|
/** Converts a ParsedJournal structure back into clean Markdown syntax */
|
|
export function serializeJournalMarkdown(journal: ParsedJournal): string {
|
|
const lines: string[] = [];
|
|
|
|
// Frontmatter
|
|
const fmKeys = Object.keys(journal.frontmatter) as Array<keyof JournalFrontmatter>;
|
|
if (
|
|
fmKeys.length > 0 &&
|
|
fmKeys.some((k) => journal.frontmatter[k] !== undefined && journal.frontmatter[k] !== '')
|
|
) {
|
|
lines.push('---');
|
|
for (const key of fmKeys) {
|
|
const val = journal.frontmatter[key];
|
|
if (val !== undefined && val !== '') {
|
|
if (typeof val === 'boolean') {
|
|
lines.push(`${key}: ${val}`);
|
|
} else {
|
|
const escaped = String(val).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
lines.push(`${key}: "${escaped}"`);
|
|
}
|
|
}
|
|
}
|
|
lines.push('---');
|
|
lines.push('');
|
|
}
|
|
|
|
// Blocks
|
|
for (const block of journal.blocks) {
|
|
switch (block.type) {
|
|
case 'heading': {
|
|
const hashes = '#'.repeat(Math.min(Math.max(block.level, 1), 6));
|
|
lines.push(`${hashes} ${block.text}`);
|
|
break;
|
|
}
|
|
case 'paragraph': {
|
|
lines.push(inlineHtmlToMarkdown(block.html));
|
|
break;
|
|
}
|
|
case 'quote': {
|
|
const authorSuffix = block.author ? ` -- ${block.author}` : '';
|
|
const text = inlineHtmlToMarkdown(block.text.replace(/[\r\n]+/g, ' '));
|
|
lines.push(`> ${text}${authorSuffix}`);
|
|
break;
|
|
}
|
|
case 'photo': {
|
|
const layoutSuffix = block.layout !== 'contained' ? `:${block.layout}` : '';
|
|
const caption = block.caption
|
|
? inlineHtmlToMarkdown(block.caption.replace(/[\r\n]+/g, ' '))
|
|
: '';
|
|
lines.push(``);
|
|
break;
|
|
}
|
|
case 'photo-pair': {
|
|
const caption = block.caption
|
|
? inlineHtmlToMarkdown(block.caption.replace(/[\r\n]+/g, ' '))
|
|
: '';
|
|
lines.push(`![${block.assetIds[0]}, ${block.assetIds[1]}](${caption})`);
|
|
break;
|
|
}
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
return lines.join('\n').trim();
|
|
}
|
|
|
|
/** Calculate approximate word count and reading time */
|
|
export function calculateReadingTime(text: string): { words: number; minutes: number } {
|
|
const plainText = text.replace(/<[^>]+>/g, ' ').replace(/!\[.*?\]\(.*?\)/g, ' ');
|
|
const words = plainText.trim().split(/\s+/).filter(Boolean).length;
|
|
const minutes = Math.max(1, Math.round(words / 200));
|
|
return { words, minutes };
|
|
}
|
|
|
|
/** Extract a brief plain-text excerpt from journal blocks */
|
|
export function extractExcerpt(parsed: ParsedJournal, maxLength = 160): string {
|
|
if (parsed.frontmatter.subtitle) return parsed.frontmatter.subtitle;
|
|
for (const block of parsed.blocks) {
|
|
if (block.type === 'paragraph') {
|
|
const text = block.html.replace(/<[^>]+>/g, '').trim();
|
|
if (text) {
|
|
return text.length > maxLength ? `${text.slice(0, maxLength).trim()}...` : text;
|
|
}
|
|
}
|
|
}
|
|
return '';
|
|
}
|