@idealyst/theme 1.1.7 → 1.1.9

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,187 @@
1
+ /**
2
+ * Babel plugin that transforms applyExtensions calls into Unistyles-compatible code.
3
+ *
4
+ * This plugin runs BEFORE Unistyles' Babel plugin to transform:
5
+ *
6
+ * ```typescript
7
+ * // INPUT:
8
+ * StyleSheet.create((theme) => {
9
+ * return applyExtensions('View', theme, {
10
+ * view: createViewStyles(theme),
11
+ * });
12
+ * });
13
+ *
14
+ * // OUTPUT:
15
+ * StyleSheet.create((theme) => ({
16
+ * view: __withExtension('View', 'view', theme, createViewStyles(theme)),
17
+ * }));
18
+ * ```
19
+ *
20
+ * This transformation allows Unistyles to:
21
+ * 1. See an ObjectExpression return (required for analysis)
22
+ * 2. Track theme dependencies through the __withExtension call
23
+ * 3. Update styles reactively when theme changes
24
+ */
25
+
26
+ import type { PluginObj, NodePath, types as BabelTypes } from '@babel/core';
27
+
28
+ interface PluginState {
29
+ file: {
30
+ path: NodePath;
31
+ };
32
+ needsImport: boolean;
33
+ importAdded: boolean;
34
+ }
35
+
36
+ export default function idealystExtensionsPlugin(
37
+ { types: t }: { types: typeof BabelTypes }
38
+ ): PluginObj<PluginState> {
39
+ return {
40
+ name: 'idealyst-extensions',
41
+
42
+ pre() {
43
+ this.needsImport = false;
44
+ this.importAdded = false;
45
+ },
46
+
47
+ visitor: {
48
+ // Transform applyExtensions calls
49
+ CallExpression(path, state) {
50
+ const { node } = path;
51
+
52
+ // Check if this is applyExtensions(...)
53
+ if (!t.isIdentifier(node.callee, { name: 'applyExtensions' })) {
54
+ return;
55
+ }
56
+
57
+ // Get arguments: applyExtensions(componentName, theme, styleCreators)
58
+ const [componentNameNode, themeNode, styleCreatorsNode] = node.arguments;
59
+
60
+ // Validate argument types
61
+ if (!t.isStringLiteral(componentNameNode)) {
62
+ return;
63
+ }
64
+
65
+ if (!t.isIdentifier(themeNode)) {
66
+ return;
67
+ }
68
+
69
+ if (!t.isObjectExpression(styleCreatorsNode)) {
70
+ return;
71
+ }
72
+
73
+ const componentName = componentNameNode.value;
74
+ const themeName = themeNode.name;
75
+
76
+ // Transform each property in styleCreators
77
+ const transformedProperties = styleCreatorsNode.properties.map((prop) => {
78
+ // Skip spread elements and methods
79
+ if (!t.isObjectProperty(prop)) {
80
+ return prop;
81
+ }
82
+
83
+ // Get the key name
84
+ let elementName: string;
85
+ if (t.isIdentifier(prop.key)) {
86
+ elementName = prop.key.name;
87
+ } else if (t.isStringLiteral(prop.key)) {
88
+ elementName = prop.key.value;
89
+ } else {
90
+ return prop; // Can't handle computed keys
91
+ }
92
+
93
+ // Wrap value: __withExtension('Component', 'element', theme, originalValue)
94
+ const wrappedValue = t.callExpression(
95
+ t.identifier('__withExtension'),
96
+ [
97
+ t.stringLiteral(componentName),
98
+ t.stringLiteral(elementName),
99
+ t.identifier(themeName),
100
+ prop.value as BabelTypes.Expression,
101
+ ]
102
+ );
103
+
104
+ return t.objectProperty(prop.key, wrappedValue);
105
+ });
106
+
107
+ // Replace applyExtensions(...) with the transformed object
108
+ path.replaceWith(t.objectExpression(transformedProperties));
109
+
110
+ // Mark that we need to add the import
111
+ state.needsImport = true;
112
+ },
113
+
114
+ // Add import at the end of the program
115
+ Program: {
116
+ exit(path, state) {
117
+ if (!state.needsImport || state.importAdded) {
118
+ return;
119
+ }
120
+
121
+ // Check if import already exists
122
+ const hasImport = path.node.body.some((node) => {
123
+ if (!t.isImportDeclaration(node)) return false;
124
+ return (
125
+ node.source.value === '@idealyst/theme/extensions' ||
126
+ node.source.value === '@idealyst/theme'
127
+ );
128
+ });
129
+
130
+ if (hasImport) {
131
+ // Check if __withExtension is already imported
132
+ const existingImport = path.node.body.find((node) => {
133
+ if (!t.isImportDeclaration(node)) return false;
134
+ return node.source.value === '@idealyst/theme/extensions';
135
+ });
136
+
137
+ if (existingImport && t.isImportDeclaration(existingImport)) {
138
+ const hasWithExtension = existingImport.specifiers.some(
139
+ (spec) =>
140
+ t.isImportSpecifier(spec) &&
141
+ t.isIdentifier(spec.imported, { name: '__withExtension' })
142
+ );
143
+
144
+ if (!hasWithExtension) {
145
+ existingImport.specifiers.push(
146
+ t.importSpecifier(
147
+ t.identifier('__withExtension'),
148
+ t.identifier('__withExtension')
149
+ )
150
+ );
151
+ }
152
+ }
153
+ state.importAdded = true;
154
+ return;
155
+ }
156
+
157
+ // Add new import
158
+ const importDecl = t.importDeclaration(
159
+ [
160
+ t.importSpecifier(
161
+ t.identifier('__withExtension'),
162
+ t.identifier('__withExtension')
163
+ ),
164
+ ],
165
+ t.stringLiteral('@idealyst/theme/extensions')
166
+ );
167
+
168
+ // Insert after other imports
169
+ let insertIndex = 0;
170
+ for (let i = 0; i < path.node.body.length; i++) {
171
+ if (t.isImportDeclaration(path.node.body[i])) {
172
+ insertIndex = i + 1;
173
+ } else {
174
+ break;
175
+ }
176
+ }
177
+
178
+ path.node.body.splice(insertIndex, 0, importDecl);
179
+ state.importAdded = true;
180
+ },
181
+ },
182
+ },
183
+ };
184
+ }
185
+
186
+ // Also export as module.exports for CommonJS compatibility
187
+ module.exports = idealystExtensionsPlugin;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Runtime helper for the idealyst-extensions Babel plugin.
3
+ *
4
+ * This function wraps style creator functions to merge extensions from the theme.
5
+ * It's called by code transformed by the Babel plugin - DO NOT call directly.
6
+ *
7
+ * @example
8
+ * // Babel transforms this:
9
+ * applyExtensions('Button', theme, { button: createButtonStyles(theme) })
10
+ *
11
+ * // Into this:
12
+ * { button: __withExtension('Button', 'button', theme, createButtonStyles(theme)) }
13
+ */
14
+
15
+ /**
16
+ * Deep merge two objects, with source values taking priority.
17
+ * Handles nested objects recursively.
18
+ */
19
+ function deepMerge<T extends object>(target: T, source: Partial<T>): T {
20
+ const result = { ...target } as T;
21
+
22
+ for (const key in source) {
23
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
24
+ const sourceValue = source[key];
25
+ const targetValue = (target as any)[key];
26
+
27
+ if (
28
+ sourceValue !== null &&
29
+ typeof sourceValue === 'object' &&
30
+ !Array.isArray(sourceValue) &&
31
+ targetValue !== null &&
32
+ typeof targetValue === 'object' &&
33
+ !Array.isArray(targetValue)
34
+ ) {
35
+ (result as any)[key] = deepMerge(targetValue, sourceValue);
36
+ } else if (sourceValue !== undefined) {
37
+ (result as any)[key] = sourceValue;
38
+ }
39
+ }
40
+ }
41
+
42
+ return result;
43
+ }
44
+
45
+ /**
46
+ * Theme type with extensions support.
47
+ */
48
+ interface ThemeWithExtensions {
49
+ __extensions?: Record<string, Record<string, any>>;
50
+ [key: string]: any;
51
+ }
52
+
53
+ /**
54
+ * Wrap a style function with extension support.
55
+ *
56
+ * @param component - Component name (e.g., 'View', 'Button')
57
+ * @param element - Style element name (e.g., 'view', 'button', 'text')
58
+ * @param theme - Theme object (may contain __extensions)
59
+ * @param baseStyleFn - Base style creator function
60
+ * @returns Wrapped function that merges extensions
61
+ */
62
+ export function __withExtension<TProps, TResult>(
63
+ component: string,
64
+ element: string,
65
+ theme: ThemeWithExtensions,
66
+ baseStyleFn: ((props: TProps) => TResult) | TResult
67
+ ): ((props: TProps) => TResult) | TResult {
68
+ const ext = theme.__extensions?.[component]?.[element];
69
+
70
+ // If no extension, return base as-is
71
+ if (!ext) {
72
+ return baseStyleFn;
73
+ }
74
+
75
+ // If baseStyleFn is not a function (static style object), merge directly
76
+ if (typeof baseStyleFn !== 'function') {
77
+ return deepMerge(baseStyleFn as object, ext) as TResult;
78
+ }
79
+
80
+ // Cast to function type after the typeof check
81
+ const styleFn = baseStyleFn as (props: TProps) => TResult;
82
+
83
+ // Return wrapped function that merges extension at call time
84
+ return ((props: TProps): TResult => {
85
+ const base = styleFn(props);
86
+
87
+ // If base is not an object, can't merge
88
+ if (typeof base !== 'object' || base === null) {
89
+ return base;
90
+ }
91
+
92
+ return deepMerge(base as object, ext) as TResult;
93
+ }) as (props: TProps) => TResult;
94
+ }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Theme Analyzer - Extracts theme keys by statically analyzing theme files.
3
+ *
4
+ * Traces the declarative builder API:
5
+ * - createTheme() / fromTheme(base)
6
+ * - .addIntent('name', {...})
7
+ * - .addRadius('name', value)
8
+ * - .addShadow('name', {...})
9
+ * - .setSizes({ button: { xs: {}, sm: {}, ... }, ... })
10
+ * - .build()
11
+ */
12
+
13
+ const nodePath = require('path');
14
+ const fs = require('fs');
15
+
16
+ let themeKeys = null;
17
+ let themeLoadAttempted = false;
18
+
19
+ /**
20
+ * Extract theme keys by statically analyzing the theme file's AST.
21
+ */
22
+ function extractThemeKeysFromAST(themeFilePath, babelTypes, verboseMode) {
23
+ const { parseSync } = require('@babel/core');
24
+ const traverse = require('@babel/traverse').default;
25
+ const t = babelTypes;
26
+
27
+ const log = (...args) => {
28
+ if (verboseMode) console.log('[idealyst-plugin]', ...args);
29
+ };
30
+
31
+ const keys = {
32
+ intents: [],
33
+ sizes: {},
34
+ radii: [],
35
+ shadows: [],
36
+ };
37
+
38
+ log('Reading theme file:', themeFilePath);
39
+
40
+ // Read and parse the theme file
41
+ const code = fs.readFileSync(themeFilePath, 'utf-8');
42
+ const ast = parseSync(code, {
43
+ filename: themeFilePath,
44
+ presets: [
45
+ ['@babel/preset-typescript', { isTSX: true, allExtensions: true }]
46
+ ],
47
+ parserOpts: {
48
+ plugins: ['typescript', 'jsx']
49
+ }
50
+ });
51
+
52
+ if (!ast) {
53
+ throw new Error(`[idealyst-plugin] Failed to parse theme file: ${themeFilePath}`);
54
+ }
55
+
56
+ // Track imports to resolve base themes
57
+ const imports = new Map(); // varName -> { source, imported }
58
+
59
+ // First pass: collect imports
60
+ traverse(ast, {
61
+ ImportDeclaration(path) {
62
+ const source = path.node.source.value;
63
+ for (const spec of path.node.specifiers) {
64
+ if (t.isImportSpecifier(spec)) {
65
+ const localName = spec.local.name;
66
+ const importedName = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
67
+ imports.set(localName, { source, imported: importedName });
68
+ }
69
+ }
70
+ }
71
+ });
72
+
73
+ /**
74
+ * Recursively trace a builder chain to extract all method calls.
75
+ * Returns { calls: Array, baseThemeVar: string | null }
76
+ */
77
+ function traceBuilderChain(node, calls = []) {
78
+ if (!node) return { calls, baseThemeVar: null };
79
+
80
+ if (t.isCallExpression(node)) {
81
+ if (t.isIdentifier(node.callee, { name: 'createTheme' })) {
82
+ return { calls, baseThemeVar: null };
83
+ }
84
+ if (t.isIdentifier(node.callee, { name: 'fromTheme' })) {
85
+ const arg = node.arguments[0];
86
+ if (t.isIdentifier(arg)) {
87
+ return { calls, baseThemeVar: arg.name };
88
+ }
89
+ return { calls, baseThemeVar: null };
90
+ }
91
+
92
+ if (t.isMemberExpression(node.callee)) {
93
+ const methodName = node.callee.property.name;
94
+ calls.unshift({ method: methodName, args: node.arguments });
95
+ return traceBuilderChain(node.callee.object, calls);
96
+ }
97
+ }
98
+
99
+ return { calls, baseThemeVar: null };
100
+ }
101
+
102
+ /**
103
+ * Resolve and analyze a base theme from an import.
104
+ */
105
+ function analyzeBaseTheme(varName) {
106
+ const importInfo = imports.get(varName);
107
+ if (!importInfo) {
108
+ log('Could not find import for base theme:', varName);
109
+ return;
110
+ }
111
+
112
+ log('Base theme', varName, 'imported from', importInfo.source);
113
+
114
+ let baseThemePath;
115
+ try {
116
+ if (importInfo.source.startsWith('.')) {
117
+ baseThemePath = nodePath.resolve(nodePath.dirname(themeFilePath), importInfo.source);
118
+ if (!baseThemePath.endsWith('.ts') && !baseThemePath.endsWith('.tsx')) {
119
+ if (fs.existsSync(baseThemePath + '.ts')) baseThemePath += '.ts';
120
+ else if (fs.existsSync(baseThemePath + '.tsx')) baseThemePath += '.tsx';
121
+ }
122
+ } else {
123
+ const packageDir = nodePath.dirname(themeFilePath);
124
+
125
+ // Determine which theme file to look for based on variable name
126
+ const themeFileName = varName.includes('dark') ? 'darkTheme.ts' : 'lightTheme.ts';
127
+ let possiblePaths = [];
128
+
129
+ if (importInfo.source === '@idealyst/theme') {
130
+ possiblePaths = [
131
+ // Symlinked packages at root level
132
+ `/idealyst-packages/theme/src/${themeFileName}`,
133
+ // Standard node_modules
134
+ nodePath.resolve(packageDir, `node_modules/@idealyst/theme/src/${themeFileName}`),
135
+ // Monorepo structure - walk up to find packages dir
136
+ nodePath.resolve(packageDir, `../theme/src/${themeFileName}`),
137
+ nodePath.resolve(packageDir, `../../theme/src/${themeFileName}`),
138
+ nodePath.resolve(packageDir, `../../../theme/src/${themeFileName}`),
139
+ nodePath.resolve(packageDir, `../../packages/theme/src/${themeFileName}`),
140
+ nodePath.resolve(packageDir, `../../../packages/theme/src/${themeFileName}`),
141
+ // This plugin's own package location
142
+ nodePath.resolve(__dirname, `../${themeFileName}`),
143
+ ];
144
+
145
+ log('Looking for base theme in:', possiblePaths);
146
+
147
+ for (const p of possiblePaths) {
148
+ if (fs.existsSync(p)) {
149
+ baseThemePath = p;
150
+ log('Found base theme at:', p);
151
+ break;
152
+ }
153
+ }
154
+ }
155
+
156
+ if (!baseThemePath) {
157
+ log('Could not resolve base theme path for:', importInfo.source);
158
+ if (possiblePaths.length > 0) {
159
+ log('Searched paths:', possiblePaths);
160
+ }
161
+ return;
162
+ }
163
+ }
164
+
165
+ log('Analyzing base theme file:', baseThemePath);
166
+
167
+ const baseKeys = extractThemeKeysFromAST(baseThemePath, babelTypes, verboseMode);
168
+
169
+ // Merge base keys
170
+ keys.intents.push(...baseKeys.intents.filter(k => !keys.intents.includes(k)));
171
+ keys.radii.push(...baseKeys.radii.filter(k => !keys.radii.includes(k)));
172
+ keys.shadows.push(...baseKeys.shadows.filter(k => !keys.shadows.includes(k)));
173
+ for (const [comp, sizes] of Object.entries(baseKeys.sizes)) {
174
+ if (!keys.sizes[comp]) {
175
+ keys.sizes[comp] = sizes;
176
+ }
177
+ }
178
+
179
+ log('Merged base theme keys');
180
+ } catch (err) {
181
+ log('Error analyzing base theme:', err.message);
182
+ }
183
+ }
184
+
185
+ function getStringValue(node) {
186
+ if (t.isStringLiteral(node)) return node.value;
187
+ if (t.isIdentifier(node)) return node.name;
188
+ return null;
189
+ }
190
+
191
+ function getObjectKeys(node) {
192
+ if (!t.isObjectExpression(node)) return [];
193
+ return node.properties
194
+ .filter(prop => t.isObjectProperty(prop))
195
+ .map(prop => {
196
+ if (t.isIdentifier(prop.key)) return prop.key.name;
197
+ if (t.isStringLiteral(prop.key)) return prop.key.value;
198
+ return null;
199
+ })
200
+ .filter(Boolean);
201
+ }
202
+
203
+ function processBuilderCalls(calls) {
204
+ log('Processing builder chain with', calls.length, 'method calls');
205
+
206
+ for (const { method, args } of calls) {
207
+ switch (method) {
208
+ case 'addIntent': {
209
+ const name = getStringValue(args[0]);
210
+ if (name && !keys.intents.includes(name)) {
211
+ keys.intents.push(name);
212
+ log(' Found intent:', name);
213
+ }
214
+ break;
215
+ }
216
+ case 'addRadius': {
217
+ const name = getStringValue(args[0]);
218
+ if (name && !keys.radii.includes(name)) {
219
+ keys.radii.push(name);
220
+ log(' Found radius:', name);
221
+ }
222
+ break;
223
+ }
224
+ case 'addShadow': {
225
+ const name = getStringValue(args[0]);
226
+ if (name && !keys.shadows.includes(name)) {
227
+ keys.shadows.push(name);
228
+ log(' Found shadow:', name);
229
+ }
230
+ break;
231
+ }
232
+ case 'setSizes': {
233
+ const sizesObj = args[0];
234
+ if (t.isObjectExpression(sizesObj)) {
235
+ for (const prop of sizesObj.properties) {
236
+ if (!t.isObjectProperty(prop)) continue;
237
+ const componentName = t.isIdentifier(prop.key) ? prop.key.name :
238
+ t.isStringLiteral(prop.key) ? prop.key.value : null;
239
+ if (componentName && t.isObjectExpression(prop.value)) {
240
+ keys.sizes[componentName] = getObjectKeys(prop.value);
241
+ log(' Found sizes for', componentName + ':', keys.sizes[componentName]);
242
+ }
243
+ }
244
+ }
245
+ break;
246
+ }
247
+ case 'build':
248
+ break;
249
+ default:
250
+ log(' Skipping unknown method:', method);
251
+ }
252
+ }
253
+ }
254
+
255
+ // Second pass: find theme builder chains
256
+ traverse(ast, {
257
+ VariableDeclarator(path) {
258
+ const init = path.node.init;
259
+ if (!init) return;
260
+
261
+ if (t.isCallExpression(init) &&
262
+ t.isMemberExpression(init.callee) &&
263
+ t.isIdentifier(init.callee.property, { name: 'build' })) {
264
+
265
+ const { calls, baseThemeVar } = traceBuilderChain(init);
266
+
267
+ if (baseThemeVar) {
268
+ log('Found fromTheme with base:', baseThemeVar);
269
+ analyzeBaseTheme(baseThemeVar);
270
+ }
271
+
272
+ processBuilderCalls(calls);
273
+ }
274
+ },
275
+
276
+ ExportNamedDeclaration(path) {
277
+ if (!path.node.declaration) return;
278
+ if (!t.isVariableDeclaration(path.node.declaration)) return;
279
+
280
+ for (const decl of path.node.declaration.declarations) {
281
+ const init = decl.init;
282
+ if (!init) continue;
283
+
284
+ if (t.isCallExpression(init) &&
285
+ t.isMemberExpression(init.callee) &&
286
+ t.isIdentifier(init.callee.property, { name: 'build' })) {
287
+
288
+ const { calls, baseThemeVar } = traceBuilderChain(init);
289
+
290
+ if (baseThemeVar) {
291
+ log('Found fromTheme with base:', baseThemeVar);
292
+ analyzeBaseTheme(baseThemeVar);
293
+ }
294
+
295
+ processBuilderCalls(calls);
296
+ }
297
+ }
298
+ }
299
+ });
300
+
301
+ return keys;
302
+ }
303
+
304
+ /**
305
+ * Load theme keys by statically analyzing the theme file.
306
+ *
307
+ * REQUIRED Options:
308
+ * - themePath: Path to the consumer's theme file (e.g., './src/theme/styles.ts')
309
+ */
310
+ function loadThemeKeys(opts, rootDir, babelTypes, verboseMode) {
311
+ if (themeLoadAttempted) return themeKeys;
312
+ themeLoadAttempted = true;
313
+
314
+ const themePath = opts.themePath;
315
+
316
+ if (!themePath) {
317
+ throw new Error(
318
+ '[idealyst-plugin] themePath is required!\n' +
319
+ 'Add it to your babel config:\n' +
320
+ ' ["@idealyst/theme/plugin", { themePath: "./src/theme/styles.ts" }]'
321
+ );
322
+ }
323
+
324
+ const resolvedPath = themePath.startsWith('.')
325
+ ? nodePath.resolve(rootDir, themePath)
326
+ : require.resolve(themePath, { paths: [rootDir] });
327
+
328
+ console.log('[idealyst-plugin] Analyzing theme file:', resolvedPath);
329
+
330
+ themeKeys = extractThemeKeysFromAST(resolvedPath, babelTypes, verboseMode);
331
+
332
+ // Always log the extracted keys
333
+ console.log('[idealyst-plugin] Extracted theme keys:');
334
+ console.log(' intents:', themeKeys.intents);
335
+ console.log(' radii:', themeKeys.radii);
336
+ console.log(' shadows:', themeKeys.shadows);
337
+ console.log(' sizes:');
338
+ for (const [component, sizes] of Object.entries(themeKeys.sizes)) {
339
+ console.log(` ${component}:`, sizes);
340
+ }
341
+
342
+ return themeKeys;
343
+ }
344
+
345
+ /**
346
+ * Reset the theme cache (useful for testing or hot reload).
347
+ */
348
+ function resetThemeCache() {
349
+ themeKeys = null;
350
+ themeLoadAttempted = false;
351
+ }
352
+
353
+ module.exports = {
354
+ extractThemeKeysFromAST,
355
+ loadThemeKeys,
356
+ resetThemeCache,
357
+ };