@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/src/docs.ts ADDED
@@ -0,0 +1,378 @@
1
+ import type { ThemeConfig } from '@react-native-rethemed/core/config';
2
+ import { code } from './emit';
3
+ import {
4
+ buildModel,
5
+ colorTable,
6
+ examplePreset,
7
+ lineHeightNote,
8
+ lineHeightRows,
9
+ presetGroups,
10
+ presetTable,
11
+ primitiveColorNote,
12
+ primitiveColorTables,
13
+ scaleTable,
14
+ shadowTable,
15
+ type TokenModel,
16
+ } from './model';
17
+
18
+ export type GenerateDocsOptions = {
19
+ config: ThemeConfig;
20
+ /** Theme file as shown to readers, e.g. `src/theme/themed/theme.ts`. */
21
+ themeFile: string;
22
+ /** Generated bindings as shown to readers, e.g. `src/theme/themed/themed.gen.ts`. */
23
+ genFile: string;
24
+ /** Shown in the header so readers know how to regenerate. */
25
+ command?: string;
26
+ };
27
+
28
+ type Section = { title: string; body: string[] };
29
+
30
+ /** A literal as it would be written in TS source (`4`, `'md'`). */
31
+ const asArg = (key: string) => (/^\d+(\.\d+)?$/.test(key) ? key : `'${key}'`);
32
+
33
+ /** `title.md` -> `themed.text.title.md`, bracketing non-identifier keys. */
34
+ const presetCall = (path: string) =>
35
+ `themed.text${path
36
+ .split('.')
37
+ .map((k) => (/^[A-Za-z_$][\w$]*$/.test(k) ? `.${k}` : `['${k}']`))
38
+ .join('')}`;
39
+
40
+ /** Picks a readable primitive for the example (not `transparent`). */
41
+ function primitiveFor(
42
+ model: TokenModel,
43
+ preferred: string,
44
+ ): string | undefined {
45
+ const keys = model.primitiveColors.map(([k]) => k);
46
+ return (
47
+ keys.find((k) => k === preferred) ??
48
+ keys.find((k) => k !== 'transparent') ??
49
+ keys[0]
50
+ );
51
+ }
52
+
53
+ const objectArg = (entries: string[]) =>
54
+ entries.length > 0 ? `{ ${entries.join(', ')} }` : '';
55
+
56
+ /** A primitive color token to cite in prose (`'red.500'`). */
57
+ function primitiveExample(model: TokenModel): string | undefined {
58
+ return (
59
+ primitiveColorTables(model.primitiveColors).example ??
60
+ primitiveFor(model, 'white')
61
+ );
62
+ }
63
+
64
+ function usageSection(model: TokenModel, genFile: string): Section {
65
+ const hasSemanticColors = model.colors.length > 0;
66
+ const hasPrimitiveColors = model.primitiveColors.length > 0;
67
+ const hasPresets = model.text.length > 0;
68
+ const hasSemanticTokens = hasSemanticColors || hasPresets;
69
+
70
+ // Only real token names from this theme, so the example always
71
+ // type-checks. Categories the theme does not define are left out.
72
+ // Semantic colors are preferred; a primitive stands in when there are none.
73
+ const bg =
74
+ (
75
+ model.colors.find((c) => c.group === 'bg') ??
76
+ model.colors.find((c) => c.token === 'background') ??
77
+ model.colors[0]
78
+ )?.token ?? primitiveFor(model, 'white');
79
+ const fg =
80
+ (
81
+ model.colors.find((c) => c.group === 'fg') ??
82
+ model.colors.find((c) => c.token === 'foreground') ??
83
+ model.colors[0]
84
+ )?.token ?? primitiveFor(model, 'black');
85
+ const radius =
86
+ model.radii.find(([k]) => k === 'md')?.[0] ??
87
+ model.radii[Math.floor(model.radii.length / 2)]?.[0];
88
+ const spacing =
89
+ model.spacing.find(([, v]) => v >= 16)?.[0] ?? model.spacing[0]?.[0];
90
+ const shadow = model.shadows[0]?.[0];
91
+ const fontSize =
92
+ model.fontSizes.find(([k]) => k === 'md')?.[0] ?? model.fontSizes[0]?.[0];
93
+
94
+ const viewArgs = [
95
+ ...(bg !== undefined ? [`backgroundColor: ${asArg(bg)},`] : []),
96
+ ...(radius !== undefined ? [`borderRadius: ${asArg(radius)},`] : []),
97
+ ...(spacing !== undefined ? [`padding: ${asArg(spacing)},`] : []),
98
+ ...(shadow !== undefined ? [`shadow: ${asArg(shadow)},`] : []),
99
+ ];
100
+ const view =
101
+ viewArgs.length > 0
102
+ ? [
103
+ ' <View',
104
+ ' style={themed.view({',
105
+ ...viewArgs.map((l) => ` ${l}`),
106
+ ' })}',
107
+ ' >',
108
+ ]
109
+ : // No View-related tokens: `themed.view()` needs an argument, and a
110
+ // style with nothing in it adds nothing to the example.
111
+ [' <View>'];
112
+
113
+ // Prefer a mid-sized heading (`title.md`) for the example when it exists.
114
+ const presetPath = examplePreset(model.text);
115
+ const textArgs = objectArg([
116
+ ...(!presetPath && fontSize !== undefined
117
+ ? [`fontSize: ${asArg(fontSize)}`]
118
+ : []),
119
+ ...(fg !== undefined ? [`color: ${asArg(fg)}`] : []),
120
+ ]);
121
+ const textCall = `${presetPath ? presetCall(presetPath) : 'themed.text'}(${textArgs})`;
122
+
123
+ const primitive = primitiveExample(model);
124
+ const colorRules = [
125
+ hasSemanticColors || hasPrimitiveColors
126
+ ? "- **Colors, radii and spacing are token-only.** Use the names in the tables below; raw values like `'#fff'` or `12` do not type-check. Spacing also accepts `'auto'` and percentages."
127
+ : "- **Radii and spacing are token-only.** Use the names in the tables below; raw values like `12` do not type-check. Spacing also accepts `'auto'` and percentages.",
128
+ '- For a genuine one-off raw value, put it in a second plain style object: `style={[themed.view({ padding: 4 }), { backgroundColor: overlayColor }]}`. Do not add a token for it.',
129
+ hasSemanticColors
130
+ ? `- **Prefer semantic colors** (\`'group.token'\`). They switch with light/dark.${hasPrimitiveColors ? ` Primitive colors (\`'${primitive}'\`) are accepted too, but they are fixed: use them only for values that must not change with the scheme.` : ''}`
131
+ : hasPrimitiveColors
132
+ ? `- **This theme defines no semantic colors:** color props take primitive colors (\`'${primitive}'\`), which are the same in light and dark. Add \`semanticTokens.colors\` to the theme for colors that follow the scheme.`
133
+ : '- **This theme defines no colors**, so `themed.*()` accepts no color values. Put colors in a second plain style object.',
134
+ ];
135
+
136
+ const typographyRule = hasPresets
137
+ ? '- **Typography:** prefer the presets `themed.text.<path>(override?)` (see Text presets). `fontSize` / `fontWeight` / `lineHeight` / `letterSpacing` accept a token or a raw value.'
138
+ : '- **Typography:** `fontSize` / `fontWeight` / `lineHeight` / `letterSpacing` accept a token or a raw value. This theme defines no text presets (`semanticTokens.text`).';
139
+
140
+ const lineHeightRule = (() => {
141
+ const hasScale = model.lineHeights.length > 0;
142
+ const basics = hasScale
143
+ ? 'a `lineHeight` token is a ratio of `fontSize`; a raw number is absolute.'
144
+ : 'a `lineHeight` number is absolute.';
145
+ const noSplit = 'Do not override `fontSize` in a separate style object.';
146
+ const { ratio, absolute } = model.presetLineHeights;
147
+ // A real size for the examples: a font-size token, else a number.
148
+ const largerSize = asArg(
149
+ model.fontSizes.find(([k]) => k === 'lg')?.[0] ??
150
+ model.fontSizes.at(-1)?.[0] ??
151
+ '18',
152
+ );
153
+ const ratioHow = ratio
154
+ ? `\`${presetCall(ratio)}({ fontSize: ${largerSize} })\` recomputes the line height`
155
+ : '';
156
+ const absoluteHow = absolute
157
+ ? `\`${presetCall(absolute)}({ fontSize: 18, lineHeight: 26 })\` needs \`lineHeight\` too, because its line height is absolute and \`fontSize\` alone keeps it`
158
+ : '';
159
+
160
+ if (ratio && absolute) {
161
+ return [
162
+ `- **Line heights:** ${basics} To change a preset's size, pass it in the override. A preset whose line height is a token follows it (${ratioHow}); a preset with an absolute line height does not (${absoluteHow}). ${noSplit}`,
163
+ ];
164
+ }
165
+ if (ratio) {
166
+ return [
167
+ `- **Line heights:** ${basics} To change a preset's size, pass it in the override: ${ratioHow}. ${noSplit}`,
168
+ ];
169
+ }
170
+ if (absolute) {
171
+ return [
172
+ `- **Line heights:** ${basics} The presets use absolute line heights, so to change a preset's size pass both in the override: ${absoluteHow}. ${noSplit}`,
173
+ ];
174
+ }
175
+ if (hasScale) {
176
+ return [
177
+ `- **Line heights:** ${basics} Set \`fontSize\` in the same \`themed.text()\` call (\`themed.text({ ${fontSize !== undefined ? `fontSize: ${asArg(fontSize)}, ` : ''}lineHeight: ${asArg(model.lineHeights[0][0])} })\`) so the line height is computed from it, not in a separate style object.`,
178
+ ];
179
+ }
180
+ return [];
181
+ })();
182
+
183
+ const semanticPath = {
184
+ grouped: '`useThemed().semanticTokens.colors.<group>.<token>`',
185
+ flat: "`useThemed().semanticTokens.colors.<token>` (`colors['primary-foreground']` for names with a hyphen)",
186
+ mixed:
187
+ '`useThemed().semanticTokens.colors.<group>.<token>` (`.colors.<token>` for colors outside a group)',
188
+ }[colorNaming(model)];
189
+ const outsideStyleRule = hasSemanticColors
190
+ ? `- Outside \`style\` (e.g. an icon \`color\` prop), read resolved values from ${semanticPath} or \`useThemed().tokens\`.`
191
+ : '- Outside `style` (e.g. an icon `color` prop), read values from `useThemed().tokens`.';
192
+
193
+ return {
194
+ title: 'Usage',
195
+ body: [
196
+ `Get \`themed\`${hasSemanticTokens ? ', `tokens` and `semanticTokens`' : ' and `tokens`'} from \`useThemed()\` (exported by \`${genFile}\`). Values follow the current light/dark scheme, so call it inside the component.`,
197
+ '',
198
+ '```tsx',
199
+ "import { Text, View } from 'react-native';",
200
+ '',
201
+ 'function Card() {',
202
+ ' const { themed } = useThemed();',
203
+ ' return (',
204
+ ...view,
205
+ ` <Text style={${textCall}}>Title</Text>`,
206
+ ' </View>',
207
+ ' );',
208
+ '}',
209
+ '```',
210
+ '',
211
+ '### Rules',
212
+ '',
213
+ '- Pass every style through `themed.view()` / `themed.text()` / `themed.image()`. Props without tokens (`flex`, `width`, …) pass through unchanged.',
214
+ ...colorRules,
215
+ typographyRule,
216
+ ...lineHeightRule,
217
+ model.zIndices.length > 0
218
+ ? '- `zIndex` accepts a z-index token or a raw number.'
219
+ : '- `zIndex` takes a raw number (this theme defines no z-index tokens).',
220
+ ...(model.shadows.length > 0
221
+ ? [
222
+ '- **Shadows:** use the virtual `shadow` prop (View and Image). It expands to the platform shadow props and `elevation`.',
223
+ ]
224
+ : []),
225
+ outsideStyleRule,
226
+ '- **Performance:** calling `themed.*()` inline on every render is fine. Built-in components compare `style` by value, and a call costs well under a microsecond. Memoize with `useMemo(() => themed.view({ ... }), [themed])` only when the style must keep the same reference: when it is passed to a `React.memo` component, passed as a list prop such as `contentContainerStyle` / `ListHeaderComponentStyle`, or used as a hook dependency. `themed` itself only changes when the color scheme does.',
227
+ '- Do not edit the generated files. Change the theme file and re-run the codegen command instead.',
228
+ ],
229
+ };
230
+ }
231
+
232
+ /**
233
+ * How the theme names its semantic colors: all in groups (`'bg.default'`),
234
+ * all top-level (`'primary'`, as in shadcn/ui), or both.
235
+ */
236
+ function colorNaming(model: TokenModel): 'grouped' | 'flat' | 'mixed' {
237
+ const flat = model.colors.filter((c) => c.group === '').length;
238
+ if (flat === 0) return 'grouped';
239
+ return flat === model.colors.length ? 'flat' : 'mixed';
240
+ }
241
+
242
+ function colorSection(model: TokenModel): Section | null {
243
+ if (model.colors.length === 0) return null;
244
+ const groups = [...new Set(model.colors.map((c) => c.group))].filter(
245
+ (group) => group !== '',
246
+ );
247
+ const flat = model.colors.filter((c) => c.group === '');
248
+ const usage = {
249
+ grouped: "Use as `'<group>.<token>'`",
250
+ flat: "Use by name (`'<token>'`)",
251
+ mixed:
252
+ "Use as `'<group>.<token>'`, or by name (`'<token>'`) for colors outside a group,",
253
+ }[colorNaming(model)];
254
+ return {
255
+ title: 'Semantic colors',
256
+ body: [
257
+ `${usage} on \`color\`, \`backgroundColor\`, \`border*Color\`, \`tintColor\`, \`overlayColor\`, \`shadowColor\`, \`textShadowColor\`, \`textDecorationColor\` and \`outlineColor\`.`,
258
+ ...(flat.length > 0 ? ['', ...colorTable(flat)] : []),
259
+ ...groups.flatMap((group) => [
260
+ '',
261
+ `### ${group}`,
262
+ '',
263
+ ...colorTable(model.colors.filter((c) => c.group === group)),
264
+ ]),
265
+ ],
266
+ };
267
+ }
268
+
269
+ function scaleSection(
270
+ title: string,
271
+ intro: string,
272
+ rows: [string, unknown][],
273
+ ): Section | null {
274
+ if (rows.length === 0) return null;
275
+ return { title, body: [intro, '', ...scaleTable(rows)] };
276
+ }
277
+
278
+ function shadowSection(model: TokenModel): Section | null {
279
+ if (model.shadows.length === 0) return null;
280
+ return {
281
+ title: 'Shadows',
282
+ body: [
283
+ `Use with the virtual \`shadow\` prop: \`themed.view({ shadow: ${asArg(model.shadows[0][0])} })\`.`,
284
+ '',
285
+ ...shadowTable(model.shadows),
286
+ ],
287
+ };
288
+ }
289
+
290
+ function textSection(model: TokenModel): Section | null {
291
+ const groups = presetGroups(model.text);
292
+ if (groups.length === 0) return null;
293
+ return {
294
+ title: 'Text presets',
295
+ body: [
296
+ `Call a preset by its path: \`themed.text.<path>(override?)\`, e.g. \`${presetCall(examplePreset(model.text) ?? '')}()\`. The override is merged on top and accepts the same tokens as \`themed.text()\`. A cell like \`\` \`lg\` (18) \`\` means the preset references the \`lg\` token, which resolves to 18.`,
297
+ ...groups.flatMap((g) => [
298
+ '',
299
+ ...(g.path ? [`### ${g.path}`, ''] : []),
300
+ ...presetTable(g.presets),
301
+ ]),
302
+ ],
303
+ };
304
+ }
305
+
306
+ function primitiveColorSection(model: TokenModel): Section | null {
307
+ if (model.primitiveColors.length === 0) return null;
308
+ const { lines, example } = primitiveColorTables(model.primitiveColors);
309
+ const token = example ?? primitiveExample(model);
310
+ return {
311
+ title: 'Primitive colors',
312
+ body: [
313
+ `${primitiveColorNote(example)} Use them on the same color props (\`themed.view({ backgroundColor: '${token}' })\`) or read them via \`useThemed().tokens.colors[...]\`.${model.colors.length > 0 ? ' Prefer semantic colors for anything that should follow the scheme.' : ''}`,
314
+ '',
315
+ ...lines,
316
+ ],
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Renders the AI-/human-readable reference for a theme: usage rules plus
322
+ * every token table. Deterministic (no timestamps), so it only changes when
323
+ * the theme does.
324
+ */
325
+ export function generateDocs({
326
+ config,
327
+ themeFile,
328
+ genFile,
329
+ command = 'react-native-rethemed codegen',
330
+ }: GenerateDocsOptions): string {
331
+ const model = buildModel(config);
332
+
333
+ const sections = [
334
+ usageSection(model, genFile),
335
+ colorSection(model),
336
+ primitiveColorSection(model),
337
+ scaleSection(
338
+ 'Radii',
339
+ 'For `borderRadius` and every corner-radius variant.',
340
+ model.radii,
341
+ ),
342
+ scaleSection(
343
+ 'Spacing',
344
+ 'For `padding*`, `margin*` (including the logical `*Block*` / `*Inline*` variants), `gap`, `rowGap` and `columnGap`. Positional props (`top`, `left`, `inset`, …) take raw values.',
345
+ model.spacing,
346
+ ),
347
+ scaleSection('Font sizes', 'For `fontSize`.', model.fontSizes),
348
+ scaleSection('Font weights', 'For `fontWeight`.', model.fontWeights),
349
+ scaleSection(
350
+ 'Line heights',
351
+ `For \`lineHeight\`. ${lineHeightNote(model)}`,
352
+ lineHeightRows(model),
353
+ ),
354
+ scaleSection(
355
+ 'Letter spacings',
356
+ 'For `letterSpacing`.',
357
+ model.letterSpacings,
358
+ ),
359
+ shadowSection(model),
360
+ textSection(model),
361
+ scaleSection(
362
+ 'z-indices',
363
+ 'For `zIndex`, which also accepts a raw number.',
364
+ model.zIndices,
365
+ ),
366
+ ].filter((s): s is Section => s !== null);
367
+
368
+ return [
369
+ '<!-- Code generated by @react-native-rethemed/cli. DO NOT EDIT. -->',
370
+ `<!-- Regenerate with: ${command} -->`,
371
+ '',
372
+ '# Theme tokens',
373
+ '',
374
+ `The design tokens of this app's theme (${code(themeFile)}) and how to use them with react-native-rethemed. Always use these tokens instead of hard-coded colors, radii and spacing.`,
375
+ ...sections.flatMap((s) => ['', `## ${s.title}`, '', ...s.body]),
376
+ '',
377
+ ].join('\n');
378
+ }
package/src/emit.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Small string helpers for emitting TypeScript source and Markdown tables
3
+ * inside JSDoc. Kept free of theme knowledge so `generate.ts` reads as the
4
+ * layout of the generated file only.
5
+ */
6
+
7
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
8
+
9
+ /** `'0'`, `'0.5'`, `'12'` — keys JS/TS treat as numbers. */
10
+ export function isNumericKey(key: string): boolean {
11
+ return /^\d+(\.\d+)?$/.test(key) && String(Number(key)) === key;
12
+ }
13
+
14
+ /** A string literal type, single-quoted. */
15
+ export function quote(value: string): string {
16
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
17
+ }
18
+
19
+ /**
20
+ * A token name as a literal type. Numeric keys become number literals so
21
+ * `padding: 4` type-checks (matches how `keyof { 4: 16 }` is typed).
22
+ */
23
+ export function keyLiteral(key: string): string {
24
+ return isNumericKey(key) ? key : quote(key);
25
+ }
26
+
27
+ /** A property name in a type literal / interface. */
28
+ export function member(key: string): string {
29
+ return IDENTIFIER.test(key) || isNumericKey(key) ? key : quote(key);
30
+ }
31
+
32
+ /** A union type; one member per line once it gets long. */
33
+ export function union(keys: string[], indent = ''): string {
34
+ if (keys.length === 0) return 'never';
35
+ const literals = keys.map(keyLiteral);
36
+ if (literals.length <= 4) return literals.join(' | ');
37
+ return literals.map((l) => `\n${indent} | ${l}`).join('');
38
+ }
39
+
40
+ /** A JSDoc block; empty strings become blank ` *` lines. */
41
+ export function jsdoc(lines: string[], indent: string): string {
42
+ const body = lines.map((l) => (l ? `${indent} * ${l}` : `${indent} *`));
43
+ return [`${indent}/**`, ...body, `${indent} */`].join('\n');
44
+ }
45
+
46
+ export type Align = 'left' | 'right';
47
+
48
+ /** A GitHub-flavoured Markdown table (VS Code renders these in hovers). */
49
+ export function table(
50
+ header: string[],
51
+ align: Align[],
52
+ rows: (string | number)[][],
53
+ ): string[] {
54
+ const cell = (v: string | number) => String(v).replace(/\|/g, '\\|');
55
+ const rule = align.map((a) => (a === 'left' ? ':--' : '--:')).join('|');
56
+ return [
57
+ `| ${header.join(' | ')} |`,
58
+ `|${rule}|`,
59
+ ...rows.map((r) => `| ${r.map(cell).join(' | ')} |`),
60
+ ];
61
+ }
62
+
63
+ /** Inline code in Markdown. */
64
+ export function code(value: string | number): string {
65
+ return `\`${value}\``;
66
+ }
67
+
68
+ /** A type expression emitted verbatim by `typeLiteral` (e.g. a union). */
69
+ export class TypeExpr {
70
+ constructor(readonly text: string) {}
71
+ }
72
+
73
+ /**
74
+ * The exact type of a plain JSON-like value, e.g. `{ md: 16; lg: 18 }`,
75
+ * so `useThemed().tokens` keeps literal types without inference.
76
+ */
77
+ export function typeLiteral(value: unknown, indent: string): string {
78
+ if (value instanceof TypeExpr) return value.text;
79
+ if (typeof value === 'string') return quote(value);
80
+ if (typeof value === 'number' || typeof value === 'boolean') {
81
+ return String(value);
82
+ }
83
+ if (value === null) return 'null';
84
+ if (Array.isArray(value)) {
85
+ return `readonly [${value.map((v) => typeLiteral(v, indent)).join(', ')}]`;
86
+ }
87
+ if (typeof value === 'object') {
88
+ const entries = Object.entries(value as Record<string, unknown>);
89
+ if (entries.length === 0) return '{}';
90
+ const inner = `${indent} `;
91
+ const body = entries
92
+ .map(([k, v]) => `${inner}${member(k)}: ${typeLiteral(v, inner)};`)
93
+ .join('\n');
94
+ return `{\n${body}\n${indent}}`;
95
+ }
96
+ return 'unknown';
97
+ }
98
+
99
+ /**
100
+ * Definition order, except scales with integer-like keys: JS moves those to
101
+ * the front of an object, so the authored order is already lost — sort by
102
+ * value instead so the table still reads as a scale.
103
+ */
104
+ export function orderedEntries<V>(
105
+ table: Record<string, V> | undefined,
106
+ rank: (value: V) => number,
107
+ ): [string, V][] {
108
+ const entries = Object.entries(table ?? {});
109
+ return entries.some(([k]) => /^\d+$/.test(k))
110
+ ? entries.sort((a, b) => rank(a[1]) - rank(b[1]))
111
+ : entries;
112
+ }