5ef2af6a9f
Establish the LLeMbas foundation: FastAPI/Jinja/SQLite layout, the ORM schema, and the original SVG identity. Notable decisions, all recorded in comments at the point they matter: - No Alembic. SQLite only, schema created at startup, so models carry a few columns nothing reads yet (Message.parent_id for branching, content_parts_json for multimodal turns). Adding them later to a live database without migrations is the painful path. - Sessions are server-side rows keyed by a SHA-256 of the cookie value, not JWTs, so logout and bans revoke access immediately. - Upstream API keys are Fernet-encrypted with a key derived from LEMBAS_SECRET_KEY. decrypt() fails soft to "" so rotating the secret degrades to re-entering keys rather than crashing the admin UI. - Artwork is generated by scripts/build_artwork.py rather than hand-drawn per file: the mallorn leaf appears in the icon, favicon, lockup and banner, and one source is the only way those stay in sync. The wordmark is Source Serif 4 (OFL) converted to outlines, because a README banner cannot load a webfont and <text> would render in whatever serif the viewer happens to have. - Icons live in a template partial, not assets/, because same-document <use href="#id"> is universally supported and the cross-document form is not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
474 lines
17 KiB
Python
474 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate every LLeMbas SVG asset from one source of truth.
|
|
|
|
Why a generator rather than five hand-written files: the leaf mark appears in
|
|
the icon, the favicon, the lockup and the banner. Keeping the geometry in one
|
|
place is the only way those stay identical as the mark is tuned.
|
|
|
|
Why the wordmark is outlines and not <text>: a README banner on GitHub or Gitea
|
|
cannot load a webfont, so <text> would render in whatever serif the viewer
|
|
happens to have. Outlines look the same everywhere. Letterforms come from
|
|
Source Serif 4 (Adobe, SIL OFL 1.1); only the handful of glyphs actually used
|
|
are extracted, as a static drawing -- no font binary is redistributed.
|
|
|
|
This is a design-time tool. The application never imports it, and the generated
|
|
files are committed. Re-run it only when the artwork itself changes:
|
|
|
|
pip install fonttools
|
|
python scripts/build_artwork.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import random
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from fontTools.pens.boundsPen import BoundsPen
|
|
from fontTools.pens.svgPathPen import SVGPathPen
|
|
from fontTools.pens.transformPen import TransformPen
|
|
from fontTools.ttLib import TTFont
|
|
except ImportError: # pragma: no cover - design-time tool
|
|
sys.exit("fontTools is required for this script: pip install fonttools")
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
ASSETS = ROOT / "assets"
|
|
|
|
FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf")
|
|
FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf")
|
|
|
|
WORDMARK = "LLeMbas"
|
|
TAGLINE = "Waybread for the long road of thought"
|
|
# The capitals of LLeMbas spell LLM. Those three glyphs carry the accent colour.
|
|
ACCENT_GLYPHS = frozenset({0, 1, 3})
|
|
|
|
# --- Palette -----------------------------------------------------------------
|
|
GOLD_LIGHT = "#EACB74"
|
|
GOLD = "#C9A227"
|
|
GOLD_DARK = "#916F13"
|
|
GOLD_SCORE = "#7A5C10"
|
|
GOLD_HILIGHT = "#F6E3A8"
|
|
RUNE_GOLD = "#E0B252"
|
|
|
|
LEAF_EDGE = "#93A5B6"
|
|
LEAF_LIGHT = "#F1F6FA"
|
|
LEAF_MID = "#B8C7D5"
|
|
LEAF_VEIN = "#61758A"
|
|
LEAF_STEM = "#8A9AA8"
|
|
|
|
NIGHT_TOP = "#080B0F"
|
|
NIGHT_MID = "#101822"
|
|
NIGHT_LOW = "#1A2530"
|
|
PARCHMENT = "#EDE6D6"
|
|
INK = "#1B1F23"
|
|
MUTED = "#9AA7B4"
|
|
|
|
# --- The mallorn leaf --------------------------------------------------------
|
|
# Drawn once, in a 64x64 box, and reused everywhere. Tuned so the silhouette
|
|
# still reads as a leaf at 16px, where veins and score lines disappear.
|
|
LEAF_BLADE = (
|
|
"M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z"
|
|
)
|
|
LEAF_MIDRIB = "M20.5 45.5 C28 38 36 29 45.5 18.5"
|
|
LEAF_STEM_PATH = "M21.2 44.8 L17 49.4"
|
|
LEAF_VEINS = [
|
|
"M26.9 38.8 Q25.2 35.8 24.9 32.1",
|
|
"M32.3 33.1 Q30.9 30.3 30.4 26.9",
|
|
"M37.8 27.0 Q36.6 24.6 36.3 21.7",
|
|
"M26.9 38.8 Q30.5 40.1 33.7 40.3",
|
|
"M32.3 33.1 Q35.8 34.3 38.6 34.5",
|
|
"M37.8 27.0 Q40.8 27.9 43.2 28.1",
|
|
]
|
|
|
|
HEADER = '<svg xmlns="http://www.w3.org/2000/svg"'
|
|
|
|
|
|
# --- Type --------------------------------------------------------------------
|
|
class TextRun:
|
|
"""A string converted to SVG outlines, positioned with the baseline at y=0."""
|
|
|
|
def __init__(self, font_path: Path, text: str, cap_px: float):
|
|
if not font_path.exists():
|
|
sys.exit(f"font not found: {font_path}")
|
|
font = TTFont(font_path)
|
|
cmap = font.getBestCmap()
|
|
glyph_set = font.getGlyphSet()
|
|
hmtx = font["hmtx"]
|
|
# Scale to cap height rather than em size: it is what the eye measures.
|
|
cap_height = getattr(font["OS/2"], "sCapHeight", None) or font["head"].unitsPerEm * 0.7
|
|
scale = cap_px / cap_height
|
|
|
|
self.glyphs: list[dict] = []
|
|
pen_x = 0.0
|
|
x0 = y0 = float("inf")
|
|
x1 = y1 = float("-inf")
|
|
|
|
for index, char in enumerate(text):
|
|
if char == " ":
|
|
pen_x += hmtx[cmap[ord(" ")]][0] * scale
|
|
continue
|
|
name = cmap.get(ord(char))
|
|
if name is None:
|
|
sys.exit(f"{font_path.name} has no glyph for {char!r}")
|
|
|
|
# Font coordinates run upwards; SVG's run downwards, hence -scale.
|
|
transform = (scale, 0, 0, -scale, pen_x, 0)
|
|
svg_pen = SVGPathPen(glyph_set, ntos=lambda v: f"{v:.2f}")
|
|
glyph_set[name].draw(TransformPen(svg_pen, transform))
|
|
bounds_pen = BoundsPen(glyph_set)
|
|
glyph_set[name].draw(TransformPen(bounds_pen, transform))
|
|
|
|
if bounds_pen.bounds:
|
|
gx0, gy0, gx1, gy1 = bounds_pen.bounds
|
|
x0, y0 = min(x0, gx0), min(y0, gy0)
|
|
x1, y1 = max(x1, gx1), max(y1, gy1)
|
|
|
|
self.glyphs.append(
|
|
{"char": char, "index": index, "path": svg_pen.getCommands()}
|
|
)
|
|
pen_x += hmtx[name][0] * scale
|
|
|
|
self.x0, self.y0, self.x1, self.y1 = x0, y0, x1, y1
|
|
self.width = x1 - x0
|
|
self.height = y1 - y0
|
|
|
|
def paths(
|
|
self,
|
|
accent_indices: frozenset[int] = frozenset(),
|
|
*,
|
|
indent: str = " ",
|
|
base_class: str = "base",
|
|
accent_class: str = "accent",
|
|
) -> str:
|
|
"""Render the glyphs, offset so the run's left edge sits at x=0.
|
|
|
|
Class names are caller-supplied because <style> inside an SVG is scoped
|
|
to the whole document, not to the group it sits in. Two runs in one file
|
|
sharing a class name means the second rule silently recolours the first.
|
|
"""
|
|
out = []
|
|
for glyph in self.glyphs:
|
|
cls = accent_class if glyph["index"] in accent_indices else base_class
|
|
out.append(
|
|
f'{indent}<path class="{cls}" data-char="{glyph["char"]}" '
|
|
f'd="{glyph["path"]}"/>'
|
|
)
|
|
return "\n".join(out)
|
|
|
|
@property
|
|
def origin_shift(self) -> str:
|
|
"""Transform placing the run's top-left at the current origin."""
|
|
return f"translate({-self.x0:.2f} {-self.y0:.2f})"
|
|
|
|
|
|
def type_style(indent: str = " ") -> str:
|
|
"""Colour rules for outlined type.
|
|
|
|
The CSS variables let the application theme the type when the SVG is
|
|
inlined into a page. The literal fallbacks matter just as much: opened as a
|
|
standalone file or loaded through <img>, no page CSS reaches the document,
|
|
so the media query is the only thing keeping the wordmark legible on a dark
|
|
background.
|
|
"""
|
|
return f"""{indent}<style>
|
|
{indent} .base {{ fill: var(--lembas-ink, {INK}); }}
|
|
{indent} .accent {{ fill: var(--lembas-gold, {GOLD}); }}
|
|
{indent} @media (prefers-color-scheme: dark) {{
|
|
{indent} .base {{ fill: var(--lembas-ink, {PARCHMENT}); }}
|
|
{indent} .accent {{ fill: var(--lembas-gold, {RUNE_GOLD}); }}
|
|
{indent} }}
|
|
{indent}</style>"""
|
|
|
|
|
|
# --- The mark ----------------------------------------------------------------
|
|
def mark_defs(prefix: str) -> str:
|
|
return f""" <defs>
|
|
<linearGradient id="{prefix}-wafer" x1="0" y1="0" x2="0.3" y2="1">
|
|
<stop offset="0" stop-color="{GOLD_LIGHT}"/>
|
|
<stop offset="0.5" stop-color="{GOLD}"/>
|
|
<stop offset="1" stop-color="{GOLD_DARK}"/>
|
|
</linearGradient>
|
|
<linearGradient id="{prefix}-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
|
|
<stop offset="0" stop-color="{LEAF_EDGE}"/>
|
|
<stop offset="0.4" stop-color="{LEAF_LIGHT}"/>
|
|
<stop offset="1" stop-color="{LEAF_MID}"/>
|
|
</linearGradient>
|
|
<clipPath id="{prefix}-clip">
|
|
<rect x="6" y="6" width="52" height="52" rx="13"/>
|
|
</clipPath>
|
|
</defs>"""
|
|
|
|
|
|
def mark_body(prefix: str, *, detail: bool = True) -> str:
|
|
"""The wafer-and-leaf mark in a 64x64 box.
|
|
|
|
detail=False drops the score lines, rim and veins for small-size use.
|
|
"""
|
|
parts = [f' <rect x="6" y="6" width="52" height="52" rx="13" fill="url(#{prefix}-wafer)"/>']
|
|
|
|
if detail:
|
|
parts.append(f""" <g clip-path="url(#{prefix}-clip)" fill="none" stroke-linecap="round">
|
|
<g stroke="{GOLD_SCORE}" stroke-opacity="0.38" stroke-width="2">
|
|
<path d="M32 6 V58"/>
|
|
<path d="M6 32 H58"/>
|
|
</g>
|
|
<g stroke="{GOLD_HILIGHT}" stroke-opacity="0.3" stroke-width="1">
|
|
<path d="M33.2 6 V58"/>
|
|
<path d="M6 33.2 H58"/>
|
|
</g>
|
|
</g>
|
|
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9"
|
|
fill="none" stroke="{GOLD_SCORE}" stroke-opacity="0.3" stroke-width="1.2"/>""")
|
|
|
|
parts.append(f""" <g>
|
|
<path d="{LEAF_STEM_PATH}" stroke="{LEAF_STEM}" stroke-width="3"
|
|
stroke-linecap="round" fill="none"/>
|
|
<path d="{LEAF_BLADE}" fill="url(#{prefix}-leaf)"/>
|
|
<path d="{LEAF_MIDRIB}" fill="none" stroke="{LEAF_VEIN}" stroke-opacity="0.5"
|
|
stroke-width="1.5" stroke-linecap="round"/>""")
|
|
|
|
if detail:
|
|
veins = "\n".join(f' <path d="{v}"/>' for v in LEAF_VEINS)
|
|
parts.append(f""" <g fill="none" stroke="{LEAF_VEIN}" stroke-opacity="0.32"
|
|
stroke-width="1" stroke-linecap="round">
|
|
{veins}
|
|
</g>""")
|
|
|
|
parts.append(" </g>")
|
|
return "\n".join(parts)
|
|
|
|
|
|
# --- Asset builders ----------------------------------------------------------
|
|
def build_logo_mark() -> str:
|
|
return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64"
|
|
role="img" aria-label="LLeMbas">
|
|
<title>LLeMbas</title>
|
|
<desc>A silver mallorn leaf laid across a scored golden lembas wafer.</desc>
|
|
{mark_defs("m")}
|
|
{mark_body("m")}
|
|
</svg>
|
|
"""
|
|
|
|
|
|
def build_favicon() -> str:
|
|
"""Small-size variant: no score lines or veins, larger blade, tighter tile."""
|
|
return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64"
|
|
role="img" aria-label="LLeMbas">
|
|
<title>LLeMbas</title>
|
|
{mark_defs("f")}
|
|
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#f-wafer)"/>
|
|
<g transform="translate(32 32) scale(1.16) translate(-32 -32)">
|
|
<path d="M20.6 44.6 L15.6 50.1" stroke="{LEAF_STEM}" stroke-width="3.4"
|
|
stroke-linecap="round" fill="none"/>
|
|
<path d="{LEAF_BLADE}" fill="url(#f-leaf)"/>
|
|
<path d="{LEAF_MIDRIB}" fill="none" stroke="{LEAF_VEIN}" stroke-opacity="0.45"
|
|
stroke-width="1.8" stroke-linecap="round"/>
|
|
</g>
|
|
</svg>
|
|
"""
|
|
|
|
|
|
def build_wordmark() -> str:
|
|
"""Standalone type. Inherits colour so it can sit on any background."""
|
|
run = TextRun(FONT_SEMIBOLD, WORDMARK, 100)
|
|
return f"""{HEADER} viewBox="0 0 {run.width:.2f} {run.height:.2f}"
|
|
width="{run.width:.2f}" height="{run.height:.2f}" role="img" aria-label="LLeMbas">
|
|
<title>LLeMbas</title>
|
|
<!-- Source Serif 4 (SIL OFL 1.1) outlines. The capitals L, L and M spell out
|
|
LLM and take the accent colour; see scripts/build_artwork.py. -->
|
|
{type_style(" ")}
|
|
<g transform="{run.origin_shift}">
|
|
{run.paths(ACCENT_GLYPHS, indent=" ")}
|
|
</g>
|
|
</svg>
|
|
"""
|
|
|
|
|
|
def build_lockup() -> str:
|
|
"""Horizontal mark + wordmark, for the application header."""
|
|
cap = 46.0
|
|
run = TextRun(FONT_SEMIBOLD, WORDMARK, cap)
|
|
mark_size = 64.0
|
|
gap = 20.0
|
|
pad = 4.0
|
|
|
|
height = mark_size + pad * 2
|
|
text_x = pad + mark_size + gap
|
|
# Optically centre on the cap height rather than the full glyph bounds, so
|
|
# the ascender of "b" and the overshoot of "e" do not shift the baseline.
|
|
baseline_y = height / 2 + cap / 2
|
|
width = text_x + run.width + pad
|
|
|
|
return f"""{HEADER} viewBox="0 0 {width:.2f} {height:.2f}"
|
|
width="{width:.2f}" height="{height:.2f}" role="img" aria-label="LLeMbas">
|
|
<title>LLeMbas</title>
|
|
{mark_defs("l")}
|
|
{type_style(" ")}
|
|
<g transform="translate({pad} {pad})">
|
|
{mark_body("l")}
|
|
</g>
|
|
<g transform="translate({text_x - run.x0:.2f} {baseline_y:.2f})">
|
|
{run.paths(ACCENT_GLYPHS, indent=" ")}
|
|
</g>
|
|
</svg>
|
|
"""
|
|
|
|
|
|
def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str:
|
|
"""One jagged ridge line spanning the full width."""
|
|
rng = random.Random(seed)
|
|
points = [(0.0, base_y)]
|
|
x = 0.0
|
|
while x < width:
|
|
step = rng.uniform(width * 0.045, width * 0.11)
|
|
x = min(x + step, width)
|
|
peak = base_y - rng.uniform(height * 0.35, height)
|
|
points.append((x, peak))
|
|
# A short shoulder after each peak keeps the ridge from looking like a saw.
|
|
if x < width:
|
|
x = min(x + rng.uniform(width * 0.01, width * 0.03), width)
|
|
points.append((x, peak + rng.uniform(height * 0.08, height * 0.25)))
|
|
points.append((width, base_y))
|
|
coords = " ".join(f"{px:.1f},{py:.1f}" for px, py in points)
|
|
return f' <polygon points="{coords} {width:.0f},999 0,999" fill="{colour}"/>'
|
|
|
|
|
|
def _stars(width: float, height: float, count: int, seed: int) -> str:
|
|
rng = random.Random(seed)
|
|
out = []
|
|
for _ in range(count):
|
|
sx = rng.uniform(0, width)
|
|
sy = rng.uniform(0, height)
|
|
r = rng.uniform(0.6, 1.9)
|
|
opacity = rng.uniform(0.18, 0.85)
|
|
out.append(
|
|
f' <circle cx="{sx:.1f}" cy="{sy:.1f}" r="{r:.2f}" opacity="{opacity:.2f}"/>'
|
|
)
|
|
return "\n".join(out)
|
|
|
|
|
|
def _drifting_leaves(seed: int) -> str:
|
|
"""A few mallorn leaves adrift in the sky, well behind the type."""
|
|
rng = random.Random(seed)
|
|
placements = [
|
|
(120, 90, 0.42, -18), (250, 250, 0.30, 24), (1035, 95, 0.36, 12),
|
|
(1160, 215, 0.46, -32), (905, 300, 0.26, 40), (185, 300, 0.24, -8),
|
|
]
|
|
out = []
|
|
for cx, cy, scale, rot in placements:
|
|
opacity = rng.uniform(0.10, 0.19)
|
|
out.append(
|
|
f' <g transform="translate({cx} {cy}) rotate({rot}) '
|
|
f'scale({scale}) translate(-32 -32)" opacity="{opacity:.2f}">'
|
|
f'<path d="{LEAF_BLADE}" fill="{RUNE_GOLD}"/></g>'
|
|
)
|
|
return "\n".join(out)
|
|
|
|
|
|
def build_banner() -> str:
|
|
"""README hero.
|
|
|
|
Carries its own dark background rather than relying on the page, because a
|
|
README is rendered on a light background as often as a dark one.
|
|
"""
|
|
width, height = 1280.0, 420.0
|
|
cap = 92.0
|
|
run = TextRun(FONT_SEMIBOLD, WORDMARK, cap)
|
|
tag = TextRun(FONT_ITALIC, TAGLINE, 26.0)
|
|
|
|
mark_size = 136.0
|
|
gap = 34.0
|
|
lockup_w = mark_size + gap + run.width
|
|
lockup_x = (width - lockup_w) / 2
|
|
baseline_y = 232.0
|
|
mark_y = baseline_y - cap / 2 - mark_size / 2
|
|
|
|
tag_x = (width - tag.width) / 2 - tag.x0
|
|
tag_y = baseline_y + 68.0
|
|
|
|
mark_scale = mark_size / 64.0
|
|
|
|
return f"""{HEADER} viewBox="0 0 {width:.0f} {height:.0f}"
|
|
width="{width:.0f}" height="{height:.0f}" role="img"
|
|
aria-label="LLeMbas - {TAGLINE}">
|
|
<title>LLeMbas</title>
|
|
<desc>{TAGLINE}. A mallorn leaf and wafer above the mountains at night.</desc>
|
|
<defs>
|
|
<linearGradient id="b-sky" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0" stop-color="{NIGHT_TOP}"/>
|
|
<stop offset="0.62" stop-color="{NIGHT_MID}"/>
|
|
<stop offset="1" stop-color="{NIGHT_LOW}"/>
|
|
</linearGradient>
|
|
<radialGradient id="b-glow" cx="0.5" cy="0.54" r="0.5">
|
|
<stop offset="0" stop-color="{GOLD}" stop-opacity="0.22"/>
|
|
<stop offset="1" stop-color="{GOLD}" stop-opacity="0"/>
|
|
</radialGradient>
|
|
<!-- Cool light sitting just above the ridge line, so the far mountains
|
|
separate from the near ones instead of merging into one dark mass. -->
|
|
<radialGradient id="b-horizon" cx="0.5" cy="1" r="0.72">
|
|
<stop offset="0" stop-color="#4E6C86" stop-opacity="0.30"/>
|
|
<stop offset="1" stop-color="#4E6C86" stop-opacity="0"/>
|
|
</radialGradient>
|
|
{mark_defs("b").removeprefix(" <defs>").removesuffix(" </defs>").rstrip()}
|
|
</defs>
|
|
|
|
<rect width="{width:.0f}" height="{height:.0f}" fill="url(#b-sky)"/>
|
|
<g fill="#FFFFFF">
|
|
{_stars(width, 300, 130, 11)}
|
|
</g>
|
|
<rect y="180" width="{width:.0f}" height="240" fill="url(#b-horizon)"/>
|
|
<rect width="{width:.0f}" height="{height:.0f}" fill="url(#b-glow)"/>
|
|
{_drifting_leaves(5)}
|
|
|
|
<!-- Ridge lines, furthest first. Each is lighter than the one in front of it,
|
|
which is what reads as distance. -->
|
|
{_mountains(width, 366, 3, 150, "#1C2836")}
|
|
{_mountains(width, 392, 8, 112, "#111A25")}
|
|
{_mountains(width, 416, 21, 74, "#080D13")}
|
|
<rect y="{height - 5:.0f}" width="{width:.0f}" height="5" fill="{GOLD}" opacity="0.55"/>
|
|
|
|
<!-- Lockup. Colours are fixed rather than themed: the banner carries its own
|
|
night sky, so it must not follow the reader's colour scheme. -->
|
|
<g transform="translate({lockup_x:.2f} {mark_y:.2f}) scale({mark_scale:.4f})">
|
|
{mark_body("b")}
|
|
</g>
|
|
<g transform="translate({lockup_x + mark_size + gap - run.x0:.2f} {baseline_y:.2f})">
|
|
<style>.base {{ fill: {PARCHMENT}; }} .accent {{ fill: {RUNE_GOLD}; }}</style>
|
|
{run.paths(ACCENT_GLYPHS, indent=" ")}
|
|
</g>
|
|
<g transform="translate({tag_x:.2f} {tag_y:.2f})">
|
|
<style>.tag {{ fill: {MUTED}; }}</style>
|
|
{tag.paths(indent=" ", base_class="tag")}
|
|
</g>
|
|
</svg>
|
|
"""
|
|
|
|
|
|
# --- Entry point -------------------------------------------------------------
|
|
BUILDERS = {
|
|
"logo-mark.svg": build_logo_mark,
|
|
"favicon.svg": build_favicon,
|
|
"wordmark.svg": build_wordmark,
|
|
"logo-lockup.svg": build_lockup,
|
|
"banner.svg": build_banner,
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--out", type=Path, default=ASSETS)
|
|
parser.add_argument("--only", nargs="*", choices=sorted(BUILDERS), default=None)
|
|
args = parser.parse_args()
|
|
|
|
args.out.mkdir(parents=True, exist_ok=True)
|
|
for filename in args.only or BUILDERS:
|
|
path = args.out / filename
|
|
path.write_text(BUILDERS[filename](), encoding="utf-8")
|
|
print(f"wrote {path.relative_to(ROOT)} ({path.stat().st_size:,} bytes)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|