Skip to content

Commit 77e92e8

Browse files
etjonesclaude
andcommitted
Add fontpkg-gen site: static specimen site with live type demo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d8bc704 commit 77e92e8

8 files changed

Lines changed: 406 additions & 0 deletions

File tree

.claude/launch.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"version": "0.0.1",
3+
"configurations": [
4+
{
5+
"name": "specimen-site",
6+
"runtimeExecutable": "python3",
7+
"runtimeArgs": ["-m", "http.server", "8734", "--directory", "site-dist"],
8+
"port": 8734
9+
}
10+
]
11+
}

.github/workflows/pages.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Publish specimen site
2+
3+
on:
4+
workflow_dispatch:
5+
workflow_run:
6+
workflows: ["Sync font packages"]
7+
types: [completed]
8+
9+
permissions:
10+
contents: read
11+
pages: write
12+
id-token: write
13+
14+
concurrency:
15+
group: pages
16+
cancel-in-progress: true
17+
18+
jobs:
19+
build:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
- uses: astral-sh/setup-uv@v5
24+
with:
25+
python-version: "3.13"
26+
- run: uv sync --all-packages
27+
- name: Build all font packages
28+
env:
29+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30+
run: uv run fontpkg-gen build $(grep -v '^#' families.txt | tr '\n' ' ') --out build
31+
- name: Generate site
32+
run: uv run fontpkg-gen site --catalog catalog.json --packages build --out site-dist
33+
- uses: actions/configure-pages@v5
34+
- uses: actions/upload-pages-artifact@v3
35+
with:
36+
path: site-dist
37+
- id: deployment
38+
uses: actions/deploy-pages@v4

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ dist/
77
.pytest_cache/
88
scratch/
99
.env
10+
site-dist/

CONVERSATION.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,12 @@ Open decisions: project/package naming (`fontgrab` vs `pyfontsource`), PyPI scal
130130
**User:** Suggested fontpkg-<name> packages depend on fontpkg core, so one `uv add fontpkg-inter` suffices.
131131

132132
**Claude:** Agreed (clean dependency direction, core guarantees schema back-compat) and implemented: generated pyproject now has dependencies = ["fontpkg>=0.1.0"]; added --post N flag to fontpkg-gen build for republishing already-released names as .postN without an upstream bump. 46 tests passing. Republish of the already-published packages queued behind the still-running PyPI throttle retry job.
133+
134+
---
135+
136+
**User:** Asked to start a static specimen site with live-typing demo + filtering; asked whether to serve fonts via fontpkg's own files or Fontsource; open to a separate repo.
137+
138+
**Claude:** Recommended serving fontpkg's own TTFs (the site's promise is "this is exactly what uv add gives you"; browsers render TTF/VF natively; fontsource CDN would demo their subsetted builds) and staying in the same repo. Built:
139+
- `fontpkg-gen site`: generates a static site from catalog.json + built packages — copies font binaries (bracket-free names), emits @font-face rules (truetype-variations with weight ranges for VFs), embeds the catalog as JSON in a vanilla-JS page.
140+
- Page features: live-typing preview (global input + per-card contenteditable), substring family filter, category chips, global weight slider driving VF axes, italic toggle, uv-add copy buttons, lazy font loading via IntersectionObserver (30MB of fonts load only as cards scroll into view), dark/light theme.
141+
- pages.yml: deploys to GitHub Pages on dispatch and after each font sync; enabled Pages (build_type=workflow) via API. 48 tests passing. Verified live in browser: typing, filtering, weight slider all work.

generator/src/fontpkg_generator/cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from fontpkg_generator.build import SourceInfo, UnsupportedLicense, build_package
88
from fontpkg_generator.gh import REPO_URL, FamilyNotFound, fetch_family
9+
from fontpkg_generator.site import build_site
910
from fontpkg_generator.sync import sync_families
1011

1112

@@ -32,9 +33,17 @@ def main(argv: list[str] | None = None) -> int:
3233
sync.add_argument("--catalog", type=Path, default=Path("catalog.json"))
3334
sync.add_argument("--out", type=Path, default=Path("build"))
3435
sync.add_argument("--wheel", action="store_true", help="also build wheels with uv build")
36+
site = sub.add_parser("site", help="generate the static specimen site")
37+
site.add_argument("--catalog", type=Path, default=Path("catalog.json"))
38+
site.add_argument("--packages", type=Path, default=Path("build"))
39+
site.add_argument("--out", type=Path, default=Path("site-dist"))
3540
args = parser.parse_args(argv)
3641
if args.command == "sync":
3742
return _sync(args)
43+
if args.command == "site":
44+
out = build_site(args.catalog, args.packages, args.out)
45+
print(f"site written to {out}")
46+
return 0
3847
return _build(args)
3948

