@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.
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 +19 -12
  11. package/api/theme/build/build.test.mjs +53 -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 +20 -9
  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
@@ -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
+ });
@@ -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";
@@ -10,5 +10,6 @@
10
10
 
11
11
  export {themeBuild, importSpecifier} from './build/build.mjs';
12
12
  export {themeAdd} from './add/add.mjs';
13
+ export {themeTemplate} from './template/template.mjs';
13
14
  export {themeList} from './list/list.mjs';
14
15
  export {listThemes} from './_adapter.mjs';
@@ -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
+ };
@@ -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 {};