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 <> - - - - ); -} -`, - }, - - 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 },