Files
scarf/tools/render-release-notes.py
Alan Wizemann cd5bb32a21 release: prep v2.7.0 — consolidated notes + in-app Sparkle release notes
Rolls up everything since v2.6.5 (36 commits across remote-perf,
project wizard, dashboard widgets, OAuth resilience, ScarfMon
instrumentation, and the v2.7 skeleton-then-hydrate redesign) into
a single 2.7.0 release.

* releases/v2.7.0/RELEASE_NOTES.md — full consolidated notes,
  reorganized around the throughline (slow-remote performance) with
  five thematic sections: skeleton-then-hydrate loaders, SSH
  cancellation, project wizard + Keychain cron secrets, dashboard
  widgets, OAuth resilience, and ScarfMon. Replaces the previously-
  drafted dashboard-only v2.7.0 stub and the separate v2.8 wizard
  stub (both unreleased).
* releases/v2.8/ — deleted; folded into v2.7.
* README.md — "What's New in 2.6" → "What's New in 2.7" with the
  five-section summary linking out to the full notes.

* tools/render-release-notes.py — stdlib-only Markdown → HTML
  renderer covering the subset of GitHub-flavored markdown that
  release notes use (## / ### headings, paragraphs, ul lists,
  fenced code, inline code/bold/italic/links, hr). Output includes
  a small <style> block tuned for Sparkle's update alert WebKit
  view (light + dark variants via prefers-color-scheme).
* scripts/release.sh — render the active RELEASE_NOTES.md and
  inject the result as <description><![CDATA[...]]></description>
  on the appcast item. Sparkle's standard updater renders this in
  the in-app update sheet so users see release-specific "what's
  new" alongside the version number, not just the bare version.
  Falls back to a "see GitHub release page" placeholder when the
  notes file is missing.

User runs ./scripts/release.sh 2.7.0 to ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:31:27 +02:00

