@idealyst/theme 1.2.104 → 1.2.106

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idealyst/theme",
3
- "version": "1.2.104",
3
+ "version": "1.2.106",
4
4
  "description": "Theming system for Idealyst Framework",
5
5
  "readme": "README.md",
6
6
  "main": "src/index.ts",
@@ -63,7 +63,7 @@
63
63
  "publish:npm": "npm publish"
64
64
  },
65
65
  "dependencies": {
66
- "@idealyst/tooling": "^1.2.104"
66
+ "@idealyst/tooling": "^1.2.106"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "react-native-unistyles": ">=3.0.0"
@@ -29,6 +29,10 @@
29
29
  * }))
30
30
  */
31
31
 
32
+ const fs = require('fs');
33
+ const nodePath = require('path');
34
+ const crypto = require('crypto');
35
+
32
36
  // Theme analysis is provided by @idealyst/tooling (single source of truth)
33
37
  // This uses the TypeScript Compiler API for accurate theme extraction
34
38
  let loadThemeKeys;
@@ -48,6 +52,7 @@ try {
48
52
  // ============================================================================
49
53
 
50
54
  // Map of componentName -> { base: AST | null, extensions: AST[], overrides: AST | null }
55
+ // In-memory registry still used as fast path within a single worker
51
56
  const styleRegistry = {};
52
57
 
53
58
  function getOrCreateEntry(componentName) {
@@ -62,6 +67,212 @@ function getOrCreateEntry(componentName) {
62
67
  return styleRegistry[componentName];
63
68
  }
64
69
 
70
+ // ============================================================================
71
+ // File-Based Extension Cache - Cross-worker persistence
72
+ // ============================================================================
73
+ // When bundlers use multiple workers (Metro, Vite), each worker has its own
74
+ // copy of the in-memory styleRegistry. The file-based cache ensures extensions
75
+ // registered in any worker are visible to all workers processing defineStyle.
76
+
77
+ let _cacheDir = null;
78
+ let _cacheInitialized = false;
79
+ // Track hashes we've already written in this worker to avoid re-reading our own
80
+ const _writtenHashes = new Set();
81
+
82
+ /**
83
+ * Get or create the cache directory for style extensions.
84
+ * Uses node_modules/.cache/idealyst-styles/ by convention.
85
+ */
86
+ function getCacheDir(rootDir) {
87
+ if (_cacheDir) return _cacheDir;
88
+
89
+ // Walk up from rootDir to find the nearest node_modules
90
+ let dir = rootDir;
91
+ while (dir !== nodePath.dirname(dir)) {
92
+ const nmPath = nodePath.join(dir, 'node_modules');
93
+ if (fs.existsSync(nmPath)) {
94
+ _cacheDir = nodePath.join(nmPath, '.cache', 'idealyst-styles');
95
+ return _cacheDir;
96
+ }
97
+ dir = nodePath.dirname(dir);
98
+ }
99
+
100
+ // Fallback: use rootDir directly
101
+ _cacheDir = nodePath.join(rootDir, 'node_modules', '.cache', 'idealyst-styles');
102
+ return _cacheDir;
103
+ }
104
+
105
+ /**
106
+ * Initialize the cache directory. Clears stale data from previous builds.
107
+ * Uses a build marker (PID + timestamp) to detect new build processes.
108
+ */
109
+ function initCache(rootDir) {
110
+ if (_cacheInitialized) return;
111
+ _cacheInitialized = true;
112
+
113
+ const cacheDir = getCacheDir(rootDir);
114
+ const markerPath = nodePath.join(cacheDir, '.build-marker');
115
+
116
+ // Create cache dir if needed
117
+ fs.mkdirSync(cacheDir, { recursive: true });
118
+
119
+ // Check if this is a new build
120
+ const currentMarker = `${process.pid}`;
121
+ let existingMarker = null;
122
+ try {
123
+ existingMarker = fs.readFileSync(markerPath, 'utf8');
124
+ } catch (e) {
125
+ // No marker — fresh cache
126
+ }
127
+
128
+ if (existingMarker !== currentMarker) {
129
+ // New build process — clear old extensions
130
+ try {
131
+ const files = fs.readdirSync(cacheDir);
132
+ for (const file of files) {
133
+ if (file !== '.build-marker') {
134
+ fs.unlinkSync(nodePath.join(cacheDir, file));
135
+ }
136
+ }
137
+ } catch (e) {
138
+ // Ignore cleanup errors
139
+ }
140
+ fs.writeFileSync(markerPath, currentMarker);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Write an expanded extension callback to the cache.
146
+ * Uses @babel/generator to convert AST to source, then writes atomically.
147
+ *
148
+ * @param {string} rootDir - Project root directory
149
+ * @param {string} componentName - Component name (e.g., 'Text')
150
+ * @param {object} expandedCallback - Expanded AST node (ArrowFunctionExpression)
151
+ * @param {'extension'|'override'} type - Type of style modification
152
+ */
153
+ function writeExtensionToCache(rootDir, componentName, expandedCallback, type) {
154
+ const cacheDir = getCacheDir(rootDir);
155
+ initCache(rootDir);
156
+
157
+ // Generate source code from AST
158
+ let generate;
159
+ try {
160
+ generate = require('@babel/generator').default || require('@babel/generator');
161
+ } catch (e) {
162
+ // If generator not available, skip file caching (single-worker fallback)
163
+ return;
164
+ }
165
+
166
+ const source = generate(expandedCallback).code;
167
+ const hash = crypto.createHash('md5').update(source).digest('hex').substring(0, 12);
168
+
169
+ // Track this hash so we don't re-read our own extensions in this worker
170
+ _writtenHashes.add(`${componentName}-${type}-${hash}`);
171
+
172
+ const filename = `${componentName}-${type}-${hash}.js`;
173
+ const filepath = nodePath.join(cacheDir, filename);
174
+
175
+ // Atomic write: write to temp file, then rename
176
+ const tmpPath = filepath + '.tmp.' + process.pid;
177
+ try {
178
+ fs.writeFileSync(tmpPath, source, 'utf8');
179
+ fs.renameSync(tmpPath, filepath);
180
+ } catch (e) {
181
+ // Clean up temp file on error
182
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Read all cached extensions for a component.
188
+ * Returns parsed AST nodes ready for merging.
189
+ *
190
+ * @param {object} t - Babel types
191
+ * @param {string} rootDir - Project root directory
192
+ * @param {string} componentName - Component name
193
+ * @returns {{ extensions: object[], override: object|null }}
194
+ */
195
+ function readExtensionsFromCache(t, rootDir, componentName) {
196
+ const cacheDir = getCacheDir(rootDir);
197
+ const result = { extensions: [], override: null };
198
+
199
+ let files;
200
+ try {
201
+ files = fs.readdirSync(cacheDir);
202
+ } catch (e) {
203
+ return result; // Cache dir doesn't exist yet
204
+ }
205
+
206
+ let parse;
207
+ try {
208
+ parse = require('@babel/parser').parse;
209
+ } catch (e) {
210
+ return result; // Parser not available
211
+ }
212
+
213
+ // Find all extension/override files for this component
214
+ const prefix = `${componentName}-`;
215
+ const relevantFiles = files.filter(f => f.startsWith(prefix) && f.endsWith('.js'))
216
+ .sort(); // Sort for deterministic order
217
+
218
+ for (const file of relevantFiles) {
219
+ // Extract type and hash from filename: ComponentName-type-hash.js
220
+ const match = file.match(/^.+-(extension|override)-([a-f0-9]+)\.js$/);
221
+ if (!match) continue;
222
+
223
+ const type = match[1];
224
+ const hash = match[2];
225
+ const key = `${componentName}-${type}-${hash}`;
226
+
227
+ // Skip extensions we wrote in this worker — they're already in the in-memory registry
228
+ if (_writtenHashes.has(key)) continue;
229
+
230
+ try {
231
+ const source = fs.readFileSync(nodePath.join(cacheDir, file), 'utf8');
232
+
233
+ // Parse source back into AST
234
+ const ast = parse(source, {
235
+ sourceType: 'module',
236
+ plugins: ['typescript', 'jsx'],
237
+ });
238
+
239
+ // The parsed file contains a single expression statement (the arrow function)
240
+ // Extract it from the program body
241
+ if (ast.program.body.length > 0) {
242
+ const stmt = ast.program.body[0];
243
+ let expr = null;
244
+ if (t.isExpressionStatement(stmt)) {
245
+ expr = stmt.expression;
246
+ }
247
+ if (expr) {
248
+ if (type === 'override') {
249
+ result.override = expr;
250
+ } else {
251
+ result.extensions.push(expr);
252
+ }
253
+ }
254
+ }
255
+ } catch (e) {
256
+ // Skip unreadable/unparseable files
257
+ }
258
+ }
259
+
260
+ return result;
261
+ }
262
+
263
+ /**
264
+ * Reset the file-based cache. Used for testing.
265
+ */
266
+ function resetStyleCache() {
267
+ _cacheDir = null;
268
+ _cacheInitialized = false;
269
+ _writtenHashes.clear();
270
+ // Also clear in-memory registry
271
+ for (const key of Object.keys(styleRegistry)) {
272
+ delete styleRegistry[key];
273
+ }
274
+ }
275
+
65
276
  // ============================================================================
66
277
  // AST Deep Merge - Merges style object ASTs at build time
67
278
  // ============================================================================
@@ -116,9 +327,15 @@ function mergeObjectExpressions(t, target, source) {
116
327
 
117
328
  if (existingProp) {
118
329
  // Both have this property - need to merge or replace
119
- const existingValue = existingProp.value;
330
+ let existingValue = existingProp.value;
120
331
  const newValue = prop.value;
121
332
 
333
+ // Unwrap TSAsExpression (e.g., from "as const") to get at the inner value
334
+ const existingHasTSAs = t.isTSAsExpression(existingValue);
335
+ if (existingHasTSAs) {
336
+ existingValue = existingValue.expression;
337
+ }
338
+
122
339
  // If both are objects, deep merge
123
340
  if (t.isObjectExpression(existingValue) && t.isObjectExpression(newValue)) {
124
341
  const mergedValue = mergeObjectExpressions(t, existingValue, newValue);
@@ -805,7 +1022,7 @@ function buildMemberExpression(t, base, chain) {
805
1022
  // Babel Plugin
806
1023
  // ============================================================================
807
1024
 
808
- module.exports = function idealystStylesPlugin({ types: t }) {
1025
+ function idealystStylesPlugin({ types: t }) {
809
1026
  // Store babel types for use in extractThemeKeysFromAST
810
1027
  babelTypes = t;
811
1028
 
@@ -945,16 +1162,24 @@ module.exports = function idealystStylesPlugin({ types: t }) {
945
1162
  if (t.isIdentifier(node.callee, { name: 'extendStyle' })) {
946
1163
  debug(`FOUND extendStyle in: ${filename}`);
947
1164
 
948
- const [componentNameArg, stylesCallback] = node.arguments;
1165
+ const [componentNameArg, stylesArg] = node.arguments;
949
1166
 
950
1167
  if (!t.isStringLiteral(componentNameArg)) {
951
1168
  debug(` SKIP - componentName is not a string literal`);
952
1169
  return;
953
1170
  }
954
1171
 
955
- if (!t.isArrowFunctionExpression(stylesCallback) &&
956
- !t.isFunctionExpression(stylesCallback)) {
957
- debug(` SKIP - callback is not a function`);
1172
+ // Accept either a function callback or a plain object
1173
+ let stylesCallback = stylesArg;
1174
+ if (t.isObjectExpression(stylesArg)) {
1175
+ // Wrap plain object in an arrow function: (theme) => ({ ... })
1176
+ stylesCallback = t.arrowFunctionExpression(
1177
+ [t.identifier('theme')],
1178
+ t.parenthesizedExpression(stylesArg)
1179
+ );
1180
+ } else if (!t.isArrowFunctionExpression(stylesArg) &&
1181
+ !t.isFunctionExpression(stylesArg)) {
1182
+ debug(` SKIP - second argument is not a function or object`);
958
1183
  return;
959
1184
  }
960
1185
 
@@ -973,11 +1198,14 @@ module.exports = function idealystStylesPlugin({ types: t }) {
973
1198
  const expandedVariants = [];
974
1199
  const expandedCallback = expandIterators(t, stylesCallback, themeParam, keys, verbose, expandedVariants);
975
1200
 
976
- // Store in registry for later merging
1201
+ // Store in in-memory registry (fast path for same-worker)
977
1202
  const entry = getOrCreateEntry(componentName);
978
1203
  entry.extensions.push(expandedCallback);
979
1204
  entry.themeParam = themeParam;
980
1205
 
1206
+ // Write to file cache (cross-worker persistence)
1207
+ writeExtensionToCache(rootDir, componentName, expandedCallback, 'extension');
1208
+
981
1209
  debug(` -> Stored extension for '${componentName}' (${entry.extensions.length} total)`);
982
1210
 
983
1211
  // Remove the extendStyle call (it's been captured)
@@ -991,16 +1219,24 @@ module.exports = function idealystStylesPlugin({ types: t }) {
991
1219
  if (t.isIdentifier(node.callee, { name: 'overrideStyle' })) {
992
1220
  debug(`FOUND overrideStyle in: ${filename}`);
993
1221
 
994
- const [componentNameArg, stylesCallback] = node.arguments;
1222
+ const [componentNameArg, stylesArg] = node.arguments;
995
1223
 
996
1224
  if (!t.isStringLiteral(componentNameArg)) {
997
1225
  debug(` SKIP - componentName is not a string literal`);
998
1226
  return;
999
1227
  }
1000
1228
 
1001
- if (!t.isArrowFunctionExpression(stylesCallback) &&
1002
- !t.isFunctionExpression(stylesCallback)) {
1003
- debug(` SKIP - callback is not a function`);
1229
+ // Accept either a function callback or a plain object
1230
+ let stylesCallback = stylesArg;
1231
+ if (t.isObjectExpression(stylesArg)) {
1232
+ // Wrap plain object in an arrow function: (theme) => ({ ... })
1233
+ stylesCallback = t.arrowFunctionExpression(
1234
+ [t.identifier('theme')],
1235
+ t.parenthesizedExpression(stylesArg)
1236
+ );
1237
+ } else if (!t.isArrowFunctionExpression(stylesArg) &&
1238
+ !t.isFunctionExpression(stylesArg)) {
1239
+ debug(` SKIP - second argument is not a function or object`);
1004
1240
  return;
1005
1241
  }
1006
1242
 
@@ -1017,11 +1253,14 @@ module.exports = function idealystStylesPlugin({ types: t }) {
1017
1253
  const expandedVariants = [];
1018
1254
  const expandedCallback = expandIterators(t, stylesCallback, themeParam, keys, verbose, expandedVariants);
1019
1255
 
1020
- // Store as override (replaces base entirely)
1256
+ // Store as override in in-memory registry (fast path for same-worker)
1021
1257
  const entry = getOrCreateEntry(componentName);
1022
1258
  entry.override = expandedCallback;
1023
1259
  entry.themeParam = themeParam;
1024
1260
 
1261
+ // Write to file cache (cross-worker persistence)
1262
+ writeExtensionToCache(rootDir, componentName, expandedCallback, 'override');
1263
+
1025
1264
  debug(` -> Stored override for '${componentName}'`);
1026
1265
 
1027
1266
  // Remove the overrideStyle call
@@ -1078,16 +1317,23 @@ module.exports = function idealystStylesPlugin({ types: t }) {
1078
1317
  let expandedCallback = expandIterators(t, stylesCallback, themeParam, keys, verbose, expandedVariants);
1079
1318
 
1080
1319
  // Check for registered override or extensions
1320
+ // Combine in-memory registry (same worker) with file cache (cross-worker)
1081
1321
  const entry = getOrCreateEntry(componentName);
1322
+ const cached = readExtensionsFromCache(t, rootDir, componentName);
1323
+
1324
+ // Determine override: in-memory takes precedence, then file cache
1325
+ const override = entry.override || cached.override;
1326
+ // Combine extensions: in-memory first, then file cache (already deduplicated)
1327
+ const allExtensions = [...entry.extensions, ...cached.extensions];
1082
1328
 
1083
- if (entry.override) {
1329
+ if (override) {
1084
1330
  // Override completely replaces base
1085
1331
  debug(` -> Using override for '${componentName}'`);
1086
- expandedCallback = entry.override;
1087
- } else if (entry.extensions.length > 0) {
1332
+ expandedCallback = override;
1333
+ } else if (allExtensions.length > 0) {
1088
1334
  // Merge extensions into base
1089
- debug(` -> Merging ${entry.extensions.length} extensions for '${componentName}'`);
1090
- for (const ext of entry.extensions) {
1335
+ debug(` -> Merging ${allExtensions.length} extensions for '${componentName}' (${entry.extensions.length} in-memory, ${cached.extensions.length} from cache)`);
1336
+ for (const ext of allExtensions) {
1091
1337
  expandedCallback = mergeCallbackBodies(t, expandedCallback, ext);
1092
1338
  }
1093
1339
  }
@@ -1161,4 +1407,8 @@ module.exports = function idealystStylesPlugin({ types: t }) {
1161
1407
  },
1162
1408
  },
1163
1409
  };
1164
- };
1410
+ }
1411
+
1412
+ // Export plugin as default (Babel convention) with test utilities
1413
+ module.exports = idealystStylesPlugin;
1414
+ module.exports.resetStyleCache = resetStyleCache;
@@ -102,18 +102,27 @@ export function defineStyle<TTheme, TStyles extends Record<string, unknown>>(
102
102
  /**
103
103
  * Extend existing component styles (merged at build time).
104
104
  * Import BEFORE components for extensions to apply.
105
+ *
106
+ * Accepts either a plain style object or a theme callback:
107
+ * ```typescript
108
+ * // Plain object (no theme access needed)
109
+ * extendStyle('Text', { text: { fontFamily: 'MyFont' } });
110
+ *
111
+ * // Theme callback (when you need theme tokens)
112
+ * extendStyle('Text', (theme) => ({ text: { color: theme.colors.text.primary } }));
113
+ * ```
105
114
  */
106
115
  export function extendStyle<K extends keyof ComponentStyleRegistry>(
107
116
  componentName: K,
108
- styles: (theme: any) => ExtendStyleDef<K>
117
+ styles: ExtendStyleDef<K> | ((theme: any) => ExtendStyleDef<K>)
109
118
  ): void;
110
119
  export function extendStyle<K extends string>(
111
120
  componentName: K,
112
- styles: (theme: any) => Record<string, any>
121
+ styles: Record<string, any> | ((theme: any) => Record<string, any>)
113
122
  ): void;
114
123
  export function extendStyle(
115
124
  _componentName: string,
116
- _styles: (theme: any) => any
125
+ _styles: any
117
126
  ): void {
118
127
  // Babel removes this call and merges into defineStyle
119
128
  }
@@ -121,18 +130,27 @@ export function extendStyle(
121
130
  /**
122
131
  * Override component styles completely (replaces base at build time).
123
132
  * Import BEFORE components for overrides to apply.
133
+ *
134
+ * Accepts either a plain style object or a theme callback:
135
+ * ```typescript
136
+ * // Plain object
137
+ * overrideStyle('Text', { text: { fontFamily: 'MyFont' } });
138
+ *
139
+ * // Theme callback
140
+ * overrideStyle('Text', (theme) => ({ text: { color: theme.colors.text.primary } }));
141
+ * ```
124
142
  */
125
143
  export function overrideStyle<K extends keyof ComponentStyleRegistry>(
126
144
  componentName: K,
127
- styles: (theme: any) => OverrideStyleDef<K>
145
+ styles: OverrideStyleDef<K> | ((theme: any) => OverrideStyleDef<K>)
128
146
  ): void;
129
147
  export function overrideStyle<K extends string>(
130
148
  componentName: K,
131
- styles: (theme: any) => Record<string, any>
149
+ styles: Record<string, any> | ((theme: any) => Record<string, any>)
132
150
  ): void;
133
151
  export function overrideStyle(
134
152
  _componentName: string,
135
- _styles: (theme: any) => any
153
+ _styles: any
136
154
  ): void {
137
155
  // Babel removes this call and replaces defineStyle
138
156
  }