@kbach/ui 0.1.0-beta.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.
@@ -0,0 +1,605 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+
6
+ // ─── Terminal color helper ──────────────────────────────────────────────────────
7
+ // Plain ANSI escapes — no-ops when stdout isn't a color-capable TTY (CI logs,
8
+ // redirected output) or NO_COLOR is set.
9
+ const _useColor = !!(process.stdout && process.stdout.isTTY) && !process.env.NO_COLOR;
10
+ function _paint(code, s) { return _useColor ? `\x1b[${code}m${s}\x1b[0m` : s; }
11
+ function log(message) {
12
+ console.log(`${_paint('1', _paint('35', '[kbach]'))} ${message}`);
13
+ }
14
+ function warn(message) {
15
+ console.warn(`${_paint('1', _paint('35', '[kbach]'))} ${_paint('33', message)}`);
16
+ }
17
+
18
+ // ─── Cross-file resolve cache ─────────────────────────────────────────────────
19
+ // Keyed by (config file absolute path + classString) so that two projects/apps
20
+ // transformed by the same long-lived Babel/Metro worker process (a shared worker
21
+ // pool in a monorepo, or a multi-project Jest run) never share resolved styles
22
+ // for an identical class name that maps to a different theme in each project.
23
+ const _resolveCache = new Map();
24
+
25
+ // ─── Shared mtime-poll mechanics ───────────────────────────────────────────────
26
+ // getCore() and getUserConfig() below both cache a value keyed off a file's
27
+ // mtime, re-checking that mtime at most once every N ms rather than on every
28
+ // call (fs.statSync on every JSXAttribute visited would be wasteful). The
29
+ // fallback/warning behavior differs enough between the two (getCore has one
30
+ // global slot and propagates when nothing is cached yet; getUserConfig is
31
+ // keyed per config path and always has a default-config fallback) that
32
+ // sharing more than this small freshness/stat mechanism would obscure more
33
+ // than it simplifies — so only these two pure helpers are shared.
34
+ function isFresh(lastStatMs, pollIntervalMs) {
35
+ return (Date.now() - lastStatMs) < pollIntervalMs;
36
+ }
37
+
38
+ // Missing/unreadable file reads as mtime 0 — never equal to a real mtime, so
39
+ // callers correctly treat that as "changed" and attempt a fresh load.
40
+ function safeStatMtime(filePath) {
41
+ try {
42
+ return fs.statSync(filePath).mtimeMs;
43
+ } catch {
44
+ return 0;
45
+ }
46
+ }
47
+
48
+ // ─── Load core lazily, reloading when the dist changes ───────────────────────
49
+ //
50
+ // This plugin runs inside a plain Node.js process (a Metro/Babel worker), where
51
+ // @kbach/ui's isWeb and isNative both read false — there's no `window` and no
52
+ // RN device globals. Left alone, getEffectiveIsWeb() would default to "web" and
53
+ // bake web-flavored values (e.g. padding: '10px' as a string) into the
54
+ // __kbachStyles this plugin injects, which the actual native runtime then uses
55
+ // verbatim (see jsx-runtime.tsx's `!isWeb && __kbachStyles != null` fast path) —
56
+ // breaking arbitrary values, transforms, and any other getEffectiveIsWeb()-gated
57
+ // utility on a real device. setResolveTarget('native') forces the correct answer
58
+ // for every resolve() call this plugin makes.
59
+ let _core = null;
60
+ let _coreMtime = 0;
61
+ let _corePath = null;
62
+ let _lastStatMs = 0;
63
+ let _coreWarned = false;
64
+ const STAT_INTERVAL_MS = 500;
65
+
66
+ function getCore() {
67
+ if (_core && isFresh(_lastStatMs, STAT_INTERVAL_MS)) return _core;
68
+
69
+ try {
70
+ if (!_corePath) _corePath = require.resolve('@kbach/ui');
71
+ const mtime = fs.statSync(_corePath).mtimeMs;
72
+ _lastStatMs = Date.now();
73
+ if (_core && mtime === _coreMtime) return _core;
74
+ for (const id of Object.keys(require.cache)) {
75
+ if (id.includes(`${path.sep}@kbach${path.sep}react`) || id.includes(`${path.sep}packages${path.sep}react${path.sep}`)) {
76
+ delete require.cache[id];
77
+ }
78
+ }
79
+ _core = require('@kbach/ui');
80
+ _core.setResolveTarget?.('native');
81
+ _coreMtime = mtime;
82
+ _configCache.clear();
83
+ _resolveCache.clear(); // config changed — invalidate resolve cache too
84
+ _coreWarned = false;
85
+ } catch (err) {
86
+ if (!_core) {
87
+ // No previously loaded copy to fall back to — let this propagate as a
88
+ // real build error rather than swallowing it, since there's nothing
89
+ // usable to silently continue with.
90
+ _core = require('@kbach/ui');
91
+ _core.setResolveTarget?.('native');
92
+ } else if (!_coreWarned) {
93
+ // A previously loaded copy exists — fall back to it, but only silently
94
+ // once. Otherwise a persistent reload failure (e.g. @kbach/ui briefly
95
+ // mid-write on disk, or a broken reinstall) never surfaces anywhere in
96
+ // the Metro/Babel output, and every class resolved afterward silently
97
+ // uses a stale copy of the core engine with no indication why.
98
+ _coreWarned = true;
99
+ warn(`Failed to reload @kbach/ui (${err.message}) — using the previously loaded copy.`);
100
+ }
101
+ }
102
+ return _core;
103
+ }
104
+
105
+ // ─── Load user config ─────────────────────────────────────────────────────────
106
+ // Keyed by resolved config file path (like _resolveCache above), not a single
107
+ // global slot — resolveProjectRoot() means two files in the same worker process
108
+ // can legitimately resolve to two DIFFERENT kbach.config.js paths (different
109
+ // projects sharing a Metro/Babel worker pool, see _resolveCache's comment). A
110
+ // single-slot cache was safe back when configFile resolution was always
111
+ // process.cwd()-based (constant for the whole process, so every call landed on
112
+ // the same path); with per-file roots, it would serve one project's cached
113
+ // config to another's classes. Each entry also tracks its own mtime so
114
+ // kbach.config.js edits are picked up during watch mode without restarting.
115
+ const _configCache = new Map(); // cfgPath -> { config, mtime, lastStatMs }
116
+ const CFG_STAT_INTERVAL_MS = 500;
117
+
118
+ // Gives useColors()/useSpacing() autocomplete for a project's custom
119
+ // colors/spacing keys with zero manual setup — see @kbach/ui's
120
+ // generateTypesDts.ts for what actually gets generated and why. Written next
121
+ // to kbach.config.js itself (not the project root — unlike the Vite plugin,
122
+ // this function already has the config file's own resolved path, which is
123
+ // the more predictable location for a native project without a single
124
+ // canonical "root" the way a Vite `root` option is). Content-compared
125
+ // against what's already on disk so a config reload with an unchanged theme
126
+ // doesn't touch the file's mtime.
127
+ //
128
+ // This read/compare/write/delete shape is intentionally kept in sync BY HAND
129
+ // with vite-plugin.ts's own writeKbachTypesDts (../vite-plugin.ts) rather
130
+ // than extracted into a shared module: this file ships as raw, unbuilt
131
+ // CommonJS (no tsup step, by design — see package.json's "./babel-plugin"
132
+ // export), so it can only require() other plain-JS files, not vite-plugin's
133
+ // TypeScript. Update both if this logic changes.
134
+ function writeKbachTypesDts(cfgPath, resolvedConfig) {
135
+ const filePath = path.join(path.dirname(cfgPath), 'kbach-types.d.ts');
136
+ let content;
137
+ try {
138
+ content = getCore().generateKbachTypesDts(resolvedConfig.theme);
139
+ } catch {
140
+ return; // Best-effort — an old @kbach/ui without this export shouldn't break the build.
141
+ }
142
+
143
+ let existing = null;
144
+ try { existing = fs.readFileSync(filePath, 'utf-8'); } catch {}
145
+ if (content === (existing ?? '')) return;
146
+
147
+ try {
148
+ if (content === '') {
149
+ fs.unlinkSync(filePath);
150
+ } else {
151
+ const isNew = existing === null;
152
+ fs.writeFileSync(filePath, content, 'utf-8');
153
+ if (isNew) {
154
+ log(`Generated kbach-types.d.ts — gives useColors()/useSpacing() autocomplete for your custom colors/spacing keys. Safe to add to .gitignore.`);
155
+ }
156
+ }
157
+ } catch {
158
+ // Best-effort — a read-only filesystem or permissions issue here
159
+ // shouldn't break the build; the manual KbachCustomColors augmentation
160
+ // documented on it still works as a fallback.
161
+ }
162
+ }
163
+
164
+ function getUserConfig(configFile, root) {
165
+ const cfgPath = path.resolve(root || process.cwd(), configFile);
166
+ let entry = _configCache.get(cfgPath);
167
+
168
+ if (entry && isFresh(entry.lastStatMs, CFG_STAT_INTERVAL_MS)) return entry.config;
169
+
170
+ const now = Date.now();
171
+ try {
172
+ const mtime = safeStatMtime(cfgPath);
173
+
174
+ if (entry && mtime === entry.mtime) {
175
+ entry.lastStatMs = now;
176
+ return entry.config;
177
+ }
178
+
179
+ // Config file changed (or first load for this path) — bust require cache
180
+ // for it and reload.
181
+ if (require.cache[cfgPath]) delete require.cache[cfgPath];
182
+ // eslint-disable-next-line import/no-dynamic-require
183
+ const userCfg = require(cfgPath);
184
+ const { buildConfig } = getCore();
185
+ entry = { config: buildConfig(userCfg), mtime, lastStatMs: now };
186
+ _configCache.set(cfgPath, entry);
187
+ _resolveCache.clear();
188
+ writeKbachTypesDts(cfgPath, entry.config);
189
+ } catch (err) {
190
+ // A syntax error or throw in kbach.config.js (or a transient failure while
191
+ // it's mid-write on disk during a save) used to fall back to a stale or
192
+ // default config with zero output anywhere — a broken config file was
193
+ // very hard to notice since styles just looked subtly wrong instead of
194
+ // erroring. Warn once per failure streak (not on every call — this can be
195
+ // hit once per file transformed while the config stays broken) so it
196
+ // shows up in the Metro/Babel terminal output; the warning clears the
197
+ // next time the config loads successfully, since that path creates a
198
+ // fresh cache entry with no `warned` flag set.
199
+ if (!entry) {
200
+ const { getConfig } = getCore();
201
+ entry = { config: getConfig(), mtime: 0, lastStatMs: now };
202
+ _configCache.set(cfgPath, entry);
203
+ warn(`Couldn't load "${cfgPath}" (${err.message}) — using the default theme until it's fixed.`);
204
+ entry.warned = true;
205
+ } else {
206
+ entry.lastStatMs = now;
207
+ if (!entry.warned) {
208
+ entry.warned = true;
209
+ warn(`Couldn't reload "${cfgPath}" (${err.message}) — keeping the previously loaded config.`);
210
+ }
211
+ }
212
+ }
213
+ return entry.config;
214
+ }
215
+
216
+ // Resolve the project root a config-relative path (kbach.config.js) should be
217
+ // read from. process.cwd() is only correct when the Metro/Babel worker's cwd
218
+ // happens to be the app directory — not guaranteed for a monorepo root script
219
+ // or a CI job invoked from the repo root. state.file.opts.root is Babel's own
220
+ // resolved project root (based on where babel.config.js was found for this
221
+ // file), which tracks the actual app directory regardless of the process cwd.
222
+ function resolveProjectRoot(state) {
223
+ return (state && state.file && state.file.opts && state.file.opts.root)
224
+ || (state && state.cwd)
225
+ || process.cwd();
226
+ }
227
+
228
+ // Valid JS identifier pattern — avoids quoting camelCase property names like fontFamily.
229
+ const _identRe = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
230
+
231
+ // ─── Convert a StyleValue to a Babel AST ObjectExpression ────────────────────
232
+ function styleToAST(t, styles) {
233
+ if (!styles || typeof styles !== 'object') return t.nullLiteral();
234
+
235
+ const props = [];
236
+ for (const [key, val] of Object.entries(styles)) {
237
+ if (val === undefined || val === null) continue;
238
+
239
+ let valueNode;
240
+ if (typeof val === 'number') {
241
+ valueNode = val < 0 ? t.unaryExpression('-', t.numericLiteral(-val)) : t.numericLiteral(val);
242
+ } else if (typeof val === 'string') {
243
+ valueNode = t.stringLiteral(val);
244
+ } else if (typeof val === 'object' && !Array.isArray(val)) {
245
+ valueNode = styleToAST(t, val);
246
+ } else if (Array.isArray(val)) {
247
+ valueNode = t.arrayExpression(val.map(item =>
248
+ typeof item === 'object' ? styleToAST(t, item) : t.stringLiteral(String(item)),
249
+ ));
250
+ } else {
251
+ valueNode = t.stringLiteral(String(val));
252
+ }
253
+
254
+ // Use t.identifier for valid identifier keys (e.g. fontFamily) instead of
255
+ // t.stringLiteral, which generates unnecessary quoted keys in the output.
256
+ const keyNode = _identRe.test(key) ? t.identifier(key) : t.stringLiteral(key);
257
+ props.push(t.objectProperty(keyNode, valueNode));
258
+ }
259
+
260
+ return t.objectExpression(props);
261
+ }
262
+
263
+ // ─── Convert a ResolvedStyle to a Babel AST ObjectExpression ─────────────────
264
+ function resolvedStyleToAST(t, resolved) {
265
+ const props = [];
266
+ for (const [bucketKey, styles] of Object.entries(resolved)) {
267
+ if (!styles || Object.keys(styles).length === 0) continue;
268
+ props.push(
269
+ t.objectProperty(t.stringLiteral(bucketKey), styleToAST(t, styles)),
270
+ );
271
+ }
272
+ return t.objectExpression(props);
273
+ }
274
+
275
+ // ─── Runtime config init AST ─────────────────────────────────────────────────
276
+ // Generates a call injected into every transformed file:
277
+ //
278
+ // ;(function(){
279
+ // try { require('@kbach/ui').initConfig(require('/path/to/kbach.config.js')); } catch(_e) {}
280
+ // })();
281
+ //
282
+ // Metro bundles kbach.config.js into the app, so plugins (functions) are
283
+ // included and run correctly — the full config is available at runtime.
284
+ //
285
+ // initConfig() is reference-based: it skips updateConfig() when the same config
286
+ // object is passed again (multiple files importing the same cached require result).
287
+ // When kbach.config.js changes on disk, Metro's Fast Refresh re-evaluates the
288
+ // module and produces a new object reference → initConfig() re-applies the config.
289
+ //
290
+ // cfgAbsPath MUST use forward slashes — Metro require() does not handle Windows
291
+ // backslash paths inside string literals.
292
+
293
+ function buildConfigInitAST(t, cfgAbsPath) {
294
+ const initCall = t.expressionStatement(
295
+ t.callExpression(
296
+ t.memberExpression(
297
+ t.callExpression(t.identifier('require'), [t.stringLiteral('@kbach/ui')]),
298
+ t.identifier('initConfig'),
299
+ ),
300
+ [t.callExpression(t.identifier('require'), [t.stringLiteral(cfgAbsPath)])],
301
+ ),
302
+ );
303
+
304
+ const body = t.blockStatement([
305
+ t.tryStatement(
306
+ t.blockStatement([initCall]),
307
+ t.catchClause(t.identifier('_e'), t.blockStatement([])),
308
+ ),
309
+ ]);
310
+
311
+ return t.expressionStatement(
312
+ t.callExpression(t.functionExpression(null, [], body), []),
313
+ );
314
+ }
315
+
316
+ // ─── Plugin ───────────────────────────────────────────────────────────────────
317
+
318
+ module.exports = function kbachBabelPlugin(api, options = {}) {
319
+ const { types: t } = api;
320
+
321
+ const {
322
+ configFile = 'kbach.config.js',
323
+ attributes = ['kb', 'className'],
324
+ debug = false,
325
+ } = options;
326
+
327
+ // The JSX runtime (jsx-runtime.tsx) only ever reads className/kb/__kbachClasses
328
+ // off props — it has no way to know about a custom `attributes` name configured
329
+ // here, since that option never reaches the runtime. A STATIC string on a custom
330
+ // attribute still works (this plugin resolves and renames it to __kbachClasses
331
+ // at build time), but a DYNAMIC expression (template literal, ternary, computed)
332
+ // on that same custom attribute is left completely untouched by both this plugin
333
+ // (correctly, since it's not a string literal) and the runtime (which never
334
+ // recognizes the original attribute name) — it silently renders unstyled with no
335
+ // error. Surfacing that gap once per build beats leaving it silent.
336
+ const customAttributes = attributes.filter((a) => a !== 'kb' && a !== 'className');
337
+ if (customAttributes.length) {
338
+ warn(
339
+ `Custom class attribute(s) ${customAttributes.map((a) => `"${a}"`).join(', ')} are ` +
340
+ 'only resolved for STATIC string classes at build time. A dynamic expression ' +
341
+ '(template literal, ternary, computed value) on them is not recognized by the ' +
342
+ '@kbach/ui runtime, which only reads className/kb — it will render unstyled ' +
343
+ 'with no warning at runtime. Use className or kb for any dynamic class string.',
344
+ );
345
+ }
346
+
347
+ const SEP = path.sep;
348
+
349
+ return {
350
+ name: 'babel-plugin-kbach',
351
+
352
+ // JSX runtime setup: pre() injects a @jsxImportSource comment so that
353
+ // @babel/plugin-transform-react-jsx (from babel-preset-expo or any React preset)
354
+ // uses @kbach/ui/jsx-runtime. Our pre() runs before the preset's pre(), so the
355
+ // comment is in place when the JSX transform reads it.
356
+ //
357
+ // NOTE: Do NOT add plugins dynamically inside manipulateOptions. By the time
358
+ // manipulateOptions runs, opts.presets has already been resolved from strings to
359
+ // functions — preset detection by name is impossible — and pushing a new entry into
360
+ // opts.plugins at that stage produces an uninstantiated plugin with visitor: undefined,
361
+ // which crashes @babel/traverse's visitors.merge().
362
+
363
+ pre(file) {
364
+ // Skip node_modules — they have their own JSX runtime and are already compiled
365
+ const filename = file.opts.filename || '';
366
+ if (filename.includes(`${SEP}node_modules${SEP}`)) return;
367
+
368
+ // Inject @jsxImportSource so @babel/plugin-transform-react-jsx (from a React preset)
369
+ // uses our runtime. This pre() runs before the preset's pre() calls.
370
+ const comments = file.ast.comments;
371
+ if (!Array.isArray(comments)) return;
372
+ const alreadySet = comments.some(c => /@jsxImportSource|@jsxRuntime/.test(c.value));
373
+ if (!alreadySet) {
374
+ comments.unshift({ type: 'CommentLine', value: ' @jsxImportSource @kbach/ui' });
375
+ }
376
+ },
377
+
378
+ visitor: {
379
+ Program: {
380
+ enter(programPath, state) {
381
+ state.kbachDeclarations = new Map();
382
+ // openingElementNode -> { classString, stylesIdentifier, classAttrValue }
383
+ // Tracks the first matched class attribute seen on each JSX element, so a
384
+ // second matched attribute on the SAME element (e.g. both `kb` and
385
+ // `className`) merges into it instead of producing a duplicate
386
+ // __kbachClasses/__kbachStyles attribute pair — see the JSXAttribute
387
+ // visitor below.
388
+ state.kbachElementInfo = new Map();
389
+ },
390
+
391
+ exit(programPath, state) {
392
+ const hasStaticDeclarations = !!(state.kbachDeclarations && state.kbachDeclarations.size);
393
+ // hasClassAttr is set by the JSXAttribute visitor for ANY matched attribute
394
+ // (kb/className/…), static or dynamic — a file whose only usage is a
395
+ // dynamic class expression (template literal, conditional, computed) never
396
+ // populates kbachDeclarations, but the runtime still resolves those classes
397
+ // dynamically and needs kbach.config.js synced just as much as a file with
398
+ // static classes does. Gating this whole block on kbachDeclarations.size
399
+ // (as before) meant a dynamic-only file — or a dynamic-only app, if no file
400
+ // anywhere has a static class string — never got the config-init call at
401
+ // all, silently leaving dark mode / useColors() / dynamic resolution on the
402
+ // default config instead of the user's kbach.config.js.
403
+ if (!hasStaticDeclarations && !state.hasClassAttr) return;
404
+
405
+ const body = programPath.get('body');
406
+ const imports = body.filter(p => p.isImportDeclaration());
407
+ const insertAfterPath = imports.length > 0 ? imports[imports.length - 1] : null;
408
+
409
+ const insert = (node) => {
410
+ if (insertAfterPath) insertAfterPath.insertAfter(node);
411
+ else programPath.unshiftContainer('body', node);
412
+ };
413
+
414
+ // 1. Inject __kbachStyles declarations (reverse so they appear in order)
415
+ if (hasStaticDeclarations) {
416
+ const entries = [...state.kbachDeclarations.values()];
417
+ for (let i = entries.length - 1; i >= 0; i--) {
418
+ const { uid, astNode } = entries[i];
419
+ insert(t.variableDeclaration('const', [t.variableDeclarator(uid, astNode)]));
420
+ }
421
+ }
422
+
423
+ // 2. Inject runtime config init LAST so insertAfter places it FIRST
424
+ // (right after imports, before the declarations above).
425
+ // This syncs kbach.config.js into the runtime for dynamic class
426
+ // resolution, useColors(), and custom darkMode strategy.
427
+ const cfgAbsPath = path.resolve(resolveProjectRoot(state), configFile).replace(/\\/g, '/');
428
+ if (fs.existsSync(cfgAbsPath.replace(/\//g, path.sep))) {
429
+ insert(buildConfigInitAST(t, cfgAbsPath));
430
+ }
431
+ },
432
+ },
433
+
434
+ JSXAttribute(nodePath, state) {
435
+ // Skip node_modules entirely — they're already compiled
436
+ const filename = state.file.opts.filename || '';
437
+ if (filename.includes(`${SEP}node_modules${SEP}`)) return;
438
+
439
+ const attrName = nodePath.node.name;
440
+ const name = t.isJSXIdentifier(attrName) ? attrName.name : null;
441
+
442
+ if (!name || !attributes.includes(name)) return;
443
+
444
+ // Mark this file as having class-attribute usage regardless of whether
445
+ // the value turns out to be static or dynamic — see the exit() handler
446
+ // above for why the config-init injection depends on this, not just on
447
+ // whether any static declarations were collected.
448
+ state.hasClassAttr = true;
449
+
450
+ const value = nodePath.node.value;
451
+
452
+ if (!t.isStringLiteral(value) && !(t.isJSXExpressionContainer(value) && t.isStringLiteral(value.expression))) {
453
+ return;
454
+ }
455
+
456
+ const classString = t.isStringLiteral(value)
457
+ ? value.value
458
+ : value.expression.value;
459
+
460
+ if (!classString || !classString.trim()) return;
461
+
462
+ // An element carrying more than one configured class attribute at once
463
+ // (e.g. both `kb` and `className` — the default `attributes` list) would
464
+ // otherwise get two independent __kbachClasses/__kbachStyles attribute
465
+ // pairs with the SAME names: each JSXAttribute visit renames its own
466
+ // attribute in place and inserts its own sibling, with no awareness that
467
+ // another matched attribute already did the same on this element. When
468
+ // the JSX transform lowers that into a createElement/jsx() props object
469
+ // literal, duplicate keys silently keep only the LATER pair — the
470
+ // earlier attribute's resolved styles are dropped with no warning even
471
+ // though both were valid, statically-resolved class strings. Merging
472
+ // here — combine the class strings, drop the second attribute, and
473
+ // re-resolve once as a single list — makes the result match what the
474
+ // runtime would produce if both class strings had been written in one
475
+ // attribute to begin with.
476
+ const openingElement = nodePath.parentPath.node;
477
+ const existing = state.kbachElementInfo.get(openingElement);
478
+ const combinedClassString = existing ? `${existing.classString} ${classString}` : classString;
479
+
480
+ try {
481
+ // Use global cache to avoid re-resolving the same class string
482
+ // across different files in the same build. Namespaced by the
483
+ // resolved config file path so different projects/configs sharing
484
+ // this worker process never collide (see _resolveCache comment above).
485
+ const projectRoot = resolveProjectRoot(state);
486
+ const cfgAbsPath = path.resolve(projectRoot, configFile);
487
+ const cacheKey = `${cfgAbsPath} ${combinedClassString}`;
488
+ let resolved;
489
+ if (_resolveCache.has(cacheKey)) {
490
+ resolved = _resolveCache.get(cacheKey);
491
+ } else {
492
+ const { resolve } = getCore();
493
+ const config = getUserConfig(configFile, projectRoot);
494
+ resolved = resolve(combinedClassString, config.theme, config.darkMode);
495
+ _resolveCache.set(cacheKey, resolved);
496
+ }
497
+
498
+ // Don't inject __kbachStyles when nothing resolved — this avoids
499
+ // bloating compiled output with {} for pure-unknown or pure-CSS classes.
500
+ const hasStyles = Object.values(resolved).some(
501
+ v => v && typeof v === 'object' && Object.keys(v).length > 0,
502
+ );
503
+
504
+ // existing.stylesIdentifier is null when an earlier attribute on this
505
+ // element was tracked but never became a real __kbachClasses/
506
+ // __kbachStyles pair itself — i.e. it alone resolved to no styles
507
+ // (e.g. `kb="group"`, a standalone marker with no styles of its
508
+ // own). Bug fix: that earlier attribute used to fall through the
509
+ // `!hasStyles` branch below and `return` WITHOUT ever calling
510
+ // state.kbachElementInfo.set(), so a second class attribute on the
511
+ // same element never saw `existing` at all — it transformed itself
512
+ // in isolation, silently dropping the merge and leaving the first,
513
+ // styleless attribute (e.g. `kb="group"`) untouched in the output
514
+ // instead of folded away. Tracking it here (with no styles yet)
515
+ // closes that gap: a later attribute now always finds `existing`,
516
+ // whether or not the earlier one had styles on its own.
517
+ if (existing && existing.stylesIdentifier) {
518
+ if (!hasStyles) { nodePath.remove(); return; }
519
+
520
+ let uid;
521
+ if (state.kbachDeclarations.has(combinedClassString)) {
522
+ uid = state.kbachDeclarations.get(combinedClassString).uid;
523
+ } else {
524
+ const astNode = resolvedStyleToAST(t, resolved);
525
+ uid = nodePath.scope.getProgramParent().generateUidIdentifier('kbach');
526
+ state.kbachDeclarations.set(combinedClassString, { uid, astNode });
527
+ }
528
+
529
+ if (debug) {
530
+ log(`Transformed "${combinedClassString}" (merged from multiple class attributes)`);
531
+ }
532
+
533
+ existing.classString = combinedClassString;
534
+ existing.stylesIdentifier.name = uid.name;
535
+ existing.classAttrValue.value = combinedClassString;
536
+ nodePath.remove();
537
+ return;
538
+ }
539
+
540
+ if (!hasStyles) {
541
+ // Track this attribute (styleless alone, even combined with any
542
+ // earlier one) so a LATER class attribute on the same element
543
+ // still merges with it. Neither this attribute nor any earlier
544
+ // tracked one is touched here — there is no __kbachClasses/
545
+ // __kbachStyles pair yet for anything to fold into, so removing
546
+ // either would just discard that attribute's class(es) with
547
+ // nothing taking their place. Keep the FIRST untransformed
548
+ // attribute's path (existing.attrPath, if any) — that's the one
549
+ // that will need removing once something eventually claims the
550
+ // merged pair, not this one.
551
+ state.kbachElementInfo.set(openingElement, {
552
+ classString: combinedClassString,
553
+ stylesIdentifier: null,
554
+ classAttrValue: null,
555
+ attrPath: existing ? existing.attrPath : nodePath,
556
+ });
557
+ return;
558
+ }
559
+
560
+ if (debug) {
561
+ log(existing
562
+ ? `Transformed "${combinedClassString}" (merged from multiple class attributes)`
563
+ : `Transformed "${classString}"`);
564
+ }
565
+
566
+ let uid;
567
+ if (state.kbachDeclarations.has(combinedClassString)) {
568
+ uid = state.kbachDeclarations.get(combinedClassString).uid;
569
+ } else {
570
+ const astNode = resolvedStyleToAST(t, resolved);
571
+ uid = nodePath.scope.getProgramParent().generateUidIdentifier('kbach');
572
+ state.kbachDeclarations.set(combinedClassString, { uid, astNode });
573
+ }
574
+
575
+ const stylesIdentifier = t.identifier(uid.name);
576
+ nodePath.insertAfter(
577
+ t.jSXAttribute(
578
+ t.jSXIdentifier('__kbachStyles'),
579
+ t.jSXExpressionContainer(stylesIdentifier),
580
+ ),
581
+ );
582
+
583
+ nodePath.node.name = t.jSXIdentifier('__kbachClasses');
584
+ if (t.isStringLiteral(value)) value.value = combinedClassString;
585
+ else value.expression.value = combinedClassString;
586
+
587
+ // An earlier attribute was tracked but never transformed (it had no
588
+ // styles on its own) — this attribute is taking over as the merged
589
+ // pair, so remove the earlier, still-untouched one now.
590
+ if (existing) existing.attrPath.remove();
591
+
592
+ state.kbachElementInfo.set(openingElement, {
593
+ classString: combinedClassString,
594
+ stylesIdentifier,
595
+ classAttrValue: t.isStringLiteral(value) ? value : value.expression,
596
+ });
597
+ } catch (err) {
598
+ if (debug) {
599
+ warn(`Couldn't transform "${combinedClassString}": ${err.message}`);
600
+ }
601
+ }
602
+ },
603
+ },
604
+ };
605
+ };