@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/model.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isTextPreset,
|
|
3
|
+
RN_DEFAULT_FONT_SIZE,
|
|
4
|
+
resolveBaseFontSize,
|
|
5
|
+
type ShadowToken,
|
|
6
|
+
type TextColor,
|
|
7
|
+
type TextToken,
|
|
8
|
+
type TextTokenTree,
|
|
9
|
+
type ThemeConfig,
|
|
10
|
+
walkSemanticColors,
|
|
11
|
+
walkTextPresets,
|
|
12
|
+
} from '@react-native-rethemed/core/config';
|
|
13
|
+
import { code, orderedEntries, table } from './emit';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The evaluated theme flattened into display order — the single view of the
|
|
17
|
+
* tokens that both `themed.gen.ts` and the Markdown docs render from, so the
|
|
18
|
+
* two can never disagree.
|
|
19
|
+
*/
|
|
20
|
+
export type TokenModel = {
|
|
21
|
+
/**
|
|
22
|
+
* `semanticTokens.colors`, flattened to `'group.token'` (or the bare name
|
|
23
|
+
* for a top-level color, whose `group` is `''`).
|
|
24
|
+
*/
|
|
25
|
+
colors: { token: string; group: string; light: string; dark: string }[];
|
|
26
|
+
radii: [string, number][];
|
|
27
|
+
spacing: [string, number][];
|
|
28
|
+
fontSizes: [string, number][];
|
|
29
|
+
fontWeights: [string, unknown][];
|
|
30
|
+
lineHeights: [string, number][];
|
|
31
|
+
letterSpacings: [string, number][];
|
|
32
|
+
shadows: [string, ShadowToken][];
|
|
33
|
+
zIndices: [string, number][];
|
|
34
|
+
/** `tokens.colors` — scheme-independent primitives, also color tokens. */
|
|
35
|
+
primitiveColors: [string, string][];
|
|
36
|
+
/** `semanticTokens.text` as a tree, each preset's cells already rendered. */
|
|
37
|
+
text: TextNode[];
|
|
38
|
+
/**
|
|
39
|
+
* The first preset (by path) whose `lineHeight` is a token (a ratio, so it
|
|
40
|
+
* follows `fontSize`) and the first whose `lineHeight` is an absolute
|
|
41
|
+
* number. Overriding a preset's size works differently for each.
|
|
42
|
+
*/
|
|
43
|
+
presetLineHeights: { ratio?: string; absolute?: string };
|
|
44
|
+
/** `defaults.fontSize` resolved, for line-height ratios without a fontSize. */
|
|
45
|
+
baseFontSize: { value: number; label: string };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** A node of `semanticTokens.text`: a preset (leaf) or a group. */
|
|
49
|
+
export type TextNode =
|
|
50
|
+
| {
|
|
51
|
+
kind: 'preset';
|
|
52
|
+
name: string;
|
|
53
|
+
path: string;
|
|
54
|
+
cells: string[];
|
|
55
|
+
/** The preset's `color`, rendered; `undefined` when it has none. */
|
|
56
|
+
color?: string;
|
|
57
|
+
/** The preset's `textTransform`; `undefined` when it has none. */
|
|
58
|
+
textTransform?: string;
|
|
59
|
+
}
|
|
60
|
+
| { kind: 'group'; name: string; path: string; children: TextNode[] };
|
|
61
|
+
|
|
62
|
+
export const PRESET_FIELDS = [
|
|
63
|
+
'fontSize',
|
|
64
|
+
'lineHeight',
|
|
65
|
+
'letterSpacing',
|
|
66
|
+
'fontWeight',
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
export function buildModel(config: ThemeConfig): TokenModel {
|
|
70
|
+
const tokens = config.tokens ?? {};
|
|
71
|
+
const byValue = (v: number) => v;
|
|
72
|
+
|
|
73
|
+
const scaleFor = {
|
|
74
|
+
fontSize: tokens.fontSizes,
|
|
75
|
+
lineHeight: tokens.lineHeights,
|
|
76
|
+
letterSpacing: tokens.letterSpacings,
|
|
77
|
+
fontWeight: tokens.fontWeights,
|
|
78
|
+
} as const;
|
|
79
|
+
const baseFontSize = resolveBaseFontSize(config);
|
|
80
|
+
const fontSizeOf = (preset: TextToken) => {
|
|
81
|
+
const { fontSize } = preset;
|
|
82
|
+
if (typeof fontSize === 'number') return fontSize;
|
|
83
|
+
const scale = tokens.fontSizes;
|
|
84
|
+
return typeof fontSize === 'string' &&
|
|
85
|
+
scale &&
|
|
86
|
+
Object.hasOwn(scale, fontSize)
|
|
87
|
+
? scale[fontSize]
|
|
88
|
+
: baseFontSize;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `'lg'` -> `` `lg` (18) `` so a table shows what a reference means. A
|
|
93
|
+
* line-height token is a ratio, so it also shows the computed value:
|
|
94
|
+
* `` `short` (×1.375 → 24.75) ``.
|
|
95
|
+
*/
|
|
96
|
+
const presetCell = (preset: TextToken, field: keyof typeof scaleFor) => {
|
|
97
|
+
const value = preset[field];
|
|
98
|
+
if (value === undefined) return '–';
|
|
99
|
+
const scale = scaleFor[field] as Record<string, unknown> | undefined;
|
|
100
|
+
if (typeof value === 'string' && scale && Object.hasOwn(scale, value)) {
|
|
101
|
+
if (field === 'lineHeight') {
|
|
102
|
+
const ratio = scale[value] as number;
|
|
103
|
+
return `${code(value)} (×${ratio} → ${round2(fontSizeOf(preset) * ratio)})`;
|
|
104
|
+
}
|
|
105
|
+
return `${code(value)} (${scale[value]})`;
|
|
106
|
+
}
|
|
107
|
+
return String(value);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const baseKey = config.defaults?.fontSize;
|
|
111
|
+
const baseLabel =
|
|
112
|
+
typeof baseKey === 'string'
|
|
113
|
+
? `${code(baseKey)} (${baseFontSize})`
|
|
114
|
+
: typeof baseKey === 'number'
|
|
115
|
+
? String(baseKey)
|
|
116
|
+
: `${RN_DEFAULT_FONT_SIZE} (React Native default)`;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
colors: semanticColorRows(config),
|
|
120
|
+
radii: orderedEntries(tokens.radii, byValue),
|
|
121
|
+
spacing: orderedEntries(tokens.spacing, byValue),
|
|
122
|
+
fontSizes: orderedEntries(tokens.fontSizes, byValue),
|
|
123
|
+
fontWeights: orderedEntries(tokens.fontWeights, (v) => Number(v) || 0),
|
|
124
|
+
lineHeights: orderedEntries(tokens.lineHeights, byValue),
|
|
125
|
+
letterSpacings: orderedEntries(tokens.letterSpacings, byValue),
|
|
126
|
+
shadows: Object.entries(tokens.shadows ?? {}),
|
|
127
|
+
zIndices: orderedEntries(tokens.zIndices, byValue),
|
|
128
|
+
primitiveColors: Object.entries(tokens.colors ?? {}),
|
|
129
|
+
text: textNodes(config.semanticTokens?.text, (preset) =>
|
|
130
|
+
PRESET_FIELDS.map((f) => presetCell(preset, f)),
|
|
131
|
+
),
|
|
132
|
+
presetLineHeights: presetLineHeightKinds(config),
|
|
133
|
+
baseFontSize: { value: baseFontSize, label: baseLabel },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function semanticColorRows(config: ThemeConfig): TokenModel['colors'] {
|
|
138
|
+
const rows: TokenModel['colors'] = [];
|
|
139
|
+
walkSemanticColors(config.semanticTokens?.colors, (token, color, group) => {
|
|
140
|
+
rows.push({
|
|
141
|
+
token,
|
|
142
|
+
group: group ?? '',
|
|
143
|
+
light: color.light,
|
|
144
|
+
dark: color.dark,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
return rows;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const round2 = (value: number) => Math.round(value * 100) / 100;
|
|
151
|
+
|
|
152
|
+
function presetLineHeightKinds(
|
|
153
|
+
config: ThemeConfig,
|
|
154
|
+
): TokenModel['presetLineHeights'] {
|
|
155
|
+
const paths: Record<'ratio' | 'absolute', string[]> = {
|
|
156
|
+
ratio: [],
|
|
157
|
+
absolute: [],
|
|
158
|
+
};
|
|
159
|
+
walkTextPresets(config.semanticTokens?.text, (path, preset) => {
|
|
160
|
+
if (typeof preset.lineHeight === 'string') paths.ratio.push(path.join('.'));
|
|
161
|
+
if (typeof preset.lineHeight === 'number') {
|
|
162
|
+
paths.absolute.push(path.join('.'));
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
// Same preference as `examplePreset`: a mid-sized `title.md` reads best.
|
|
166
|
+
const pick = (list: string[]) =>
|
|
167
|
+
list.find((p) => p === 'title.md') ?? list[0];
|
|
168
|
+
return { ratio: pick(paths.ratio), absolute: pick(paths.absolute) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A preset `color` as a table cell: `#777777` (both schemes), or
|
|
173
|
+
* `light: #111111, dark: #ffffff` — a scheme left out is simply not listed.
|
|
174
|
+
*/
|
|
175
|
+
export function colorCell(color: TextColor | undefined): string | undefined {
|
|
176
|
+
if (color === undefined) return undefined;
|
|
177
|
+
if (typeof color === 'string') return color;
|
|
178
|
+
return (['light', 'dark'] as const)
|
|
179
|
+
.filter((scheme) => color[scheme] !== undefined)
|
|
180
|
+
.map((scheme) => `${scheme}: ${color[scheme]}`)
|
|
181
|
+
.join(', ');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function textNodes(
|
|
185
|
+
tree: TextTokenTree | undefined,
|
|
186
|
+
cells: (preset: TextToken) => string[],
|
|
187
|
+
prefix: string[] = [],
|
|
188
|
+
): TextNode[] {
|
|
189
|
+
return Object.entries(tree ?? {})
|
|
190
|
+
.filter(([, node]) => typeof node === 'object' && node !== null)
|
|
191
|
+
.map(([name, node]): TextNode => {
|
|
192
|
+
const path = [...prefix, name];
|
|
193
|
+
return isTextPreset(node)
|
|
194
|
+
? {
|
|
195
|
+
kind: 'preset',
|
|
196
|
+
name,
|
|
197
|
+
path: path.join('.'),
|
|
198
|
+
cells: cells(node),
|
|
199
|
+
color: colorCell(node.color),
|
|
200
|
+
textTransform: node.textTransform,
|
|
201
|
+
}
|
|
202
|
+
: {
|
|
203
|
+
kind: 'group',
|
|
204
|
+
name,
|
|
205
|
+
path: path.join('.'),
|
|
206
|
+
children: textNodes(node as TextTokenTree, cells, path),
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Every group that directly holds presets, with those presets — one table
|
|
213
|
+
* each. The root comes first with path `''` when presets sit at the top.
|
|
214
|
+
*/
|
|
215
|
+
export function presetGroups(
|
|
216
|
+
nodes: TextNode[],
|
|
217
|
+
path = '',
|
|
218
|
+
): { path: string; presets: Extract<TextNode, { kind: 'preset' }>[] }[] {
|
|
219
|
+
const presets = nodes.filter(
|
|
220
|
+
(n): n is Extract<TextNode, { kind: 'preset' }> => n.kind === 'preset',
|
|
221
|
+
);
|
|
222
|
+
return [
|
|
223
|
+
...(presets.length > 0 ? [{ path, presets }] : []),
|
|
224
|
+
...nodes.flatMap((n) =>
|
|
225
|
+
n.kind === 'group' ? presetGroups(n.children, n.path) : [],
|
|
226
|
+
),
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** First preset in definition order, preferring `title.md` for examples. */
|
|
231
|
+
export function examplePreset(nodes: TextNode[]): string | undefined {
|
|
232
|
+
const all = presetGroups(nodes).flatMap((g) => g.presets.map((p) => p.path));
|
|
233
|
+
return all.find((p) => p === 'title.md') ?? all[0];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Line heights are ratios of `fontSize`; show them as `×1.375`. */
|
|
237
|
+
export function lineHeightRows(model: TokenModel): [string, string][] {
|
|
238
|
+
return model.lineHeights.map(([k, v]) => [k, `×${v}`]);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** One sentence explaining how a line-height token resolves. */
|
|
242
|
+
export function lineHeightNote(model: TokenModel): string {
|
|
243
|
+
return `Ratios of \`fontSize\`: a token resolves to \`fontSize × ratio\`, using the style's own \`fontSize\` or else the default font size, ${model.baseFontSize.label}. A raw number is an absolute line height.`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Markdown tables shared by the JSDoc and the docs file
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
export function colorTable(rows: TokenModel['colors']): string[] {
|
|
251
|
+
return table(
|
|
252
|
+
['token', 'light', 'dark'],
|
|
253
|
+
['left', 'left', 'left'],
|
|
254
|
+
rows.map((c) => [code(c.token), c.light, c.dark]),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** More distinct shade names than this and a grid gets too wide to read. */
|
|
259
|
+
const MAX_PALETTE_SHADES = 12;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* `tokens.colors` for display. Keys shaped `<hue>.<shade>` become one grid
|
|
263
|
+
* row per hue (`| red | #fef2f2 | … |`), which keeps a few hundred palette
|
|
264
|
+
* colors readable in a hover; anything else (`white`) goes in a plain
|
|
265
|
+
* token/value table first. Falls back to a single plain table when the keys
|
|
266
|
+
* don't form a palette.
|
|
267
|
+
*/
|
|
268
|
+
export function primitiveColorTables(rows: [string, string][]): {
|
|
269
|
+
lines: string[];
|
|
270
|
+
/** A real `<hue>.<shade>` token to cite next to the grid. */
|
|
271
|
+
example?: string;
|
|
272
|
+
} {
|
|
273
|
+
const plain = (r: [string, string][]) =>
|
|
274
|
+
table(
|
|
275
|
+
['token', 'value'],
|
|
276
|
+
['left', 'left'],
|
|
277
|
+
r.map(([k, v]) => [code(k), v]),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const singles: [string, string][] = [];
|
|
281
|
+
const hues = new Map<string, Map<string, string>>();
|
|
282
|
+
for (const [key, value] of rows) {
|
|
283
|
+
const parts = key.split('.');
|
|
284
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
285
|
+
singles.push([key, value]);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const [hue, shade] = parts;
|
|
289
|
+
const shades = hues.get(hue) ?? new Map<string, string>();
|
|
290
|
+
shades.set(shade, value);
|
|
291
|
+
hues.set(hue, shades);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const shades = [...new Set([...hues.values()].flatMap((m) => [...m.keys()]))];
|
|
295
|
+
if (shades.every((s) => /^\d+$/.test(s))) {
|
|
296
|
+
shades.sort((a, b) => Number(a) - Number(b));
|
|
297
|
+
}
|
|
298
|
+
if (hues.size === 0 || shades.length > MAX_PALETTE_SHADES) {
|
|
299
|
+
return { lines: plain(rows) };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const grid = table(
|
|
303
|
+
['hue', ...shades],
|
|
304
|
+
['left', ...shades.map(() => 'left' as const)],
|
|
305
|
+
[...hues].map(([hue, byShade]) => [
|
|
306
|
+
code(hue),
|
|
307
|
+
...shades.map((s) => byShade.get(s) ?? '–'),
|
|
308
|
+
]),
|
|
309
|
+
);
|
|
310
|
+
const [firstHue, firstShades] = [...hues][0];
|
|
311
|
+
const exampleShade = firstShades.has('500')
|
|
312
|
+
? '500'
|
|
313
|
+
: [...firstShades.keys()][0];
|
|
314
|
+
return {
|
|
315
|
+
lines: singles.length > 0 ? [...plain(singles), '', ...grid] : grid,
|
|
316
|
+
example: `${firstHue}.${exampleShade}`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** How primitive colors behave, and how to name one from the grid. */
|
|
321
|
+
export function primitiveColorNote(example: string | undefined): string {
|
|
322
|
+
const grid = example
|
|
323
|
+
? ` A grid cell is the token \`'<hue>.<shade>'\`, e.g. \`'${example}'\`.`
|
|
324
|
+
: '';
|
|
325
|
+
return `Fixed colors, the same in light and dark.${grid}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function scaleTable(rows: [string, unknown][]): string[] {
|
|
329
|
+
return table(
|
|
330
|
+
['token', 'value'],
|
|
331
|
+
['left', 'right'],
|
|
332
|
+
rows.map(([k, v]) => [code(k), String(v)]),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function shadowTable(rows: TokenModel['shadows']): string[] {
|
|
337
|
+
return table(
|
|
338
|
+
['token', 'offset (x, y)', 'radius', 'opacity', 'elevation', 'color'],
|
|
339
|
+
['left', 'right', 'right', 'right', 'right', 'left'],
|
|
340
|
+
rows.map(([k, s]) => [
|
|
341
|
+
code(k),
|
|
342
|
+
`${s.shadowOffset?.width ?? 0}, ${s.shadowOffset?.height ?? 0}`,
|
|
343
|
+
String(s.shadowRadius ?? '–'),
|
|
344
|
+
String(s.shadowOpacity ?? '–'),
|
|
345
|
+
String(s.elevation ?? '–'),
|
|
346
|
+
String(s.shadowColor ?? '–'),
|
|
347
|
+
]),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Presets as rows, named by their full path (`title.md`). */
|
|
352
|
+
export function presetTable(
|
|
353
|
+
presets: Extract<TextNode, { kind: 'preset' }>[],
|
|
354
|
+
): string[] {
|
|
355
|
+
// Optional columns only appear when a preset in this table has a value.
|
|
356
|
+
const extras = optionalPresetColumns(presets);
|
|
357
|
+
return table(
|
|
358
|
+
['preset', ...PRESET_FIELDS, ...extras.map((c) => c.header)],
|
|
359
|
+
[
|
|
360
|
+
'left',
|
|
361
|
+
'right',
|
|
362
|
+
'right',
|
|
363
|
+
'right',
|
|
364
|
+
'right',
|
|
365
|
+
...extras.map(() => 'left' as const),
|
|
366
|
+
],
|
|
367
|
+
presets.map((p) => [
|
|
368
|
+
code(p.path),
|
|
369
|
+
...p.cells,
|
|
370
|
+
...extras.map((c) => c.cell(p)),
|
|
371
|
+
]),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
type PresetExtras = Pick<
|
|
376
|
+
Extract<TextNode, { kind: 'preset' }>,
|
|
377
|
+
'textTransform' | 'color'
|
|
378
|
+
>;
|
|
379
|
+
|
|
380
|
+
const OPTIONAL_PRESET_FIELDS = ['textTransform', 'color'] as const;
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Preset fields shown only when used (`textTransform`, `color`), in column
|
|
384
|
+
* order: a table includes a column when any of its presets sets the field.
|
|
385
|
+
*/
|
|
386
|
+
export function optionalPresetColumns(
|
|
387
|
+
presets: PresetExtras[],
|
|
388
|
+
): { header: string; cell: (preset: PresetExtras) => string }[] {
|
|
389
|
+
return OPTIONAL_PRESET_FIELDS.filter((field) =>
|
|
390
|
+
presets.some((p) => p[field] !== undefined),
|
|
391
|
+
).map((field) => ({ header: field, cell: (p) => p[field] ?? '–' }));
|
|
392
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stands in for `react-native` while the CLI evaluates a theme file in plain
|
|
3
|
+
* Node (see `load-theme.ts`). `react-native`'s entry is Flow source that Node
|
|
4
|
+
* can't parse, but a theme file may import from `@react-native-rethemed/core`,
|
|
5
|
+
* whose root also exports the React Native runtime (`createThemed`). Those
|
|
6
|
+
* imports are only used at render time, never while building a config, so an
|
|
7
|
+
* empty module is enough.
|
|
8
|
+
*/
|
|
9
|
+
module.exports = {};
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isSchemeColor,
|
|
3
|
+
isTextPreset,
|
|
4
|
+
TEXT_TOKEN_FIELDS,
|
|
5
|
+
type TextTokenTree,
|
|
6
|
+
type ThemeConfig,
|
|
7
|
+
walkSemanticColors,
|
|
8
|
+
walkTextPresets,
|
|
9
|
+
} from '@react-native-rethemed/core/config';
|
|
10
|
+
|
|
11
|
+
/** Values RN's `TextStyle['fontWeight']` accepts as strings. */
|
|
12
|
+
const RN_FONT_WEIGHTS = new Set([
|
|
13
|
+
'normal',
|
|
14
|
+
'bold',
|
|
15
|
+
'100',
|
|
16
|
+
'200',
|
|
17
|
+
'300',
|
|
18
|
+
'400',
|
|
19
|
+
'500',
|
|
20
|
+
'600',
|
|
21
|
+
'700',
|
|
22
|
+
'800',
|
|
23
|
+
'900',
|
|
24
|
+
'ultralight',
|
|
25
|
+
'thin',
|
|
26
|
+
'light',
|
|
27
|
+
'medium',
|
|
28
|
+
'regular',
|
|
29
|
+
'semibold',
|
|
30
|
+
'condensedBold',
|
|
31
|
+
'condensed',
|
|
32
|
+
'heavy',
|
|
33
|
+
'black',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** Values RN's `TextStyle['textTransform']` accepts. */
|
|
37
|
+
const RN_TEXT_TRANSFORMS = new Set([
|
|
38
|
+
'none',
|
|
39
|
+
'capitalize',
|
|
40
|
+
'uppercase',
|
|
41
|
+
'lowercase',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const TEXT_REF_SCALES = {
|
|
45
|
+
fontSize: 'fontSizes',
|
|
46
|
+
lineHeight: 'lineHeights',
|
|
47
|
+
letterSpacing: 'letterSpacings',
|
|
48
|
+
fontWeight: 'fontWeights',
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Top-level preset/group names that would collide with the properties every
|
|
53
|
+
* function already has, since `themed.text` itself is callable.
|
|
54
|
+
*/
|
|
55
|
+
const RESERVED_TEXT_NAMES = new Set([
|
|
56
|
+
'apply',
|
|
57
|
+
'arguments',
|
|
58
|
+
'bind',
|
|
59
|
+
'call',
|
|
60
|
+
'caller',
|
|
61
|
+
'constructor',
|
|
62
|
+
'length',
|
|
63
|
+
'name',
|
|
64
|
+
'prototype',
|
|
65
|
+
'toString',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const isPrimitive = (value: unknown) =>
|
|
69
|
+
typeof value === 'string' || typeof value === 'number';
|
|
70
|
+
|
|
71
|
+
const isField = (key: string) =>
|
|
72
|
+
(TEXT_TOKEN_FIELDS as readonly string[]).includes(key);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A preset `color`: a color string (both schemes), or `{ light?, dark? }`
|
|
76
|
+
* with at least one string.
|
|
77
|
+
*/
|
|
78
|
+
function checkTextColor(value: unknown, at: string, problems: string[]) {
|
|
79
|
+
if (typeof value === 'string' && value.length > 0) return;
|
|
80
|
+
const shape = `must be a color string or { light?, dark? } (preset field names such as 'color' can't name a group or preset)`;
|
|
81
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
82
|
+
problems.push(`${at}: ${shape}`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const entries = Object.entries(value);
|
|
86
|
+
const valid =
|
|
87
|
+
entries.length > 0 &&
|
|
88
|
+
entries.every(
|
|
89
|
+
([k, v]) =>
|
|
90
|
+
(k === 'light' || k === 'dark') &&
|
|
91
|
+
typeof v === 'string' &&
|
|
92
|
+
v.length > 0,
|
|
93
|
+
);
|
|
94
|
+
if (!valid) problems.push(`${at}: ${shape}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Structural checks for the `semanticTokens.text` tree: every node is an
|
|
99
|
+
* object that is either a preset (known fields only; primitives, plus
|
|
100
|
+
* `color` which may be `{ light?, dark? }`) or a group (all objects) —
|
|
101
|
+
* never both, never empty. See `isTextPreset` for how the two are told
|
|
102
|
+
* apart.
|
|
103
|
+
*/
|
|
104
|
+
function checkTextTree(
|
|
105
|
+
tree: TextTokenTree,
|
|
106
|
+
path: string[],
|
|
107
|
+
problems: string[],
|
|
108
|
+
): void {
|
|
109
|
+
for (const [key, node] of Object.entries(tree)) {
|
|
110
|
+
const at = `semanticTokens.text.${[...path, key].join('.')}`;
|
|
111
|
+
|
|
112
|
+
if (path.length === 0 && RESERVED_TEXT_NAMES.has(key)) {
|
|
113
|
+
problems.push(
|
|
114
|
+
`${at}: '${key}' is reserved (it collides with a function property of themed.text)`,
|
|
115
|
+
);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (typeof node !== 'object' || node === null || Array.isArray(node)) {
|
|
119
|
+
problems.push(`${at}: must be a preset or a group object`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const entries = Object.entries(node);
|
|
124
|
+
if (entries.length === 0) {
|
|
125
|
+
problems.push(`${at}: is empty`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (isTextPreset(node)) {
|
|
130
|
+
// Objects under a non-field key are groups/presets nested in a preset.
|
|
131
|
+
const nested = entries
|
|
132
|
+
.filter(([k, v]) => !isField(k) && !isPrimitive(v))
|
|
133
|
+
.map(([k]) => k);
|
|
134
|
+
if (nested.length > 0) {
|
|
135
|
+
const fields = entries
|
|
136
|
+
.filter(([k, v]) => isField(k) || isPrimitive(v))
|
|
137
|
+
.map(([k]) => k);
|
|
138
|
+
problems.push(
|
|
139
|
+
`${at}: mixes preset fields (${fields.join(', ')}) with groups (${nested.join(', ')})`,
|
|
140
|
+
);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
for (const [field, value] of entries) {
|
|
144
|
+
if (!isField(field)) {
|
|
145
|
+
problems.push(
|
|
146
|
+
`${at}.${field}: unknown preset field (expected ${TEXT_TOKEN_FIELDS.join(', ')})`,
|
|
147
|
+
);
|
|
148
|
+
} else if (field === 'color') {
|
|
149
|
+
checkTextColor(value, `${at}.color`, problems);
|
|
150
|
+
} else if (field === 'textTransform') {
|
|
151
|
+
if (typeof value !== 'string' || !RN_TEXT_TRANSFORMS.has(value)) {
|
|
152
|
+
problems.push(
|
|
153
|
+
`${at}.textTransform: must be one of ${[...RN_TEXT_TRANSFORMS].join(', ')}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
} else if (!isPrimitive(value)) {
|
|
157
|
+
problems.push(`${at}.${field}: must be a string or a number`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
const fields = entries.filter(([, v]) => isPrimitive(v)).map(([k]) => k);
|
|
162
|
+
if (fields.length > 0) {
|
|
163
|
+
const groups = entries
|
|
164
|
+
.filter(([, v]) => !isPrimitive(v))
|
|
165
|
+
.map(([k]) => k);
|
|
166
|
+
problems.push(
|
|
167
|
+
`${at}: mixes preset fields (${fields.join(', ')}) with groups (${groups.join(', ')})`,
|
|
168
|
+
);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
checkTextTree(node as TextTokenTree, [...path, key], problems);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Checks what the type system can no longer check once token types are
|
|
178
|
+
* generated rather than inferred: every `semanticTokens.text` reference must
|
|
179
|
+
* name a key of the matching `tokens` scale, and every semantic color must
|
|
180
|
+
* define both schemes. Returns human-readable problems (empty when valid).
|
|
181
|
+
*/
|
|
182
|
+
export function validateTheme(config: ThemeConfig): string[] {
|
|
183
|
+
const problems: string[] = [];
|
|
184
|
+
const tokens = config.tokens ?? {};
|
|
185
|
+
|
|
186
|
+
checkTextTree(config.semanticTokens?.text ?? {}, [], problems);
|
|
187
|
+
|
|
188
|
+
walkTextPresets(config.semanticTokens?.text, (path, preset) => {
|
|
189
|
+
for (const [field, scaleName] of Object.entries(TEXT_REF_SCALES)) {
|
|
190
|
+
const value = preset[field as keyof typeof preset];
|
|
191
|
+
if (typeof value !== 'string') continue;
|
|
192
|
+
|
|
193
|
+
const scale = tokens[scaleName];
|
|
194
|
+
if (scale && Object.hasOwn(scale, value)) continue;
|
|
195
|
+
if (field === 'fontWeight' && RN_FONT_WEIGHTS.has(value)) continue;
|
|
196
|
+
|
|
197
|
+
problems.push(
|
|
198
|
+
`semanticTokens.text.${path.join('.')}.${field}: '${value}' is not a key of tokens.${scaleName}` +
|
|
199
|
+
(scale ? '' : ` (tokens.${scaleName} is not defined)`),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const baseFontSize = config.defaults?.fontSize;
|
|
205
|
+
if (
|
|
206
|
+
typeof baseFontSize === 'string' &&
|
|
207
|
+
!(tokens.fontSizes && Object.hasOwn(tokens.fontSizes, baseFontSize))
|
|
208
|
+
) {
|
|
209
|
+
problems.push(
|
|
210
|
+
`defaults.fontSize: '${baseFontSize}' is not a key of tokens.fontSizes`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const checkSchemes = (value: unknown, token: string) => {
|
|
215
|
+
const color = value as { light?: unknown; dark?: unknown } | null;
|
|
216
|
+
if (typeof color?.light !== 'string' || typeof color?.dark !== 'string') {
|
|
217
|
+
problems.push(
|
|
218
|
+
`semanticTokens.colors.${token}: must define both 'light' and 'dark' strings`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
for (const [key, node] of Object.entries(
|
|
223
|
+
config.semanticTokens?.colors ?? {},
|
|
224
|
+
)) {
|
|
225
|
+
// A top-level color (`primary`) or a group of them (`bg.default`).
|
|
226
|
+
if (isSchemeColor(node) || typeof node !== 'object' || node === null) {
|
|
227
|
+
checkSchemes(node, key);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
for (const [name, value] of Object.entries(node)) {
|
|
231
|
+
checkSchemes(value, `${key}.${name}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return problems;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Problems that don't stop codegen. A semantic color named like a primitive
|
|
240
|
+
* (`semanticTokens.colors.red['500']` vs `tokens.colors['red.500']`) is
|
|
241
|
+
* legal — color props resolve the name to the semantic color — but usually
|
|
242
|
+
* an accident, e.g. after `extendTheme` combined two packages.
|
|
243
|
+
*/
|
|
244
|
+
export function themeWarnings(config: ThemeConfig): string[] {
|
|
245
|
+
const primitives = config.tokens?.colors ?? {};
|
|
246
|
+
const warnings: string[] = [];
|
|
247
|
+
walkSemanticColors(config.semanticTokens?.colors, (token) => {
|
|
248
|
+
if (Object.hasOwn(primitives, token)) {
|
|
249
|
+
warnings.push(
|
|
250
|
+
`'${token}' is both a semantic color and a primitive color (tokens.colors); color props resolve it to the semantic color`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
return warnings;
|
|
255
|
+
}
|