Last active
July 3, 2026 09:13
-
-
Save jcayzac/164831f6e59c0ada7385d3b5e9b15fb9 to your computer and use it in GitHub Desktop.
Render a Bikeshed spec, diff it against a git baseline, and serve the result.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S uv run --script | |
| # SPDX-License-Identifier: MIT AND Apache-2.0 | |
| # | |
| # Copyright (c) 2026 Julien Cayzac | |
| # | |
| # ─ MIT License ──────────────────────────────────────────────────────────────── | |
| # | |
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |
| # of this software and associated documentation files (the "Software"), to deal | |
| # in the Software without restriction, including without limitation the rights | |
| # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| # copies of the Software, and to permit persons to whom the Software is | |
| # furnished to do so, subject to the following conditions: | |
| # | |
| # The above copyright notice and this permission notice shall be included in all | |
| # copies or substantial portions of the Software. | |
| # | |
| # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| # SOFTWARE. | |
| # | |
| # ─ Apache License ───────────────────────────────────────────────────────────── | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not | |
| # use this file except in compliance with the License. You may obtain a copy of | |
| # the License at | |
| # | |
| # https://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | |
| # License for the specific language governing permissions and limitations under | |
| # the License. | |
| # | |
| # /// script | |
| # requires-python = ">=3.14" | |
| # dependencies = [ | |
| # "bikeshed==7.1.1", | |
| # "gitpython==3.1.50", | |
| # "lxml==6.1.0", | |
| # "lxml-stubs==0.5.1", | |
| # "rich==15.0.0", | |
| # "typer==0.26.8", | |
| # ] | |
| # /// | |
| """ | |
| Render a Bikeshed spec, diff it against a git baseline, serve the result. | |
| The new document is always the output skeleton — its <head>, inlined styles, | |
| and all structural containers are preserved intact. Children of each element | |
| are aligned against the baseline by stable Bikeshed anchors (id > href > | |
| descendant anchor > structural key), using difflib LCS. Matched pairs are | |
| recursed into; unmatched new nodes are marked inserted; unmatched old nodes | |
| are spliced back in as deleted; matched text-only leaves get word-level diffs. | |
| Nothing is written to disk unless -o is given. | |
| """ | |
| from __future__ import annotations | |
| import copy | |
| import dataclasses | |
| import difflib | |
| import hashlib | |
| import http.server | |
| import io | |
| import re | |
| import socketserver | |
| import sys | |
| import threading | |
| import time | |
| import traceback | |
| from pathlib import Path | |
| from typing import TYPE_CHECKING | |
| import typer | |
| import lxml.etree as ET | |
| from lxml import html as lhtml | |
| from rich.console import Console | |
| from rich.markup import escape as e | |
| if TYPE_CHECKING: | |
| from lxml.html import HtmlElement | |
| # ── Console output ──────────────────────────────────────────────────────────── | |
| @dataclasses.dataclass | |
| class _Console: | |
| """stdout/stderr console pair with four semantic output helpers. | |
| Call configure(color) once at startup. All helpers are thread-safe. | |
| Bikeshed uses its own ANSI system (not Rich), controlled by | |
| messages.state.printMode. configure() sets that too so Bikeshed | |
| output respects --color as well. | |
| """ | |
| stdout: Console = dataclasses.field( | |
| default_factory=lambda: Console(highlight=False) | |
| ) | |
| stderr: Console = dataclasses.field( | |
| default_factory=lambda: Console(highlight=False, stderr=True) | |
| ) | |
| def configure(self, color: str) -> None: | |
| """Apply the requested color mode: 'always', 'never', or 'auto'.""" | |
| from bikeshed import messages as bk | |
| bk.state = bk.state.replace( | |
| printMode="plain" if color == "never" else "console" | |
| ) | |
| match color: | |
| case "always": | |
| self.stdout = Console(highlight=False, force_terminal=True) | |
| self.stderr = Console(highlight=False, force_terminal=True, stderr=True) | |
| case "never": | |
| self.stdout = Console(highlight=False, no_color=True) | |
| self.stderr = Console(highlight=False, no_color=True, stderr=True) | |
| case _: | |
| self.stdout = Console(highlight=False) | |
| self.stderr = Console(highlight=False, stderr=True) | |
| def step(self, msg: str) -> None: | |
| self.stdout.print(f"[dim]·[/] {msg}") | |
| def done(self, msg: str) -> None: | |
| self.stdout.print(f"[green]✓[/] {msg}") | |
| def warn(self, msg: str) -> None: | |
| self.stderr.print(f"[yellow]⚠[/] {msg}") | |
| def fail(self, msg: str) -> None: | |
| self.stderr.print(f"[red]✗[/] {msg}") | |
| console = _Console() | |
| # ── Constants ───────────────────────────────────────────────────────────────── | |
| _DEFAULT_PORT = 9991 | |
| _POLL_INTERVAL = 0.5 # seconds | |
| REPO_DIR = Path.cwd() | |
| # ── Shared state ────────────────────────────────────────────────────────────── | |
| @dataclasses.dataclass | |
| class _State: | |
| # Baseline body: loaded once from git and reused on every rebuild. | |
| baseline: HtmlElement | None = None | |
| # Latest rendered diff, served on GET / and updated after every rebuild. | |
| diff: bytes | None = None | |
| # Version token embedded in the served page and polled by the reload script. | |
| build_id: str = "0" | |
| _state = _State() | |
| _state_lock = threading.Lock() | |
| # ── Diff engine ─────────────────────────────────────────────────────────────── | |
| # Classes whose text is volatile (section numbers, self-link glyphs) and must | |
| # be excluded from identity keys so renumbering doesn't flag every heading. | |
| _VOLATILE_CLASSES: frozenset[str] = frozenset({"secno", "self-link"}) | |
| # Bikeshed appends a document-wide occurrence counter to cross-reference IDs | |
| # (e.g. "ref-for-USVString⑦"). Inserting content shifts all later counters, | |
| # so strip the suffix before using an id/href as an identity key. | |
| _CIRCLED_DIGITS = "⓪①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳" | |
| _RE_COUNTER = re.compile("[" + re.escape(_CIRCLED_DIGITS) + "]+$") | |
| _RE_WHITESPACE = re.compile(r"\s+") | |
| _RE_WORD = re.compile(r"\S+|\s+") | |
| # Bikeshed always places the identifying anchor (section id, TOC href) near the | |
| # front of a container. Capping the DFS avoids scanning large subtrees for the | |
| # rare container with no anchor. | |
| _ANCHOR_SCAN_LIMIT = 40 | |
| def _children(el: HtmlElement) -> list[HtmlElement]: | |
| """Element children only — comments and PIs have non-string tags.""" | |
| return [c for c in el if isinstance(c.tag, str)] | |
| def _stable(value: str) -> str: | |
| """Strip a Bikeshed occurrence counter from an id or href.""" | |
| return _RE_COUNTER.sub("", value) | |
| def _collect_text(el: HtmlElement, out: list[str], *, is_root: bool) -> None: | |
| """Depth-first text collection, skipping volatile-class subtrees.""" | |
| if set((el.get("class") or "").split()) & _VOLATILE_CLASSES: | |
| if not is_root and el.tail: | |
| out.append(el.tail) | |
| return | |
| if el.text: | |
| out.append(el.text) | |
| for child in el: | |
| if isinstance(child.tag, str): | |
| _collect_text(child, out, is_root=False) | |
| elif child.tail: | |
| out.append(child.tail) | |
| if not is_root and el.tail: | |
| out.append(el.tail) | |
| def _norm_text(el: HtmlElement) -> str: | |
| """Collapse whitespace in el's text, excluding volatile-class subtrees.""" | |
| parts: list[str] = [] | |
| _collect_text(el, parts, is_root=True) | |
| return _RE_WHITESPACE.sub(" ", "".join(parts)).strip() | |
| def _stable_classes(el: HtmlElement) -> tuple[str, ...]: | |
| return tuple( | |
| c for c in (el.get("class") or "").split() if c not in _VOLATILE_CLASSES | |
| ) | |
| def _defining_anchor(el: HtmlElement) -> tuple[str, str] | None: | |
| """Return the first stable (kind, value) anchor in a bounded DFS.""" | |
| queue = _children(el) | |
| i = 0 | |
| while i < len(queue) and i < _ANCHOR_SCAN_LIMIT: | |
| node = queue[i] | |
| i += 1 | |
| if eid := node.get("id"): | |
| return ("id", _stable(eid)) | |
| if node.tag == "a" and (href := node.get("href")): | |
| return ("href", _stable(href)) | |
| queue.extend(_children(node)) | |
| return None | |
| def _node_key(el: HtmlElement) -> tuple: | |
| """Stable identity key for aligning a node across old and new documents. | |
| Priority: own id > own href (for <a>) > descendant anchor > structural | |
| signature. Only true text leaves fall back to a content hash — hashing a | |
| container's text would misalign it whenever any inner content changes. | |
| """ | |
| if eid := el.get("id"): | |
| return ("id", _stable(eid)) | |
| if el.tag == "a" and (href := el.get("href")): | |
| return ("href", _stable(href), _norm_text(el)) | |
| if _children(el): | |
| anchor = _defining_anchor(el) | |
| return ("anchor", *anchor) if anchor else ("box", el.tag, _stable_classes(el)) | |
| return ("text", el.tag, hashlib.md5(_norm_text(el).encode()).hexdigest()) | |
| def _add_class(el: HtmlElement, cls: str) -> None: | |
| existing = el.get("class") | |
| el.set("class", f"{existing} {cls}" if existing else cls) | |
| def _splice_deletion( | |
| old_node: HtmlElement, new_parent: HtmlElement, before: HtmlElement | None | |
| ) -> None: | |
| """Insert a deep-copy of old_node into new_parent, marked as deleted.""" | |
| clone = copy.deepcopy(old_node) | |
| clone.tail = "\n" | |
| _add_class(clone, "diff-del") | |
| if before is not None: | |
| before.addprevious(clone) | |
| else: | |
| new_parent.append(clone) | |
| def _is_matching_text_leaf(a: HtmlElement, b: HtmlElement) -> bool: | |
| """True when a and b are same-tag leaves — eligible for word-level diff.""" | |
| return a.tag == b.tag and not _children(a) and not _children(b) | |
| def _apply_word_diff(old_el: HtmlElement, new_el: HtmlElement) -> None: | |
| """Replace new_el's content with inline word-level ins/del spans.""" | |
| old_words = _RE_WORD.findall(old_el.text or "") | |
| new_words = _RE_WORD.findall(new_el.text or "") | |
| new_el.text = "" | |
| for child in list(new_el): | |
| new_el.remove(child) | |
| def append_text(text: str) -> None: | |
| if len(new_el): | |
| new_el[-1].tail = (new_el[-1].tail or "") + text | |
| else: | |
| new_el.text = (new_el.text or "") + text | |
| def append_span(css_class: str, words: list[str]) -> None: | |
| if text := "".join(words): | |
| ET.SubElement(new_el, "span", {"class": css_class}).text = text | |
| for op, i1, i2, j1, j2 in difflib.SequenceMatcher( | |
| None, old_words, new_words, autojunk=False | |
| ).get_opcodes(): | |
| match op: | |
| case "equal": | |
| append_text("".join(new_words[j1:j2])) | |
| case "insert": | |
| append_span("diff-ins-word", new_words[j1:j2]) | |
| case "delete": | |
| append_span("diff-del-word", old_words[i1:i2]) | |
| case "replace": | |
| append_span("diff-del-word", old_words[i1:i2]) | |
| append_span("diff-ins-word", new_words[j1:j2]) | |
| def _diff_element(old_el: HtmlElement, new_el: HtmlElement) -> None: | |
| """Recursively annotate new_el with differences relative to old_el.""" | |
| old_kids = _children(old_el) | |
| new_kids = _children(new_el) | |
| # Leaf node: compare text directly; word-diff if it changed. | |
| if not old_kids and not new_kids: | |
| if old_el.text != new_el.text: | |
| _apply_word_diff(old_el, new_el) | |
| return | |
| for op, i1, i2, j1, j2 in difflib.SequenceMatcher( | |
| None, | |
| [_node_key(c) for c in old_kids], | |
| [_node_key(c) for c in new_kids], | |
| autojunk=False, | |
| ).get_opcodes(): | |
| match op: | |
| case "equal": | |
| for old, new in zip(old_kids[i1:i2], new_kids[j1:j2]): | |
| _diff_element(old, new) | |
| case "insert": | |
| for node in new_kids[j1:j2]: | |
| _add_class(node, "diff-ins") | |
| case "delete": | |
| anchor = new_kids[j1] if j1 < len(new_kids) else None | |
| for node in old_kids[i1:i2]: | |
| _splice_deletion(node, new_el, anchor) | |
| case "replace": | |
| # Single matched text-leaf pair: word diff is more informative | |
| # than marking the whole node as deleted and re-inserted. | |
| if i2 - i1 == j2 - j1 == 1 and _is_matching_text_leaf( | |
| old_kids[i1], new_kids[j1] | |
| ): | |
| _apply_word_diff(old_kids[i1], new_kids[j1]) | |
| else: | |
| anchor = new_kids[j1] if j1 < len(new_kids) else None | |
| for node in new_kids[j1:j2]: | |
| _add_class(node, "diff-ins") | |
| for node in old_kids[i1:i2]: | |
| _splice_deletion(node, new_el, anchor) | |
| # ── Injected assets ─────────────────────────────────────────────────────────── | |
| # Both bg and fg are pinned together for each theme so contrast is guaranteed | |
| # regardless of whatever the page's own stylesheet does. | |
| # fixup.js (loaded by the W3C stylesheet) sets body.darkmode when dark is | |
| # active — this responds to both the OS preference and the page's own toggle. | |
| _DIFF_CSS = """ | |
| :root { | |
| --diff-ins-bg: #d4fcbc; --diff-ins-fg: #08350a; | |
| --diff-del-bg: #ffc9d1; --diff-del-fg: #4a0912; | |
| --legend-bg: #fff; --legend-fg: #111; --legend-border: #ccc; | |
| } | |
| body.darkmode { | |
| --diff-ins-bg: #0f3d16; --diff-ins-fg: #c6f7c9; | |
| --diff-del-bg: #4a1420; --diff-del-fg: #ffd0d8; | |
| --legend-bg: #1b1b1b; --legend-fg: #eee; --legend-border: #555; | |
| } | |
| .diff-ins, .diff-ins *:not(.diff-del, .diff-del *) { | |
| background-color: var(--diff-ins-bg) !important; | |
| color: var(--diff-ins-fg) !important; | |
| } | |
| .diff-del, .diff-del * { | |
| background-color: var(--diff-del-bg) !important; | |
| color: var(--diff-del-fg) !important; | |
| text-decoration: line-through !important; | |
| } | |
| .diff-ins-word { | |
| background-color: var(--diff-ins-bg) !important; | |
| color: var(--diff-ins-fg) !important; | |
| } | |
| .diff-del-word { | |
| background-color: var(--diff-del-bg) !important; | |
| color: var(--diff-del-fg) !important; | |
| text-decoration: line-through !important; | |
| } | |
| .diff-ins a, .diff-ins code, .diff-ins-word a, .diff-ins-word code { | |
| color: var(--diff-ins-fg) !important; | |
| } | |
| .diff-del a, .diff-del code, .diff-del-word a, .diff-del-word code { | |
| color: var(--diff-del-fg) !important; | |
| } | |
| .diff-ins img, .diff-ins-word img { outline: 2px solid #1a7f37; } | |
| .diff-del img, .diff-del-word img { outline: 2px solid #cf222e; } | |
| #diff-legend { | |
| position: fixed; top: 8px; right: 8px; z-index: 99999; | |
| font: 12px/1.4 system-ui, sans-serif; | |
| background: var(--legend-bg); color: var(--legend-fg); | |
| border: 1px solid var(--legend-border); | |
| border-radius: 6px; padding: 6px 10px; | |
| box-shadow: 0 1px 4px rgba(0,0,0,.3); | |
| } | |
| #diff-legend b { | |
| display: inline-block; width: 10px; height: 10px; | |
| border-radius: 2px; vertical-align: middle; margin-right: 4px; | |
| } | |
| """ | |
| _LEGEND_HTML = ( | |
| '<div id="diff-legend">' | |
| '<span><b style="background:var(--diff-ins-bg)"></b>added</span> ' | |
| '<span><b style="background:var(--diff-del-bg)"></b>removed</span>' | |
| "</div>" | |
| ) | |
| # Preserves scroll position across live reloads via sessionStorage. | |
| # The build id is embedded at render time and polled against /version; | |
| # a mismatch saves scroll position and reloads. | |
| _RELOAD_JS = """ | |
| (function () { | |
| if ('scrollRestoration' in history) history.scrollRestoration = 'manual'; | |
| const BUILD_ID = "__BUILD_ID__"; | |
| const KEY = 'specdiff:scroll'; | |
| const save = () => { try { sessionStorage.setItem(KEY, window.scrollY); } catch (_) {} }; | |
| window.addEventListener('scroll', save, { passive: true }); | |
| window.addEventListener('beforeunload', save); | |
| const restore = () => { | |
| const y = parseInt(sessionStorage.getItem(KEY), 10); | |
| if (!isNaN(y)) { | |
| requestAnimationFrame(() => window.scrollTo(0, y)); | |
| setTimeout(() => window.scrollTo(0, y), 60); | |
| } | |
| }; | |
| if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', restore); | |
| else restore(); | |
| const poll = () => | |
| fetch('/version', { cache: 'no-store' }) | |
| .then(r => r.text()) | |
| .then(v => { if (v.trim() && v.trim() !== BUILD_ID) { save(); location.reload(); } else setTimeout(poll, 1000); }) | |
| .catch(() => setTimeout(poll, 1500)); | |
| setTimeout(poll, 1000); | |
| })(); | |
| """ | |
| # ── Rendering ───────────────────────────────────────────────────────────────── | |
| def _run_bikeshed(doc, label: str) -> str | None: | |
| """Preprocess and serialize an already-constructed Bikeshed Spec.""" | |
| if not doc.valid: | |
| console.fail(f"bikeshed could not initialize [bold]{e(label)}[/]") | |
| return None | |
| doc.preprocess() | |
| return doc.serialize() or None | |
| def _render_file(path: Path) -> str | None: | |
| """Render a .bs file from disk via Bikeshed; return HTML or None.""" | |
| console.step(f"bikeshed: rendering [bold]{e(path.name)}[/]…") | |
| try: | |
| from bikeshed import Spec | |
| return _run_bikeshed(Spec(str(path)), path.name) | |
| except Exception: | |
| console.fail(traceback.format_exc()) | |
| return None | |
| def _render_bytes(content: bytes, label: str) -> str | None: | |
| """Render raw .bs bytes via Bikeshed stdin; return HTML or None. | |
| Diagnostics are silenced — the baseline's warnings are not actionable | |
| from the working tree. | |
| """ | |
| console.step(f"bikeshed: rendering [bold]{e(label)}[/]…") | |
| try: | |
| from bikeshed import Spec | |
| from bikeshed.messages import messagesSilent | |
| sys.stdin = io.TextIOWrapper(io.BytesIO(content), encoding="utf-8") | |
| with messagesSilent(): | |
| return _run_bikeshed(Spec("-"), label) | |
| except Exception: | |
| console.fail(traceback.format_exc()) | |
| return None | |
| finally: | |
| sys.stdin = sys.__stdin__ | |
| def _load_baseline(ref: str, path: str) -> HtmlElement | None: | |
| """Fetch path@ref from git and render it; cache the result in _state.""" | |
| if _state.baseline is not None: | |
| return _state.baseline | |
| try: | |
| import git | |
| blob = git.Repo(REPO_DIR).commit(ref).tree / path | |
| content = blob.data_stream.read() | |
| except Exception as ex: | |
| console.fail( | |
| f"git: cannot read [bold]{e(path)}[/] at [bold]{e(ref)}[/]: {e(str(ex))}" | |
| ) | |
| return None | |
| html = _render_bytes(content, f"{path}@{ref}") | |
| if html is None: | |
| return None | |
| _state.baseline = lhtml.fromstring(html).find(".//body") | |
| return _state.baseline | |
| # ── Build ───────────────────────────────────────────────────────────────────── | |
| def _annotate( | |
| new_html: str, baseline_ref: str, baseline_path: str | |
| ) -> lhtml.HtmlElement | None: | |
| """Diff new_html against the baseline and return the annotated document root.""" | |
| baseline = _load_baseline(baseline_ref, baseline_path) | |
| if baseline is None: | |
| return None | |
| new_root = lhtml.fromstring(new_html) | |
| new_body = new_root.find(".//body") | |
| if new_body is None: | |
| console.fail("new document has no <body>") | |
| return None | |
| console.step("diffing…") | |
| # Deep-copy baseline so the cache stays pristine across repeated builds. | |
| _diff_element(copy.deepcopy(baseline), new_body) | |
| head = new_root.find(".//head") | |
| assert head is not None # every Bikeshed document has a <head> | |
| ET.SubElement(head, "style", {"id": "diff-css"}).text = _DIFF_CSS | |
| new_body.insert(0, lhtml.fragment_fromstring(_LEGEND_HTML)) | |
| return new_root | |
| def _serialize(root: lhtml.HtmlElement) -> bytes: | |
| return lhtml.tostring(root, encoding="utf-8", doctype="<!DOCTYPE html>") | |
| def _rebuild( | |
| baseline_ref: str, baseline_path: str, src: Path, output: Path | None | |
| ) -> None: | |
| """Render src, diff against baseline, then publish in memory or write to disk.""" | |
| new_html = _render_file(src) | |
| if new_html is None: | |
| return | |
| root = _annotate(new_html, baseline_ref, baseline_path) | |
| if root is None: | |
| return | |
| if output is None: | |
| build_id = str(time.time_ns()) | |
| body = root.find(".//body") | |
| ET.SubElement(body, "script", {"id": "diff-js"}).text = _RELOAD_JS.replace( | |
| "__BUILD_ID__", build_id | |
| ) | |
| diff = _serialize(root) | |
| with _state_lock: | |
| _state.diff = diff | |
| _state.build_id = build_id | |
| console.done(f"diff ready ({len(diff):,} bytes)") | |
| else: | |
| diff = _serialize(root) | |
| output.write_bytes(diff) | |
| url = output.resolve().as_uri() | |
| console.done( | |
| f"[blue][link={url}]{e(str(output))}[/link][/blue] ({len(diff):,} bytes)" | |
| ) | |
| def _watch(src: Path, baseline_ref: str, baseline_path: str) -> None: | |
| """Poll src for mtime changes and trigger a rebuild on each change.""" | |
| last_mtime = src.stat().st_mtime | |
| while True: | |
| time.sleep(_POLL_INTERVAL) | |
| try: | |
| mtime = src.stat().st_mtime | |
| except FileNotFoundError: | |
| console.fail(f"[bold]{e(src.name)}[/] disappeared") | |
| return | |
| if mtime == last_mtime: | |
| continue | |
| last_mtime = mtime | |
| console.step(f"[bold]{e(src.name)}[/] changed") | |
| try: | |
| _rebuild(baseline_ref, baseline_path, src, output=None) | |
| except Exception: | |
| console.fail(traceback.format_exc()) | |
| # ── HTTP server ─────────────────────────────────────────────────────────────── | |
| _BUILDING = "building…".encode() | |
| _MIME: dict[str, str] = { | |
| ".css": "text/css", | |
| ".js": "text/javascript", | |
| ".svg": "image/svg+xml", | |
| ".png": "image/png", | |
| } | |
| class _Handler(http.server.BaseHTTPRequestHandler): | |
| def log_message(self, format: str, *args: object) -> None: | |
| pass # suppress per-request console noise | |
| def _respond(self, body: bytes, content_type: str, status: int = 200) -> None: | |
| self.send_response(status) | |
| self.send_header("Content-Type", content_type) | |
| self.send_header("Content-Length", str(len(body))) | |
| self.send_header("Cache-Control", "no-store") | |
| self.end_headers() | |
| if self.command != "HEAD": | |
| self.wfile.write(body) | |
| def do_GET(self) -> None: | |
| path = self.path.split("?", 1)[0] | |
| if path == "/version": | |
| with _state_lock: | |
| self._respond(_state.build_id.encode(), "text/plain; charset=utf-8") | |
| return | |
| if path in ("/", "/diff.html"): | |
| with _state_lock: | |
| body = _state.diff | |
| if body is None: | |
| self._respond(_BUILDING, "text/plain; charset=utf-8", 503) | |
| else: | |
| self._respond(body, "text/html; charset=utf-8") | |
| return | |
| # Serve static assets (FileAPI.css, dfn.js, …) from the repo root. | |
| candidate = (REPO_DIR / path.lstrip("/")).resolve() | |
| if candidate.is_file() and candidate.is_relative_to(REPO_DIR): | |
| self._respond( | |
| candidate.read_bytes(), | |
| _MIME.get(candidate.suffix, "application/octet-stream"), | |
| ) | |
| else: | |
| self._respond(b"not found", "text/plain", 404) | |
| do_HEAD = do_GET | |
| class _Server(socketserver.ThreadingMixIn, http.server.HTTPServer): | |
| daemon_threads = True | |
| allow_reuse_address = True | |
| # ── CLI ─────────────────────────────────────────────────────────────────────── | |
| app = typer.Typer( | |
| add_completion=False, | |
| context_settings={"help_option_names": ["-h", "--help"]}, | |
| ) | |
| @app.command() | |
| def main( | |
| spec_path: str = typer.Argument( | |
| default="index.bs", | |
| metavar="<path_to_spec.bs>", | |
| help=( | |
| "Path to the .bs file to watch, relative to the repo root. " | |
| "If the single argument given does not end in .bs, it is treated as the base ref." | |
| ), | |
| ), | |
| base_ref: str = typer.Argument( | |
| default="main", | |
| metavar="<base_ref>", | |
| help="Git ref for the baseline: branch, tag, full or truncated SHA1, or remote ref (e.g. origin/main).", | |
| ), | |
| output: Path | None = typer.Option( | |
| None, | |
| "-o", | |
| "--output", | |
| metavar="<output.html>", | |
| help="Write a static diff file and exit (no watching, no live reload).", | |
| ), | |
| port: int = typer.Option( | |
| _DEFAULT_PORT, | |
| "-p", | |
| "--port", | |
| metavar="<num>", | |
| help="Port to serve on.", | |
| ), | |
| bind: str = typer.Option( | |
| "localhost", | |
| "-b", | |
| "--bind", | |
| metavar="<address>", | |
| help="Address to listen on (hostname, IPv4, or IPv6).", | |
| ), | |
| color: str = typer.Option( | |
| "auto", | |
| "--color", | |
| metavar="<always|never|auto>", | |
| help="Color output: always, never, or auto (default).", | |
| ), | |
| ) -> None: | |
| """Render a Bikeshed spec, diff it against a git baseline, and serve the result.""" | |
| console.configure(color) | |
| # Single non-.bs argument: treat it as the ref, not the path. | |
| if not spec_path.endswith(".bs"): | |
| spec_path, base_ref = "index.bs", spec_path | |
| src = Path(spec_path) if Path(spec_path).is_absolute() else REPO_DIR / spec_path | |
| if not src.exists(): | |
| console.fail(f"[bold]{e(str(src))}[/] not found") | |
| raise typer.Exit(1) | |
| console.step(f"[bold]{e(src.name)}[/] vs [bold]{e(spec_path)}@{e(base_ref)}[/]") | |
| try: | |
| _rebuild(base_ref, spec_path, src, output) | |
| except Exception: | |
| console.fail(traceback.format_exc()) | |
| if output is not None: | |
| return | |
| threading.Thread( | |
| target=_watch, args=(src, base_ref, spec_path), daemon=True | |
| ).start() | |
| server = _Server((bind, port), _Handler) | |
| console.done( | |
| f"serving at [blue][link=http://{bind}:{port}/]http://{bind}:{port}/[/link][/blue]" | |
| ) | |
| try: | |
| server.serve_forever() | |
| except KeyboardInterrupt: | |
| console.step("stopping.") | |
| server.shutdown() | |
| if __name__ == "__main__": | |
| # Strip `--` separators that orchestrators (uv run, shell wrappers) may inject. | |
| sys.argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a != "--"] | |
| app() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment