#!/usr/bin/env bun
/**
* Generate changelog section in webpage/index.html from src/lib/data/changelog.json
* This ensures a single source of truth for release information
*/
import { readFileSync, writeFileSync } from 'fs';
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
const FEATURE_SVG = ``;
const FIX_SVG = ``;
const TOGGLE_SVG = ``;
// Lucide rss icon for the "subscribe to release notes" link.
const RSS_SVG = ``;
const COPY_SVG = ``;
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',
day: 'numeric'
});
}
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 `
${svg}${label}${linkifyIssues(change.text)}`;
}
function versionLink(version: string, comingSoon?: boolean): string {
if (comingSoon) return version;
return `${version} ${GITHUB_SVG}`;
}
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 `
`;
}
function generateLatestEntry(entry: ChangelogEntry): string {
const changes = sortChanges(entry.changes).map(generateChangeItem).join('\n');
const version = entry.version.startsWith('v') ? entry.version : `v${entry.version}`;
return `
Docker image:
${entry.imageTag}
or
fnsys/dockhand:latest
`;
}
function generateCollapsibleEntry(entry: ChangelogEntry): string {
const changes = sortChanges(entry.changes).map(generateChangeItem).join('\n');
const version = entry.version.startsWith('v') ? entry.version : `v${entry.version}`;
return `
Docker image:
${entry.imageTag}
`;
}
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 `
Docker image:
${entry.imageTag}
`;
}
const ARCHIVE_SVG = ``;
function generateChangelogSection(entries: ChangelogEntry[]): string {
if (entries.length === 0) {
return '';
}
// Separate coming soon entries from released entries
const comingSoonEntries = entries.filter(e => e.comingSoon);
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 = `
`;
}
return `
${comingSoonHtml}
${latestHtml}
${recentHtml}${olderSection}
`;
}
// Strip ref tokens (#N, PR#N, @user) from changelog text for the plain-text RSS feed.
// A trailing "(#1313, PR#1339, @user)" cluster is removed whole; bare refs mid-text too.
export function stripRefs(text: string): string {
const ref = '(?:PR)?#\\d+|@[\\w-]+';
return text
// Whole parenthesised ref cluster: "(#1, PR#2, @u)" -> ""
.replace(new RegExp(`\\s*\\((?:\\s*(?:${ref})\\s*[,\\s]*)+\\)`, 'g'), '')
// Any leftover bare refs
.replace(new RegExp(ref, 'g'), '')
// Tidy ONLY separators orphaned by a removed ref (e.g. "a , , b" -> "a b"),
// not legitimate prose punctuation like "(./config, ./data)".
.replace(/\s+,(\s|$)/g, '$1')
.replace(/\s{2,}/g, ' ')
.replace(/[,;]\s*$/g, '')
.trim();
}
// 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 ` -
Dockhand ${escapeHtml(ver)}
${SITE_URL}/#changelog
dockhand-${escapeHtml(ver)}
${pubDate}
${escapeHtml(body)}
`;
})
.join('\n');
const lastBuild = released[0]?.date
? new Date(`${released[0].date}T00:00:00Z`).toUTCString()
: new Date().toUTCString();
return `
Dockhand - Release notes
${SITE_URL}/#changelog
New features and fixes in each Dockhand release.
en
${lastBuild}
${items}
`;
}
// Guarded so the pure functions above (e.g. stripRefs) can be imported by unit tests
// without triggering file reads/writes.
if (import.meta.main) {
// Read changelog.json
console.log('Reading changelog from:', CHANGELOG_PATH);
const changelog: ChangelogEntry[] = JSON.parse(readFileSync(CHANGELOG_PATH, 'utf-8'));
console.log(`Found ${changelog.length} changelog entries`);
// 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 "" to the closing "" before ""
const changelogRegex = / [\s\S]*?<\/section>(?=\s*\n\s*)/;
if (!changelogRegex.test(indexHtml)) {
console.error('ERROR: Could not find changelog section in index.html');
console.error('Looking for pattern: ... followed by ');
process.exit(1);
}
indexHtml = indexHtml.replace(changelogRegex, newChangelogSection);
// Also update softwareVersion in JSON-LD schema
if (changelog.length > 0) {
const latestVersion = changelog[0].version;
// 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}`);
}