From db21ae47083e4a5da5eafcb2642379dbfb5cb075 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 19 Apr 2026 20:37:53 +0200 Subject: [PATCH] feat: improve release history page --- .github/workflows/release-stable.yml | 48 +-- .github/workflows/release.yml | 48 +-- scripts/build-release-history-page.ts | 33 ++ src/utils/releaseHistoryPage.test.ts | 63 +++ src/utils/releaseHistoryPage.ts | 586 ++++++++++++++++++++++++++ 5 files changed, 684 insertions(+), 94 deletions(-) create mode 100644 scripts/build-release-history-page.ts create mode 100644 src/utils/releaseHistoryPage.test.ts create mode 100644 src/utils/releaseHistoryPage.ts diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 231e59b2..0157cda3 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -271,59 +271,13 @@ jobs: curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html + bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html mkdir -p _site/download cp _site/stable/download/index.html _site/download/index.html cp _site/alpha/latest.json _site/latest.json cp _site/alpha/latest.json _site/latest-canary.json - cat > _site/index.html << 'HTMLEOF' - - - - - - Tolaria — Release History - - - -

Tolaria Release History

-

Alpha builds update on every push to main. Stable builds appear when a stable-vYYYY.M.D tag is promoted.

-
- - - - HTMLEOF - - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d50d8763..9fe39057 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -307,59 +307,13 @@ jobs: gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json curl -fsSL "${PAGES_URL}/stable/latest.json" -o _site/stable/latest.json || echo '{}' > _site/stable/latest.json bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html + bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html mkdir -p _site/download cp _site/stable/download/index.html _site/download/index.html cp _site/alpha/latest.json _site/latest.json cp _site/alpha/latest.json _site/latest-canary.json - cat > _site/index.html << 'HTMLEOF' - - - - - - Tolaria — Release History - - - -

Tolaria Release History

-

Alpha builds update on every push to main. Stable builds appear when a stable-vYYYY.M.D tag is promoted.

