(KEY, DEFAULT);
return
;
}
`,
@@ -78,3 +78,26 @@ test('collectUseUIHooks should resolve const-based args for useScopedUI', async
}
);
});
+
+test('collectUseUIHooks handles + both hooks', async () => {
+ const code = `
+ import { useUI, useScopedUI } from '@react-zero-ui/core';
+ export function Comp() {
+ const [, setTheme] = useUI<'theme', 'dark'>('theme','dark');
+ const [, setAcc] = useScopedUI<'accordion', 'closed'>('accordion','closed');
+ return
;
+ }
+ `;
+ const ast = parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] });
+ const hooks = collectUseUIHooks(ast, code);
+ console.log('hooks: ', hooks);
+
+ assert.equal(hooks.length, 2);
+ assert.deepEqual(
+ hooks.map((h) => [h.stateKey, h.scope]),
+ [
+ ['theme', 'global'],
+ ['accordion', 'scoped'],
+ ]
+ );
+});
diff --git a/packages/core/src/postcss/ast-parsing.ts b/packages/core/src/postcss/ast-parsing.ts
index 30f11f2..f7d5beb 100644
--- a/packages/core/src/postcss/ast-parsing.ts
+++ b/packages/core/src/postcss/ast-parsing.ts
@@ -9,7 +9,7 @@ import { codeFrameColumns } from '@babel/code-frame';
import { LRUCache as LRU } from 'lru-cache';
import { scanVariantTokens } from './scanner.js';
import { findAllSourceFiles, mapLimit, toKebabCase } from './helpers.js';
-import { Binding, NodePath, Node } from '@babel/traverse';
+import { NodePath, Node } from '@babel/traverse';
import traverse from './traverse.cjs';
const PARSE_OPTS = (f: string): Partial
=> ({
@@ -19,8 +19,8 @@ const PARSE_OPTS = (f: string): Partial => ({
});
export interface HookMeta {
- /** Babel binding object — use `binding.referencePaths` in Pass 2 */
- binding: Binding;
+ /** Babel binding object — use `binding.referencePaths` */
+ // binding: Binding;
/** Variable name (`setTheme`) */
setterFnName: string;
/** State key passed to `useUI` (`'theme'`) */
@@ -40,6 +40,7 @@ export interface HookMeta {
* Throws if the key is dynamic or if the initial value cannot be
* reduced to a space-free string.
*/
+const ALL_HOOK_NAMES = new Set([CONFIG.HOOK_NAME, CONFIG.LOCAL_HOOK_NAME]);
export function collectUseUIHooks(ast: t.File, sourceCode: string): HookMeta[] {
const hooks: HookMeta[] = [];
@@ -59,8 +60,6 @@ export function collectUseUIHooks(ast: t.File, sourceCode: string): HookMeta[] {
return value;
}
- const ALL_HOOK_NAMES = new Set([CONFIG.HOOK_NAME, CONFIG.LOCAL_HOOK_NAME]);
-
traverse(ast, {
VariableDeclarator(path: NodePath) {
const { id, init } = path.node;
@@ -110,14 +109,14 @@ export function collectUseUIHooks(ast: t.File, sourceCode: string): HookMeta[] {
);
}
- const binding = path.scope.getBinding(setterEl.name);
- if (!binding) {
- throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] Could not resolve binding for setter "${setterEl.name}".`);
- }
+ // const binding = path.scope.getBinding(setterEl.name);
+ // if (!binding) {
+ // throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] Could not resolve binding for setter "${setterEl.name}".`);
+ // }
const scope: 'global' | 'scoped' = init.callee.name === CONFIG.HOOK_NAME ? 'global' : 'scoped';
- hooks.push({ binding, setterFnName: setterEl.name, stateKey, initialValue, scope });
+ hooks.push({ setterFnName: setterEl.name, stateKey, initialValue, scope });
},
});
@@ -131,6 +130,8 @@ export interface VariantData {
values: string[];
/** Literal initial value as string, or `null` if non-literal */
initialValue: string | null;
+ /** Whether the variant is global or scoped */
+ scope: 'global' | 'scoped';
}
/* ── LRU cache keyed by absolute file path ──────────────────────────── */
@@ -151,7 +152,7 @@ export function clearCache(): void {
/* ── Main compiler ──────────────────────────────────────────────────── */
export interface ProcessVariantsResult {
finalVariants: VariantData[];
- initialValues: Record;
+ initialGlobalValues: Record;
sourceFiles: string[];
}
@@ -209,16 +210,25 @@ export async function processVariants(changedFiles: string[] | null = null): Pro
/* Phase D — aggregate variant & initial-value maps */
const variantMap = new Map>();
const initMap = new Map();
+ const scopeMap = new Map();
for (const { hooks, tokens } of fileCache.values()) {
- // initial values
hooks.forEach((h) => {
- if (!h.initialValue) return;
- const prev = initMap.get(h.stateKey);
- if (prev && prev !== h.initialValue) {
- throw new Error(`[Zero-UI] Conflicting initial values for '${h.stateKey}': '${prev}' vs '${h.initialValue}'`);
+ /* initial-value aggregation */
+ if (h.initialValue) {
+ const prev = initMap.get(h.stateKey);
+ if (prev && prev !== h.initialValue) {
+ throw new Error(`[Zero-UI] Conflicting initial values for '${h.stateKey}': '${prev}' vs '${h.initialValue}'`);
+ }
+ initMap.set(h.stateKey, h.initialValue);
+ }
+
+ /* scope aggregation — always run */
+ const prevScope = scopeMap.get(h.stateKey);
+ if (prevScope && prevScope !== h.scope) {
+ throw new Error(`[Zero-UI] Key "${h.stateKey}" used with both global and scoped hooks.`);
}
- initMap.set(h.stateKey, h.initialValue);
+ scopeMap.set(h.stateKey, h.scope);
});
// tokens → variantMap
@@ -230,12 +240,15 @@ export async function processVariants(changedFiles: string[] | null = null): Pro
/* Phase E — final assembly */
const finalVariants: VariantData[] = [...variantMap]
- .map(([key, set]) => ({ key, values: [...set].sort(), initialValue: initMap.get(key) ?? null }))
+ .map(([key, set]) => ({ key, values: [...set].sort(), initialValue: initMap.get(key) ?? null, scope: scopeMap.get(key)! }))
.sort((a, b) => a.key.localeCompare(b.key));
- const initialValues = Object.fromEntries(finalVariants.map((v) => [`data-${toKebabCase(v.key)}`, v.initialValue ?? v.values[0] ?? '']));
+ const initialGlobalValues = Object.fromEntries(
+ // only include global variants in the initialGlobalValues object because scoped variants are handled by the component data-attributes
+ finalVariants.filter((v) => v.scope === 'global').map((v) => [`data-${toKebabCase(v.key)}`, v.initialValue ?? v.values[0] ?? ''])
+ );
- return { finalVariants, initialValues, sourceFiles: srcFiles };
+ return { finalVariants, initialGlobalValues, sourceFiles: srcFiles };
}
/**
diff --git a/packages/core/src/postcss/helpers.test.ts b/packages/core/src/postcss/helpers.test.ts
index 3441ecf..f571f0d 100644
--- a/packages/core/src/postcss/helpers.test.ts
+++ b/packages/core/src/postcss/helpers.test.ts
@@ -10,10 +10,10 @@ import {
patchViteConfig,
toKebabCase,
} from './helpers.js';
-import { readFile, runTest } from '../utilities.js';
+import { readFile, runTest } from './utilities.js';
import { CONFIG } from '../config.js';
import path from 'node:path';
-import { processVariants } from './ast-parsing.js';
+import { processVariants, VariantData } from './ast-parsing.js';
test('toKebabCase should convert a string to kebab case', () => {
assert.equal(toKebabCase('helloWorld'), 'hello-world');
@@ -40,9 +40,9 @@ return (
); }`;
-const expectedVariants = [
- { key: 'feature-enabled', values: ['false'], initialValue: 'true' },
- { key: 'modal-visible', values: ['true'], initialValue: 'false' },
+const expectedVariants: VariantData[] = [
+ { key: 'feature-enabled', values: ['false'], initialValue: 'true', scope: 'global' },
+ { key: 'modal-visible', values: ['true'], initialValue: 'false', scope: 'global' },
];
const initialValues = { 'data-feature-enabled': 'true', 'data-modal-visible': 'false' };
@@ -53,9 +53,9 @@ test('findAllSourceFiles Return *absolute* paths of every JS/TS file we care abo
test('processVariants should process variants', async () => {
await runTest({ 'src/app/component.tsx': src }, async () => {
- const { finalVariants, initialValues, sourceFiles } = await processVariants();
+ const { finalVariants, initialGlobalValues, sourceFiles } = await processVariants();
assert.deepStrictEqual(finalVariants, expectedVariants);
- assert.deepStrictEqual(initialValues, initialValues);
+ assert.deepStrictEqual(initialGlobalValues, initialValues);
assert.equal(sourceFiles.length, 1);
});
});
@@ -65,9 +65,7 @@ const norm = (s: string) => s.replace(/\r\n/g, '\n');
test('buildCss emits @custom-variant blocks in stable order', () => {
const css = buildCss(expectedVariants);
- const expected = `${CONFIG.HEADER}
-@custom-variant feature-enabled-false {&:where(body[data-feature-enabled="false"] *) { @slot; } [data-feature-enabled="false"] &, &[data-feature-enabled="false"] { @slot; }}
-@custom-variant modal-visible-true {&:where(body[data-modal-visible="true"] *) { @slot; } [data-modal-visible="true"] &, &[data-modal-visible="true"] { @slot; }}\n`;
+ const expected = `/* AUTO-GENERATED - DO NOT EDIT */\n@custom-variant feature-enabled-false { &:where(body[data-feature-enabled='false'] &) { @slot; } }\n@custom-variant modal-visible-true { &:where(body[data-modal-visible='true'] &) { @slot; } }\n`;
assert.strictEqual(norm(css), norm(expected), 'CSS snapshot mismatch');
});
diff --git a/packages/core/src/postcss/helpers.ts b/packages/core/src/postcss/helpers.ts
index ec6686e..227e074 100644
--- a/packages/core/src/postcss/helpers.ts
+++ b/packages/core/src/postcss/helpers.ts
@@ -50,16 +50,30 @@ export function findAllSourceFiles(patterns: string[] = CONFIG.CONTENT, cwd: str
.map((p) => path.resolve(p)); // normalize on Windows
}
+function buildLocalSelector(keySlug: string, valSlug: string): string {
+ return `[data-${keySlug}="${valSlug}"] &, &[data-${keySlug}="${valSlug}"] { @slot; }`;
+}
+
+function buildGlobalSelector(keySlug: string, valSlug: string): string {
+ return `&:where(body[data-${keySlug}='${valSlug}'] &) { @slot; }`;
+}
+
export function buildCss(variants: VariantData[]): string {
- const lines = variants.flatMap(({ key, values }) => {
+ const lines = variants.flatMap(({ key, values, scope }) => {
if (values.length === 0) return [];
const keySlug = toKebabCase(key);
// Double-ensure sorted order, even if extractor didn't sort
return [...values].sort().map((v) => {
const valSlug = toKebabCase(v);
+ let selector;
+ if (scope === 'scoped') {
+ selector = buildLocalSelector(keySlug, valSlug);
+ } else {
+ selector = buildGlobalSelector(keySlug, valSlug);
+ }
- return `@custom-variant ${keySlug}-${valSlug} {&:where(body[data-${keySlug}="${valSlug}"] *) { @slot; } [data-${keySlug}="${valSlug}"] &, &[data-${keySlug}="${valSlug}"] { @slot; }}`;
+ return `@custom-variant ${keySlug}-${valSlug} { ${selector} }`;
});
});
diff --git a/packages/core/src/postcss/index.cts b/packages/core/src/postcss/index.cts
index 99fc1be..e383a86 100644
--- a/packages/core/src/postcss/index.cts
+++ b/packages/core/src/postcss/index.cts
@@ -9,9 +9,10 @@ import { processVariants } from './ast-parsing';
const plugin: PluginCreator
= () => {
const DEV = process.env.NODE_ENV !== 'production';
+ const zeroUIPlugin = 'postcss-react-zero-ui';
return {
- postcssPlugin: 'postcss-react-zero-ui',
+ postcssPlugin: zeroUIPlugin,
async Once(root: Root, { result }) {
try {
@@ -21,21 +22,20 @@ const plugin: PluginCreator = () => {
initialValues: Record; // key: initialValue
sourceFiles: string[]; // file paths (absolute)
*/
- const { finalVariants, initialValues, sourceFiles } = await processVariants();
+ const { finalVariants, initialGlobalValues, sourceFiles } = await processVariants();
const cssBlock = buildCss(finalVariants);
if (cssBlock.trim()) root.prepend(cssBlock + '\n');
/* ── register file-dependencies for HMR ─────────────────── */
- sourceFiles.forEach((file) => result.messages.push({ type: 'dependency', plugin: 'postcss-react-zero-ui', file, parent: result.opts.from }));
+ sourceFiles.forEach((file) => result.messages.push({ type: 'dependency', plugin: zeroUIPlugin, file, parent: result.opts.from }));
/* ── first-run bootstrap ────────────────────────────────── */
if (!isZeroUiInitialized()) {
console.log('[Zero-UI] Auto-initializing (first-time setup)…');
await runZeroUiInit();
}
-
- await generateAttributesFile(finalVariants, initialValues);
+ await generateAttributesFile(finalVariants, initialGlobalValues);
} catch (err: unknown) {
/* ───────────────── error handling ─────────────────────── */
const error = err instanceof Error ? err : new Error(String(err));
@@ -61,10 +61,10 @@ const plugin: PluginCreator = () => {
/* ❸ Dev = warn + keep server alive | Prod = fail build */
if (DEV) {
if (eWithLoc.loc?.file) {
- result.messages.push({ type: 'dependency', plugin: 'postcss-react-zero-ui', file: eWithLoc.loc.file, parent: result.opts.from });
+ result.messages.push({ type: 'dependency', plugin: zeroUIPlugin, file: eWithLoc.loc.file, parent: result.opts.from });
}
- result.warn(friendly, { plugin: 'postcss-react-zero-ui', ...(eWithLoc.loc && { line: eWithLoc.loc.line, column: eWithLoc.loc.column }) });
+ result.warn(friendly, { plugin: zeroUIPlugin, ...(eWithLoc.loc && { line: eWithLoc.loc.line, column: eWithLoc.loc.column }) });
console.error('[Zero-UI] Full error (dev-only):\n', error);
return; // ← do **not** abort dev-server
diff --git a/packages/core/src/postcss/resolvers.test.ts b/packages/core/src/postcss/resolvers.test.ts
index 4b7d5e3..f24706e 100644
--- a/packages/core/src/postcss/resolvers.test.ts
+++ b/packages/core/src/postcss/resolvers.test.ts
@@ -4,7 +4,7 @@ import { parse } from '@babel/parser';
import * as t from '@babel/types';
import { NodePath } from '@babel/traverse';
import { literalFromNode, resolveLocalConstIdentifier, resolveTemplateLiteral, resolveMemberExpression, ResolveOpts } from './resolvers.js';
-import { runTest } from '../utilities.js';
+import { runTest } from './utilities.js';
import traverse from './traverse.cjs';
/*
diff --git a/packages/core/src/utilities.ts b/packages/core/src/postcss/utilities.ts
similarity index 100%
rename from packages/core/src/utilities.ts
rename to packages/core/src/postcss/utilities.ts