feat: add readable stable release notes
This commit is contained in:
2
.github/workflows/release-stable.yml
vendored
2
.github/workflows/release-stable.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
|
||||
18
release-notes/stable-v2026.5.2.md
Normal file
18
release-notes/stable-v2026.5.2.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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'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', () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
65
src/utils/releaseReadableNotes.ts
Normal file
65
src/utils/releaseReadableNotes.ts
Normal 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('&', '&')
|
||||
.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