#!/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 `

    ${versionLink(version, true)}

    Coming soon
    ${formatDate(entry.date, true)}
    `; } 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 `

    ${versionLink(version)}

    Latest
    ${formatDate(entry.date)}
    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 `

    ${versionLink(version)}

    ${TOGGLE_SVG}
    ${formatDate(entry.date)}
    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 `

    ${versionLink(version)}

    ${formatDate(entry.date)}
    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 = `

    ${ARCHIVE_SVG}Older releases (${firstOlder} – ${lastOlder})

    ${olderHtml}
    `; } return `

    Release history

    ${RSS_SVG}RSS

    Track our progress and see what's new in each version. Spoiler: it gets better every time.

    ${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}`); }