@react-native-rethemed/cli 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,338 @@
1
+ import {
2
+ COLOR_KEYS,
3
+ FONT_SIZE_KEYS,
4
+ FONT_WEIGHT_KEYS,
5
+ isSchemeColor,
6
+ isTextPreset,
7
+ LETTER_SPACING_KEYS,
8
+ LINE_HEIGHT_KEYS,
9
+ RADIUS_KEYS,
10
+ type SchemeColor,
11
+ SPACING_KEYS,
12
+ type TextColor,
13
+ type TextTokenTree,
14
+ type ThemeConfig,
15
+ Z_INDEX_KEYS,
16
+ } from '@react-native-rethemed/core/config';
17
+ import {
18
+ code,
19
+ jsdoc,
20
+ member,
21
+ quote,
22
+ TypeExpr,
23
+ table,
24
+ typeLiteral,
25
+ union,
26
+ } from './emit';
27
+ import {
28
+ buildModel,
29
+ colorTable,
30
+ lineHeightNote,
31
+ lineHeightRows,
32
+ optionalPresetColumns,
33
+ PRESET_FIELDS,
34
+ presetGroups,
35
+ presetTable,
36
+ primitiveColorNote,
37
+ primitiveColorTables,
38
+ scaleTable,
39
+ shadowTable,
40
+ type TextNode,
41
+ } from './model';
42
+
43
+ export type GenerateOptions = {
44
+ config: ThemeConfig;
45
+ /** How the generated file imports the theme config. */
46
+ themeImport: {
47
+ /** Module specifier relative to the generated file, e.g. `'./theme'`. */
48
+ specifier: string;
49
+ /** `'default'` or a named export. */
50
+ exportName: string;
51
+ };
52
+ /** Defaults to `'@react-native-rethemed/core'`. */
53
+ coreSpecifier?: string;
54
+ /** Shown in the header so readers know how to regenerate. */
55
+ command?: string;
56
+ };
57
+
58
+ const INDENT = ' ';
59
+
60
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: emitted TypeScript, not a template
61
+ const SPACING_TYPE = "SpacingToken | 'auto' | `${number}%`";
62
+
63
+ /**
64
+ * Renders `themed.gen.ts` for an evaluated theme config: token unions, one
65
+ * interface of token-aware style props (each with a JSDoc token table), the
66
+ * typography presets, the exact `useThemed().tokens` / `semanticTokens`
67
+ * shapes, and the `createThemed<ThemedTypes>()` instance itself.
68
+ *
69
+ * Pure: config in, source text out. Output is deterministic so regenerating
70
+ * an unchanged theme is a no-op.
71
+ */
72
+ /**
73
+ * The type of a preset `color` as `useThemed()` returns it: resolved for the
74
+ * current scheme, so a `{ light, dark }` pair becomes the union of its values,
75
+ * plus `undefined` when a scheme is left out.
76
+ */
77
+ function resolvedColorType(color: TextColor): TypeExpr | TextColor {
78
+ if (typeof color !== 'object') return color;
79
+ const values = [...new Set([color.light, color.dark])].filter(
80
+ (v): v is string => v !== undefined,
81
+ );
82
+ const missing = color.light === undefined || color.dark === undefined;
83
+ return new TypeExpr(
84
+ [...values.map(quote), ...(missing ? ['undefined'] : [])].join(' | '),
85
+ );
86
+ }
87
+
88
+ /** `semanticTokens.text` with preset colors typed as resolved per scheme. */
89
+ function resolvedTextShape(tree: TextTokenTree): unknown {
90
+ return Object.fromEntries(
91
+ Object.entries(tree).map(([key, node]) => {
92
+ if (!isTextPreset(node)) {
93
+ return [key, resolvedTextShape(node as TextTokenTree)];
94
+ }
95
+ return [
96
+ key,
97
+ node.color === undefined
98
+ ? node
99
+ : { ...node, color: resolvedColorType(node.color) },
100
+ ];
101
+ }),
102
+ );
103
+ }
104
+
105
+ export function generate({
106
+ config,
107
+ themeImport,
108
+ coreSpecifier = '@react-native-rethemed/core',
109
+ command = 'react-native-rethemed codegen',
110
+ }: GenerateOptions): string {
111
+ const tokens = config.tokens ?? {};
112
+ const semanticColors = config.semanticTokens?.colors ?? {};
113
+ const semanticText = config.semanticTokens?.text ?? {};
114
+ const model = buildModel(config);
115
+
116
+ const typeAlias = (name: string, names: string[]) => {
117
+ const body = union(names);
118
+ return `export type ${name} =${body.startsWith('\n') ? '' : ' '}${body};`;
119
+ };
120
+ const keys = (entries: readonly (readonly [string, ...unknown[]])[]) =>
121
+ entries.map(([k]) => k);
122
+
123
+ // --- JSDoc token tables ---------------------------------------------------
124
+ // A category the theme does not define gets a note instead of an empty
125
+ // table, so hovering a prop explains why no token is suggested.
126
+ const notDefined = 'Not defined in this theme.';
127
+ // Color props take semantic colors (switch with the scheme) and primitive
128
+ // colors (fixed); each kind gets its own table.
129
+ const primitiveTables = primitiveColorTables(model.primitiveColors);
130
+ const semanticColorDoc =
131
+ model.colors.length > 0
132
+ ? [
133
+ `${code('semanticTokens.colors')}: switch with light/dark. Prefer these.`,
134
+ '',
135
+ ...colorTable(model.colors),
136
+ ]
137
+ : [];
138
+ const primitiveColorDoc =
139
+ model.primitiveColors.length > 0
140
+ ? [
141
+ `${code('tokens.colors')}: ${primitiveColorNote(primitiveTables.example)}`,
142
+ '',
143
+ ...primitiveTables.lines,
144
+ ]
145
+ : [];
146
+ const colorDoc =
147
+ semanticColorDoc.length + primitiveColorDoc.length > 0
148
+ ? [
149
+ ...semanticColorDoc,
150
+ ...(semanticColorDoc.length > 0 && primitiveColorDoc.length > 0
151
+ ? ['']
152
+ : []),
153
+ ...primitiveColorDoc,
154
+ ]
155
+ : [
156
+ `${code('semanticTokens.colors')} / ${code('tokens.colors')}`,
157
+ '',
158
+ `${notDefined} No color token is accepted here; pass colors in a second plain style object.`,
159
+ ];
160
+ const scaleDoc = (source: string, rows: [string, unknown][]) => [
161
+ code(`tokens.${source}`),
162
+ '',
163
+ ...(rows.length > 0 ? scaleTable(rows) : [notDefined]),
164
+ ];
165
+ const lineHeightDoc =
166
+ model.lineHeights.length > 0
167
+ ? [
168
+ lineHeightNote(model),
169
+ '',
170
+ code('tokens.lineHeights'),
171
+ '',
172
+ ...scaleTable(lineHeightRows(model)),
173
+ ]
174
+ : [code('tokens.lineHeights'), '', notDefined];
175
+ const shadowDoc = [
176
+ 'Virtual prop: expands to `shadowColor` / `shadowOffset` / `shadowOpacity` / `shadowRadius` / `elevation`.',
177
+ '',
178
+ code('tokens.shadows'),
179
+ '',
180
+ ...(model.shadows.length > 0 ? shadowTable(model.shadows) : [notDefined]),
181
+ ];
182
+
183
+ const props = (names: readonly string[], type: string, doc: string[]) =>
184
+ names
185
+ .map((n) => `${jsdoc(doc, INDENT)}\n${INDENT}${n}?: ${type};`)
186
+ .join('\n');
187
+
188
+ // --- typography presets ---------------------------------------------------
189
+ const presetDoc = (node: Extract<TextNode, { kind: 'preset' }>) => {
190
+ const extras = optionalPresetColumns([node]);
191
+ return [
192
+ code(`semanticTokens.text.${node.path}`),
193
+ '',
194
+ ...table(
195
+ [...PRESET_FIELDS, ...extras.map((c) => c.header)],
196
+ [
197
+ 'right',
198
+ 'right',
199
+ 'right',
200
+ 'right',
201
+ ...extras.map(() => 'left' as const),
202
+ ],
203
+ [[...node.cells, ...extras.map((c) => c.cell(node))]],
204
+ ),
205
+ ];
206
+ };
207
+ /** One member per node; groups nest, and list their own presets. */
208
+ const emitTextNodes = (nodes: TextNode[], depth: number): string =>
209
+ nodes
210
+ .map((node) => {
211
+ const pad = INDENT.repeat(depth);
212
+ if (node.kind === 'preset') {
213
+ return `${jsdoc(presetDoc(node), pad)}\n${pad}${member(node.name)}: TextVariant;`;
214
+ }
215
+ const [own] = presetGroups(node.children, node.path).filter(
216
+ (g) => g.path === node.path,
217
+ );
218
+ const doc = [
219
+ code(`semanticTokens.text.${node.path}`),
220
+ ...(own ? ['', ...presetTable(own.presets)] : []),
221
+ ];
222
+ return `${jsdoc(doc, pad)}\n${pad}${member(node.name)}: {\n${emitTextNodes(node.children, depth + 1)}\n${pad}};`;
223
+ })
224
+ .join('\n');
225
+ const variants = emitTextNodes(model.text, 1);
226
+
227
+ // --- useThemed().semanticTokens -------------------------------------------
228
+ const colorField = (name: string, color: SchemeColor, indent: string) => {
229
+ const doc = table(
230
+ ['light', 'dark'],
231
+ ['left', 'left'],
232
+ [[color.light, color.dark]],
233
+ );
234
+ return `${jsdoc(doc, indent)}\n${indent}${member(name)}: string;`;
235
+ };
236
+ const semanticColorType = Object.entries(semanticColors)
237
+ .map(([key, node]) => {
238
+ // A top-level color resolves to a string; a group to an object.
239
+ if (isSchemeColor(node)) return colorField(key, node, INDENT.repeat(2));
240
+ const fields = Object.entries(node)
241
+ .map(([name, color]) => colorField(name, color, INDENT.repeat(3)))
242
+ .join('\n');
243
+ return `${INDENT.repeat(2)}${member(key)}: {\n${fields}\n${INDENT.repeat(2)}};`;
244
+ })
245
+ .join('\n');
246
+
247
+ const themeBinding =
248
+ themeImport.exportName === 'default'
249
+ ? 'themeConfig'
250
+ : themeImport.exportName;
251
+ const themeImportLine =
252
+ themeImport.exportName === 'default'
253
+ ? `import themeConfig from '${themeImport.specifier}';`
254
+ : `import { ${themeImport.exportName} } from '${themeImport.specifier}';`;
255
+
256
+ return `// Code generated by @react-native-rethemed/cli. DO NOT EDIT.
257
+ // Regenerate with: ${command}
258
+
259
+ import type { TextStyle } from 'react-native';
260
+ import { createThemed, type TokenizeStyle } from '${coreSpecifier}';
261
+ ${themeImportLine}
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Token names
265
+ // ---------------------------------------------------------------------------
266
+
267
+ ${typeAlias(
268
+ 'SemanticColorToken',
269
+ model.colors.map((c) => c.token),
270
+ )}
271
+ ${typeAlias('PrimitiveColorToken', keys(model.primitiveColors))}
272
+ /** Color props take both: semantic colors switch with light/dark, primitives are fixed. */
273
+ export type ColorToken = SemanticColorToken | PrimitiveColorToken;
274
+ ${typeAlias('RadiusToken', keys(model.radii))}
275
+ ${typeAlias('SpacingToken', keys(model.spacing))}
276
+ ${typeAlias('FontSizeToken', keys(model.fontSizes))}
277
+ ${typeAlias('FontWeightToken', keys(model.fontWeights))}
278
+ ${typeAlias('LineHeightToken', keys(model.lineHeights))}
279
+ ${typeAlias('LetterSpacingToken', keys(model.letterSpacings))}
280
+ ${typeAlias('ShadowToken', keys(model.shadows))}
281
+ ${typeAlias('ZIndexToken', keys(model.zIndices))}
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Token-aware style props. Each primitive picks the ones its RN style type
285
+ // has (see \`TokenizeStyle\` in core), so e.g. \`color\` never shows on View.
286
+ // ---------------------------------------------------------------------------
287
+
288
+ export interface ThemedStyleProps {
289
+ ${props(COLOR_KEYS, 'ColorToken', colorDoc)}
290
+ ${props(RADIUS_KEYS, 'RadiusToken', scaleDoc('radii', model.radii))}
291
+ ${props(SPACING_KEYS, SPACING_TYPE, scaleDoc('spacing', model.spacing))}
292
+ ${props(FONT_SIZE_KEYS, 'FontSizeToken | number', scaleDoc('fontSizes', model.fontSizes))}
293
+ ${props(FONT_WEIGHT_KEYS, "FontWeightToken | TextStyle['fontWeight']", scaleDoc('fontWeights', model.fontWeights))}
294
+ ${props(LINE_HEIGHT_KEYS, 'LineHeightToken | number', lineHeightDoc)}
295
+ ${props(LETTER_SPACING_KEYS, 'LetterSpacingToken | number', scaleDoc('letterSpacings', model.letterSpacings))}
296
+ ${props(Z_INDEX_KEYS, 'ZIndexToken | number', scaleDoc('zIndices', model.zIndices))}
297
+ ${props(['shadow'], 'ShadowToken', shadowDoc)}
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Typography presets: themed.text.<path>(override?)
302
+ // ---------------------------------------------------------------------------
303
+
304
+ type TextVariant = (
305
+ override?: TokenizeStyle<TextStyle, ThemedStyleProps>,
306
+ ) => TextStyle;
307
+
308
+ export interface ThemedTextVariants {
309
+ ${variants}
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // useThemed().tokens / useThemed().semanticTokens
314
+ // ---------------------------------------------------------------------------
315
+
316
+ export interface ThemedTokens ${typeLiteral(tokens, '')}
317
+
318
+ export interface ThemedSemanticTokens {
319
+ /** Resolved for the current color scheme. */
320
+ colors: ${semanticColorType ? `{\n${semanticColorType}\n }` : '{}'};
321
+ text: ${typeLiteral(resolvedTextShape(semanticText), INDENT)};
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Instance
326
+ // ---------------------------------------------------------------------------
327
+
328
+ export interface ThemedTypes {
329
+ style: ThemedStyleProps;
330
+ textVariants: ThemedTextVariants;
331
+ tokens: ThemedTokens;
332
+ semanticTokens: ThemedSemanticTokens;
333
+ }
334
+
335
+ export const { ThemedProvider, useThemed, useColorMode } =
336
+ createThemed<ThemedTypes>(${themeBinding});
337
+ `;
338
+ }
@@ -0,0 +1,72 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import type { ThemeConfig } from '@react-native-rethemed/core/config';
4
+ import { createJiti } from 'jiti';
5
+
6
+ /** See `react-native-stub.cjs`. */
7
+ const REACT_NATIVE_STUB = path.join(
8
+ path.dirname(fileURLToPath(import.meta.url)),
9
+ 'react-native-stub.cjs',
10
+ );
11
+
12
+ export type LoadedTheme = {
13
+ config: ThemeConfig;
14
+ /** `'default'` or the named export the config was read from. */
15
+ exportName: string;
16
+ };
17
+
18
+ const looksLikeThemeConfig = (value: unknown): value is ThemeConfig =>
19
+ typeof value === 'object' &&
20
+ value !== null &&
21
+ ('tokens' in value || 'semanticTokens' in value);
22
+
23
+ /**
24
+ * Evaluates a theme file (TS or JS) in plain Node via jiti and returns the
25
+ * exported config. `react-native` is aliased to an empty stub, so the file
26
+ * may import from `@react-native-rethemed/core` (or `/config`), but must not
27
+ * use `react-native` while building the config.
28
+ *
29
+ * Export resolution: `exportName` if given, else the default export, else
30
+ * the single export that looks like a `ThemeConfig`.
31
+ */
32
+ export async function loadTheme(
33
+ file: string,
34
+ exportName?: string,
35
+ ): Promise<LoadedTheme> {
36
+ const jiti = createJiti(file, {
37
+ moduleCache: false,
38
+ fsCache: false,
39
+ alias: { 'react-native': REACT_NATIVE_STUB },
40
+ });
41
+ const mod = await jiti.import<Record<string, unknown>>(file);
42
+
43
+ if (exportName) {
44
+ const value = mod[exportName];
45
+ if (!looksLikeThemeConfig(value)) {
46
+ throw new Error(
47
+ `Export '${exportName}' of ${file} is not a theme config (expected an object with 'tokens' or 'semanticTokens').`,
48
+ );
49
+ }
50
+ return { config: value, exportName };
51
+ }
52
+
53
+ if (looksLikeThemeConfig(mod.default)) {
54
+ return { config: mod.default, exportName: 'default' };
55
+ }
56
+
57
+ const candidates = Object.entries(mod).filter(
58
+ ([name, value]) => name !== 'default' && looksLikeThemeConfig(value),
59
+ );
60
+ if (candidates.length === 1) {
61
+ const [[name, value]] = candidates;
62
+ return { config: value as ThemeConfig, exportName: name };
63
+ }
64
+
65
+ throw new Error(
66
+ candidates.length === 0
67
+ ? `No theme config export found in ${file}.`
68
+ : `Several theme config exports found in ${file} (${candidates
69
+ .map(([n]) => n)
70
+ .join(', ')}); pick one with --export <name>.`,
71
+ );
72
+ }