diff --git a/apps/landing/scripts/fonts/subset-fonts.py b/apps/landing/scripts/fonts/subset-fonts.py new file mode 100755 index 0000000000..4f13aaefa3 --- /dev/null +++ b/apps/landing/scripts/fonts/subset-fonts.py @@ -0,0 +1,471 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = ["fonttools[woff]==4.60.2", "uharfbuzz==0.57.0"] +# /// +"""Cut Geist and Geist Mono down to what the landing site renders. + +Run by hand from apps/landing, commit the output: + + bun run build && ./scripts/fonts/subset-fonts.py + bun run build && ./scripts/fonts/subset-fonts.py --check # stale? exit 1 + +It reads the built site in dist/ for the characters in use, downloads the +pinned upstream fonts, and writes per family a preloaded core file and an +extended file behind `unicode-range` (plus a box-drawing file for Geist Mono), +into src/fonts/, and the @font-face rules in src/styles/fonts.generated.css. +The build itself does no font work. + +Rerun (`--check` tells you) when: + - content gains a character Geist already drew that the files lack, e.g. + a new symbol or box-drawing character; + - a page starts using a weight outside WGHT, or turns on an OpenType feature + not in FEATURES (`font-variant-numeric`, `font-feature-settings`, ...); + - the source fonts are bumped (update SOURCES and the sha256s). + +Rendering must stay identical, so the script checks its own output: every +kept glyph has the same outline and advance as the source at each weight the +site uses, and every text run in dist/ shapes to the same positions across +the file split as with the whole font. It exits non-zero otherwise. The one +that will trip first is kerning across the split: text like "Té" or "café" +puts the accented letter in another file, and the pair kern is lost. Move the +letter into CORE, or accept the change. +""" + +from __future__ import annotations + +import hashlib +import html.parser +import io +import itertools +import re +import subprocess +import sys +import tempfile +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +import uharfbuzz as hb +from fontTools import subset +from fontTools.pens.recordingPen import DecomposingRecordingPen +from fontTools.ttLib import TTFont +from fontTools.varLib import instancer + +LANDING = Path(__file__).resolve().parents[2] +DIST = LANDING / "dist" +OUT_FONTS = LANDING / "src" / "fonts" +OUT_CSS = LANDING / "src" / "styles" / "fonts.generated.css" +CACHE = LANDING / "node_modules" / ".cache" / "landing-fonts" + + +@dataclass(frozen=True) +class Source: + url: str + sha256: str + + +# Same builds the @fontsource-variable 5.3.0 files were cut from (Geist 1.800, +# Geist Mono 1.701): glyph outlines, deltas and advances match those files. +SOURCES = { + "geist": Source( + "https://raw.githubusercontent.com/google/fonts/87a44ad3d4535f7b3a5a715a08e8944f144afd06/ofl/geist/Geist%5Bwght%5D.ttf", + "73894e0448cae90a92b6c2f8732b7bb9acb7b94c418bff559dad4a18e1de9659", + ), + "geist-mono": Source( + "https://raw.githubusercontent.com/google/fonts/9e25e2ba265e5298f70f6182dd4e8a3ebf1b9123/ofl/geistmono/GeistMono%5Bwght%5D.ttf", + "d00e590b8eb3a59acc329b2d044fd143ae935090b7da33199ebee27cc7de8196", + ), +} + + +def ranges(spec: str) -> frozenset[int]: + out: set[int] = set() + for part in spec.replace("U+", "").split(","): + lo, _, hi = part.strip().partition("-") + out.update(range(int(lo, 16), int(hi or lo, 16) + 1)) + return frozenset(out) + + +# What each family drew before this script existed: the union of the +# @fontsource-variable 5.3.0 faces' unicode-ranges, intersected with the glyphs +# those files carry. Anything else (→, ⌘, ✓, CJK) has always come from a system +# font. Keeping it that way is what keeps rendering identical, so a character +# outside this set is never added, even when Geist has a glyph for it. +_COMMON = ( + "U+0020-007E,U+00A0-00AC,U+00AE-0113,U+0116-012B,U+012E-0131,U+0134-0137,U+0139-013E," + "U+0141-0148,U+014A-014D,U+0150-017E,U+018F,U+0192,U+01A0-01A1,U+01AF-01B0,U+01CD-01CE," + "U+01E4-01E9,U+0218-021B,U+0237,U+0259,U+02B9,U+02BC,U+02C6-02C8,U+02DA,U+02DC-02DD," + "U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0400-045F,U+0462-0463,U+046A-046B," + "U+0472-0475,U+0490-0493,U+0496-0497,U+049A-049B,U+04A2-04A3,U+04AE-04B3,U+04B6-04B7," + "U+04BA-04BB,U+04C0,U+04CF,U+04D8-04D9,U+04E2-04E3,U+04E8-04E9,U+04EE-04EF,U+1E20-1E21," + "U+1E80-1E85,U+1E9E,U+1EA0-1EF9,U+2013-2014,U+2018-201A,U+201C-201E,U+2020,U+2022,U+2026," + "U+2032-2033,U+2039-203A,U+2044,U+20AA,U+20AC,U+20B1,U+20B4,U+20B9,U+20BD,U+2116,U+2122," + "U+2191,U+2193,U+2212,U+A78B-A78C" +) +# Geist Mono lacks U+0132-0133; Geist lacks U+2500-259F (Mono's symbols2 face). +DRAWN_TODAY = { + "geist": ranges(_COMMON) | ranges("U+0132-0133"), + "geist-mono": ranges(_COMMON) | ranges("U+2500-259F"), +} + +# ASCII and the punctuation most pages use. `…` also has to be here for +# `text-overflow: ellipsis`, which draws it without it being in the text. +# `©` and `·` sit in the footer and nav of every page. +CORE = ranges("U+0020-007E") | {0x2013, 0x2014, 0x2018, 0x2019, 0x201C, 0x201D, 0x2026, 0x00A9, 0x00B7} + +# The old faces' unicode-ranges also listed characters their files had no +# glyph for, and browsers still drew them from that face. Chromium drew U+2011 +# (in the pricing headline) as Geist's own hyphen; WebKit picks the face for a +# tab, a newline collapsed to a space, or a zero-width space by the original +# character, then draws it with that face's space. The core maps them to those +# glyphs rather than handing them to a system font. +ALIASES = {0x2011: 0x002D, 0x0009: 0x0020, 0x000A: 0x0020, 0x200B: 0x0020} + +LATIN_1_AND_EXT_A = ranges("U+00A0-017F") + +# Chromium on macOS looks up the glyphs named `X` and `o` in each font file, +# and whether they are there changes how every glyph in that file is +# antialiased at text sizes (seen at 14-16px, gone by 22px). fontsource's latin +# file had both, so the extended file carries an unencoded `X` (it already has +# `o` as a component of ò ó ô). Its symbols2 file had neither, so Geist Mono's +# box drawing gets a file of its own without them. +EXT_GLYPHS = ("X", "o") +BOX_DRAWING = ranges("U+2500-259F") + +# WebKit picks the face for a no-break space by its unicode-range, then draws +# it with that face's U+0020. Without one it borrows a system font's space, +# narrower and with other line metrics. The non-core files carry U+0020 for +# that, encoded but left out of their unicode-range so the core still owns it. +UNLISTED = {0x0020} + +# Default-on features plus `tnum` (Tailwind `tabular-nums`, the only feature +# the site turns on). Dropped: frac/numr/dnom and pnum (never enabled), locl +# (only AZE/CAT/NLD/ROM/TRK-style language systems; pages are en/ja/ko). +FEATURES = ["ccmp", "kern", "liga", "mark", "mkmk", "tnum"] + +# Weights in use, from a computed-style audit of every page: Geist 400, 430, +# 500, 550, 600, 650, 700, 750; Geist Mono 400, 430, 500, 550, 600, 700. Both +# fonts are two masters, 100 and 900, around a 400 default. Starting the axis +# at 400 drops the 100 master outright. The top stays at 900: cutting it to +# 750/700 removes no master, saves almost nothing, and rescales every delta, +# which rounds outlines off by up to half a unit. +WGHT = {"geist": (400, 900), "geist-mono": (400, 900)} +CHECK_WEIGHTS = {"geist": (400, 430, 500, 550, 600, 650, 700, 750, 900), "geist-mono": (400, 430, 500, 550, 600, 700, 900)} + + +@dataclass(frozen=True) +class Family: + slug: str + css_family: str + + +FAMILIES = (Family("geist", "Geist Variable"), Family("geist-mono", "Geist Mono Variable")) + + +def fetch(slug: str) -> bytes: + src = SOURCES[slug] + path = CACHE / f"{slug}-{src.sha256[:12]}.ttf" + if not path.exists(): + CACHE.mkdir(parents=True, exist_ok=True) + with urllib.request.urlopen(src.url) as res: + path.write_bytes(res.read()) + data = path.read_bytes() + if hashlib.sha256(data).hexdigest() != src.sha256: + path.unlink() + sys.exit(f"{slug}: sha256 mismatch for {src.url}") + return data + + +class TextCollector(html.parser.HTMLParser): + """Text content of a page, minus scripts and styles, plus input text.""" + + SKIP = {"script", "style", "template"} + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.depth = 0 + self.runs: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in self.SKIP: + self.depth += 1 + for name, value in attrs: + if value and name in ("placeholder", "value"): + self.runs.append(value) + + def handle_endtag(self, tag: str) -> None: + if tag in self.SKIP and self.depth: + self.depth -= 1 + + def handle_data(self, data: str) -> None: + if not self.depth and data.strip(): + self.runs.append(data) + + +# `\u00e9` takes exactly four digits, `\u{1F341}` up to six inside braces. +ESCAPE = re.compile(r"\\u(?:\{([0-9a-fA-F]{1,6})\}|([0-9a-fA-F]{4}))") + + +def content() -> tuple[set[int], list[str]]: + """Every codepoint in dist/, and the HTML text runs for the shaping check. + + Scripts count too, for text the islands render on the client. That reaches + library strings as well, but only characters a family drew already can get + in, so the cost is a few glyphs at most. + """ + if not (DIST / "index.html").exists(): + sys.exit("dist/ is missing: run `bun run build` first") + cps: set[int] = set() + runs: list[str] = [] + for page in sorted(DIST.rglob("*.html")): + parser = TextCollector() + parser.feed(page.read_text("utf-8")) + runs.extend(parser.runs) + for path in [*DIST.rglob("*.js"), *DIST.rglob("*.css")]: + text = path.read_text("utf-8", errors="ignore") + cps.update(ord(c) for c in text) + cps.update(cp for cp in (int(a or b, 16) for a, b in ESCAPE.findall(text)) if cp <= 0x10FFFF) + for run in runs: + cps.update(ord(c) for c in run) + # text-transform can draw the other case. + for cp in list(cps): + for c in (chr(cp).upper(), chr(cp).lower()): + if len(c) == 1: + cps.add(ord(c)) + return cps, list(dict.fromkeys(runs)) + + +def check_license(font: TTFont, slug: str) -> None: + for name_id in (0, 13): + text = font["name"].getDebugName(name_id) or "" + if "reserved font name" in text.lower(): + sys.exit(f"{slug}: declares a Reserved Font Name; subsets must be renamed before shipping") + + +def build(source: bytes, unicodes: set[int], wght: tuple[int, int], glyphs: tuple[str, ...] = ()) -> TTFont: + font = TTFont(io.BytesIO(source), recalcTimestamp=False) + options = subset.Options() + options.layout_features = FEATURES + # The only "hinting" here is gftools' unhinted-font fix (a 7-byte prep and + # a gasp table); no glyph has instructions. Windows uses them for + # smoothing, so they stay. + options.hinting = True + options.drop_tables += ["meta"] + # Chromium on macOS antialiases small text differently when the post table + # has no glyph names, or different ones (seen at 16px, not at 28px). The + # names cost ~0.5 KB a file and are what keeps the pixels identical. + options.glyph_names = True + options.recalc_timestamp = False + # Otherwise `›` pulls in `‹` and `«` pulls in `»`, for right-to-left + # mirroring that no page here needs. + options.bidi_closure = False + subsetter = subset.Subsetter(options) + subsetter.populate(unicodes=unicodes, glyphs=glyphs) + subsetter.subset(font) + for table in font["cmap"].tables: + # Extra glyphs come back encoded; they stay in the file, not the cmap. + table.cmap = {cp: glyph for cp, glyph in table.cmap.items() if cp in unicodes} + for alias, target in ALIASES.items(): + if alias in unicodes and target in table.cmap: + table.cmap[alias] = table.cmap[target] + # The instancer bakes the deltas the source leaves to interpolation (IUP) + # into rounded integers, moving points by a fraction of a unit. The default + # stays at 400, so the upper master's normalized coordinates don't change: + # put its original tuples back and only drop the ones below the default. + tuples = {glyph: list(variations) for glyph, variations in font["gvar"].variations.items()} + font = instancer.instantiateVariableFont(font, {"wght": wght}, optimize=False) + if wght[0] != 400: + sys.exit("restoring gvar tuples assumes the axis keeps its 400 default") + font["gvar"].variations = { + glyph: [v for v in variations if v.axes["wght"][1] > 0] for glyph, variations in tuples.items() + } + # Keep the full font's bounding box, as the fontsource files did: a box + # recomputed from the kept glyphs also shifts antialiasing on macOS. + source_head = TTFont(io.BytesIO(source))["head"] + for key in ("xMin", "yMin", "xMax", "yMax"): + setattr(font["head"], key, getattr(source_head, key)) + font.recalcBBoxes = False + font.flavor = "woff2" + return font + + +def reload(font: TTFont) -> tuple[TTFont, bytes, bytes]: + """The font as written, its WOFF2 bytes, and the plain sfnt that HarfBuzz + can read (it treats WOFF2 bytes as an empty face).""" + buf = io.BytesIO() + font.save(buf) + data = buf.getvalue() + written = TTFont(io.BytesIO(data), recalcTimestamp=False, recalcBBoxes=False) + sfnt = io.BytesIO() + written.flavor = None + written.save(sfnt) + return TTFont(io.BytesIO(data), recalcTimestamp=False, recalcBBoxes=False), data, sfnt.getvalue() + + +def unicode_range(cps: set[int]) -> str: + cps_sorted, out, i = sorted(cps), [], 0 + while i < len(cps_sorted): + j = i + while j + 1 < len(cps_sorted) and cps_sorted[j + 1] == cps_sorted[j] + 1: + j += 1 + lo, hi = cps_sorted[i], cps_sorted[j] + out.append(f"U+{lo:04X}" if lo == hi else f"U+{lo:04X}-{hi:04X}") + i = j + 1 + return ",".join(out) + + +def outlines(font: TTFont, weight: int, cps: set[int]) -> dict[int, tuple]: + cmap = font.getBestCmap() + glyphs = font.getGlyphSet(location={"wght": weight}, normalized=False) + out = {} + for cp in cps: + glyph = glyphs[cmap.get(cp) or cmap[ALIASES[cp]]] + pen = DecomposingRecordingPen(glyphs) + glyph.draw(pen) + out[cp] = (glyph.width, tuple(pen.value)) + return out + + +class Shaper: + def __init__(self, data: bytes) -> None: + self.face = hb.Face(data) + self.fonts: dict[int, hb.Font] = {} + + def __call__(self, text: str, weight: int, features: dict[str, bool]) -> list[tuple[int, int, int, int]]: + if weight not in self.fonts: + self.fonts[weight] = hb.Font(self.face) + self.fonts[weight].set_variations({"wght": weight}) + buf = hb.Buffer() + buf.add_codepoints([ord(c) for c in text]) + buf.guess_segment_properties() + hb.shape(self.fonts[weight], buf, features) + return [(i.cluster, p.x_advance, p.x_offset, p.y_offset) for i, p in zip(buf.glyph_infos, buf.glyph_positions)] + + +def check_shaping(source: bytes, files: list[tuple[bytes, set[int]]], runs: list[str], slug: str) -> tuple[list[str], int]: + """Shape each text run as the browser does, split by which file covers each + character, and compare it with the source font shaping the same run whole. + + Characters no file has come from a fallback font before and after, so they + end a run. Returns the differing runs and how many runs were checked.""" + shape_source = Shaper(source) + shapers = [(Shaper(data), cps) for data, cps in files] + owner = {cp: shaper for shaper, cps in shapers for cp in cps} + # Browsers collapse tabs and newlines to spaces, and HarfBuzz hides a + # zero-width space whether or not the font maps it: only U+2011 shapes as + # its alias. + collapsed = (re.sub(r"[\t\n]+", " ", run) for run in runs) + pieces = {p for run in collapsed for p in re.split(f"[^{''.join(re.escape(chr(c)) for c in sorted(owner))}]+", run) if p.strip()} + problems = [] + for piece in sorted(pieces): + segments = ["".join(g) for _, g in itertools.groupby(piece, lambda c: id(owner[ord(c)]))] + for weight in (400, 600): + for features in ({}, {"tnum": True}): + want = shape_source(piece.translate({0x2011: 0x002D}), weight, features) + got, offset = [], 0 + for seg in segments: + got += [(c + offset, *rest) for c, *rest in owner[ord(seg[0])](seg, weight, features)] + offset += len(seg) + if want != got: + problems.append(f"{slug} wght {weight} {features or ''}: {piece.strip()[:80]!r}") + return problems, len(pieces) + + +def formatted(css: str) -> str: + """The CSS as the repo's oxfmt leaves it, so a rerun is not a diff.""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "fonts.css" + path.write_text(css) + subprocess.run([LANDING.parents[1] / "node_modules" / ".bin" / "oxfmt", path], check=True, capture_output=True) + return path.read_text() + + +def main() -> None: + check = "--check" in sys.argv[1:] + outputs: dict[Path, bytes] = {} + used, runs = content() + css = [ + "/* Generated by scripts/fonts/subset-fonts.py. Do not edit; rerun the script.", + " Core files hold ASCII and common punctuation and are preloaded (FontPreloads.astro);", + " the others only download on pages that use one of their characters. `no-inline`", + " keeps Vite from inlining a small file into the CSS every page loads. */", + ] + failures: list[str] = [] + OUT_FONTS.mkdir(parents=True, exist_ok=True) + for family in FAMILIES: + source = fetch(family.slug) + src_font = TTFont(io.BytesIO(source)) + check_license(src_font, family.slug) + has = set(src_font.getBestCmap()) & DRAWN_TODAY[family.slug] + core_cps = (CORE & has) | {alias for alias, target in ALIASES.items() if target in CORE & has} + box_cps = BOX_DRAWING & used & has + ext_cps = ((LATIN_1_AND_EXT_A | used) & has) - core_cps - box_cps + parts = [("core", core_cps, ()), ("ext", ext_cps, EXT_GLYPHS), ("box", box_cps, ())] + + files = {} + for part, cps, glyphs in (p for p in parts if p[1]): + unlisted = UNLISTED - cps if part != "core" else set() + font, data, sfnt = reload(build(source, cps | unlisted, WGHT[family.slug], glyphs)) + got = set(font.getBestCmap()) + if got != cps | unlisted: + failures.append(f"{family.slug}-{part}: cmap differs from plan: {sorted(got ^ (cps | unlisted))}") + got -= unlisted + has_context = [g in font.getGlyphOrder() for g in EXT_GLYPHS] + if has_context != [part != "box"] * len(EXT_GLYPHS): + failures.append(f"{family.slug}-{part}: glyphs {EXT_GLYPHS} present: {has_context}") + if font["post"].formatType != 2.0 or font.getGlyphOrder() != [g for g in src_font.getGlyphOrder() if g in set(font.getGlyphOrder())]: + failures.append(f"{family.slug}-{part}: glyph names were not kept") + if [getattr(font["head"], k) for k in ("xMin", "yMin", "xMax", "yMax")] != [getattr(src_font["head"], k) for k in ("xMin", "yMin", "xMax", "yMax")]: + failures.append(f"{family.slug}-{part}: head bounding box differs from the source") + axis = next(a for a in font["fvar"].axes if a.axisTag == "wght") + if (axis.minValue, axis.maxValue) != WGHT[family.slug]: + failures.append(f"{family.slug}-{part}: wght axis is {axis.minValue}-{axis.maxValue}") + for weight in CHECK_WEIGHTS[family.slug]: + if outlines(font, weight, got) != outlines(src_font, weight, got): + failures.append(f"{family.slug}-{part}: outlines or advances differ from the source at wght {weight}") + path = OUT_FONTS / f"{family.slug}-{part}.woff2" + outputs[path] = data + files[part] = (sfnt, got) + features = sorted({r.FeatureTag for t in ("GSUB", "GPOS") if t in font for r in font[t].table.FeatureList.FeatureRecord}) + print(f"{path.relative_to(LANDING)}: {len(data):,} B, {len(got)} codepoints, {font['maxp'].numGlyphs} glyphs, features {' '.join(features)}, tables {' '.join(sorted(font.keys()))}") + css += [ + "", + "@font-face {", + f'\tfont-family: "{family.css_family}";', + "\tfont-style: normal;", + "\tfont-display: swap;", + f"\tfont-weight: {WGHT[family.slug][0]} {WGHT[family.slug][1]};", + f'\tsrc: url("../fonts/{path.name}?no-inline") format("woff2-variations");', + f"\tunicode-range: {unicode_range(got)};", + "}", + ] + problems, checked = check_shaping(source, list(files.values()), runs, family.slug) + print(f" {family.slug}: {checked:,} text runs shaped, {len(problems)} differ from the source font") + failures += problems + + skipped = sorted(cp for cp in used & set(src_font.getBestCmap()) if cp not in DRAWN_TODAY[family.slug] and cp > 0x7E) + if skipped: + print(f" {family.slug}: has glyphs for, but never drew, {' '.join(chr(c) for c in skipped)} (left to fallback fonts)") + + outputs[OUT_CSS] = formatted("\n".join(css) + "\n").encode() + committed = {p for p in OUT_FONTS.glob("*.woff2")} | {OUT_CSS} + if check: + stale = sorted(str(p.relative_to(LANDING)) for p in committed | set(outputs) if not p.exists() or p not in outputs or p.read_bytes() != outputs[p]) + print("committed fonts are current" if not stale else f"stale, rerun without --check: {' '.join(stale)}") + failures += [f"stale: {p}" for p in stale] + else: + for path in committed - set(outputs): + path.unlink() + for path, data in outputs.items(): + path.write_bytes(data) + print(f"wrote {len(outputs)} files") + if failures: + print("\nFAILED:", *failures[:40], sep="\n ") + if len(failures) > 40: + print(f" ... and {len(failures) - 40} more") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/apps/landing/src/components/FontPreloads.astro b/apps/landing/src/components/FontPreloads.astro new file mode 100644 index 0000000000..dd7b5b5f5f --- /dev/null +++ b/apps/landing/src/components/FontPreloads.astro @@ -0,0 +1,10 @@ +--- +// The core subsets from scripts/fonts/subset-fonts.py. Every page draws both +// families (Geist Mono is the body face), so both are preloaded; the extended +// files stay behind their unicode-range. +import geistCore from "../fonts/geist-core.woff2?url&no-inline"; +import geistMonoCore from "../fonts/geist-mono-core.woff2?url&no-inline"; +--- + + + diff --git a/apps/landing/src/fonts/geist-core.woff2 b/apps/landing/src/fonts/geist-core.woff2 new file mode 100644 index 0000000000..e336e21952 Binary files /dev/null and b/apps/landing/src/fonts/geist-core.woff2 differ diff --git a/apps/landing/src/fonts/geist-ext.woff2 b/apps/landing/src/fonts/geist-ext.woff2 new file mode 100644 index 0000000000..89effaa487 Binary files /dev/null and b/apps/landing/src/fonts/geist-ext.woff2 differ diff --git a/apps/landing/src/fonts/geist-mono-box.woff2 b/apps/landing/src/fonts/geist-mono-box.woff2 new file mode 100644 index 0000000000..386494cd74 Binary files /dev/null and b/apps/landing/src/fonts/geist-mono-box.woff2 differ diff --git a/apps/landing/src/fonts/geist-mono-core.woff2 b/apps/landing/src/fonts/geist-mono-core.woff2 new file mode 100644 index 0000000000..33ffe372e2 Binary files /dev/null and b/apps/landing/src/fonts/geist-mono-core.woff2 differ diff --git a/apps/landing/src/fonts/geist-mono-ext.woff2 b/apps/landing/src/fonts/geist-mono-ext.woff2 new file mode 100644 index 0000000000..8bb517e06f Binary files /dev/null and b/apps/landing/src/fonts/geist-mono-ext.woff2 differ diff --git a/apps/landing/src/layouts/DocsLayout.astro b/apps/landing/src/layouts/DocsLayout.astro index e7cecc6fbf..54ce6a715d 100644 --- a/apps/landing/src/layouts/DocsLayout.astro +++ b/apps/landing/src/layouts/DocsLayout.astro @@ -1,5 +1,6 @@ --- import "../styles/global.css"; +import FontPreloads from "../components/FontPreloads.astro"; import DocsSidebar from "../components/docs/DocsSidebar.astro"; import TableOfContents from "../components/TableOfContents.astro"; import DocsPrevNext from "../components/docs/DocsPrevNext.astro"; @@ -46,6 +47,7 @@ const metaDescription = description.replaceAll("`", ""); + diff --git a/apps/landing/src/layouts/Layout.astro b/apps/landing/src/layouts/Layout.astro index eae428f811..6fb7123d8b 100644 --- a/apps/landing/src/layouts/Layout.astro +++ b/apps/landing/src/layouts/Layout.astro @@ -1,6 +1,7 @@ --- import { getRelativeLocaleUrl } from "astro:i18n"; import "../styles/global.css"; +import FontPreloads from "../components/FontPreloads.astro"; import MediaLightbox from "../components/MediaLightbox.astro"; interface Props { @@ -67,6 +68,7 @@ const absoluteImage = image.startsWith("http") + diff --git a/apps/landing/src/styles/fonts.generated.css b/apps/landing/src/styles/fonts.generated.css new file mode 100644 index 0000000000..ecd7bb8cf9 --- /dev/null +++ b/apps/landing/src/styles/fonts.generated.css @@ -0,0 +1,57 @@ +/* Generated by scripts/fonts/subset-fonts.py. Do not edit; rerun the script. + Core files hold ASCII and common punctuation and are preloaded (FontPreloads.astro); + the others only download on pages that use one of their characters. `no-inline` + keeps Vite from inlining a small file into the CSS every page loads. */ + +@font-face { + font-family: "Geist Variable"; + font-style: normal; + font-display: swap; + font-weight: 400 900; + src: url("../fonts/geist-core.woff2?no-inline") format("woff2-variations"); + unicode-range: + U+0009-000A, U+0020-007E, U+00A9, U+00B7, U+200B, U+2011, U+2013-2014, U+2018-2019, U+201C-201D, + U+2026; +} + +@font-face { + font-family: "Geist Variable"; + font-style: normal; + font-display: swap; + font-weight: 400 900; + src: url("../fonts/geist-ext.woff2?no-inline") format("woff2-variations"); + unicode-range: + U+00A0-00A8, U+00AA-00AC, U+00AE-00B6, U+00B8-0113, U+0116-012B, U+012E-0137, U+0139-013E, + U+0141-0148, U+014A-014D, U+0150-017E, U+0300, U+2022, U+203A, U+2191, U+2193, U+2212; +} + +@font-face { + font-family: "Geist Mono Variable"; + font-style: normal; + font-display: swap; + font-weight: 400 900; + src: url("../fonts/geist-mono-core.woff2?no-inline") format("woff2-variations"); + unicode-range: + U+0009-000A, U+0020-007E, U+00A9, U+00B7, U+200B, U+2011, U+2013-2014, U+2018-2019, U+201C-201D, + U+2026; +} + +@font-face { + font-family: "Geist Mono Variable"; + font-style: normal; + font-display: swap; + font-weight: 400 900; + src: url("../fonts/geist-mono-ext.woff2?no-inline") format("woff2-variations"); + unicode-range: + U+00A0-00A8, U+00AA-00AC, U+00AE-00B6, U+00B8-0113, U+0116-012B, U+012E-0131, U+0134-0137, + U+0139-013E, U+0141-0148, U+014A-014D, U+0150-017E, U+0300, U+2022, U+203A, U+2191, U+2193, U+2212; +} + +@font-face { + font-family: "Geist Mono Variable"; + font-style: normal; + font-display: swap; + font-weight: 400 900; + src: url("../fonts/geist-mono-box.woff2?no-inline") format("woff2-variations"); + unicode-range: U+2500, U+2502, U+2514, U+251C; +} diff --git a/apps/landing/src/styles/global.css b/apps/landing/src/styles/global.css index 9278e9cfc2..d1676a3e7f 100644 --- a/apps/landing/src/styles/global.css +++ b/apps/landing/src/styles/global.css @@ -4,8 +4,7 @@ here (enforced by `bun run check:tokens`). Must come after tailwindcss. Landing is dark-only: Layout.astro pins class="dark" on . */ @import "@maple/ui/styles/tokens.css"; -@import "@fontsource-variable/geist-mono"; -@import "@fontsource-variable/geist"; +@import "./fonts.generated.css"; @source "../../../../packages/ui/src"; diff --git a/knip.json b/knip.json index a6c2f0856d..7de1ccd0fd 100644 --- a/knip.json +++ b/knip.json @@ -15,9 +15,11 @@ "apps/landing": { // scripts/figures/*.mjs generate blog figures and covers, run by hand with node. // iso/ is a vendored drawing kit; only part of its API is used. + // The fontsource packages are read by path in src/lib/docs-og.ts for OG images; + // the site itself serves the subsets from scripts/fonts/subset-fonts.py. "entry": ["src/worker.ts", "tools/**/*.ts", "tools/**/*.js", "scripts/figures/*.mjs"], "ignore": ["src/components/blog/foldkit/iso/**"], - "ignoreDependencies": ["@paper-design/shaders"] + "ignoreDependencies": ["@paper-design/shaders", "@fontsource-variable/geist", "@fontsource-variable/geist-mono"] }, "apps/api": { // src/worker.ts was auto-detected from wrangler.jsonc's `main` until the