4049

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import importlib.resources
2+
import json
3+
import shutil
4+
from pathlib import Path
5+
6+
7+
def build_site(catalog_path: Path, packages_dir: Path, out_dir: Path) -> Path:
8+
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
9+
fonts_dir = out_dir / "fonts"
10+
fonts_dir.mkdir(parents=True, exist_ok=True)
11+
css_rules: list[str] = []
12+
entries: list[dict] = []
13+
for slug in sorted(catalog):
14+
entry = catalog[slug]
15+
module_dir = _module_dir(packages_dir, entry["package"])
16+
meta = json.loads((module_dir / "metadata.json").read_text(encoding="utf-8"))
17+
for f in meta["files"]:
18+
src = module_dir / f["path"]
19+
dest_name = _dest_name(slug, f)
20+
shutil.copy2(src, fonts_dir / dest_name)
21+
css_rules.append(_font_face(entry, f, dest_name))
22+
entries.append(entry)
23+
template = (
24+
importlib.resources.files("fontpkg_generator") / "site_template.html"
25+
).read_text(encoding="utf-8")
26+
html = template.replace("/*__FONT_FACES__*/", "\n".join(css_rules)).replace(
27+
"__FAMILY_DATA__", json.dumps(entries)
28+
)
29+
(out_dir / "index.html").write_text(html, encoding="utf-8")
30+
(out_dir / ".nojekyll").write_text("", encoding="utf-8")
31+
(out_dir / "catalog.json").write_text(json.dumps(catalog, indent=2), encoding="utf-8")
32+
return out_dir
33+
34+
35+
def _module_dir(packages_dir: Path, package: str) -> Path:
36+
src_dir = packages_dir / package / "src"
37+
if not src_dir.is_dir():
38+
raise FileNotFoundError(f"built package not found: {packages_dir / package}")
39+
return next(src_dir.iterdir())
40+
41+
42+
def _dest_name(slug: str, f: dict) -> str:
43+
style = f.get("style", "normal")
44+
if f.get("variable"):
45+
return f"{slug}-{style}.ttf"
46+
return f"{slug}-{f.get('weight', 400)}-{style}.ttf"
47+
48+
49+
def _font_face(entry: dict, f: dict, dest_name: str) -> str:
50+
family = entry["family"]
51+
style = f.get("style", "normal")
52+
if f.get("variable") and entry.get("weight_range"):
53+
lo, hi = entry["weight_range"]
54+
weight = f"{lo} {hi}"
55+
fmt = "truetype-variations"
56+
else:
57+
weight = str(f.get("weight", 400))
58+
fmt = "truetype"
59+
return (
60+
f'@font-face {{ font-family: "{family}"; src: url("fonts/{dest_name}") '
61+
f'format("{fmt}"); font-weight: {weight}; font-style: {style}; font-display: swap; }}'
62+
)
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>fontpkg — fonts as Python dependencies</title>
7+
<style>
8+
:root {
9+
--bg: #fafaf8; --fg: #1a1a1a; --muted: #6b6b6b; --card: #ffffff;
10+
--border: #e4e2dd; --accent: #2563eb; --accent-fg: #ffffff; --chip: #efede8;
11+
}
12+
@media (prefers-color-scheme: dark) {
13+
:root {
14+
--bg: #12110f; --fg: #ececec; --muted: #9a9a9a; --card: #1c1b18;
15+
--border: #2e2c28; --accent: #3b82f6; --accent-fg: #ffffff; --chip: #262420;
16+
}
17+
}
18+
* { box-sizing: border-box; }
19+
body {
20+
margin: 0; background: var(--bg); color: var(--fg);
21+
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
22+
}
23+
header { max-width: 1100px; margin: 0 auto; padding: 2.5rem 1.25rem 0; }
24+
h1 { font-size: 1.6rem; margin: 0 0 .25rem; }
25+
h1 code { font-family: ui-monospace, monospace; }
26+
.tagline { color: var(--muted); margin: 0 0 1.5rem; }
27+
.tagline a { color: var(--accent); text-decoration: none; }
28+
.controls {
29+
max-width: 1100px; margin: 0 auto; padding: 0 1.25rem 1rem;
30+
display: flex; flex-wrap: wrap; gap: .75rem; align-items: center;
31+
position: sticky; top: 0; background: var(--bg); padding-top: 1rem; z-index: 5;
32+
border-bottom: 1px solid var(--border);
33+
}
34+
.controls input[type="text"] {
35+
background: var(--card); color: var(--fg); border: 1px solid var(--border);
36+
border-radius: 8px; padding: .55rem .8rem; font-size: 1rem;
37+
}
38+
#search { width: 14rem; }
39+
#preview { flex: 1 1 16rem; }
40+
.chips { display: flex; gap: .4rem; flex-wrap: wrap; }
41+
.chip {
42+
background: var(--chip); border: 1px solid var(--border); color: var(--fg);
43+
border-radius: 999px; padding: .3rem .8rem; font-size: .85rem; cursor: pointer;
44+
}
45+
.chip.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
46+
.slider-group { display: flex; align-items: center; gap: .5rem; font-size: .85rem; color: var(--muted); }
47+
.grid {
48+
max-width: 1100px; margin: 0 auto; padding: 1.25rem;
49+
display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem;
50+
}
51+
.card {
52+
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
53+
padding: 1.1rem 1.25rem; display: flex; flex-direction: column; gap: .6rem;
54+
}
55+
.card .meta { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; }
56+
.card .name { font-weight: 600; }
57+
.card .info { color: var(--muted); font-size: .78rem; text-align: right; }
58+
.card .sample {
59+
font-size: 1.9rem; line-height: 1.25; min-height: 3.2rem; overflow-wrap: anywhere;
60+
outline: none; border: none; padding: 0; background: none; color: var(--fg);
61+
}
62+
.card .install {
63+
display: flex; align-items: center; gap: .5rem; margin-top: auto;
64+
}
65+
.card .install code {
66+
font-family: ui-monospace, monospace; font-size: .82rem; color: var(--muted);
67+
background: var(--chip); border-radius: 6px; padding: .3rem .55rem; flex: 1;
68+
overflow-x: auto; white-space: nowrap;
69+
}
70+
.card .install button {
71+
background: none; border: 1px solid var(--border); color: var(--muted);
72+
border-radius: 6px; padding: .28rem .6rem; font-size: .78rem; cursor: pointer;
73+
}
74+
.card .install button:hover { color: var(--fg); border-color: var(--muted); }
75+
#empty { max-width: 1100px; margin: 2rem auto; padding: 0 1.25rem; color: var(--muted); display: none; }
76+
footer {
77+
max-width: 1100px; margin: 2rem auto 3rem; padding: 0 1.25rem;
78+
color: var(--muted); font-size: .85rem;
79+
}
80+
footer a { color: var(--accent); text-decoration: none; }
81+
/*__FONT_FACES__*/
82+
</style>
83+
</head>
84+
<body>
85+
<header>
86+
<h1><code>fontpkg</code></h1>
87+
<p class="tagline">Fonts as ordinary Python dependencies.
88+
Every specimen below renders from the exact font file the package installs.
89+
<a href="https://github.com/Fontpkg/fontpkg">GitHub</a> ·
90+
<a href="https://pypi.org/project/fontpkg/">PyPI</a></p>
91+
</header>
92+
<div class="controls">
93+
<input id="search" type="text" placeholder="Filter families… (e.g. got)">
94+
<div class="chips" id="categories"></div>
95+
<input id="preview" type="text" placeholder="Type to preview in every font…">
96+
<span class="slider-group">
97+
<label for="weight">weight <b id="weight-val">400</b></label>
98+
<input id="weight" type="range" min="100" max="900" step="10" value="400">
99+
<label><input id="italic" type="checkbox"> italic</label>
100+
</span>
101+
</div>
102+
<div class="grid" id="grid"></div>
103+
<p id="empty">No families match. The catalog grows weekly — request one on
104+
<a href="https://github.com/Fontpkg/fontpkg/issues">GitHub</a>.</p>
105+
<footer>
106+
Fonts are redistributed unmodified under their own licenses (OFL-1.1 / Apache-2.0),
107+
packaged from <a href="https://github.com/google/fonts">google/fonts</a>.
108+
Inspired by <a href="https://fontsource.org">Fontsource</a>.
109+
</footer>
110+
<script>
111+
const FAMILIES = __FAMILY_DATA__;
112+
const DEFAULT_TEXT = "Grumpy wizards make toxic brew for the evil queen and jack.";
113+
const CATEGORY_LABELS = {
114+
SANS_SERIF: "Sans", SERIF: "Serif", MONOSPACE: "Mono",
115+
DISPLAY: "Display", HANDWRITING: "Handwriting",
116+
};
117+
const state = { query: "", category: "ALL", text: "", weight: 400, italic: false };
118+
119+
const grid = document.getElementById("grid");
120+
const cards = new Map();
121+
const lazyLoader = new IntersectionObserver((entries) => {
122+
for (const entry of entries) {
123+
if (!entry.isIntersecting) continue;
124+
const { sample, fam } = cards.get(entry.target.dataset.slug);
125+
sample.style.fontFamily = `"${fam.family}"`;
126+
lazyLoader.unobserve(entry.target);
127+
}
128+
}, { rootMargin: "300px" });
129+
130+
function clamp(value, range) {
131+
if (!range) return value;
132+
return Math.min(Math.max(value, range[0]), range[1]);
133+
}
134+
135+
function buildCard(fam) {
136+
const card = document.createElement("div");
137+
card.className = "card";
138+
const range = fam.weight_range ? `${fam.weight_range[0]}${fam.weight_range[1]}` : "";
139+
const kind = fam.variable ? `variable ${range}` : `${fam.weights.length} weights`;
140+
card.innerHTML = `
141+
<div class="meta">
142+
<span class="name">${fam.family}</span>
143+
<span class="info">${fam.license} · ${kind}</span>
144+
</div>
145+
<div class="sample" contenteditable="true" spellcheck="false"></div>
146+
<div class="install">
147+
<code>uv add ${fam.package}</code>
148+
<button type="button">copy</button>
149+
</div>`;
150+
const sample = card.querySelector(".sample");
151+
card.dataset.slug = fam.slug;
152+
lazyLoader.observe(card);
153+
sample.addEventListener("input", () => { sample.dataset.custom = "1"; });
154+
const button = card.querySelector("button");
155+
button.addEventListener("click", async () => {
156+
await navigator.clipboard.writeText(`uv add ${fam.package}`);
157+
button.textContent = "copied!";
158+
setTimeout(() => { button.textContent = "copy"; }, 1200);
159+
});
160+
cards.set(fam.slug, { card, sample, fam });
161+
return card;
162+
}
163+
164+
function applyText() {
165+
const text = state.text || DEFAULT_TEXT;
166+
for (const { sample } of cards.values()) {
167+
if (!sample.dataset.custom) sample.textContent = text;
168+
}
169+
}
170+
171+
function applyStyle() {
172+
for (const { sample, fam } of cards.values()) {
173+
const weight = clamp(state.weight, fam.weight_range);
174+
sample.style.fontWeight = weight;
175+
const italic = state.italic && fam.styles.includes("italic");
176+
sample.style.fontStyle = italic ? "italic" : "normal";
177+
}
178+
}
179+
180+
function applyFilter() {
181+
const q = state.query.trim().toLowerCase();
182+
let visible = 0;
183+
for (const { card, fam } of cards.values()) {
184+
const matchesQuery =
185+
!q || fam.family.toLowerCase().includes(q) || fam.slug.includes(q);
186+
const matchesCategory =
187+
state.category === "ALL" || (fam.category || []).includes(state.category);
188+
const show = matchesQuery && matchesCategory;
189+
card.style.display = show ? "" : "none";
190+
if (show) visible += 1;
191+
}
192+
document.getElementById("empty").style.display = visible ? "none" : "block";
193+
}
194+
195+
const seen = new Set(FAMILIES.flatMap((f) => f.category || []));
196+
const chipHost = document.getElementById("categories");
197+
const chips = [["ALL", "All"], ...Object.entries(CATEGORY_LABELS).filter(([k]) => seen.has(k))];
198+
for (const [value, label] of chips) {
199+
const chip = document.createElement("button");
200+
chip.className = "chip" + (value === "ALL" ? " active" : "");
201+
chip.textContent = label;
202+
chip.addEventListener("click", () => {
203+
state.category = value;
204+
chipHost.querySelectorAll(".chip").forEach((c) => c.classList.remove("active"));
205+
chip.classList.add("active");
206+
applyFilter();
207+
});
208+
chipHost.appendChild(chip);
209+
}
210+
211+
for (const fam of FAMILIES) grid.appendChild(buildCard(fam));
212+
applyText();
213+
applyStyle();
214+
applyFilter();
215+
216+
document.getElementById("search").addEventListener("input", (e) => {
217+
state.query = e.target.value;
218+
applyFilter();
219+
});
220+
document.getElementById("preview").addEventListener("input", (e) => {
221+
state.text = e.target.value;
222+
for (const { sample } of cards.values()) delete sample.dataset.custom;
223+
applyText();
224+
});
225+
document.getElementById("weight").addEventListener("input", (e) => {
226+
state.weight = Number(e.target.value);
227+
document.getElementById("weight-val").textContent = e.target.value;
228+
applyStyle();
229+
});
230+
document.getElementById("italic").addEventListener("change", (e) => {
231+
state.italic = e.target.checked;
232+
applyStyle();
233+
});
234+
</script>
235+
</body>
236+
</html>

0 commit comments

Comments
 (0)