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 `
  • ${svg}${label}${change.text}
  • `; + 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 = 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}`; return `
    -

    ${version}

    +

    ${versionLink(version)}

    Latest
    ${formatDate(entry.date)} @@ -71,14 +185,14 @@ ${changes} } 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}`; return `
    -

    ${version}

    +

    ${versionLink(version)}

    ${TOGGLE_SVG}
    ${formatDate(entry.date)} @@ -96,69 +210,191 @@ ${changes}
    `; } +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)} +
    +
      +${changes} +
    +
    + Docker image: + ${entry.imageTag} + +
    +
    `; +} + +const ARCHIVE_SVG = ``; + function generateChangelogSection(entries: ChangelogEntry[]): string { if (entries.length === 0) { return ''; } - const [latest, ...rest] = entries; - const latestHtml = generateLatestEntry(latest); - const restHtml = rest.map(generateCollapsibleEntry).join('\n'); + // 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

    +
    +

    Release history

    + ${RSS_SVG}RSS +

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

    +${comingSoonHtml} ${latestHtml} -${restHtml} +${recentHtml}${olderSection}
    `; } -// 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); +// 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(); } -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 ` + 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} + + +`; +} -// 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}`); +// 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); } -} -// Write back to index.html -writeFileSync(INDEX_PATH, indexHtml); -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}`); + 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}`); +}