updated generate-changelog-page.ts

This commit is contained in:
Jarek Krochmalski committed 2026-08-22 09:25:42 +02:00
1 parent aff8f25d2a
commit 9700412a8f
1 file changed
+283 -47
+283 -47
View File
@@ -10,12 +10,15 @@ import { join } from 'path';
const ROOT_DIR = join(import.meta.dir, '..'); const ROOT_DIR = join(import.meta.dir, '..');
const CHANGELOG_PATH = join(ROOT_DIR, 'src/lib/data/changelog.json'); const CHANGELOG_PATH = join(ROOT_DIR, 'src/lib/data/changelog.json');
const INDEX_PATH = join(ROOT_DIR, 'webpage/index.html'); 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 { interface ChangelogEntry {
version: string; version: string;
date: string; date: string;
changes: Array<{ type: 'feature' | 'fix'; text: string }>; changes: Array<{ type: 'feature' | 'fix'; text: string }>;
imageTag: string; imageTag: string;
comingSoon?: boolean;
} }
// SVG icons for change types // SVG icons for change types
@@ -25,10 +28,26 @@ const FIX_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stro
const TOGGLE_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>`; const TOGGLE_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>`;
// Lucide rss icon for the "subscribe to release notes" link.
const RSS_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>`;
const COPY_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`; const COPY_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
function formatDate(dateStr: string): string { const GITHUB_SVG = `<svg viewBox="0 0 24 24" fill="currentColor" style="width:10px;height:10px"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>`;
const EXTLINK_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:10px;height:10px;vertical-align:middle"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`;
// Lucide git-pull-request-arrow — inline before PR refs so they're visually distinct from issues.
const PR_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:10px;height:10px;vertical-align:-1px;margin-right:1px"><circle cx="5" cy="6" r="3"/><path d="M5 9v12"/><path d="m15 9-3-3 3-3"/><path d="M12 6h5a3 3 0 0 1 3 3v10"/></svg>`;
function formatDate(dateStr: string, comingSoon?: boolean): string {
if (!dateStr || comingSoon) {
return 'Coming soon';
}
const date = new Date(dateStr); const date = new Date(dateStr);
if (isNaN(date.getTime())) {
return 'Coming soon';
}
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'long', 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 = `<a href="https://github.com/Finsys/dockhand/pull/${m[1]}" target="_blank" rel="noopener noreferrer" class="changelog-issue-link" title="Pull request #${m[1]}">${PR_SVG}#${m[1]}</a>`;
} else if (m[2]) {
html = `<a href="https://github.com/Finsys/dockhand/issues/${m[2]}" target="_blank" rel="noopener noreferrer" class="changelog-issue-link" title="Issue #${m[2]}">#${m[2]}</a>`;
} else {
const name = m[3]!;
html = `<a href="https://github.com/${name}" target="_blank" rel="noopener noreferrer" class="changelog-issue-link" title="@${name} on GitHub">@${name}</a>`;
}
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 += ` <span class="changelog-issues">${GITHUB_SVG}${g.refs.map(r => r.html).join(' · ')}</span>`;
lastIndex = e;
}
out += escapeHtml(text.slice(lastIndex));
return out;
}
function generateChangeItem(change: { type: 'feature' | 'fix'; text: string }): string { function generateChangeItem(change: { type: 'feature' | 'fix'; text: string }): string {
const pillClass = change.type === 'feature' ? 'changelog-pill-feature' : 'changelog-pill-fix'; const pillClass = change.type === 'feature' ? 'changelog-pill-feature' : 'changelog-pill-fix';
const svg = change.type === 'feature' ? FEATURE_SVG : FIX_SVG; const svg = change.type === 'feature' ? FEATURE_SVG : FIX_SVG;
const label = change.type === 'feature' ? 'New' : 'Fix'; const label = change.type === 'feature' ? 'New' : 'Fix';
return ` <li><span class="changelog-pill ${pillClass}">${svg}${label}</span>${change.text}</li>`; return ` <li><span class="changelog-pill ${pillClass}">${svg}${label}</span><span class="changelog-text">${linkifyIssues(change.text)}</span></li>`;
}
function versionLink(version: string, comingSoon?: boolean): string {
if (comingSoon) return version;
return `<a href="https://github.com/Finsys/dockhand/releases/tag/${version}" target="_blank" rel="noopener noreferrer" class="changelog-version-link">${version} ${GITHUB_SVG}</a>`;
}
function generateComingSoonEntry(entry: ChangelogEntry): string {
const changes = sortChanges(entry.changes).map(generateChangeItem).join('\n');
const version = entry.version.startsWith('v') ? entry.version : `v${entry.version}`;
return ` <!-- ${version} (coming soon) -->
<div class="changelog-entry">
<div class="changelog-header">
<div class="changelog-version">
<h3>${versionLink(version, true)}</h3>
<span class="changelog-badge" style="background: linear-gradient(135deg, #f59e0b, #d97706); animation: pulseGlowAmber 2s ease-in-out infinite;">Coming soon</span>
</div>
<span class="changelog-date">${formatDate(entry.date, true)}</span>
</div>
<ul class="changelog-changes">
${changes}
</ul>
</div>`;
} }
function generateLatestEntry(entry: ChangelogEntry): string { function generateLatestEntry(entry: ChangelogEntry): string {
const changes = entry.changes.map(generateChangeItem).join('\n'); const changes = sortChanges(entry.changes).map(generateChangeItem).join('\n');
const version = entry.version.startsWith('v') ? entry.version : `v${entry.version}`; const version = entry.version.startsWith('v') ? entry.version : `v${entry.version}`;
return ` <!-- ${version} --> return ` <!-- ${version} -->
<div class="changelog-entry"> <div class="changelog-entry">
<div class="changelog-header"> <div class="changelog-header">
<div class="changelog-version"> <div class="changelog-version">
<h3>${version}</h3> <h3>${versionLink(version)}</h3>
<span class="changelog-badge">Latest</span> <span class="changelog-badge">Latest</span>
</div> </div>
<span class="changelog-date">${formatDate(entry.date)}</span> <span class="changelog-date">${formatDate(entry.date)}</span>
@@ -71,14 +185,14 @@ ${changes}
} }
function generateCollapsibleEntry(entry: ChangelogEntry): string { function generateCollapsibleEntry(entry: ChangelogEntry): string {
const changes = entry.changes.map(generateChangeItem).join('\n'); const changes = sortChanges(entry.changes).map(generateChangeItem).join('\n');
const version = entry.version.startsWith('v') ? entry.version : `v${entry.version}`; const version = entry.version.startsWith('v') ? entry.version : `v${entry.version}`;
return ` <!-- ${version} (collapsible) --> return ` <!-- ${version} (collapsible) -->
<div class="changelog-entry collapsible" data-version="${version}"> <div class="changelog-entry collapsible" data-version="${version}">
<div class="changelog-header"> <div class="changelog-header">
<div class="changelog-version"> <div class="changelog-version">
<h3>${version}</h3> <h3>${versionLink(version)}</h3>
<span class="changelog-toggle">${TOGGLE_SVG}</span> <span class="changelog-toggle">${TOGGLE_SVG}</span>
</div> </div>
<span class="changelog-date">${formatDate(entry.date)}</span> <span class="changelog-date">${formatDate(entry.date)}</span>
@@ -96,69 +210,191 @@ ${changes}
</div>`; </div>`;
} }
function generateRecentEntry(entry: ChangelogEntry): string {
const changes = sortChanges(entry.changes).map(generateChangeItem).join('\n');
const version = entry.version.startsWith('v') ? entry.version : `v${entry.version}`;
return ` <!-- ${version} -->
<div class="changelog-entry">
<div class="changelog-header">
<div class="changelog-version">
<h3>${versionLink(version)}</h3>
</div>
<span class="changelog-date">${formatDate(entry.date)}</span>
</div>
<ul class="changelog-changes">
${changes}
</ul>
<div class="changelog-image-tag">
<span>Docker image:</span>
<code>${entry.imageTag}</code>
<button class="copy-btn" onclick="copyDockerImage(this, '${entry.imageTag}')" title="Copy to clipboard">${COPY_SVG}</button>
</div>
</div>`;
}
const ARCHIVE_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21 8-2 2-1.5-3.7A2 2 0 0 0 15.64 5H8.36a2 2 0 0 0-1.86 1.3L5 10l-2-2"/><path d="M3.1 11.2A2 2 0 0 0 3 12v6c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2v-6a2 2 0 0 0-.1-.8"/><path d="M10 14h4"/></svg>`;
function generateChangelogSection(entries: ChangelogEntry[]): string { function generateChangelogSection(entries: ChangelogEntry[]): string {
if (entries.length === 0) { if (entries.length === 0) {
return ''; return '';
} }
const [latest, ...rest] = entries; // Separate coming soon entries from released entries
const latestHtml = generateLatestEntry(latest); const comingSoonEntries = entries.filter(e => e.comingSoon);
const restHtml = rest.map(generateCollapsibleEntry).join('\n'); const releasedEntries = entries.filter(e => !e.comingSoon);
// Latest 3 released versions shown expanded
const RECENT_COUNT = 3;
const latestReleased = releasedEntries[0];
const recentEntries = releasedEntries.slice(1, RECENT_COUNT);
const olderEntries = releasedEntries.slice(RECENT_COUNT);
// Generate HTML for each section
const comingSoonHtml = comingSoonEntries.map(generateComingSoonEntry).join('\n');
const latestHtml = latestReleased ? generateLatestEntry(latestReleased) : '';
const recentHtml = recentEntries.map(generateRecentEntry).join('\n');
const olderHtml = olderEntries.map(generateCollapsibleEntry).join('\n');
// Build older releases wrapper if there are older entries
let olderSection = '';
if (olderEntries.length > 0) {
const firstOlder = olderEntries[0].version.startsWith('v') ? olderEntries[0].version : `v${olderEntries[0].version}`;
const lastOlder = olderEntries[olderEntries.length - 1].version.startsWith('v') ? olderEntries[olderEntries.length - 1].version : `v${olderEntries[olderEntries.length - 1].version}`;
olderSection = `
<!-- Older Releases -->
<div class="older-releases-wrapper" id="older-releases">
<div class="older-releases-header" onclick="toggleOlderReleases()">
<h4>${ARCHIVE_SVG}Older releases (${firstOlder} – ${lastOlder})</h4>
<svg class="older-releases-toggle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="older-releases-content">
${olderHtml}
</div>
</div>`;
}
return ` <!-- Changelog Section --> return ` <!-- Changelog Section -->
<section class="changelog" id="changelog"> <section class="changelog" id="changelog">
<div class="changelog-container"> <div class="changelog-container">
<div class="section-header"> <div class="section-header">
<div class="section-label">Changelog</div> <div class="section-label">Changelog</div>
<h2 class="section-title">Release history</h2> <div class="changelog-title-row">
<h2 class="section-title">Release history</h2>
<a class="changelog-rss-link" href="/changelog.xml" title="Subscribe to release notes (RSS)">${RSS_SVG}<span>RSS</span></a>
</div>
<p class="section-subtitle">Track our progress and see what's new in each version. <span style="color: #fbbf24; white-space: nowrap;">Spoiler: it gets better every time.</span></p> <p class="section-subtitle">Track our progress and see what's new in each version. <span style="color: #fbbf24; white-space: nowrap;">Spoiler: it gets better every time.</span></p>
</div> </div>
<div class="changelog-list"> <div class="changelog-list">
${comingSoonHtml}
${latestHtml} ${latestHtml}
${restHtml} ${recentHtml}${olderSection}
</div> </div>
</div> </div>
</section>`; </section>`;
} }
// Read changelog.json // Strip ref tokens (#N, PR#N, @user) from changelog text for the plain-text RSS feed.
console.log('Reading changelog from:', CHANGELOG_PATH); // A trailing "(#1313, PR#1339, @user)" cluster is removed whole; bare refs mid-text too.
const changelog: ChangelogEntry[] = JSON.parse(readFileSync(CHANGELOG_PATH, 'utf-8')); export function stripRefs(text: string): string {
console.log(`Found ${changelog.length} changelog entries`); const ref = '(?:PR)?#\\d+|@[\\w-]+';
return text
// Read index.html // Whole parenthesised ref cluster: "(#1, PR#2, @u)" -> ""
console.log('Reading index.html from:', INDEX_PATH); .replace(new RegExp(`\\s*\\((?:\\s*(?:${ref})\\s*[,\\s]*)+\\)`, 'g'), '')
let indexHtml = readFileSync(INDEX_PATH, 'utf-8'); // Any leftover bare refs
.replace(new RegExp(ref, 'g'), '')
// Generate new changelog section // Tidy ONLY separators orphaned by a removed ref (e.g. "a , , b" -> "a b"),
const newChangelogSection = generateChangelogSection(changelog); // not legitimate prose punctuation like "(./config, ./data)".
.replace(/\s+,(\s|$)/g, '$1')
// Replace changelog section using regex .replace(/\s{2,}/g, ' ')
// Match from "<!-- Changelog Section -->" to the closing "</section>" before "<!-- CTA -->" .replace(/[,;]\s*$/g, '')
const changelogRegex = / <!-- Changelog Section -->[\s\S]*?<\/section>(?=\s*\n\s*<!-- CTA -->)/; .trim();
if (!changelogRegex.test(indexHtml)) {
console.error('ERROR: Could not find changelog section in index.html');
console.error('Looking for pattern: <!-- Changelog Section --> ... </section> followed by <!-- CTA -->');
process.exit(1);
} }
indexHtml = indexHtml.replace(changelogRegex, newChangelogSection); // Build an RSS 2.0 feed from the released changelog entries (coming-soon excluded).
function generateRssFeed(entries: ChangelogEntry[]): string {
const released = entries.filter((e) => !e.comingSoon && e.date);
const items = released
.map((e) => {
const ver = e.version.startsWith('v') ? e.version : `v${e.version}`;
const pubDate = new Date(`${e.date}T00:00:00Z`).toUTCString();
const body = e.changes
.map((c) => `${c.type === 'feature' ? 'Feature' : 'Fix'}: ${stripRefs(c.text)}`)
.join('\n');
return ` <item>
<title>Dockhand ${escapeHtml(ver)}</title>
<link>${SITE_URL}/#changelog</link>
<guid isPermaLink="false">dockhand-${escapeHtml(ver)}</guid>
<pubDate>${pubDate}</pubDate>
<description>${escapeHtml(body)}</description>
</item>`;
})
.join('\n');
const lastBuild = released[0]?.date
? new Date(`${released[0].date}T00:00:00Z`).toUTCString()
: new Date().toUTCString();
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Dockhand - Release notes</title>
<link>${SITE_URL}/#changelog</link>
<atom:link href="${SITE_URL}/changelog.xml" rel="self" type="application/rss+xml"/>
<description>New features and fixes in each Dockhand release.</description>
<language>en</language>
<lastBuildDate>${lastBuild}</lastBuildDate>
${items}
</channel>
</rss>
`;
}
// Also update softwareVersion in JSON-LD schema // Guarded so the pure functions above (e.g. stripRefs) can be imported by unit tests
if (changelog.length > 0) { // without triggering file reads/writes.
const latestVersion = changelog[0].version; if (import.meta.main) {
// Match "softwareVersion": "X.X" or "softwareVersion": "X.X.X" // Read changelog.json
const versionRegex = /"softwareVersion":\s*"[\d.]+"/; console.log('Reading changelog from:', CHANGELOG_PATH);
if (versionRegex.test(indexHtml)) { const changelog: ChangelogEntry[] = JSON.parse(readFileSync(CHANGELOG_PATH, 'utf-8'));
indexHtml = indexHtml.replace(versionRegex, `"softwareVersion": "${latestVersion}"`); console.log(`Found ${changelog.length} changelog entries`);
console.log(`Updated softwareVersion to: ${latestVersion}`);
// Read index.html
console.log('Reading index.html from:', INDEX_PATH);
let indexHtml = readFileSync(INDEX_PATH, 'utf-8');
// Generate new changelog section
const newChangelogSection = generateChangelogSection(changelog);
// Replace changelog section using regex
// Match from "<!-- Changelog Section -->" to the closing "</section>" before "<!-- CTA -->"
const changelogRegex = / <!-- Changelog Section -->[\s\S]*?<\/section>(?=\s*\n\s*<!-- CTA -->)/;
if (!changelogRegex.test(indexHtml)) {
console.error('ERROR: Could not find changelog section in index.html');
console.error('Looking for pattern: <!-- Changelog Section --> ... </section> followed by <!-- CTA -->');
process.exit(1);
} }
}
// Write back to index.html indexHtml = indexHtml.replace(changelogRegex, newChangelogSection);
writeFileSync(INDEX_PATH, indexHtml);
console.log(''); // Also update softwareVersion in JSON-LD schema
console.log('Generated changelog in webpage/index.html'); if (changelog.length > 0) {
console.log(` - Latest version: v${changelog[0]?.version || 'unknown'}`); const latestVersion = changelog[0].version;
console.log(` - Total entries: ${changelog.length}`); // Match "softwareVersion": "X.X" or "softwareVersion": "X.X.X"
const versionRegex = /"softwareVersion":\s*"[\d.]+"/;
if (versionRegex.test(indexHtml)) {
indexHtml = indexHtml.replace(versionRegex, `"softwareVersion": "${latestVersion}"`);
console.log(`Updated softwareVersion to: ${latestVersion}`);
}
}
// Write back to index.html
writeFileSync(INDEX_PATH, indexHtml);
// Generate the RSS feed alongside the page
writeFileSync(RSS_PATH, generateRssFeed(changelog));
console.log('Generated RSS feed in webpage/changelog.xml');
console.log('');
console.log('Generated changelog in webpage/index.html');
console.log(` - Latest version: v${changelog[0]?.version || 'unknown'}`);
console.log(` - Total entries: ${changelog.length}`);
}