-
- - - - HTMLEOF - - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 with: diff --git a/scripts/build-release-history-page.ts b/scripts/build-release-history-page.ts new file mode 100644 index 00000000..b014de4e --- /dev/null +++ b/scripts/build-release-history-page.ts @@ -0,0 +1,33 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +import { buildReleaseHistoryPage } from '../src/utils/releaseHistoryPage' + +function getArg(flag: string): string { + const index = process.argv.indexOf(flag) + const value = index >= 0 ? process.argv[index + 1] : null + + if (!value) { + throw new Error(`Missing required argument: ${flag}`) + } + + return value +} + +function readReleasePayload(filePath: string): unknown { + try { + return JSON.parse(readFileSync(filePath, 'utf8')) + } catch { + return [] + } +} + +const releasesJsonPath = resolve(getArg('--releases-json')) +const outputFilePath = resolve(getArg('--output-file')) +const releasesPayload = readReleasePayload(releasesJsonPath) +const html = buildReleaseHistoryPage(releasesPayload) + +mkdirSync(dirname(outputFilePath), { recursive: true }) +writeFileSync(outputFilePath, html) + +console.log(`Release history page written to ${outputFilePath}`) diff --git a/src/utils/releaseHistoryPage.test.ts b/src/utils/releaseHistoryPage.test.ts new file mode 100644 index 00000000..b876bbf6 --- /dev/null +++ b/src/utils/releaseHistoryPage.test.ts @@ -0,0 +1,63 @@ +import { buildReleaseHistoryPage } from './releaseHistoryPage' + +describe('buildReleaseHistoryPage', () => { + it('renders markdown notes into separate stable and alpha panels with stable selected by default', () => { + const html = buildReleaseHistoryPage([ + { + assets: [ + { + browser_download_url: 'https://example.com/Tolaria.dmg', + name: 'Tolaria.dmg', + }, + ], + body: '## Highlights\n\n- Faster startup\n- Better release notes', + html_url: 'https://github.com/refactoringhq/tolaria/releases/tag/stable-v2026.4.19', + name: 'Tolaria Stable 2026.4.19', + prerelease: false, + published_at: '2026-04-19T11:00:00Z', + tag_name: 'stable-v2026.4.19', + }, + { + assets: [ + { + browser_download_url: 'https://example.com/Tolaria.app.tar.gz', + name: 'Tolaria.app.tar.gz', + }, + ], + body: '**Alpha** notes with [details](https://example.com/details).', + html_url: 'https://github.com/refactoringhq/tolaria/releases/tag/2026.4.19-alpha.1', + name: 'Alpha 2026.4.19.1', + prerelease: true, + published_at: '2026-04-19T10:00:00Z', + tag_name: '2026.4.19-alpha.1', + }, + ]) + + expect(html).toContain('role="tablist"') + expect(html).toContain('id="tab-stable"') + expect(html).toContain('aria-selected="true"') + expect(html).toContain('data-release-panel="alpha" hidden') + expect(html).toContain('

Highlights

') + expect(html).toContain('
  • Faster startup
  • ') + expect(html).toContain('Alpha notes') + expect(html).toContain('Tolaria.app.tar.gz') + expect(html).toContain('View on GitHub') + }) + + it('filters draft releases and shows an empty state for channels without published builds', () => { + const html = buildReleaseHistoryPage([ + { + body: 'Draft release', + draft: true, + name: 'Draft release', + prerelease: false, + published_at: '2026-04-19T11:00:00Z', + tag_name: 'stable-v2026.4.19', + }, + ]) + + expect(html).not.toContain('Draft release') + expect(html).toContain('No stable releases published yet.') + expect(html).toContain('No alpha releases published yet.') + }) +}) diff --git a/src/utils/releaseHistoryPage.ts b/src/utils/releaseHistoryPage.ts new file mode 100644 index 00000000..0498be43 --- /dev/null +++ b/src/utils/releaseHistoryPage.ts @@ -0,0 +1,586 @@ +import { createElement } from 'react' +import Markdown from 'react-markdown' +import { renderToStaticMarkup } from 'react-dom/server' +import remarkGfm from 'remark-gfm' + +type ReleaseAssetPayload = { + browser_download_url?: unknown + name?: unknown +} + +type GitHubReleasePayload = { + assets?: ReleaseAssetPayload[] + body?: unknown + draft?: unknown + html_url?: unknown + name?: unknown + prerelease?: unknown + published_at?: unknown + tag_name?: unknown +} + +type ReleaseChannel = 'stable' | 'alpha' + +type ReleaseDownload = { + label: string + url: string +} + +type ReleaseEntry = { + downloads: ReleaseDownload[] + githubUrl: string | null + notesHtml: string + publishedLabel: string + tagName: string + title: string +} + +type ReleaseSections = Record + +const RELEASE_HISTORY_PAGE_STYLES = ` + :root { + color-scheme: light; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + } + + * { + box-sizing: border-box; + } + + body { + margin: 0; + min-height: 100vh; + background: #f7f6f3; + color: #37352f; + padding: 32px 20px 48px; + } + + main { + width: min(100%, 840px); + margin: 0 auto; + } + + header { + margin-bottom: 24px; + } + + h1 { + margin: 0 0 8px; + font-size: clamp(2rem, 4vw, 2.5rem); + line-height: 1.1; + } + + .subtitle, + .keyboard-hint { + margin: 0; + color: #787774; + line-height: 1.6; + } + + .keyboard-hint { + margin-top: 8px; + font-size: 0.9375rem; + } + + .channel-tabs { + margin-bottom: 24px; + border-bottom: 1px solid #e9e9e7; + } + + .channel-tablist { + display: inline-flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: -1px; + } + + .channel-tab { + appearance: none; + border: 1px solid transparent; + border-bottom: none; + border-radius: 12px 12px 0 0; + background: transparent; + color: #5e5c57; + cursor: pointer; + font: inherit; + font-weight: 600; + padding: 12px 16px; + } + + .channel-tab:hover { + background: rgba(21, 93, 255, 0.06); + color: #155dff; + } + + .channel-tab:focus-visible { + outline: 2px solid #155dff; + outline-offset: 2px; + } + + .channel-tab[aria-selected="true"] { + background: #ffffff; + border-color: #d8e3ff; + color: #155dff; + box-shadow: 0 -1px 0 #ffffff inset; + } + + .tab-count { + color: #787774; + font-weight: 500; + margin-left: 6px; + } + + .release-panel { + display: grid; + gap: 16px; + outline: none; + } + + .release-panel[hidden] { + display: none; + } + + .release-card { + background: #ffffff; + border: 1px solid #e9e9e7; + border-radius: 18px; + padding: 20px 22px; + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.05); + } + + .release-card--alpha { + border-left: 4px solid #f59e0b; + } + + .release-card--stable { + border-left: 4px solid #155dff; + } + + .release-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; + } + + .release-header h2 { + margin: 0; + font-size: 1.25rem; + line-height: 1.25; + } + + .release-meta { + margin: 4px 0 0; + color: #787774; + font-size: 0.9375rem; + } + + .release-channel { + align-self: start; + background: #f1f5ff; + border-radius: 999px; + color: #155dff; + font-size: 0.8125rem; + font-weight: 700; + letter-spacing: 0.02em; + padding: 6px 10px; + text-transform: uppercase; + } + + .release-card--alpha .release-channel { + background: #fff3d6; + color: #b45309; + } + + .release-notes { + color: #44403c; + font-size: 0.98rem; + line-height: 1.7; + } + + .release-notes > :first-child { + margin-top: 0; + } + + .release-notes > :last-child { + margin-bottom: 0; + } + + .release-notes h1, + .release-notes h2, + .release-notes h3 { + line-height: 1.25; + margin: 1.2em 0 0.4em; + } + + .release-notes p, + .release-notes ul, + .release-notes ol, + .release-notes blockquote, + .release-notes pre { + margin: 0 0 1em; + } + + .release-notes ul, + .release-notes ol { + padding-left: 1.4rem; + } + + .release-notes code { + background: #f4f4f2; + border-radius: 6px; + padding: 0.12em 0.35em; + } + + .release-notes pre { + overflow-x: auto; + background: #f4f4f2; + border-radius: 12px; + padding: 14px; + } + + .release-notes pre code { + background: transparent; + padding: 0; + } + + .release-notes blockquote { + border-left: 3px solid #d6d3d1; + color: #57534e; + padding-left: 14px; + } + + .release-notes a, + .release-downloads a { + color: #155dff; + text-decoration-thickness: 0.08em; + text-underline-offset: 0.18em; + } + + .release-downloads { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 18px; + } + + .release-downloads a { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 0 14px; + border-radius: 10px; + background: #155dff; + color: #ffffff; + font-weight: 600; + text-decoration: none; + } + + .release-downloads a[data-secondary="true"] { + background: #ebeef5; + color: #37352f; + } + + .release-downloads a:hover, + .release-downloads a:focus-visible { + filter: brightness(0.96); + } + + .release-downloads a:focus-visible { + outline: 2px solid #155dff; + outline-offset: 2px; + } + + .empty-state { + background: #ffffff; + border: 1px dashed #d6d3d1; + border-radius: 18px; + color: #787774; + padding: 28px 24px; + text-align: center; + } + + @media (max-width: 640px) { + body { + padding-inline: 16px; + } + + .release-card { + padding: 18px; + } + + .channel-tablist { + width: 100%; + } + + .channel-tab { + flex: 1 1 180px; + text-align: center; + } + } +` + +const RELEASE_HISTORY_PAGE_SCRIPT = ` + (() => { + const tabs = Array.from(document.querySelectorAll('[data-release-tab]')); + const panels = Array.from(document.querySelectorAll('[data-release-panel]')); + if (!tabs.length || !panels.length) return; + + const activateTab = (nextTab) => { + const nextChannel = nextTab.getAttribute('data-release-tab'); + tabs.forEach((tab) => { + const selected = tab === nextTab; + tab.setAttribute('aria-selected', String(selected)); + tab.tabIndex = selected ? 0 : -1; + }); + panels.forEach((panel) => { + panel.hidden = panel.getAttribute('data-release-panel') !== nextChannel; + }); + }; + + const moveFocus = (currentIndex, offset) => { + const nextIndex = (currentIndex + offset + tabs.length) % tabs.length; + tabs[nextIndex].focus(); + activateTab(tabs[nextIndex]); + }; + + const activateFocusedTab = (tab) => { + tab.focus(); + activateTab(tab); + }; + + const handleTabKeydown = (event, tab, index) => { + const keyActions = { + ' ': () => activateTab(tab), + ArrowDown: () => moveFocus(index, 1), + ArrowLeft: () => moveFocus(index, -1), + ArrowRight: () => moveFocus(index, 1), + ArrowUp: () => moveFocus(index, -1), + End: () => activateFocusedTab(tabs[tabs.length - 1]), + Enter: () => activateTab(tab), + Home: () => activateFocusedTab(tabs[0]), + }; + const action = keyActions[event.key]; + if (!action) return; + + event.preventDefault(); + action(); + }; + + tabs.forEach((tab, index) => { + tab.addEventListener('click', () => activateTab(tab)); + tab.addEventListener('keydown', (event) => handleTabKeydown(event, tab, index)); + }); + })(); +` + +const MARKDOWN_REMARK_PLUGINS = [remarkGfm] +const RELEASE_CHANNEL_LABELS: Record = { + alpha: 'Alpha', + stable: 'Stable', +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') +} + +function normalizeText(value: unknown): string | null { + if (typeof value !== 'string') return null + const trimmedValue = value.trim() + return trimmedValue.length > 0 ? trimmedValue : null +} + +function normalizeUrl(value: unknown): string | null { + const text = normalizeText(value) + if (text === null) return null + + try { + const parsedUrl = new URL(text) + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') return null + return parsedUrl.toString() + } catch { + return null + } +} + +function formatPublishedLabel(value: unknown): string { + const text = normalizeText(value) + if (text === null) return 'Unknown publish date' + + const publishedAt = new Date(text) + if (Number.isNaN(publishedAt.getTime())) return 'Unknown publish date' + + return publishedAt.toLocaleDateString('en-US', { + day: 'numeric', + month: 'long', + timeZone: 'UTC', + year: 'numeric', + }) +} + +function isDownloadableAsset(name: string): boolean { + return name.endsWith('.dmg') || name.endsWith('.app.tar.gz') || name.endsWith('.zip') +} + +function normalizeDownloads(assets: ReleaseAssetPayload[] | undefined): ReleaseDownload[] { + if (!Array.isArray(assets)) return [] + + const seenUrls = new Set() + const downloads: ReleaseDownload[] = [] + + for (const asset of assets) { + const name = normalizeText(asset.name) + const url = normalizeUrl(asset.browser_download_url) + if (name === null || url === null || !isDownloadableAsset(name) || seenUrls.has(url)) continue + + seenUrls.add(url) + downloads.push({ label: name, url }) + } + + return downloads +} + +function renderReleaseNotes(markdown: string): string { + return renderToStaticMarkup(createElement(Markdown, { remarkPlugins: MARKDOWN_REMARK_PLUGINS }, markdown)) +} + +function normalizeReleaseEntry(release: GitHubReleasePayload): [ReleaseChannel, ReleaseEntry] | null { + if (release.draft === true) return null + + const title = normalizeText(release.name) ?? normalizeText(release.tag_name) ?? 'Untitled release' + const tagName = normalizeText(release.tag_name) ?? 'Unknown tag' + const notes = normalizeText(release.body) ?? 'No release notes provided.' + const channel: ReleaseChannel = release.prerelease === true ? 'alpha' : 'stable' + + return [channel, { + downloads: normalizeDownloads(release.assets), + githubUrl: normalizeUrl(release.html_url), + notesHtml: renderReleaseNotes(notes), + publishedLabel: formatPublishedLabel(release.published_at), + tagName, + title, + }] +} + +function collectReleaseSections(payload: unknown): ReleaseSections { + const sections: ReleaseSections = { alpha: [], stable: [] } + if (!Array.isArray(payload)) return sections + + for (const item of payload) { + if (!item || typeof item !== 'object') continue + + const normalizedRelease = normalizeReleaseEntry(item as GitHubReleasePayload) + if (normalizedRelease === null) continue + + const [channel, release] = normalizedRelease + sections[channel].push(release) + } + + return sections +} + +function buildTabMarkup(channel: ReleaseChannel, count: number, selected: boolean): string { + const label = RELEASE_CHANNEL_LABELS[channel] + return ` + ` +} + +function buildReleaseMarkup(channel: ReleaseChannel, release: ReleaseEntry): string { + const downloads = [...release.downloads] + if (release.githubUrl !== null) { + downloads.push({ label: 'View on GitHub', url: release.githubUrl }) + } + + const channelLabel = RELEASE_CHANNEL_LABELS[channel] + const downloadsMarkup = downloads.length > 0 + ? ` +
    + ${downloads.map(download => { + const isSecondary = download.label === 'View on GitHub' + return `${escapeHtml(download.label)}` + }).join('')} +
    ` + : '' + + return ` +
    +
    +
    +

    ${escapeHtml(release.title)}

    +

    ${escapeHtml(release.publishedLabel)} · ${escapeHtml(release.tagName)}

    +
    + ${channelLabel} +
    +
    ${release.notesHtml}
    ${downloadsMarkup} +
    ` +} + +function buildPanelMarkup(channel: ReleaseChannel, releases: ReleaseEntry[], selected: boolean): string { + const releasesMarkup = releases.length > 0 + ? releases.map(release => buildReleaseMarkup(channel, release)).join('') + : `
    No ${channel} releases published yet.
    ` + + return ` +
    + ${releasesMarkup} +
    ` +} + +export function buildReleaseHistoryPage(releasesPayload: unknown): string { + const sections = collectReleaseSections(releasesPayload) + + return ` + + + + + Tolaria — Release History + + + +
    +
    +

    Tolaria Release History

    +

    Stable builds appear when a stable-vYYYY.M.D tag is promoted. Alpha builds update on every push to main.

    +

    Use Tab to reach the channel picker, then use the arrow keys to switch between Stable and Alpha.

    +
    +
    +
    + ${buildTabMarkup('stable', sections.stable.length, true)} + ${buildTabMarkup('alpha', sections.alpha.length, false)} +
    +
    + ${buildPanelMarkup('stable', sections.stable, true)} + ${buildPanelMarkup('alpha', sections.alpha, false)} +
    + + + +` +}