@nebutra/fonts 0.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ # @nebutra/fonts
2
+
3
+ ## 2.0.0
@@ -0,0 +1,19 @@
1
+ # Font redistribution notice
2
+
3
+ This package's MIT licence covers **first-party code only**.
4
+
5
+ ## Noto Sans SC
6
+
7
+ 本软件使用了 **Noto Sans SC** 字体。
8
+ This software uses the **Noto Sans SC** typeface.
9
+
10
+ Noto Sans SC is licensed under the SIL Open Font License 1.1. The licence
11
+ text is `vendor/noto-sans-sc/OFL.txt`.
12
+
13
+ Generated `.woff2` subsets stay in this workspace for first-party apps so
14
+ `next/font/local` can load them offline. They are **not** included in the npm
15
+ `files` list and must not be published. Downstream npm consumers do not receive
16
+ font binaries from this package.
17
+
18
+ Do not add `generated/*.woff2` or other font binaries to a publishable `files`
19
+ glob.
package/README.md CHANGED
@@ -1,16 +1,27 @@
1
1
  # @nebutra/fonts
2
2
 
3
- Status: WIP — Not yet integrated into any production app.
3
+ Status: **WIP** — not yet published to npm.
4
4
 
5
- Self-hosted OSS font registry for Nebutra themes and imported DESIGN.md font
6
- families.
5
+ The Simplified-Chinese face is wired into every Next app that loads Geist (web,
6
+ landing, design-docs, sailor-docs, admin, forge, router, sleptons, typelens,
7
+ mail-preview). The theme / DESIGN.md registry (the ~16 Google faces) is still used
8
+ only by `apps/web`.
7
9
 
8
- The package has two entries:
10
+ Self-hosted fonts for Nebutra: the CJK body face, plus an OSS font registry for
11
+ themes and imported DESIGN.md font families.
12
+
13
+ The package has three entries:
9
14
 
10
15
  - `@nebutra/fonts` is client-safe and maps a CSS font-family stack to the
11
16
  registry CSS variable that should be prepended.
12
- - `@nebutra/fonts/next` is server-only and declares build-time `next/font`
13
- faces plus the combined registry class name.
17
+ - `@nebutra/fonts/next/cjk` is server-only and declares the self-hosted
18
+ Simplified-Chinese face via `next/font/local` — no network at build time, which
19
+ also keeps it working in a network-sandboxed dev server. **This is the one every
20
+ app needs.**
21
+ - `@nebutra/fonts/next` is server-only and declares the build-time
22
+ `next/font/google` registry faces plus the combined registry class name. It
23
+ re-exports the CJK face, but importing it just for that would drag ~16 Google
24
+ font downloads into the app — use `./next/cjk`.
14
25
 
15
26
  ## Installation
16
27
 
@@ -43,6 +54,137 @@ const stack = withRegistryFont("Space Grotesk, sans-serif");
43
54
  // "var(--font-space-grotesk), Space Grotesk, sans-serif"