238 lines
6.8 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Render a release-notes Markdown file into a small standalone HTML
fragment suitable for inlining into a Sparkle appcast `<description>`
element (CDATA-wrapped).
Stdlib only — no `markdown` package dependency. Covers the subset of
GitHub-flavored markdown that `releases/v*/RELEASE_NOTES.md` uses:
* `## Heading 2` / `### Heading 3`
* paragraphs (blank-line-separated)
* unordered lists (`- item`, single level only)
* fenced code blocks (` ``` `)
* inline `code`, **bold**, *italic*, `[link text](url)`
* horizontal rules (`---`)
Sparkle's `SUUserUpdateAlertController` renders the inline HTML in a
WebKit view with no styling beyond what's in the body, so a tiny
`<style>` block is included. Fonts and spacing are tuned to look
right inside the standard 480×360 update sheet.
Usage:
python3 tools/render-release-notes.py releases/v2.7.0/RELEASE_NOTES.md > out.html
Used by `scripts/release.sh` to populate the appcast item's
`<description>` block per release.
"""
from __future__ import annotations
import html
import re
import sys
from pathlib import Path
from typing import Iterator
# ---------- inline ----------
_INLINE_CODE = re.compile(r"`([^`]+)`")
_BOLD = re.compile(r"\*\*([^*]+)\*\*")
_ITALIC = re.compile(r"(?<!\*)\*([^*]+)\*(?!\*)")
_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
def render_inline(text: str) -> str:
"""Apply inline transforms in order: escape HTML first, then
swap markdown markers in-place. Order matters — links before
bold so `[**bold**](url)` doesn't double-process."""
out = html.escape(text)
out = _INLINE_CODE.sub(lambda m: f"<code>{m.group(1)}</code>", out)
out = _LINK.sub(lambda m: f'<a href="{m.group(2)}">{m.group(1)}</a>', out)
out = _BOLD.sub(lambda m: f"<strong>{m.group(1)}</strong>", out)
out = _ITALIC.sub(lambda m: f"<em>{m.group(1)}</em>", out)
return out
# ---------- block ----------
def render_blocks(lines: list[str]) -> Iterator[str]:
"""Walk lines and emit HTML blocks. Maintains state for fenced
code, lists, and paragraph buffers."""
i = 0
n = len(lines)
paragraph_buf: list[str] = []
list_buf: list[str] = []
def flush_paragraph() -> Iterator[str]:
if paragraph_buf:
text = " ".join(paragraph_buf).strip()
if text:
yield f"<p>{render_inline(text)}</p>"
paragraph_buf.clear()
def flush_list() -> Iterator[str]:
if list_buf:
yield "<ul>"
for item in list_buf:
yield f" <li>{render_inline(item)}</li>"
yield "</ul>"
list_buf.clear()
while i < n:
line = lines[i]
stripped = line.rstrip("\n")
# Fenced code block
if stripped.startswith("```"):
yield from flush_paragraph()
yield from flush_list()
i += 1
code_lines: list[str] = []
while i < n and not lines[i].rstrip("\n").startswith("```"):
code_lines.append(lines[i].rstrip("\n"))
i += 1
i += 1 # skip closing fence
escaped = html.escape("\n".join(code_lines))
yield f"<pre><code>{escaped}</code></pre>"
continue
# Blank line — close paragraph + list
if not stripped.strip():
yield from flush_paragraph()
yield from flush_list()
i += 1
continue
# Horizontal rule
if stripped.strip() == "---":
yield from flush_paragraph()
yield from flush_list()
yield "<hr>"
i += 1
continue
# Heading
if stripped.startswith("### "):
yield from flush_paragraph()
yield from flush_list()
yield f"<h3>{render_inline(stripped[4:])}</h3>"
i += 1
continue
if stripped.startswith("## "):
yield from flush_paragraph()
yield from flush_list()
yield f"<h2>{render_inline(stripped[3:])}</h2>"
i += 1
continue
if stripped.startswith("#### "):
yield from flush_paragraph()
yield from flush_list()
yield f"<h4>{render_inline(stripped[5:])}</h4>"
i += 1
continue
# Unordered list item
list_match = re.match(r"^[-*]\s+(.+)$", stripped)
if list_match:
yield from flush_paragraph()
list_buf.append(list_match.group(1))
i += 1
continue
# Paragraph line — close list, accumulate
if list_buf:
yield from flush_list()
paragraph_buf.append(stripped)
i += 1
yield from flush_paragraph()
yield from flush_list()
# ---------- document ----------
# Sparkle WebKit view default styling is plain — give it enough to
# look like a release notes sheet, not a 1995 docs dump. Sized for
# the standard update alert dimensions.
STYLE = """\
body {
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
font-size: 13px;
line-height: 1.5;
color: #1d1d1f;
margin: 0;
padding: 0 4px;
}
h2 {
font-size: 17px;
margin: 16px 0 6px 0;
border-bottom: 1px solid #e5e5e7;
padding-bottom: 3px;
}
h3 {
font-size: 14px;
margin: 14px 0 4px 0;
color: #424245;
}
h4 {
font-size: 13px;
font-weight: 600;
margin: 10px 0 2px 0;
}
p { margin: 6px 0; }
ul { margin: 6px 0; padding-left: 20px; }
li { margin: 3px 0; }
code {
background: #f5f5f7;
border-radius: 3px;
padding: 1px 4px;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
}
pre {
background: #f5f5f7;
border-radius: 5px;
padding: 8px 10px;
overflow-x: auto;
font-size: 12px;
}
pre code { background: transparent; padding: 0; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
hr {
border: none;
border-top: 1px solid #e5e5e7;
margin: 16px 0;
}
strong { color: #1d1d1f; }
@media (prefers-color-scheme: dark) {
body { color: #f5f5f7; background: #1c1c1e; }
h2 { border-bottom-color: #38383a; }
h3 { color: #c7c7cc; }
code, pre { background: #2c2c2e; }
hr { border-top-color: #38383a; }
a { color: #4499ff; }
strong { color: #f5f5f7; }
}
"""
def render_document(markdown: str) -> str:
body = "\n".join(render_blocks(markdown.splitlines(keepends=True)))
return f"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><style>{STYLE}</style></head><body>\n{body}\n</body></html>"
def main(argv: list[str]) -> int:
if len(argv) != 2:
sys.stderr.write("usage: render-release-notes.py <RELEASE_NOTES.md>\n")
return 2
path = Path(argv[1])
if not path.exists():
sys.stderr.write(f"file not found: {path}\n")
return 1
sys.stdout.write(render_document(path.read_text(encoding="utf-8")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))