@react-native-rethemed/cli 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +53 -2
- package/bin/react-native-rethemed.mjs +8 -0
- package/package.json +56 -3
- package/src/cli.ts +156 -0
- package/src/codegen.ts +216 -0
- package/src/docs.ts +378 -0
- package/src/emit.ts +112 -0
- package/src/generate.ts +338 -0
- package/src/load-theme.ts +72 -0
- package/src/model.ts +392 -0
- package/src/react-native-stub.cjs +9 -0
- package/src/validate.ts +255 -0
package/src/generate.ts
ADDED
|
@@ -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
|
+
}
|