44
55
  ```
45
56
 
57
+ ## Simplified Chinese — self-hosted Noto Sans SC
58
+
59
+ Geist has no CJK coverage at all, so without a CJK face every Chinese character
60
+ falls back to whatever the OS supplies: PingFang on macOS, Microsoft YaHei on
61
+ Windows, something else again on Android. Chinese copy is a first-class surface
62
+ in this product (see `docs/microcopy/`), so the CJK face is self-hosted and
63
+ subset here.
64
+
65
+ ### Wiring an app
66
+
67
+ Two lines in the root layout, beside the Geist loaders:
68
+
69
+ ```tsx
70
+ import { cjkFontClassName } from "@nebutra/fonts/next/cjk";
71
+ import { GeistMono } from "geist/font/mono";
72
+ import { GeistSans } from "geist/font/sans";
73
+
74
+ <html className={`${GeistSans.variable} ${GeistMono.variable} ${cjkFontClassName}`}>
75
+ ```
76
+
77
+ That defines `--font-noto-sans-sc`. Nothing else is needed: the token stacks in
78
+ `@nebutra/tokens` (`--font-sans`, `--font-cn`, `--font-display`, `--font-heading`)
79
+ already reference the variable in the right position. The app also needs
80
+ `"@nebutra/fonts"` in `dependencies` and in `transpilePackages` (the package ships
81
+ TypeScript source).
82
+
83
+ Do **not** re-declare `--font-sans` and friends downstream. `@nebutra/ui`'s
84
+ `typography/fonts.css` used to, and being the later import its Geist-only copies
85
+ won, so the CJK half of the token stack never reached `--font-sans` at all. That
86
+ duplication is gone; the file now only carries derived aliases.
87
+
88
+ ### Stack order is the design decision
89
+
90
+ ```css
91
+ font-family: var(--font-geist-sans), "Geist", var(--font-noto-sans-sc), "Noto Sans SC", …;
92
+ ```
93
+
94
+ Geist comes **first** and keeps Latin and the numerals — its tabular figures and
95
+ tighter x-height are what dense dashboard tables need, and it is the locked UI
96
+ face. Noto Sans SC takes CJK. Both faces cover Latin, so the *order* is what
97
+ decides: reversed, Noto would take the Latin too, and its Latin is not as
98
+ good as Geist's for UI.
99
+
100
+ Belt and braces: the generated `@font-face` rules carry a `unicode-range` with
101
+ no Latin, no ASCII and no general-punctuation codepoints in it, so a Latin-only
102
+ page can never trigger a CJK download even if a stack somewhere is written the
103
+ wrong way round. Geist Mono remains the code face.
104
+
105
+ ### Building the subsets
106
+
107
+ ```bash
108
+ FONTTOOLS_PYTHON=/path/to/python \
109
+ pnpm --filter @nebutra/fonts subset:cjk -- --force
110
+ ```
111
+
112
+ Requires Python with `fontTools` and `brotli` (for `--flavor=woff2`).
113
+ The script downloads the OFL Noto Sans SC variable face, instances 400 / 500 /
114
+ 600 / 700, then subsets. Outputs land in `generated/`. The committed woff2
115
+ files are the SIL OFL Noto Sans SC chinese-simplified faces used by
116
+ `next/font/local` so a clean clone does not need fontTools to render text.
117
+
118
+ ### Why three weights
119
+
120
+ The design system's numeric slots are `--font-weight-medium: 500` and
121
+ `--font-weight-heading: 600` (`packages/design/tokens/recipe.css`), and the token
122
+ CSS writes literal `font-weight` in only four values: 500 (43×), 600 (35×),
123
+ 400 (27×), 700 (10×). So 400 / 500 / 600 ship. 700 resolves to the 600 face by
124
+ normal CSS font matching, and because that matched face is itself ≥ 600 no
125
+ browser applies synthetic bold — which is precisely why the third face is
126
+ DemiBold 600 rather than Bold 700. Choosing 700 instead would leave the *default*
127
+ heading weight, the most common heading value in the system, a step out of place.
128
+ Skin-declared fractional weights (300 / 450 / 510) resolve into the same set.
129
+ Each weight costs ~490 KB, so shipping all nine static faces would be ~4.4 MB.
130
+
131
+ ### Why the static faces, not the variable one
132
+
133
+ A variable CJK font carries per-weight deltas for every glyph it keeps, so one
134
+ variable file costs more than the static weights a page actually uses. The
135
+ pipeline instances 400 / 500 / 600 / 700 and subsets each one.
136
+
137
+ ### Character set
138
+
139
+ Three inputs, unioned — 4,282 characters in the current build:
140
+
141
+ 1. **The zh catalogs, by glob** (1,586 chars) — every `zh*.json` under any
142
+ `messages/` or `locales/` directory, walked at build time, so new Chinese copy
143
+ is covered on the next run instead of drifting away from a hardcoded list.
144
+ 2. **CJK punctuation and fullwidth forms, wholesale** (197 chars) — U+3000–303F,
145
+ U+FE30–FE4F, U+FF01–FF5E, U+FFE0–FFE6. Chinese text whose `、。!?()` are set
146
+ in a different face than its characters looks broken immediately: different
147
+ baseline, different advance width. 35 of these are absent from the source face
148
+ (`〄〰〸₩` and friends); the build reports them and they fall through the stack.
149
+ 3. **A floor of common characters: GB2312 level-1** (3,755 chars, 一级汉字). The
150
+ catalogs only cover *our* copy — product surfaces render *user* data, and one
151
+ character of a name or a city falling back to PingFang mid-sentence is worse
152
+ than not using the font at all. The conventional floor is the 通用规范汉字表
153
+ 一级字表 (3,500 常用字); GB2312 level-1 is a superset of essentially that set
154
+ and, the deciding factor, is derivable in-process from the platform's own
155
+ GB2312 decoder — no 3,500-entry list to vendor, review or let rot. It covers
156
+ >99.5% of running modern text. Level 2 (~3,000 further rare surname and
157
+ place-name glyphs) is excluded: it would roughly double every file for
158
+ characters that appear in a fraction of a percent of text, which is exactly
159
+ what OS fallback is for.
160
+
161
+ The floor is what costs the bytes: the catalog set alone subsets to 191,108 B per
162
+ weight, the full set to ~498,000 B.
163
+
164
+ > Possible follow-up, measured but **not** implemented here: splitting each
165
+ > weight into a hot tier (catalogs + punctuation, 1,777 chars, 197,028 B) and an
166
+ > extended tier (the GB2312 remainder, 2,509 chars, 312,644 B) with complementary
167
+ > `unicode-range`s would drop the common path from ~1.47 MB to ~591 KB across
168
+ > three weights, and only fetch the extended tier when user data actually renders
169
+ > a character outside our own copy. It costs two files per weight and (on the
170
+ > `next/font/local` path) a per-tier `declarations` entry to carry the range.
171
+
172
+ ### Vendored sources
173
+
174
+ `vendor/noto-sans-sc/OFL.txt` is committed. Full source TTFs are downloaded by
175
+ `subset:cjk` and gitignored. Do not commit unmodified CJK source faces.
176
+
177
+ ## Third-party font attribution
178
+
179
+ 本软件使用了 **Noto Sans SC** 字体。
180
+ This software uses the **Noto Sans SC** typeface.
181
+
182
+ Noto Sans SC is licensed under the SIL Open Font License 1.1
183
+ (`vendor/noto-sans-sc/OFL.txt`). That licence is separate from this package's
184
+ MIT licence, which applies to first-party code only. Generated `.woff2` files
185
+ stay in the workspace for first-party apps and are excluded from the npm
186
+ tarball. See `NOTICE-FONTS.md`.
187
+
46
188
  ## Registered Families
47
189
 
48
190
  The registry includes Geist, Inter, Space Grotesk, Playfair Display, JetBrains
@@ -57,4 +199,5 @@ uses the corresponding CSS variable.
57
199
 
58
200
  ## License
59
201
 
60
- MIT
202
+ MIT for first-party code. Noto Sans SC binaries are SIL OFL 1.1 and are not
203
+ published to npm.
@@ -0,0 +1,27 @@
1
+ // src/next-cjk.ts
2
+ import localFont from "next/font/local";
3
+ var notoSansSc = localFont({
4
+ src: [
5
+ { path: "../generated/noto-sans-sc-400.woff2", weight: "400", style: "normal" },
6
+ { path: "../generated/noto-sans-sc-500.woff2", weight: "500", style: "normal" },
7
+ { path: "../generated/noto-sans-sc-600.woff2", weight: "600", style: "normal" },
8
+ { path: "../generated/noto-sans-sc-700.woff2", weight: "700", style: "normal" }
9
+ ],
10
+ declarations: [
11
+ {
12
+ prop: "unicode-range",
13
+ value: "U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF"
14
+ }
15
+ ],
16
+ display: "swap",
17
+ preload: false,
18
+ adjustFontFallback: false,
19
+ variable: "--font-noto-sans-sc"
20
+ });
21
+ var cjkFontClassName = notoSansSc.variable;
22
+
23
+ export {
24
+ notoSansSc,
25
+ cjkFontClassName
26
+ };
27
+ //# sourceMappingURL=chunk-GRE7GS6W.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/next-cjk.ts"],"sourcesContent":["/**\n * @nebutra/fonts/next/cjk — the self-hosted Simplified-Chinese face (server-only).\n *\n * WHY A SEPARATE ENTRY FROM `./next`: that module declares ~16 `next/font/google`\n * faces for the theme / DESIGN.md registry. Importing it just to get the CJK face\n * would drag those build-time Google downloads into every app — and this repo has\n * a known trap where `next/font/google` fails outright in a network-sandboxed dev\n * server. This file imports `next/font/local` ONLY: the woff2 files live in the\n * workspace `generated/` directory (not the npm tarball), so first-party apps\n * work offline, in CI, and in the sandbox. `./next` re-exports it,\n * so an app already applying `fontRegistryClassName` still only needs one import.\n *\n * WHY SELF-HOSTED AT ALL: Geist has no CJK coverage, so without this every Chinese\n * character falls back to whatever the OS supplies — PingFang on macOS, Microsoft\n * YaHei on Windows, something else on Android. Chinese copy is a first-class\n * surface here (see docs/microcopy/), so the face is pinned rather than left to\n * the OS.\n *\n * The files are built by `pnpm --filter @nebutra/fonts subset:cjk` from\n * Noto Sans SC (SIL OFL) and live in the workspace `generated/` directory.\n * The literal `src` list below mirrors NOTO_SANS_SC_SOURCES in\n * ../generated/index.ts (a drift test in ./next-cjk.test.ts asserts they agree).\n * It is spelled out rather than spread because next/font is a compile-time\n * transform — SWC statically analyses this call, so the options object cannot\n * be computed.\n */\n\nimport localFont from \"next/font/local\";\n\n/**\n * Noto Sans SC — 400 / 500 / 600 / 700 static subsets.\n *\n * - `preload: false` on purpose. Each weight is hundreds of KB; preloading them\n * on every route would tax Latin-only pages for nothing. The browser fetches\n * a weight only when a glyph in the `unicode-range` below actually renders.\n * - `declarations` carries that `unicode-range` (CJK blocks only — no ASCII, no\n * Latin, no general punctuation), so a Latin-only page can never trigger a CJK\n * download even if a font stack somewhere is written the wrong way round.\n * - `adjustFontFallback: false` — next/font's metric-matched fallback is derived\n * from Arial, which is meaningless for a Han face.\n */\nexport const notoSansSc = localFont({\n src: [\n { path: \"../generated/noto-sans-sc-400.woff2\", weight: \"400\", style: \"normal\" },\n { path: \"../generated/noto-sans-sc-500.woff2\", weight: \"500\", style: \"normal\" },\n { path: \"../generated/noto-sans-sc-600.woff2\", weight: \"600\", style: \"normal\" },\n { path: \"../generated/noto-sans-sc-700.woff2\", weight: \"700\", style: \"normal\" },\n ],\n declarations: [\n {\n prop: \"unicode-range\",\n value: \"U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF\",\n },\n ],\n display: \"swap\",\n preload: false,\n adjustFontFallback: false,\n variable: \"--font-noto-sans-sc\",\n});\n\n/**\n * Apply to <html> next to the Geist loaders so `--font-noto-sans-sc` exists:\n *\n * className={`${GeistSans.variable} ${GeistMono.variable} ${cjkFontClassName}`}\n *\n * The token stacks (`--font-sans` / `--font-cn` / `--font-display` in\n * @nebutra/tokens) reference the variable AFTER Geist, so Geist keeps Latin and\n * the numerals and only CJK falls through to this face.\n */\nexport const cjkFontClassName = notoSansSc.variable;\n"],"mappings":";AA2BA,OAAO,eAAe;AAcf,IAAM,aAAa,UAAU;AAAA,EAClC,KAAK;AAAA,IACH,EAAE,MAAM,uCAAuC,QAAQ,OAAO,OAAO,SAAS;AAAA,IAC9E,EAAE,MAAM,uCAAuC,QAAQ,OAAO,OAAO,SAAS;AAAA,IAC9E,EAAE,MAAM,uCAAuC,QAAQ,OAAO,OAAO,SAAS;AAAA,IAC9E,EAAE,MAAM,uCAAuC,QAAQ,OAAO,OAAO,SAAS;AAAA,EAChF;AAAA,EACA,cAAc;AAAA,IACZ;AAAA,MACE,MAAM;AAAA,MACN,OAAO;AAAA,IACT;AAAA,EACF;AAAA,EACA,SAAS;AAAA,EACT,SAAS;AAAA,EACT,oBAAoB;AAAA,EACpB,UAAU;AACZ,CAAC;AAWM,IAAM,mBAAmB,WAAW;","names":[]}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * GENERATED FILE — face metadata for `@nebutra/fonts/next/cjk`.
3
+ * Current binaries are SIL OFL Noto Sans SC (chinese-simplified) woff2 files.
4
+ * Rebuild with `pnpm --filter @nebutra/fonts subset:cjk` when FONTTOOLS_PYTHON
5
+ * is available, or replace the woff2 files from an OFL source.
6
+ */
7
+ declare const NOTO_SANS_SC_VARIABLE: "--font-noto-sans-sc";
8
+ declare const NOTO_SANS_SC_FAMILY: "Noto Sans SC";
9
+ /** Characters covered per face (catalogs ∪ CJK punctuation ∪ GB2312 level-1). */
10
+ declare const NOTO_SANS_SC_CHAR_COUNT: 4282;
11
+ /** `unicode-range` of every generated @font-face — CJK only, no Latin. */
12
+ declare const NOTO_SANS_SC_UNICODE_RANGE: "U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF";
13
+ /** Sources for `next/font/local({ src: [...] })`, paths relative to this file. */
14
+ declare const NOTO_SANS_SC_SOURCES: readonly [{
15
+ readonly path: "./noto-sans-sc-400.woff2";
16
+ readonly weight: "400";
17
+ readonly style: "normal";
18
+ readonly bytes: 1142552;
19
+ }, {
20
+ readonly path: "./noto-sans-sc-500.woff2";
21
+ readonly weight: "500";
22
+ readonly style: "normal";
23
+ readonly bytes: 1159128;
24
+ }, {
25
+ readonly path: "./noto-sans-sc-600.woff2";
26
+ readonly weight: "600";
27
+ readonly style: "normal";
28
+ readonly bytes: 1162352;
29
+ }, {
30
+ readonly path: "./noto-sans-sc-700.woff2";
31
+ readonly weight: "700";
32
+ readonly style: "normal";
33
+ readonly bytes: 1172244;
34
+ }];
35
+
36
+ export { NOTO_SANS_SC_CHAR_COUNT, NOTO_SANS_SC_FAMILY, NOTO_SANS_SC_SOURCES, NOTO_SANS_SC_UNICODE_RANGE, NOTO_SANS_SC_VARIABLE };
@@ -0,0 +1,19 @@
1
+ // generated/index.ts
2
+ var NOTO_SANS_SC_VARIABLE = "--font-noto-sans-sc";
3
+ var NOTO_SANS_SC_FAMILY = "Noto Sans SC";
4
+ var NOTO_SANS_SC_CHAR_COUNT = 4282;
5
+ var NOTO_SANS_SC_UNICODE_RANGE = "U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF";
6
+ var NOTO_SANS_SC_SOURCES = [
7
+ { path: "./noto-sans-sc-400.woff2", weight: "400", style: "normal", bytes: 1142552 },
8
+ { path: "./noto-sans-sc-500.woff2", weight: "500", style: "normal", bytes: 1159128 },
9
+ { path: "./noto-sans-sc-600.woff2", weight: "600", style: "normal", bytes: 1162352 },
10
+ { path: "./noto-sans-sc-700.woff2", weight: "700", style: "normal", bytes: 1172244 }
11
+ ];
12
+ export {
13
+ NOTO_SANS_SC_CHAR_COUNT,
14
+ NOTO_SANS_SC_FAMILY,
15
+ NOTO_SANS_SC_SOURCES,
16
+ NOTO_SANS_SC_UNICODE_RANGE,
17
+ NOTO_SANS_SC_VARIABLE
18
+ };
19
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../generated/index.ts"],"sourcesContent":["/**\n * GENERATED FILE — face metadata for `@nebutra/fonts/next/cjk`.\n * Current binaries are SIL OFL Noto Sans SC (chinese-simplified) woff2 files.\n * Rebuild with `pnpm --filter @nebutra/fonts subset:cjk` when FONTTOOLS_PYTHON\n * is available, or replace the woff2 files from an OFL source.\n */\n\nexport const NOTO_SANS_SC_VARIABLE = \"--font-noto-sans-sc\" as const;\n\nexport const NOTO_SANS_SC_FAMILY = \"Noto Sans SC\" as const;\n\n/** Characters covered per face (catalogs ∪ CJK punctuation ∪ GB2312 level-1). */\nexport const NOTO_SANS_SC_CHAR_COUNT = 4282 as const;\n\n/** `unicode-range` of every generated @font-face — CJK only, no Latin. */\nexport const NOTO_SANS_SC_UNICODE_RANGE =\n \"U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF\" as const;\n\n/** Sources for `next/font/local({ src: [...] })`, paths relative to this file. */\nexport const NOTO_SANS_SC_SOURCES = [\n { path: \"./noto-sans-sc-400.woff2\", weight: \"400\", style: \"normal\", bytes: 1142552 },\n { path: \"./noto-sans-sc-500.woff2\", weight: \"500\", style: \"normal\", bytes: 1159128 },\n { path: \"./noto-sans-sc-600.woff2\", weight: \"600\", style: \"normal\", bytes: 1162352 },\n { path: \"./noto-sans-sc-700.woff2\", weight: \"700\", style: \"normal\", bytes: 1172244 },\n] as const;\n"],"mappings":";AAOO,IAAM,wBAAwB;AAE9B,IAAM,sBAAsB;AAG5B,IAAM,0BAA0B;AAGhC,IAAM,6BACX;AAGK,IAAM,uBAAuB;AAAA,EAClC,EAAE,MAAM,4BAA4B,QAAQ,OAAO,OAAO,UAAU,OAAO,QAAQ;AAAA,EACnF,EAAE,MAAM,4BAA4B,QAAQ,OAAO,OAAO,UAAU,OAAO,QAAQ;AAAA,EACnF,EAAE,MAAM,4BAA4B,QAAQ,OAAO,OAAO,UAAU,OAAO,QAAQ;AAAA,EACnF,EAAE,MAAM,4BAA4B,QAAQ,OAAO,OAAO,UAAU,OAAO,QAAQ;AACrF;","names":[]}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @nebutra/fonts — self-hosted OSS font registry (client-safe entry).
3
+ *
4
+ * Maps a normalized theme / DESIGN.md font-family name to the CSS variable that
5
+ * the build-time self-hosted face defines (declared with next/font in
6
+ * `@nebutra/fonts/next`, applied to <html> via `fontRegistryClassName`).
7
+ *
8
+ * WHY: next/font registers each face under a HASHED family name reachable ONLY
9
+ * via its CSS variable — `font-family: 'Inter'` does NOT use the self-hosted
10
+ * Inter. So when a theme / imported DESIGN.md font's primary family matches an
11
+ * entry here, callers prepend `var(--font-…)` to the stack, making the
12
+ * self-hosted font actually render — with ZERO runtime external requests
13
+ * (next/font self-hosts at build time) and next/font's automatic metric-matched
14
+ * fallback (no layout shift). Unmatched families keep their declared stack.
15
+ *
16
+ * This entry is FREE of `next/font` imports so client modules can use it.
17
+ * The `./next` subpath holds the (server-only) next/font declarations and MUST
18
+ * keep its CSS-variable names in sync with FONT_REGISTRY below.
19
+ */
20
+ declare const FONT_REGISTRY: Record<string, string>;
21
+ /** The first (primary) family in a CSS font-family list, normalized. */
22
+ declare function primaryFamily(stack: string): string;
23
+ /** Registry CSS variable for a stack's primary family, or undefined. */
24
+ declare function resolveRegistryVar(stack: string): string | undefined;
25
+ /**
26
+ * Return `stack` with the self-hosted registry font prepended when its primary
27
+ * family is registered; otherwise return it unchanged.
28
+ * e.g. "Space Grotesk, sans-serif" → "var(--font-space-grotesk), Space Grotesk, sans-serif"
29
+ */
30
+ declare function withRegistryFont(stack: string | undefined): string | undefined;
31
+ /**
32
+ * Like `withRegistryFont`, but matches the first registered family ANYWHERE in
33
+ * the stack rather than only in first position.
34
+ *
35
+ * A brand package names the typeface the design language actually uses, and
36
+ * those are frequently licensed faces we have no right to serve — Söhne, Mori,
37
+ * Lyon Text, Reckless. The declared stack already says what to do when they are
38
+ * absent: fall to the next family. But "the next family" is usually a bare name
39
+ * like `Inter`, which does NOT reach next/font's hashed face, so the stack
40
+ * skidded past every self-hosted option and landed on `ui-sans-serif`. All
41
+ * seven built-in design languages rendered in the system font until 2026-08-18.
42
+ *
43
+ * Prepending the nearest registered family produces exactly the outcome the
44
+ * declared chain intended, and leaves the licensed names in place so a customer
45
+ * who does own the font still gets it by shipping the face themselves.
46
+ */
47
+ declare function withNearestRegistryFont(stack: string | undefined): string | undefined;
48
+
49
+ export { FONT_REGISTRY, primaryFamily, resolveRegistryVar, withNearestRegistryFont, withRegistryFont };
package/dist/index.js ADDED
@@ -0,0 +1,61 @@
1
+ // src/index.ts
2
+ var FONT_REGISTRY = {
3
+ // Self-hosted via geist/font (default brand faces, loaded by the app shell)
4
+ geist: "--font-geist-sans",
5
+ "geist sans": "--font-geist-sans",
6
+ "geist mono": "--font-geist-mono",
7
+ // Self-hosted via next/font/local from the subset built in ./generated (see
8
+ // ./next-cjk). The Simplified-Chinese face — CJK only via unicode-range, so it
9
+ // cannot take Latin away from Geist.
10
+ "noto sans sc": "--font-noto-sans-sc",
11
+ // Self-hosted via next/font/google (see ./next)
12
+ inter: "--font-inter",
13
+ "inter tight": "--font-reg-inter-tight",
14
+ "space grotesk": "--font-space-grotesk",
15
+ "playfair display": "--font-playfair-display",
16
+ fraunces: "--font-reg-fraunces",
17
+ "source serif 4": "--font-reg-source-serif-4",
18
+ "jetbrains mono": "--font-jetbrains-mono",
19
+ manrope: "--font-reg-manrope",
20
+ sora: "--font-reg-sora",
21
+ "work sans": "--font-reg-work-sans",
22
+ "dm sans": "--font-reg-dm-sans",
23
+ "plus jakarta sans": "--font-reg-plus-jakarta-sans",
24
+ outfit: "--font-reg-outfit",
25
+ figtree: "--font-reg-figtree",
26
+ montserrat: "--font-reg-montserrat",
27
+ lexend: "--font-reg-lexend",
28
+ "fira code": "--font-reg-fira-code",
29
+ "roboto mono": "--font-reg-roboto-mono",
30
+ "source code pro": "--font-reg-source-code-pro"
31
+ };
32
+ function normalizeFamily(name) {
33
+ return name.replace(/['"]/g, "").trim().toLowerCase();
34
+ }
35
+ function primaryFamily(stack) {
36
+ return normalizeFamily(stack.split(",")[0] ?? "");
37
+ }
38
+ function resolveRegistryVar(stack) {
39
+ return FONT_REGISTRY[primaryFamily(stack)];
40
+ }
41
+ function withRegistryFont(stack) {
42
+ if (!stack) return stack;
43
+ const variable = resolveRegistryVar(stack);
44
+ return variable ? `var(${variable}), ${stack}` : stack;
45
+ }
46
+ function withNearestRegistryFont(stack) {
47
+ if (!stack) return stack;
48
+ for (const token of stack.split(",")) {
49
+ const variable = FONT_REGISTRY[normalizeFamily(token)];
50
+ if (variable) return `var(${variable}), ${stack}`;
51
+ }
52
+ return stack;
53
+ }
54
+ export {
55
+ FONT_REGISTRY,
56
+ primaryFamily,
57
+ resolveRegistryVar,
58
+ withNearestRegistryFont,
59
+ withRegistryFont
60
+ };
61
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @nebutra/fonts — self-hosted OSS font registry (client-safe entry).\n *\n * Maps a normalized theme / DESIGN.md font-family name to the CSS variable that\n * the build-time self-hosted face defines (declared with next/font in\n * `@nebutra/fonts/next`, applied to <html> via `fontRegistryClassName`).\n *\n * WHY: next/font registers each face under a HASHED family name reachable ONLY\n * via its CSS variable — `font-family: 'Inter'` does NOT use the self-hosted\n * Inter. So when a theme / imported DESIGN.md font's primary family matches an\n * entry here, callers prepend `var(--font-…)` to the stack, making the\n * self-hosted font actually render — with ZERO runtime external requests\n * (next/font self-hosts at build time) and next/font's automatic metric-matched\n * fallback (no layout shift). Unmatched families keep their declared stack.\n *\n * This entry is FREE of `next/font` imports so client modules can use it.\n * The `./next` subpath holds the (server-only) next/font declarations and MUST\n * keep its CSS-variable names in sync with FONT_REGISTRY below.\n */\n\nexport const FONT_REGISTRY: Record<string, string> = {\n // Self-hosted via geist/font (default brand faces, loaded by the app shell)\n geist: \"--font-geist-sans\",\n \"geist sans\": \"--font-geist-sans\",\n \"geist mono\": \"--font-geist-mono\",\n // Self-hosted via next/font/local from the subset built in ./generated (see\n // ./next-cjk). The Simplified-Chinese face — CJK only via unicode-range, so it\n // cannot take Latin away from Geist.\n \"noto sans sc\": \"--font-noto-sans-sc\",\n // Self-hosted via next/font/google (see ./next)\n inter: \"--font-inter\",\n \"inter tight\": \"--font-reg-inter-tight\",\n \"space grotesk\": \"--font-space-grotesk\",\n \"playfair display\": \"--font-playfair-display\",\n fraunces: \"--font-reg-fraunces\",\n \"source serif 4\": \"--font-reg-source-serif-4\",\n \"jetbrains mono\": \"--font-jetbrains-mono\",\n manrope: \"--font-reg-manrope\",\n sora: \"--font-reg-sora\",\n \"work sans\": \"--font-reg-work-sans\",\n \"dm sans\": \"--font-reg-dm-sans\",\n \"plus jakarta sans\": \"--font-reg-plus-jakarta-sans\",\n outfit: \"--font-reg-outfit\",\n figtree: \"--font-reg-figtree\",\n montserrat: \"--font-reg-montserrat\",\n lexend: \"--font-reg-lexend\",\n \"fira code\": \"--font-reg-fira-code\",\n \"roboto mono\": \"--font-reg-roboto-mono\",\n \"source code pro\": \"--font-reg-source-code-pro\",\n};\n\n/** Normalize a single font-family token: strip quotes/whitespace, lowercase. */\nfunction normalizeFamily(name: string): string {\n // Strip ALL quotes with a quantifier-free global replace (quotes only appear\n // at token boundaries in a font-family value). Avoids the end-anchored /['\"]+$/\n // form, which CodeQL flags as polynomial ReDoS (scanned from every position).\n return name.replace(/['\"]/g, \"\").trim().toLowerCase();\n}\n\n/** The first (primary) family in a CSS font-family list, normalized. */\nexport function primaryFamily(stack: string): string {\n return normalizeFamily(stack.split(\",\")[0] ?? \"\");\n}\n\n/** Registry CSS variable for a stack's primary family, or undefined. */\nexport function resolveRegistryVar(stack: string): string | undefined {\n return FONT_REGISTRY[primaryFamily(stack)];\n}\n\n/**\n * Return `stack` with the self-hosted registry font prepended when its primary\n * family is registered; otherwise return it unchanged.\n * e.g. \"Space Grotesk, sans-serif\" → \"var(--font-space-grotesk), Space Grotesk, sans-serif\"\n */\nexport function withRegistryFont(stack: string | undefined): string | undefined {\n if (!stack) return stack;\n const variable = resolveRegistryVar(stack);\n return variable ? `var(${variable}), ${stack}` : stack;\n}\n\n/**\n * Like `withRegistryFont`, but matches the first registered family ANYWHERE in\n * the stack rather than only in first position.\n *\n * A brand package names the typeface the design language actually uses, and\n * those are frequently licensed faces we have no right to serve — Söhne, Mori,\n * Lyon Text, Reckless. The declared stack already says what to do when they are\n * absent: fall to the next family. But \"the next family\" is usually a bare name\n * like `Inter`, which does NOT reach next/font's hashed face, so the stack\n * skidded past every self-hosted option and landed on `ui-sans-serif`. All\n * seven built-in design languages rendered in the system font until 2026-08-18.\n *\n * Prepending the nearest registered family produces exactly the outcome the\n * declared chain intended, and leaves the licensed names in place so a customer\n * who does own the font still gets it by shipping the face themselves.\n */\nexport function withNearestRegistryFont(stack: string | undefined): string | undefined {\n if (!stack) return stack;\n for (const token of stack.split(\",\")) {\n const variable = FONT_REGISTRY[normalizeFamily(token)];\n if (variable) return `var(${variable}), ${stack}`;\n }\n return stack;\n}\n"],"mappings":";AAoBO,IAAM,gBAAwC;AAAA;AAAA,EAEnD,OAAO;AAAA,EACP,cAAc;AAAA,EACd,cAAc;AAAA;AAAA;AAAA;AAAA,EAId,gBAAgB;AAAA;AAAA,EAEhB,OAAO;AAAA,EACP,eAAe;AAAA,EACf,iBAAiB;AAAA,EACjB,oBAAoB;AAAA,EACpB,UAAU;AAAA,EACV,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB,SAAS;AAAA,EACT,MAAM;AAAA,EACN,aAAa;AAAA,EACb,WAAW;AAAA,EACX,qBAAqB;AAAA,EACrB,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,eAAe;AAAA,EACf,mBAAmB;AACrB;AAGA,SAAS,gBAAgB,MAAsB;AAI7C,SAAO,KAAK,QAAQ,SAAS,EAAE,EAAE,KAAK,EAAE,YAAY;AACtD;AAGO,SAAS,cAAc,OAAuB;AACnD,SAAO,gBAAgB,MAAM,MAAM,GAAG,EAAE,CAAC,KAAK,EAAE;AAClD;AAGO,SAAS,mBAAmB,OAAmC;AACpE,SAAO,cAAc,cAAc,KAAK,CAAC;AAC3C;AAOO,SAAS,iBAAiB,OAA+C;AAC9E,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,WAAW,mBAAmB,KAAK;AACzC,SAAO,WAAW,OAAO,QAAQ,MAAM,KAAK,KAAK;AACnD;AAkBO,SAAS,wBAAwB,OAA+C;AACrF,MAAI,CAAC,MAAO,QAAO;AACnB,aAAW,SAAS,MAAM,MAAM,GAAG,GAAG;AACpC,UAAM,WAAW,cAAc,gBAAgB,KAAK,CAAC;AACrD,QAAI,SAAU,QAAO,OAAO,QAAQ,MAAM,KAAK;AAAA,EACjD;AACA,SAAO;AACT;","names":[]}
@@ -0,0 +1,53 @@
1
+ import * as next_dist_compiled__next_font from 'next/dist/compiled/@next/font';
2
+
3
+ /**
4
+ * @nebutra/fonts/next/cjk — the self-hosted Simplified-Chinese face (server-only).
5
+ *
6
+ * WHY A SEPARATE ENTRY FROM `./next`: that module declares ~16 `next/font/google`
7
+ * faces for the theme / DESIGN.md registry. Importing it just to get the CJK face
8
+ * would drag those build-time Google downloads into every app — and this repo has
9
+ * a known trap where `next/font/google` fails outright in a network-sandboxed dev
10
+ * server. This file imports `next/font/local` ONLY: the woff2 files live in the
11
+ * workspace `generated/` directory (not the npm tarball), so first-party apps
12
+ * work offline, in CI, and in the sandbox. `./next` re-exports it,
13
+ * so an app already applying `fontRegistryClassName` still only needs one import.
14
+ *
15
+ * WHY SELF-HOSTED AT ALL: Geist has no CJK coverage, so without this every Chinese
16
+ * character falls back to whatever the OS supplies — PingFang on macOS, Microsoft
17
+ * YaHei on Windows, something else on Android. Chinese copy is a first-class
18
+ * surface here (see docs/microcopy/), so the face is pinned rather than left to
19
+ * the OS.
20
+ *
21
+ * The files are built by `pnpm --filter @nebutra/fonts subset:cjk` from
22
+ * Noto Sans SC (SIL OFL) and live in the workspace `generated/` directory.
23
+ * The literal `src` list below mirrors NOTO_SANS_SC_SOURCES in
24
+ * ../generated/index.ts (a drift test in ./next-cjk.test.ts asserts they agree).
25
+ * It is spelled out rather than spread because next/font is a compile-time
26
+ * transform — SWC statically analyses this call, so the options object cannot
27
+ * be computed.
28
+ */
29
+ /**
30
+ * Noto Sans SC — 400 / 500 / 600 / 700 static subsets.
31
+ *
32
+ * - `preload: false` on purpose. Each weight is hundreds of KB; preloading them
33
+ * on every route would tax Latin-only pages for nothing. The browser fetches
34
+ * a weight only when a glyph in the `unicode-range` below actually renders.
35
+ * - `declarations` carries that `unicode-range` (CJK blocks only — no ASCII, no
36
+ * Latin, no general punctuation), so a Latin-only page can never trigger a CJK
37
+ * download even if a font stack somewhere is written the wrong way round.
38
+ * - `adjustFontFallback: false` — next/font's metric-matched fallback is derived
39
+ * from Arial, which is meaningless for a Han face.
40
+ */
41
+ declare const notoSansSc: next_dist_compiled__next_font.NextFontWithVariable;
42
+ /**
43
+ * Apply to <html> next to the Geist loaders so `--font-noto-sans-sc` exists:
44
+ *
45
+ * className={`${GeistSans.variable} ${GeistMono.variable} ${cjkFontClassName}`}
46
+ *
47
+ * The token stacks (`--font-sans` / `--font-cn` / `--font-display` in
48
+ * @nebutra/tokens) reference the variable AFTER Geist, so Geist keeps Latin and
49
+ * the numerals and only CJK falls through to this face.
50
+ */
51
+ declare const cjkFontClassName: string;
52
+
53
+ export { cjkFontClassName, notoSansSc };
@@ -0,0 +1,9 @@
1
+ import {
2
+ cjkFontClassName,
3
+ notoSansSc
4
+ } from "./chunk-GRE7GS6W.js";
5
+ export {
6
+ cjkFontClassName,
7
+ notoSansSc
8
+ };
9
+ //# sourceMappingURL=next-cjk.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/dist/next.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import * as next_dist_compiled__next_font from 'next/dist/compiled/@next/font';
2
+ export { cjkFontClassName, notoSansSc } from './next-cjk.js';
3
+
4
+ /** All registry faces, in declaration order. */
5
+ declare const FONT_REGISTRY_FACES: readonly [next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable, next_dist_compiled__next_font.NextFontWithVariable];
6
+ /**
7
+ * Space-joined `.variable` classNames for every registry face. Apply to <html>
8
+ * so all `--font-*` registry variables are defined (font files lazy-load on
9
+ * first use). Combine with the app's own Geist faces.
10
+ */
11
+ declare const fontRegistryClassName: string;
12
+
13
+ export { FONT_REGISTRY_FACES, fontRegistryClassName };
package/dist/next.js ADDED
@@ -0,0 +1,123 @@
1
+ import {
2
+ cjkFontClassName,
3
+ notoSansSc
4
+ } from "./chunk-GRE7GS6W.js";
5
+
6
+ // src/next.ts
7
+ import {
8
+ DM_Sans,
9
+ Figtree,
10
+ Fira_Code,
11
+ Fraunces,
12
+ Inter,
13
+ Inter_Tight,
14
+ JetBrains_Mono,
15
+ Lexend,
16
+ Manrope,
17
+ Montserrat,
18
+ Outfit,
19
+ Playfair_Display,
20
+ Plus_Jakarta_Sans,
21
+ Roboto_Mono,
22
+ Sora,
23
+ Source_Code_Pro,
24
+ Source_Serif_4,
25
+ Space_Grotesk,
26
+ Work_Sans
27
+ } from "next/font/google";
28
+ var inter = Inter({ subsets: ["latin"], display: "swap", variable: "--font-inter" });
29
+ var interTight = Inter_Tight({
30
+ subsets: ["latin"],
31
+ display: "swap",
32
+ variable: "--font-reg-inter-tight"
33
+ });
34
+ var spaceGrotesk = Space_Grotesk({
35
+ subsets: ["latin"],
36
+ display: "swap",
37
+ variable: "--font-space-grotesk"
38
+ });
39
+ var playfairDisplay = Playfair_Display({
40
+ subsets: ["latin"],
41
+ display: "swap",
42
+ variable: "--font-playfair-display"
43
+ });
44
+ var fraunces = Fraunces({
45
+ subsets: ["latin"],
46
+ display: "swap",
47
+ variable: "--font-reg-fraunces"
48
+ });
49
+ var jetbrainsMono = JetBrains_Mono({
50
+ subsets: ["latin"],
51
+ display: "swap",
52
+ variable: "--font-jetbrains-mono"
53
+ });
54
+ var manrope = Manrope({ subsets: ["latin"], display: "swap", variable: "--font-reg-manrope" });
55
+ var sora = Sora({ subsets: ["latin"], display: "swap", variable: "--font-reg-sora" });
56
+ var workSans = Work_Sans({
57
+ subsets: ["latin"],
58
+ display: "swap",
59
+ variable: "--font-reg-work-sans"
60
+ });
61
+ var dmSans = DM_Sans({ subsets: ["latin"], display: "swap", variable: "--font-reg-dm-sans" });
62
+ var plusJakartaSans = Plus_Jakarta_Sans({
63
+ subsets: ["latin"],
64
+ display: "swap",
65
+ variable: "--font-reg-plus-jakarta-sans"
66
+ });
67
+ var outfit = Outfit({ subsets: ["latin"], display: "swap", variable: "--font-reg-outfit" });
68
+ var figtree = Figtree({ subsets: ["latin"], display: "swap", variable: "--font-reg-figtree" });
69
+ var montserrat = Montserrat({
70
+ subsets: ["latin"],
71
+ display: "swap",
72
+ variable: "--font-reg-montserrat"
73
+ });
74
+ var lexend = Lexend({ subsets: ["latin"], display: "swap", variable: "--font-reg-lexend" });
75
+ var firaCode = Fira_Code({
76
+ subsets: ["latin"],
77
+ display: "swap",
78
+ variable: "--font-reg-fira-code"
79
+ });
80
+ var robotoMono = Roboto_Mono({
81
+ subsets: ["latin"],
82
+ display: "swap",
83
+ variable: "--font-reg-roboto-mono"
84
+ });
85
+ var sourceSerif4 = Source_Serif_4({
86
+ subsets: ["latin"],
87
+ display: "swap",
88
+ variable: "--font-reg-source-serif-4"
89
+ });
90
+ var sourceCodePro = Source_Code_Pro({
91
+ subsets: ["latin"],
92
+ display: "swap",
93
+ variable: "--font-reg-source-code-pro"
94
+ });
95
+ var FONT_REGISTRY_FACES = [
96
+ inter,
97
+ interTight,
98
+ spaceGrotesk,
99
+ playfairDisplay,
100
+ sourceSerif4,
101
+ fraunces,
102
+ jetbrainsMono,
103
+ manrope,
104
+ sora,
105
+ workSans,
106
+ dmSans,
107
+ plusJakartaSans,
108
+ outfit,
109
+ figtree,
110
+ montserrat,
111
+ lexend,
112
+ firaCode,
113
+ robotoMono,
114
+ sourceCodePro
115
+ ];
116
+ var fontRegistryClassName = FONT_REGISTRY_FACES.map((face) => face.variable).join(" ");
117
+ export {
118
+ FONT_REGISTRY_FACES,
119
+ cjkFontClassName,
120
+ fontRegistryClassName,
121
+ notoSansSc
122
+ };
123
+ //# sourceMappingURL=next.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/next.ts"],"sourcesContent":["/**\n * @nebutra/fonts/next — build-time self-hosted OSS font faces (server-only).\n *\n * Each face is loaded via next/font/google, which downloads the font AT BUILD\n * TIME and self-hosts it from the app's own origin — there are ZERO runtime\n * requests to Google (no IP leak / GDPR concern, no third-party runtime\n * dependency). Each face exposes a CSS variable; the browser only fetches a\n * given font file when an element actually uses that variable, so declaring the\n * whole registry is cheap.\n *\n * Apply `fontRegistryClassName` to <html> so the `--font-*` variables exist;\n * the appearance layer then prepends the matching `var(--font-*)` (see the\n * client-safe map in `@nebutra/fonts`) when a theme / DESIGN.md font matches.\n *\n * All declarations use a literal options object — next/font statically analyses\n * the call, so the config must NOT be computed. Variable fonts omit `weight`.\n * Keep the `variable` names in sync with FONT_REGISTRY in `../index.ts`.\n *\n * The self-hosted Simplified-Chinese face lives in `./next-cjk` (next/font/local,\n * no Google fetch) and is re-exported here for discoverability. It is NOT folded\n * into `fontRegistryClassName`: the CJK face is core typography, not an optional\n * theme face, and every app wires it the same way — `${cjkFontClassName}` on\n * <html> beside the Geist loaders. Apps that only need the CJK face should import\n * `@nebutra/fonts/next/cjk` directly so they don't pull in the Google faces below.\n */\n\nimport {\n DM_Sans,\n Figtree,\n Fira_Code,\n Fraunces,\n Inter,\n Inter_Tight,\n JetBrains_Mono,\n Lexend,\n Manrope,\n Montserrat,\n Outfit,\n Playfair_Display,\n Plus_Jakarta_Sans,\n Roboto_Mono,\n Sora,\n Source_Code_Pro,\n Source_Serif_4,\n Space_Grotesk,\n Work_Sans,\n} from \"next/font/google\";\n\nexport { cjkFontClassName, notoSansSc } from \"./next-cjk\";\n\nconst inter = Inter({ subsets: [\"latin\"], display: \"swap\", variable: \"--font-inter\" });\nconst interTight = Inter_Tight({\n subsets: [\"latin\"],\n display: \"swap\",\n variable: \"--font-reg-inter-tight\",\n});\nconst spaceGrotesk = Space_Grotesk({\n subsets: [\"latin\"],\n display: \"swap\",\n variable: \"--font-space-grotesk\",\n});\nconst playfairDisplay = Playfair_Display({\n subsets: [\"latin\"],\n display: \"swap\",\n variable: \"--font-playfair-display\",\n});\nconst fraunces = Fraunces({\n subsets: [\"latin\"],\n display: \"swap\",\n variable: \"--font-reg-fraunces\",\n});\nconst jetbrainsMono = JetBrains_Mono({\n subsets: [\"latin\"],\n display: \"swap\",\n variable: \"--font-jetbrains-mono\",\n});\nconst manrope = Manrope({ subsets: [\"latin\"], display: \"swap\", variable: \"--font-reg-manrope\" });\nconst sora = Sora({ subsets: [\"latin\"], display: \"swap\", variable: \"--font-reg-sora\" });\nconst workSans = Work_Sans({\n subsets: [\"latin\"],\n display: \"swap\",\n variable: \"--font-reg-work-sans\",\n});\nconst dmSans = DM_Sans({ subsets: [\"latin\"], display: \"swap\", variable: \"--font-reg-dm-sans\" });\nconst plusJakartaSans = Plus_Jakarta_Sans({\n subsets: [\"latin\"],\n display: \"swap\",\n variable: \"--font-reg-plus-jakarta-sans\",\n});\nconst outfit = Outfit({ subsets: [\"latin\"], display: \"swap\", variable: \"--font-reg-outfit\" });\nconst figtree = Figtree({ subsets: [\"latin\"], display: \"swap\", variable: \"--font-reg-figtree\" });\nconst montserrat = Montserrat({\n subsets: [\"latin\"],\n display: \"swap\",\n variable: \"--font-reg-montserrat\",\n});\nconst lexend = Lexend({ subsets: [\"latin\"], display: \"swap\", variable: \"--font-reg-lexend\" });\nconst firaCode = Fira_Code({\n subsets: [\"latin\"],\n display: \"swap\",\n variable: \"--font-reg-fira-code\",\n});\nconst robotoMono = Roboto_Mono({\n subsets: [\"latin\"],\n display: \"swap\",\n variable: \"--font-reg-roboto-mono\",\n});\nconst sourceSerif4 = Source_Serif_4({\n subsets: [\"latin\"],\n display: \"swap\",\n variable: \"--font-reg-source-serif-4\",\n});\nconst sourceCodePro = Source_Code_Pro({\n subsets: [\"latin\"],\n display: \"swap\",\n variable: \"--font-reg-source-code-pro\",\n});\n\n/** All registry faces, in declaration order. */\nexport const FONT_REGISTRY_FACES = [\n inter,\n interTight,\n spaceGrotesk,\n playfairDisplay,\n sourceSerif4,\n fraunces,\n jetbrainsMono,\n manrope,\n sora,\n workSans,\n dmSans,\n plusJakartaSans,\n outfit,\n figtree,\n montserrat,\n lexend,\n firaCode,\n robotoMono,\n sourceCodePro,\n] as const;\n\n/**\n * Space-joined `.variable` classNames for every registry face. Apply to <html>\n * so all `--font-*` registry variables are defined (font files lazy-load on\n * first use). Combine with the app's own Geist faces.\n */\nexport const fontRegistryClassName = FONT_REGISTRY_FACES.map((face) => face.variable).join(\" \");\n"],"mappings":";;;;;;AA0BA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAIP,IAAM,QAAQ,MAAM,EAAE,SAAS,CAAC,OAAO,GAAG,SAAS,QAAQ,UAAU,eAAe,CAAC;AACrF,IAAM,aAAa,YAAY;AAAA,EAC7B,SAAS,CAAC,OAAO;AAAA,EACjB,SAAS;AAAA,EACT,UAAU;AACZ,CAAC;AACD,IAAM,eAAe,cAAc;AAAA,EACjC,SAAS,CAAC,OAAO;AAAA,EACjB,SAAS;AAAA,EACT,UAAU;AACZ,CAAC;AACD,IAAM,kBAAkB,iBAAiB;AAAA,EACvC,SAAS,CAAC,OAAO;AAAA,EACjB,SAAS;AAAA,EACT,UAAU;AACZ,CAAC;AACD,IAAM,WAAW,SAAS;AAAA,EACxB,SAAS,CAAC,OAAO;AAAA,EACjB,SAAS;AAAA,EACT,UAAU;AACZ,CAAC;AACD,IAAM,gBAAgB,eAAe;AAAA,EACnC,SAAS,CAAC,OAAO;AAAA,EACjB,SAAS;AAAA,EACT,UAAU;AACZ,CAAC;AACD,IAAM,UAAU,QAAQ,EAAE,SAAS,CAAC,OAAO,GAAG,SAAS,QAAQ,UAAU,qBAAqB,CAAC;AAC/F,IAAM,OAAO,KAAK,EAAE,SAAS,CAAC,OAAO,GAAG,SAAS,QAAQ,UAAU,kBAAkB,CAAC;AACtF,IAAM,WAAW,UAAU;AAAA,EACzB,SAAS,CAAC,OAAO;AAAA,EACjB,SAAS;AAAA,EACT,UAAU;AACZ,CAAC;AACD,IAAM,SAAS,QAAQ,EAAE,SAAS,CAAC,OAAO,GAAG,SAAS,QAAQ,UAAU,qBAAqB,CAAC;AAC9F,IAAM,kBAAkB,kBAAkB;AAAA,EACxC,SAAS,CAAC,OAAO;AAAA,EACjB,SAAS;AAAA,EACT,UAAU;AACZ,CAAC;AACD,IAAM,SAAS,OAAO,EAAE,SAAS,CAAC,OAAO,GAAG,SAAS,QAAQ,UAAU,oBAAoB,CAAC;AAC5F,IAAM,UAAU,QAAQ,EAAE,SAAS,CAAC,OAAO,GAAG,SAAS,QAAQ,UAAU,qBAAqB,CAAC;AAC/F,IAAM,aAAa,WAAW;AAAA,EAC5B,SAAS,CAAC,OAAO;AAAA,EACjB,SAAS;AAAA,EACT,UAAU;AACZ,CAAC;AACD,IAAM,SAAS,OAAO,EAAE,SAAS,CAAC,OAAO,GAAG,SAAS,QAAQ,UAAU,oBAAoB,CAAC;AAC5F,IAAM,WAAW,UAAU;AAAA,EACzB,SAAS,CAAC,OAAO;AAAA,EACjB,SAAS;AAAA,EACT,UAAU;AACZ,CAAC;AACD,IAAM,aAAa,YAAY;AAAA,EAC7B,SAAS,CAAC,OAAO;AAAA,EACjB,SAAS;AAAA,EACT,UAAU;AACZ,CAAC;AACD,IAAM,eAAe,eAAe;AAAA,EAClC,SAAS,CAAC,OAAO;AAAA,EACjB,SAAS;AAAA,EACT,UAAU;AACZ,CAAC;AACD,IAAM,gBAAgB,gBAAgB;AAAA,EACpC,SAAS,CAAC,OAAO;AAAA,EACjB,SAAS;AAAA,EACT,UAAU;AACZ,CAAC;AAGM,IAAM,sBAAsB;AAAA,EACjC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAOO,IAAM,wBAAwB,oBAAoB,IAAI,CAAC,SAAS,KAAK,QAAQ,EAAE,KAAK,GAAG;","names":[]}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * GENERATED FILE — face metadata for `@nebutra/fonts/next/cjk`.
3
+ * Current binaries are SIL OFL Noto Sans SC (chinese-simplified) woff2 files.
4
+ * Rebuild with `pnpm --filter @nebutra/fonts subset:cjk` when FONTTOOLS_PYTHON
5
+ * is available, or replace the woff2 files from an OFL source.
6
+ */
7
+
8
+ export const NOTO_SANS_SC_VARIABLE = "--font-noto-sans-sc" as const;
9
+
10
+ export const NOTO_SANS_SC_FAMILY = "Noto Sans SC" as const;
11
+
12
+ /** Characters covered per face (catalogs ∪ CJK punctuation ∪ GB2312 level-1). */
13
+ export const NOTO_SANS_SC_CHAR_COUNT = 4282 as const;
14
+
15
+ /** `unicode-range` of every generated @font-face — CJK only, no Latin. */
16
+ export const NOTO_SANS_SC_UNICODE_RANGE =
17
+ "U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF" as const;
18
+
19
+ /** Sources for `next/font/local({ src: [...] })`, paths relative to this file. */
20
+ export const NOTO_SANS_SC_SOURCES = [
21
+ { path: "./noto-sans-sc-400.woff2", weight: "400", style: "normal", bytes: 1142552 },
22
+ { path: "./noto-sans-sc-500.woff2", weight: "500", style: "normal", bytes: 1159128 },
23
+ { path: "./noto-sans-sc-600.woff2", weight: "600", style: "normal", bytes: 1162352 },
24
+ { path: "./noto-sans-sc-700.woff2", weight: "700", style: "normal", bytes: 1172244 },
25
+ ] as const;
@@ -0,0 +1,43 @@
1
+ /*
2
+ * noto-sans-sc.css — self-hosted Noto Sans SC (SIL Open Font License 1.1).
3
+ * See vendor/noto-sans-sc/OFL.txt.
4
+ *
5
+ * STACK ORDER: use "Geist, Noto Sans SC, …" — Geist first so it keeps Latin
6
+ * and the numerals. The unicode-range below contains no Latin, ASCII or
7
+ * general punctuation, so Latin-only text never downloads a CJK file.
8
+ */
9
+ @font-face {
10
+ font-family: "Noto Sans SC";
11
+ font-style: normal;
12
+ font-weight: 400;
13
+ font-display: swap;
14
+ src: url("./noto-sans-sc-400.woff2") format("woff2");
15
+ unicode-range: U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF;
16
+ }
17
+
18
+ @font-face {
19
+ font-family: "Noto Sans SC";
20
+ font-style: normal;
21
+ font-weight: 500;
22
+ font-display: swap;
23
+ src: url("./noto-sans-sc-500.woff2") format("woff2");
24
+ unicode-range: U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF;
25
+ }
26
+
27
+ @font-face {
28
+ font-family: "Noto Sans SC";
29
+ font-style: normal;
30
+ font-weight: 600;
31
+ font-display: swap;
32
+ src: url("./noto-sans-sc-600.woff2") format("woff2");
33
+ unicode-range: U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF;
34
+ }
35
+
36
+ @font-face {
37
+ font-family: "Noto Sans SC";
38
+ font-style: normal;
39
+ font-weight: 700;
40
+ font-display: swap;
41
+ src: url("./noto-sans-sc-700.woff2") format("woff2");
42
+ unicode-range: U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF;
43
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "generatedBy": "packages/design/fonts/scripts/subset-cjk.mjs",
3
+ "source": "Noto Sans SC (SIL Open Font License 1.1)",
4
+ "faces": [
5
+ { "weight": 400, "output": "noto-sans-sc-400.woff2", "bytes": 1142552 },
6
+ { "weight": 500, "output": "noto-sans-sc-500.woff2", "bytes": 1159128 },
7
+ { "weight": 600, "output": "noto-sans-sc-600.woff2", "bytes": 1162352 },
8
+ { "weight": 700, "output": "noto-sans-sc-700.woff2", "bytes": 1172244 }
9
+ ]
10
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@nebutra/fonts",
3
- "version": "0.1.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "description": "Self-hosted OSS font registry — build-time next/font faces + a name→CSS-var resolver so theme / DESIGN.md fonts render with zero runtime external requests",
6
6
  "private": false,
7
7
  "nebutra": {
8
+ "graph": "core",
8
9
  "status": "wip",
9
10
  "productionReady": false,
10
11
  "surface": "support-contract",
@@ -22,19 +23,52 @@
22
23
  "directory": "packages/design/fonts"
23
24
  },
24
25
  "files": [
25
- "src/index.ts",
26
- "src/next.ts"
26
+ "dist",
27
+ "src",
28
+ "generated/noto-sans-sc.css",
29
+ "generated/subset-manifest.json",
30
+ "generated/index.ts",
31
+ "vendor/noto-sans-sc/OFL.txt",
32
+ "NOTICE-FONTS.md",
33
+ "README.md",
34
+ "LICENSE",
35
+ "CHANGELOG.md"
27
36
  ],
28
- "main": "./src/index.ts",
29
- "types": "./src/index.ts",
37
+ "main": "./dist/index.js",
38
+ "types": "./dist/index.d.ts",
30
39
  "exports": {
31
- ".": "./src/index.ts",
32
- "./next": "./src/next.ts"
40
+ ".": {
41
+ "types": "./dist/index.d.ts",
42
+ "import": "./dist/index.js",
43
+ "default": "./dist/index.js"
44
+ },
45
+ "./registry": {
46
+ "types": "./src/index.ts",
47
+ "import": "./src/index.ts",
48
+ "default": "./src/index.ts"
49
+ },
50
+ "./next": {
51
+ "types": "./src/next.ts",
52
+ "import": "./src/next.ts",
53
+ "default": "./src/next.ts"
54
+ },
55
+ "./next/cjk": {
56
+ "types": "./src/next-cjk.ts",
57
+ "import": "./src/next-cjk.ts",
58
+ "default": "./src/next-cjk.ts"
59
+ },
60
+ "./cjk": {
61
+ "types": "./dist/generated/index.d.ts",
62
+ "import": "./dist/generated/index.js",
63
+ "default": "./dist/generated/index.js"
64
+ },
65
+ "./cjk.css": "./generated/noto-sans-sc.css"
33
66
  },
34
67
  "devDependencies": {
35
68
  "@types/react": "^19.2.14",
36
69
  "typescript": "^5.9.3",
37
- "vitest": "^4.1.4"
70
+ "vitest": "^4.1.4",
71
+ "tsup": "^8.5.1"
38
72
  },
39
73
  "peerDependencies": {
40
74
  "next": ">=15",
@@ -44,7 +78,9 @@
44
78
  "access": "public"
45
79
  },
46
80
  "scripts": {
81
+ "subset:cjk": "node scripts/subset-cjk.mjs",
47
82
  "test": "vitest run",
48
- "typecheck": "tsc --noEmit"
83
+ "typecheck": "tsc --noEmit",
84
+ "build": "tsup"
49
85
  }
50
86
  }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { primaryFamily, resolveRegistryVar, withRegistryFont } from "./index";
3
+
4
+ describe("@nebutra/fonts registry", () => {
5
+ it("normalizes the primary family (strips quotes, lowercases)", () => {
6
+ expect(primaryFamily("'Space Grotesk', sans-serif")).toBe("space grotesk");
7
+ expect(primaryFamily("Inter")).toBe("inter");
8
+ expect(primaryFamily('"JetBrains Mono", ui-monospace, monospace')).toBe("jetbrains mono");
9
+ });
10
+
11
+ it("resolves a registered family to its CSS variable", () => {
12
+ expect(resolveRegistryVar("Space Grotesk, sans-serif")).toBe("--font-space-grotesk");
13
+ expect(resolveRegistryVar("'JetBrains Mono', monospace")).toBe("--font-jetbrains-mono");
14
+ expect(resolveRegistryVar("Geist, sans-serif")).toBe("--font-geist-sans");
15
+ });
16
+
17
+ it("returns undefined for an unregistered (e.g. proprietary) family", () => {
18
+ expect(resolveRegistryVar("Airbnb Cereal VF, sans-serif")).toBeUndefined();
19
+ expect(resolveRegistryVar("BMWTypeNext, sans-serif")).toBeUndefined();
20
+ });
21
+
22
+ it("prepends the self-hosted var for a registered family, keeping the fallback", () => {
23
+ expect(withRegistryFont("Space Grotesk, sans-serif")).toBe(
24
+ "var(--font-space-grotesk), Space Grotesk, sans-serif",
25
+ );
26
+ expect(withRegistryFont("'JetBrains Mono', ui-monospace, monospace")).toBe(
27
+ "var(--font-jetbrains-mono), 'JetBrains Mono', ui-monospace, monospace",
28
+ );
29
+ });
30
+
31
+ it("leaves an unregistered stack untouched (graceful fallback)", () => {
32
+ expect(withRegistryFont("Airbnb Cereal, sans-serif")).toBe("Airbnb Cereal, sans-serif");
33
+ });
34
+
35
+ it("handles undefined/empty input safely", () => {
36
+ expect(withRegistryFont(undefined)).toBeUndefined();
37
+ expect(withRegistryFont("")).toBe("");
38
+ });
39
+ });
package/src/index.ts CHANGED
@@ -23,10 +23,17 @@ export const FONT_REGISTRY: Record<string, string> = {
23
23
  geist: "--font-geist-sans",
24
24
  "geist sans": "--font-geist-sans",
25
25
  "geist mono": "--font-geist-mono",
26
+ // Self-hosted via next/font/local from the subset built in ./generated (see
27
+ // ./next-cjk). The Simplified-Chinese face — CJK only via unicode-range, so it
28
+ // cannot take Latin away from Geist.
29
+ "noto sans sc": "--font-noto-sans-sc",
26
30
  // Self-hosted via next/font/google (see ./next)
27
31
  inter: "--font-inter",
32
+ "inter tight": "--font-reg-inter-tight",
28
33
  "space grotesk": "--font-space-grotesk",
29
34
  "playfair display": "--font-playfair-display",
35
+ fraunces: "--font-reg-fraunces",
36
+ "source serif 4": "--font-reg-source-serif-4",
30
37
  "jetbrains mono": "--font-jetbrains-mono",
31
38
  manrope: "--font-reg-manrope",
32
39
  sora: "--font-reg-sora",
@@ -70,3 +77,28 @@ export function withRegistryFont(stack: string | undefined): string | undefined
70
77
  const variable = resolveRegistryVar(stack);
71
78
  return variable ? `var(${variable}), ${stack}` : stack;
72
79
  }
80
+
81
+ /**
82
+ * Like `withRegistryFont`, but matches the first registered family ANYWHERE in
83
+ * the stack rather than only in first position.
84
+ *
85
+ * A brand package names the typeface the design language actually uses, and
86
+ * those are frequently licensed faces we have no right to serve — Söhne, Mori,
87
+ * Lyon Text, Reckless. The declared stack already says what to do when they are
88
+ * absent: fall to the next family. But "the next family" is usually a bare name
89
+ * like `Inter`, which does NOT reach next/font's hashed face, so the stack
90
+ * skidded past every self-hosted option and landed on `ui-sans-serif`. All
91
+ * seven built-in design languages rendered in the system font until 2026-08-18.
92
+ *
93
+ * Prepending the nearest registered family produces exactly the outcome the
94
+ * declared chain intended, and leaves the licensed names in place so a customer
95
+ * who does own the font still gets it by shipping the face themselves.
96
+ */
97
+ export function withNearestRegistryFont(stack: string | undefined): string | undefined {
98
+ if (!stack) return stack;
99
+ for (const token of stack.split(",")) {
100
+ const variable = FONT_REGISTRY[normalizeFamily(token)];
101
+ if (variable) return `var(${variable}), ${stack}`;
102
+ }
103
+ return stack;
104
+ }
@@ -0,0 +1,49 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ NOTO_SANS_SC_SOURCES,
6
+ NOTO_SANS_SC_UNICODE_RANGE,
7
+ NOTO_SANS_SC_VARIABLE,
8
+ } from "../generated/index";
9
+ import { FONT_REGISTRY } from "./index";
10
+
11
+ /**
12
+ * next/font is a compile-time transform: the options object passed to
13
+ * localFont() must be a literal, so ./next-cjk.ts cannot spread the generated
14
+ * metadata. These tests read that file as TEXT (importing it would pull in
15
+ * next/font/local, which only exists inside a Next build) and assert the
16
+ * literals still agree with what the subsetter actually emitted.
17
+ */
18
+ const NEXT_CJK_SOURCE = readFileSync(
19
+ fileURLToPath(new URL("./next-cjk.ts", import.meta.url)),
20
+ "utf8",
21
+ );
22
+
23
+ describe("Noto Sans SC wiring", () => {
24
+ it("declares every generated weight, with the file the subsetter wrote", () => {
25
+ for (const { path, weight } of NOTO_SANS_SC_SOURCES) {
26
+ const file = path.replace(/^\.\//, "");
27
+ expect(NEXT_CJK_SOURCE).toContain(`"../generated/${file}", weight: "${weight}"`);
28
+ }
29
+ });
30
+
31
+ it("declares no weight that has no file (nothing can be synthesised)", () => {
32
+ const declared = [...NEXT_CJK_SOURCE.matchAll(/weight: "(\d+)"/g)].map((m) => m[1]);
33
+ expect(declared.sort()).toEqual(NOTO_SANS_SC_SOURCES.map((s) => s.weight).sort());
34
+ });
35
+
36
+ it("carries the generated unicode-range verbatim (CJK only, no Latin)", () => {
37
+ expect(NEXT_CJK_SOURCE).toContain(NOTO_SANS_SC_UNICODE_RANGE);
38
+ expect(NOTO_SANS_SC_UNICODE_RANGE).not.toMatch(/U\+00[0-7]/i);
39
+ });
40
+
41
+ it("uses the CSS variable the registry and the token stacks reference", () => {
42
+ expect(NEXT_CJK_SOURCE).toContain(`variable: "${NOTO_SANS_SC_VARIABLE}"`);
43
+ expect(FONT_REGISTRY["noto sans sc"]).toBe(NOTO_SANS_SC_VARIABLE);
44
+ });
45
+
46
+ it("never preloads (a CJK weight must be demand-loaded)", () => {
47
+ expect(NEXT_CJK_SOURCE).toContain("preload: false");
48
+ });
49
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @nebutra/fonts/next/cjk — the self-hosted Simplified-Chinese face (server-only).
3
+ *
4
+ * WHY A SEPARATE ENTRY FROM `./next`: that module declares ~16 `next/font/google`
5
+ * faces for the theme / DESIGN.md registry. Importing it just to get the CJK face
6
+ * would drag those build-time Google downloads into every app — and this repo has
7
+ * a known trap where `next/font/google` fails outright in a network-sandboxed dev
8
+ * server. This file imports `next/font/local` ONLY: the woff2 files live in the
9
+ * workspace `generated/` directory (not the npm tarball), so first-party apps
10
+ * work offline, in CI, and in the sandbox. `./next` re-exports it,
11
+ * so an app already applying `fontRegistryClassName` still only needs one import.
12
+ *
13
+ * WHY SELF-HOSTED AT ALL: Geist has no CJK coverage, so without this every Chinese
14
+ * character falls back to whatever the OS supplies — PingFang on macOS, Microsoft
15
+ * YaHei on Windows, something else on Android. Chinese copy is a first-class
16
+ * surface here (see docs/microcopy/), so the face is pinned rather than left to
17
+ * the OS.
18
+ *
19
+ * The files are built by `pnpm --filter @nebutra/fonts subset:cjk` from
20
+ * Noto Sans SC (SIL OFL) and live in the workspace `generated/` directory.
21
+ * The literal `src` list below mirrors NOTO_SANS_SC_SOURCES in
22
+ * ../generated/index.ts (a drift test in ./next-cjk.test.ts asserts they agree).
23
+ * It is spelled out rather than spread because next/font is a compile-time
24
+ * transform — SWC statically analyses this call, so the options object cannot
25
+ * be computed.
26
+ */
27
+
28
+ import localFont from "next/font/local";
29
+
30
+ /**
31
+ * Noto Sans SC — 400 / 500 / 600 / 700 static subsets.
32
+ *
33
+ * - `preload: false` on purpose. Each weight is hundreds of KB; preloading them
34
+ * on every route would tax Latin-only pages for nothing. The browser fetches
35
+ * a weight only when a glyph in the `unicode-range` below actually renders.
36
+ * - `declarations` carries that `unicode-range` (CJK blocks only — no ASCII, no
37
+ * Latin, no general punctuation), so a Latin-only page can never trigger a CJK
38
+ * download even if a font stack somewhere is written the wrong way round.
39
+ * - `adjustFontFallback: false` — next/font's metric-matched fallback is derived
40
+ * from Arial, which is meaningless for a Han face.
41
+ */
42
+ export const notoSansSc = localFont({
43
+ src: [
44
+ { path: "../generated/noto-sans-sc-400.woff2", weight: "400", style: "normal" },
45
+ { path: "../generated/noto-sans-sc-500.woff2", weight: "500", style: "normal" },
46
+ { path: "../generated/noto-sans-sc-600.woff2", weight: "600", style: "normal" },
47
+ { path: "../generated/noto-sans-sc-700.woff2", weight: "700", style: "normal" },
48
+ ],
49
+ declarations: [
50
+ {
51
+ prop: "unicode-range",
52
+ value: "U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF",
53
+ },
54
+ ],
55
+ display: "swap",
56
+ preload: false,
57
+ adjustFontFallback: false,
58
+ variable: "--font-noto-sans-sc",
59
+ });
60
+
61
+ /**
62
+ * Apply to <html> next to the Geist loaders so `--font-noto-sans-sc` exists:
63
+ *
64
+ * className={`${GeistSans.variable} ${GeistMono.variable} ${cjkFontClassName}`}
65
+ *
66
+ * The token stacks (`--font-sans` / `--font-cn` / `--font-display` in
67
+ * @nebutra/tokens) reference the variable AFTER Geist, so Geist keeps Latin and
68
+ * the numerals and only CJK falls through to this face.
69
+ */
70
+ export const cjkFontClassName = notoSansSc.variable;
package/src/next.ts CHANGED
@@ -15,13 +15,22 @@
15
15
  * All declarations use a literal options object — next/font statically analyses
16
16
  * the call, so the config must NOT be computed. Variable fonts omit `weight`.
17
17
  * Keep the `variable` names in sync with FONT_REGISTRY in `../index.ts`.
18
+ *
19
+ * The self-hosted Simplified-Chinese face lives in `./next-cjk` (next/font/local,
20
+ * no Google fetch) and is re-exported here for discoverability. It is NOT folded
21
+ * into `fontRegistryClassName`: the CJK face is core typography, not an optional
22
+ * theme face, and every app wires it the same way — `${cjkFontClassName}` on
23
+ * <html> beside the Geist loaders. Apps that only need the CJK face should import
24
+ * `@nebutra/fonts/next/cjk` directly so they don't pull in the Google faces below.
18
25
  */
19
26
 
20
27
  import {
21
28
  DM_Sans,
22
29
  Figtree,
23
30
  Fira_Code,
31
+ Fraunces,
24
32
  Inter,
33
+ Inter_Tight,
25
34
  JetBrains_Mono,
26
35
  Lexend,
27
36
  Manrope,
@@ -32,11 +41,19 @@ import {
32
41
  Roboto_Mono,
33
42
  Sora,
34
43
  Source_Code_Pro,
44
+ Source_Serif_4,
35
45
  Space_Grotesk,
36
46
  Work_Sans,
37
47
  } from "next/font/google";
38
48
 
49
+ export { cjkFontClassName, notoSansSc } from "./next-cjk";
50
+
39
51
  const inter = Inter({ subsets: ["latin"], display: "swap", variable: "--font-inter" });
52
+ const interTight = Inter_Tight({
53
+ subsets: ["latin"],
54
+ display: "swap",
55
+ variable: "--font-reg-inter-tight",
56
+ });
40
57
  const spaceGrotesk = Space_Grotesk({
41
58
  subsets: ["latin"],
42
59
  display: "swap",
@@ -47,6 +64,11 @@ const playfairDisplay = Playfair_Display({
47
64
  display: "swap",
48
65
  variable: "--font-playfair-display",
49
66
  });
67
+ const fraunces = Fraunces({
68
+ subsets: ["latin"],
69
+ display: "swap",
70
+ variable: "--font-reg-fraunces",
71
+ });
50
72
  const jetbrainsMono = JetBrains_Mono({
51
73
  subsets: ["latin"],
52
74
  display: "swap",
@@ -83,6 +105,11 @@ const robotoMono = Roboto_Mono({
83
105
  display: "swap",
84
106
  variable: "--font-reg-roboto-mono",
85
107
  });
108
+ const sourceSerif4 = Source_Serif_4({
109
+ subsets: ["latin"],
110
+ display: "swap",
111
+ variable: "--font-reg-source-serif-4",
112
+ });
86
113
  const sourceCodePro = Source_Code_Pro({
87
114
  subsets: ["latin"],
88
115
  display: "swap",
@@ -92,8 +119,11 @@ const sourceCodePro = Source_Code_Pro({
92
119
  /** All registry faces, in declaration order. */
93
120
  export const FONT_REGISTRY_FACES = [
94
121
  inter,
122
+ interTight,
95
123
  spaceGrotesk,
96
124
  playfairDisplay,
125
+ sourceSerif4,
126
+ fraunces,
97
127
  jetbrainsMono,
98
128
  manrope,
99
129
  sora,
@@ -0,0 +1,92 @@
1
+ This Font Software is licensed under the SIL Open Font License,
2
+ Version 1.1.
3
+
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+ -----------------------------------------------------------
8
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
9
+ -----------------------------------------------------------
10
+
11
+ PREAMBLE
12
+ The goals of the Open Font License (OFL) are to stimulate worldwide
13
+ development of collaborative font projects, to support the font
14
+ creation efforts of academic and linguistic communities, and to
15
+ provide a free and open framework in which fonts may be shared and
16
+ improved in partnership with others.
17
+
18
+ The OFL allows the licensed fonts to be used, studied, modified and
19
+ redistributed freely as long as they are not sold by themselves. The
20
+ fonts, including any derivative works, can be bundled, embedded,
21
+ redistributed and/or sold with any software provided that any reserved
22
+ names are not used by derivative works. The fonts and derivatives,
23
+ however, cannot be released under any other type of license. The
24
+ requirement for fonts to remain under this license does not apply to
25
+ any document created using the fonts or their derivatives.
26
+
27
+ DEFINITIONS
28
+ "Font Software" refers to the set of files released by the Copyright
29
+ Holder(s) under this license and clearly marked as such. This may
30
+ include source files, build scripts and documentation.
31
+
32
+ "Reserved Font Name" refers to any names specified as such after the
33
+ copyright statement(s).
34
+
35
+ "Original Version" refers to the collection of Font Software
36
+ components as distributed by the Copyright Holder(s).
37
+
38
+ "Modified Version" refers to any derivative made by adding to,
39
+ deleting, or substituting -- in part or in whole -- any of the
40
+ components of the Original Version, by changing formats or by porting
41
+ the Font Software to a new environment.
42
+
43
+ "Author" refers to any designer, engineer, programmer, technical
44
+ writer or other person who contributed to the Font Software.
45
+
46
+ PERMISSION & CONDITIONS
47
+ Permission is hereby granted, free of charge, to any person obtaining
48
+ a copy of the Font Software, to use, study, copy, merge, embed,
49
+ modify, redistribute, and sell modified and unmodified copies of the
50
+ Font Software, subject to the following conditions:
51
+
52
+ 1) Neither the Font Software nor any of its individual components, in
53
+ Original or Modified Versions, may be sold by itself.
54
+
55
+ 2) Original or Modified Versions of the Font Software may be bundled,
56
+ redistributed and/or sold with any software, provided that each copy
57
+ contains the above copyright notice and this license. These can be
58
+ included either as stand-alone text files, human-readable headers or
59
+ in the appropriate machine-readable metadata fields within text or
60
+ binary files as long as those fields can be easily viewed by the user.
61
+
62
+ 3) No Modified Version of the Font Software may use the Reserved Font
63
+ Name(s) unless explicit written permission is granted by the
64
+ corresponding Copyright Holder. This restriction only applies to the
65
+ primary font name as presented to the users.
66
+
67
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
68
+ Software shall not be used to promote, endorse or advertise any
69
+ Modified Version, except to acknowledge the contribution(s) of the
70
+ Copyright Holder(s) and the Author(s) or with their explicit written
71
+ permission.
72
+
73
+ 5) The Font Software, modified or unmodified, in part or in whole,
74
+ must be distributed entirely under this license, and must not be
75
+ distributed under any other license. The requirement for fonts to
76
+ remain under this license does not apply to any document created using
77
+ the Font Software.
78
+
79
+ TERMINATION
80
+ This license becomes null and void if any of the above conditions are
81
+ not met.
82
+
83
+ DISCLAIMER
84
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
88
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92
+ OTHER DEALINGS IN THE FONT SOFTWARE.