@astryxdesign/cli 0.4.1 → 0.4.2-canary.356d2f9

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -0
  3. package/api/init/init.doc.mjs +2 -2
  4. package/api/init/init.test.mjs +19 -2
  5. package/api/init/init.type.d.mts +9 -1
  6. package/api/init/init.type.mjs +3 -1
  7. package/api/init/run/run.mjs +36 -6
  8. package/api/theme/add/add.mjs +2 -13
  9. package/api/theme/build/build.font-warning.test.mjs +161 -0
  10. package/api/theme/build/build.mjs +95 -26
  11. package/api/theme/build/build.test.mjs +227 -0
  12. package/api/theme/build/font-warning.d.mts +26 -0
  13. package/api/theme/build/font-warning.mjs +214 -0
  14. package/api/theme/build/font-warning.test.mjs +242 -0
  15. package/api/theme/template/template.d.mts +21 -0
  16. package/api/theme/template/template.mjs +69 -0
  17. package/api/theme/template/template.test.mjs +82 -0
  18. package/api/theme/theme.d.mts +1 -0
  19. package/api/theme/theme.mjs +1 -0
  20. package/api/theme/theme.type.d.mts +13 -0
  21. package/api/theme/theme.type.mjs +11 -1
  22. package/api/theme/themeTemplate.doc.d.mts +11 -0
  23. package/api/theme/themeTemplate.doc.mjs +64 -0
  24. package/assets/docs/getting-started.doc.mjs +11 -1
  25. package/assets/docs/theme.doc.dense.mjs +2 -2
  26. package/assets/docs/theme.doc.mjs +26 -10
  27. package/assets/docs/theme.doc.zh.mjs +1 -1
  28. package/assets/docs/typography.doc.mjs +38 -0
  29. package/assets/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleCustomContent.doc.mjs +13 -0
  30. package/assets/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleCustomContent.tsx +55 -0
  31. package/assets/templates/pages/ai-chat/page.tsx +18 -13
  32. package/assets/templates/themes/butter/butterTheme.ts +4 -1
  33. package/assets/templates/themes/chocolate/chocolateTheme.ts +4 -1
  34. package/assets/templates/themes/gothic/gothicTheme.ts +4 -1
  35. package/assets/templates/themes/stone/stoneTheme.ts +4 -1
  36. package/assets/theme.template.ts +322 -0
  37. package/authoring/doctypes/base/type.ts +9 -1
  38. package/authoring/doctypes/component/component.doc.mjs +1 -1
  39. package/clients/cli/commands/build-theme.font-warning.test.mjs +138 -0
  40. package/clients/cli/commands/build-theme.mjs +50 -0
  41. package/clients/cli/commands/init.behavior.test.mjs +13 -2
  42. package/clients/cli/commands/theme-template.behavior.test.mjs +85 -0
  43. package/clients/cli/commands/theme-template.doc.mjs +42 -0
  44. package/clients/cli/commands/theme.doc.mjs +2 -2
  45. package/clients/cli/index.mjs +1 -0
  46. package/clients/cli/lib/manifest.mjs +2 -0
  47. package/foundation/agent-docs/agent-docs.mjs +1 -1
  48. package/foundation/response/response-types.doc.mjs +5 -0
  49. package/foundation/text/copyright-header.d.mts +11 -0
  50. package/foundation/text/copyright-header.mjs +35 -0
  51. package/foundation/text/copyright-header.test.mjs +58 -0
  52. 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
+ }