Skip to content

Commit 9295cc1

Browse files
authored
feat: add Noto Sans as default admin UI font with multi-script support (emdash-cms#600)
* feat: add Noto Sans as default admin UI font with multi-script support * fix: address PR review feedback on font provider
1 parent 8fb93eb commit 9295cc1

7 files changed

Lines changed: 333 additions & 3 deletions

File tree

.changeset/wet-kings-bake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": minor
3+
---
4+
5+
Adds Noto Sans as the default admin UI font via the Astro Font API. Fonts are downloaded from Google at build time and self-hosted. The base font covers Latin, Cyrillic, Greek, Devanagari, and Vietnamese. Additional scripts (Arabic, CJK, Hebrew, Thai, etc.) can be added via the new `fonts.scripts` config option. Set `fonts: false` to disable and use system fonts.

docs/src/content/docs/reference/configuration.mdx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,36 @@ import seoPlugin from "@emdash-cms/plugin-seo";
9898
plugins: [seoPlugin()];
9999
```
100100

101+
### `fonts`
102+
103+
**Optional.** Admin UI font configuration.
104+
105+
By default, EmDash loads [Noto Sans](https://fonts.google.com/noto/specimen/Noto+Sans) via the [Astro Font API](https://docs.astro.build/en/guides/fonts/). Fonts are downloaded from Google at build time and self-hosted, so there are no runtime CDN requests. The base font covers Latin, Cyrillic, Greek, Devanagari, and Vietnamese scripts.
106+
107+
To add support for additional writing systems, pass script names:
108+
109+
```js
110+
emdash({
111+
fonts: {
112+
scripts: ["arabic", "japanese"],
113+
},
114+
})
115+
```
116+
117+
Available scripts: `arabic`, `armenian`, `bengali`, `chinese-simplified`, `chinese-traditional`, `chinese-hongkong`, `devanagari`, `ethiopic`, `georgian`, `gujarati`, `gurmukhi`, `hebrew`, `japanese`, `kannada`, `khmer`, `korean`, `lao`, `malayalam`, `myanmar`, `oriya`, `sinhala`, `tamil`, `telugu`, `thai`, `tibetan`.
118+
119+
Each script maps to the corresponding Noto Sans variant on Google Fonts (e.g. `"arabic"` loads Noto Sans Arabic). All font faces share a single `font-family` name and use `unicode-range` so the browser only downloads the files it needs for the characters on the page.
120+
121+
Set to `false` to disable font injection entirely and use system fonts:
122+
123+
```js
124+
emdash({
125+
fonts: false,
126+
})
127+
```
128+
129+
The admin CSS uses the `--font-emdash` CSS variable. This is set automatically by the font configuration above.
130+
101131
### `auth`
102132

103133
**Optional.** Authentication configuration.

packages/admin/src/styles.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,30 @@
7272
--text-color-kumo-warning: #dba617;
7373
}
7474

75+
/**
76+
* Admin font stack
77+
*
78+
* --font-emdash is set by the Astro Font API (Noto Sans by default).
79+
* To add extra script coverage (Arabic, CJK, etc.), configure EmDash with
80+
* emdash({ fonts: { scripts: [...] } }) rather than adding another font entry
81+
* that targets --font-emdash. If you need to supply your own font entry and
82+
* CSS variable instead, disable EmDash-managed fonts first with fonts: false.
83+
*
84+
* Override Tailwind's --font-sans so all font-sans utilities use it.
85+
*/
86+
@theme {
87+
--font-sans: var(
88+
--font-emdash,
89+
ui-sans-serif,
90+
system-ui,
91+
sans-serif,
92+
"Apple Color Emoji",
93+
"Segoe UI Emoji",
94+
"Segoe UI Symbol",
95+
"Noto Color Emoji"
96+
);
97+
}
98+
7599
/* Base styles */
76100
* {
77101
border-color: var(--color-kumo-line);
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* EmDash Noto Sans font provider
3+
*
4+
* A custom Astro font provider that wraps Google Fonts to resolve
5+
* multiple Noto Sans families (Latin, Arabic, JP, etc.) under a
6+
* single logical font entry. This lets all @font-face blocks share
7+
* the same font-family name, so the browser picks the right file
8+
* per character via unicode-range.
9+
*
10+
* Without this, registering "Noto Sans" and "Noto Sans Arabic" as
11+
* separate font entries on the same cssVariable triggers an Astro
12+
* warning and the last entry overwrites the first.
13+
*/
14+
15+
import { fontProviders } from "astro/config";
16+
17+
/**
18+
* All subset names used by Google Fonts CSS responses.
19+
* Passed when resolving extra script families so the unifont
20+
* provider doesn't filter out any faces.
21+
*/
22+
const ALL_GOOGLE_SUBSETS = [
23+
"arabic",
24+
"armenian",
25+
"bengali",
26+
"chinese-simplified",
27+
"chinese-traditional",
28+
"chinese-hongkong",
29+
"cyrillic",
30+
"cyrillic-ext",
31+
"devanagari",
32+
"ethiopic",
33+
"georgian",
34+
"greek",
35+
"greek-ext",
36+
"gujarati",
37+
"gurmukhi",
38+
"hebrew",
39+
"japanese",
40+
"kannada",
41+
"khmer",
42+
"korean",
43+
"lao",
44+
"latin",
45+
"latin-ext",
46+
"malayalam",
47+
"math",
48+
"myanmar",
49+
"oriya",
50+
"sinhala",
51+
"symbols",
52+
"tamil",
53+
"telugu",
54+
"thai",
55+
"tibetan",
56+
"vietnamese",
57+
];
58+
59+
/**
60+
* Known Noto Sans script families on Google Fonts.
61+
* Maps user-friendly script names to Google Fonts family names.
62+
*/
63+
const NOTO_SCRIPT_FAMILIES: Record<string, string> = {
64+
arabic: "Noto Sans Arabic",
65+
armenian: "Noto Sans Armenian",
66+
bengali: "Noto Sans Bengali",
67+
"chinese-simplified": "Noto Sans SC",
68+
"chinese-traditional": "Noto Sans TC",
69+
"chinese-hongkong": "Noto Sans HK",
70+
devanagari: "Noto Sans Devanagari",
71+
ethiopic: "Noto Sans Ethiopic",
72+
georgian: "Noto Sans Georgian",
73+
gujarati: "Noto Sans Gujarati",
74+
gurmukhi: "Noto Sans Gurmukhi",
75+
hebrew: "Noto Sans Hebrew",
76+
japanese: "Noto Sans JP",
77+
kannada: "Noto Sans Kannada",
78+
khmer: "Noto Sans Khmer",
79+
korean: "Noto Sans KR",
80+
lao: "Noto Sans Lao",
81+
malayalam: "Noto Sans Malayalam",
82+
myanmar: "Noto Sans Myanmar",
83+
oriya: "Noto Sans Oriya",
84+
sinhala: "Noto Sans Sinhala",
85+
tamil: "Noto Sans Tamil",
86+
telugu: "Noto Sans Telugu",
87+
thai: "Noto Sans Thai",
88+
tibetan: "Noto Sans Tibetan",
89+
};
90+
91+
export interface NotoSansProviderOptions {
92+
/**
93+
* Additional Noto Sans script families to include.
94+
* Use script names like "arabic", "japanese", "chinese-simplified".
95+
*
96+
* @see {@link NOTO_SCRIPT_FAMILIES} for the full list of supported scripts.
97+
*/
98+
scripts?: string[];
99+
}
100+
101+
// Use ReturnType to get the provider type without importing it directly.
102+
// The Astro FontProvider type is not part of the public API surface.
103+
type GoogleProvider = ReturnType<typeof fontProviders.google>;
104+
105+
/**
106+
* Create a font provider that resolves Noto Sans plus additional
107+
* script-specific Noto families from Google Fonts, all under one
108+
* font-family name.
109+
*/
110+
export function notoSans(options?: NotoSansProviderOptions): GoogleProvider {
111+
// Create a single Google provider instance to share initialization
112+
const googleProvider = fontProviders.google();
113+
114+
return {
115+
name: "emdash-noto",
116+
async init(context) {
117+
await googleProvider.init?.(context);
118+
},
119+
async resolveFont(resolveFontOptions) {
120+
// Resolve the base Noto Sans (Latin, Cyrillic, Greek, etc.)
121+
const base = await googleProvider.resolveFont(resolveFontOptions);
122+
const baseFonts = base?.fonts ?? [];
123+
124+
if (!options?.scripts?.length) {
125+
return base;
126+
}
127+
128+
// Collect subset names already covered by the base font so we
129+
// can filter out duplicate faces from extra script families.
130+
// e.g. Noto Sans Arabic includes latin/latin-ext faces that
131+
// would otherwise override the base Noto Sans latin faces.
132+
const baseSubsets = new Set(baseFonts.map((f) => f.meta?.subset).filter(Boolean));
133+
134+
// Resolve additional script families
135+
const extraFonts = await Promise.all(
136+
options.scripts.map(async (script) => {
137+
const family = NOTO_SCRIPT_FAMILIES[script];
138+
if (!family) {
139+
// Silently skip subset names that are already covered
140+
// by the base Noto Sans font (latin, cyrillic, etc.)
141+
if (ALL_GOOGLE_SUBSETS.includes(script)) {
142+
return undefined;
143+
}
144+
console.warn(
145+
`[emdash] Unknown Noto Sans script "${script}". ` +
146+
`Available: ${Object.keys(NOTO_SCRIPT_FAMILIES).join(", ")}`,
147+
);
148+
return undefined;
149+
}
150+
return googleProvider.resolveFont({
151+
...resolveFontOptions,
152+
familyName: family,
153+
// Pass all known subset names so the unifont provider
154+
// doesn't filter out any faces. Each script family
155+
// only returns faces for its own subsets anyway.
156+
subsets: ALL_GOOGLE_SUBSETS,
157+
});
158+
}),
159+
);
160+
161+
// Merge, dropping faces from extra fonts that duplicate base subsets
162+
const extraFaces = extraFonts.flatMap((r) =>
163+
(r?.fonts ?? []).filter((f) => !f.meta?.subset || !baseSubsets.has(f.meta.subset)),
164+
);
165+
166+
return {
167+
fonts: [...baseFonts, ...extraFaces],
168+
};
169+
},
170+
};
171+
}
172+
173+
/** Get the list of available Noto Sans script names */
174+
export function getAvailableNotoScripts(): string[] {
175+
return Object.keys(NOTO_SCRIPT_FAMILIES);
176+
}

packages/core/src/astro/integration/index.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { AstroIntegration, AstroIntegrationLogger } from "astro";
1414

1515
import type { ResolvedPlugin } from "../../plugins/types.js";
1616
import { local } from "../storage/adapters.js";
17+
import { notoSans } from "./font-provider.js";
1718
import { injectCoreRoutes, injectBuiltinAuthRoutes, injectMcpRoute } from "./routes.js";
1819
import type { EmDashConfig, PluginDescriptor } from "./runtime.js";
1920
import { createViteConfig } from "./vite-config.js";
@@ -207,8 +208,48 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
207208
? { allowedDomains: [{ hostname: new URL(resolvedConfig.siteUrl).hostname }] }
208209
: {}),
209210
};
211+
212+
// Inject default Noto Sans font for the admin UI.
213+
// Uses the Astro Font API so fonts are downloaded at build time
214+
// and self-hosted (no runtime CDN requests).
215+
//
216+
// The admin CSS references var(--font-emdash) with a system font
217+
// fallback. Users can add extra script coverage (Arabic, CJK, etc.)
218+
// by passing fonts.scripts in the emdash() config. The custom
219+
// notoSans provider resolves all script families from Google Fonts
220+
// under a single font-family name, so they stack via unicode-range.
221+
const fontsConfig = resolvedConfig.fonts;
222+
const emdashFonts =
223+
fontsConfig === false
224+
? []
225+
: [
226+
{
227+
provider: notoSans({
228+
scripts: fontsConfig?.scripts,
229+
}),
230+
name: "Noto Sans",
231+
cssVariable: "--font-emdash",
232+
weights: ["100 900" as const],
233+
styles: ["normal" as const, "italic" as const],
234+
subsets: [
235+
"latin" as const,
236+
"latin-ext" as const,
237+
"cyrillic" as const,
238+
"cyrillic-ext" as const,
239+
"devanagari" as const,
240+
"greek" as const,
241+
"greek-ext" as const,
242+
"vietnamese" as const,
243+
],
244+
fallbacks: ["ui-sans-serif", "system-ui", "sans-serif"],
245+
},
246+
];
247+
210248
updateConfig({
211249
security: securityConfig,
250+
// fonts is a valid AstroConfig key but may not be in the
251+
// type definition for the minimum supported Astro version
252+
...({ fonts: emdashFonts } as Record<string, unknown>),
212253
vite: createViteConfig(
213254
{
214255
serializableConfig,

packages/core/src/astro/integration/runtime.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,56 @@ export interface EmDashConfig {
322322
* ```
323323
*/
324324
mediaProviders?: MediaProviderDescriptor[];
325+
326+
/**
327+
* Admin UI font configuration.
328+
*
329+
* By default, EmDash loads Noto Sans via the Astro Font API, covering
330+
* Latin, Latin Extended, Cyrillic, Cyrillic Extended, Greek, Greek
331+
* Extended, Devanagari, and Vietnamese. Fonts are downloaded from
332+
* Google at build time and self-hosted, so there are no runtime CDN
333+
* requests.
334+
*
335+
* To add support for additional writing systems (Arabic, CJK, etc.),
336+
* pass script names. EmDash resolves the matching Noto Sans variant
337+
* from Google Fonts and merges all script faces under a single
338+
* font-family, so the browser downloads only the glyphs it needs
339+
* via unicode-range.
340+
*
341+
* Set to `false` to disable font injection entirely and use system fonts.
342+
*
343+
* @example
344+
* ```ts
345+
* // Add Arabic and Japanese support
346+
* emdash({
347+
* fonts: {
348+
* scripts: ["arabic", "japanese"],
349+
* },
350+
* })
351+
* ```
352+
*
353+
* @example
354+
* ```ts
355+
* // Disable web fonts entirely (use system fonts)
356+
* emdash({
357+
* fonts: false,
358+
* })
359+
* ```
360+
*/
361+
fonts?:
362+
| false
363+
| {
364+
/**
365+
* Additional Noto Sans script families to include.
366+
*
367+
* Available scripts: arabic, armenian, bengali, chinese-simplified,
368+
* chinese-traditional, chinese-hongkong, devanagari, ethiopic,
369+
* georgian, gujarati, gurmukhi, hebrew, japanese, kannada, khmer,
370+
* korean, lao, malayalam, myanmar, oriya, sinhala, tamil, telugu,
371+
* thai, tibetan.
372+
*/
373+
scripts?: string[];
374+
};
325375
}
326376

