@astryxdesign/cli 0.4.1 → 0.4.2-canary.464a445
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 +19 -12
- package/api/theme/build/build.test.mjs +53 -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 +20 -9
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unit tests for the font-loading warning helpers behind `astryx theme build`
|
|
5
|
+
* (#5015). `collectUnloadedFonts()` reads a RESOLVED theme — `--font-family-*`
|
|
6
|
+
* tokens plus component-override `fontFamily` values (typography configs are
|
|
7
|
+
* already collapsed into tokens by the time the build sees the theme) — and
|
|
8
|
+
* returns the families the theme names but nothing loads. CSS generics,
|
|
9
|
+
* CSS-wide keywords, `var()` references, and known preinstalled system
|
|
10
|
+
* families are treated as loaded; anything unrecognized is assumed to be a
|
|
11
|
+
* webfont, because a missed warning is worse than a spurious one.
|
|
12
|
+
* `formatFontLoadingHelp()` renders the human snippet (<link> pair +
|
|
13
|
+
* @font-face) printed after the install instructions.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {describe, it, expect} from 'vitest';
|
|
17
|
+
import {collectUnloadedFonts, formatFontLoadingHelp} from './font-warning.mjs';
|
|
18
|
+
|
|
19
|
+
describe('collectUnloadedFonts', () => {
|
|
20
|
+
it('returns webfont families from font-family tokens, skipping known fallbacks', () => {
|
|
21
|
+
expect(
|
|
22
|
+
collectUnloadedFonts({
|
|
23
|
+
tokens: {'--font-family-body': '"Fraunces", Georgia, serif'},
|
|
24
|
+
}),
|
|
25
|
+
).toEqual(['Fraunces']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns nothing for the default system stacks', () => {
|
|
29
|
+
expect(
|
|
30
|
+
collectUnloadedFonts({
|
|
31
|
+
tokens: {
|
|
32
|
+
'--font-family-body':
|
|
33
|
+
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
|
34
|
+
'--font-family-code': '"SF Mono", Monaco, Consolas, monospace',
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('treats bare system keywords like ui-monospace as loaded', () => {
|
|
41
|
+
expect(
|
|
42
|
+
collectUnloadedFonts({tokens: {'--font-family-code': 'ui-monospace'}}),
|
|
43
|
+
).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('handles single-quoted and unquoted multi-word family names', () => {
|
|
47
|
+
expect(
|
|
48
|
+
collectUnloadedFonts({
|
|
49
|
+
tokens: {
|
|
50
|
+
'--font-family-body': "'JetBrains Mono', monospace",
|
|
51
|
+
'--font-family-heading': 'Space Grotesk, sans-serif',
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
).toEqual(['JetBrains Mono', 'Space Grotesk']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('dedupes case-insensitively across roles, keeping the first casing', () => {
|
|
58
|
+
expect(
|
|
59
|
+
collectUnloadedFonts({
|
|
60
|
+
tokens: {
|
|
61
|
+
'--font-family-body': '"Space Grotesk", sans-serif',
|
|
62
|
+
'--font-family-heading': '"space grotesk", serif',
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
).toEqual(['Space Grotesk']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('skips var() references and CSS-wide keywords', () => {
|
|
69
|
+
expect(
|
|
70
|
+
collectUnloadedFonts({
|
|
71
|
+
tokens: {
|
|
72
|
+
'--font-family-heading': 'var(--font-family-body)',
|
|
73
|
+
'--font-family-code': 'inherit',
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("strips whole var() calls — fallback arguments are not this theme's declarations", () => {
|
|
80
|
+
expect(
|
|
81
|
+
collectUnloadedFonts({
|
|
82
|
+
tokens: {
|
|
83
|
+
'--font-family-body': 'var(--x, "Web Font")',
|
|
84
|
+
'--font-family-heading': 'var(--x, "Web Font", serif)',
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
).toEqual([]);
|
|
88
|
+
// Families outside the var() call still count.
|
|
89
|
+
expect(
|
|
90
|
+
collectUnloadedFonts({
|
|
91
|
+
tokens: {'--font-family-code': 'var(--x), Bungee'},
|
|
92
|
+
}),
|
|
93
|
+
).toEqual(['Bungee']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('keeps a comma inside a quoted name whole, even mid-list', () => {
|
|
97
|
+
expect(
|
|
98
|
+
collectUnloadedFonts({
|
|
99
|
+
tokens: {'--font-family-body': 'Georgia, "Foo, Bar", serif'},
|
|
100
|
+
}),
|
|
101
|
+
).toEqual(['Foo, Bar']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('ignores an empty font-family token', () => {
|
|
105
|
+
expect(collectUnloadedFonts({tokens: {'--font-family-body': ''}})).toEqual(
|
|
106
|
+
[],
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('collects component-override fontFamily values, any style key', () => {
|
|
111
|
+
expect(
|
|
112
|
+
collectUnloadedFonts({
|
|
113
|
+
components: {
|
|
114
|
+
Button: {base: {fontFamily: '"Orbitron", sans-serif'}},
|
|
115
|
+
Card: {'variant:hero': {fontFamily: 'Bungee'}},
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
).toEqual(['Orbitron', 'Bungee']);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('matches known system families case-insensitively', () => {
|
|
122
|
+
expect(
|
|
123
|
+
collectUnloadedFonts({
|
|
124
|
+
tokens: {'--font-family-body': 'arial, sans-serif'},
|
|
125
|
+
}),
|
|
126
|
+
).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns nothing for a theme with no tokens or components', () => {
|
|
130
|
+
expect(collectUnloadedFonts({})).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns nothing for a null or undefined resolved theme', () => {
|
|
134
|
+
// Older build.mjs consumers guard with `resolvedTheme || themeDef` — if
|
|
135
|
+
// a path ever hands the collector nothing, it must shrug, not throw.
|
|
136
|
+
expect(collectUnloadedFonts(null)).toEqual([]);
|
|
137
|
+
expect(collectUnloadedFonts(undefined)).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('ignores non-string values without throwing: numeric or object-valued', () => {
|
|
141
|
+
// The generator only allows object values under ':pseudo' keys, so an
|
|
142
|
+
// object-valued fontFamily is not a family declaration — skip, not crash.
|
|
143
|
+
expect(
|
|
144
|
+
collectUnloadedFonts({
|
|
145
|
+
tokens: {'--font-family-body': 42},
|
|
146
|
+
components: {
|
|
147
|
+
button: {base: {fontFamily: 700}},
|
|
148
|
+
card: {base: {fontFamily: {default: '"Sneaky"'}}},
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('collects fontFamily nested under a pseudo-class block', () => {
|
|
155
|
+
// generateThemeRules accepts {':hover': {fontFamily}} inside a style
|
|
156
|
+
// key — a family named only there must still warn.
|
|
157
|
+
expect(
|
|
158
|
+
collectUnloadedFonts({
|
|
159
|
+
components: {
|
|
160
|
+
button: {base: {':hover': {fontFamily: '"Rubik Doodle", cursive'}}},
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
).toEqual(['Rubik Doodle']);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('strips a var() whose fallback list contains another var()', () => {
|
|
167
|
+
expect(
|
|
168
|
+
collectUnloadedFonts({
|
|
169
|
+
tokens: {
|
|
170
|
+
'--font-family-body': 'var(--brand, var(--fallback), "Web Font")',
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
).toEqual([]);
|
|
174
|
+
// …and still keeps a family declared outside the nested call.
|
|
175
|
+
expect(
|
|
176
|
+
collectUnloadedFonts({
|
|
177
|
+
tokens: {'--font-family-code': 'var(--a, var(--b, serif)), Bungee'},
|
|
178
|
+
}),
|
|
179
|
+
).toEqual(['Bungee']);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('matches generic keywords case-insensitively', () => {
|
|
183
|
+
expect(
|
|
184
|
+
collectUnloadedFonts({
|
|
185
|
+
tokens: {
|
|
186
|
+
'--font-family-body': 'Sans-Serif',
|
|
187
|
+
'--font-family-heading': 'SERIF',
|
|
188
|
+
'--font-family-code': 'Ui-Monospace',
|
|
189
|
+
},
|
|
190
|
+
}),
|
|
191
|
+
).toEqual([]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('collapses newlines and whitespace runs inside a family name', () => {
|
|
195
|
+
// Template-literal theme sources wrap long stacks across lines.
|
|
196
|
+
expect(
|
|
197
|
+
collectUnloadedFonts({
|
|
198
|
+
tokens: {'--font-family-body': '"Space\n Grotesk",\n serif'},
|
|
199
|
+
}),
|
|
200
|
+
).toEqual(['Space Grotesk']);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('formatFontLoadingHelp', () => {
|
|
205
|
+
const help = formatFontLoadingHelp('ocean', ['Fraunces', 'JetBrains Mono']);
|
|
206
|
+
|
|
207
|
+
it('names the theme and every family in the headline', () => {
|
|
208
|
+
expect(help).toContain('⚠');
|
|
209
|
+
expect(help).toContain('ocean');
|
|
210
|
+
expect(help).toContain('"Fraunces"');
|
|
211
|
+
expect(help).toContain('"JetBrains Mono"');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('includes the Google Fonts recipe: preconnect pair + the exact css2 URL', () => {
|
|
215
|
+
expect(help).toContain(
|
|
216
|
+
'<link rel="preconnect" href="https://fonts.googleapis.com"',
|
|
217
|
+
);
|
|
218
|
+
expect(help).toContain('https://fonts.gstatic.com');
|
|
219
|
+
// The whole href, so a malformed URL cannot hide behind fragment checks.
|
|
220
|
+
expect(help).toContain(
|
|
221
|
+
'href="https://fonts.googleapis.com/css2?family=Fraunces&family=JetBrains+Mono&display=swap"',
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('includes a self-hosted @font-face alternative with font-display: swap', () => {
|
|
226
|
+
expect(help).toContain('@font-face');
|
|
227
|
+
expect(help).toContain('font-family: "Fraunces"');
|
|
228
|
+
expect(help).toContain('woff2');
|
|
229
|
+
expect(help).toContain('font-display: swap');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('points at the docs recipe', () => {
|
|
233
|
+
expect(help).toContain('astryx docs typography');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('percent-encodes family names in the css2 URL', () => {
|
|
237
|
+
const spiced = formatFontLoadingHelp('spice', ['P&Co Sans', 'Sömething']);
|
|
238
|
+
expect(spiced).toContain(
|
|
239
|
+
'href="https://fonts.googleapis.com/css2?family=P%26Co+Sans&family=S%C3%B6mething&display=swap"',
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
* Write the annotated theme template into a project.
|
|
6
|
+
*
|
|
7
|
+
* Refuses to overwrite without `overwrite`: an edited copy is the consumer's
|
|
8
|
+
* work, and this command is safe to re-run (init calls it on every setup).
|
|
9
|
+
*
|
|
10
|
+
* @param {{targetPath?: string, overwrite?: boolean, cwd?: string}} [options]
|
|
11
|
+
* @returns {import('../theme.type.mjs').ThemeTemplateResponse}
|
|
12
|
+
*/
|
|
13
|
+
export function themeTemplate(options?: {
|
|
14
|
+
targetPath?: string;
|
|
15
|
+
overwrite?: boolean;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
}): import("../theme.type.mjs").ThemeTemplateResponse;
|
|
18
|
+
/** The annotated `defineTheme` reference that ships with the CLI. */
|
|
19
|
+
export const THEME_TEMPLATE_SRC: string;
|
|
20
|
+
/** Where it lands when the caller does not say. */
|
|
21
|
+
export const THEME_TEMPLATE_DEFAULT_PATH: "theme.template.ts";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file `astryx theme template` leaf — writes the annotated theme template into the
|
|
5
|
+
* consumer's project.
|
|
6
|
+
*
|
|
7
|
+
* Sibling of `theme add`: both answer "put a theme starting point in my
|
|
8
|
+
* project", and they split on where you start. `add` copies a theme we ship
|
|
9
|
+
* (you like stone, you want to own it); `template` writes the blank annotated
|
|
10
|
+
* reference (you want your own, and need to know what the surface contains).
|
|
11
|
+
*
|
|
12
|
+
* The template is a doc that happens to compile, so it is one file at the
|
|
13
|
+
* project root by default rather than a package under src/themes/ — you read
|
|
14
|
+
* it, copy what you need into your own theme file, and delete it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import {CLI_ROOT} from '../../../foundation/fs/paths.mjs';
|
|
20
|
+
import {assertWithin, PathSafetyError} from '../../../foundation/fs/path-safety.mjs';
|
|
21
|
+
import {stripCopyrightHeader} from '../../../foundation/text/copyright-header.mjs';
|
|
22
|
+
import {AstryxError} from '../../error.mjs';
|
|
23
|
+
import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
|
|
24
|
+
|
|
25
|
+
/** The annotated `defineTheme` reference that ships with the CLI. */
|
|
26
|
+
export const THEME_TEMPLATE_SRC = path.join(CLI_ROOT, 'assets', 'theme.template.ts');
|
|
27
|
+
|
|
28
|
+
/** Where it lands when the caller does not say. */
|
|
29
|
+
export const THEME_TEMPLATE_DEFAULT_PATH = 'theme.template.ts';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Write the annotated theme template into a project.
|
|
33
|
+
*
|
|
34
|
+
* Refuses to overwrite without `overwrite`: an edited copy is the consumer's
|
|
35
|
+
* work, and this command is safe to re-run (init calls it on every setup).
|
|
36
|
+
*
|
|
37
|
+
* @param {{targetPath?: string, overwrite?: boolean, cwd?: string}} [options]
|
|
38
|
+
* @returns {import('../theme.type.mjs').ThemeTemplateResponse}
|
|
39
|
+
*/
|
|
40
|
+
export function themeTemplate(options = {}) {
|
|
41
|
+
const {
|
|
42
|
+
targetPath = THEME_TEMPLATE_DEFAULT_PATH,
|
|
43
|
+
overwrite = false,
|
|
44
|
+
cwd = process.cwd(),
|
|
45
|
+
} = options;
|
|
46
|
+
|
|
47
|
+
let resolved;
|
|
48
|
+
try {
|
|
49
|
+
resolved = assertWithin(targetPath, cwd, {label: 'theme template path'});
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (err instanceof PathSafetyError) {
|
|
52
|
+
throw new AstryxError(err.message, undefined, ERROR_CODES.ERR_PATH_TRAVERSAL);
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const relative = path.relative(cwd, resolved) || targetPath;
|
|
58
|
+
|
|
59
|
+
if (fs.existsSync(resolved) && !overwrite) {
|
|
60
|
+
return {type: 'theme.template', data: {path: relative, written: false, reason: 'exists'}};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Our repo header has no business in someone else's source tree.
|
|
64
|
+
const contents = stripCopyrightHeader(fs.readFileSync(THEME_TEMPLATE_SRC, 'utf-8'));
|
|
65
|
+
fs.mkdirSync(path.dirname(resolved), {recursive: true});
|
|
66
|
+
fs.writeFileSync(resolved, contents);
|
|
67
|
+
|
|
68
|
+
return {type: 'theme.template', data: {path: relative, written: true, reason: null}};
|
|
69
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Direct API tests for `themeTemplate()` — the function behind
|
|
5
|
+
* `astryx theme template` and `astryx init --features theme`.
|
|
6
|
+
*
|
|
7
|
+
* What matters here is that it never destroys work: the template lands once,
|
|
8
|
+
* a second run leaves an edited copy alone, and a path that escapes the
|
|
9
|
+
* project is refused.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import * as os from 'node:os';
|
|
16
|
+
import {themeTemplate, THEME_TEMPLATE_DEFAULT_PATH} from './template.mjs';
|
|
17
|
+
|
|
18
|
+
let tmpDir;
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-theme-template-'));
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('themeTemplate()', () => {
|
|
27
|
+
it('writes the template and returns a theme.template receipt', () => {
|
|
28
|
+
const res = themeTemplate({cwd: tmpDir});
|
|
29
|
+
|
|
30
|
+
expect(res).toEqual({
|
|
31
|
+
type: 'theme.template',
|
|
32
|
+
data: {path: THEME_TEMPLATE_DEFAULT_PATH, written: true, reason: null},
|
|
33
|
+
});
|
|
34
|
+
const written = fs.readFileSync(path.join(tmpDir, THEME_TEMPLATE_DEFAULT_PATH), 'utf-8');
|
|
35
|
+
expect(written).toMatch(/defineTheme/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('does not carry our copyright header into the consumer tree', () => {
|
|
39
|
+
themeTemplate({cwd: tmpDir});
|
|
40
|
+
const written = fs.readFileSync(path.join(tmpDir, THEME_TEMPLATE_DEFAULT_PATH), 'utf-8');
|
|
41
|
+
expect(written).not.toMatch(/Copyright \(c\) Meta Platforms/);
|
|
42
|
+
expect(written.startsWith('/**')).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('leaves an existing file alone and says so', () => {
|
|
46
|
+
const dest = path.join(tmpDir, THEME_TEMPLATE_DEFAULT_PATH);
|
|
47
|
+
fs.writeFileSync(dest, '// mine\n');
|
|
48
|
+
|
|
49
|
+
const res = themeTemplate({cwd: tmpDir});
|
|
50
|
+
|
|
51
|
+
expect(res.data).toEqual({
|
|
52
|
+
path: THEME_TEMPLATE_DEFAULT_PATH,
|
|
53
|
+
written: false,
|
|
54
|
+
reason: 'exists',
|
|
55
|
+
});
|
|
56
|
+
expect(fs.readFileSync(dest, 'utf-8')).toBe('// mine\n');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('overwrites when asked', () => {
|
|
60
|
+
const dest = path.join(tmpDir, THEME_TEMPLATE_DEFAULT_PATH);
|
|
61
|
+
fs.writeFileSync(dest, '// mine\n');
|
|
62
|
+
|
|
63
|
+
const res = themeTemplate({cwd: tmpDir, overwrite: true});
|
|
64
|
+
|
|
65
|
+
expect(res.data.written).toBe(true);
|
|
66
|
+
expect(fs.readFileSync(dest, 'utf-8')).toMatch(/defineTheme/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('honors a custom path and creates its directory', () => {
|
|
70
|
+
const res = themeTemplate({cwd: tmpDir, targetPath: 'src/themes/starter.ts'});
|
|
71
|
+
|
|
72
|
+
expect(res.data.path).toBe(path.join('src', 'themes', 'starter.ts'));
|
|
73
|
+
expect(fs.existsSync(path.join(tmpDir, 'src/themes/starter.ts'))).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('refuses a path that escapes the project', () => {
|
|
77
|
+
expect(() => themeTemplate({cwd: tmpDir, targetPath: '../escaped.ts'})).toThrow(
|
|
78
|
+
/theme template path/,
|
|
79
|
+
);
|
|
80
|
+
expect(fs.existsSync(path.join(path.dirname(tmpDir), 'escaped.ts'))).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
package/api/theme/theme.d.mts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
|
|
3
3
|
|
|
4
4
|
export { themeAdd } from "./add/add.mjs";
|
|
5
|
+
export { themeTemplate } from "./template/template.mjs";
|
|
5
6
|
export { themeList } from "./list/list.mjs";
|
|
6
7
|
export { listThemes } from "./_adapter.mjs";
|
|
7
8
|
export { themeBuild, importSpecifier } from "./build/build.mjs";
|
package/api/theme/theme.mjs
CHANGED
|
@@ -66,3 +66,16 @@ export type ThemeAddResponse = {
|
|
|
66
66
|
files: string[];
|
|
67
67
|
};
|
|
68
68
|
};
|
|
69
|
+
/**
|
|
70
|
+
* xds --json theme template
|
|
71
|
+
* `written: false` with `reason: 'exists'` is a success: the command is safe to
|
|
72
|
+
* re-run, and an edited template is the consumer's file to keep.
|
|
73
|
+
*/
|
|
74
|
+
export type ThemeTemplateResponse = {
|
|
75
|
+
type: "theme.template";
|
|
76
|
+
data: {
|
|
77
|
+
path: string;
|
|
78
|
+
written: boolean;
|
|
79
|
+
reason: "exists" | null;
|
|
80
|
+
};
|
|
81
|
+
};
|
package/api/theme/theme.type.mjs
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
* xds --json theme build <file> --check -> theme.build.check
|
|
13
13
|
* xds --json theme list -> theme.list
|
|
14
14
|
* xds --json theme add <slug> -> theme.add
|
|
15
|
+
* xds --json theme template -> theme.template
|
|
15
16
|
* (file not found / parse error) -> CLIError
|
|
16
17
|
*
|
|
17
|
-
* @position api — colocated typedefs for api/theme/{theme,build,add,list,_adapter}
|
|
18
|
+
* @position api — colocated typedefs for api/theme/{theme,build,add,list,template,_adapter}
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -54,6 +55,15 @@
|
|
|
54
55
|
* @property {{slug: string, displayName: string, maintained: boolean, outputDir: string, entry: string, exportName: string, files: string[]}} data
|
|
55
56
|
*/
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* xds --json theme template
|
|
60
|
+
* `written: false` with `reason: 'exists'` is a success: the command is safe to
|
|
61
|
+
* re-run, and an edited template is the consumer's file to keep.
|
|
62
|
+
* @typedef {object} ThemeTemplateResponse
|
|
63
|
+
* @property {'theme.template'} type
|
|
64
|
+
* @property {{path: string, written: boolean, reason: 'exists' | null}} data
|
|
65
|
+
*/
|
|
66
|
+
|
|
57
67
|
// Make this a module so the @typedefs above are importable as types via
|
|
58
68
|
// `import('./theme.type.mjs').ThemeBuildResponse` (and re-exportable from a .d.ts).
|
|
59
69
|
export {};
|