feat: add readable stable release notes

This commit is contained in:
lucaronin
2026-05-04 13:01:06 +02:00
parent 71ef8325a0
commit 69b9da6da6
7 changed files with 205 additions and 30 deletions

View File

@@ -734,7 +734,7 @@ 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
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --release-notes-dir release-notes --output-file _site/index.html
mkdir -p _site/download
cp _site/stable/download/index.html _site/download/index.html

View File

@@ -785,7 +785,7 @@ 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
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --release-notes-dir release-notes --output-file _site/index.html
mkdir -p _site/download
cp _site/stable/download/index.html _site/download/index.html

View File

@@ -0,0 +1,18 @@
## New Features
- 📋 **Paste Without Formatting** — Paste copied text as plain content without bringing unwanted styling into a note.
- 🇵🇱 **Polish Language Support** — Use Tolaria with a new Polish interface translation.
- 🧭 **Refined Titlebar Navigation** — Navigate with clearer titlebar controls that feel more native on desktop.
## Improvements
-**Faster Note Loading** — Switch between notes more smoothly by reusing cached note content and parsed editor blocks.
- 🗂️ **Cleaner Sidebar and Menus** — Scan folders, sidebar sections, slash commands, icon choices, and release tabs with cleaner spacing and styling.
- 🖥️ **Better Cross-Platform Window Controls** — Use macOS and Linux window controls that better match each platform.
- ☀️ **Improved Light Mode Editing** — Read code blocks more comfortably when Tolaria is using the light theme.
## Stability and Fixes
- This release also improves editor reliability around block dragging, table handles, pasted Markdown, stale side-menu actions, and unrelated vault refreshes.
- Vault, type, and frontmatter handling were hardened to prevent freezes, filename collisions, parse failures, and stale saved-view deletes.
- Startup, Linux AppImage, release CI, and AI/Codex integration fixes are included in the full commit list.

View File

@@ -1,5 +1,5 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { basename, dirname, extname, resolve } from 'node:path'
import { buildReleaseHistoryPage } from '../src/utils/releaseHistoryPage'
@@ -14,6 +14,12 @@ 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'))
@@ -22,10 +28,28 @@ 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 html = buildReleaseHistoryPage(releasesPayload)
const readableReleaseNotes = readReadableReleaseNotes(getOptionalArg('--release-notes-dir'))
const html = buildReleaseHistoryPage(releasesPayload, readableReleaseNotes)
mkdirSync(dirname(outputFilePath), { recursive: true })
writeFileSync(outputFilePath, html)

View File

