@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 manato
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # @react-native-rethemed/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/@react-native-rethemed/cli.svg)](https://www.npmjs.com/package/@react-native-rethemed/cli)
4
+
5
+ Code generator for [react-native-rethemed](https://github.com/manakuro/react-native-rethemed). Reads your theme and writes:
6
+
7
+ - **`themed.gen.ts`** — token types, a JSDoc table of tokens and values on every token-aware prop (shown in editor hovers and autocomplete), and the ready-to-use `ThemedProvider` / `useThemed` / `useColorMode`.
8
+ - **A token reference for AI agents** (`--docs`) — every token with its light/dark values, every text preset, and the rules for using them. Reference it from your `DESIGN.md` so coding agents use tokens instead of hard-coded values.
9
+
10
+ ## Installation
11
+
12
+ ```sh
13
+ npm install @react-native-rethemed/core
14
+ npm install --save-dev @react-native-rethemed/cli
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```sh
20
+ npx @react-native-rethemed/cli codegen src/theme/theme.ts --docs docs/themed.md
21
+ ```
22
+
23
+ We recommend a script run from `prepare`, so the bindings are regenerated on every install:
24
+
25
+ ```json
26
+ {
27
+ "scripts": {
28
+ "theme:codegen": "react-native-rethemed codegen src/theme/theme.ts --docs docs/themed.md",
29
+ "prepare": "npm run theme:codegen"
30
+ }
31
+ }
32
+ ```
33
+
34
+ The theme file is evaluated directly (TypeScript is supported, no build step), and validated before anything is written — for example, a text preset that references a missing `fontSizes` key fails with a clear message.
35
+
36
+ ## Options
37
+
38
+ ```
39
+ Usage: react-native-rethemed codegen <theme-file> [options]
40
+
41
+ Options:
42
+ -o, --out <file> Output file (default: <theme-dir>/themed.gen.ts)
43
+ -d, --docs <file> Also write a Markdown token reference for AI
44
+ agents and humans (e.g. docs/themed.md)
45
+ -e, --export <name> Export holding the config (default: the default
46
+ export, or the only ThemeConfig-looking export)
47
+ --core <module> Core module specifier used by the generated file
48
+ (default: @react-native-rethemed/core)
49
+ -h, --help Show this help
50
+ ```
51
+
52
+ See the [main README](https://github.com/manakuro/react-native-rethemed#readme) for the generated API.
53
+
54
+ ## License
55
+
56
+ MIT
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // Runs the TypeScript sources through jiti, so no build step is needed while
3
+ // the package lives in the monorepo (and `prepare` never races a build).
4
+ import { createJiti } from 'jiti';
5
+
6
+ const jiti = createJiti(import.meta.url);
7
+ const { main } = await jiti.import('../src/cli.ts');
8
+ await main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@react-native-rethemed/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "CLI for react-native-rethemed: generates typed token docs (themed.gen.ts) and an AI-agent token reference from your theme.",
5
+ "keywords": [
6
+ "react-native",
7
+ "react-native-rethemed",
8
+ "design-tokens",
9
+ "codegen",
10
+ "cli",
11
+ "ai-agents"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "manakuro",
15
+ "homepage": "https://github.com/manakuro/react-native-rethemed/tree/main/packages/react-native-rethemed/cli#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/manakuro/react-native-rethemed/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/manakuro/react-native-rethemed.git",
22
+ "directory": "packages/react-native-rethemed/cli"
23
+ },
24
+ "type": "module",
25
+ "bin": {
26
+ "react-native-rethemed": "./bin/react-native-rethemed.mjs"
27
+ },
28
+ "files": [
29
+ "bin",
30
+ "src",
31
+ "!src/**/*.test.ts",
32
+ "!src/__fixtures__"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "dependencies": {
38
+ "@clack/prompts": "1.8.1",
39
+ "@react-native-rethemed/core": "^0.1.1",
40
+ "jiti": "2.7.0"
41
+ },
42
+ "devDependencies": {
43
+ "@biomejs/biome": "2.5.4",
44
+ "@types/node": "24.10.4",
45
+ "@types/react": "19.2.17",
46
+ "biome-config": "0.0.0",
47
+ "react": "19.2.3",
48
+ "react-native": "0.86.2",
49
+ "typescript": "6.0.3",
50
+ "vitest": "4.1.10"
51
+ },
52
+ "scripts": {
53
+ "lint": "biome check src bin",
54
+ "lint:fix": "biome check --write --unsafe src bin",
55
+ "tsc": "tsc --noEmit",
56
+ "test": "vitest",
57
+ "test:ci": "vitest run"
58
+ }
6
59
  }
package/src/cli.ts ADDED
@@ -0,0 +1,156 @@
1
+ import path from 'node:path';
2
+ import { parseArgs, styleText } from 'node:util';
3
+ import * as p from '@clack/prompts';
4
+ import { CodegenError, codegen, type TokenCounts } from './codegen';
5
+
6
+ const USAGE = `Usage: react-native-rethemed codegen <theme-file> [options]
7
+
8
+ Generates typed \`themed\` bindings (themed.gen.ts) from a theme config.
9
+
10
+ Options:
11
+ -o, --out <file> Output file (default: <theme-dir>/themed.gen.ts)
12
+ -d, --docs <file> Also write a Markdown token reference for AI
13
+ agents and humans (e.g. docs/themed.md)
14
+ -e, --export <name> Export holding the config (default: the default
15
+ export, or the only ThemeConfig-looking export)
16
+ --core <module> Core module specifier used by the generated file
17
+ (default: @react-native-rethemed/core)
18
+ -h, --help Show this help`;
19
+
20
+ const dim = (text: string) => styleText('dim', text);
21
+
22
+ /** Paths as the user typed them: relative to where the command ran. */
23
+ const display = (file: string) =>
24
+ path.relative(process.cwd(), file).split(path.sep).join('/');
25
+
26
+ const COUNT_LABELS: [keyof TokenCounts, string][] = [
27
+ ['colors', 'semantic colors'],
28
+ ['primitiveColors', 'primitive colors'],
29
+ ['radii', 'radii'],
30
+ ['spacing', 'spacing'],
31
+ ['shadows', 'shadows'],
32
+ ['textPresets', 'text presets'],
33
+ ];
34
+
35
+ /** `54 semantic colors · 11 radii · 35 spacing`, skipping empty categories. */
36
+ export function formatCounts(counts: TokenCounts): string {
37
+ const parts = COUNT_LABELS.filter(([key]) => counts[key] > 0).map(
38
+ ([key, label]) => `${counts[key]} ${label}`,
39
+ );
40
+ return parts.length > 0 ? parts.join(' · ') : 'no tokens';
41
+ }
42
+
43
+ const STAGE_TITLES = {
44
+ load: 'Failed to load theme',
45
+ validate: 'Invalid theme config',
46
+ write: 'Failed to write output',
47
+ } as const;
48
+
49
+ const parseCodegenArgs = (args: string[]) =>
50
+ parseArgs({
51
+ args,
52
+ allowPositionals: true,
53
+ options: {
54
+ out: { type: 'string', short: 'o' },
55
+ docs: { type: 'string', short: 'd' },
56
+ export: { type: 'string', short: 'e' },
57
+ core: { type: 'string' },
58
+ help: { type: 'boolean', short: 'h' },
59
+ },
60
+ });
61
+
62
+ async function runCodegen(args: string[]): Promise<void> {
63
+ let parsed: ReturnType<typeof parseCodegenArgs>;
64
+ try {
65
+ parsed = parseCodegenArgs(args);
66
+ } catch (error) {
67
+ // e.g. an unknown option or a missing option value
68
+ console.error(`${error instanceof Error ? error.message : error}\n`);
69
+ console.log(USAGE);
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+ const { values, positionals } = parsed;
74
+
75
+ if (values.help || positionals.length !== 1) {
76
+ console.log(USAGE);
77
+ if (!values.help) process.exitCode = 1;
78
+ return;
79
+ }
80
+
81
+ const startedAt = performance.now();
82
+ p.intro('react-native-rethemed ⚡️');
83
+
84
+ try {
85
+ await codegen(
86
+ {
87
+ themeFile: positionals[0],
88
+ outFile: values.out,
89
+ exportName: values.export,
90
+ coreSpecifier: values.core,
91
+ docsFile: values.docs,
92
+ },
93
+ {
94
+ loaded: ({ themeFile, exportName }) =>
95
+ p.log.step(
96
+ `✅ Loaded theme ${dim(`${display(themeFile)} (${exportName})`)}`,
97
+ ),
98
+ validated: (counts) =>
99
+ p.log.step(`✅ Validated tokens ${dim(formatCounts(counts))}`),
100
+ warned: (warnings) =>
101
+ p.log.warn(
102
+ [
103
+ `⚠️ ${warnings.length === 1 ? '1 warning' : `${warnings.length} warnings`}`,
104
+ ...warnings.map((w) => dim(` - ${w}`)),
105
+ ].join('\n'),
106
+ ),
107
+ written: ({ outFile, changed }) =>
108
+ p.log.step(
109
+ changed
110
+ ? `✅ Generated ${display(outFile)}`
111
+ : `⏭️ ${display(outFile)} ${dim('is up to date')}`,
112
+ ),
113
+ docsWritten: ({ docsFile, changed }) =>
114
+ p.log.step(
115
+ changed
116
+ ? `✅ Generated ${display(docsFile)}`
117
+ : `⏭️ ${display(docsFile)} ${dim('is up to date')}`,
118
+ ),
119
+ },
120
+ );
121
+ } catch (error) {
122
+ if (error instanceof CodegenError) {
123
+ const details = error.details.map((d) => dim(` - ${d}`));
124
+ const title =
125
+ error.stage === 'validate'
126
+ ? STAGE_TITLES.validate
127
+ : `${STAGE_TITLES[error.stage]}: ${error.message}`;
128
+ p.log.error([`❌ ${title}`, ...details].join('\n'));
129
+ } else {
130
+ p.log.error(`❌ ${error instanceof Error ? error.message : error}`);
131
+ }
132
+ p.cancel('Failed');
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+
137
+ p.outro(`🎉 Done in ${Math.round(performance.now() - startedAt)}ms`);
138
+ }
139
+
140
+ export async function main(argv: string[]): Promise<void> {
141
+ const [command, ...rest] = argv;
142
+ switch (command) {
143
+ case 'codegen':
144
+ await runCodegen(rest);
145
+ break;
146
+ case undefined:
147
+ case '-h':
148
+ case '--help':
149
+ console.log(USAGE);
150
+ break;
151
+ default:
152
+ console.error(`Unknown command: ${command}\n`);
153
+ console.log(USAGE);
154
+ process.exitCode = 1;
155
+ }
156
+ }
package/src/codegen.ts ADDED
@@ -0,0 +1,216 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ type ThemeConfig,
5
+ walkSemanticColors,
6
+ walkTextPresets,
7
+ } from '@react-native-rethemed/core/config';
8
+ import { generateDocs } from './docs';
9
+ import { generate } from './generate';
10
+ import { loadTheme } from './load-theme';
11
+ import { themeWarnings, validateTheme } from './validate';
12
+
13
+ export type CodegenOptions = {
14
+ /** Theme file, absolute or relative to `cwd`. */
15
+ themeFile: string;
16
+ /** Output file. Defaults to `themed.gen.ts` next to the theme file. */
17
+ outFile?: string;
18
+ /** Export holding the config. Auto-detected when omitted. */
19
+ exportName?: string;
20
+ /** Core module specifier used by the generated file. */
21
+ coreSpecifier?: string;
22
+ /** Also write the Markdown token reference here (opt-in). */
23
+ docsFile?: string;
24
+ cwd?: string;
25
+ };
26
+
27
+ export type TokenCounts = {
28
+ /** Semantic colors (`semanticTokens.colors`). */
29
+ colors: number;
30
+ /** Primitive colors (`tokens.colors`). */
31
+ primitiveColors: number;
32
+ radii: number;
33
+ spacing: number;
34
+ fontSizes: number;
35
+ fontWeights: number;
36
+ lineHeights: number;
37
+ letterSpacings: number;
38
+ shadows: number;
39
+ textPresets: number;
40
+ };
41
+
42
+ export type CodegenResult = {
43
+ themeFile: string;
44
+ exportName: string;
45
+ outFile: string;
46
+ counts: TokenCounts;
47
+ /** Non-fatal theme problems (see `themeWarnings`). */
48
+ warnings: string[];
49
+ /** `false` when the output was already up to date (nothing written). */
50
+ changed: boolean;
51
+ /** Present when `docsFile` was requested. */
52
+ docs?: { file: string; changed: boolean };
53
+ };
54
+
55
+ /** Which step failed, so the CLI can say where things went wrong. */
56
+ export type CodegenStage = 'load' | 'validate' | 'write';
57
+
58
+ export class CodegenError extends Error {
59
+ constructor(
60
+ readonly stage: CodegenStage,
61
+ message: string,
62
+ readonly details: string[] = [],
63
+ ) {
64
+ super(message);
65
+ this.name = 'CodegenError';
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Progress callbacks, fired as each step completes. The CLI renders them;
71
+ * tests and programmatic callers can ignore them and use the result.
72
+ */
73
+ export type CodegenReporter = {
74
+ loaded?: (info: { themeFile: string; exportName: string }) => void;
75
+ validated?: (counts: TokenCounts) => void;
76
+ /** Only fired when there is something to warn about. */
77
+ warned?: (warnings: string[]) => void;
78
+ written?: (info: { outFile: string; changed: boolean }) => void;
79
+ docsWritten?: (info: { docsFile: string; changed: boolean }) => void;
80
+ };
81
+
82
+ const size = (table: object | undefined) => Object.keys(table ?? {}).length;
83
+
84
+ function countPresets(config: ThemeConfig): number {
85
+ let count = 0;
86
+ walkTextPresets(config.semanticTokens?.text, () => {
87
+ count += 1;
88
+ });
89
+ return count;
90
+ }
91
+
92
+ export function countTokens(config: ThemeConfig): TokenCounts {
93
+ const tokens = config.tokens ?? {};
94
+ let colors = 0;
95
+ walkSemanticColors(config.semanticTokens?.colors, () => {
96
+ colors += 1;
97
+ });
98
+
99
+ return {
100
+ colors,
101
+ primitiveColors: size(tokens.colors),
102
+ radii: size(tokens.radii),
103
+ spacing: size(tokens.spacing),
104
+ fontSizes: size(tokens.fontSizes),
105
+ fontWeights: size(tokens.fontWeights),
106
+ lineHeights: size(tokens.lineHeights),
107
+ letterSpacings: size(tokens.letterSpacings),
108
+ shadows: size(tokens.shadows),
109
+ textPresets: countPresets(config),
110
+ };
111
+ }
112
+
113
+ /** `./theme` style specifier from the output file to the theme file. */
114
+ function importSpecifier(fromFile: string, toFile: string): string {
115
+ const rel = path
116
+ .relative(path.dirname(fromFile), toFile)
117
+ .split(path.sep)
118
+ .join('/')
119
+ .replace(/\.[cm]?[jt]sx?$/, '');
120
+ return rel.startsWith('.') ? rel : `./${rel}`;
121
+ }
122
+
123
+ /** Writes only when the content differs; returns whether it wrote. */
124
+ function writeIfChanged(file: string, content: string): boolean {
125
+ const current = existsSync(file) ? readFileSync(file, 'utf8') : null;
126
+ if (current === content) return false;
127
+ try {
128
+ mkdirSync(path.dirname(file), { recursive: true });
129
+ writeFileSync(file, content);
130
+ } catch (error) {
131
+ throw new CodegenError(
132
+ 'write',
133
+ error instanceof Error ? error.message : String(error),
134
+ );
135
+ }
136
+ return true;
137
+ }
138
+
139
+ /**
140
+ * Load → validate → generate → write. Throws `CodegenError` tagged with the
141
+ * failing stage. Skips the write when the output is already identical, so
142
+ * Metro/tsc watchers don't churn.
143
+ */
144
+ export async function codegen(
145
+ options: CodegenOptions,
146
+ reporter: CodegenReporter = {},
147
+ ): Promise<CodegenResult> {
148
+ const cwd = options.cwd ?? process.cwd();
149
+ const themeFile = path.resolve(cwd, options.themeFile);
150
+ const outFile = path.resolve(
151
+ cwd,
152
+ options.outFile ?? path.join(path.dirname(themeFile), 'themed.gen.ts'),
153
+ );
154
+
155
+ // --- load -----------------------------------------------------------------
156
+ if (!existsSync(themeFile)) {
157
+ throw new CodegenError('load', `${options.themeFile} not found`);
158
+ }
159
+ let loaded: Awaited<ReturnType<typeof loadTheme>>;
160
+ try {
161
+ loaded = await loadTheme(themeFile, options.exportName);
162
+ } catch (error) {
163
+ throw new CodegenError(
164
+ 'load',
165
+ error instanceof Error ? error.message : String(error),
166
+ );
167
+ }
168
+ const { config, exportName } = loaded;
169
+ reporter.loaded?.({ themeFile, exportName });
170
+
171
+ // --- validate -------------------------------------------------------------
172
+ const problems = validateTheme(config);
173
+ if (problems.length > 0) {
174
+ throw new CodegenError('validate', 'Invalid theme config', problems);
175
+ }
176
+ const counts = countTokens(config);
177
+ reporter.validated?.(counts);
178
+ const warnings = themeWarnings(config);
179
+ if (warnings.length > 0) reporter.warned?.(warnings);
180
+
181
+ // --- generate & write -----------------------------------------------------
182
+ const toPosix = (file: string) =>
183
+ path.relative(cwd, file).split(path.sep).join('/');
184
+ const command = [
185
+ 'react-native-rethemed codegen',
186
+ toPosix(themeFile),
187
+ ...(options.outFile ? ['--out', toPosix(outFile)] : []),
188
+ ...(options.docsFile
189
+ ? ['--docs', toPosix(path.resolve(cwd, options.docsFile))]
190
+ : []),
191
+ ].join(' ');
192
+
193
+ const source = generate({
194
+ config,
195
+ themeImport: { specifier: importSpecifier(outFile, themeFile), exportName },
196
+ coreSpecifier: options.coreSpecifier,
197
+ command,
198
+ });
199
+ const changed = writeIfChanged(outFile, source);
200
+ reporter.written?.({ outFile, changed });
201
+
202
+ let docs: CodegenResult['docs'];
203
+ if (options.docsFile) {
204
+ const docsFile = path.resolve(cwd, options.docsFile);
205
+ const markdown = generateDocs({
206
+ config,
207
+ themeFile: toPosix(themeFile),
208
+ genFile: toPosix(outFile),
209
+ command,
210
+ });
211
+ docs = { file: docsFile, changed: writeIfChanged(docsFile, markdown) };
212
+ reporter.docsWritten?.({ docsFile, changed: docs.changed });
213
+ }
214
+
215
+ return { themeFile, exportName, outFile, counts, warnings, changed, docs };
216
+ }