327377
/**

packages/core/src/astro/routes/admin.astro

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import "@emdash-cms/admin/styles.css";
99
// Use package-qualified import so Astro generates a proper module URL
1010
// (relative imports resolve to absolute paths which break client hydration)
1111
import AdminWrapper from "emdash/routes/PluginRegistry";
12+
import { Font } from "astro:assets";
1213
1314
export const prerender = false;
1415
@@ -24,6 +25,7 @@ const messages = await loadMessages(resolvedLocale);
2425
<head>
2526
<meta charset="UTF-8" />
2627
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
28+
<Font cssVariable="--font-emdash" />
2729
<link
2830
rel="icon"
2931
href="data:image/svg+xml,<svg width='75' height='75' viewBox='0 0 75 75' fill='none' xmlns='http://www.w3.org/2000/svg'> <g clip-path='url(%23clip0_50_99)'> <rect x='3' y='3' width='69' height='69' rx='10.518' stroke='url(%23paint0_linear_50_99)' stroke-width='6'/> <rect x='18' y='34' width='39.3661' height='6.56101' fill='url(%23paint1_linear_50_99)'/> </g> <defs> <linearGradient id='paint0_linear_50_99' x1='-42.9996' y1='124' x2='92.4233' y2='-41.7456' gradientUnits='userSpaceOnUse'> <stop stop-color='%230F006B'/> <stop offset='0.0833333' stop-color='%23281A81'/> <stop offset='0.166667' stop-color='%235D0C83'/> <stop offset='0.25' stop-color='%23911475'/> <stop offset='0.333333' stop-color='%23CE2F55'/> <stop offset='0.416667' stop-color='%23FF6633'/> <stop offset='0.5' stop-color='%23F6821F'/> <stop offset='0.583333' stop-color='%23FBAD41'/> <stop offset='0.666667' stop-color='%23FFCD89'/> <stop offset='0.75' stop-color='%23FFE9CB'/> <stop offset='0.833333' stop-color='%23FFF7EC'/> <stop offset='0.916667' stop-color='%23FFF8EE'/> <stop offset='1' stop-color='white'/> </linearGradient> <linearGradient id='paint1_linear_50_99' x1='91.4992' y1='27.4982' x2='28.1217' y2='54.1775' gradientUnits='userSpaceOnUse'> <stop stop-color='white'/> <stop offset='0.129253' stop-color='%23FFF8EE'/> <stop offset='0.617058' stop-color='%23FBAD41'/> <stop offset='0.848019' stop-color='%23F6821F'/> <stop offset='1' stop-color='%23FF6633'/> </linearGradient> <clipPath id='clip0_50_99'> <rect width='75' height='75' fill='white'/> </clipPath> </defs> </svg>"
@@ -63,10 +65,12 @@ const messages = await loadMessages(resolvedLocale);
6365
}
6466
#emdash-boot-loader p {
6567
margin-top: 1rem;
66-
font-family:
68+
font-family: var(
69+
--font-emdash,
70+
ui-sans-serif,
6771
system-ui,
68-
-apple-system,
69-
sans-serif;
72+
sans-serif
73+
);
7074
font-size: 0.875rem;
7175
color: light-dark(hsl(215.4 16.3% 46.9%), hsl(215 20.2% 65.1%));
7276
}

0 commit comments

Comments
 (0)