diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index e80f78d8..9980e0f2 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed4d32f0..b1abf9cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/scripts/build-release-history-page.ts b/scripts/build-release-history-page.ts index 8d08755e..b014de4e 100644 --- a/scripts/build-release-history-page.ts +++ b/scripts/build-release-history-page.ts @@ -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 { - 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) diff --git a/src/utils/releaseHistoryPage.test.ts b/src/utils/releaseHistoryPage.test.ts index 7af44907..da801baa 100644 --- a/src/utils/releaseHistoryPage.test.ts +++ b/src/utils/releaseHistoryPage.test.ts @@ -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('📋 Paste Without Formatting') + 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('📋 Paste Without Formatting') }) it('falls back to escaped paragraph markup when rendered html is unavailable', () => { diff --git a/src/utils/releaseHistoryPage.ts b/src/utils/releaseHistoryPage.ts index 7926f6d3..4fe4bfb0 100644 --- a/src/utils/releaseHistoryPage.ts +++ b/src/utils/releaseHistoryPage.ts @@ -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 -type ReadableReleaseNotesByTag = Record 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('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + + const renderInlineMarkdown = (value) => escapeHtml(value) + .replace(/\\*\\*([^*]+)\\*\\*/g, '$1') + .replace(/\`([^\`]+)\`/g, '$1'); + + 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: '' + renderInlineMarkdown(heading[2]) + '', + kind: 'heading', + }; + } + + const bullet = line.match(/^[-*]\\s+(.+)$/); + if (bullet) return { html: '
  • ' + renderInlineMarkdown(bullet[1]) + '
  • ', kind: 'bullet' }; + + return { html: '

    ' + renderInlineMarkdown(line) + '

    ', kind: 'paragraph' }; + }; + + const closeList = (html, listOpen) => { + if (listOpen) html.push(''); + return false; + }; + + const appendBulletLine = (html, line, listOpen) => { + if (!listOpen) html.push('
      '); + html.push(line.html); + return true; + }; + + const appendBlockLine = (html, line, listOpen) => { + if (listOpen) html.push('
    '); + 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(''); + 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('')} ` : '' + const readableNotesAttributes = release.readableNotesUrl === null + ? '' + : ` data-readable-notes-url="${escapeHtml(release.readableNotesUrl)}"` return `
    @@ -635,7 +705,7 @@ function buildReleaseMarkup(channel: ReleaseChannel, release: ReleaseEntry): str ${githubMarkup} -
    ${release.readableNotesHtml ?? release.notesHtml}
    ${downloadsMarkup} +
    ${release.notesHtml}
    ${downloadsMarkup}
    ` } @@ -657,11 +727,8 @@ function buildPanelMarkup(channel: ReleaseChannel, releases: ReleaseEntry[], sel ` } -export function buildReleaseHistoryPage( - releasesPayload: unknown, - readableReleaseNotesByTag: ReadableReleaseNotesByTag = {}, -): string { - const sections = collectReleaseSections(releasesPayload, readableReleaseNotesByTag) +export function buildReleaseHistoryPage(releasesPayload: unknown): string { + const sections = collectReleaseSections(releasesPayload) return ` diff --git a/src/utils/releaseReadableNotes.ts b/src/utils/releaseReadableNotes.ts deleted file mode 100644 index 3151bdb0..00000000 --- a/src/utils/releaseReadableNotes.ts +++ /dev/null @@ -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('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') -} - -function renderInlineMarkdown(source: ReadableMarkdownSource): string { - return escapeHtml(source) - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/`([^`]+)`/g, '$1') -} - -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: `${renderInlineMarkdown({ markdown: heading[2] })}`, kind: 'heading' } - } - - const bullet = line.match(/^[-*]\s+(.+)$/) - if (bullet) return { html: `
  • ${renderInlineMarkdown({ markdown: bullet[1] })}
  • `, kind: 'bullet' } - - return { html: `

    ${renderInlineMarkdown({ markdown: line })}

    `, kind: 'paragraph' } -} - -function appendRenderedLine(html: string[], line: ReadableMarkdownLine, listOpen: boolean): boolean { - if (line.kind === 'blank') { - if (listOpen) html.push('') - return false - } - if (line.kind === 'bullet') { - if (!listOpen) html.push('
      ') - html.push(line.html) - return true - } - - if (listOpen) html.push('
    ') - 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('') - return html.join('') -}