diff --git a/.release-please-config.json b/.release-please-config.json
index b9caf0d..288e79b 100644
--- a/.release-please-config.json
+++ b/.release-please-config.json
@@ -1,15 +1,9 @@
{
- "release-type": "node",
- "monorepo-tags": true,
- "include-component-in-tag": true,
- "packages": {
- "packages/core": {
- "package-name": "@react-zero-ui/core",
- "release-type": "node"
- },
- "packages/cli": {
- "package-name": "create-zero-ui",
- "release-type": "node"
- }
- }
-}
\ No newline at end of file
+ "release-type": "node",
+ "monorepo-tags": true,
+ "include-component-in-tag": true,
+ "packages": {
+ "packages/core": { "package-name": "@react-zero-ui/core", "release-type": "node" },
+ "packages/cli": { "package-name": "create-zero-ui", "release-type": "node" }
+ }
+}
diff --git a/README.md b/README.md
index 000abe4..ab15d3e 100644
--- a/README.md
+++ b/README.md
@@ -20,10 +20,10 @@ Pre‑render your UI once, flip a `data-*` attribute to update — that's it.
## 🚀 Live Demo
-| Example | Link | What it shows | Link to Code |
-| --- | --- | --- | --- |
-| Interactive menu with render tracker | Main Demo↗ | Compare Zero‑UI vs. React side‑by‑side while toggling a menu. | Github |
-| React benchmark (10 000 nested nodes) | React 10k↗ | How long the traditional React render path takes. | Github |
+| Example | Link | What it shows | Link to Code |
+| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
+| Interactive menu with render tracker | Main Demo↗ | Compare Zero‑UI vs. React side‑by‑side while toggling a menu. | Github |
+| React benchmark (10 000 nested nodes) | React 10k↗ | How long the traditional React render path takes. | Github |
| Zero‑UI benchmark (10 000 nested nodes) | Zero‑UI 10k↗ | Identical DOM, but powered by Zero‑UI's `data-*` switch. | Github |
---
diff --git a/examples/demo/package.json b/examples/demo/package.json
index 63e5f33..a97a8f6 100644
--- a/examples/demo/package.json
+++ b/examples/demo/package.json
@@ -33,4 +33,4 @@
"tailwindcss": "^4.1.10",
"typescript": "^5"
}
-}
\ No newline at end of file
+}
diff --git a/package.json b/package.json
index 164cf3b..95ecbc9 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,9 @@
"name": "react-zero-ui-monorepo",
"private": true,
"type": "module",
+ "engines": {
+ "node": ">=22"
+ },
"workspaces": [
"packages/*"
],
diff --git a/packages/core/__tests__/fixtures/next/app/layout.tsx b/packages/core/__tests__/fixtures/next/app/layout.tsx
index e7d8a25..07f1e2c 100644
--- a/packages/core/__tests__/fixtures/next/app/layout.tsx
+++ b/packages/core/__tests__/fixtures/next/app/layout.tsx
@@ -1,13 +1,15 @@
-import { bodyAttributes } from "@zero-ui/attributes";import './globals.css';
+import { bodyAttributes } from '@zero-ui/attributes';
+import './globals.css';
export default function RootLayout({ children }) {
- return (
-
-
+ return (
+
+
{children}
- );
-
-}
\ No newline at end of file
+
+ );
+}
diff --git a/packages/core/__tests__/helpers/loadCli.js b/packages/core/__tests__/helpers/loadCli.js
index 6e130ad..cd66d3b 100644
--- a/packages/core/__tests__/helpers/loadCli.js
+++ b/packages/core/__tests__/helpers/loadCli.js
@@ -4,7 +4,7 @@ import path from 'node:path';
export async function loadCliFromFixture(fixtureDir) {
const r = createRequire(path.join(fixtureDir, 'package.json'));
- const modulePath = r.resolve('../../../dist/cli/init.cjs'); // get the path
+ const modulePath = r.resolve('../../../dist/cli/init.js'); // get the path
const mod = r(modulePath); // actually require the module
console.log('[Global Setup] Loaded CLI from fixture:', modulePath);
// Return a wrapper function that changes directory before running CLI
@@ -13,14 +13,14 @@ export async function loadCliFromFixture(fixtureDir) {
try {
process.chdir(fixtureDir); // Change to fixture directory
- // The init.cjs exports a cli function, so call it
+ // The init.js exports a cli function, so call it
if (typeof mod === 'function') {
return await Promise.resolve(mod(args)); // run the CLI
} else if (typeof mod.default === 'function') {
return await Promise.resolve(mod.default(args)); // run the CLI (ESM default export)
} else {
- throw new Error('Could not find CLI function in init.cjs');
+ throw new Error('Could not find CLI function in init.js');
}
} finally {
process.chdir(originalCwd); // Always restore original directory
diff --git a/packages/core/__tests__/helpers/resetProjectState.js b/packages/core/__tests__/helpers/resetProjectState.js
index 2260d01..8ede516 100644
--- a/packages/core/__tests__/helpers/resetProjectState.js
+++ b/packages/core/__tests__/helpers/resetProjectState.js
@@ -2,7 +2,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { rmSync } from 'node:fs';
-import { overwriteFile } from './overwriteFile';
+import { overwriteFile } from './overwriteFile.js';
/**
* Reset everything the Zero-UI CLI generates inside a fixture.
diff --git a/packages/core/__tests__/unit/ast.test.cjs b/packages/core/__tests__/unit/ast.test.cjs
deleted file mode 100644
index 842a538..0000000
--- a/packages/core/__tests__/unit/ast.test.cjs
+++ /dev/null
@@ -1,243 +0,0 @@
-const { test } = require('node:test');
-const assert = require('node:assert');
-const fs = require('fs');
-const path = require('path');
-const os = require('os');
-const { performance } = require('node:perf_hooks');
-const { findAllSourceFiles } = require('../../dist/postcss/helpers.cjs');
-const { collectUseUIHooks, extractVariants } = require('../../dist/postcss/ast-parsing.cjs');
-
-const ComponentImports = readFile(path.join(__dirname, './fixtures/test-components.jsx'));
-const AllPatternsComponent = readFile(path.join(__dirname, './fixtures/ts-test-components.tsx'));
-
-const { parse } = require('@babel/parser');
-
-// a helper to read a file and return the content
-function readFile(path) {
- return fs.readFileSync(path, 'utf-8');
-}
-// Helper to create temp directory and run test
-async function runTest(files, callback) {
- const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zero-ui-test-ast'));
- const originalCwd = process.cwd();
-
- try {
- process.chdir(testDir);
-
- // Create test files
- for (const [filePath, content] of Object.entries(files)) {
- const dir = path.dirname(filePath);
- if (dir !== '.') {
- fs.mkdirSync(dir, { recursive: true });
- }
- fs.writeFileSync(filePath, content);
- }
-
- // Run assertions
- await callback();
- } finally {
- process.chdir(originalCwd);
-
- // Clean up any generated files in the package directory
- fs.rmSync(testDir, { recursive: true, force: true });
- }
-}
-
-test('findAllSourceFiles', async () => {
- await runTest(
- {
- 'src/components/Button.tsx': `
- import { Button } from '@zero-ui/core';
- export default Button;
- `,
- 'src/components/Button.jsx': `
- import { Button } from '@zero-ui/core';
- export default Button;
- `,
- },
- async () => {
- const sourceFiles = findAllSourceFiles();
- assert.ok(sourceFiles.length > 0);
- }
- );
-});
-
-test('collectUseUIHooks - basic functionality', async () => {
- const sourceCode = `
- import { useUI } from '@react-zero-ui/core';
-
- const Component = () => {
- const [theme, setTheme] = useUI('theme', 'light');
- const [size, setSize] = useUI('size', 'medium');
- return <>
-
-
- >;
- }
- `;
-
- const ast = parse(sourceCode, { sourceType: 'module', plugins: ['jsx', 'typescript'] });
- const setters = collectUseUIHooks(ast);
-
- assert.strictEqual(setters[0].stateKey, 'theme');
- assert.strictEqual(setters[0].initialValue, 'light');
- assert.strictEqual(setters[1].stateKey, 'size');
- assert.strictEqual(setters[1].initialValue, 'medium');
-});
-
-test('Extract Variant without setter', async () => {
- // Read fixture file for proper syntax highlighting
- await runTest(
- {
- 'src/app/Component.jsx': `
-export function ComponentSimple() {
- const [, setTheme] = useUI('theme', 'light');
- const [, setSize] = useUI('size', 'medium');
- return (
-
-
-
-
- );
-}
-`,
- },
-
- async () => {
- const variants = extractVariants('src/app/Component.jsx');
- assert.strictEqual(variants.length, 2);
- assert.strictEqual(variants.length, 2);
-
- assert.ok(variants.some((v) => v.key === 'theme' && ['light', 'dark', 'blue', 'purple'].every((c) => v.values.includes(c))));
-
- assert.ok(variants.some((v) => v.key === 'size' && ['medium', 'large'].every((c) => v.values.includes(c))));
- }
- );
-});
-
-test('Extract Variant with imports and throw error', async () => {
- await runTest({ 'src/app/Component.jsx': ComponentImports }, async () => {
- assert.throws(() => {
- collectUseUIHooks(parse(ComponentImports, { sourceType: 'module', plugins: ['jsx', 'typescript'] }), ComponentImports);
- // tests that the error message contains the correct text
- }, /const VARSLocal = VARS/);
- });
-});
-
-test('testKeyInitialValue', async () => {
- await runTest({ 'src/app/Component.jsx': AllPatternsComponent }, async () => {
- const setters = collectUseUIHooks(parse(AllPatternsComponent, { sourceType: 'module', plugins: ['jsx', 'typescript'] }), AllPatternsComponent);
- assert.strictEqual(setters[0].stateKey, 'theme');
- assert.strictEqual(setters[0].initialValue, 'light');
- assert.strictEqual(setters[1].stateKey, 'altTheme');
- assert.strictEqual(setters[1].initialValue, 'dark');
- assert.strictEqual(setters[2].stateKey, 'variant');
- assert.strictEqual(setters[2].initialValue, 'th-dark');
- assert.strictEqual(setters[3].stateKey, 'size');
- assert.strictEqual(setters[3].initialValue, 'lg');
- assert.strictEqual(setters[4].stateKey, 'mode');
- assert.strictEqual(setters[4].initialValue, 'auto');
- assert.strictEqual(setters[5].stateKey, 'color');
- assert.strictEqual(setters[5].initialValue, 'bg-blue');
- assert.strictEqual(setters[6].initialValue, 'th-dark');
- assert.strictEqual(setters[7].initialValue, 'th-blue');
- assert.strictEqual(setters[8].initialValue, 'th-blue-inverse');
- assert.strictEqual(setters[9].initialValue, 'blue-inverse');
- assert.strictEqual(setters[10].initialValue, 'blue-th-dark');
- assert.strictEqual(setters[11].initialValue, 'th-blue-th-dark');
- assert.strictEqual(setters[12].initialValue, 'th-light');
- assert.strictEqual(setters[13].initialValue, 'blue');
- });
-});
-
-test('conditional setterFn value', async () => {
- await runTest(
- {
- 'src/app/Component.jsx': `
-const COLORS = { primary: 'blue', secondary: 'green' } as const;
-const VARIANTS = { dark: \`th-\${DARK}\`, light: COLORS.primary } as const;
-
-const isMobile = false;
-
-function TestComponent() {
- const [theme, setTheme] = useUI('theme', 'light');
- const [variant, setVariant] = useUI('variant', 'th-light');
- const [variant2, setVariant2] = useUI('variant2', 'th-light');
-
-
- setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
- setVariant(isMobile ? VARIANTS.light : 'th-light');
- setVariant2(isMobile ? VARIANTS?.light : 'th-light');
-}
-`,
- },
- async () => {
- const variants = extractVariants('src/app/Component.jsx');
- assert.strictEqual(variants.length, 3);
- }
- );
-});
-
-test('cache performance', async () => {
- // Simple inline component with a few useUI hooks, no conflicting constants
- const simpleComponent = `
- import { useUI } from '@zero-ui/core';
- const DARK = 'dark' as const;
- const PREFIX = \`th-\${DARK}\` as const;
- const SIZES = { small: 'sm', large: 'lg' } as const;
- const MODES = ['auto', 'manual'] as const;
-
- const COLORS = { primary: 'blue', secondary: 'green' } as const;
- const VARIANTS = { dark: \`th-\${DARK}\`, light: COLORS.primary } as const;
- function TestComponent() {
- /* ① literal */
- const [theme, setTheme] = useUI('theme', 'light');
- /* ② identifier */
- const [altTheme, setAltTheme] = useUI('altTheme', DARK);
- /* ③ static template literal */
- const [variant, setVariant] = useUI('variant', PREFIX);
- /* ④ object-member */
- const [size, setSize] = useUI('size', SIZES.large);
- /* ⑤ array-index */
- const [mode, setMode] = useUI('mode', MODES[0]);
- /* ⑥ nested template + member */
- const [color, setColor] = useUI('color', \`bg-\${COLORS.primary}\`);
- /* ⑦ object-member */
- const [variant2, setVariant2] = useUI('variant', VARIANTS.dark);
-
-
-
- return setTheme('dark')}>Test
;
- }
- `;
-
- await runTest({ 'src/Component.jsx': simpleComponent }, async () => {
- const filePath = 'src/Component.jsx';
-
- console.log('=== FIRST CALL ===');
- const start1 = performance.now();
- const result1 = extractVariants(filePath);
- const firstCall = performance.now() - start1;
-
- console.log('=== SECOND CALL ===');
- const start2 = performance.now();
- const result2 = extractVariants(filePath);
- const secondCall = performance.now() - start2;
-
- console.log('=== THIRD CALL ===');
- const start3 = performance.now();
- const result3 = extractVariants(filePath);
- const thirdCall = performance.now() - start3;
-
- console.log(`First call: ${firstCall.toFixed(2)}ms`);
- console.log(`Second call: ${secondCall.toFixed(2)}ms`);
- console.log(`Third call: ${thirdCall.toFixed(2)}ms`);
-
- if (secondCall < firstCall) {
- console.log(`Speedup: ${(firstCall / secondCall).toFixed(1)}x faster`);
- }
-
- assert.strictEqual(result1, result2);
- assert.strictEqual(result2, result3);
- });
-});
diff --git a/packages/core/__tests__/unit/cli.test.cjs b/packages/core/__tests__/unit/cli.test.cjs
index 24d7912..f3d963e 100644
--- a/packages/core/__tests__/unit/cli.test.cjs
+++ b/packages/core/__tests__/unit/cli.test.cjs
@@ -56,7 +56,7 @@ function cleanupTestDir(testDir) {
function runCLIScript(targetDir, timeout = 30000) {
return new Promise((resolve, reject) => {
// Updated path to the correct CLI script location
- const binScript = path.resolve(__dirname, '../../../cli/bin.js');
+ const binScript = path.resolve(__dirname, '../../../cli/bin');
const child = spawn('node', [binScript, '.'], { cwd: targetDir, stdio: ['pipe', 'pipe', 'pipe'] });
@@ -296,7 +296,7 @@ export function TestComponent() {
fs.writeFileSync(path.join(componentDir, 'TestComponent.jsx'), testComponent);
// Import and run the library CLI directly
- const { runZeroUiInit } = require('../../dist/cli/postInstall.cjs');
+ const { runZeroUiInit } = require('../../dist/cli/postInstall');
// Mock console to capture output
const originalConsoleLog = console.log;
@@ -354,7 +354,7 @@ test('Library CLI handles errors gracefully', async () => {
};
try {
- const { runZeroUiInit } = require('../../dist/cli/postInstall.cjs');
+ const { runZeroUiInit } = require('../../dist/cli/postInstall');
// This should complete without errors in most cases
await runZeroUiInit();
@@ -523,7 +523,7 @@ export function Toggle() {
fs.writeFileSync(path.join(componentsDir, 'Toggle.jsx'), component2);
// Import and run the library CLI
- const { runZeroUiInit } = require('../../dist/cli/postInstall.cjs');
+ const { runZeroUiInit } = require('../../dist/cli/postInstall');
const originalConsoleLog = console.log;
const logMessages = [];
diff --git a/packages/core/__tests__/unit/index.test.cjs b/packages/core/__tests__/unit/index.test.cjs
index 89976ce..6b66404 100644
--- a/packages/core/__tests__/unit/index.test.cjs
+++ b/packages/core/__tests__/unit/index.test.cjs
@@ -7,7 +7,7 @@ const os = require('os');
// This file is the entry point for the react-zero-ui library, that uses postcss to trigger the build process
const plugin = require('../../dist/postcss/index.cjs');
-const { patchTsConfig, toKebabCase, patchPostcssConfig, patchViteConfig } = require('../../dist/postcss/helpers.cjs');
+const { patchTsConfig, toKebabCase, patchPostcssConfig, patchViteConfig } = require('../../dist/postcss/helpers.js');
function getAttrFile() {
return path.join(process.cwd(), '.zero-ui', 'attributes.js');
@@ -23,7 +23,7 @@ async function runTest(files, callback) {
// Clear the global file cache to prevent stale entries from previous tests
try {
- const astParsing = require('../../dist/postcss/ast-parsing.cjs');
+ const astParsing = require('../../dist/postcss/ast-parsing.js');
if (astParsing.clearCache) {
astParsing.clearCache();
}
diff --git a/packages/core/package.json b/packages/core/package.json
index 40ac158..1d4c0b0 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@react-zero-ui/core",
- "version": "0.2.6",
+ "version": "0.2.7",
"description": "Zero re-render, global UI state management for React",
"private": false,
"type": "module",
@@ -11,8 +11,7 @@
"files": [
"dist/**/*",
"README.md",
- "LICENSE",
- "!dist/postcss/coming-soon/**/*"
+ "LICENSE"
],
"exports": {
".": {
@@ -20,7 +19,7 @@
"import": "./dist/index.js"
},
"./postcss": {
- "types": "./dist/postcss/index.d.ts",
+ "types": "./dist/postcss/index.d.cts",
"require": "./dist/postcss/index.cjs"
},
"./vite": {
@@ -29,8 +28,8 @@
},
"./cli": {
"types": "./dist/cli/init.d.ts",
- "require": "./dist/cli/init.cjs",
- "import": "./dist/cli/init.cjs"
+ "require": "./dist/cli/init.js",
+ "import": "./dist/cli/init.js"
}
},
"scripts": {
@@ -40,7 +39,7 @@
"test:next": "playwright test -c __tests__/config/playwright.next.config.js",
"test:vite": "playwright test -c __tests__/config/playwright.vite.config.js",
"test:integration": "node --test __tests__/unit/index.test.cjs",
- "test:cli": "node --test __tests__/unit/cli.test.cjs",
+ "test:cli": "node --test __tests__/unit/*.test.cjs",
"test:all": "pnpm run test:vite && pnpm run test:next && pnpm run test:unit && pnpm run test:cli && pnpm run test:integration",
"test:unit": "tsx --test src/**/*.test.ts"
},
@@ -79,15 +78,15 @@
"tailwindcss": "^4.1.10"
},
"dependencies": {
+ "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.0",
"@babel/parser": "^7.28.0",
- "@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.0",
"fast-glob": "^3.3.3",
- "@babel/code-frame": "^7.27.1",
"lru-cache": "^11.1.0"
},
"devDependencies": {
+ "@babel/traverse": "^7.28.0",
"@playwright/test": "^1.54.0",
"@types/babel__code-frame": "^7.0.6",
"@types/babel__generator": "^7.27.0",
@@ -95,4 +94,4 @@
"@types/react": "^19.1.8",
"tsx": "^4.20.3"
}
-}
+}
\ No newline at end of file
diff --git a/packages/core/src/cli/init.cts b/packages/core/src/cli/init.ts
similarity index 62%
rename from packages/core/src/cli/init.cts
rename to packages/core/src/cli/init.ts
index 4d4b4e6..a26bf9a 100755
--- a/packages/core/src/cli/init.cts
+++ b/packages/core/src/cli/init.ts
@@ -1,13 +1,13 @@
#!/usr/bin/env node
-// src/cli/init.cts - single source of truth
+// src/cli/init.ts - single source of truth
-//import the actual implementation from postInstall.cjs
-const { runZeroUiInit } = require('./postInstall.cjs');
+//import the actual implementation from postInstall.ts
+import { runZeroUiInit } from './postInstall.js';
// Take command line arguments (defaulting to process.argv.slice(2) which are the args after node ) and pass them to runZeroUiInit
-async function cli(argv = process.argv.slice(2)) {
- return await runZeroUiInit(argv);
+async function cli() {
+ return await runZeroUiInit();
}
/* -------- CL I -------- */
diff --git a/packages/core/src/cli/postInstall.cts b/packages/core/src/cli/postInstall.ts
similarity index 83%
rename from packages/core/src/cli/postInstall.cts
rename to packages/core/src/cli/postInstall.ts
index d5f635f..e76114a 100644
--- a/packages/core/src/cli/postInstall.cts
+++ b/packages/core/src/cli/postInstall.ts
@@ -1,7 +1,7 @@
-// src/cli/postInstall.cts
-import { patchNextBodyTag } from '../postcss/ast-generating.cjs';
-import { generateAttributesFile, patchTsConfig, patchPostcssConfig, patchViteConfig, hasViteConfig } from '../postcss/helpers.cjs';
-import { processVariants } from '../postcss/ast-parsing.cjs';
+// src/cli/postInstall.ts
+import { patchNextBodyTag } from '../postcss/ast-generating.js';
+import { generateAttributesFile, patchTsConfig, patchPostcssConfig, patchViteConfig, hasViteConfig } from '../postcss/helpers.js';
+import { processVariants } from '../postcss/ast-parsing.js';
export async function runZeroUiInit() {
try {
diff --git a/packages/core/src/config.cts b/packages/core/src/config.ts
similarity index 100%
rename from packages/core/src/config.cts
rename to packages/core/src/config.ts
diff --git a/packages/core/src/postcss/ast-generating.test.ts b/packages/core/src/postcss/ast-generating.test.ts
index 9a5cbf7..c41617b 100644
--- a/packages/core/src/postcss/ast-generating.test.ts
+++ b/packages/core/src/postcss/ast-generating.test.ts
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert';
-import { readFile, runTest } from '../utilities.ts';
-import { parseAndUpdatePostcssConfig, parseAndUpdateViteConfig, parseJsonWithBabel, patchNextBodyTag } from './ast-generating.cts';
+import { readFile, runTest } from '../utilities.js';
+import { parseAndUpdatePostcssConfig, parseAndUpdateViteConfig, parseJsonWithBabel, patchNextBodyTag } from './ast-generating.js';
const zeroUiPlugin = '@react-zero-ui/core/postcss';
const zeroUiVitePlugin = '@react-zero-ui/core/vite';
diff --git a/packages/core/src/postcss/ast-generating.cts b/packages/core/src/postcss/ast-generating.ts
similarity index 98%
rename from packages/core/src/postcss/ast-generating.cts
rename to packages/core/src/postcss/ast-generating.ts
index ded967c..d41bfe3 100644
--- a/packages/core/src/postcss/ast-generating.cts
+++ b/packages/core/src/postcss/ast-generating.ts
@@ -1,12 +1,11 @@
import { parse, parseExpression, ParserOptions } from '@babel/parser';
-import * as babelTraverse from '@babel/traverse';
-import { NodePath } from '@babel/traverse';
import * as t from '@babel/types';
import { generate } from '@babel/generator';
-const traverse = (babelTraverse as any).default;
import * as fs from 'fs';
import fg from 'fast-glob';
-import { IGNORE_DIRS } from '../config.cjs';
+import { IGNORE_DIRS } from '../config.js';
+import { type NodePath } from '@babel/traverse';
+import traverse from './traverse.cjs';
const AST_CONFIG_OPTS: Partial = {
sourceType: 'unambiguous',
diff --git a/packages/core/src/postcss/ast-parsing.test.ts b/packages/core/src/postcss/ast-parsing.test.ts
index 4720731..414d7b3 100644
--- a/packages/core/src/postcss/ast-parsing.test.ts
+++ b/packages/core/src/postcss/ast-parsing.test.ts
@@ -1,8 +1,8 @@
import { test } from 'node:test';
import assert from 'node:assert';
-import { collectUseUIHooks, processVariants } from './ast-parsing.cts';
+import { collectUseUIHooks, processVariants } from './ast-parsing.js';
import { parse } from '@babel/parser';
-import { readFile, runTest } from '../utilities.ts';
+import { readFile, runTest } from '../utilities.js';
test('collectUseUIHooks should collect setters from a component', async () => {
await runTest(
@@ -51,3 +51,30 @@ return (
}
);
});
+
+test('collectUseUIHooks should resolve const-based args for useScopedUI', async () => {
+ await runTest(
+ {
+ 'app/tabs.tsx': `
+ import { useScopedUI } from '@react-zero-ui/core';
+
+ const KEY = 'tabs';
+ const DEFAULT = 'first';
+
+ export function Tabs() {
+ const [, setTab] = useScopedUI(KEY, DEFAULT);
+ return ;
+ }
+ `,
+ },
+ async () => {
+ const src = readFile('app/tabs.tsx');
+ const ast = parse(src, { sourceType: 'module', plugins: ['jsx', 'typescript'] });
+ const [meta] = collectUseUIHooks(ast, src);
+
+ assert.equal(meta.stateKey, 'tabs');
+ assert.equal(meta.initialValue, 'first');
+ assert.equal(meta.scope, 'scoped');
+ }
+ );
+});
diff --git a/packages/core/src/postcss/ast-parsing.cts b/packages/core/src/postcss/ast-parsing.ts
similarity index 91%
rename from packages/core/src/postcss/ast-parsing.cts
rename to packages/core/src/postcss/ast-parsing.ts
index 2cf60e7..30f11f2 100644
--- a/packages/core/src/postcss/ast-parsing.cts
+++ b/packages/core/src/postcss/ast-parsing.ts
@@ -1,17 +1,16 @@
// src/core/postcss/ast-parsing.cts
import os from 'os';
import { parse, ParserOptions } from '@babel/parser';
-import * as babelTraverse from '@babel/traverse';
-import { Binding, NodePath, Node } from '@babel/traverse';
import * as t from '@babel/types';
-import { CONFIG } from '../config.cjs';
+import { CONFIG } from '../config.js';
import * as fs from 'fs';
-import { literalFromNode, ResolveOpts } from './resolvers.cjs';
+import { literalFromNode, ResolveOpts } from './resolvers.js';
import { codeFrameColumns } from '@babel/code-frame';
-const traverse = (babelTraverse as any).default;
import { LRUCache as LRU } from 'lru-cache';
-import { scanVariantTokens } from './scanner.cjs';
-import { findAllSourceFiles, mapLimit, toKebabCase } from './helpers.cjs';
+import { scanVariantTokens } from './scanner.js';
+import { findAllSourceFiles, mapLimit, toKebabCase } from './helpers.js';
+import { Binding, NodePath, Node } from '@babel/traverse';
+import traverse from './traverse.cjs';
const PARSE_OPTS = (f: string): Partial => ({
sourceType: 'module',
@@ -28,6 +27,8 @@ export interface HookMeta {
stateKey: string;
/** Literal initial value as string, or `null` if non-literal */
initialValue: string | null;
+ /** Whether the hook is scoped to a specific element or global */
+ scope: 'global' | 'scoped';
}
/**
@@ -39,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.
*/
+
export function collectUseUIHooks(ast: t.File, sourceCode: string): HookMeta[] {
const hooks: HookMeta[] = [];
/* ---------- cache resolved literals per AST node ---------- */
@@ -57,12 +59,17 @@ 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;
- // match: const [ , setX ] = useUI(...)
- if (!t.isArrayPattern(id) || !t.isCallExpression(init) || !t.isIdentifier(init.callee, { name: CONFIG.HOOK_NAME })) return;
+ // a) must be const [ , setX ] = (…)
+ if (!t.isArrayPattern(id) || !t.isCallExpression(init)) return;
+
+ // b) callee must be one of our hook names
+ if (!(t.isIdentifier(init.callee) && ALL_HOOK_NAMES.has(init.callee.name))) return;
if (id.elements.length !== 2) {
throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] useUI() must destructure two values: [value, setterFn].`);
@@ -108,7 +115,9 @@ export function collectUseUIHooks(ast: t.File, sourceCode: string): HookMeta[] {
throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] Could not resolve binding for setter "${setterEl.name}".`);
}
- hooks.push({ binding, setterFnName: setterEl.name, stateKey, initialValue });
+ const scope: 'global' | 'scoped' = init.callee.name === CONFIG.HOOK_NAME ? 'global' : 'scoped';
+
+ hooks.push({ binding, setterFnName: setterEl.name, stateKey, initialValue, scope });
},
});
diff --git a/packages/core/src/postcss/helpers.test.ts b/packages/core/src/postcss/helpers.test.ts
index ce0f16f..3441ecf 100644
--- a/packages/core/src/postcss/helpers.test.ts
+++ b/packages/core/src/postcss/helpers.test.ts
@@ -9,11 +9,11 @@ import {
patchTsConfig,
patchViteConfig,
toKebabCase,
-} from './helpers.cts';
-import { readFile, runTest } from '../utilities.ts';
-import { CONFIG } from '../config.cts';
+} from './helpers.js';
+import { readFile, runTest } from '../utilities.js';
+import { CONFIG } from '../config.js';
import path from 'node:path';
-import { processVariants } from './ast-parsing.cts';
+import { processVariants } from './ast-parsing.js';
test('toKebabCase should convert a string to kebab case', () => {
assert.equal(toKebabCase('helloWorld'), 'hello-world');
diff --git a/packages/core/src/postcss/helpers.cts b/packages/core/src/postcss/helpers.ts
similarity index 98%
rename from packages/core/src/postcss/helpers.cts
rename to packages/core/src/postcss/helpers.ts
index 6d1f88e..ec6686e 100644
--- a/packages/core/src/postcss/helpers.cts
+++ b/packages/core/src/postcss/helpers.ts
@@ -1,10 +1,10 @@
-// src/postcss/helpers.cts
+// src/postcss/helpers.ts
import fs from 'fs';
import fg from 'fast-glob';
import path from 'path';
-import { CONFIG, IGNORE_DIRS } from '../config.cjs';
-import { parseJsonWithBabel, parseAndUpdatePostcssConfig, parseAndUpdateViteConfig } from './ast-generating.cjs';
-import { VariantData } from './ast-parsing.cjs';
+import { CONFIG, IGNORE_DIRS } from '../config.js';
+import { parseJsonWithBabel, parseAndUpdatePostcssConfig, parseAndUpdateViteConfig } from './ast-generating.js';
+import { VariantData } from './ast-parsing.js';
export interface ProcessVariantsResult {
/** Array of deduplicated and sorted variant data */
diff --git a/packages/core/src/postcss/index.cts b/packages/core/src/postcss/index.cts
index 1a088d4..99fc1be 100644
--- a/packages/core/src/postcss/index.cts
+++ b/packages/core/src/postcss/index.cts
@@ -2,10 +2,10 @@
/**
* @type {import('postcss').PluginCreator}
*/
-import { buildCss, generateAttributesFile, isZeroUiInitialized } from './helpers.cjs';
-import { runZeroUiInit } from '../cli/postInstall.cjs';
+import { buildCss, generateAttributesFile, isZeroUiInitialized } from './helpers';
+import { runZeroUiInit } from '../cli/postInstall.js';
import type { PluginCreator, Root } from 'postcss';
-import { processVariants } from './ast-parsing.cjs';
+import { processVariants } from './ast-parsing';
const plugin: PluginCreator = () => {
const DEV = process.env.NODE_ENV !== 'production';
diff --git a/packages/core/src/postcss/resolvers.test.ts b/packages/core/src/postcss/resolvers.test.ts
index a71c952..4b7d5e3 100644
--- a/packages/core/src/postcss/resolvers.test.ts
+++ b/packages/core/src/postcss/resolvers.test.ts
@@ -3,11 +3,9 @@ import assert from 'node:assert';
import { parse } from '@babel/parser';
import * as t from '@babel/types';
import { NodePath } from '@babel/traverse';
-import { literalFromNode, resolveLocalConstIdentifier, resolveTemplateLiteral, resolveMemberExpression, ResolveOpts } from './resolvers.cts';
-import { runTest } from '../utilities.ts';
-import _traverse from '@babel/traverse';
-
-const traverse = (_traverse as any).default || _traverse;
+import { literalFromNode, resolveLocalConstIdentifier, resolveTemplateLiteral, resolveMemberExpression, ResolveOpts } from './resolvers.js';
+import { runTest } from '../utilities.js';
+import traverse from './traverse.cjs';
/*
Test Coverage:
diff --git a/packages/core/src/postcss/resolvers.cts b/packages/core/src/postcss/resolvers.ts
similarity index 99%
rename from packages/core/src/postcss/resolvers.cts
rename to packages/core/src/postcss/resolvers.ts
index c65a851..242e6d5 100644
--- a/packages/core/src/postcss/resolvers.cts
+++ b/packages/core/src/postcss/resolvers.ts
@@ -1,6 +1,7 @@
+// src/postcss/resolvers.ts
import * as t from '@babel/types';
import { NodePath } from '@babel/traverse';
-import { throwCodeFrame } from './ast-parsing.cjs';
+import { throwCodeFrame } from './ast-parsing.js';
import { generate } from '@babel/generator';
export interface ResolveOpts {
throwOnFail?: boolean; // default false
diff --git a/packages/core/src/postcss/scanner.test.ts b/packages/core/src/postcss/scanner.test.ts
index 82c718b..aff13b4 100644
--- a/packages/core/src/postcss/scanner.test.ts
+++ b/packages/core/src/postcss/scanner.test.ts
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert';
-import { scanVariantTokens } from './scanner.cts';
+import { scanVariantTokens } from './scanner.js';
test('scanVariantTokens', () => {
const src = `
diff --git a/packages/core/src/postcss/scanner.cts b/packages/core/src/postcss/scanner.ts
similarity index 98%
rename from packages/core/src/postcss/scanner.cts
rename to packages/core/src/postcss/scanner.ts
index 0fb9ddf..e8d46f5 100644
--- a/packages/core/src/postcss/scanner.cts
+++ b/packages/core/src/postcss/scanner.ts
@@ -1,4 +1,4 @@
-// scanner.cts
+// scanner.ts
export function scanVariantTokens(src: string, keys: Set): Map> {
/* 1. bootstrap the output */
const out = new Map>();
diff --git a/packages/core/src/postcss/traverse.cjs b/packages/core/src/postcss/traverse.cjs
new file mode 100644
index 0000000..2e035f7
--- /dev/null
+++ b/packages/core/src/postcss/traverse.cjs
@@ -0,0 +1,5 @@
+// src/postcss/traverse.cjs
+
+// Always returns the callable function from @babel/traverse to fix Node 22 no longer applies synthetic-default interop.+
+const t = require('@babel/traverse');
+module.exports = t.default || t;
diff --git a/packages/core/src/postcss/traverse.d.ts b/packages/core/src/postcss/traverse.d.ts
new file mode 100644
index 0000000..932cd73
--- /dev/null
+++ b/packages/core/src/postcss/traverse.d.ts
@@ -0,0 +1,4 @@
+// src/postcss/traverse.d.ts
+import type traverse from '@babel/traverse';
+declare const t: typeof traverse;
+export default t;
diff --git a/packages/core/src/postcss/traverse.test.ts b/packages/core/src/postcss/traverse.test.ts
new file mode 100644
index 0000000..7bb02f0
--- /dev/null
+++ b/packages/core/src/postcss/traverse.test.ts
@@ -0,0 +1,7 @@
+import traverse from './traverse.cjs';
+import { test } from 'node:test';
+import assert from 'node:assert';
+
+test('babel traverse wrapper works', () => {
+ assert.strictEqual(typeof traverse, 'function');
+});
diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json
index 08de3ef..2ada250 100644
--- a/packages/core/tsconfig.build.json
+++ b/packages/core/tsconfig.build.json
@@ -1,20 +1,15 @@
+// tsconfig.build.json
{
"extends": "../../tsconfig.base.json",
/* — compile exactly one file — */
"include": ["src/**/*"],
- "exclude": ["src/postcss/coming-soon", "src/**/*.test.ts", "./utilities.ts"],
+ "exclude": ["src/**/*.test.ts", "./utilities.ts"],
/* — compiler output — */
"compilerOptions": {
- "target": "ES2020",
- "module": "NodeNext",
- "moduleResolution": "NodeNext",
"esModuleInterop": true,
- "allowSyntheticDefaultImports": true,
"rootDir": "./src", // keeps relative paths clean
"outDir": "./dist", // compiled JS → dist/
- "composite": false, // flip to true when we add references
- "incremental": true, // speeds up "one-file" rebuilds
- "strict": true, // enable all strict type-checking options
- "skipLibCheck": true // Hides all errors coming from node_modules
+ "noEmit": false,
+ "removeComments": true
}
}
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
index 2a1dacc..8ed1347 100644
--- a/packages/core/tsconfig.json
+++ b/packages/core/tsconfig.json
@@ -2,21 +2,11 @@
"extends": "../../tsconfig.base.json",
/* — compile exactly one file — */
"include": ["src/**/*"],
- "exclude": ["src/postcss/coming-soon", "node_modules"],
+ "exclude": ["node_modules"],
/* — compiler output — */
"compilerOptions": {
- "target": "ES2020",
- "module": "NodeNext",
- "moduleResolution": "NodeNext",
"esModuleInterop": true,
- "allowSyntheticDefaultImports": true,
- "allowImportingTsExtensions": true,
- "noEmit": true,
"rootDir": "./src", // keeps relative paths clean
- "outDir": "./dist", // compiled JS → dist/
- "composite": false, // flip to true when we add references
- "incremental": true, // speeds up "one-file" rebuilds
- "strict": true, // enable all strict type-checking options
- "skipLibCheck": true // Hides all errors coming from node_modules
+ "outDir": "./dist" // compiled JS → dist/
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f1ef00e..b51a03f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -114,9 +114,6 @@ importers:
'@babel/parser':
specifier: ^7.28.0
version: 7.28.0
- '@babel/traverse':
- specifier: ^7.28.0
- version: 7.28.0
'@babel/types':
specifier: ^7.28.0
version: 7.28.0
@@ -139,6 +136,9 @@ importers:
specifier: ^4.1.10
version: 4.1.10
devDependencies:
+ '@babel/traverse':
+ specifier: ^7.28.0
+ version: 7.28.0
'@playwright/test':
specifier: ^1.54.0
version: 1.54.0
@@ -236,10 +236,6 @@ packages:
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
- '@babel/traverse@7.27.4':
- resolution: {integrity: sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==}
- engines: {node: '>=6.9.0'}
-
'@babel/traverse@7.28.0':
resolution: {integrity: sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==}
engines: {node: '>=6.9.0'}
@@ -1673,10 +1669,6 @@ packages:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
- globals@11.12.0:
- resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
- engines: {node: '>=4'}
-
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
@@ -2889,18 +2881,6 @@ snapshots:
'@babel/parser': 7.28.0
'@babel/types': 7.28.0
- '@babel/traverse@7.27.4':
- dependencies:
- '@babel/code-frame': 7.27.1
- '@babel/generator': 7.28.0
- '@babel/parser': 7.28.0
- '@babel/template': 7.27.2
- '@babel/types': 7.28.0
- debug: 4.4.1
- globals: 11.12.0
- transitivePeerDependencies:
- - supports-color
-
'@babel/traverse@7.28.0':
dependencies:
'@babel/code-frame': 7.27.1
@@ -3346,7 +3326,7 @@ snapshots:
'@babel/code-frame': 7.27.1
'@babel/generator': 7.27.5
'@babel/parser': 7.27.5
- '@babel/traverse': 7.27.4
+ '@babel/traverse': 7.28.0
'@babel/types': 7.27.6
'@tailwindcss/postcss': 4.1.10
fast-glob: 3.3.3
@@ -4306,8 +4286,6 @@ snapshots:
once: 1.4.0
path-is-absolute: 1.0.1
- globals@11.12.0: {}
-
globals@14.0.0: {}
globalthis@1.0.4:
diff --git a/tsconfig.base.json b/tsconfig.base.json
index e7c9209..f6cd38c 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -1,20 +1,17 @@
+// tsconfig.base.json
{
// Shared defaults
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
- // "verbatimModuleSyntax": true,
- /* Gradual-migration flags */
"allowJs": true, // <- JS still builds
- "checkJs": false, // <- flip to true later for type-checking JS
+ "checkJs": true,
"strict": true, // <- tighten as we go
"declaration": true, // <- emit declaration files
- "emitDeclarationOnly": false, // <- emit declaration files only
"outDir": "dist", // <- output directory
"skipLibCheck": true, // <- skip type checking of declaration files in node_modules
/* Maps for monorepo imports like "@react-zero-ui/core" */
- "composite": true,
"rootDir": "./",
"forceConsistentCasingInFileNames": true
},