feat: load readable release notes dynamically
This commit is contained in:
5
.github/workflows/release-stable.yml
vendored
5
.github/workflows/release-stable.yml
vendored
@@ -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
|
||||
|
||||
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
|
||||
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">
|
||||
|
||||
@@ -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, '<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('')
|
||||
}
|
||||
Reference in New Issue
Block a user