@idealyst/theme 1.2.105 → 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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/babel/plugin.js +244 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idealyst/theme",
3
- "version": "1.2.105",
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.105"
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
 
@@ -981,11 +1198,14 @@ module.exports = function idealystStylesPlugin({ types: t }) {
981
1198
  const expandedVariants = [];
982
1199
  const expandedCallback = expandIterators(t, stylesCallback, themeParam, keys, verbose, expandedVariants);
983
1200
 
984
- // Store in registry for later merging
1201
+ // Store in in-memory registry (fast path for same-worker)
985
1202
  const entry = getOrCreateEntry(componentName);
986
1203
  entry.extensions.push(expandedCallback);
987
1204
  entry.themeParam = themeParam;
988
1205
 
1206
+ // Write to file cache (cross-worker persistence)
1207
+ writeExtensionToCache(rootDir, componentName, expandedCallback, 'extension');
1208
+
989
1209
  debug(` -> Stored extension for '${componentName}' (${entry.extensions.length} total)`);
990
1210
 
991
1211
  // Remove the extendStyle call (it's been captured)
@@ -1033,11 +1253,14 @@ module.exports = function idealystStylesPlugin({ types: t }) {
1033
1253
  const expandedVariants = [];
1034
1254
  const expandedCallback = expandIterators(t, stylesCallback, themeParam, keys, verbose, expandedVariants);
1035
1255
 
1036
- // Store as override (replaces base entirely)
1256
+ // Store as override in in-memory registry (fast path for same-worker)
1037
1257
  const entry = getOrCreateEntry(componentName);
1038
1258
  entry.override = expandedCallback;
1039
1259
  entry.themeParam = themeParam;
1040
1260
 
1261
+ // Write to file cache (cross-worker persistence)
1262
+ writeExtensionToCache(rootDir, componentName, expandedCallback, 'override');
1263
+
1041
1264
  debug(` -> Stored override for '${componentName}'`);
1042
1265
 
1043
1266
  // Remove the overrideStyle call
@@ -1094,16 +1317,23 @@ module.exports = function idealystStylesPlugin({ types: t }) {
1094
1317
  let expandedCallback = expandIterators(t, stylesCallback, themeParam, keys, verbose, expandedVariants);
1095
1318
 
1096
1319
  // Check for registered override or extensions
1320
+ // Combine in-memory registry (same worker) with file cache (cross-worker)
1097
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];
1098
1328
 
1099
- if (entry.override) {
1329
+ if (override) {
1100
1330
  // Override completely replaces base
1101
1331
  debug(` -> Using override for '${componentName}'`);
1102
- expandedCallback = entry.override;
1103
- } else if (entry.extensions.length > 0) {
1332
+ expandedCallback = override;
1333
+ } else if (allExtensions.length > 0) {
1104
1334
  // Merge extensions into base
1105
- debug(` -> Merging ${entry.extensions.length} extensions for '${componentName}'`);
1106
- 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) {
1107
1337
  expandedCallback = mergeCallbackBodies(t, expandedCallback, ext);
1108
1338
  }
1109
1339
  }
@@ -1177,4 +1407,8 @@ module.exports = function idealystStylesPlugin({ types: t }) {
1177
1407
  },
1178
1408
  },
1179
1409
  };
1180
- };
1410
+ }
1411
+
1412
+ // Export plugin as default (Babel convention) with test utilities
1413
+ module.exports = idealystStylesPlugin;
1414
+ module.exports.resetStyleCache = resetStyleCache;