diff --git a/scripts/generate-changelog-page.ts b/scripts/generate-changelog-page.ts index 6ab60d1..e17d6a7 100644 --- a/scripts/generate-changelog-page.ts +++ b/scripts/generate-changelog-page.ts @@ -10,12 +10,15 @@ import { join } from 'path'; const ROOT_DIR = join(import.meta.dir, '..'); const CHANGELOG_PATH = join(ROOT_DIR, 'src/lib/data/changelog.json'); const INDEX_PATH = join(ROOT_DIR, 'webpage/index.html'); +const RSS_PATH = join(ROOT_DIR, 'webpage/changelog.xml'); +const SITE_URL = 'https://dockhand.pro'; interface ChangelogEntry { version: string; date: string; changes: Array<{ type: 'feature' | 'fix'; text: string }>; imageTag: string; + comingSoon?: boolean; } // SVG icons for change types @@ -25,10 +28,26 @@ const FIX_SVG = ``; +// Lucide rss icon for the "subscribe to release notes" link. +const RSS_SVG = ``; + const COPY_SVG = ``; -function formatDate(dateStr: string): string { +const GITHUB_SVG = ``; + +const EXTLINK_SVG = ``; + +// Lucide git-pull-request-arrow — inline before PR refs so they're visually distinct from issues. +const PR_SVG = ``; + +function formatDate(dateStr: string, comingSoon?: boolean): string { + if (!dateStr || comingSoon) { + return 'Coming soon'; + } const date = new Date(dateStr); + if (isNaN(date.getTime())) { + return 'Coming soon'; + } return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', @@ -36,22 +55,117 @@ function formatDate(dateStr: string): string { }); } +function sortChanges(changes: Array<{ type: 'feature' | 'fix'; text: string }>): Array<{ type: 'feature' | 'fix'; text: string }> { + return [...changes].sort((a, b) => { + if (a.type === b.type) return 0; + return a.type === 'feature' ? -1 : 1; + }); +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +function linkifyIssues(text: string): string { + // Tokenize PR#N, #N, and @username. Collect each contiguous group of refs + // (separated only by whitespace, commas, or parens) into a single pill cluster + // prefixed by the GitHub octocat icon. + const PATTERN = /PR#(\d+)|#(\d+)|@([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38}))/g; + + type RefLink = { html: string }; + const matches: Array<{ start: number; end: number; ref: RefLink }> = []; + + for (const m of text.matchAll(PATTERN)) { + const start = m.index ?? 0; + const end = start + m[0].length; + let html: string; + if (m[1]) { + html = `${PR_SVG}#${m[1]}`; + } else if (m[2]) { + html = `#${m[2]}`; + } else { + const name = m[3]!; + html = `@${name}`; + } + matches.push({ start, end, ref: { html } }); + } + + if (matches.length === 0) return escapeHtml(text); + + // Group adjacent matches whose gap contains only [\s,()] + const GLUE_RE = /^[\s,()]*$/; + const groups: Array<{ start: number; end: number; refs: RefLink[] }> = []; + let current = { start: matches[0].start, end: matches[0].end, refs: [matches[0].ref] }; + for (let i = 1; i < matches.length; i++) { + const gap = text.slice(current.end, matches[i].start); + if (GLUE_RE.test(gap)) { + current.refs.push(matches[i].ref); + current.end = matches[i].end; + } else { + groups.push(current); + current = { start: matches[i].start, end: matches[i].end, refs: [matches[i].ref] }; + } + } + groups.push(current); + + // Expand each group's bounds to swallow surrounding glue (parens, commas, spaces) + // so we don't end up with "foo · #123" or "foo (#123)" leftovers. + let out = ''; + let lastIndex = 0; + for (const g of groups) { + let s = g.start; + while (s > lastIndex && /[\s(]/.test(text[s - 1])) s--; + let e = g.end; + while (e < text.length && /[\s),]/.test(text[e])) e++; + out += escapeHtml(text.slice(lastIndex, s)); + out += ` ${GITHUB_SVG}${g.refs.map(r => r.html).join(' · ')}`; + lastIndex = e; + } + out += escapeHtml(text.slice(lastIndex)); + + return out; +} + function generateChangeItem(change: { type: 'feature' | 'fix'; text: string }): string { const pillClass = change.type === 'feature' ? 'changelog-pill-feature' : 'changelog-pill-fix'; const svg = change.type === 'feature' ? FEATURE_SVG : FIX_SVG; const label = change.type === 'feature' ? 'New' : 'Fix'; - return `
${entry.imageTag}
+
+ Track our progress and see what's new in each version. Spoiler: it gets better every time.