feat: load readable release notes dynamically

This commit is contained in:
lucaronin
2026-05-04 15:07:09 +02:00
parent 9973d8d4e6
commit ff4a78f964
6 changed files with 114 additions and 133 deletions

View File

@@ -727,14 +727,15 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p _site/alpha _site/stable
mkdir -p _site/alpha _site/stable _site/release-notes
if [ -d release-notes ]; then cp release-notes/*.md _site/release-notes/ 2>/dev/null || true; fi
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
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 --release-notes-dir release-notes --output-file _site/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

View File

@@ -778,14 +778,15 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p _site/alpha _site/stable
mkdir -p _site/alpha _site/stable _site/release-notes
if [ -d release-notes ]; then cp release-notes/*.md _site/release-notes/ 2>/dev/null || true; fi
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
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 --release-notes-dir release-notes --output-file _site/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

View File

@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { basename, dirname, extname, resolve } from 'node:path'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { buildReleaseHistoryPage } from '../src/utils/releaseHistoryPage'
@@ -14,12 +14,6 @@ function getArg(flag: string): string {
return value
}
function getOptionalArg(flag: string): string | null {
const index = process.argv.indexOf(flag)
const value = index >= 0 ? process.argv[index + 1] : null
return value || null
}
function readReleasePayload(filePath: string): unknown {
try {
return JSON.parse(readFileSync(filePath, 'utf8'))
@@ -28,28 +22,10 @@ function readReleasePayload(filePath: string): unknown {
}
}
function readReadableReleaseNotes(directoryPath: string | null): Record<string, string> {
if (directoryPath === null) return {}
const resolvedDirectory = resolve(directoryPath)
if (!existsSync(resolvedDirectory)) return {}
return Object.fromEntries(
readdirSync(resolvedDirectory)
.filter(fileName => extname(fileName) === '.md')
.map(fileName => {
const filePath = resolve(resolvedDirectory, fileName)
const tagName = basename(fileName, '.md')
return [tagName, readFileSync(filePath, 'utf8')]
}),
)
}
const releasesJsonPath = resolve(getArg('--releases-json'))
const outputFilePath = resolve(getArg('--output-file'))
const releasesPayload = readReleasePayload(releasesJsonPath)
const readableReleaseNotes = readReadableReleaseNotes(getOptionalArg('--release-notes-dir'))
const html = buildReleaseHistoryPage(releasesPayload, readableReleaseNotes)
const html = buildReleaseHistoryPage(releasesPayload)
mkdirSync(dirname(outputFilePath), { recursive: true })
writeFileSync(outputFilePath, html)

View File

@@ -50,7 +50,7 @@ describe('buildReleaseHistoryPage', () => {
expect(html).not.toContain('class="release-channel"')
})
it('renders optional readable notes for stable releases instead of the commit list', () => {
it('loads readable notes for stable releases at runtime and keeps commits as the fallback', () => {
const html = buildReleaseHistoryPage([
{
body: "## What's Changed\n\n- ee71a00 feat: add paste without formatting command",
@@ -66,15 +66,16 @@ describe('buildReleaseHistoryPage', () => {
published_at: '2026-05-02T16:15:00Z',
tag_name: 'stable-v2026.5.2',
},
], {
'stable-v2026.5.2': '## New Features\n\n- 📋 **Paste Without Formatting** — Paste copied text as plain content.',
})
])
expect(html).toContain('📋 <strong>Paste Without Formatting</strong>')
expect(html).toContain('data-readable-notes-url="release-notes/stable-v2026.5.2.md"')
expect(html).toContain("fetch(notesUrl, { cache: 'no-cache' })")
expect(html).toContain('ee71a00')
expect(html).toContain('feat: add paste without formatting command')
expect(html).not.toContain('data-release-detail-tab')
expect(html).not.toContain('Readable')
expect(html).not.toContain('Commits')
expect(html).not.toContain('ee71a00 feat: add paste without formatting command')
expect(html).not.toContain('>Readable<')
expect(html).not.toContain('>Commits<')
expect(html).not.toContain('📋 <strong>Paste Without Formatting</strong>')
})
it('falls back to escaped paragraph markup when rendered html is unavailable', () => {

View File

@@ -1,5 +1,3 @@
import { renderReadableReleaseNotesMarkdown } from './releaseReadableNotes'
type ReleaseAssetPayload = {
browser_download_url?: unknown
name?: unknown
@@ -30,13 +28,12 @@ type ReleaseEntry = {
notesHtml: string
publishedLabel: string
publishedTimestamp: number
readableNotesHtml: string | null
readableNotesUrl: string | null
tagName: string
title: string
}
type ReleaseSections = Record<ReleaseChannel, ReleaseEntry[]>
type ReadableReleaseNotesByTag = Record<string, string>
const RELEASE_HISTORY_PAGE_STYLES = `
:root {
@@ -379,7 +376,6 @@ 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');
@@ -427,6 +423,86 @@ const RELEASE_HISTORY_PAGE_SCRIPT = `
tab.addEventListener('keydown', (event) => handleTabKeydown(event, tab, index));
});
const escapeHtml = (value) => value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
const renderInlineMarkdown = (value) => escapeHtml(value)
.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>')
.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
const renderMarkdownLine = (rawLine) => {
const line = rawLine.trim();
if (!line) return { kind: 'blank' };
const heading = line.match(/^(#{1,3})\\s+(.+)$/);
if (heading) {
const level = heading[1].length;
return {
html: '<h' + level + '>' + renderInlineMarkdown(heading[2]) + '</h' + level + '>',
kind: 'heading',
};
}
const bullet = line.match(/^[-*]\\s+(.+)$/);
if (bullet) return { html: '<li>' + renderInlineMarkdown(bullet[1]) + '</li>', kind: 'bullet' };
return { html: '<p>' + renderInlineMarkdown(line) + '</p>', kind: 'paragraph' };
};
const closeList = (html, listOpen) => {
if (listOpen) html.push('</ul>');
return false;
};
const appendBulletLine = (html, line, listOpen) => {
if (!listOpen) html.push('<ul>');
html.push(line.html);
return true;
};
const appendBlockLine = (html, line, listOpen) => {
if (listOpen) html.push('</ul>');
html.push(line.html);
return false;
};
const appendRenderedLine = (html, line, listOpen) => {
if (line.kind === 'blank') return closeList(html, listOpen);
if (line.kind === 'bullet') return appendBulletLine(html, line, listOpen);
return appendBlockLine(html, line, listOpen);
};
const renderReadableMarkdown = (markdown) => {
const html = [];
let listOpen = false;
markdown.split('\\n').forEach((rawLine) => {
listOpen = appendRenderedLine(html, renderMarkdownLine(rawLine), listOpen);
});
if (listOpen) html.push('</ul>');
return html.join('');
};
const loadReadableNotes = async (container) => {
const notesUrl = container.getAttribute('data-readable-notes-url');
if (!notesUrl) return;
try {
const response = await fetch(notesUrl, { cache: 'no-cache' });
if (!response.ok) return;
const html = renderReadableMarkdown(await response.text()).trim();
if (html.length > 0) container.innerHTML = html;
} catch {
// Keep the generated commit list when a readable note cannot be loaded.
}
};
Array.from(document.querySelectorAll('[data-readable-notes-url]')).forEach((container) => {
void loadReadableNotes(container);
});
})();
`
@@ -533,6 +609,11 @@ function resolveReleaseNotesHtml(renderedHtml: unknown, markdownFallback: unknow
return buildFallbackReleaseNotesHtml(fallback)
}
function readableNotesUrlForRelease(channel: ReleaseChannel, tagName: string): string | null {
if (channel !== 'stable' || tagName === 'Unknown tag') return null
return `release-notes/${encodeURIComponent(tagName)}.md`
}
function normalizeReleaseEntry(release: GitHubReleasePayload): [ReleaseChannel, ReleaseEntry] | null {
if (release.draft === true) return null
@@ -546,35 +627,21 @@ function normalizeReleaseEntry(release: GitHubReleasePayload): [ReleaseChannel,
notesHtml: resolveReleaseNotesHtml(release.body_html, release.body),
publishedLabel: formatPublishedLabel(release.published_at),
publishedTimestamp: parsePublishedTimestamp(release.published_at),
readableNotesHtml: null,
readableNotesUrl: readableNotesUrlForRelease(channel, tagName),
tagName,
title,
}]
}
function applyReadableReleaseNotes(release: ReleaseEntry, readableReleaseNotesByTag: ReadableReleaseNotesByTag): ReleaseEntry {
const readableNotesMarkdown = normalizeText(readableReleaseNotesByTag[release.tagName])
if (readableNotesMarkdown === null) return release
return {
...release,
readableNotesHtml: renderReadableReleaseNotesMarkdown({ markdown: readableNotesMarkdown }),
}
}
function appendReleaseSection(
sections: ReleaseSections,
normalizedRelease: [ReleaseChannel, ReleaseEntry],
readableReleaseNotesByTag: ReadableReleaseNotesByTag,
): void {
const [channel, release] = normalizedRelease
const releaseEntry = channel === 'stable'
? applyReadableReleaseNotes(release, readableReleaseNotesByTag)
: release
sections[channel].push(releaseEntry)
sections[channel].push(release)
}
function collectReleaseSections(payload: unknown, readableReleaseNotesByTag: ReadableReleaseNotesByTag): ReleaseSections {
function collectReleaseSections(payload: unknown): ReleaseSections {
const sections: ReleaseSections = { alpha: [], stable: [] }
if (!Array.isArray(payload)) return sections
@@ -584,7 +651,7 @@ function collectReleaseSections(payload: unknown, readableReleaseNotesByTag: Rea
const normalizedRelease = normalizeReleaseEntry(item as GitHubReleasePayload)
if (normalizedRelease === null) continue
appendReleaseSection(sections, normalizedRelease, readableReleaseNotesByTag)
appendReleaseSection(sections, normalizedRelease)
}
for (const channel of ['stable', 'alpha'] as const) {
@@ -625,6 +692,9 @@ function buildReleaseMarkup(channel: ReleaseChannel, release: ReleaseEntry): str
}).join('')}
</div>`
: ''
const readableNotesAttributes = release.readableNotesUrl === null
? ''
: ` data-readable-notes-url="${escapeHtml(release.readableNotesUrl)}"`
return `
<article class="release-card release-card--${channel}">
@@ -635,7 +705,7 @@ function buildReleaseMarkup(channel: ReleaseChannel, release: ReleaseEntry): str
</div>
${githubMarkup}
</div>
<div class="release-notes">${release.readableNotesHtml ?? release.notesHtml}</div>${downloadsMarkup}
<div class="release-notes"${readableNotesAttributes}>${release.notesHtml}</div>${downloadsMarkup}
</article>`
}
@@ -657,11 +727,8 @@ function buildPanelMarkup(channel: ReleaseChannel, releases: ReleaseEntry[], sel
</section>`
}
export function buildReleaseHistoryPage(
releasesPayload: unknown,
readableReleaseNotesByTag: ReadableReleaseNotesByTag = {},
): string {
const sections = collectReleaseSections(releasesPayload, readableReleaseNotesByTag)
export function buildReleaseHistoryPage(releasesPayload: unknown): string {
const sections = collectReleaseSections(releasesPayload)
return `<!DOCTYPE html>
<html lang="en">

View File

@@ -1,65 +0,0 @@
type ReadableMarkdownSource = {
markdown: string
}
type ReadableMarkdownLine =
| { kind: 'blank' }
| { html: string, kind: 'bullet' | 'heading' | 'paragraph' }
function escapeHtml(source: ReadableMarkdownSource): string {
return source.markdown
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
function renderInlineMarkdown(source: ReadableMarkdownSource): string {
return escapeHtml(source)
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
}
function renderMarkdownLine(source: ReadableMarkdownSource): ReadableMarkdownLine {
const line = source.markdown.trim()
if (line.length === 0) return { kind: 'blank' }
const heading = line.match(/^(#{1,3})\s+(.+)$/)
if (heading) {
const level = heading[1].length
return { html: `<h${level}>${renderInlineMarkdown({ markdown: heading[2] })}</h${level}>`, kind: 'heading' }
}
const bullet = line.match(/^[-*]\s+(.+)$/)
if (bullet) return { html: `<li>${renderInlineMarkdown({ markdown: bullet[1] })}</li>`, kind: 'bullet' }
return { html: `<p>${renderInlineMarkdown({ markdown: line })}</p>`, kind: 'paragraph' }
}
function appendRenderedLine(html: string[], line: ReadableMarkdownLine, listOpen: boolean): boolean {
if (line.kind === 'blank') {
if (listOpen) html.push('</ul>')
return false
}
if (line.kind === 'bullet') {
if (!listOpen) html.push('<ul>')
html.push(line.html)
return true
}
if (listOpen) html.push('</ul>')
html.push(line.html)
return false
}
export function renderReadableReleaseNotesMarkdown(source: ReadableMarkdownSource): string {
const html: string[] = []
let listOpen = false
for (const rawLine of source.markdown.split('\n')) {
listOpen = appendRenderedLine(html, renderMarkdownLine({ markdown: rawLine }), listOpen)
}
if (listOpen) html.push('</ul>')
return html.join('')
}