mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
cd5bb32a21
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>
238 lines
6.8 KiB
Python
Executable File
238 lines
6.8 KiB
Python
Executable File
#!/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))
|