@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.
- package/KBACH.md +1044 -0
- package/README.md +473 -0
- package/dist/chunk-BPCFICND.mjs +4187 -0
- package/dist/chunk-UE54W6ZG.mjs +208 -0
- package/dist/core/index.d.ts +1346 -0
- package/dist/core/index.js +3712 -0
- package/dist/index.d.ts +328 -0
- package/dist/index.js +1191 -0
- package/dist/index.mjs +589 -0
- package/dist/jsx-dev-runtime.d.ts +21 -0
- package/dist/jsx-dev-runtime.js +780 -0
- package/dist/jsx-dev-runtime.mjs +21 -0
- package/dist/jsx-runtime.d.ts +17 -0
- package/dist/jsx-runtime.js +779 -0
- package/dist/jsx-runtime.mjs +17 -0
- package/dist/native.d.ts +314 -0
- package/dist/native.js +99 -0
- package/dist/vite-plugin.d.mts +155 -0
- package/dist/vite-plugin.d.ts +155 -0
- package/dist/vite-plugin.js +3939 -0
- package/dist/vite-plugin.mjs +3903 -0
- package/dist/web-substitute-6xH1WxpZ.d.ts +22 -0
- package/jsx-dev-runtime.js +3 -0
- package/jsx-runtime.js +3 -0
- package/kbach-ui.md +889 -0
- package/package.json +104 -0
- package/scripts/postinstall.js +28 -0
- package/src/native/babel/index.js +30 -0
- package/src/native/babel-plugin/index.js +605 -0
- package/src/native/babel-plugin/index.test.ts +86 -0
- package/types.d.ts +1 -0
|
@@ -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
|
+
};
|