@@ -47,6 +47,34 @@ describe('buildReleaseHistoryPage', () => {
expect(html).toContain('<strong>Alpha</strong> notes')
expect(html).toContain('Tolaria-setup.exe')
expect(html).toContain('View on GitHub')
expect(html).not.toContain('class="release-channel"')
})
it('renders optional readable notes for stable releases instead of the commit list', () => {
const html = buildReleaseHistoryPage([
{
body: "## What's Changed\n\n- ee71a00 feat: add paste without formatting command",
body_html: [
'<h2>What&apos;s Changed</h2>',
'<ul>',
'<li><a href="https://github.com/refactoringhq/tolaria/commit/ee71a00">ee71a00</a> feat: add paste without formatting command</li>',
'</ul>',
].join(''),
html_url: 'https://github.com/refactoringhq/tolaria/releases/tag/stable-v2026.5.2',
name: 'Tolaria 2026.5.2',
prerelease: false,
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).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')
})
it('falls back to escaped paragraph markup when rendered html is unavailable', () => {

View File

@@ -1,3 +1,5 @@
import { renderReadableReleaseNotesMarkdown } from './releaseReadableNotes'
type ReleaseAssetPayload = {
browser_download_url?: unknown
name?: unknown
@@ -28,11 +30,13 @@ type ReleaseEntry = {
notesHtml: string
publishedLabel: string
publishedTimestamp: number
readableNotesHtml: string | null
tagName: string
title: string
}
type ReleaseSections = Record<ReleaseChannel, ReleaseEntry[]>
type ReadableReleaseNotesByTag = Record<string, string>
const RELEASE_HISTORY_PAGE_STYLES = `
:root {
@@ -117,11 +121,6 @@ const RELEASE_HISTORY_PAGE_STYLES = `
line-height: 1.6;
}
.keyboard-hint {
margin-top: 8px;
font-size: 0.9375rem;
}
.channel-tabs {
margin-bottom: 24px;
}
@@ -197,12 +196,12 @@ const RELEASE_HISTORY_PAGE_STYLES = `
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
margin-bottom: 26px;
}
.release-header h2 {
margin: 0;
font-size: 1.25rem;
font-size: 1.9rem;
line-height: 1.25;
}
@@ -212,7 +211,7 @@ const RELEASE_HISTORY_PAGE_STYLES = `
font-size: 0.9375rem;
}
.release-channel {
.release-github-link {
align-self: start;
background: var(--release-surface-tab-hover);
border-radius: 999px;
@@ -221,12 +220,18 @@ const RELEASE_HISTORY_PAGE_STYLES = `
font-weight: 700;
letter-spacing: 0.02em;
padding: 6px 10px;
text-decoration: none;
text-transform: uppercase;
}
.release-card--alpha .release-channel {
background: var(--release-alpha-bg);
color: var(--release-alpha-text);
.release-github-link:hover,
.release-github-link:focus-visible {
filter: brightness(0.96);
}
.release-github-link:focus-visible {
outline: 2px solid var(--release-accent);
outline-offset: 2px;
}
.release-notes {
@@ -250,6 +255,18 @@ const RELEASE_HISTORY_PAGE_STYLES = `
margin: 1.2em 0 0.4em;
}
.release-notes h1 {
font-size: 1.35rem;
}
.release-notes h2 {
font-size: 1.25rem;
}
.release-notes h3 {
font-size: 1.1rem;
}
.release-notes p,
.release-notes ul,
.release-notes ol,
@@ -409,6 +426,7 @@ const RELEASE_HISTORY_PAGE_SCRIPT = `
tab.addEventListener('click', () => activateTab(tab));
tab.addEventListener('keydown', (event) => handleTabKeydown(event, tab, index));
});
})();
`
@@ -528,12 +546,35 @@ function normalizeReleaseEntry(release: GitHubReleasePayload): [ReleaseChannel,
notesHtml: resolveReleaseNotesHtml(release.body_html, release.body),
publishedLabel: formatPublishedLabel(release.published_at),
publishedTimestamp: parsePublishedTimestamp(release.published_at),
readableNotesHtml: null,
tagName,
title,
}]
}
function collectReleaseSections(payload: unknown): ReleaseSections {
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)
}
function collectReleaseSections(payload: unknown, readableReleaseNotesByTag: ReadableReleaseNotesByTag): ReleaseSections {
const sections: ReleaseSections = { alpha: [], stable: [] }
if (!Array.isArray(payload)) return sections
@@ -543,8 +584,7 @@ function collectReleaseSections(payload: unknown): ReleaseSections {
const normalizedRelease = normalizeReleaseEntry(item as GitHubReleasePayload)
if (normalizedRelease === null) continue
const [channel, release] = normalizedRelease
sections[channel].push(release)
appendReleaseSection(sections, normalizedRelease, readableReleaseNotesByTag)
}
for (const channel of ['stable', 'alpha'] as const) {
@@ -573,17 +613,15 @@ function buildTabMarkup(channel: ReleaseChannel, count: number, selected: boolea
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 githubMarkup = release.githubUrl === null
? ''
: `<a class="release-github-link" href="${escapeHtml(release.githubUrl)}" target="_blank" rel="noreferrer">View on GitHub</a>`
const downloadsMarkup = downloads.length > 0
? `
<div class="release-downloads">
${downloads.map(download => {
const isSecondary = download.label === 'View on GitHub'
return `<a href="${escapeHtml(download.url)}" ${isSecondary ? 'data-secondary="true" ' : ''}target="_blank" rel="noreferrer">${escapeHtml(download.label)}</a>`
return `<a href="${escapeHtml(download.url)}" target="_blank" rel="noreferrer">${escapeHtml(download.label)}</a>`
}).join('')}
</div>`
: ''
@@ -595,9 +633,9 @@ function buildReleaseMarkup(channel: ReleaseChannel, release: ReleaseEntry): str
<h2>${escapeHtml(release.title)}</h2>
<p class="release-meta">${escapeHtml(release.publishedLabel)} · ${escapeHtml(release.tagName)}</p>
</div>
<span class="release-channel">${channelLabel}</span>
${githubMarkup}
</div>
<div class="release-notes">${release.notesHtml}</div>${downloadsMarkup}
<div class="release-notes">${release.readableNotesHtml ?? release.notesHtml}</div>${downloadsMarkup}
</article>`
}
@@ -619,8 +657,11 @@ function buildPanelMarkup(channel: ReleaseChannel, releases: ReleaseEntry[], sel
</section>`
}
export function buildReleaseHistoryPage(releasesPayload: unknown): string {
const sections = collectReleaseSections(releasesPayload)
export function buildReleaseHistoryPage(
releasesPayload: unknown,
readableReleaseNotesByTag: ReadableReleaseNotesByTag = {},
): string {
const sections = collectReleaseSections(releasesPayload, readableReleaseNotesByTag)
return `<!DOCTYPE html>
<html lang="en">
@@ -636,7 +677,6 @@ export function buildReleaseHistoryPage(releasesPayload: unknown): string {
<header>
<h1>Tolaria Release History</h1>
<p class="subtitle">Stable builds appear when a stable-vYYYY.M.D tag is promoted. Alpha builds update on every push to main.</p>
<p class="keyboard-hint">Use Tab to reach the channel picker, then use the arrow keys to switch between Stable and Alpha.</p>
</header>
<div class="channel-tabs">
<div class="channel-tablist" role="tablist" aria-label="Release channels">

View File

@@ -0,0 +1,65 @@
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('')
}