Neon-Vision-Editor/scripts/update_release_history_svg.py

463 lines
16 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
"""Generate dark/light release history timeline SVGs with upcoming milestones.
This script updates:
- docs/images/neon-vision-release-history-0.1-to-0.5.svg
- docs/images/neon-vision-release-history-0.1-to-0.5-light.svg
The filename is kept stable for README compatibility.
"""
from __future__ import annotations
import argparse
import pathlib
import re
import textwrap
from dataclasses import dataclass
from html import escape
ROOT = pathlib.Path(__file__).resolve().parents[1]
CHANGELOG = ROOT / "CHANGELOG.md"
DARK_SVG = ROOT / "docs" / "images" / "neon-vision-release-history-0.1-to-0.5.svg"
LIGHT_SVG = ROOT / "docs" / "images" / "neon-vision-release-history-0.1-to-0.5-light.svg"
@dataclass(frozen=True)
class ReleaseSection:
tag: str
major: int
minor: int
patch: int
body: str
@dataclass(frozen=True)
class Milestone:
label: str
title: str
bullets: list[str]
is_future: bool
color: str
link: str
SEED_MINOR_TITLES: dict[tuple[int, int], tuple[str, list[str]]] = {
(0, 1): (
"Early Editor Foundation",
[
"Initial lightweight editor core",
"Basic syntax highlighting",
"First SwiftUI editor interface",
"Early file handling",
],
),
(0, 2): (
"Core Editing",
[
"Regex Find & Replace",
"Bracket helper",
"Improved syntax highlighting",
"Faster editor rendering",
],
),
(0, 3): (
"Projects",
[
"Project sidebar navigation",
"Recursive folder support",
"Quick Open workflow",
"Better file loading performance",
],
),
(0, 4): (
"Cross-Platform",
[
"iPadOS + iOS workflow parity",
"Toolbar and keyboard polish",
"Reliability and performance hardening",
"Cross-platform Save As support",
],
),
(0, 5): (
"Editor Intelligence",
[
"Inline code completion",
"Optional AI assistance",
"Markdown preview templates",
"Diagnostics and runtime controls",
],
),
}
COLOR_PALETTE = ["#49C6FF", "#66E3FF", "#9F6BFF", "#FF6FD8", "#FF5CA8", "#22C55E", "#F59E0B", "#06B6D4", "#A855F7"]
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Update release history SVG timelines.")
p.add_argument("tag", nargs="?", help="Optional release tag context (e.g. v0.6.0).")
p.add_argument("--check", action="store_true", help="Fail when output files are outdated.")
return p.parse_args()
def parse_tag(raw: str) -> tuple[int, int, int]:
m = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", raw.strip())
if not m:
raise ValueError(f"Invalid tag: {raw}")
return int(m.group(1)), int(m.group(2)), int(m.group(3))
def load_sections(changelog: str) -> list[ReleaseSection]:
pattern = re.compile(
r"^## \[(v(\d+)\.(\d+)\.(\d+))\] - [0-9]{4}-[0-9]{2}-[0-9]{2}\n(?P<body>.*?)(?=^## \[|\Z)",
flags=re.M | re.S,
)
sections: list[ReleaseSection] = []
for m in pattern.finditer(changelog):
sections.append(
ReleaseSection(
tag=m.group(1),
major=int(m.group(2)),
minor=int(m.group(3)),
patch=int(m.group(4)),
body=m.group("body").strip(),
)
)
return sections
def minor_key(major: int, minor: int) -> tuple[int, int]:
return (major, minor)
def latest_section_by_minor(sections: list[ReleaseSection]) -> dict[tuple[int, int], ReleaseSection]:
out: dict[tuple[int, int], ReleaseSection] = {}
for section in sections:
key = minor_key(section.major, section.minor)
prev = out.get(key)
if prev is None or section.patch > prev.patch:
out[key] = section
return out
def extract_bullets(body: str, limit: int = 4) -> list[str]:
bullets: list[str] = []
for line in body.splitlines():
stripped = line.strip()
if stripped.startswith("- "):
text = stripped[2:]
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\[[^\]]+\]\([^)]+\)", "", text).strip()
if text:
bullets.append(text)
if len(bullets) >= limit:
break
return bullets
def milestone_title_from_section(section: ReleaseSection) -> str:
body = section.body
heading_match = re.search(r"^### ([^\n]+)$", body, flags=re.M)
if heading_match:
heading = heading_match.group(1).strip()
if heading.lower() not in {"added", "improved", "fixed"}:
return heading
return f"Release line {section.major}.{section.minor}"
def choose_current_minor(sections: list[ReleaseSection], explicit_tag: str | None) -> tuple[int, int]:
if explicit_tag:
major, minor, _ = parse_tag(explicit_tag)
return major, minor
stable = [s for s in sections if "-" not in s.tag]
if not stable:
return (0, 5)
latest = max(stable, key=lambda s: (s.major, s.minor, s.patch))
return latest.major, latest.minor
def build_completed_minors(current: tuple[int, int], by_minor: dict[tuple[int, int], ReleaseSection]) -> list[tuple[int, int]]:
seeds = set(SEED_MINOR_TITLES.keys())
known = set(by_minor.keys()) | seeds
completed = {m for m in known if m <= current}
# Keep the full historical 0.X line visible in the timeline (0.1 ... 0.N),
# even when the active release line moves beyond 0.x.
zero_minors = [minor for (major, minor) in completed if major == 0]
if current[0] == 0:
zero_end = current[1]
else:
zero_end = max(zero_minors, default=0)
for minor in range(1, zero_end + 1):
completed.add((0, minor))
return sorted(completed)
def build_future_minors(current: tuple[int, int], completed: list[tuple[int, int]]) -> list[tuple[int, int]]:
major, minor = current
candidates = [(major, minor + 1), (major, minor + 2), (major + 1, 0)]
out: list[tuple[int, int]] = []
completed_set = set(completed)
for c in candidates:
if c not in completed_set and c not in out:
out.append(c)
return out[:3]
def format_minor_label(m: tuple[int, int]) -> str:
return f"{m[0]}.{m[1]}"
def wrap_lines(text: str, width: int, max_lines: int) -> list[str]:
wrapped = textwrap.wrap(text, width=width) or [text]
if len(wrapped) <= max_lines:
return wrapped
trimmed = wrapped[:max_lines]
last = trimmed[-1].rstrip()
if len(last) > 1:
last = (last[:-1] + "").rstrip()
else:
last = ""
trimmed[-1] = last
return trimmed
def milestone_for_minor(
m: tuple[int, int],
by_minor: dict[tuple[int, int], ReleaseSection],
is_future: bool,
color: str,
) -> Milestone:
label = format_minor_label(m)
if is_future:
major, minor = m
if minor == 0:
title = "Next Major Foundation"
bullets = [
"Platform + architecture step-up",
"Roadmap themes converge",
"Migration guidance in release notes",
]
else:
title = "Upcoming Milestone"
bullets = [
"Planned roadmap milestone",
"UX + reliability polishing",
"Scope refined via issue feedback",
]
return Milestone(
label=label,
title=title,
bullets=bullets,
is_future=True,
color=color,
link="https://github.com/h3pdesign/Neon-Vision-Editor/issues/49",
)
if m in SEED_MINOR_TITLES:
title, bullets = SEED_MINOR_TITLES[m]
return Milestone(
label=label,
title=title,
bullets=bullets,
is_future=False,
color=color,
link=f"https://github.com/h3pdesign/Neon-Vision-Editor/releases/tag/v{m[0]}.{m[1]}.0",
)
section = by_minor.get(m)
if section is None:
return Milestone(
label=label,
title=f"Release line {label}",
bullets=["See CHANGELOG for details."],
is_future=False,
color=color,
link="https://github.com/h3pdesign/Neon-Vision-Editor/blob/main/CHANGELOG.md",
)
bullets = extract_bullets(section.body, limit=4) or ["See CHANGELOG for details."]
title = milestone_title_from_section(section)
return Milestone(
label=label,
title=title,
bullets=bullets,
is_future=False,
color=color,
link=f"https://github.com/h3pdesign/Neon-Vision-Editor/releases/tag/{section.tag}",
)
def render_svg(milestones: list[Milestone], dark: bool) -> str:
n = len(milestones)
card_w = 300
card_h = 470
gap = 52
margin = 110
width = max(2050, margin * 2 + n * card_w + (n - 1) * gap)
height = 1080
timeline_y = 800
card_y = 260
if dark:
bg_stops = ("#050A2A", "#1B1850", "#41123A")
title_fill = "#FFFFFF"
subtitle_fill = "#DFE8FF"
card_fill = "rgba(255,255,255,0.06)"
text_main = "#F3F7FF"
text_body = "#E7EFFF"
divider = "#DBE6FF"
shadow_opacity = "0.35"
else:
bg_stops = ("#F6FBFF", "#EEF2FF", "#F9EEF6")
title_fill = "#0F172A"
subtitle_fill = "#334155"
card_fill = "rgba(255,255,255,0.78)"
text_main = "#0F172A"
text_body = "#1F2937"
divider = "#94A3B8"
shadow_opacity = "0.12"
completed = [m for m in milestones if not m.is_future]
start_label = completed[0].label if completed else milestones[0].label
end_label = completed[-1].label if completed else milestones[0].label
subtitle = f"Release History · Versions {start_label} {end_label} + upcoming"
card_layout: list[tuple[float, float]] = []
for i in range(n):
cx = margin + i * (card_w + gap) + card_w / 2
rect_x = cx - card_w / 2
card_layout.append((cx, rect_x))
out: list[str] = []
out.append(f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">')
out.extend(
[
"<defs>",
'<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">',
f'<stop offset="0%" stop-color="{bg_stops[0]}"/>',
f'<stop offset="55%" stop-color="{bg_stops[1]}"/>',
f'<stop offset="100%" stop-color="{bg_stops[2]}"/>',
"</linearGradient>",
'<linearGradient id="lineGrad" x1="0" y1="0" x2="1" y2="0">',
'<stop offset="0%" stop-color="#4DD9FF"/>',
'<stop offset="50%" stop-color="#9D63FF"/>',
'<stop offset="100%" stop-color="#FF689A"/>',
"</linearGradient>",
'<filter id="shadow" x="-20%" y="-20%" width="140%" height="160%">',
f'<feDropShadow dx="0" dy="12" stdDeviation="18" flood-color="#000000" flood-opacity="{shadow_opacity}"/>',
"</filter>",
]
)
for i, (_, rect_x) in enumerate(card_layout):
out.extend(
[
f'<clipPath id="cardClip{i}">',
f'<rect x="{rect_x + 10:.1f}" y="{card_y + 96}" width="{card_w - 20}" height="{card_h - 116}" rx="20"/>',
"</clipPath>",
]
)
out.extend(
[
"</defs>",
'<rect width="100%" height="100%" fill="url(#bg)"/>',
f'<text x="{margin}" y="110" fill="{title_fill}" font-size="62" font-weight="700" font-family="Arial, Helvetica, sans-serif">Neon Vision Editor</text>',
f'<text x="{margin}" y="165" fill="{subtitle_fill}" font-size="30" font-family="Arial, Helvetica, sans-serif">{escape(subtitle)}</text>',
f'<line x1="{margin + 70}" y1="{timeline_y}" x2="{width - margin + 10}" y2="{timeline_y}" stroke="url(#lineGrad)" stroke-width="8" stroke-linecap="round"/>',
]
)
for i, m in enumerate(milestones):
cx, rect_x = card_layout[i]
stroke_dash = ' stroke-dasharray="12 10"' if m.is_future else ""
out.extend(
[
f'<a href="{escape(m.link)}">',
f'<title>{escape(m.label)} - {escape(m.title)}</title>',
'<g filter="url(#shadow)">',
(
f'<rect x="{rect_x:.1f}" y="{card_y}" width="{card_w}" height="{card_h}" rx="30" '
f'fill="{card_fill}" stroke="{m.color}" stroke-width="3"{stroke_dash}/>'
),
"</g>",
f'<text x="{cx:.1f}" y="{card_y + 70}" text-anchor="middle" fill="{text_main}" font-size="48" font-weight="700" font-family="Arial, Helvetica, sans-serif">{escape(m.label)}</text>',
]
)
out.append(f'<g clip-path="url(#cardClip{i})">')
title_lines = wrap_lines(m.title, width=16, max_lines=2)
for idx, line in enumerate(title_lines):
out.append(
f'<text x="{rect_x + 24:.1f}" y="{card_y + 130 + idx * 36}" fill="{text_main}" font-size="30" font-weight="600" font-family="Arial, Helvetica, sans-serif">{escape(line)}</text>'
)
divider_y = card_y + 176
out.append(
f'<line x1="{rect_x + 24:.1f}" y1="{divider_y}" x2="{rect_x + card_w - 24:.1f}" y2="{divider_y}" stroke="{divider}" stroke-opacity="0.4"/>'
)
for bi, bullet in enumerate(m.bullets[:4]):
bullet_lines = wrap_lines(bullet, width=24, max_lines=2)
y = divider_y + 50 + bi * 70
out.append(
f'<text x="{rect_x + 24:.1f}" y="{y}" fill="{text_body}" font-size="20" font-family="Arial, Helvetica, sans-serif">• {escape(bullet_lines[0])}</text>'
)
if len(bullet_lines) > 1:
out.append(
f'<text x="{rect_x + 40:.1f}" y="{y + 28}" fill="{text_body}" font-size="20" font-family="Arial, Helvetica, sans-serif">{escape(bullet_lines[1])}</text>'
)
out.append("</g>")
node_dash = ' stroke-dasharray="6 6"' if m.is_future else ""
fill_opacity = "0.5" if m.is_future else "0.85"
out.append(f'<circle cx="{cx:.1f}" cy="{timeline_y}" r="26" fill="{m.color}" fill-opacity="{fill_opacity}"{node_dash}/>')
out.append(
f'<text x="{cx:.1f}" y="{timeline_y + 70}" text-anchor="middle" fill="{title_fill if dark else "#0F172A"}" font-size="28" font-family="Arial, Helvetica, sans-serif">{escape(m.label)}</text>'
)
if m.is_future:
out.append(
f'<text x="{cx:.1f}" y="{timeline_y + 102}" text-anchor="middle" fill="{subtitle_fill}" font-size="18" font-family="Arial, Helvetica, sans-serif">upcoming</text>'
)
out.append("</a>")
out.append("</svg>")
return "\n".join(out) + "\n"
def main() -> int:
args = parse_args()
changelog = CHANGELOG.read_text(encoding="utf-8")
sections = load_sections(changelog)
by_minor = latest_section_by_minor(sections)
current = choose_current_minor(sections, args.tag)
completed = build_completed_minors(current, by_minor)
future = build_future_minors(current, completed)
pairs = completed + future
milestones = [
milestone_for_minor(pair, by_minor, pair in future, COLOR_PALETTE[i % len(COLOR_PALETTE)])
for i, pair in enumerate(pairs)
]
dark_svg = render_svg(milestones, dark=True)
light_svg = render_svg(milestones, dark=False)
dark_before = DARK_SVG.read_text(encoding="utf-8") if DARK_SVG.exists() else ""
light_before = LIGHT_SVG.read_text(encoding="utf-8") if LIGHT_SVG.exists() else ""
changed = dark_before != dark_svg or light_before != light_svg
if args.check:
if changed:
print("Release history SVGs are outdated. Run scripts/update_release_history_svg.py")
return 1
return 0
DARK_SVG.write_text(dark_svg, encoding="utf-8")
LIGHT_SVG.write_text(light_svg, encoding="utf-8")
print(f"Updated release history SVGs for current milestone {current[0]}.{current[1]} with {len(future)} upcoming nodes.")
return 0
if __name__ == "__main__":
raise SystemExit(main())