@astryxdesign/cli 0.4.1 → 0.4.2-canary.10bcaa1
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 +34 -0
- package/README.md +1 -0
- package/api/init/init.doc.mjs +2 -2
- package/api/init/init.test.mjs +19 -2
- package/api/init/init.type.d.mts +9 -1
- package/api/init/init.type.mjs +3 -1
- package/api/init/run/run.mjs +36 -6
- package/api/theme/add/add.mjs +2 -13
- package/api/theme/build/build.font-warning.test.mjs +161 -0
- package/api/theme/build/build.mjs +95 -26
- package/api/theme/build/build.test.mjs +227 -0
- package/api/theme/build/font-warning.d.mts +26 -0
- package/api/theme/build/font-warning.mjs +214 -0
- package/api/theme/build/font-warning.test.mjs +242 -0
- package/api/theme/template/template.d.mts +21 -0
- package/api/theme/template/template.mjs +69 -0
- package/api/theme/template/template.test.mjs +82 -0
- package/api/theme/theme.d.mts +1 -0
- package/api/theme/theme.mjs +1 -0
- package/api/theme/theme.type.d.mts +13 -0
- package/api/theme/theme.type.mjs +11 -1
- package/api/theme/themeTemplate.doc.d.mts +11 -0
- package/api/theme/themeTemplate.doc.mjs +64 -0
- package/assets/docs/getting-started.doc.mjs +11 -1
- package/assets/docs/theme.doc.dense.mjs +2 -2
- package/assets/docs/theme.doc.mjs +26 -10
- package/assets/docs/theme.doc.zh.mjs +1 -1
- package/assets/docs/typography.doc.mjs +38 -0
- package/assets/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleCustomContent.doc.mjs +13 -0
- package/assets/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleCustomContent.tsx +55 -0
- package/assets/templates/pages/ai-chat/page.tsx +18 -13
- package/assets/templates/themes/butter/butterTheme.ts +4 -1
- package/assets/templates/themes/chocolate/chocolateTheme.ts +4 -1
- package/assets/templates/themes/gothic/gothicTheme.ts +4 -1
- package/assets/templates/themes/stone/stoneTheme.ts +4 -1
- package/assets/theme.template.ts +322 -0
- package/authoring/doctypes/base/type.ts +9 -1
- package/authoring/doctypes/component/component.doc.mjs +1 -1
- package/clients/cli/commands/build-theme.font-warning.test.mjs +138 -0
- package/clients/cli/commands/build-theme.mjs +50 -0
- package/clients/cli/commands/init.behavior.test.mjs +13 -2
- package/clients/cli/commands/theme-template.behavior.test.mjs +85 -0
- package/clients/cli/commands/theme-template.doc.mjs +42 -0
- package/clients/cli/commands/theme.doc.mjs +2 -2
- package/clients/cli/index.mjs +1 -0
- package/clients/cli/lib/manifest.mjs +2 -0
- package/foundation/agent-docs/agent-docs.mjs +1 -1
- package/foundation/response/response-types.doc.mjs +5 -0
- package/foundation/text/copyright-header.d.mts +11 -0
- package/foundation/text/copyright-header.mjs +35 -0
- package/foundation/text/copyright-header.test.mjs +58 -0
- package/package.json +9 -9
|
@@ -254,6 +254,28 @@ describe('themeBuild() — component override validation', () => {
|
|
|
254
254
|
expect(result?.data.warnings).toEqual([]);
|
|
255
255
|
});
|
|
256
256
|
|
|
257
|
+
it('accepts the heading type rules a type scale generates', async () => {
|
|
258
|
+
// `typography.scale` makes defineTheme emit `heading: {'type:display-1' …}`
|
|
259
|
+
// (Heading renders a `type:` class alongside `level:`), so any theme with a
|
|
260
|
+
// type scale carried override keys the validator called unknown — including
|
|
261
|
+
// the shipped neutralTheme.
|
|
262
|
+
const themeFile = path.join(tmpDir, 'typescale.mjs');
|
|
263
|
+
fs.writeFileSync(
|
|
264
|
+
themeFile,
|
|
265
|
+
`export default {
|
|
266
|
+
name: 'typescale',
|
|
267
|
+
tokens: {'--color-bg': '#0a0a0a'},
|
|
268
|
+
components: {
|
|
269
|
+
heading: {'type:display-1': {letterSpacing: '0.01em'}},
|
|
270
|
+
},
|
|
271
|
+
};\n`,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const result = await themeBuild('typescale.mjs', {}, {cwd: tmpDir});
|
|
275
|
+
|
|
276
|
+
expect(result?.data.warnings).toEqual([]);
|
|
277
|
+
});
|
|
278
|
+
|
|
257
279
|
it('still warns on a key that is neither a visual prop nor a state', async () => {
|
|
258
280
|
// Widening the known set to states must not turn the guard off.
|
|
259
281
|
const themeFile = path.join(tmpDir, 'bogus.mjs');
|
|
@@ -273,3 +295,208 @@ describe('themeBuild() — component override validation', () => {
|
|
|
273
295
|
]);
|
|
274
296
|
});
|
|
275
297
|
});
|
|
298
|
+
|
|
299
|
+
describe('themeBuild() — the shipped theme template', () => {
|
|
300
|
+
// `assets/theme.template.ts` is what `astryx theme template` puts in a
|
|
301
|
+
// consumer's project. It is the one theme file we hand out, so it has to
|
|
302
|
+
// compile as shipped — and cleanly apart from the font warnings it earns on
|
|
303
|
+
// purpose: a template that greets its first reader with warnings teaches them
|
|
304
|
+
// to ignore warnings. The claims its comments make are checked separately by
|
|
305
|
+
// scripts/check-theme-template.test.mjs.
|
|
306
|
+
it('compiles as shipped, warning only about the fonts it deliberately names', async () => {
|
|
307
|
+
const src = path.resolve(
|
|
308
|
+
import.meta.dirname,
|
|
309
|
+
'../../../assets/theme.template.ts',
|
|
310
|
+
);
|
|
311
|
+
fs.copyFileSync(src, path.join(tmpDir, 'theme.template.ts'));
|
|
312
|
+
|
|
313
|
+
const result = await themeBuild('theme.template.ts', {}, {cwd: tmpDir});
|
|
314
|
+
|
|
315
|
+
// The template names Inter and Geist Mono to teach "SHIP THE FONTS YOU
|
|
316
|
+
// NAME", and loads neither — so the unloaded-font warning firing here is
|
|
317
|
+
// the lesson landing, not a defect. Any OTHER warning still fails.
|
|
318
|
+
const unexpected = (result?.data.warnings ?? []).filter(
|
|
319
|
+
w => !/^Font "(Inter|Geist Mono)" is named by this theme but not loaded/.test(w),
|
|
320
|
+
);
|
|
321
|
+
expect(unexpected).toEqual([]);
|
|
322
|
+
expect(result?.data.warnings).toHaveLength(2);
|
|
323
|
+
expect(fs.existsSync(path.join(tmpDir, 'my-theme.css'))).toBe(true);
|
|
324
|
+
// The template teaches custom variants; the augmentation it promises the
|
|
325
|
+
// reader has to actually be generated.
|
|
326
|
+
expect(fs.existsSync(path.join(tmpDir, 'my-theme.variants.d.ts'))).toBe(
|
|
327
|
+
true,
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('themeBuild() — extends', () => {
|
|
333
|
+
// These fixtures `import {defineTheme} from '@astryxdesign/core/theme'` the
|
|
334
|
+
// way a real theme file does, so they have to sit somewhere that specifier
|
|
335
|
+
// resolves — an OS temp dir has no node_modules above it.
|
|
336
|
+
let extDir;
|
|
337
|
+
beforeEach(() => {
|
|
338
|
+
extDir = fs.mkdtempSync(
|
|
339
|
+
path.join(path.resolve(import.meta.dirname, '../../..'), '.tmp-extends-'),
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
afterEach(() => {
|
|
343
|
+
fs.rmSync(extDir, {recursive: true, force: true});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Every `prop: value` a generated stylesheet actually applies. Header
|
|
348
|
+
* comments and scope wrappers are ignored — two themes never share those.
|
|
349
|
+
*/
|
|
350
|
+
function declarations(css) {
|
|
351
|
+
return new Set(
|
|
352
|
+
css
|
|
353
|
+
.split('\n')
|
|
354
|
+
.map(l => l.trim())
|
|
355
|
+
.filter(l => /^[-a-z][^{}]*:.+;$/.test(l)),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
/** Every component rule a stylesheet opens, e.g. `.astryx-switch {`. */
|
|
359
|
+
function selectors(css) {
|
|
360
|
+
return new Set(
|
|
361
|
+
css
|
|
362
|
+
.split('\n')
|
|
363
|
+
.map(l => l.trim())
|
|
364
|
+
.filter(l => l.endsWith('{') && l.startsWith('.')),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** A base theme with geometry, elevation and a component override. */
|
|
369
|
+
const BASE_SOURCE = `export const brandTheme = {
|
|
370
|
+
name: 'ext-base',
|
|
371
|
+
tokens: {
|
|
372
|
+
'--radius-element': '6px',
|
|
373
|
+
'--shadow-low': '0 1px 3px rgb(0 0 0 / 0.1)',
|
|
374
|
+
'--color-border-emphasized': '#D4D4D4',
|
|
375
|
+
},
|
|
376
|
+
components: {
|
|
377
|
+
switch: {base: {backgroundColor: 'var(--color-border-emphasized)'}},
|
|
378
|
+
},
|
|
379
|
+
};\n`;
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The child names its base with a plain relative specifier, exactly as a
|
|
383
|
+
* generated palette does. `theme build` writes `ext-base.js` next to
|
|
384
|
+
* `ext-base.mjs`, so `./ext-base` is ambiguous — and the artifact, which
|
|
385
|
+
* exports `extBaseTheme` rather than `brandTheme`, is the wrong answer.
|
|
386
|
+
*/
|
|
387
|
+
const CHILD_SOURCE = `import {defineTheme} from '@astryxdesign/core/theme';
|
|
388
|
+
import {brandTheme} from './ext-base';
|
|
389
|
+
export const paletteTheme = defineTheme({
|
|
390
|
+
name: 'ext-child',
|
|
391
|
+
extends: brandTheme,
|
|
392
|
+
tokens: {'--color-accent': 'hsl(220 88% 72%)'},
|
|
393
|
+
});\n`;
|
|
394
|
+
|
|
395
|
+
it('emits every declaration its base emits (the child stylesheet is self-contained)', async () => {
|
|
396
|
+
fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
|
|
397
|
+
fs.writeFileSync(path.join(extDir, 'ext-child.mjs'), CHILD_SOURCE);
|
|
398
|
+
|
|
399
|
+
// Build the base FIRST, as any real project does — that write is what
|
|
400
|
+
// used to poison the child's build.
|
|
401
|
+
await themeBuild('ext-base.mjs', {}, {cwd: extDir});
|
|
402
|
+
await themeBuild('ext-child.mjs', {}, {cwd: extDir});
|
|
403
|
+
|
|
404
|
+
const baseCss = fs.readFileSync(path.join(extDir, 'ext-base.css'), 'utf8');
|
|
405
|
+
const childCss = fs.readFileSync(
|
|
406
|
+
path.join(extDir, 'ext-child.css'),
|
|
407
|
+
'utf8',
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
const childDecls = declarations(childCss);
|
|
411
|
+
expect([...declarations(baseCss)].filter(d => !childDecls.has(d))).toEqual(
|
|
412
|
+
[],
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const childSelectors = selectors(childCss);
|
|
416
|
+
expect([...selectors(baseCss)].filter(s => !childSelectors.has(s))).toEqual(
|
|
417
|
+
[],
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
// …and the child's own override still wins.
|
|
421
|
+
expect(childCss).toContain('--color-accent: hsl(220 88% 72%);');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('resolves the base from its source, not from the generated sibling artifact', async () => {
|
|
425
|
+
fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
|
|
426
|
+
fs.writeFileSync(path.join(extDir, 'ext-child.mjs'), CHILD_SOURCE);
|
|
427
|
+
|
|
428
|
+
await themeBuild('ext-base.mjs', {}, {cwd: extDir});
|
|
429
|
+
const result = await themeBuild('ext-child.mjs', {}, {cwd: extDir});
|
|
430
|
+
|
|
431
|
+
expect(result?.data.componentCount).toBe(1);
|
|
432
|
+
expect(result?.data.tokenCount).toBe(4);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('inherits component overrides when the base IS a built theme module', async () => {
|
|
436
|
+
fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
|
|
437
|
+
await themeBuild('ext-base.mjs', {}, {cwd: extDir});
|
|
438
|
+
|
|
439
|
+
// Extending a package's pre-built theme module (e.g. the `./built`
|
|
440
|
+
// subpath the shipped themes expose) must not silently drop its
|
|
441
|
+
// component overrides.
|
|
442
|
+
fs.writeFileSync(
|
|
443
|
+
path.join(extDir, 'ext-built-child.mjs'),
|
|
444
|
+
`import {defineTheme} from '@astryxdesign/core/theme';
|
|
445
|
+
import {extBaseTheme} from './ext-base.js';
|
|
446
|
+
export const builtChildTheme = defineTheme({
|
|
447
|
+
name: 'ext-built-child',
|
|
448
|
+
extends: extBaseTheme,
|
|
449
|
+
});\n`,
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
await themeBuild('ext-built-child.mjs', {}, {cwd: extDir});
|
|
453
|
+
const css = fs.readFileSync(
|
|
454
|
+
path.join(extDir, 'ext-built-child.css'),
|
|
455
|
+
'utf8',
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
expect(css).toContain('.astryx-switch {');
|
|
459
|
+
expect(css).toContain('--radius-element: 6px;');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('resolves extends on a plain object theme file (no defineTheme call)', async () => {
|
|
463
|
+
fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
|
|
464
|
+
fs.writeFileSync(
|
|
465
|
+
path.join(extDir, 'ext-plain.mjs'),
|
|
466
|
+
`import {brandTheme} from './ext-base.mjs';
|
|
467
|
+
export default {
|
|
468
|
+
name: 'ext-plain',
|
|
469
|
+
extends: brandTheme,
|
|
470
|
+
tokens: {'--color-accent': '#ff0000'},
|
|
471
|
+
};\n`,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
await themeBuild('ext-base.mjs', {}, {cwd: extDir});
|
|
475
|
+
await themeBuild('ext-plain.mjs', {}, {cwd: extDir});
|
|
476
|
+
|
|
477
|
+
const css = fs.readFileSync(path.join(extDir, 'ext-plain.css'), 'utf8');
|
|
478
|
+
expect(css).toContain('--radius-element: 6px;');
|
|
479
|
+
expect(css).toContain('.astryx-switch {');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('fails loudly when the base import resolved to nothing', async () => {
|
|
483
|
+
fs.writeFileSync(
|
|
484
|
+
path.join(extDir, 'ext-broken.mjs'),
|
|
485
|
+
`import {defineTheme} from '@astryxdesign/core/theme';
|
|
486
|
+
import {notAThing} from './ext-missing.mjs';
|
|
487
|
+
export const brokenTheme = defineTheme({
|
|
488
|
+
name: 'ext-broken',
|
|
489
|
+
extends: notAThing,
|
|
490
|
+
tokens: {'--color-accent': '#ff0000'},
|
|
491
|
+
});\n`,
|
|
492
|
+
);
|
|
493
|
+
fs.writeFileSync(
|
|
494
|
+
path.join(extDir, 'ext-missing.mjs'),
|
|
495
|
+
`export const somethingElse = 1;\n`,
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
await expect(
|
|
499
|
+
themeBuild('ext-broken.mjs', {}, {cwd: extDir}),
|
|
500
|
+
).rejects.toThrow(/extends/);
|
|
501
|
+
});
|
|
502
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
|
|
2
|
+
// DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Collect the font families a resolved theme names but does not load —
|
|
6
|
+
* every `--font-family-*` token plus every component-override `fontFamily`,
|
|
7
|
+
* minus generics, CSS-wide keywords, `var()` references, and known system
|
|
8
|
+
* families. Source order, first-seen casing, deduped case-insensitively.
|
|
9
|
+
*
|
|
10
|
+
* @param {{tokens?: Record<string, string>, components?: object}} resolvedTheme
|
|
11
|
+
* @returns {string[]}
|
|
12
|
+
*/
|
|
13
|
+
export function collectUnloadedFonts(resolvedTheme: {
|
|
14
|
+
tokens?: Record<string, string>;
|
|
15
|
+
components?: object;
|
|
16
|
+
}): string[];
|
|
17
|
+
/**
|
|
18
|
+
* Render the human fix printed after the install instructions: which fonts
|
|
19
|
+
* the theme names but does not load, the Google Fonts `<link>` recipe, and
|
|
20
|
+
* the self-hosted `@font-face` alternative.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} themeName
|
|
23
|
+
* @param {string[]} families
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
export function formatFontLoadingHelp(themeName: string, families: string[]): string;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Font-loading warning helpers for `astryx theme build` (#5015).
|
|
5
|
+
*
|
|
6
|
+
* Nothing in the pipeline loads font files — defineTheme and the built CSS
|
|
7
|
+
* only set `font-family` — so a theme that names a webfont renders in the
|
|
8
|
+
* fallback typeface on every machine whose app never loads it, and no step
|
|
9
|
+
* says so. These helpers close that gap at build time.
|
|
10
|
+
*
|
|
11
|
+
* `collectUnloadedFonts` inspects a RESOLVED theme (raw typography configs
|
|
12
|
+
* are already collapsed into `--font-family-*` tokens by the time the build
|
|
13
|
+
* sees the theme, so tokens + component-override `fontFamily` values are the
|
|
14
|
+
* complete font surface on both load paths) and returns the families that
|
|
15
|
+
* are neither CSS generics nor known preinstalled system fonts. Unrecognized
|
|
16
|
+
* names are assumed to be webfonts on purpose: a missed warning hides the
|
|
17
|
+
* bug, a spurious one costs a glance.
|
|
18
|
+
*
|
|
19
|
+
* @input A resolved theme object ({tokens, components}) from build.mjs.
|
|
20
|
+
* @output Ordered, case-insensitively deduped family names, and the human
|
|
21
|
+
* help snippet (<link> pair + @font-face) printed after the install
|
|
22
|
+
* instructions.
|
|
23
|
+
* @position Sits beside build.mjs (api/theme/build/). Pure functions, no IO,
|
|
24
|
+
* so the `--json` receipt and the human output share one source of truth.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// font-family values that never need loading: CSS generic families, the ui-*
|
|
28
|
+
// system keywords, and CSS-wide keywords that can appear in a token value.
|
|
29
|
+
const GENERIC_KEYWORDS = new Set([
|
|
30
|
+
'serif',
|
|
31
|
+
'sans-serif',
|
|
32
|
+
'monospace',
|
|
33
|
+
'cursive',
|
|
34
|
+
'fantasy',
|
|
35
|
+
'system-ui',
|
|
36
|
+
'math',
|
|
37
|
+
'emoji',
|
|
38
|
+
'fangsong',
|
|
39
|
+
'ui-serif',
|
|
40
|
+
'ui-sans-serif',
|
|
41
|
+
'ui-monospace',
|
|
42
|
+
'ui-rounded',
|
|
43
|
+
'inherit',
|
|
44
|
+
'initial',
|
|
45
|
+
'unset',
|
|
46
|
+
'revert',
|
|
47
|
+
'revert-layer',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// Families a clean machine already has: the members of core's own default
|
|
51
|
+
// stacks (tokens.stylex.ts typographyDefaults) plus the classic web-safe
|
|
52
|
+
// macOS/Windows set. Deliberately short — an unknown family warns, and for a
|
|
53
|
+
// preinstalled rarity that is a one-line false alarm, not a broken theme.
|
|
54
|
+
const SYSTEM_FAMILIES = new Set([
|
|
55
|
+
'-apple-system',
|
|
56
|
+
'blinkmacsystemfont',
|
|
57
|
+
'segoe ui',
|
|
58
|
+
'segoe ui emoji',
|
|
59
|
+
'segoe ui symbol',
|
|
60
|
+
'roboto',
|
|
61
|
+
'helvetica',
|
|
62
|
+
'helvetica neue',
|
|
63
|
+
'arial',
|
|
64
|
+
'arial black',
|
|
65
|
+
'sf mono',
|
|
66
|
+
'sf pro',
|
|
67
|
+
'sf pro text',
|
|
68
|
+
'sf pro display',
|
|
69
|
+
'monaco',
|
|
70
|
+
'consolas',
|
|
71
|
+
'menlo',
|
|
72
|
+
'courier',
|
|
73
|
+
'courier new',
|
|
74
|
+
'georgia',
|
|
75
|
+
'times',
|
|
76
|
+
'times new roman',
|
|
77
|
+
'verdana',
|
|
78
|
+
'tahoma',
|
|
79
|
+
'trebuchet ms',
|
|
80
|
+
'impact',
|
|
81
|
+
'palatino',
|
|
82
|
+
'cambria',
|
|
83
|
+
'calibri',
|
|
84
|
+
'lucida grande',
|
|
85
|
+
'lucida console',
|
|
86
|
+
'gill sans',
|
|
87
|
+
'brush script mt',
|
|
88
|
+
'snell roundhand',
|
|
89
|
+
'old english text mt',
|
|
90
|
+
// Linux staples — the shipped themes' fallback stacks name these on
|
|
91
|
+
// purpose (e.g. neutral's code stack ends in Liberation Mono).
|
|
92
|
+
'liberation mono',
|
|
93
|
+
'liberation sans',
|
|
94
|
+
'liberation serif',
|
|
95
|
+
'dejavu sans',
|
|
96
|
+
'dejavu sans mono',
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Split a CSS font-family value into clean family names. Complete `var()`
|
|
101
|
+
* calls are stripped first — neither the reference nor its fallback
|
|
102
|
+
* arguments are this theme's own family declarations (the referenced token
|
|
103
|
+
* is checked in its own right) — then names are unquoted and
|
|
104
|
+
* whitespace-collapsed, and any remaining function-shaped fragment is
|
|
105
|
+
* dropped. The unquoted branch of the tokenizer cannot start at whitespace
|
|
106
|
+
* or a quote, so a quoted name is always taken whole even mid-list (commas
|
|
107
|
+
* inside quotes stay inside the name).
|
|
108
|
+
*
|
|
109
|
+
* @param {string} value
|
|
110
|
+
* @returns {string[]}
|
|
111
|
+
*/
|
|
112
|
+
function splitFamilies(value) {
|
|
113
|
+
const families = [];
|
|
114
|
+
const withoutVars = value.replace(/var\([^()]*(?:\([^()]*\)[^()]*)*\)/g, '');
|
|
115
|
+
for (const segment of withoutVars.match(/"[^"]*"|'[^']*'|[^,"'\s][^,]*/g) ??
|
|
116
|
+
[]) {
|
|
117
|
+
let name = segment.trim();
|
|
118
|
+
if (!name) continue;
|
|
119
|
+
// /s: a quoted name may span lines (template-literal theme sources).
|
|
120
|
+
const quoted = /^(["']).*\1$/s.test(name);
|
|
121
|
+
if (quoted) name = name.slice(1, -1);
|
|
122
|
+
name = name.trim().replace(/\s+/g, ' ');
|
|
123
|
+
if (!name || (!quoted && /[()]/.test(name))) continue;
|
|
124
|
+
families.push(name);
|
|
125
|
+
}
|
|
126
|
+
return families;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Collect the font families a resolved theme names but does not load —
|
|
131
|
+
* every `--font-family-*` token plus every component-override `fontFamily`,
|
|
132
|
+
* minus generics, CSS-wide keywords, `var()` references, and known system
|
|
133
|
+
* families. Source order, first-seen casing, deduped case-insensitively.
|
|
134
|
+
*
|
|
135
|
+
* @param {{tokens?: Record<string, string>, components?: object}} resolvedTheme
|
|
136
|
+
* @returns {string[]}
|
|
137
|
+
*/
|
|
138
|
+
export function collectUnloadedFonts(resolvedTheme) {
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
/** @type {string[]} */
|
|
141
|
+
const unloaded = [];
|
|
142
|
+
|
|
143
|
+
/** @param {unknown} value */
|
|
144
|
+
const consider = value => {
|
|
145
|
+
if (typeof value !== 'string') return;
|
|
146
|
+
for (const family of splitFamilies(value)) {
|
|
147
|
+
const key = family.toLowerCase();
|
|
148
|
+
if (GENERIC_KEYWORDS.has(key) || SYSTEM_FAMILIES.has(key)) continue;
|
|
149
|
+
if (seen.has(key)) continue;
|
|
150
|
+
seen.add(key);
|
|
151
|
+
unloaded.push(family);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
for (const [token, value] of Object.entries(resolvedTheme?.tokens ?? {})) {
|
|
156
|
+
if (token.startsWith('--font-family-')) consider(value);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Component overrides are plain CSS maps of unknown depth (style keys,
|
|
160
|
+
// nested at-rule blocks) — walk them and pick up every fontFamily.
|
|
161
|
+
/** @param {unknown} node */
|
|
162
|
+
const walk = node => {
|
|
163
|
+
if (!node || typeof node !== 'object') return;
|
|
164
|
+
for (const [key, value] of Object.entries(node)) {
|
|
165
|
+
if (key === 'fontFamily') consider(value);
|
|
166
|
+
else walk(value);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
walk(resolvedTheme?.components);
|
|
170
|
+
|
|
171
|
+
return unloaded;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Render the human fix printed after the install instructions: which fonts
|
|
176
|
+
* the theme names but does not load, the Google Fonts `<link>` recipe, and
|
|
177
|
+
* the self-hosted `@font-face` alternative.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} themeName
|
|
180
|
+
* @param {string[]} families
|
|
181
|
+
* @returns {string}
|
|
182
|
+
*/
|
|
183
|
+
export function formatFontLoadingHelp(themeName, families) {
|
|
184
|
+
const named = families.map(f => `"${f}"`).join(', ');
|
|
185
|
+
const cssHref =
|
|
186
|
+
'https://fonts.googleapis.com/css2?' +
|
|
187
|
+
families
|
|
188
|
+
.map(f => `family=${encodeURIComponent(f).replace(/%20/g, '+')}`)
|
|
189
|
+
.join('&') +
|
|
190
|
+
'&display=swap';
|
|
191
|
+
const first = families[0];
|
|
192
|
+
const slug = first.toLowerCase().replace(/ /g, '-');
|
|
193
|
+
return `
|
|
194
|
+
⚠ Theme "${themeName}" names fonts it does not load: ${named}
|
|
195
|
+
The built CSS only sets font-family — load these in your app, or every
|
|
196
|
+
browser quietly falls back.
|
|
197
|
+
|
|
198
|
+
Google Fonts (add :wght@… axes as your theme's weights require):
|
|
199
|
+
|
|
200
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
201
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
202
|
+
<link rel="stylesheet" href="${cssHref}" />
|
|
203
|
+
|
|
204
|
+
Self-hosted (repeat per family and weight):
|
|
205
|
+
|
|
206
|
+
@font-face {
|
|
207
|
+
font-family: ${JSON.stringify(first)};
|
|
208
|
+
src: url('/fonts/${slug}.woff2') format('woff2');
|
|
209
|
+
font-display: swap;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
Recipe and fallback-stack guidance: astryx docs typography
|
|
213
|
+
`;
|
|
214
|
+
}
|