From 1b700b2bfa47e495e6894c68be7e4e28bd9f65ea Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Mon, 23 Jun 2025 18:01:24 -0700 Subject: [PATCH 01/34] feat:added v0.1 scoped styles --- .gitignore | 6 +- README.md | 8 +- examples/demo/src/app/style/page.tsx | 902 ++++++++++++++++++ .../fixtures/next/.zero-ui/attributes.d.ts | 1 + .../fixtures/next/.zero-ui/attributes.js | 1 + .../fixtures/next/app/AutoThemeComponent.tsx | 68 ++ .../core/__tests__/fixtures/next/app/page.tsx | 69 +- .../__tests__/fixtures/next/app/test/page.tsx | 23 + packages/core/src/index.d.ts | 14 +- packages/core/src/index.js | 42 +- packages/core/src/postcss/helpers.cjs | 2 +- 11 files changed, 1053 insertions(+), 83 deletions(-) create mode 100644 examples/demo/src/app/style/page.tsx create mode 100644 packages/core/__tests__/fixtures/next/app/AutoThemeComponent.tsx create mode 100644 packages/core/__tests__/fixtures/next/app/test/page.tsx diff --git a/.gitignore b/.gitignore index 5d3307b..007c1de 100644 --- a/.gitignore +++ b/.gitignore @@ -19,11 +19,9 @@ yarn-error.log* # tarballs produced during local tests *.tgz -# local scratch files -t.py -todo.md +# local scratch file +internal.md # keep these files !next-env.d.ts - \ No newline at end of file diff --git a/README.md b/README.md index 46a14f1..b12d17f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # React Zero‑UI (Beta) -**Instant UI state updates. ZERO React re‑renders. <1 KB runtime.** +**Instant UI state updates. ZERO React re‑renders. Near ZERO runtime. <500 bytes** Pre‑render your UI once, flip a `data-*` attribute to update — that's it. @@ -156,18 +156,18 @@ Any `data-{key}="{value}"` pair becomes a variant: `{key}-{value}:`. - **Zero React re‑renders** for UI‑only state. - **Global setters** — call from any component or util. - **Tiny**: < 391 Byte gzipped runtime. -- **TypeScript‑first**. - **SSR‑friendly** (Next.js & Vite SSR). -- **Framework‑agnostic CSS** — generated classes work in plain HTML / Vue / Svelte as well with extra config. +- **Use from anywhere** — Consume with tailwind variants from anywhere. --- ## 🏗 Best Practices -1. **UI state only** → themes, layout toggles, feature flags. +1. **Global UI state only** → themes, layout toggles, feature flags. 2. **Business logic stays in React** → fetching, data mutation, etc. 3. **Kebab‑case keys** → e.g. `sidebar-open`. 4. **Provide defaults** to avoid Flash‑Of‑Unstyled‑Content. +5. **Avoid** for per-component logic or data. --- diff --git a/examples/demo/src/app/style/page.tsx b/examples/demo/src/app/style/page.tsx new file mode 100644 index 0000000..48c7548 --- /dev/null +++ b/examples/demo/src/app/style/page.tsx @@ -0,0 +1,902 @@ +'use client'; + +import { useUI } from '@austinserb/react-zero-ui'; + +const variants = [ + // Basic theme variants - background colors + 'theme-light:bg-gray-100', + 'theme-light:bg-gray-200', + 'theme-light:bg-gray-300', + 'theme-light:bg-gray-400', + 'theme-light:bg-gray-500', + 'theme-light:bg-gray-600', + 'theme-light:bg-gray-700', + 'theme-light:bg-gray-800', + 'theme-light:bg-gray-900', + 'theme-dark:bg-gray-100', + 'theme-dark:bg-gray-200', + 'theme-dark:bg-gray-300', + 'theme-dark:bg-gray-400', + 'theme-dark:bg-gray-500', + 'theme-dark:bg-gray-600', + 'theme-dark:bg-gray-700', + 'theme-dark:bg-gray-800', + 'theme-dark:bg-gray-900', + + // More background colors + 'theme-light:bg-red-100', + 'theme-light:bg-red-200', + 'theme-light:bg-red-300', + 'theme-light:bg-red-400', + 'theme-light:bg-red-500', + 'theme-light:bg-red-600', + 'theme-light:bg-red-700', + 'theme-light:bg-red-800', + 'theme-light:bg-red-900', + 'theme-light:bg-blue-100', + 'theme-light:bg-blue-200', + 'theme-light:bg-blue-300', + 'theme-light:bg-blue-400', + 'theme-light:bg-blue-500', + 'theme-light:bg-blue-600', + 'theme-light:bg-blue-700', + 'theme-light:bg-blue-800', + 'theme-light:bg-blue-900', + 'theme-light:bg-green-100', + 'theme-light:bg-green-200', + 'theme-light:bg-green-300', + 'theme-light:bg-green-400', + 'theme-light:bg-green-500', + 'theme-light:bg-green-600', + 'theme-light:bg-green-700', + 'theme-light:bg-green-800', + 'theme-light:bg-green-900', + 'theme-light:bg-yellow-100', + 'theme-light:bg-yellow-200', + 'theme-light:bg-yellow-300', + 'theme-light:bg-yellow-400', + 'theme-light:bg-yellow-500', + 'theme-light:bg-yellow-600', + 'theme-light:bg-yellow-700', + 'theme-light:bg-yellow-800', + 'theme-light:bg-yellow-900', + 'theme-light:bg-purple-100', + 'theme-light:bg-purple-200', + 'theme-light:bg-purple-300', + 'theme-light:bg-purple-400', + 'theme-light:bg-purple-500', + 'theme-light:bg-purple-600', + 'theme-light:bg-purple-700', + 'theme-light:bg-purple-800', + 'theme-light:bg-purple-900', + 'theme-light:bg-pink-100', + 'theme-light:bg-pink-200', + 'theme-light:bg-pink-300', + 'theme-light:bg-pink-400', + 'theme-light:bg-pink-500', + 'theme-light:bg-pink-600', + 'theme-light:bg-pink-700', + 'theme-light:bg-pink-800', + 'theme-light:bg-pink-900', + 'theme-light:bg-indigo-100', + 'theme-light:bg-indigo-200', + 'theme-light:bg-indigo-300', + 'theme-light:bg-indigo-400', + 'theme-light:bg-indigo-500', + 'theme-light:bg-indigo-600', + 'theme-light:bg-indigo-700', + 'theme-light:bg-indigo-800', + 'theme-light:bg-indigo-900', + + // Dark theme background colors + 'theme-dark:bg-red-100', + 'theme-dark:bg-red-200', + 'theme-dark:bg-red-300', + 'theme-dark:bg-red-400', + 'theme-dark:bg-red-500', + 'theme-dark:bg-red-600', + 'theme-dark:bg-red-700', + 'theme-dark:bg-red-800', + 'theme-dark:bg-red-900', + 'theme-dark:bg-blue-100', + 'theme-dark:bg-blue-200', + 'theme-dark:bg-blue-300', + 'theme-dark:bg-blue-400', + 'theme-dark:bg-blue-500', + 'theme-dark:bg-blue-600', + 'theme-dark:bg-blue-700', + 'theme-dark:bg-blue-800', + 'theme-dark:bg-blue-900', + 'theme-dark:bg-green-100', + 'theme-dark:bg-green-200', + 'theme-dark:bg-green-300', + 'theme-dark:bg-green-400', + 'theme-dark:bg-green-500', + 'theme-dark:bg-green-600', + 'theme-dark:bg-green-700', + 'theme-dark:bg-green-800', + 'theme-dark:bg-green-900', + + // Text colors + 'theme-light:text-gray-100', + 'theme-light:text-gray-200', + 'theme-light:text-gray-300', + 'theme-light:text-gray-400', + 'theme-light:text-gray-500', + 'theme-light:text-gray-600', + 'theme-light:text-gray-700', + 'theme-light:text-gray-800', + 'theme-light:text-gray-900', + 'theme-light:text-red-100', + 'theme-light:text-red-200', + 'theme-light:text-red-300', + 'theme-light:text-red-400', + 'theme-light:text-red-500', + 'theme-light:text-red-600', + 'theme-light:text-red-700', + 'theme-light:text-red-800', + 'theme-light:text-red-900', + 'theme-light:text-blue-100', + 'theme-light:text-blue-200', + 'theme-light:text-blue-300', + 'theme-light:text-blue-400', + 'theme-light:text-blue-500', + 'theme-light:text-blue-600', + 'theme-light:text-blue-700', + 'theme-light:text-blue-800', + 'theme-light:text-blue-900', + 'theme-dark:text-gray-100', + 'theme-dark:text-gray-200', + 'theme-dark:text-gray-300', + 'theme-dark:text-gray-400', + 'theme-dark:text-gray-500', + 'theme-dark:text-red-100', + 'theme-dark:text-red-200', + 'theme-dark:text-red-300', + 'theme-dark:text-red-400', + 'theme-dark:text-red-500', + 'theme-dark:text-blue-100', + 'theme-dark:text-blue-200', + 'theme-dark:text-blue-300', + 'theme-dark:text-blue-400', + 'theme-dark:text-blue-500', + + // Border colors + 'theme-light:border-gray-100', + 'theme-light:border-gray-200', + 'theme-light:border-gray-300', + 'theme-light:border-gray-400', + 'theme-light:border-gray-500', + 'theme-light:border-red-100', + 'theme-light:border-red-200', + 'theme-light:border-red-300', + 'theme-light:border-red-400', + 'theme-light:border-red-500', + 'theme-light:border-blue-100', + 'theme-light:border-blue-200', + 'theme-light:border-blue-300', + 'theme-light:border-blue-400', + 'theme-light:border-blue-500', + 'theme-dark:border-gray-100', + 'theme-dark:border-gray-200', + 'theme-dark:border-gray-300', + 'theme-dark:border-gray-400', + 'theme-dark:border-gray-500', + 'theme-dark:border-red-100', + 'theme-dark:border-red-200', + 'theme-dark:border-red-300', + 'theme-dark:border-red-400', + 'theme-dark:border-red-500', + + // Responsive variants with theme + 'sm:theme-light:bg-gray-100', + 'sm:theme-light:bg-gray-200', + 'sm:theme-light:bg-gray-300', + 'sm:theme-light:bg-gray-400', + 'sm:theme-light:bg-gray-500', + 'sm:theme-light:bg-red-100', + 'sm:theme-light:bg-red-200', + 'sm:theme-light:bg-red-300', + 'sm:theme-light:bg-blue-100', + 'sm:theme-light:bg-blue-200', + 'sm:theme-light:bg-blue-300', + 'sm:theme-dark:bg-gray-100', + 'sm:theme-dark:bg-gray-200', + 'sm:theme-dark:bg-gray-300', + 'sm:theme-dark:bg-red-100', + 'sm:theme-dark:bg-red-200', + 'sm:theme-dark:bg-blue-100', + 'sm:theme-dark:bg-blue-200', + + 'md:theme-light:bg-gray-100', + 'md:theme-light:bg-gray-200', + 'md:theme-light:bg-gray-300', + 'md:theme-light:bg-gray-400', + 'md:theme-light:bg-gray-500', + 'md:theme-light:bg-red-100', + 'md:theme-light:bg-red-200', + 'md:theme-light:bg-red-300', + 'md:theme-light:bg-blue-100', + 'md:theme-light:bg-blue-200', + 'md:theme-light:bg-blue-300', + 'md:theme-dark:bg-gray-100', + 'md:theme-dark:bg-gray-200', + 'md:theme-dark:bg-gray-300', + 'md:theme-dark:bg-red-100', + 'md:theme-dark:bg-red-200', + 'md:theme-dark:bg-blue-100', + + 'lg:theme-light:bg-gray-100', + 'lg:theme-light:bg-gray-200', + 'lg:theme-light:bg-gray-300', + 'lg:theme-light:bg-gray-400', + 'lg:theme-light:bg-gray-500', + 'lg:theme-light:bg-red-100', + 'lg:theme-light:bg-red-200', + 'lg:theme-light:bg-red-300', + 'lg:theme-light:bg-blue-100', + 'lg:theme-light:bg-blue-200', + 'lg:theme-dark:bg-gray-100', + 'lg:theme-dark:bg-gray-200', + 'lg:theme-dark:bg-gray-300', + 'lg:theme-dark:bg-red-100', + 'lg:theme-dark:bg-red-200', + + 'xl:theme-light:bg-gray-100', + 'xl:theme-light:bg-gray-200', + 'xl:theme-light:bg-gray-300', + 'xl:theme-light:bg-gray-400', + 'xl:theme-light:bg-gray-500', + 'xl:theme-light:bg-red-100', + 'xl:theme-light:bg-red-200', + 'xl:theme-light:bg-blue-100', + 'xl:theme-light:bg-blue-200', + 'xl:theme-dark:bg-gray-100', + 'xl:theme-dark:bg-gray-200', + 'xl:theme-dark:bg-red-100', + 'xl:theme-dark:bg-red-200', + + '2xl:theme-light:bg-gray-100', + '2xl:theme-light:bg-gray-200', + '2xl:theme-light:bg-gray-300', + '2xl:theme-light:bg-gray-400', + '2xl:theme-light:bg-gray-500', + '2xl:theme-light:bg-red-100', + '2xl:theme-light:bg-red-200', + '2xl:theme-dark:bg-gray-100', + '2xl:theme-dark:bg-gray-200', + + // Accent color variants + 'accent-violet:bg-violet-100', + 'accent-violet:bg-violet-200', + 'accent-violet:bg-violet-300', + 'accent-violet:bg-violet-400', + 'accent-violet:bg-violet-500', + 'accent-violet:bg-violet-600', + 'accent-violet:bg-violet-700', + 'accent-violet:bg-violet-800', + 'accent-violet:bg-violet-900', + 'accent-violet:text-violet-100', + 'accent-violet:text-violet-200', + 'accent-violet:text-violet-300', + 'accent-violet:text-violet-400', + 'accent-violet:text-violet-500', + 'accent-violet:text-violet-600', + 'accent-violet:text-violet-700', + 'accent-violet:text-violet-800', + 'accent-violet:text-violet-900', + 'accent-violet:border-violet-100', + 'accent-violet:border-violet-200', + 'accent-violet:border-violet-300', + 'accent-violet:border-violet-400', + 'accent-violet:border-violet-500', + 'accent-violet:border-violet-600', + 'accent-violet:border-violet-700', + 'accent-violet:border-violet-800', + 'accent-violet:border-violet-900', + + 'accent-emerald:bg-emerald-100', + 'accent-emerald:bg-emerald-200', + 'accent-emerald:bg-emerald-300', + 'accent-emerald:bg-emerald-400', + 'accent-emerald:bg-emerald-500', + 'accent-emerald:bg-emerald-600', + 'accent-emerald:bg-emerald-700', + 'accent-emerald:bg-emerald-800', + 'accent-emerald:bg-emerald-900', + 'accent-emerald:text-emerald-100', + 'accent-emerald:text-emerald-200', + 'accent-emerald:text-emerald-300', + 'accent-emerald:text-emerald-400', + 'accent-emerald:text-emerald-500', + 'accent-emerald:text-emerald-600', + 'accent-emerald:text-emerald-700', + 'accent-emerald:text-emerald-800', + 'accent-emerald:text-emerald-900', + 'accent-emerald:border-emerald-100', + 'accent-emerald:border-emerald-200', + 'accent-emerald:border-emerald-300', + 'accent-emerald:border-emerald-400', + 'accent-emerald:border-emerald-500', + 'accent-emerald:border-emerald-600', + 'accent-emerald:border-emerald-700', + 'accent-emerald:border-emerald-800', + 'accent-emerald:border-emerald-900', + + 'accent-amber:bg-amber-100', + 'accent-amber:bg-amber-200', + 'accent-amber:bg-amber-300', + 'accent-amber:bg-amber-400', + 'accent-amber:bg-amber-500', + 'accent-amber:bg-amber-600', + 'accent-amber:bg-amber-700', + 'accent-amber:bg-amber-800', + 'accent-amber:bg-amber-900', + 'accent-amber:text-amber-100', + 'accent-amber:text-amber-200', + 'accent-amber:text-amber-300', + 'accent-amber:text-amber-400', + 'accent-amber:text-amber-500', + 'accent-amber:text-amber-600', + 'accent-amber:text-amber-700', + 'accent-amber:text-amber-800', + 'accent-amber:text-amber-900', + 'accent-amber:border-amber-100', + 'accent-amber:border-amber-200', + 'accent-amber:border-amber-300', + 'accent-amber:border-amber-400', + 'accent-amber:border-amber-500', + 'accent-amber:border-amber-600', + 'accent-amber:border-amber-700', + 'accent-amber:border-amber-800', + 'accent-amber:border-amber-900', + + // Combined theme and accent variants + 'theme-light:accent-violet:bg-gray-100', + 'theme-light:accent-violet:bg-gray-200', + 'theme-light:accent-violet:bg-gray-300', + 'theme-light:accent-violet:bg-violet-100', + 'theme-light:accent-violet:bg-violet-200', + 'theme-light:accent-violet:bg-violet-300', + 'theme-light:accent-violet:text-gray-100', + 'theme-light:accent-violet:text-gray-200', + 'theme-light:accent-violet:text-violet-100', + 'theme-light:accent-violet:text-violet-200', + 'theme-light:accent-violet:border-gray-100', + 'theme-light:accent-violet:border-violet-100', + 'theme-light:accent-emerald:bg-gray-100', + 'theme-light:accent-emerald:bg-gray-200', + 'theme-light:accent-emerald:bg-emerald-100', + 'theme-light:accent-emerald:bg-emerald-200', + 'theme-light:accent-emerald:text-gray-100', + 'theme-light:accent-emerald:text-emerald-100', + 'theme-light:accent-emerald:border-gray-100', + 'theme-light:accent-emerald:border-emerald-100', + 'theme-light:accent-amber:bg-gray-100', + 'theme-light:accent-amber:bg-gray-200', + 'theme-light:accent-amber:bg-amber-100', + 'theme-light:accent-amber:bg-amber-200', + 'theme-light:accent-amber:text-gray-100', + 'theme-light:accent-amber:text-amber-100', + 'theme-light:accent-amber:border-gray-100', + 'theme-light:accent-amber:border-amber-100', + + 'theme-dark:accent-violet:bg-gray-100', + 'theme-dark:accent-violet:bg-gray-200', + 'theme-dark:accent-violet:bg-violet-100', + 'theme-dark:accent-violet:bg-violet-200', + 'theme-dark:accent-violet:text-gray-100', + 'theme-dark:accent-violet:text-violet-100', + 'theme-dark:accent-violet:border-gray-100', + 'theme-dark:accent-violet:border-violet-100', + 'theme-dark:accent-emerald:bg-gray-100', + 'theme-dark:accent-emerald:bg-emerald-100', + 'theme-dark:accent-emerald:text-gray-100', + 'theme-dark:accent-emerald:text-emerald-100', + 'theme-dark:accent-emerald:border-gray-100', + 'theme-dark:accent-emerald:border-emerald-100', + 'theme-dark:accent-amber:bg-gray-100', + 'theme-dark:accent-amber:bg-amber-100', + 'theme-dark:accent-amber:text-gray-100', + 'theme-dark:accent-amber:text-amber-100', + 'theme-dark:accent-amber:border-gray-100', + 'theme-dark:accent-amber:border-amber-100', + + // Responsive + theme + accent combinations + 'sm:theme-light:accent-violet:bg-gray-100', + 'sm:theme-light:accent-violet:bg-violet-100', + 'sm:theme-light:accent-violet:text-gray-100', + 'sm:theme-light:accent-violet:text-violet-100', + 'sm:theme-light:accent-violet:border-gray-100', + 'sm:theme-light:accent-violet:border-violet-100', + 'sm:theme-light:accent-emerald:bg-gray-100', + 'sm:theme-light:accent-emerald:bg-emerald-100', + 'sm:theme-light:accent-emerald:text-gray-100', + 'sm:theme-light:accent-emerald:text-emerald-100', + 'sm:theme-light:accent-emerald:border-gray-100', + 'sm:theme-light:accent-emerald:border-emerald-100', + 'sm:theme-light:accent-amber:bg-gray-100', + 'sm:theme-light:accent-amber:bg-amber-100', + 'sm:theme-light:accent-amber:text-gray-100', + 'sm:theme-light:accent-amber:text-amber-100', + 'sm:theme-light:accent-amber:border-gray-100', + 'sm:theme-light:accent-amber:border-amber-100', + 'sm:theme-dark:accent-violet:bg-gray-100', + 'sm:theme-dark:accent-violet:bg-violet-100', + 'sm:theme-dark:accent-emerald:bg-gray-100', + 'sm:theme-dark:accent-emerald:bg-emerald-100', + 'sm:theme-dark:accent-amber:bg-gray-100', + 'sm:theme-dark:accent-amber:bg-amber-100', + + 'md:theme-light:accent-violet:bg-gray-100', + 'md:theme-light:accent-violet:bg-violet-100', + 'md:theme-light:accent-emerald:bg-gray-100', + 'md:theme-light:accent-emerald:bg-emerald-100', + 'md:theme-light:accent-amber:bg-gray-100', + 'md:theme-light:accent-amber:bg-amber-100', + 'md:theme-dark:accent-violet:bg-gray-100', + 'md:theme-dark:accent-violet:bg-violet-100', + 'md:theme-dark:accent-emerald:bg-gray-100', + 'md:theme-dark:accent-emerald:bg-emerald-100', + 'md:theme-dark:accent-amber:bg-gray-100', + 'md:theme-dark:accent-amber:bg-amber-100', + + 'lg:theme-light:accent-violet:bg-gray-100', + 'lg:theme-light:accent-violet:bg-violet-100', + 'lg:theme-light:accent-emerald:bg-gray-100', + 'lg:theme-light:accent-emerald:bg-emerald-100', + 'lg:theme-light:accent-amber:bg-gray-100', + 'lg:theme-light:accent-amber:bg-amber-100', + 'lg:theme-dark:accent-violet:bg-gray-100', + 'lg:theme-dark:accent-violet:bg-violet-100', + 'lg:theme-dark:accent-emerald:bg-gray-100', + 'lg:theme-dark:accent-emerald:bg-emerald-100', + 'lg:theme-dark:accent-amber:bg-gray-100', + 'lg:theme-dark:accent-amber:bg-amber-100', + + 'xl:theme-light:accent-violet:bg-gray-100', + 'xl:theme-light:accent-violet:bg-violet-100', + 'xl:theme-light:accent-emerald:bg-gray-100', + 'xl:theme-light:accent-emerald:bg-emerald-100', + 'xl:theme-light:accent-amber:bg-gray-100', + 'xl:theme-light:accent-amber:bg-amber-100', + 'xl:theme-dark:accent-violet:bg-gray-100', + 'xl:theme-dark:accent-violet:bg-violet-100', + 'xl:theme-dark:accent-emerald:bg-gray-100', + 'xl:theme-dark:accent-emerald:bg-emerald-100', + 'xl:theme-dark:accent-amber:bg-gray-100', + 'xl:theme-dark:accent-amber:bg-amber-100', + + '2xl:theme-light:accent-violet:bg-gray-100', + '2xl:theme-light:accent-violet:bg-violet-100', + '2xl:theme-light:accent-emerald:bg-gray-100', + '2xl:theme-light:accent-emerald:bg-emerald-100', + '2xl:theme-light:accent-amber:bg-gray-100', + '2xl:theme-light:accent-amber:bg-amber-100', + '2xl:theme-dark:accent-violet:bg-gray-100', + '2xl:theme-dark:accent-violet:bg-violet-100', + '2xl:theme-dark:accent-emerald:bg-gray-100', + '2xl:theme-dark:accent-emerald:bg-emerald-100', + '2xl:theme-dark:accent-amber:bg-gray-100', + '2xl:theme-dark:accent-amber:bg-amber-100', + + // State variants with hover, focus, active + 'hover:theme-light:bg-gray-100', + 'hover:theme-light:bg-gray-200', + 'hover:theme-light:bg-gray-300', + 'hover:theme-light:bg-red-100', + 'hover:theme-light:bg-red-200', + 'hover:theme-light:bg-blue-100', + 'hover:theme-light:bg-blue-200', + 'hover:theme-dark:bg-gray-100', + 'hover:theme-dark:bg-gray-200', + 'hover:theme-dark:bg-red-100', + 'hover:theme-dark:bg-blue-100', + 'focus:theme-light:bg-gray-100', + 'focus:theme-light:bg-gray-200', + 'focus:theme-light:bg-red-100', + 'focus:theme-light:bg-blue-100', + 'focus:theme-dark:bg-gray-100', + 'focus:theme-dark:bg-red-100', + 'active:theme-light:bg-gray-100', + 'active:theme-light:bg-gray-200', + 'active:theme-light:bg-red-100', + 'active:theme-dark:bg-gray-100', + + // Responsive + hover combinations + 'sm:hover:theme-light:bg-gray-100', + 'sm:hover:theme-light:bg-gray-200', + 'sm:hover:theme-light:bg-red-100', + 'sm:hover:theme-dark:bg-gray-100', + 'md:hover:theme-light:bg-gray-100', + 'md:hover:theme-light:bg-red-100', + 'lg:hover:theme-light:bg-gray-100', + 'xl:hover:theme-light:bg-gray-100', + + // Spacing variants + 'theme-light:p-1', + 'theme-light:p-2', + 'theme-light:p-3', + 'theme-light:p-4', + 'theme-light:p-5', + 'theme-light:p-6', + 'theme-light:p-8', + 'theme-light:p-10', + 'theme-light:p-12', + 'theme-light:p-16', + 'theme-light:p-20', + 'theme-light:p-24', + 'theme-dark:p-1', + 'theme-dark:p-2', + 'theme-dark:p-3', + 'theme-dark:p-4', + 'theme-dark:p-5', + 'theme-dark:p-6', + 'theme-dark:p-8', + 'theme-dark:p-10', + 'theme-dark:p-12', + 'theme-dark:p-16', + 'theme-dark:p-20', + 'theme-dark:p-24', + + 'theme-light:m-1', + 'theme-light:m-2', + 'theme-light:m-3', + 'theme-light:m-4', + 'theme-light:m-5', + 'theme-light:m-6', + 'theme-light:m-8', + 'theme-light:m-10', + 'theme-light:m-12', + 'theme-light:m-16', + 'theme-light:m-20', + 'theme-light:m-24', + 'theme-dark:m-1', + 'theme-dark:m-2', + 'theme-dark:m-3', + 'theme-dark:m-4', + 'theme-dark:m-5', + 'theme-dark:m-6', + 'theme-dark:m-8', + 'theme-dark:m-10', + 'theme-dark:m-12', + 'theme-dark:m-16', + 'theme-dark:m-20', + 'theme-dark:m-24', + + // Width and height variants + 'theme-light:w-1', + 'theme-light:w-2', + 'theme-light:w-3', + 'theme-light:w-4', + 'theme-light:w-5', + 'theme-light:w-6', + 'theme-light:w-8', + 'theme-light:w-10', + 'theme-light:w-12', + 'theme-light:w-16', + 'theme-light:w-20', + 'theme-light:w-24', + 'theme-light:w-32', + 'theme-light:w-40', + 'theme-light:w-48', + 'theme-light:w-56', + 'theme-light:w-64', + 'theme-light:w-72', + 'theme-light:w-80', + 'theme-light:w-96', + 'theme-light:w-auto', + 'theme-light:w-full', + 'theme-light:w-screen', + 'theme-dark:w-1', + 'theme-dark:w-2', + 'theme-dark:w-3', + 'theme-dark:w-4', + 'theme-dark:w-5', + 'theme-dark:w-6', + 'theme-dark:w-8', + 'theme-dark:w-10', + 'theme-dark:w-12', + 'theme-dark:w-16', + 'theme-dark:w-20', + 'theme-dark:w-24', + 'theme-dark:w-32', + 'theme-dark:w-40', + 'theme-dark:w-48', + 'theme-dark:w-56', + 'theme-dark:w-64', + 'theme-dark:w-72', + 'theme-dark:w-80', + 'theme-dark:w-96', + + 'theme-light:h-1', + 'theme-light:h-2', + 'theme-light:h-3', + 'theme-light:h-4', + 'theme-light:h-5', + 'theme-light:h-6', + 'theme-light:h-8', + 'theme-light:h-10', + 'theme-light:h-12', + 'theme-light:h-16', + 'theme-light:h-20', + 'theme-light:h-24', + 'theme-light:h-32', + 'theme-light:h-40', + 'theme-light:h-48', + 'theme-light:h-56', + 'theme-light:h-64', + 'theme-light:h-72', + 'theme-light:h-80', + 'theme-light:h-96', + 'theme-light:h-auto', + 'theme-light:h-full', + 'theme-light:h-screen', + 'theme-dark:h-1', + 'theme-dark:h-2', + 'theme-dark:h-3', + 'theme-dark:h-4', + 'theme-dark:h-5', + 'theme-dark:h-6', + 'theme-dark:h-8', + 'theme-dark:h-10', + 'theme-dark:h-12', + 'theme-dark:h-16', + 'theme-dark:h-20', + 'theme-dark:h-24', + 'theme-dark:h-32', + 'theme-dark:h-40', + 'theme-dark:h-48', + 'theme-dark:h-56', + 'theme-dark:h-64', + 'theme-dark:h-72', + 'theme-dark:h-80', + 'theme-dark:h-96', + + // Typography variants + 'theme-light:text-xs', + 'theme-light:text-sm', + 'theme-light:text-base', + 'theme-light:text-lg', + 'theme-light:text-xl', + 'theme-light:text-2xl', + 'theme-light:text-3xl', + 'theme-light:text-4xl', + 'theme-light:text-5xl', + 'theme-light:text-6xl', + 'theme-light:text-7xl', + 'theme-light:text-8xl', + 'theme-light:text-9xl', + 'theme-dark:text-xs', + 'theme-dark:text-sm', + 'theme-dark:text-base', + 'theme-dark:text-lg', + 'theme-dark:text-xl', + 'theme-dark:text-2xl', + 'theme-dark:text-3xl', + 'theme-dark:text-4xl', + 'theme-dark:text-5xl', + 'theme-dark:text-6xl', + + 'theme-light:font-thin', + 'theme-light:font-extralight', + 'theme-light:font-light', + 'theme-light:font-normal', + 'theme-light:font-medium', + 'theme-light:font-semibold', + 'theme-light:font-bold', + 'theme-light:font-extrabold', + 'theme-light:font-black', + 'theme-dark:font-thin', + 'theme-dark:font-extralight', + 'theme-dark:font-light', + 'theme-dark:font-normal', + 'theme-dark:font-medium', + 'theme-dark:font-semibold', + 'theme-dark:font-bold', + 'theme-dark:font-extrabold', + 'theme-dark:font-black', + + // Border variants + 'theme-light:border-0', + 'theme-light:border', + 'theme-light:border-2', + 'theme-light:border-4', + 'theme-light:border-8', + 'theme-light:border-t', + 'theme-light:border-r', + 'theme-light:border-b', + 'theme-light:border-l', + 'theme-light:border-t-0', + 'theme-light:border-r-0', + 'theme-light:border-b-0', + 'theme-light:border-l-0', + 'theme-dark:border-0', + 'theme-dark:border', + 'theme-dark:border-2', + 'theme-dark:border-4', + 'theme-dark:border-8', + 'theme-dark:border-t', + 'theme-dark:border-r', + 'theme-dark:border-b', + 'theme-dark:border-l', + + // Rounded variants + 'theme-light:rounded-none', + 'theme-light:rounded-sm', + 'theme-light:rounded', + 'theme-light:rounded-md', + 'theme-light:rounded-lg', + 'theme-light:rounded-xl', + 'theme-light:rounded-2xl', + 'theme-light:rounded-3xl', + 'theme-light:rounded-full', + 'theme-dark:rounded-none', + 'theme-dark:rounded-sm', + 'theme-dark:rounded', + 'theme-dark:rounded-md', + 'theme-dark:rounded-lg', + 'theme-dark:rounded-xl', + 'theme-dark:rounded-2xl', + 'theme-dark:rounded-3xl', + 'theme-dark:rounded-full', + + // Shadow variants + 'theme-light:shadow-sm', + 'theme-light:shadow', + 'theme-light:shadow-md', + 'theme-light:shadow-lg', + 'theme-light:shadow-xl', + 'theme-light:shadow-2xl', + 'theme-light:shadow-inner', + 'theme-light:shadow-none', + 'theme-dark:shadow-sm', + 'theme-dark:shadow', + 'theme-dark:shadow-md', + 'theme-dark:shadow-lg', + 'theme-dark:shadow-xl', + 'theme-dark:shadow-2xl', + 'theme-dark:shadow-inner', + 'theme-dark:shadow-none', + + // Complex combinations for maximum CSS generation + 'sm:hover:focus:theme-light:accent-violet:bg-red-500', + 'md:hover:focus:theme-light:accent-emerald:bg-blue-500', + 'lg:hover:focus:theme-dark:accent-amber:bg-green-500', + 'xl:hover:active:theme-light:accent-violet:text-purple-500', + '2xl:focus:active:theme-dark:accent-emerald:border-yellow-500', + 'sm:md:theme-light:accent-violet:bg-gradient-to-r', + 'lg:xl:theme-dark:accent-amber:from-red-500', + 'hover:focus:active:theme-light:accent-emerald:to-blue-500', + + // Absolutely insane complex combinations + 'sm:md:lg:hover:focus:active:theme-light:theme-dark:accent-violet:accent-emerald:bg-red-500', + 'md:lg:xl:hover:active:focus:theme-dark:theme-light:accent-amber:accent-violet:text-blue-900', + 'lg:xl:2xl:focus:hover:active:theme-light:theme-dark:accent-emerald:accent-amber:border-purple-700', + 'sm:lg:2xl:active:hover:focus:theme-dark:theme-light:accent-violet:accent-emerald:bg-gradient-to-br', + 'md:xl:hover:focus:active:disabled:theme-light:theme-dark:accent-amber:accent-violet:from-red-500', + 'lg:2xl:focus:active:hover:checked:theme-dark:theme-light:accent-emerald:accent-amber:to-blue-800', + 'sm:md:xl:hover:active:focus:visited:theme-light:theme-dark:accent-violet:accent-emerald:via-green-600', + 'md:lg:2xl:active:focus:hover:invalid:theme-dark:theme-light:accent-amber:accent-violet:shadow-2xl', + 'sm:xl:2xl:hover:focus:active:required:theme-light:theme-dark:accent-emerald:accent-amber:rounded-full', + 'lg:xl:focus:hover:active:optional:theme-dark:theme-light:accent-violet:accent-emerald:border-dashed', + + // Multiple breakpoint chaos + 'sm:md:lg:xl:2xl:theme-light:accent-violet:bg-red-100', + 'md:lg:xl:2xl:theme-dark:accent-emerald:bg-blue-200', + 'sm:lg:xl:2xl:theme-light:accent-amber:bg-green-300', + 'sm:md:xl:2xl:theme-dark:accent-violet:bg-yellow-400', + 'sm:md:lg:2xl:theme-light:accent-emerald:bg-purple-500', + 'sm:md:lg:xl:theme-dark:accent-amber:bg-pink-600', + + // State combination madness + 'hover:focus:active:disabled:checked:theme-light:accent-violet:bg-red-500', + 'focus:active:hover:invalid:required:theme-dark:accent-emerald:text-blue-600', + 'active:hover:focus:visited:optional:theme-light:accent-amber:border-green-700', + 'hover:active:focus:checked:disabled:theme-dark:accent-violet:shadow-lg', + 'focus:hover:active:required:invalid:theme-light:accent-emerald:rounded-xl', + 'active:focus:hover:optional:visited:theme-dark:accent-amber:p-8', + + // Impossible but fun combinations + 'sm:md:lg:xl:2xl:hover:focus:active:disabled:checked:invalid:required:optional:visited:theme-light:theme-dark:accent-violet:accent-emerald:accent-amber:bg-red-500', + 'md:lg:xl:2xl:focus:active:hover:checked:disabled:required:invalid:visited:optional:theme-dark:theme-light:accent-emerald:accent-amber:accent-violet:text-blue-600', + 'lg:xl:2xl:active:hover:focus:invalid:required:checked:disabled:visited:optional:theme-light:theme-dark:accent-amber:accent-violet:accent-emerald:border-green-700', + + // Theme conflicts (testing edge cases) + 'theme-light:theme-dark:bg-red-500', + 'theme-dark:theme-light:text-blue-600', + 'theme-light:theme-dark:theme-light:border-green-700', + 'theme-dark:theme-light:theme-dark:shadow-xl', + + // Accent conflicts (testing edge cases) + 'accent-violet:accent-emerald:bg-red-500', + 'accent-emerald:accent-amber:text-blue-600', + 'accent-amber:accent-violet:border-green-700', + 'accent-violet:accent-emerald:accent-amber:shadow-lg', + + // Responsive with conflicting themes and accents + 'sm:theme-light:md:theme-dark:lg:theme-light:accent-violet:bg-red-500', + 'md:theme-dark:lg:theme-light:xl:theme-dark:accent-emerald:text-blue-600', + 'lg:theme-light:xl:theme-dark:2xl:theme-light:accent-amber:border-green-700', + 'sm:accent-violet:md:accent-emerald:lg:accent-amber:xl:accent-violet:bg-purple-500', + + // Ultra-nested responsive and state combinations + 'sm:hover:md:focus:lg:active:xl:disabled:2xl:checked:theme-light:accent-violet:bg-red-500', + 'md:focus:lg:hover:xl:active:2xl:invalid:sm:required:theme-dark:accent-emerald:text-blue-600', + 'lg:active:xl:hover:2xl:focus:sm:visited:md:optional:theme-light:accent-amber:border-green-700', + + // Gradient combinations with multiple breakpoints and states + 'sm:hover:md:focus:lg:active:theme-light:accent-violet:bg-gradient-to-r', + 'md:focus:lg:hover:xl:active:theme-dark:accent-emerald:from-red-500', + 'lg:active:xl:hover:2xl:focus:theme-light:accent-amber:via-blue-600', + 'xl:hover:2xl:focus:sm:active:theme-dark:accent-violet:to-green-700', + '2xl:focus:sm:hover:md:active:theme-light:accent-emerald:bg-gradient-to-br', + 'sm:active:md:hover:lg:focus:theme-dark:accent-amber:from-purple-500', + 'md:hover:lg:active:xl:focus:theme-light:accent-violet:via-pink-600', + 'lg:focus:xl:active:2xl:hover:theme-dark:accent-emerald:to-yellow-700', + + // Animation and transform combinations + 'sm:hover:focus:theme-light:accent-violet:transform:scale-105', + 'md:active:hover:theme-dark:accent-emerald:transition-all:duration-300', + 'lg:focus:active:theme-light:accent-amber:rotate-45:translate-x-4', + 'xl:hover:focus:theme-dark:accent-violet:animate-pulse:delay-150', + '2xl:active:hover:theme-light:accent-emerald:animate-bounce:duration-500', + + // Typography with complex combinations + 'sm:hover:md:focus:lg:active:theme-light:accent-violet:text-6xl:font-black:italic', + 'md:focus:lg:hover:xl:active:theme-dark:accent-emerald:text-xs:font-thin:underline', + 'lg:active:xl:hover:2xl:focus:theme-light:accent-amber:text-2xl:font-bold:line-through', + + // Spacing with complex combinations + 'sm:hover:md:focus:lg:active:theme-light:accent-violet:p-20:m-16:space-x-8', + 'md:focus:lg:hover:xl:active:theme-dark:accent-emerald:px-12:py-8:mx-6:my-4', + 'lg:active:xl:hover:2xl:focus:theme-light:accent-amber:pt-10:pb-6:pl-8:pr-4', + + // Flexbox and Grid combinations + 'sm:hover:md:focus:theme-light:accent-violet:flex:items-center:justify-between', + 'md:focus:lg:hover:theme-dark:accent-emerald:grid:grid-cols-12:gap-6', + 'lg:active:xl:hover:theme-light:accent-amber:flex:flex-col:items-stretch', + + // Positioning with complex combinations + 'sm:hover:md:focus:theme-light:accent-violet:absolute:top-4:right-6:z-50', + 'md:focus:lg:hover:theme-dark:accent-emerald:relative:inset-x-4:bottom-8', + 'lg:active:xl:hover:theme-light:accent-amber:fixed:left-0:top-0:w-full', + + // Overflow and display combinations + 'sm:hover:md:focus:theme-light:accent-violet:overflow-hidden:whitespace-nowrap', + 'md:focus:lg:hover:theme-dark:accent-emerald:overflow-scroll:block', + 'lg:active:xl:hover:theme-light:accent-amber:overflow-auto:inline-block', +]; + +export default function HeavyVariantsTest() { + const [_, setAccent] = useUI<'violet' | 'emerald' | 'amber'>('accent', 'violet'); + const [__, setTheme] = useUI<'light' | 'dark'>('theme', 'light'); + + return ( +
+

Heavy Variants Test

+

Total variants: {variants.length}

+
+ + +
+
+

This component should generate a massive amount of CSS to test your useUI hook's performance.

+

Check the generated CSS output to see how many rules are created!

+
+
+ ); +} diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts index 440f28a..d941d1b 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts @@ -1,6 +1,7 @@ /* AUTO-GENERATED - DO NOT EDIT */ export declare const bodyAttributes: { "data-auto-theme": "dark" | "light"; + "data-season": "off" | "on"; "data-theme": "dark" | "light"; "data-theme-2": "dark" | "light"; "data-theme-three": "dark" | "light"; diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js index e8864f7..c507ad6 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js @@ -1,6 +1,7 @@ /* AUTO-GENERATED - DO NOT EDIT */ export const bodyAttributes = { "data-auto-theme": "light", + "data-season": "off", "data-theme": "light", "data-theme-2": "light", "data-theme-three": "light" diff --git a/packages/core/__tests__/fixtures/next/app/AutoThemeComponent.tsx b/packages/core/__tests__/fixtures/next/app/AutoThemeComponent.tsx new file mode 100644 index 0000000..9543d05 --- /dev/null +++ b/packages/core/__tests__/fixtures/next/app/AutoThemeComponent.tsx @@ -0,0 +1,68 @@ +'use client'; +import { useEffect, useState } from 'react'; +import useUI from '@austinserb/react-zero-ui'; + +// Component that automatically cycles through themes using useEffect +export default function AutoThemeComponent() { + const [, setAutoTheme] = useUI<'light' | 'dark'>('auto-theme', 'light'); + const [isRunning, setIsRunning] = useState(false); + const [cycleCount, setCycleCount] = useState(0); + + useEffect(() => { + if (!isRunning) return; + + const interval = setInterval(() => { + setAutoTheme(prev => { + const newTheme = prev === 'light' ? 'dark' : 'light'; + setCycleCount(count => count + 1); + return newTheme; + }); + }, 2000); // Switch every 2 seconds + + return () => clearInterval(interval); + }, [isRunning, setAutoTheme]); + + return ( +
+

Auto Theme Switcher (useEffect Test)

+ +
+ + +
+
+
+ Current Theme: + Light Mode + Dark Mode +
+ +
Status: {isRunning ? 'Auto-switching...' : 'Stopped'}
+ +
Cycle Count: {cycleCount}
+
+
+ +
+
+
☀️ Light Theme Active
+
🌙 Dark Theme Active
+
+ +
+
Reactive UI
+
No Re-renders!
+
+
+
+
+ ); +} diff --git a/packages/core/__tests__/fixtures/next/app/page.tsx b/packages/core/__tests__/fixtures/next/app/page.tsx index d9b5f61..d79c290 100644 --- a/packages/core/__tests__/fixtures/next/app/page.tsx +++ b/packages/core/__tests__/fixtures/next/app/page.tsx @@ -1,72 +1,7 @@ 'use client'; import useUI from '@austinserb/react-zero-ui'; -import { useEffect, useState } from 'react'; - -// Component that automatically cycles through themes using useEffect -function AutoThemeComponent() { - const [, setAutoTheme] = useUI<'light' | 'dark'>('auto-theme', 'light'); - const [isRunning, setIsRunning] = useState(false); - const [cycleCount, setCycleCount] = useState(0); - - useEffect(() => { - if (!isRunning) return; - - const interval = setInterval(() => { - setAutoTheme(prev => { - const newTheme = prev === 'light' ? 'dark' : 'light'; - setCycleCount(count => count + 1); - return newTheme; - }); - }, 2000); // Switch every 2 seconds - - return () => clearInterval(interval); - }, [isRunning, setAutoTheme]); - - return ( -
-

Auto Theme Switcher (useEffect Test)

- -
- - -
-
-
- Current Theme: - Light Mode - Dark Mode -
- -
Status: {isRunning ? 'Auto-switching...' : 'Stopped'}
- -
Cycle Count: {cycleCount}
-
-
- -
-
-
☀️ Light Theme Active
-
🌙 Dark Theme Active
-
- -
-
Reactive UI
-
No Re-renders!
-
-
-
-
- ); -} +import AutoThemeComponent from './AutoThemeComponent'; export default function Page() { const [, setTheme] = useUI<'light' | 'dark'>('theme', 'light'); @@ -74,7 +9,7 @@ export default function Page() { const [, setThemeThree] = useUI<'light' | 'dark'>('themeThree', 'light'); return ( <> - {/* Auto Theme Component - NEW */} + {/* Auto Theme Component */}
diff --git a/packages/core/__tests__/fixtures/next/app/test/page.tsx b/packages/core/__tests__/fixtures/next/app/test/page.tsx new file mode 100644 index 0000000..90cb46a --- /dev/null +++ b/packages/core/__tests__/fixtures/next/app/test/page.tsx @@ -0,0 +1,23 @@ +'use client'; +import React from 'react'; +import useUI from '@austinserb/react-zero-ui'; + +const page = () => { + const [, setSeason] = useUI<'off' | 'on'>('season', 'off'); + + return ( +
+
setSeason(prev => (prev === 'on' ? 'off' : 'on'), { scope: e.currentTarget })}> +
+ CHILD +
CHILD2
+
+ Toggle Theme 2 +
+
+ ); +}; + +export default page; diff --git a/packages/core/src/index.d.ts b/packages/core/src/index.d.ts index 1884489..a86ccdd 100644 --- a/packages/core/src/index.d.ts +++ b/packages/core/src/index.d.ts @@ -5,7 +5,19 @@ declare function useUI( key: string, initialValue: T -): readonly [T, (v: T | ((currentValue: T) => T)) => void]; +): readonly [T, (v: T | ((currentValue: T) => T), options?: { scope?: HTMLElement }) => void]; export { useUI }; export default useUI; + +// TODO ADD SOMETHING SIMILAR FOR TYPESCRIPT AUTOCOMPLETE IN VSCODE: +// export function useUI( +// key: K, +// initial: UIMap[K] +// ): [ +// UIMap[K], +// ( +// update: UIMap[K] | ((prev: UIMap[K]) => UIMap[K]), +// target?: HTMLElement | Event +// ) => void +// ]; diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 1e6bac0..685e1bd 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -1,13 +1,41 @@ import { useCallback } from 'react'; + + function useUI(key, initialValue) { - //setValue(valueOrUpdater) + /* ───────────────── DEV-ONLY COLLISION GUARD ───────────────── */ + if (process.env.NODE_ENV !== 'production') { + // One shared registry per window / Node context + const registry = + typeof globalThis !== 'undefined' + ? (globalThis.__zeroKeys ||= new Map()) + : new Map(); // Fallback for exotic SSR envs + + const prev = registry.get(key); + if (prev !== undefined && prev !== initialValue) { + console.error( + `[Zero-UI] duplicate initial values for key "${key}" - ` + + `first "${prev}", second "${initialValue}". ` + + `Namespace your key or keep defaults consistent.` + ); + } else if (prev === undefined) { + registry.set(key, initialValue); + } + } + /* ───────────────────────────────────────────────────────────── */ + + + const camelKey = key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); const setValue = useCallback( - valueOrUpdater => { + (valueOrUpdater, { scope } = {}) => { if (typeof window === 'undefined') return; // Convert kebab-case to camelCase for dataset API - // "theme-secondary" -> "themeSecondary" - const camelKey = key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + + const target = + scope && scope.nodeType === 1 + ? scope + : document.body; + let newValue; if (typeof valueOrUpdater === 'function') { const parse = v => { @@ -23,14 +51,16 @@ function useUI(key, initialValue) { return v; } }; - newValue = valueOrUpdater(parse(document.body.dataset[camelKey])); + newValue = valueOrUpdater(parse(target.dataset[camelKey])); } else { newValue = valueOrUpdater; } - document.body.dataset[camelKey] = String(newValue); + + target.dataset[camelKey] = String(newValue); }, [key] ); + return [initialValue, setValue]; } diff --git a/packages/core/src/postcss/helpers.cjs b/packages/core/src/postcss/helpers.cjs index bdd8ca7..ac45132 100644 --- a/packages/core/src/postcss/helpers.cjs +++ b/packages/core/src/postcss/helpers.cjs @@ -101,7 +101,7 @@ function buildCss(variants) { const keySlug = toKebabCase(key); for (const val of values) { const valSlug = toKebabCase(val); - css += `@variant ${keySlug}-${valSlug} (body[data-${keySlug}="${valSlug}"] &);\n`; + css += `@variant ${keySlug}-${valSlug} (:where(body[data-${keySlug}="${valSlug}"] &), &[data-${keySlug}="${valSlug}"], [data-${keySlug}="${valSlug}"] &);\n`; } } return css; From 9cce88e96970688da9e6e94da10657bb4e177c97 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Sat, 28 Jun 2025 12:01:41 -0700 Subject: [PATCH 02/34] feat: added scoped UI state logic --- .prettierrc.json | 4 +- .release-please-manifest.json | 12 ++- .../fixtures/next/.zero-ui/attributes.d.ts | 6 +- .../fixtures/next/.zero-ui/attributes.js | 6 +- .../fixtures/next/app/AutoThemeComponent.tsx | 68 -------------- .../fixtures/next/app/UseEffectComponent.tsx | 27 ++++++ .../core/__tests__/fixtures/next/app/page.tsx | 61 +++++++++--- .../__tests__/fixtures/next/app/test/page.tsx | 22 ++++- packages/core/src/f.js | 1 + packages/core/src/index.d.ts | 58 ++++++++---- packages/core/src/index.js | 93 ++++++++++++------- packages/core/src/postcss/helpers.cjs | 34 ++++--- pnpm-lock.yaml | 30 ++++-- 13 files changed, 254 insertions(+), 168 deletions(-) delete mode 100644 packages/core/__tests__/fixtures/next/app/AutoThemeComponent.tsx create mode 100644 packages/core/__tests__/fixtures/next/app/UseEffectComponent.tsx create mode 100644 packages/core/src/f.js diff --git a/.prettierrc.json b/.prettierrc.json index 3c03064..aa184a9 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -6,10 +6,10 @@ "useTabs": true, "printWidth": 160, "endOfLine": "lf", - "arrowParens": "avoid", + "arrowParens": "always", "bracketSpacing": true, "objectWrap": "collapse", "bracketSameLine": true, "embeddedLanguageFormatting": "auto", "singleAttributePerLine": true -} +} \ No newline at end of file diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cb93513..972b7a2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,8 +1,14 @@ { "packages": { - "packages/core": { "package-name": "@austinserb/react-zero-ui", "release-type": "node" }, - "packages/cli": { "package-name": "create-zero-ui", "release-type": "node" } + "packages/core": { + "package-name": "react-zero-ui", + "release-type": "node" + }, + "packages/cli": { + "package-name": "create-zero-ui", + "release-type": "node" + } }, "monorepo-tags": true, "include-component-in-tag": true -} +} \ No newline at end of file diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts index d941d1b..e65ecf4 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts @@ -1,8 +1,10 @@ /* AUTO-GENERATED - DO NOT EDIT */ export declare const bodyAttributes: { - "data-auto-theme": "dark" | "light"; - "data-season": "off" | "on"; + "data-number": "1" | "2"; + "data-season": "maybe" | "off" | "on"; "data-theme": "dark" | "light"; "data-theme-2": "dark" | "light"; "data-theme-three": "dark" | "light"; + "data-toggle-boolean": "false" | "true"; + "data-use-effect-theme": "dark" | "light"; }; diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js index c507ad6..711ff0b 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js @@ -1,8 +1,10 @@ /* AUTO-GENERATED - DO NOT EDIT */ export const bodyAttributes = { - "data-auto-theme": "light", + "data-number": "1", "data-season": "off", "data-theme": "light", "data-theme-2": "light", - "data-theme-three": "light" + "data-theme-three": "light", + "data-toggle-boolean": "true", + "data-use-effect-theme": "light" }; diff --git a/packages/core/__tests__/fixtures/next/app/AutoThemeComponent.tsx b/packages/core/__tests__/fixtures/next/app/AutoThemeComponent.tsx deleted file mode 100644 index 9543d05..0000000 --- a/packages/core/__tests__/fixtures/next/app/AutoThemeComponent.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'use client'; -import { useEffect, useState } from 'react'; -import useUI from '@austinserb/react-zero-ui'; - -// Component that automatically cycles through themes using useEffect -export default function AutoThemeComponent() { - const [, setAutoTheme] = useUI<'light' | 'dark'>('auto-theme', 'light'); - const [isRunning, setIsRunning] = useState(false); - const [cycleCount, setCycleCount] = useState(0); - - useEffect(() => { - if (!isRunning) return; - - const interval = setInterval(() => { - setAutoTheme(prev => { - const newTheme = prev === 'light' ? 'dark' : 'light'; - setCycleCount(count => count + 1); - return newTheme; - }); - }, 2000); // Switch every 2 seconds - - return () => clearInterval(interval); - }, [isRunning, setAutoTheme]); - - return ( -
-

Auto Theme Switcher (useEffect Test)

- -
- - -
-
-
- Current Theme: - Light Mode - Dark Mode -
- -
Status: {isRunning ? 'Auto-switching...' : 'Stopped'}
- -
Cycle Count: {cycleCount}
-
-
- -
-
-
☀️ Light Theme Active
-
🌙 Dark Theme Active
-
- -
-
Reactive UI
-
No Re-renders!
-
-
-
-
- ); -} diff --git a/packages/core/__tests__/fixtures/next/app/UseEffectComponent.tsx b/packages/core/__tests__/fixtures/next/app/UseEffectComponent.tsx new file mode 100644 index 0000000..ff8e47e --- /dev/null +++ b/packages/core/__tests__/fixtures/next/app/UseEffectComponent.tsx @@ -0,0 +1,27 @@ +'use client'; +import { useEffect } from 'react'; +import useUI from '@austinserb/react-zero-ui'; + +// Component that automatically cycles through themes using useEffect +export default function UseEffectComponent() { + const [, setAutoTheme] = useUI<'light' | 'dark'>('use-effect-theme', 'light'); + + useEffect(() => { + setAutoTheme('dark'); + }, []); + + return ( +
+
+ Toggle Theme (useEffect on mount) +
+
+ Theme: Dark Light +
+
+ ); +} diff --git a/packages/core/__tests__/fixtures/next/app/page.tsx b/packages/core/__tests__/fixtures/next/app/page.tsx index d79c290..8a6fd4a 100644 --- a/packages/core/__tests__/fixtures/next/app/page.tsx +++ b/packages/core/__tests__/fixtures/next/app/page.tsx @@ -1,28 +1,30 @@ 'use client'; import useUI from '@austinserb/react-zero-ui'; -import AutoThemeComponent from './AutoThemeComponent'; +import UseEffectComponent from './UseEffectComponent'; export default function Page() { const [, setTheme] = useUI<'light' | 'dark'>('theme', 'light'); const [, setTheme2] = useUI<'light' | 'dark'>('theme-2', 'light'); const [, setThemeThree] = useUI<'light' | 'dark'>('themeThree', 'light'); + const [, setToggle] = useUI('toggle-boolean', true); + const [, setNumber] = useUI<1 | 2>('number', 1); return ( - <> +
{/* Auto Theme Component */} - +
Theme: Dark Light @@ -32,14 +34,14 @@ export default function Page() {
Theme: Dark Light @@ -49,19 +51,52 @@ export default function Page() {
Theme: Dark Light
- + +
+ +
+ +
+ Boolean: True False +
+
+
+ +
+ +
+ Number: 1 2 +
+
+
); } diff --git a/packages/core/__tests__/fixtures/next/app/test/page.tsx b/packages/core/__tests__/fixtures/next/app/test/page.tsx index 90cb46a..8b117d6 100644 --- a/packages/core/__tests__/fixtures/next/app/test/page.tsx +++ b/packages/core/__tests__/fixtures/next/app/test/page.tsx @@ -2,20 +2,32 @@ import React from 'react'; import useUI from '@austinserb/react-zero-ui'; -const page = () => { - const [, setSeason] = useUI<'off' | 'on'>('season', 'off'); +const Component = () => { + const [, setSeason] = useUI<'off' | 'on' | 'maybe'>('season', 'off'); + + return
setSeason('off')}>Component
; +}; +const page = () => { + const [, setSeason] = useUI<'off' | 'on' | 'maybe'>('season', 'off'); return ( -
+
+
setSeason(prev => (prev === 'on' ? 'off' : 'on'), { scope: e.currentTarget })}> + onClick={() => setSeason((prev) => (prev === 'on' ? 'off' : 'on'))}>
CHILD
CHILD2
- Toggle Theme 2
+
); }; diff --git a/packages/core/src/f.js b/packages/core/src/f.js new file mode 100644 index 0000000..6a5ce63 --- /dev/null +++ b/packages/core/src/f.js @@ -0,0 +1 @@ +n.d(s,{A:()=>r});var o=n(2444);let r=function(e,t){let n=(0,o.useRef)(null),r=(0,o.useCallback)((o=>{let r;if("undefined"==typeof window)return;let l=n.current??document.body,u=e.replace(/-([a-z])/g,((e,t)=>t.toUpperCase()));if("function"==typeof o){let e=l.dataset[u];r=o(e?"boolean"==typeof t?"true"===e:"number"==typeof t?+e==+e?+e:t:e:t)}else r=o;l.dataset[u]=r+""}),[e]);return r.ref=n,[t,r]}; \ No newline at end of file diff --git a/packages/core/src/index.d.ts b/packages/core/src/index.d.ts index a86ccdd..35eb402 100644 --- a/packages/core/src/index.d.ts +++ b/packages/core/src/index.d.ts @@ -1,23 +1,45 @@ +import * as React from 'react'; + +/** + * A setter function that updates data-* attributes on DOM elements. + * Includes a .ref property for scoping the updates to specific elements. + */ +export interface UISetter { + /** + * Updates the data-* attribute. Supports both direct values and updater functions. + * @param valueOrUpdater - Either a direct value or function that receives current parsed value + */ + (valueOrUpdater: T | ((currentValue: T) => T)): void; + + /** + * Attach to any HTML element whose dataset you want to mutate. + * If not attached, updates will target document.body. + */ + readonly ref: React.RefObject; +} + /** - * Returns [staleValue, setState] - destructure as `const [, setState] = useUI(...)` - * The first value is intentionally stale/static, use only the setter. + * A render-less React hook for managing UI state via data-* attributes. + * + * @param key - The data-* attribute key (kebab-case, e.g., "my-key" becomes data-my-key) + * @param initialValue - The initial value, determines the type for all operations + * @returns A tuple [staleValue, setter] where staleValue is always the initialValue + * + * @example + * ```tsx + * const [, setCount] = useUI('count', 0); + * const [, setVisible] = useUI('visible', false); + * + * Scoped to specific element + *
+ * + *
+ * + * Global (updates document.body) + * setVisible(true); + * ``` */ -declare function useUI( - key: string, - initialValue: T -): readonly [T, (v: T | ((currentValue: T) => T), options?: { scope?: HTMLElement }) => void]; +declare function useUI(key: string, initialValue: T): readonly [T, UISetter]; export { useUI }; export default useUI; - -// TODO ADD SOMETHING SIMILAR FOR TYPESCRIPT AUTOCOMPLETE IN VSCODE: -// export function useUI( -// key: K, -// initial: UIMap[K] -// ): [ -// UIMap[K], -// ( -// update: UIMap[K] | ((prev: UIMap[K]) => UIMap[K]), -// target?: HTMLElement | Event -// ) => void -// ]; diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 685e1bd..6cc5644 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -1,22 +1,25 @@ -import { useCallback } from 'react'; - - +import { useCallback, useRef } from 'react'; function useUI(key, initialValue) { - /* ───────────────── DEV-ONLY COLLISION GUARD ───────────────── */ + /* ─ DEV-ONLY COLLISION GUARD (removed in production by modern bundlers) ─ */ if (process.env.NODE_ENV !== 'production') { + // Validate inputs with helpful error messages + if (!key || typeof key !== 'string' || key.trim() === '') { + throw new Error(`useUI(key, initialValue); key must be a non-empty string, got "${key}"`); + } + + if (initialValue === '' || initialValue == null) { + throw new Error(`useUI(key, initialValue); initialValue cannot be empty string, null, or undefined, got "${initialValue}"`); + } // One shared registry per window / Node context - const registry = - typeof globalThis !== 'undefined' - ? (globalThis.__zeroKeys ||= new Map()) - : new Map(); // Fallback for exotic SSR envs + const registry = typeof globalThis !== 'undefined' ? (globalThis.__useUIRegistry ||= new Map()) : new Map(); const prev = registry.get(key); if (prev !== undefined && prev !== initialValue) { console.error( - `[Zero-UI] duplicate initial values for key "${key}" - ` + - `first "${prev}", second "${initialValue}". ` + - `Namespace your key or keep defaults consistent.` + `[useUI] Inconsistent initial values for key "${key}": ` + + `expected "${prev}", got "${initialValue}". ` + + `Use the same initial value everywhere or namespace your keys.` ); } else if (prev === undefined) { registry.set(key, initialValue); @@ -24,43 +27,65 @@ function useUI(key, initialValue) { } /* ───────────────────────────────────────────────────────────── */ + // Create a ref to hold the DOM element that will receive the data-* attributes + // This allows scoping UI state to specific elements instead of always using document.body + const scopeRef = useRef(null); + // Convert kebab-case key to camelCase for dataset property access + // e.g., "my-key" becomes "myKey" since dataset auto-lowercases after dashes + const camelKey = key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()); - const camelKey = key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + // Memoized setter function that updates data-* attributes on the target element + // useCallback prevents recreation on every render, essential for useEffect dependencies const setValue = useCallback( - (valueOrUpdater, { scope } = {}) => { + (valueOrUpdater) => { + // SSR safety: bail out if running on server where window is undefined if (typeof window === 'undefined') return; - // Convert kebab-case to camelCase for dataset API - const target = - scope && scope.nodeType === 1 - ? scope - : document.body; + // Use the scoped element if ref is attached, otherwise fall back to document.body + // This enables both scoped UI state (with ref) and global UI state (without ref) + const target = scopeRef.current ?? document.body; let newValue; + // Check if caller passed an updater function (like React's setState(prev => prev) pattern) if (typeof valueOrUpdater === 'function') { - const parse = v => { - if (!v) return initialValue; - switch (typeof initialValue) { - case 'boolean': - return v === 'true'; - case 'number': { - const n = Number(v); - return isNaN(n) ? initialValue : n; - } - default: - return v; - } - }; - newValue = valueOrUpdater(parse(target.dataset[camelKey])); + const value = target.dataset[camelKey]; + + // Parse the string value from dataset back to the original type + // Call the updater function with the parsed current value + newValue = valueOrUpdater( + // If no value exists, return the initial value + !value + ? initialValue + : // If initial was boolean, parse "true"/"false" string to boolean + typeof initialValue === 'boolean' + ? value === 'true' + : // If initial was number, convert string to number with NaN fallback + typeof initialValue === 'number' + ? // The double conversion of +value is very fast and is the same as isNaN check without the function overhead + +value === +value + ? +value + : initialValue + : value + ); } else { + // Direct value assignment (no updater function) newValue = valueOrUpdater; } - target.dataset[camelKey] = String(newValue); + // Write the new value to the data-* attribute as a string + target.dataset[camelKey] = newValue + ''; // The fastest way to convert to string }, - [key] + // Recreate callback only when the key changes + // Note: initialValue intentionally excluded since it should be stable + [key] // camelKey depends on key, so no need to include it in the dependency array ); + // Attach the ref to the setter function so users can write:
+ // This creates a clean API where the ref and setter are bundled together + setValue.ref = scopeRef; + + // Return tuple matching React's useState pattern: [currentValue, setter] + // Note: currentValue is always initialValue since this doesn't trigger re-renders return [initialValue, setValue]; } diff --git a/packages/core/src/postcss/helpers.cjs b/packages/core/src/postcss/helpers.cjs index ac45132..33e4fa2 100644 --- a/packages/core/src/postcss/helpers.cjs +++ b/packages/core/src/postcss/helpers.cjs @@ -22,11 +22,11 @@ function findAllSourceFiles(rootDirs = ['src', 'app']) { const files = []; const cwd = process.cwd(); - rootDirs.forEach(dir => { + rootDirs.forEach((dir) => { const dirPath = path.join(cwd, dir); if (!fs.existsSync(dirPath)) return; - const walk = current => { + const walk = (current) => { try { for (const entry of fs.readdirSync(current)) { const full = path.join(current, entry); @@ -34,7 +34,7 @@ function findAllSourceFiles(rootDirs = ['src', 'app']) { // TODO upgrade to fast-glob if (stat.isDirectory() && !entry.startsWith('.') && !IGNORE_DIRS.has(entry)) { walk(full); - } else if (stat.isFile() && exts.some(ext => full.endsWith(ext))) { + } else if (stat.isFile() && exts.some((ext) => full.endsWith(ext))) { files.push(full); } } @@ -57,7 +57,7 @@ function findAllSourceFiles(rootDirs = ['src', 'app']) { */ async function processVariants(files = null) { const sourceFiles = files || findAllSourceFiles(); - const allVariants = sourceFiles.flatMap(file => { + const allVariants = sourceFiles.flatMap((file) => { return extractVariants(file); }); @@ -76,7 +76,7 @@ async function processVariants(files = null) { } if (Array.isArray(values)) { - values.forEach(v => variantMap.get(key).add(v)); + values.forEach((v) => variantMap.get(key).add(v)); } } @@ -95,16 +95,20 @@ async function processVariants(files = null) { return { finalVariants, initialValues, sourceFiles }; } +// TODO build css for local and global variants separately based on scoped/global function buildCss(variants) { - let css = CONFIG.HEADER + '\n'; - for (const { key, values } of variants) { + const lines = variants.flatMap(({ key, values }) => { const keySlug = toKebabCase(key); - for (const val of values) { - const valSlug = toKebabCase(val); - css += `@variant ${keySlug}-${valSlug} (:where(body[data-${keySlug}="${valSlug}"] &), &[data-${keySlug}="${valSlug}"], [data-${keySlug}="${valSlug}"] &);\n`; - } - } - return css; + return values.map((v) => { + const valSlug = toKebabCase(v); + return `@custom-variant ${keySlug}-${valSlug} (&[data-${keySlug}="${valSlug}"],[data-${keySlug}="${valSlug}"] &, :where(body[data-${keySlug}="${valSlug}"] &));`; + // @custom-variant ${keySlug}-${valSlug} eg. - theme-light + // 1. &[data-${keySlug}="${valSlug}"] - element itself (wins ties) + // 2. [data-${keySlug}="${valSlug}"] & - nearest ancestor wrapper + // 3. :where(body[data-${keySlug}="${valSlug}"] &) - global fallback + }); + }); + return CONFIG.HEADER + '\n' + lines.join('\n') + '\n'; } async function generateAttributesFile(finalVariants, initialValues) { @@ -117,7 +121,7 @@ async function generateAttributesFile(finalVariants, initialValues) { const attrExport = `${CONFIG.HEADER}\nexport const bodyAttributes = ${JSON.stringify(initialValues, null, 2)};\n`; // Generate TypeScript definitions - const toLiteral = v => (typeof v === 'string' ? `"${v.replace(/"/g, '\\"')}"` : v); + const toLiteral = (v) => (typeof v === 'string' ? `"${v.replace(/"/g, '\\"')}"` : v); const variantLines = finalVariants.map(({ key, values }) => { const slug = `data-${toKebabCase(key)}`; const union = values.length ? values.map(toLiteral).join(' | ') : 'string'; // ← fallback @@ -357,7 +361,7 @@ async function patchViteConfig() { function hasViteConfig() { const cwd = process.cwd(); const viteConfigFiles = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']; - return viteConfigFiles.some(configFile => fs.existsSync(path.join(cwd, configFile))); + return viteConfigFiles.some((configFile) => fs.existsSync(path.join(cwd, configFile))); } module.exports = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5542c2..577f3a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.27.7': + resolution: {integrity: sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -122,6 +127,10 @@ packages: resolution: {integrity: sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==} engines: {node: '>=6.9.0'} + '@babel/types@7.27.7': + resolution: {integrity: sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==} + engines: {node: '>=6.9.0'} + '@conventional-commits/parser@0.4.1': resolution: {integrity: sha512-H2ZmUVt6q+KBccXfMBhbBF14NlANeqHTXL4qCL6QGbMzrc4HDXyzWuxPxPNbz71f/5UkR5DrycP5VO9u7crahg==} @@ -1393,8 +1402,8 @@ snapshots: '@babel/generator@7.27.5': dependencies: - '@babel/parser': 7.27.5 - '@babel/types': 7.27.6 + '@babel/parser': 7.27.7 + '@babel/types': 7.27.7 '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.1.0 @@ -1407,19 +1416,23 @@ snapshots: dependencies: '@babel/types': 7.27.6 + '@babel/parser@7.27.7': + dependencies: + '@babel/types': 7.27.7 + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 - '@babel/parser': 7.27.5 - '@babel/types': 7.27.6 + '@babel/parser': 7.27.7 + '@babel/types': 7.27.7 '@babel/traverse@7.27.4': dependencies: '@babel/code-frame': 7.27.1 '@babel/generator': 7.27.5 - '@babel/parser': 7.27.5 + '@babel/parser': 7.27.7 '@babel/template': 7.27.2 - '@babel/types': 7.27.6 + '@babel/types': 7.27.7 debug: 4.4.1 globals: 11.12.0 transitivePeerDependencies: @@ -1430,6 +1443,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@babel/types@7.27.7': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + '@conventional-commits/parser@0.4.1': dependencies: unist-util-visit: 2.0.3 From eb9746adacb6e1bacb35ba1388033c4a01e8eed2 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Sat, 28 Jun 2025 12:11:33 -0700 Subject: [PATCH 03/34] fix: tests to use new @custom-variant selector --- .../core/__tests__/fixtures/next/app/page.tsx | 10 ++-- .../__tests__/fixtures/next/app/test/page.tsx | 4 +- packages/core/__tests__/unit/index.test.cjs | 60 +++++++++---------- packages/core/src/f.js | 1 - packages/core/src/test-size.js | 20 +++++++ 5 files changed, 57 insertions(+), 38 deletions(-) delete mode 100644 packages/core/src/f.js create mode 100644 packages/core/src/test-size.js diff --git a/packages/core/__tests__/fixtures/next/app/page.tsx b/packages/core/__tests__/fixtures/next/app/page.tsx index 8a6fd4a..1b09f1d 100644 --- a/packages/core/__tests__/fixtures/next/app/page.tsx +++ b/packages/core/__tests__/fixtures/next/app/page.tsx @@ -21,7 +21,7 @@ export default function Page() { data-testid="theme-container">
diff --git a/packages/core/__tests__/unit/index.test.cjs b/packages/core/__tests__/unit/index.test.cjs index efeb47c..1c9fcf5 100644 --- a/packages/core/__tests__/unit/index.test.cjs +++ b/packages/core/__tests__/unit/index.test.cjs @@ -74,8 +74,8 @@ test('generates body attributes file correctly', async () => { assert(content.includes('"data-sidebar": "expanded"'), 'Should have sidebar attribute'); // Verify CSS variants - assert(result.css.includes('@variant theme-light'), 'Should have theme-light variant'); - assert(result.css.includes('@variant sidebar-expanded'), 'Should have sidebar-expanded variant'); + assert(result.css.includes('@custom-variant theme-light'), 'Should have theme-light variant'); + assert(result.css.includes('@custom-variant sidebar-expanded'), 'Should have sidebar-expanded variant'); } ); }); @@ -110,8 +110,8 @@ test('generates body attributes file correctly when kebab-case is used', async ( assert(content.includes('"data-sidebar-new": "expanded"'), 'Should have sidebar-new attribute'); // Verify CSS variants - assert(result.css.includes('@variant theme-secondary-light'), 'Should have theme-secondary-light variant'); - assert(result.css.includes('@variant sidebar-new-expanded'), 'Should have sidebar-new-expanded variant'); + assert(result.css.includes('@custom-variant theme-secondary-light'), 'Should have theme-secondary-light variant'); + assert(result.css.includes('@custom-variant sidebar-new-expanded'), 'Should have sidebar-new-expanded variant'); } ); }); @@ -134,7 +134,7 @@ test('handles TypeScript generic types', async () => { // Check all variants were generated const variants = ['idle', 'loading', 'success', 'error']; variants.forEach(variant => { - assert(result.css.includes(`@variant status-${variant}`), `Should have status-${variant} variant`); + assert(result.css.includes(`@custom-variant status-${variant}`), `Should have status-${variant} variant`); }); // Check attributes file @@ -176,7 +176,7 @@ test('detects JavaScript setValue calls', async () => { const states = ['closed', 'open', 'minimized', 'fullscreen']; states.forEach(state => { - assert(result.css.includes(`@variant modal-${state}`), `Should detect modal-${state}`); + assert(result.css.includes(`@custom-variant modal-${state}`), `Should detect modal-${state}`); }); const content = fs.readFileSync(getAttrFile(), 'utf-8'); @@ -206,10 +206,10 @@ test('handles boolean values', async () => { result => { console.log('\n🔍 Boolean Values Test:'); - assert(result.css.includes('@variant drawer-true'), 'Should have drawer-true'); - assert(result.css.includes('@variant drawer-false'), 'Should have drawer-false'); - assert(result.css.includes('@variant checkbox-true'), 'Should have checkbox-true'); - assert(result.css.includes('@variant checkbox-false'), 'Should have checkbox-false'); + assert(result.css.includes('@custom-variant drawer-true'), 'Should have drawer-true'); + assert(result.css.includes('@custom-variant drawer-false'), 'Should have drawer-false'); + assert(result.css.includes('@custom-variant checkbox-true'), 'Should have checkbox-true'); + assert(result.css.includes('@custom-variant checkbox-false'), 'Should have checkbox-false'); const content = fs.readFileSync(getAttrFile(), 'utf-8'); console.log('Boolean attributes:', content); @@ -240,10 +240,10 @@ test('handles kebab-case conversion', async () => { console.log('\n🔍 Kebab-case Test:'); // Check CSS has kebab-case - assert(result.css.includes('@variant primary-color-deep-blue'), 'Should convert to kebab-case'); - assert(result.css.includes('@variant primary-color-dark-red'), 'Should convert to kebab-case'); - assert(result.css.includes('@variant background-color-light-gray'), 'Should convert to kebab-case'); - assert(result.css.includes('@variant background-color-pale-yellow'), 'Should convert to kebab-case'); + assert(result.css.includes('@custom-variant primary-color-deep-blue'), 'Should convert to kebab-case'); + assert(result.css.includes('@custom-variant primary-color-dark-red'), 'Should convert to kebab-case'); + assert(result.css.includes('@custom-variant background-color-light-gray'), 'Should convert to kebab-case'); + assert(result.css.includes('@custom-variant background-color-pale-yellow'), 'Should convert to kebab-case'); // Check attributes use kebab-case keys const content = fs.readFileSync(getAttrFile(), 'utf-8'); @@ -284,7 +284,7 @@ test('handles conditional expressions', async () => { const expectedStates = ['default', 'active', 'inactive', 'night', 'day', 'fallback']; expectedStates.forEach(state => { - assert(result.css.includes(`@variant state-${state}`), `Should detect state-${state}`); + assert(result.css.includes(`@custom-variant state-${state}`), `Should detect state-${state}`); }); } ); @@ -321,11 +321,11 @@ test('handles multiple files and deduplication', async () => { // Should combine all theme values from all files const themeVariants = ['light', 'dark', 'blue', 'auto']; themeVariants.forEach(variant => { - assert(result.css.includes(`@variant theme-${variant}`), `Should have theme-${variant}`); + assert(result.css.includes(`@custom-variant theme-${variant}`), `Should have theme-${variant}`); }); // Count occurrences - should be deduplicated - const lightCount = (result.css.match(/@variant theme-light/g) || []).length; + const lightCount = (result.css.match(/@custom-variant theme-light/g) || []).length; assert.equal(lightCount, 1, 'Should deduplicate variants'); } ); @@ -353,7 +353,7 @@ test('handles parsing errors gracefully', async () => { console.log('\n🔍 Parse Error Test:'); console.log('result: ', result.css); // Should still process valid files - assert(result.css.includes('@variant valid-working'), 'Should process valid files'); + assert(result.css.includes('@custom-variant valid-working'), 'Should process valid files'); // Should not crash on invalid files assert(result.css.includes('AUTO-GENERATED'), 'Should complete processing'); @@ -380,8 +380,8 @@ test('valid edge cases: underscores + missing initial', async () => { }, result => { console.log('result: ', result.css); - assert(result.css.includes('@variant only-setter-key-set-later')); - assert(!result.css.includes('@variant no-initial-value')); + assert(result.css.includes('@custom-variant only-setter-key-set-later')); + assert(!result.css.includes('@custom-variant no-initial-value')); } ); }); @@ -404,7 +404,7 @@ test('watches for file changes', async () => { }, async result => { // Initial state - assert(result.css.includes('@variant watch-test-initial')); + assert(result.css.includes('@custom-variant watch-test-initial')); // Add a new file fs.writeFileSync( @@ -424,7 +424,7 @@ test('watches for file changes', async () => { // Re-process to check if watcher picked up changes const result2 = await postcss([plugin()]).process('', { from: undefined }); - assert(result2.css.includes('@variant watch-test-updated'), 'Should detect new state'); + assert(result2.css.includes('@custom-variant watch-test-updated'), 'Should detect new state'); } ); }); @@ -456,7 +456,7 @@ test('ignores node_modules and hidden directories', async () => { }, result => { console.log('result: ', result.css); - assert(result.css.includes('@variant valid-yes'), 'Should process valid files'); + assert(result.css.includes('@custom-variant valid-yes'), 'Should process valid files'); assert(!result.css.includes('ignored'), 'Should ignore node_modules'); assert(!result.css.includes('hidden'), 'Should ignore hidden directories'); } @@ -475,8 +475,8 @@ test('handles deeply nested file structures', async () => { `, }, result => { - assert(result.css.includes('@variant auth-state-logged-out')); - assert(result.css.includes('@variant auth-state-logged-in')); + assert(result.css.includes('@custom-variant auth-state-logged-out')); + assert(result.css.includes('@custom-variant auth-state-logged-in')); } ); }); @@ -506,7 +506,7 @@ test('handles complex TypeScript scenarios', async () => { result => { // Should extract all size variants ['xs', 'sm', 'md', 'lg', 'xl', '2xl'].forEach(size => { - assert(result.css.includes(`@variant size-${size}`), `Should have size-${size}`); + assert(result.css.includes(`@custom-variant size-${size}`), `Should have size-${size}`); }); // Check attributes file const content = fs.readFileSync(getAttrFile(), 'utf-8'); @@ -540,7 +540,7 @@ test('handles large projects efficiently', async function () { console.log(`\n⚡ Performance: Processed 50 files in ${duration}ms`); // Should process all files - assert(result.css.includes('@variant state49-value49'), 'Should process all files'); + assert(result.css.includes('@custom-variant state49-value49'), 'Should process all files'); // Should complete in reasonable time assert(duration < 300, 'Should process 50 files in under 300ms'); @@ -565,9 +565,9 @@ test('handles special characters in values', async () => { `, }, result => { - assert(result.css.includes('@variant special-with-dash')); - assert(result.css.includes('@variant special-with-underscore')); - assert(result.css.includes('@variant special-123numeric')); + assert(result.css.includes('@custom-variant special-with-dash')); + assert(result.css.includes('@custom-variant special-with-underscore')); + assert(result.css.includes('@custom-variant special-123numeric')); } ); }); diff --git a/packages/core/src/f.js b/packages/core/src/f.js deleted file mode 100644 index 6a5ce63..0000000 --- a/packages/core/src/f.js +++ /dev/null @@ -1 +0,0 @@ -n.d(s,{A:()=>r});var o=n(2444);let r=function(e,t){let n=(0,o.useRef)(null),r=(0,o.useCallback)((o=>{let r;if("undefined"==typeof window)return;let l=n.current??document.body,u=e.replace(/-([a-z])/g,((e,t)=>t.toUpperCase()));if("function"==typeof o){let e=l.dataset[u];r=o(e?"boolean"==typeof t?"true"===e:"number"==typeof t?+e==+e?+e:t:e:t)}else r=o;l.dataset[u]=r+""}),[e]);return r.ref=n,[t,r]}; \ No newline at end of file diff --git a/packages/core/src/test-size.js b/packages/core/src/test-size.js new file mode 100644 index 0000000..76df7bf --- /dev/null +++ b/packages/core/src/test-size.js @@ -0,0 +1,20 @@ +n.d(s, { A: () => r }); +var o = n(2444); +let r = function (e, t) { + let n = (0, o.useRef)(null), + r = (0, o.useCallback)( + o => { + let r; + if ('undefined' == typeof window) return; + let l = n.current ?? document.body, + u = e.replace(/-([a-z])/g, (e, t) => t.toUpperCase()); + if ('function' == typeof o) { + let e = l.dataset[u]; + r = o(e ? ('boolean' == typeof t ? 'true' === e : 'number' == typeof t ? (+e == +e ? +e : t) : e) : t); + } else r = o; + l.dataset[u] = r + ''; + }, + [e] + ); + return ((r.ref = n), [t, r]); +}; From 75bcde38446b389b6de573a3301fe95e6be66cff Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Sat, 28 Jun 2025 17:08:19 -0700 Subject: [PATCH 04/34] feat:scoped styles, FAQ tests written --- examples/demo/src/app/style/page.tsx | 2 +- package.json | 6 +- .../config/playwright.next.config.js | 1 + .../core/__tests__/e2e/next-scoped.spec.js | 179 ++++++++++++ packages/core/__tests__/e2e/next.spec.js | 178 ++++++++++-- packages/core/__tests__/e2e/vite.spec.js | 2 +- .../fixtures/next/.zero-ui/attributes.d.ts | 2 + .../fixtures/next/.zero-ui/attributes.js | 2 + .../core/__tests__/fixtures/next/app/FAQ.tsx | 25 ++ .../fixtures/next/app/UseEffectComponent.tsx | 16 +- .../__tests__/fixtures/next/app/layout.tsx | 19 +- .../core/__tests__/fixtures/next/app/page.tsx | 196 ++++++++----- .../__tests__/fixtures/next/app/test/page.tsx | 6 +- packages/core/src/index.js | 34 ++- packages/core/src/test-size.js | 20 -- pnpm-lock.yaml | 261 ++++++++++++++++++ 16 files changed, 804 insertions(+), 145 deletions(-) create mode 100644 packages/core/__tests__/e2e/next-scoped.spec.js create mode 100644 packages/core/__tests__/fixtures/next/app/FAQ.tsx delete mode 100644 packages/core/src/test-size.js diff --git a/examples/demo/src/app/style/page.tsx b/examples/demo/src/app/style/page.tsx index 48c7548..b6cb424 100644 --- a/examples/demo/src/app/style/page.tsx +++ b/examples/demo/src/app/style/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useUI } from '@austinserb/react-zero-ui'; +import { useUI } from '@react-zero-ui/core'; const variants = [ // Basic theme variants - background colors diff --git a/package.json b/package.json index 38097f5..a4d56ad 100644 --- a/package.json +++ b/package.json @@ -20,13 +20,15 @@ "test:cli": "cd packages/core && pnpm test:cli", "format": "prettier --write .", "lint": "eslint .", - "lint:fix": "eslint . --fix" + "lint:fix": "eslint . --fix", + "size": "npx esbuild ./packages/core/src/index.js --bundle --minify --format=esm --external:react --define:process.env.NODE_ENV='\"production\"' | gzip -c | wc -c" }, "devDependencies": { "@eslint/js": "^9.0.0", + "esbuild": "^0.25.5", "eslint": "^9.0.0", "eslint-plugin-node": "^11.1.0", "prettier": "^3.5.3", "release-please": "^17.0.0" } -} +} \ No newline at end of file diff --git a/packages/core/__tests__/config/playwright.next.config.js b/packages/core/__tests__/config/playwright.next.config.js index a89c959..138cd42 100644 --- a/packages/core/__tests__/config/playwright.next.config.js +++ b/packages/core/__tests__/config/playwright.next.config.js @@ -29,6 +29,7 @@ export default defineConfig({ projects: [ { name: 'next-cli-e2e', testMatch: /cli-next\.spec\.js/ }, { name: 'next-e2e', dependencies: ['next-cli-e2e'], testMatch: /next\.spec\.js/ }, + { name: 'next-scoped-e2e', dependencies: ['next-e2e'], testMatch: /next-scoped\.spec\.js/ }, ], webServer: { command: 'pnpm run dev', diff --git a/packages/core/__tests__/e2e/next-scoped.spec.js b/packages/core/__tests__/e2e/next-scoped.spec.js new file mode 100644 index 0000000..3f9cc1f --- /dev/null +++ b/packages/core/__tests__/e2e/next-scoped.spec.js @@ -0,0 +1,179 @@ +import { test, expect } from '@playwright/test'; + +test.describe.configure({ mode: 'serial' }); + +test.describe('Zero-UI Scoped State Tests', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/', { waitUntil: 'networkidle' }); + }); + + test('FAQ components start in closed state', async ({ page }) => { + console.log('\n🧪 Testing initial FAQ states'); + + // All FAQ answers should be hidden initially + for (let i = 1; i <= 3; i++) { + const answer = page.getByTestId(`faq-${i}-answer`); + await expect(answer).toBeHidden(); + console.log(`✅ FAQ ${i} answer is initially hidden`); + } + }); + + test('Each FAQ can be opened and closed independently', async ({ page }) => { + console.log('\n🧪 Testing independent FAQ state management'); + + // Open FAQ 1 + console.log('🖱️ Opening FAQ 1...'); + await page.getByTestId('faq-1-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeVisible(); + console.log('✅ FAQ 1 is open'); + + // Open FAQ 2 - FAQ 1 should stay open + console.log('🖱️ Opening FAQ 2...'); + await page.getByTestId('faq-2-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeVisible(); + await expect(page.getByTestId('faq-2-answer')).toBeVisible(); + console.log('✅ FAQ 1 and FAQ 2 are both open'); + + // Open FAQ 3 - FAQ 1 and 2 should stay open + console.log('🖱️ Opening FAQ 3...'); + await page.getByTestId('faq-3-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeVisible(); + await expect(page.getByTestId('faq-2-answer')).toBeVisible(); + await expect(page.getByTestId('faq-3-answer')).toBeVisible(); + console.log('✅ All FAQs are open simultaneously'); + }); + + test('Individual FAQ toggle functionality works correctly', async ({ page }) => { + console.log('\n🧪 Testing individual FAQ toggle functionality'); + + // Open all FAQs first + await page.getByTestId('faq-1-toggle').click(); + await page.getByTestId('faq-2-toggle').click(); + await page.getByTestId('faq-3-toggle').click(); + + // Verify all are open + await expect(page.getByTestId('faq-1-answer')).toBeVisible(); + await expect(page.getByTestId('faq-2-answer')).toBeVisible(); + await expect(page.getByTestId('faq-3-answer')).toBeVisible(); + console.log('✅ All FAQs opened'); + + // Close FAQ 2 - others should stay open + console.log('🖱️ Closing FAQ 2...'); + await page.getByTestId('faq-2-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeVisible(); + await expect(page.getByTestId('faq-2-answer')).toBeHidden(); + await expect(page.getByTestId('faq-3-answer')).toBeVisible(); + console.log('✅ FAQ 2 closed, FAQ 1 and 3 remain open'); + + // Close FAQ 1 - FAQ 3 should stay open + console.log('🖱️ Closing FAQ 1...'); + await page.getByTestId('faq-1-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeHidden(); + await expect(page.getByTestId('faq-2-answer')).toBeHidden(); + await expect(page.getByTestId('faq-3-answer')).toBeVisible(); + console.log('✅ FAQ 1 closed, only FAQ 3 remains open'); + }); + + test('Scoped state allows multiple FAQs to be open', async ({ page }) => { + console.log('\n🧪 Testing scoped state - multiple FAQs can be open'); + + // Function to count visible FAQ answers + const countVisibleAnswers = async () => { + let visibleCount = 0; + for (let i = 1; i <= 3; i++) { + const answer = page.getByTestId(`faq-${i}-answer`); + if (await answer.isVisible()) { + visibleCount++; + } + } + return visibleCount; + }; + + // Initially, no FAQs should be open + expect(await countVisibleAnswers()).toBe(0); + console.log('✅ Initially 0 FAQs are open'); + + // Open FAQ 1 + await page.getByTestId('faq-1-toggle').click(); + expect(await countVisibleAnswers()).toBe(1); + console.log('✅ 1 FAQ is open'); + + // Open FAQ 2 - should have 2 open + await page.getByTestId('faq-2-toggle').click(); + expect(await countVisibleAnswers()).toBe(2); + console.log('✅ 2 FAQs are open'); + + // Open FAQ 3 - should have 3 open + await page.getByTestId('faq-3-toggle').click(); + expect(await countVisibleAnswers()).toBe(3); + console.log('✅ All 3 FAQs are open'); + + // Close FAQ 2 - should have 2 open + await page.getByTestId('faq-2-toggle').click(); + expect(await countVisibleAnswers()).toBe(2); + console.log('✅ 2 FAQs remain open after closing one'); + }); + + test('FAQ components have correct data attributes and structure', async ({ page }) => { + console.log('\n🧪 Testing FAQ component structure and attributes'); + + // Check that each FAQ has the correct data-index + for (let i = 1; i <= 3; i++) { + const faqContainer = page.locator(`[data-index="${i}"]`); + await expect(faqContainer).toBeVisible(); + console.log(`✅ FAQ ${i} has correct data-index="${i}"`); + } + + // Check button text content + await expect(page.getByTestId('faq-1-toggle')).toHaveText('Question 1 +'); + await expect(page.getByTestId('faq-2-toggle')).toHaveText('Question 2 +'); + await expect(page.getByTestId('faq-3-toggle')).toHaveText('Question 3 +'); + console.log('✅ All FAQ buttons have correct text'); + + // Check answer content when opened + await page.getByTestId('faq-1-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toHaveText('Answer 1'); + console.log('✅ FAQ answer content is correct'); + }); + + test('FAQ styling classes are applied correctly', async ({ page }) => { + console.log('\n🧪 Testing FAQ CSS classes'); + + // Check that closed state has correct classes + const answer1 = page.getByTestId('faq-1-answer'); + await expect(answer1).toHaveClass(/faq-closed:hidden/); + await expect(answer1).toHaveClass(/faq-open:block/); + console.log('✅ FAQ has correct CSS classes for open/closed states'); + + // Open FAQ and verify styling + await page.getByTestId('faq-1-toggle').click(); + // The answer should now be visible due to faq-open:block class + await expect(answer1).toBeVisible(); + console.log('✅ FAQ styling works correctly when opened'); + }); + + test('Each FAQ manages its own scoped state independently', async ({ page }) => { + console.log('\n🧪 Testing scoped state independence'); + + // Test that each FAQ can be toggled independently + // Open FAQ 1 + await page.getByTestId('faq-1-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeVisible(); + + // Toggle FAQ 1 multiple times while keeping others in different states + await page.getByTestId('faq-2-toggle').click(); // Open FAQ 2 + await expect(page.getByTestId('faq-2-answer')).toBeVisible(); + + // Close FAQ 1, FAQ 2 should stay open + await page.getByTestId('faq-1-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeHidden(); + await expect(page.getByTestId('faq-2-answer')).toBeVisible(); + + // Reopen FAQ 1, FAQ 2 should still be open + await page.getByTestId('faq-1-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeVisible(); + await expect(page.getByTestId('faq-2-answer')).toBeVisible(); + + console.log('✅ Each FAQ maintains independent scoped state'); + }); +}); diff --git a/packages/core/__tests__/e2e/next.spec.js b/packages/core/__tests__/e2e/next.spec.js index aa70b16..085ef29 100644 --- a/packages/core/__tests__/e2e/next.spec.js +++ b/packages/core/__tests__/e2e/next.spec.js @@ -1,48 +1,168 @@ import { test, expect } from '@playwright/test'; +// Define test scenarios with proper expected values const scenarios = [ - { toggle: 'theme-toggle', attr: 'data-theme' }, - { toggle: 'theme-toggle-secondary', attr: 'data-theme-2' }, - { toggle: 'theme-toggle-3', attr: 'data-theme-three' }, + { + name: 'Primary Theme Toggle', + toggle: 'theme-toggle', + container: 'theme-container', + attr: 'data-theme', + initialValue: 'light', + toggledValue: 'dark', + initialText: 'Light', + toggledText: 'Dark', + }, + { + name: 'Secondary Theme Toggle', + toggle: 'theme-toggle-secondary', + container: 'theme-container-secondary', + attr: 'data-theme-2', + initialValue: 'light', + toggledValue: 'dark', + initialText: 'Light', + toggledText: 'Dark', + }, + { + name: 'Tertiary Theme Toggle', + toggle: 'theme-toggle-3', + container: 'theme-container-3', + attr: 'data-theme-three', + initialValue: 'light', + toggledValue: 'dark', + initialText: 'Light', + toggledText: 'Dark', + }, + { + name: 'Boolean Toggle', + toggle: 'toggle-boolean', + container: 'toggle-boolean-container', + attr: 'data-toggle-boolean', + initialValue: 'true', + toggledValue: 'false', + initialText: 'True', + toggledText: 'False', + }, + { + name: 'Number Toggle', + toggle: 'toggle-number', + container: 'toggle-number-container', + attr: 'data-number', + initialValue: '1', + toggledValue: '2', + initialText: '1', + toggledText: '2', + }, + { + name: 'UseEffect Component', + toggle: 'use-effect-theme', + container: 'use-effect-theme-container', + attr: 'data-use-effect-theme', + initialValue: 'light', + toggledValue: 'dark', + initialText: 'Light', + toggledText: 'Dark', + }, ]; -test.describe.configure({ mode: 'serial' }); // run one after another -test.describe('Zero-UI Next.js integration', () => { - for (const { toggle, attr } of scenarios) { - test(`starts "light" and flips <${attr}> → "dark"`, async ({ page }) => { - console.log(`\n🧪 Testing ${toggle} with attribute ${attr}`); +test.describe.configure({ mode: 'serial' }); - await page.goto('/', { waitUntil: 'networkidle' }); - console.log('📄 Page loaded'); +test.describe('Zero-UI Next.js Integration Tests', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/', { waitUntil: 'networkidle' }); + }); + + // Test each scenario + for (const scenario of scenarios) { + test(`${scenario.name}: toggles from ${scenario.initialValue} to ${scenario.toggledValue}`, async ({ page }) => { + console.log(`\n🧪 Testing ${scenario.name}`); const body = page.locator('body'); - const button = page.getByTestId(toggle); + const button = page.getByTestId(scenario.toggle); + const container = page.getByTestId(scenario.container); - /* ① Wait until the attribute is "light" */ - console.log(`🔍 Checking initial ${attr} attribute...`); + // Verify button exists + await expect(button).toBeVisible(); + console.log(`✅ Button ${scenario.toggle} is visible`); - // Debug: Check what the actual attribute value is - const actualValue = await body.getAttribute(attr); - console.log(`🐛 DEBUG: Current ${attr} value is:`, actualValue); + // Check initial state + console.log(`🔍 Checking initial state...`); + await expect(body).toHaveAttribute(scenario.attr, scenario.initialValue); - // Check if button exists - const buttonExists = await button.count(); - console.log(`🐛 DEBUG: Button ${toggle} count:`, buttonExists); + // Verify initial text is visible (use more specific selector) + const initialTextElement = container.locator('span').filter({ hasText: scenario.initialText }); + await expect(initialTextElement).toBeVisible(); - await expect(body).toHaveAttribute(attr, 'light'); - console.log(`✅ Initial ${attr} is "light"`); + // Also verify the other text is hidden + const toggledTextElement = container.locator('span').filter({ hasText: scenario.toggledText }); + await expect(toggledTextElement).toBeHidden(); + console.log(`✅ Initial state: ${scenario.attr}="${scenario.initialValue}", text="${scenario.initialText}"`); - /* ② Click & assert "dark" */ - console.log(`🖱️ Clicking ${toggle} button...`); + // Click to toggle + console.log(`🖱️ Clicking ${scenario.toggle}...`); await button.click(); - console.log(`🔍 Checking ${attr} attribute after click...`); - // Debug: Check what the actual attribute value is after click - const actualValueAfter = await body.getAttribute(attr); - console.log(`🐛 DEBUG: ${attr} value after click is:`, actualValueAfter); + // Check toggled state + console.log(`🔍 Checking toggled state...`); + await expect(body).toHaveAttribute(scenario.attr, scenario.toggledValue); + + // Verify toggled text is visible + const newVisibleElement = container.locator('span').filter({ hasText: scenario.toggledText }); + const newHiddenElement = container.locator('span').filter({ hasText: scenario.initialText }); + await expect(newVisibleElement).toBeVisible(); + await expect(newHiddenElement).toBeHidden(); + console.log(`✅ Toggled state: ${scenario.attr}="${scenario.toggledValue}", text="${scenario.toggledText}"`); + + // Click again to toggle back + console.log(`🖱️ Clicking ${scenario.toggle} again to toggle back...`); + await button.click(); - await expect(body).toHaveAttribute(attr, 'dark'); - console.log(`✅ ${attr} is now "dark"`); + // Verify it returns to initial state + await expect(body).toHaveAttribute(scenario.attr, scenario.initialValue); + const finalVisibleElement = container.locator('span').filter({ hasText: scenario.initialText }); + const finalHiddenElement = container.locator('span').filter({ hasText: scenario.toggledText }); + await expect(finalVisibleElement).toBeVisible(); + await expect(finalHiddenElement).toBeHidden(); + console.log(`✅ Returned to initial state: ${scenario.attr}="${scenario.initialValue}"`); }); } + + // Separate test for visual styling + test('Visual styling changes work correctly', async ({ page }) => { + const body = page.locator('body'); + const themeButton = page.getByTestId('theme-toggle'); + + await themeButton.click(); + await expect(body).toHaveAttribute('data-theme', 'dark'); + }); + + // Test all toggles work independently + test('Multiple toggles work independently', async ({ page }) => { + const body = page.locator('body'); + + // Click theme toggle + await page.getByTestId('theme-toggle').click(); + await expect(body).toHaveAttribute('data-theme', 'dark'); + + // Click boolean toggle + await page.getByTestId('toggle-boolean').click(); + await expect(body).toHaveAttribute('data-toggle-boolean', 'false'); + + // Verify theme is still dark + await expect(body).toHaveAttribute('data-theme', 'dark'); + + // Click number toggle + await page.getByTestId('toggle-number').click(); + await expect(body).toHaveAttribute('data-number', '2'); + + // Click scope toggle + await page.getByTestId('scope-toggle').click(); + await expect(body).toHaveAttribute('data-scope', 'true'); + + // Verify other states are preserved + await expect(body).toHaveAttribute('data-theme', 'dark'); + await expect(body).toHaveAttribute('data-toggle-boolean', 'false'); + }); + + // Test UseEffectComponent if it exists + // Test UseEffectComponent - automatically sets theme to dark on mount }); diff --git a/packages/core/__tests__/e2e/vite.spec.js b/packages/core/__tests__/e2e/vite.spec.js index 83ba5aa..e3b0faf 100644 --- a/packages/core/__tests__/e2e/vite.spec.js +++ b/packages/core/__tests__/e2e/vite.spec.js @@ -7,7 +7,7 @@ const scenarios = [ ]; test.describe.configure({ mode: 'serial' }); // run one after another -test.describe('Zero-UI Vite integration', () => { +test.describe(`Zero-UI Vite integration ${scenarios.map(({ toggle }) => toggle).join(', ')}`, () => { for (const { toggle, attr } of scenarios) { test(`starts "light" and flips <${attr}> → "dark"`, async ({ page }) => { console.log(`\n🧪 Testing ${toggle} with attribute ${attr}`); diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts index e65ecf4..76c513d 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts @@ -1,6 +1,8 @@ /* AUTO-GENERATED - DO NOT EDIT */ export declare const bodyAttributes: { + "data-faq": "closed" | "open"; "data-number": "1" | "2"; + "data-scope": "false" | "true"; "data-season": "maybe" | "off" | "on"; "data-theme": "dark" | "light"; "data-theme-2": "dark" | "light"; diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js index 711ff0b..96bc051 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js @@ -1,6 +1,8 @@ /* AUTO-GENERATED - DO NOT EDIT */ export const bodyAttributes = { + "data-faq": "closed", "data-number": "1", + "data-scope": "true", "data-season": "off", "data-theme": "light", "data-theme-2": "light", diff --git a/packages/core/__tests__/fixtures/next/app/FAQ.tsx b/packages/core/__tests__/fixtures/next/app/FAQ.tsx new file mode 100644 index 0000000..628ddee --- /dev/null +++ b/packages/core/__tests__/fixtures/next/app/FAQ.tsx @@ -0,0 +1,25 @@ +import { useUI } from '@react-zero-ui/core'; + +function FAQ({ question, answer, index }) { + const [, setOpen] = useUI<'open' | 'closed'>('faq', 'closed'); // Same key everywhere! + + return ( +
+ +
+ {answer} +
+
+ ); +} + +export default FAQ; diff --git a/packages/core/__tests__/fixtures/next/app/UseEffectComponent.tsx b/packages/core/__tests__/fixtures/next/app/UseEffectComponent.tsx index ff8e47e..b33ebd5 100644 --- a/packages/core/__tests__/fixtures/next/app/UseEffectComponent.tsx +++ b/packages/core/__tests__/fixtures/next/app/UseEffectComponent.tsx @@ -1,24 +1,26 @@ 'use client'; -import { useEffect } from 'react'; -import useUI from '@austinserb/react-zero-ui'; +import { useEffect, useState } from 'react'; +import useUI from '@react-zero-ui/core'; // Component that automatically cycles through themes using useEffect export default function UseEffectComponent() { const [, setAutoTheme] = useUI<'light' | 'dark'>('use-effect-theme', 'light'); + const [state, setState] = useState(false); useEffect(() => { - setAutoTheme('dark'); - }, []); + setAutoTheme(state ? 'dark' : 'light'); + }, [state]); return (
-
setState((prev) => !prev)} data-testid="use-effect-theme"> - Toggle Theme (useEffect on mount) -
+ Toggle Theme (useEffect on click) +
Theme: Dark Light
diff --git a/packages/core/__tests__/fixtures/next/app/layout.tsx b/packages/core/__tests__/fixtures/next/app/layout.tsx index e9b8664..5456a59 100644 --- a/packages/core/__tests__/fixtures/next/app/layout.tsx +++ b/packages/core/__tests__/fixtures/next/app/layout.tsx @@ -1,14 +1,11 @@ -import { bodyAttributes } from '@zero-ui/attributes'; +import { bodyAttributes } from "@zero-ui/attributes"; import './globals.css'; -export default function RootLayout({ children }) { - return ( - - +export default function RootLayout({ + children +}) { + return + {children} - - ); -} + ; +} \ No newline at end of file diff --git a/packages/core/__tests__/fixtures/next/app/page.tsx b/packages/core/__tests__/fixtures/next/app/page.tsx index 1b09f1d..56adfe6 100644 --- a/packages/core/__tests__/fixtures/next/app/page.tsx +++ b/packages/core/__tests__/fixtures/next/app/page.tsx @@ -1,7 +1,8 @@ 'use client'; -import useUI from '@austinserb/react-zero-ui'; +import useUI from '@react-zero-ui/core'; import UseEffectComponent from './UseEffectComponent'; +import FAQ from './FAQ'; export default function Page() { const [, setTheme] = useUI<'light' | 'dark'>('theme', 'light'); @@ -9,94 +10,149 @@ export default function Page() { const [, setThemeThree] = useUI<'light' | 'dark'>('themeThree', 'light'); const [, setToggle] = useUI('toggle-boolean', true); const [, setNumber] = useUI<1 | 2>('number', 1); + const [, setOpen] = useUI<'open' | 'closed'>('faq', 'closed'); // Same key everywhere! + const [, setScope] = useUI<'true' | 'false'>('scope', 'true'); + return ( -
- {/* Auto Theme Component */} - +
+

Global State

+
+
+ {/* Auto Theme Component */} + -
+
-
- -
- Theme: Dark Light +
+ +
+ Theme: Dark Light +
-
-
+
-
- -
- Theme: Dark Light +
+ +
+ Theme: Dark Light +
-
-
+
-
- -
- Theme: Dark Light +
+ +
+ Theme: Dark Light +
-
-
+
-
- -
- Boolean: True False +
+ +
+ Boolean: True False +
+
+
+ +
+ +
+ Number: 1 2 +

+

Scoped Style Tests

+
+ +
+
+ +
+ Scope: False + True +
+
+
-
+
-
- Number: 1 2 -
+
answer
+ + + +
); } diff --git a/packages/core/__tests__/fixtures/next/app/test/page.tsx b/packages/core/__tests__/fixtures/next/app/test/page.tsx index 2a74481..4a80b74 100644 --- a/packages/core/__tests__/fixtures/next/app/test/page.tsx +++ b/packages/core/__tests__/fixtures/next/app/test/page.tsx @@ -1,6 +1,6 @@ 'use client'; import React from 'react'; -import useUI from '@austinserb/react-zero-ui'; +import useUI from '@react-zero-ui/core'; const Component = () => { const [, setSeason] = useUI<'off' | 'on' | 'maybe'>('season', 'off'); @@ -17,7 +17,7 @@ const page = () => {
setSeason(prev => (prev === 'on' ? 'off' : 'on'))}> + onClick={() => setSeason((prev) => (prev === 'on' ? 'off' : 'on'))}>
CHILD
CHILD2
@@ -25,7 +25,7 @@ const page = () => {
diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 6cc5644..a6e4b6a 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -30,6 +30,11 @@ function useUI(key, initialValue) { // Create a ref to hold the DOM element that will receive the data-* attributes // This allows scoping UI state to specific elements instead of always using document.body const scopeRef = useRef(null); + + /* ─ DEV-ONLY MULTIPLE REF GUARD (removed in production by modern bundlers) ─ */ + const refAttachCount = process.env.NODE_ENV !== 'production' ? useRef(0) : null; + /* ─────────────────────────────────────────────────────────────────────── */ + // Convert kebab-case key to camelCase for dataset property access // e.g., "my-key" becomes "myKey" since dataset auto-lowercases after dashes const camelKey = key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()); @@ -80,9 +85,36 @@ function useUI(key, initialValue) { [key] // camelKey depends on key, so no need to include it in the dependency array ); + // -- DEV-ONLY MULTIPLE REF GUARD (removed in production by modern bundlers) -- // Attach the ref to the setter function so users can write:
// This creates a clean API where the ref and setter are bundled together - setValue.ref = scopeRef; + if (process.env.NODE_ENV !== 'production') { + // DEV: Wrap scopeRef to detect multiple attachments + setValue.ref = useCallback( + (node) => { + if (node !== null) { + refAttachCount.current++; + if (refAttachCount.current > 1) { + throw new Error( + // TODO add documentation link + `[useUI] Multiple ref attachments detected for key "${key}". ` + + `Each useUI hook supports only one ref attachment per component. ` + + `Solution: Create separate component. and reuse.\n` + + `Example: instead of multiple refs in one component.` + ); + } + } else { + // Handle cleanup when ref is detached + refAttachCount.current = Math.max(0, refAttachCount.current - 1); + } + scopeRef.current = node; + }, + [key] + ); + } else { + // PROD: Direct ref assignment for zero overhead + setValue.ref = scopeRef; + } // Return tuple matching React's useState pattern: [currentValue, setter] // Note: currentValue is always initialValue since this doesn't trigger re-renders diff --git a/packages/core/src/test-size.js b/packages/core/src/test-size.js deleted file mode 100644 index 76df7bf..0000000 --- a/packages/core/src/test-size.js +++ /dev/null @@ -1,20 +0,0 @@ -n.d(s, { A: () => r }); -var o = n(2444); -let r = function (e, t) { - let n = (0, o.useRef)(null), - r = (0, o.useCallback)( - o => { - let r; - if ('undefined' == typeof window) return; - let l = n.current ?? document.body, - u = e.replace(/-([a-z])/g, (e, t) => t.toUpperCase()); - if ('function' == typeof o) { - let e = l.dataset[u]; - r = o(e ? ('boolean' == typeof t ? 'true' === e : 'number' == typeof t ? (+e == +e ? +e : t) : e) : t); - } else r = o; - l.dataset[u] = r + ''; - }, - [e] - ); - return ((r.ref = n), [t, r]); -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0693d69..58ed001 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@eslint/js': specifier: ^9.0.0 version: 9.29.0 + esbuild: + specifier: ^0.25.5 + version: 0.25.5 eslint: specifier: ^9.0.0 version: 9.29.0(jiti@2.4.2) @@ -125,6 +128,156 @@ packages: '@conventional-commits/parser@0.4.1': resolution: {integrity: sha512-H2ZmUVt6q+KBccXfMBhbBF14NlANeqHTXL4qCL6QGbMzrc4HDXyzWuxPxPNbz71f/5UkR5DrycP5VO9u7crahg==} + '@esbuild/aix-ppc64@0.25.5': + resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.5': + resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.5': + resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.5': + resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.5': + resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.5': + resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.5': + resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.5': + resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.5': + resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.5': + resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.5': + resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.5': + resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.5': + resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.5': + resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.5': + resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.5': + resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.5': + resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.5': + resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.5': + resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.5': + resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.5': + resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.5': + resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.5': + resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.5': + resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.5': + resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.7.0': resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -605,6 +758,11 @@ packages: error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + esbuild@0.25.5: + resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1440,6 +1598,81 @@ snapshots: unist-util-visit: 2.0.3 unist-util-visit-parents: 3.1.1 + '@esbuild/aix-ppc64@0.25.5': + optional: true + + '@esbuild/android-arm64@0.25.5': + optional: true + + '@esbuild/android-arm@0.25.5': + optional: true + + '@esbuild/android-x64@0.25.5': + optional: true + + '@esbuild/darwin-arm64@0.25.5': + optional: true + + '@esbuild/darwin-x64@0.25.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.5': + optional: true + + '@esbuild/freebsd-x64@0.25.5': + optional: true + + '@esbuild/linux-arm64@0.25.5': + optional: true + + '@esbuild/linux-arm@0.25.5': + optional: true + + '@esbuild/linux-ia32@0.25.5': + optional: true + + '@esbuild/linux-loong64@0.25.5': + optional: true + + '@esbuild/linux-mips64el@0.25.5': + optional: true + + '@esbuild/linux-ppc64@0.25.5': + optional: true + + '@esbuild/linux-riscv64@0.25.5': + optional: true + + '@esbuild/linux-s390x@0.25.5': + optional: true + + '@esbuild/linux-x64@0.25.5': + optional: true + + '@esbuild/netbsd-arm64@0.25.5': + optional: true + + '@esbuild/netbsd-x64@0.25.5': + optional: true + + '@esbuild/openbsd-arm64@0.25.5': + optional: true + + '@esbuild/openbsd-x64@0.25.5': + optional: true + + '@esbuild/sunos-x64@0.25.5': + optional: true + + '@esbuild/win32-arm64@0.25.5': + optional: true + + '@esbuild/win32-ia32@0.25.5': + optional: true + + '@esbuild/win32-x64@0.25.5': + optional: true + '@eslint-community/eslint-utils@4.7.0(eslint@9.29.0(jiti@2.4.2))': dependencies: eslint: 9.29.0(jiti@2.4.2) @@ -1903,6 +2136,34 @@ snapshots: dependencies: is-arrayish: 0.2.1 + esbuild@0.25.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.5 + '@esbuild/android-arm': 0.25.5 + '@esbuild/android-arm64': 0.25.5 + '@esbuild/android-x64': 0.25.5 + '@esbuild/darwin-arm64': 0.25.5 + '@esbuild/darwin-x64': 0.25.5 + '@esbuild/freebsd-arm64': 0.25.5 + '@esbuild/freebsd-x64': 0.25.5 + '@esbuild/linux-arm': 0.25.5 + '@esbuild/linux-arm64': 0.25.5 + '@esbuild/linux-ia32': 0.25.5 + '@esbuild/linux-loong64': 0.25.5 + '@esbuild/linux-mips64el': 0.25.5 + '@esbuild/linux-ppc64': 0.25.5 + '@esbuild/linux-riscv64': 0.25.5 + '@esbuild/linux-s390x': 0.25.5 + '@esbuild/linux-x64': 0.25.5 + '@esbuild/netbsd-arm64': 0.25.5 + '@esbuild/netbsd-x64': 0.25.5 + '@esbuild/openbsd-arm64': 0.25.5 + '@esbuild/openbsd-x64': 0.25.5 + '@esbuild/sunos-x64': 0.25.5 + '@esbuild/win32-arm64': 0.25.5 + '@esbuild/win32-ia32': 0.25.5 + '@esbuild/win32-x64': 0.25.5 + escalade@3.2.0: {} escape-string-regexp@1.0.5: {} From cc83fb458dea50f13cea66be8c38c85c724c3422 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Sat, 28 Jun 2025 20:34:49 -0700 Subject: [PATCH 05/34] fix:starting the backup, before updating AST parsing --- packages/core/__tests__/fixtures/next/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/__tests__/fixtures/next/package.json b/packages/core/__tests__/fixtures/next/package.json index 72a02c0..34b75db 100644 --- a/packages/core/__tests__/fixtures/next/package.json +++ b/packages/core/__tests__/fixtures/next/package.json @@ -2,7 +2,7 @@ "name": "test-nextjs-app", "version": "1.0.0", "scripts": { - "dev": "next dev", + "dev": "rm -rf .next && next dev", "build": "next build", "start": "next start", "clean": "rm -rf .next node_modules package-lock.json" @@ -20,4 +20,4 @@ "tailwindcss": "^4.1.10", "typescript": "5.8.3" } -} +} \ No newline at end of file From 290314a5ce6720807e5510ea75381558914e2c6c Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Sat, 28 Jun 2025 20:34:58 -0700 Subject: [PATCH 06/34] refactor: update bodyAttributes and UI state handling; remove unused test file --- .../fixtures/next/.zero-ui/attributes.d.ts | 11 +- .../fixtures/next/.zero-ui/attributes.js | 5 +- .../core/__tests__/fixtures/next/app/page.tsx | 51 +++- .../__tests__/fixtures/next/app/test/page.jsx | 155 ++++++++++++ .../__tests__/fixtures/next/app/test/page.tsx | 35 --- packages/core/src/index.js | 17 +- packages/core/src/postcss/ast.cjs | 229 ++++++++++-------- packages/core/src/postcss/helpers.cjs | 17 +- packages/core/src/postcss/index.cjs | 3 +- 9 files changed, 346 insertions(+), 177 deletions(-) create mode 100644 packages/core/__tests__/fixtures/next/app/test/page.jsx delete mode 100644 packages/core/__tests__/fixtures/next/app/test/page.tsx diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts index 76c513d..75058e0 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts @@ -1,12 +1,11 @@ /* AUTO-GENERATED - DO NOT EDIT */ export declare const bodyAttributes: { "data-faq": "closed" | "open"; - "data-number": "1" | "2"; - "data-scope": "false" | "true"; - "data-season": "maybe" | "off" | "on"; - "data-theme": "dark" | "light"; - "data-theme-2": "dark" | "light"; - "data-theme-three": "dark" | "light"; + "data-number": string; + "data-scope": "off"; + "data-theme": "light"; + "data-theme-2": "light"; + "data-theme-three": "light"; "data-toggle-boolean": "false" | "true"; "data-use-effect-theme": "dark" | "light"; }; diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js index 96bc051..f4cf9ac 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js @@ -1,9 +1,8 @@ /* AUTO-GENERATED - DO NOT EDIT */ export const bodyAttributes = { "data-faq": "closed", - "data-number": "1", - "data-scope": "true", - "data-season": "off", + "data-number": "", + "data-scope": "off", "data-theme": "light", "data-theme-2": "light", "data-theme-three": "light", diff --git a/packages/core/__tests__/fixtures/next/app/page.tsx b/packages/core/__tests__/fixtures/next/app/page.tsx index 56adfe6..5befe96 100644 --- a/packages/core/__tests__/fixtures/next/app/page.tsx +++ b/packages/core/__tests__/fixtures/next/app/page.tsx @@ -11,7 +11,8 @@ export default function Page() { const [, setToggle] = useUI('toggle-boolean', true); const [, setNumber] = useUI<1 | 2>('number', 1); const [, setOpen] = useUI<'open' | 'closed'>('faq', 'closed'); // Same key everywhere! - const [, setScope] = useUI<'true' | 'false'>('scope', 'true'); + const [, setScope] = useUI<'off' | 'on'>('scope', 'off'); + const [, setMobile] = useUI('mobile', false); return (
@@ -23,9 +24,7 @@ export default function Page() {
-
+
-
- Scope: False - True +
+ Scope: False + True +
+
+
+ +
+ +
+ Mobile: False + True
-
+ {/*
answer
-
+
*/} +

Global State

+
+
+ {/* Auto Theme Component */} + + +
+ +
+ +
+ Theme: Dark Light +
+
+ +
+ +
+ +
+ Theme: Dark Light +
+
+ +
+ +
+ +
+ Theme: Dark Light +
+
+ +
+ +
+ +
+ Boolean: True False +
+
+
+ +
+ +
+ Number: 1 2 +
+
+
+
+

Scoped Style Tests

+
+ +
+
+ +
+ Scope: False + True +
+
+
+ + {/*
+ +
answer
+
*/} + + + + +
+ ); +} diff --git a/packages/core/__tests__/fixtures/next/app/test/page.tsx b/packages/core/__tests__/fixtures/next/app/test/page.tsx deleted file mode 100644 index 4a80b74..0000000 --- a/packages/core/__tests__/fixtures/next/app/test/page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'use client'; -import React from 'react'; -import useUI from '@react-zero-ui/core'; - -const Component = () => { - const [, setSeason] = useUI<'off' | 'on' | 'maybe'>('season', 'off'); - - return
setSeason('off')}>Component
; -}; - -const page = () => { - const [, setSeason] = useUI<'off' | 'on' | 'maybe'>('season', 'off'); - return ( -
- -
setSeason((prev) => (prev === 'on' ? 'off' : 'on'))}> -
- CHILD -
CHILD2
-
-
- -
- ); -}; - -export default page; diff --git a/packages/core/src/index.js b/packages/core/src/index.js index a6e4b6a..f22d10b 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -15,11 +15,12 @@ function useUI(key, initialValue) { const registry = typeof globalThis !== 'undefined' ? (globalThis.__useUIRegistry ||= new Map()) : new Map(); const prev = registry.get(key); + // TODO try to add per page error boundaries if (prev !== undefined && prev !== initialValue) { console.error( `[useUI] Inconsistent initial values for key "${key}": ` + - `expected "${prev}", got "${initialValue}". ` + - `Use the same initial value everywhere or namespace your keys.` + `expected "${prev}", got "${initialValue}". ` + + `Use the same initial value everywhere or namespace your keys.` ); } else if (prev === undefined) { registry.set(key, initialValue); @@ -62,12 +63,12 @@ function useUI(key, initialValue) { !value ? initialValue : // If initial was boolean, parse "true"/"false" string to boolean - typeof initialValue === 'boolean' + typeof initialValue === 'boolean' ? value === 'true' : // If initial was number, convert string to number with NaN fallback - typeof initialValue === 'number' + typeof initialValue === 'number' ? // The double conversion of +value is very fast and is the same as isNaN check without the function overhead - +value === +value + +value === +value ? +value : initialValue : value @@ -98,9 +99,9 @@ function useUI(key, initialValue) { throw new Error( // TODO add documentation link `[useUI] Multiple ref attachments detected for key "${key}". ` + - `Each useUI hook supports only one ref attachment per component. ` + - `Solution: Create separate component. and reuse.\n` + - `Example: instead of multiple refs in one component.` + `Each useUI hook supports only one ref attachment per component. ` + + `Solution: Create separate component. and reuse.\n` + + `Example: instead of multiple refs in one component.` ); } } else { diff --git a/packages/core/src/postcss/ast.cjs b/packages/core/src/postcss/ast.cjs index f732248..f1b3186 100644 --- a/packages/core/src/postcss/ast.cjs +++ b/packages/core/src/postcss/ast.cjs @@ -13,102 +13,119 @@ const { CONFIG } = require('../config.cjs'); // Cache for parsed files const fileCache = new Map(); -function extractVariants(filePath) { - try { - const code = fs.readFileSync(filePath, 'utf-8'); - if (!code.includes(CONFIG.HOOK_NAME)) return []; - // Check cache - const hash = crypto.createHash('md5').update(code).digest('hex'); - const cached = fileCache.get(filePath); - if (cached && cached.hash === hash) { - return cached.variants; - } - let ast; - ast = parser.parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); - const variants = CONFIG.SUPPORTED_EXTENSIONS.TYPESCRIPT.includes(path.extname(filePath).toLowerCase()) - ? extractTypeScriptVariants(ast) - : extractJavaScriptVariants(ast); - - // Update cache - fileCache.set(filePath, { hash, variants }); - - return variants; - } catch (error) { - console.error(`Error processing ${filePath}:`, error.message); - return []; - } -} - -function extractTypeScriptVariants(ast) { - const extractedUIStates = []; +// function extractVariants(filePath) { +// try { +// const code = fs.readFileSync(filePath, 'utf-8'); +// if (!code.includes(CONFIG.HOOK_NAME)) return []; +// // Check cache +// const hash = crypto.createHash('md5').update(code).digest('hex'); +// const cached = fileCache.get(filePath); +// if (cached && cached.hash === hash) { +// return cached.variants; +// } +// let ast; +// ast = parser.parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); +// const variants = CONFIG.SUPPORTED_EXTENSIONS.TYPESCRIPT.includes(path.extname(filePath).toLowerCase()) +// ? extractTypeScriptVariants(ast) +// : extractJavaScriptVariants(ast); + +// // Update cache +// fileCache.set(filePath, { hash, variants }); + +// return variants; +// } catch (error) { +// console.error(`Error processing ${filePath}:`, error.message); +// return []; +// } +// } + +// function extractTypeScriptVariants(ast) { +// const extractedUIStates = []; + +// traverse(ast, { +// CallExpression(path) { +// const callee = path.get('callee'); + +// if (!callee.isIdentifier() || callee.node.name !== CONFIG.HOOK_NAME) return; + +// // useUI('theme', 'light') -> hookArguments = ['theme', 'light'] +// const hookArguments = path.node.arguments; +// const typeScriptGenericTypes = path.node.typeParameters ? path.node.typeParameters.params : undefined; + +// // First arg: the state key (e.g., 'theme', 'modal', 'sidebar') +// const stateKeyArgument = hookArguments[0]; +// if (stateKeyArgument.type !== 'StringLiteral') return; +// const stateKey = stateKeyArgument.value; + +// let possibleStateValues = []; +// let initialStateValue = null; + +// // First, try to get values from TypeScript generic type +// if (typeScriptGenericTypes && typeScriptGenericTypes[0]) { +// const genericType = typeScriptGenericTypes[0]; + +// if (genericType.type === 'TSBooleanKeyword') { +// possibleStateValues = ['true', 'false']; +// } else if (genericType.type === 'TSUnionType') { +// // useUI<'light' | 'dark' | 'auto'> +// possibleStateValues = genericType.types +// .filter(unionMember => unionMember.type === 'TSLiteralType') +// .map(unionMember => unionMember.literal.value) +// .filter(Boolean) +// .map(String); +// } +// } + +// // If no TypeScript types found, infer from initial value +// if (possibleStateValues.length === 0) { +// // Second arg: the initial value (e.g., 'light', false, 'closed') +// const initialValueArgument = hookArguments[1]; + +// if (initialValueArgument.type === 'BooleanLiteral') { +// possibleStateValues = ['true', 'false']; +// initialStateValue = String(initialValueArgument.value); +// } else if (initialValueArgument.type === 'StringLiteral') { +// possibleStateValues = [initialValueArgument.value]; +// initialStateValue = initialValueArgument.value; +// } +// } else { +// // We do have TypeScript types, and still need to extract initial value +// const initialValueArgument = hookArguments[1]; +// if (initialValueArgument.type === 'StringLiteral') { +// initialStateValue = initialValueArgument.value; +// } else if (initialValueArgument.type === 'BooleanLiteral') { +// initialStateValue = String(initialValueArgument.value); +// } +// } + +// if (possibleStateValues.length > 0) { +// extractedUIStates.push({ +// key: stateKey, +// values: possibleStateValues, +// initialValue: initialStateValue, // Track initial value explicitly +// }); +// } +// }, +// }); + +// return extractedUIStates; +// } - traverse(ast, { - CallExpression(path) { - const callee = path.get('callee'); - - if (!callee.isIdentifier() || callee.node.name !== CONFIG.HOOK_NAME) return; - - // useUI('theme', 'light') -> hookArguments = ['theme', 'light'] - const hookArguments = path.node.arguments; - const typeScriptGenericTypes = path.node.typeParameters ? path.node.typeParameters.params : undefined; - - // First arg: the state key (e.g., 'theme', 'modal', 'sidebar') - const stateKeyArgument = hookArguments[0]; - if (stateKeyArgument.type !== 'StringLiteral') return; - const stateKey = stateKeyArgument.value; +function extractVariants(filePath) { + const code = fs.readFileSync(filePath, 'utf8'); + if (!code.includes(CONFIG.HOOK_NAME)) return []; - let possibleStateValues = []; - let initialStateValue = null; - - // First, try to get values from TypeScript generic type - if (typeScriptGenericTypes && typeScriptGenericTypes[0]) { - const genericType = typeScriptGenericTypes[0]; - - if (genericType.type === 'TSBooleanKeyword') { - possibleStateValues = ['true', 'false']; - } else if (genericType.type === 'TSUnionType') { - // useUI<'light' | 'dark' | 'auto'> - possibleStateValues = genericType.types - .filter(unionMember => unionMember.type === 'TSLiteralType') - .map(unionMember => unionMember.literal.value) - .filter(Boolean) - .map(String); - } - } + const hash = crypto.createHash('md5').update(code).digest('hex'); + const cached = fileCache.get(filePath); + if (cached && cached.hash === hash) return cached.variants; - // If no TypeScript types found, infer from initial value - if (possibleStateValues.length === 0) { - // Second arg: the initial value (e.g., 'light', false, 'closed') - const initialValueArgument = hookArguments[1]; - - if (initialValueArgument.type === 'BooleanLiteral') { - possibleStateValues = ['true', 'false']; - initialStateValue = String(initialValueArgument.value); - } else if (initialValueArgument.type === 'StringLiteral') { - possibleStateValues = [initialValueArgument.value]; - initialStateValue = initialValueArgument.value; - } - } else { - // We do have TypeScript types, and still need to extract initial value - const initialValueArgument = hookArguments[1]; - if (initialValueArgument.type === 'StringLiteral') { - initialStateValue = initialValueArgument.value; - } else if (initialValueArgument.type === 'BooleanLiteral') { - initialStateValue = String(initialValueArgument.value); - } - } + // Parse TS but treat it as 'JS with types' + const ast = parser.parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); - if (possibleStateValues.length > 0) { - extractedUIStates.push({ - key: stateKey, - values: possibleStateValues, - initialValue: initialStateValue, // Track initial value explicitly - }); - } - }, - }); + const variants = extractJavaScriptVariants(ast); - return extractedUIStates; + fileCache.set(filePath, { hash, variants }); + return variants; } function extractJavaScriptVariants(ast) { @@ -168,7 +185,7 @@ function extractJavaScriptVariants(ast) { if (callee.type === 'Identifier' && setterFunctionNameToStateKey.has(callee.name)) { const stateKey = setterFunctionNameToStateKey.get(callee.name); const setterArgumentValues = extractArgumentValues(callArguments[0], path); - setterArgumentValues.forEach(value => stateKeyToPossibleValues.get(stateKey).add(value)); + setterArgumentValues.forEach((value) => stateKeyToPossibleValues.get(stateKey).add(value)); } }, @@ -183,10 +200,10 @@ function extractJavaScriptVariants(ast) { }, }); - // Convert to final format with initial values + // Convert to final format with SORTED values (crucial for CSS stability) return Array.from(stateKeyToPossibleValues.entries()).map(([stateKey, possibleValuesSet]) => ({ key: stateKey, - values: Array.from(possibleValuesSet), + values: Array.from(possibleValuesSet).sort(), // Sort initialValue: stateKeyToInitialValue.get(stateKey) || null, })); } @@ -197,11 +214,11 @@ function checkExpressionForSetters(node, setterToKey, variants, path) { const expressions = body.type === 'BlockStatement' ? findCallExpressionsInBlock(body) : [body]; - expressions.forEach(expr => { + expressions.forEach((expr) => { if (expr.type === 'CallExpression' && expr.callee.type === 'Identifier' && setterToKey.has(expr.callee.name)) { const key = setterToKey.get(expr.callee.name); const values = extractArgumentValues(expr.arguments[0], path); - values.forEach(v => variants.get(key).add(v)); + values.forEach((v) => variants.get(key).add(v)); } }); } @@ -209,7 +226,7 @@ function checkExpressionForSetters(node, setterToKey, variants, path) { function findCallExpressionsInBlock(block) { const calls = []; - block.body.forEach(statement => { + block.body.forEach((statement) => { if (statement.type === 'ExpressionStatement' && statement.expression.type === 'CallExpression') { calls.push(statement.expression); } @@ -236,14 +253,14 @@ function extractArgumentValues(node, path) { case 'ConditionalExpression': // isActive ? 'react' : 'zero' - extractArgumentValues(node.consequent, path).forEach(v => values.add(v)); - extractArgumentValues(node.alternate, path).forEach(v => values.add(v)); + extractArgumentValues(node.consequent, path).forEach((v) => values.add(v)); + extractArgumentValues(node.alternate, path).forEach((v) => values.add(v)); break; case 'LogicalExpression': if (node.operator === '||' || node.operator === '??') { - extractArgumentValues(node.left, path).forEach(v => values.add(v)); - extractArgumentValues(node.right, path).forEach(v => values.add(v)); + extractArgumentValues(node.left, path).forEach((v) => values.add(v)); + extractArgumentValues(node.right, path).forEach((v) => values.add(v)); } break; @@ -313,7 +330,7 @@ function parseAndUpdatePostcssConfig(source, zeroUiPlugin, isESModule = false) { const isExportsAssignment = left.type === 'Identifier' && left.name === 'exports'; if ((isModuleExports || isExportsAssignment) && right.type === 'ObjectExpression') { - const pluginsProperty = right.properties.find(prop => prop.key && prop.key.name === 'plugins'); + const pluginsProperty = right.properties.find((prop) => prop.key && prop.key.name === 'plugins'); if (pluginsProperty) { modified = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); @@ -324,7 +341,7 @@ function parseAndUpdatePostcssConfig(source, zeroUiPlugin, isESModule = false) { // Handle ES Modules: export default { ... } ExportDefaultDeclaration(path) { if (isESModule && path.node.declaration.type === 'ObjectExpression') { - const pluginsProperty = path.node.declaration.properties.find(prop => prop.key && prop.key.name === 'plugins'); + const pluginsProperty = path.node.declaration.properties.find((prop) => prop.key && prop.key.name === 'plugins'); if (pluginsProperty) { modified = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); @@ -335,7 +352,7 @@ function parseAndUpdatePostcssConfig(source, zeroUiPlugin, isESModule = false) { // Handle: const config = { plugins: ... }; export default config VariableDeclarator(path) { if (isESModule && path.node.init && path.node.init.type === 'ObjectExpression') { - const pluginsProperty = path.node.init.properties.find(prop => prop.key && prop.key.name === 'plugins'); + const pluginsProperty = path.node.init.properties.find((prop) => prop.key && prop.key.name === 'plugins'); if (pluginsProperty) { modified = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); @@ -430,7 +447,7 @@ function processPluginsArray(pluginsArray) { * Helper to handle config object (creates plugins array if needed) */ function processConfigObject(configObject) { - const pluginsProperty = configObject.properties.find(prop => prop.key && prop.key.name === 'plugins'); + const pluginsProperty = configObject.properties.find((prop) => prop.key && prop.key.name === 'plugins'); if (pluginsProperty && pluginsProperty.value.type === 'ArrayExpression') { // Process existing plugins array @@ -580,7 +597,7 @@ async function patchNextBodyTag() { JSXOpeningElement(path) { if (!injected && t.isJSXIdentifier(path.node.name, { name: 'body' })) { // Prevent duplicate injection - const hasSpread = path.node.attributes.some(attr => t.isJSXSpreadAttribute(attr) && t.isIdentifier(attr.argument, { name: 'bodyAttributes' })); + const hasSpread = path.node.attributes.some((attr) => t.isJSXSpreadAttribute(attr) && t.isIdentifier(attr.argument, { name: 'bodyAttributes' })); if (!hasSpread) { path.node.attributes.unshift(t.jsxSpreadAttribute(t.identifier('bodyAttributes'))); injected = true; diff --git a/packages/core/src/postcss/helpers.cjs b/packages/core/src/postcss/helpers.cjs index 3312c56..4f5cd66 100644 --- a/packages/core/src/postcss/helpers.cjs +++ b/packages/core/src/postcss/helpers.cjs @@ -95,19 +95,22 @@ async function processVariants(files = null) { return { finalVariants, initialValues, sourceFiles }; } -// TODO build css for local and global variants separately based on scoped/global function buildCss(variants) { const lines = variants.flatMap(({ key, values }) => { + if (values.length === 0) return []; const keySlug = toKebabCase(key); - return values.map((v) => { + + // Double-ensure sorted order, even if extractor didn't sort + return [...values].sort().map((v) => { const valSlug = toKebabCase(v); - return `@custom-variant ${keySlug}-${valSlug} (&[data-${keySlug}="${valSlug}"],[data-${keySlug}="${valSlug}"] &, :where(body[data-${keySlug}="${valSlug}"] &));`; - // @custom-variant ${keySlug}-${valSlug} eg. - theme-light - // 1. &[data-${keySlug}="${valSlug}"] - element itself (wins ties) - // 2. [data-${keySlug}="${valSlug}"] & - nearest ancestor wrapper - // 3. :where(body[data-${keySlug}="${valSlug}"] &) - global fallback + + return `@custom-variant ${keySlug}-${valSlug} { + &:where(body[data-${keySlug}="${valSlug}"] *) { @slot; } + [data-${keySlug}="${valSlug}"] &, &[data-${keySlug}="${valSlug}"] { @slot; } +}`; }); }); + return CONFIG.HEADER + '\n' + lines.join('\n') + '\n'; } diff --git a/packages/core/src/postcss/index.cjs b/packages/core/src/postcss/index.cjs index 155b33a..b58290a 100644 --- a/packages/core/src/postcss/index.cjs +++ b/packages/core/src/postcss/index.cjs @@ -17,6 +17,7 @@ module.exports = () => { // Generate CSS const cssBlock = buildCss(finalVariants); + console.log('cssBlock: ', cssBlock); // Inject new CSS - prepend so it's before any @tailwind directives if (cssBlock.trim()) { @@ -24,7 +25,7 @@ module.exports = () => { } // Register dependencies - CRITICAL for file watching - sourceFiles.forEach(file => { + sourceFiles.forEach((file) => { result.messages.push({ type: 'dependency', plugin: 'postcss-react-zero-ui', file: file, parent: result.opts.from }); }); From 909b6977bf8a7ddd80ae11f43cf943ee150cf06b Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Sat, 28 Jun 2025 21:13:43 -0700 Subject: [PATCH 07/34] migrate:start micro-migration to .ts --- .gitignore | 3 +- package.json | 6 +- .../fixtures/next/.zero-ui/attributes.d.ts | 11 +- .../fixtures/next/.zero-ui/attributes.js | 3 +- packages/core/package.json | 10 +- packages/core/src/config.cjs | 1 + packages/core/src/postcss/ast-v2.ts | 55 +++++ packages/core/src/postcss/ast.cjs | 202 ++++++++---------- packages/core/tsconfig.json | 19 ++ pnpm-lock.yaml | 70 ++++++ tsconfig.base.json | 24 +++ 11 files changed, 282 insertions(+), 122 deletions(-) create mode 100644 packages/core/src/postcss/ast-v2.ts create mode 100644 packages/core/tsconfig.json create mode 100644 tsconfig.base.json diff --git a/.gitignore b/.gitignore index 007c1de..e884b7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ # package artifacts node_modules/ **/.next/ -dist/ +**/dist/ +**/build/ coverage/ test-results/ .pnpm-store/ diff --git a/package.json b/package.json index a4d56ad..0d00722 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preinstall": "npx only-allow pnpm", "reset": "git clean -fdx && pnpm install --frozen-lockfile && pnpm prepack:core && pnpm i-tarball", "bootstrap": "pnpm install --frozen-lockfile && pnpm prepack:core && pnpm i-tarball", + "build": "cd packages/core && pnpm build", "test": "cd packages/core && pnpm test:all", "test:all": "pnpm install --frozen-lockfile && pnpm prepack:core && pnpm lint && pnpm i-tarball && cd packages/core && pnpm test:all", "prepack:core": "pnpm -F @react-zero-ui/core pack --pack-destination ./dist", @@ -25,10 +26,13 @@ }, "devDependencies": { "@eslint/js": "^9.0.0", + "@types/node": "^24.0.7", "esbuild": "^0.25.5", "eslint": "^9.0.0", "eslint-plugin-node": "^11.1.0", "prettier": "^3.5.3", - "release-please": "^17.0.0" + "release-please": "^17.0.0", + "tsx": "^4.20.3", + "typescript": "^5.8.3" } } \ No newline at end of file diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts index 75058e0..cce8649 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts @@ -1,11 +1,12 @@ /* AUTO-GENERATED - DO NOT EDIT */ export declare const bodyAttributes: { "data-faq": "closed" | "open"; - "data-number": string; - "data-scope": "off"; - "data-theme": "light"; - "data-theme-2": "light"; - "data-theme-three": "light"; + "data-mobile": "false" | "true"; + "data-number": "1" | "2"; + "data-scope": "off" | "on"; + "data-theme": "dark" | "light"; + "data-theme-2": "dark" | "light"; + "data-theme-three": "dark" | "light"; "data-toggle-boolean": "false" | "true"; "data-use-effect-theme": "dark" | "light"; }; diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js index f4cf9ac..8a900fa 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js @@ -1,7 +1,8 @@ /* AUTO-GENERATED - DO NOT EDIT */ export const bodyAttributes = { "data-faq": "closed", - "data-number": "", + "data-mobile": "false", + "data-number": "1", "data-scope": "off", "data-theme": "light", "data-theme-2": "light", diff --git a/packages/core/package.json b/packages/core/package.json index b1ea194..c06abc7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -33,6 +33,7 @@ } }, "scripts": { + "build": "tsc -p tsconfig.json", "test:next": "playwright test -c __tests__/config/playwright.next.config.js", "test:vite": "playwright test -c __tests__/config/playwright.vite.config.js", "test:unit": "node --test __tests__/unit/index.test.cjs", @@ -69,10 +70,10 @@ "node": ">=18.0.0" }, "peerDependencies": { + "@tailwindcss/postcss": "^4.1.10", "postcss": "^8.5.5", "react": ">=16.8.0", - "tailwindcss": "^4.1.10", - "@tailwindcss/postcss": "^4.1.10" + "tailwindcss": "^4.1.10" }, "dependencies": { "@babel/generator": "^7.27.5", @@ -81,6 +82,7 @@ "@babel/types": "^7.27.4" }, "devDependencies": { - "@playwright/test": "^1.53.0" + "@playwright/test": "^1.53.0", + "@types/babel__traverse": "^7.20.7" } -} +} \ No newline at end of file diff --git a/packages/core/src/config.cjs b/packages/core/src/config.cjs index 656a05f..93279a2 100644 --- a/packages/core/src/config.cjs +++ b/packages/core/src/config.cjs @@ -8,4 +8,5 @@ const CONFIG = { }; const IGNORE_DIRS = new Set(['node_modules', '.next', '.turbo', '.vercel', '.git', 'coverage', 'out', 'public', 'dist', 'build']); + module.exports = { CONFIG, IGNORE_DIRS }; diff --git a/packages/core/src/postcss/ast-v2.ts b/packages/core/src/postcss/ast-v2.ts new file mode 100644 index 0000000..75dea58 --- /dev/null +++ b/packages/core/src/postcss/ast-v2.ts @@ -0,0 +1,55 @@ +import { parse } from '@babel/parser'; +import traverse, { Binding } from '@babel/traverse'; +import * as t from '@babel/types'; +import { CONFIG } from '../config.cjs'; + +export interface SetterMeta { + /** Babel binding object — use `binding.referencePaths` in Pass 2 */ + binding: Binding; + /** Variable name (`setTheme`) */ + setterName: string; + /** State key passed to `useUI` (`'theme'`) */ + stateKey: string; + /** Literal initial value as string, or `null` if non-literal */ + initialValue: string | null; +} + +/** + * Collects every `[ , setter ] = useUI('key', 'initial')` in a file. + */ +export function collectUseUISetters(ast: t.File): SetterMeta[] { + const setters: SetterMeta[] = []; + + traverse(ast, { + VariableDeclarator(path: any) { + const { id, init } = path.node; + + // Match: const [ , setX ] = useUI(...) + if (t.isArrayPattern(id) && id.elements.length === 2 && t.isCallExpression(init) && t.isIdentifier(init.callee, { name: CONFIG.HOOK_NAME })) { + const [, setterEl] = id.elements; + if (!t.isIdentifier(setterEl)) return; // hole or non-identifier + + // Validate & grab hook args + const [keyArg, initialArg] = init.arguments; + if (!t.isStringLiteral(keyArg)) return; // dynamic keys are ignored + + setters.push({ + binding: path.scope.getBinding(setterEl.name)!, // never null here + setterName: setterEl.name, + stateKey: keyArg.value, + initialValue: literalToString(initialArg as t.Expression), + }); + } + }, + }); + + return setters; +} + +/* ————— helpers ————— */ +function literalToString(node: t.Expression | undefined): string | null { + if (!node) return null; + if (t.isStringLiteral(node) || t.isNumericLiteral(node)) return String(node.value); + if (t.isBooleanLiteral(node)) return node.value ? 'true' : 'false'; + return null; // non-literal ⇒ treat as dynamic +} diff --git a/packages/core/src/postcss/ast.cjs b/packages/core/src/postcss/ast.cjs index f1b3186..4f86856 100644 --- a/packages/core/src/postcss/ast.cjs +++ b/packages/core/src/postcss/ast.cjs @@ -10,107 +10,41 @@ const path = require('path'); const fs = require('fs'); const { CONFIG } = require('../config.cjs'); +async function collectUseUISetters(ast) { + console.log('collectUseUISetters'); + const setters = []; + traverse(ast, { + VariableDeclarator(path) { + const { id, init } = path.node; + // Match: const [ , setX ] = useUI(...) + if (t.isArrayPattern(id) && id.elements.length === 2 && t.isCallExpression(init) && t.isIdentifier(init.callee, { name: CONFIG.HOOK_NAME })) { + const [, setterEl] = id.elements; + if (!t.isIdentifier(setterEl)) return; // hole or non-identifier + // Validate & grab hook args + const [keyArg, initialArg] = init.arguments; + if (!t.isStringLiteral(keyArg)) return; // dynamic keys are ignored + setters.push({ + binding: path.scope.getBinding(setterEl.name), // never null here + setterName: setterEl.name, + stateKey: keyArg.value, + initialValue: literalToString(initialArg), + }); + } + }, + }); + return setters; +} +/* ————— helpers ————— */ +function literalToString(node) { + if (!node) return null; + if (t.isStringLiteral(node) || t.isNumericLiteral(node)) return String(node.value); + if (t.isBooleanLiteral(node)) return node.value ? 'true' : 'false'; + return null; // non-literal ⇒ treat as dynamic +} + // Cache for parsed files const fileCache = new Map(); -// function extractVariants(filePath) { -// try { -// const code = fs.readFileSync(filePath, 'utf-8'); -// if (!code.includes(CONFIG.HOOK_NAME)) return []; -// // Check cache -// const hash = crypto.createHash('md5').update(code).digest('hex'); -// const cached = fileCache.get(filePath); -// if (cached && cached.hash === hash) { -// return cached.variants; -// } -// let ast; -// ast = parser.parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); -// const variants = CONFIG.SUPPORTED_EXTENSIONS.TYPESCRIPT.includes(path.extname(filePath).toLowerCase()) -// ? extractTypeScriptVariants(ast) -// : extractJavaScriptVariants(ast); - -// // Update cache -// fileCache.set(filePath, { hash, variants }); - -// return variants; -// } catch (error) { -// console.error(`Error processing ${filePath}:`, error.message); -// return []; -// } -// } - -// function extractTypeScriptVariants(ast) { -// const extractedUIStates = []; - -// traverse(ast, { -// CallExpression(path) { -// const callee = path.get('callee'); - -// if (!callee.isIdentifier() || callee.node.name !== CONFIG.HOOK_NAME) return; - -// // useUI('theme', 'light') -> hookArguments = ['theme', 'light'] -// const hookArguments = path.node.arguments; -// const typeScriptGenericTypes = path.node.typeParameters ? path.node.typeParameters.params : undefined; - -// // First arg: the state key (e.g., 'theme', 'modal', 'sidebar') -// const stateKeyArgument = hookArguments[0]; -// if (stateKeyArgument.type !== 'StringLiteral') return; -// const stateKey = stateKeyArgument.value; - -// let possibleStateValues = []; -// let initialStateValue = null; - -// // First, try to get values from TypeScript generic type -// if (typeScriptGenericTypes && typeScriptGenericTypes[0]) { -// const genericType = typeScriptGenericTypes[0]; - -// if (genericType.type === 'TSBooleanKeyword') { -// possibleStateValues = ['true', 'false']; -// } else if (genericType.type === 'TSUnionType') { -// // useUI<'light' | 'dark' | 'auto'> -// possibleStateValues = genericType.types -// .filter(unionMember => unionMember.type === 'TSLiteralType') -// .map(unionMember => unionMember.literal.value) -// .filter(Boolean) -// .map(String); -// } -// } - -// // If no TypeScript types found, infer from initial value -// if (possibleStateValues.length === 0) { -// // Second arg: the initial value (e.g., 'light', false, 'closed') -// const initialValueArgument = hookArguments[1]; - -// if (initialValueArgument.type === 'BooleanLiteral') { -// possibleStateValues = ['true', 'false']; -// initialStateValue = String(initialValueArgument.value); -// } else if (initialValueArgument.type === 'StringLiteral') { -// possibleStateValues = [initialValueArgument.value]; -// initialStateValue = initialValueArgument.value; -// } -// } else { -// // We do have TypeScript types, and still need to extract initial value -// const initialValueArgument = hookArguments[1]; -// if (initialValueArgument.type === 'StringLiteral') { -// initialStateValue = initialValueArgument.value; -// } else if (initialValueArgument.type === 'BooleanLiteral') { -// initialStateValue = String(initialValueArgument.value); -// } -// } - -// if (possibleStateValues.length > 0) { -// extractedUIStates.push({ -// key: stateKey, -// values: possibleStateValues, -// initialValue: initialStateValue, // Track initial value explicitly -// }); -// } -// }, -// }); - -// return extractedUIStates; -// } - function extractVariants(filePath) { const code = fs.readFileSync(filePath, 'utf8'); if (!code.includes(CONFIG.HOOK_NAME)) return []; @@ -122,6 +56,9 @@ function extractVariants(filePath) { // Parse TS but treat it as 'JS with types' const ast = parser.parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); + const setters = collectUseUISetters(ast); + console.log('setters: ', setters); + const variants = extractJavaScriptVariants(ast); fileCache.set(filePath, { hash, variants }); @@ -212,7 +149,7 @@ function checkExpressionForSetters(node, setterToKey, variants, path) { if (node.type === 'ArrowFunctionExpression') { const body = node.body; - const expressions = body.type === 'BlockStatement' ? findCallExpressionsInBlock(body) : [body]; + const expressions = body.type === 'BlockStatement' ? collectCallExpressions(body) : [body]; expressions.forEach((expr) => { if (expr.type === 'CallExpression' && expr.callee.type === 'Identifier' && setterToKey.has(expr.callee.name)) { @@ -224,13 +161,35 @@ function checkExpressionForSetters(node, setterToKey, variants, path) { } } -function findCallExpressionsInBlock(block) { - const calls = []; - block.body.forEach((statement) => { - if (statement.type === 'ExpressionStatement' && statement.expression.type === 'CallExpression') { - calls.push(statement.expression); - } - }); +function collectCallExpressions(node, calls = []) { + if (!node) return calls; + + switch (node.type) { + case 'CallExpression': + calls.push(node); + break; + + case 'BlockStatement': + // prev => { return 'dark'; } + node.body.forEach((stmt) => collectCallExpressions(stmt, calls)); + break; + + case 'IfStatement': + // prev => isActive ? 'react' : 'zero' + collectCallExpressions(node.consequent, calls); + if (node.alternate) collectCallExpressions(node.alternate, calls); + break; + + // handle other container nodes you care about; or just fall through… + default: + // Generic fallback: walk every enumerable child you might care about + for (const key in node) { + const child = node[key]; + if (Array.isArray(child)) child.forEach((n) => collectCallExpressions(n, calls)); + else if (child && typeof child.type === 'string') collectCallExpressions(child, calls); + } + } + return calls; } @@ -241,14 +200,17 @@ function extractArgumentValues(node, path) { switch (node.type) { case 'StringLiteral': + // 'light' -> 'light' values.add(node.value); break; case 'BooleanLiteral': - values.add(String(node.value)); + // true -> 'true' + values.add(node.value + ''); break; case 'NumericLiteral': - values.add(String(node.value)); + // 100 -> '100' + values.add(node.value + ''); break; case 'ConditionalExpression': @@ -258,6 +220,8 @@ function extractArgumentValues(node, path) { break; case 'LogicalExpression': + // isActive || 'react' + // isActive ?? 'react' if (node.operator === '||' || node.operator === '??') { extractArgumentValues(node.left, path).forEach((v) => values.add(v)); extractArgumentValues(node.right, path).forEach((v) => values.add(v)); @@ -265,6 +229,7 @@ function extractArgumentValues(node, path) { break; case 'Identifier': + // prev => prev === 'light' ? 'dark' : 'light' { const binding = path.scope.getBinding(node.name); if (binding && binding.path.isVariableDeclarator()) { @@ -281,8 +246,25 @@ function extractArgumentValues(node, path) { values.add(node.property.name.toLowerCase()); } break; - } + /* Handles updater fns like prev => prev === 'light' ? 'dark' : 'light' */ + case 'ArrowFunctionExpression': + case 'FunctionExpression': { + const body = node.body; + + // implicit return (single expression) + if (body.type !== 'BlockStatement') { + extractArgumentValues(body, path).forEach((v) => values.add(v)); + } else { + for (const stmt of body.body) { + if (stmt.type === 'ReturnStatement' && stmt.argument) { + extractArgumentValues(stmt.argument, path).forEach((v) => values.add(v)); + } + } + } + break; + } + } return values; } @@ -617,4 +599,4 @@ async function patchNextBodyTag() { console.log(`[Zero-UI] ✅ Patched in ${filePath} with {...bodyAttributes}`); } -module.exports = { extractVariants, parseJsonWithBabel, parseAndUpdatePostcssConfig, parseAndUpdateViteConfig, patchNextBodyTag }; +module.exports = { extractVariants, parseJsonWithBabel, parseAndUpdatePostcssConfig, parseAndUpdateViteConfig, patchNextBodyTag, collectUseUISetters }; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..330637b --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,19 @@ +// packages/core/tsconfig.ast-v2.json +{ + "extends": "../../tsconfig.base.json", + /* — compile exactly one file — */ + "include": [ + "src/postcss/ast-v2.ts" + ], + /* — compiler output — */ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "./src", // keeps relative paths clean + "outDir": "build/postcss", // compiled JS → build/ + // "declarationDir": "build/postcss", // .d.ts alongside JS + // "declaration": false, // not needed for now + "composite": false, // flip to true when you add references + "incremental": true // speeds up "one-file" rebuilds + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58ed001..dab17c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@eslint/js': specifier: ^9.0.0 version: 9.29.0 + '@types/node': + specifier: ^24.0.7 + version: 24.0.7 esbuild: specifier: ^0.25.5 version: 0.25.5 @@ -26,6 +29,12 @@ importers: release-please: specifier: ^17.0.0 version: 17.1.0 + tsx: + specifier: ^4.20.3 + version: 4.20.3 + typescript: + specifier: ^5.8.3 + version: 5.8.3 packages/cli: dependencies: @@ -72,6 +81,9 @@ importers: '@playwright/test': specifier: ^1.53.0 version: 1.53.1 + '@types/babel__traverse': + specifier: ^7.20.7 + version: 7.20.7 packages: @@ -535,6 +547,9 @@ packages: '@tailwindcss/postcss@4.1.10': resolution: {integrity: sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==} + '@types/babel__traverse@7.20.7': + resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -544,6 +559,9 @@ packages: '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} + '@types/node@24.0.7': + resolution: {integrity: sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -877,6 +895,11 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -884,6 +907,9 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-tsconfig@4.10.1: + resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1328,6 +1354,9 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.10: resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} engines: {node: '>= 0.4'} @@ -1423,6 +1452,11 @@ packages: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} + tsx@4.20.3: + resolution: {integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1448,11 +1482,19 @@ packages: engines: {node: '>=4.2.0'} hasBin: true + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} hasBin: true + undici-types@7.8.0: + resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + unist-util-is@4.1.0: resolution: {integrity: sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==} @@ -1923,12 +1965,20 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.10 + '@types/babel__traverse@7.20.7': + dependencies: + '@babel/types': 7.27.7 + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} '@types/minimist@1.2.5': {} + '@types/node@24.0.7': + dependencies: + undici-types: 7.8.0 + '@types/normalize-package-data@2.4.4': {} '@types/npm-package-arg@6.1.4': {} @@ -2297,10 +2347,17 @@ snapshots: fsevents@2.3.2: optional: true + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} get-caller-file@2.0.5: {} + get-tsconfig@4.10.1: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -2720,6 +2777,8 @@ snapshots: resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.10: dependencies: is-core-module: 2.16.1 @@ -2801,6 +2860,13 @@ snapshots: trim-newlines@3.0.1: {} + tsx@4.20.3: + dependencies: + esbuild: 0.25.5 + get-tsconfig: 4.10.1 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -2815,9 +2881,13 @@ snapshots: typescript@4.9.5: {} + typescript@5.8.3: {} + uglify-js@3.19.3: optional: true + undici-types@7.8.0: {} + unist-util-is@4.1.0: {} unist-util-visit-parents@3.1.1: diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..4d4dede --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,24 @@ +{ + // Shared defaults + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "NodeNext", + "verbatimModuleSyntax": true, + /* Gradual-migration flags */ + "allowJs": true, // <- JS still builds + "checkJs": false, // <- flip to true later for type-checking JS + "strict": true, // <- tighten as we go + "declaration": true, // <- emit declaration files + "emitDeclarationOnly": false, // <- emit declaration files only + "outDir": "dist", // <- output directory + /* Maps for monorepo imports like "@react-zero-ui/core" */ + "composite": true, + "rootDir": "src", + "forceConsistentCasingInFileNames": true + }, + "exclude": [ + "dist", + "node_modules" + ] +} \ No newline at end of file From 322d7d28d4a964bfe99c1699ac910bcdd918f8cd Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Sat, 28 Jun 2025 23:46:03 -0700 Subject: [PATCH 08/34] scoped styles work, with better ast, and partial typescript move --- .gitignore | 2 +- .../fixtures/next/.zero-ui/attributes.d.ts | 1 + .../fixtures/next/.zero-ui/attributes.js | 1 + .../core/__tests__/fixtures/next/app/page.tsx | 24 +- .../__tests__/fixtures/next/app/test/page.jsx | 56 ++- packages/core/src/postcss/ast-v2.ts | 55 --- packages/core/src/postcss/ast.cjs | 7 +- packages/core/src/postcss/helpers.cjs | 4 +- packages/core/src/postcss/v2/ast-v2.cts | 352 ++++++++++++++++++ packages/core/src/postcss/v2/build-tool.cts | 180 +++++++++ packages/core/src/postcss/v2/collect-refs.cts | 189 ++++++++++ .../core/src/postcss/v2/inject-attributes.cts | 207 ++++++++++ packages/core/tsconfig.json | 4 +- tsconfig.base.json | 6 +- 14 files changed, 1022 insertions(+), 66 deletions(-) delete mode 100644 packages/core/src/postcss/ast-v2.ts create mode 100644 packages/core/src/postcss/v2/ast-v2.cts create mode 100644 packages/core/src/postcss/v2/build-tool.cts create mode 100644 packages/core/src/postcss/v2/collect-refs.cts create mode 100644 packages/core/src/postcss/v2/inject-attributes.cts diff --git a/.gitignore b/.gitignore index e884b7b..0a1574a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ node_modules/ **/.next/ **/dist/ -**/build/ +# **/build/ coverage/ test-results/ .pnpm-store/ diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts index cce8649..eb87a3f 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts @@ -8,5 +8,6 @@ export declare const bodyAttributes: { "data-theme-2": "dark" | "light"; "data-theme-three": "dark" | "light"; "data-toggle-boolean": "false" | "true"; + "data-toggle-function": "black" | "white"; "data-use-effect-theme": "dark" | "light"; }; diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js index 8a900fa..2bbbf57 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.js @@ -8,5 +8,6 @@ export const bodyAttributes = { "data-theme-2": "light", "data-theme-three": "light", "data-toggle-boolean": "true", + "data-toggle-function": "white", "data-use-effect-theme": "light" }; diff --git a/packages/core/__tests__/fixtures/next/app/page.tsx b/packages/core/__tests__/fixtures/next/app/page.tsx index 5befe96..be8d85e 100644 --- a/packages/core/__tests__/fixtures/next/app/page.tsx +++ b/packages/core/__tests__/fixtures/next/app/page.tsx @@ -14,6 +14,12 @@ export default function Page() { const [, setScope] = useUI<'off' | 'on'>('scope', 'off'); const [, setMobile] = useUI('mobile', false); + const [, setToggleFunction] = useUI<'white' | 'black'>('toggle-function', 'white'); + + const toggleFunction = () => { + setToggleFunction((prev) => (prev === 'white' ? 'black' : 'white')); + }; + return (

Global State

@@ -103,6 +109,22 @@ export default function Page() { Number: 1 2
+
+ +
+ +
+ Function: White Black +
+

Scoped Style Tests

@@ -143,7 +165,7 @@ export default function Page() { } if (window.innerWidth < 768) { // force the mobile state to false on click - setMobile(false); + setMobile(true); } } }} diff --git a/packages/core/__tests__/fixtures/next/app/test/page.jsx b/packages/core/__tests__/fixtures/next/app/test/page.jsx index bfa7ad6..eea62dc 100644 --- a/packages/core/__tests__/fixtures/next/app/test/page.jsx +++ b/packages/core/__tests__/fixtures/next/app/test/page.jsx @@ -10,7 +10,15 @@ export default function Page() { const [, setThemeThree] = useUI('themeThree', 'light'); const [, setToggle] = useUI('toggle-boolean', true); const [, setNumber] = useUI('number', 1); - const [, setScope] = useUI('scope', 'off'); + const [, setOpen] = useUI('faq', 'closed'); // Same key everywhere! + const [, setScope] = useUI('scope', 'off'); + const [, setMobile] = useUI('mobile', false); + + const [, setToggleFunction] = useUI('toggle-function', 'white'); + + const toggleFunction = () => { + setToggleFunction((prev) => (prev === 'white' ? 'black' : 'white')); + }; return (
@@ -101,6 +109,22 @@ export default function Page() { Number: 1 2
+
+ +
+ +
+ Function: White Black +
+

Scoped Style Tests

@@ -124,6 +148,36 @@ export default function Page() { True
+
+ +
+ +
+ Mobile: False + True +
+
{/*
diff --git a/packages/core/src/postcss/ast-v2.ts b/packages/core/src/postcss/ast-v2.ts deleted file mode 100644 index 75dea58..0000000 --- a/packages/core/src/postcss/ast-v2.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { parse } from '@babel/parser'; -import traverse, { Binding } from '@babel/traverse'; -import * as t from '@babel/types'; -import { CONFIG } from '../config.cjs'; - -export interface SetterMeta { - /** Babel binding object — use `binding.referencePaths` in Pass 2 */ - binding: Binding; - /** Variable name (`setTheme`) */ - setterName: string; - /** State key passed to `useUI` (`'theme'`) */ - stateKey: string; - /** Literal initial value as string, or `null` if non-literal */ - initialValue: string | null; -} - -/** - * Collects every `[ , setter ] = useUI('key', 'initial')` in a file. - */ -export function collectUseUISetters(ast: t.File): SetterMeta[] { - const setters: SetterMeta[] = []; - - traverse(ast, { - VariableDeclarator(path: any) { - const { id, init } = path.node; - - // Match: const [ , setX ] = useUI(...) - if (t.isArrayPattern(id) && id.elements.length === 2 && t.isCallExpression(init) && t.isIdentifier(init.callee, { name: CONFIG.HOOK_NAME })) { - const [, setterEl] = id.elements; - if (!t.isIdentifier(setterEl)) return; // hole or non-identifier - - // Validate & grab hook args - const [keyArg, initialArg] = init.arguments; - if (!t.isStringLiteral(keyArg)) return; // dynamic keys are ignored - - setters.push({ - binding: path.scope.getBinding(setterEl.name)!, // never null here - setterName: setterEl.name, - stateKey: keyArg.value, - initialValue: literalToString(initialArg as t.Expression), - }); - } - }, - }); - - return setters; -} - -/* ————— helpers ————— */ -function literalToString(node: t.Expression | undefined): string | null { - if (!node) return null; - if (t.isStringLiteral(node) || t.isNumericLiteral(node)) return String(node.value); - if (t.isBooleanLiteral(node)) return node.value ? 'true' : 'false'; - return null; // non-literal ⇒ treat as dynamic -} diff --git a/packages/core/src/postcss/ast.cjs b/packages/core/src/postcss/ast.cjs index 4f86856..2db2436 100644 --- a/packages/core/src/postcss/ast.cjs +++ b/packages/core/src/postcss/ast.cjs @@ -10,7 +10,7 @@ const path = require('path'); const fs = require('fs'); const { CONFIG } = require('../config.cjs'); -async function collectUseUISetters(ast) { +function collectUseUISetters(ast) { console.log('collectUseUISetters'); const setters = []; traverse(ast, { @@ -56,8 +56,13 @@ function extractVariants(filePath) { // Parse TS but treat it as 'JS with types' const ast = parser.parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); + // TODO REMOVE IN PROD const setters = collectUseUISetters(ast); + console.log('setters: ', setters[0].binding.referencePaths); console.log('setters: ', setters); + console.log('setters - length: ', setters.length); + + // TODO REMOVE IN PROD const variants = extractJavaScriptVariants(ast); diff --git a/packages/core/src/postcss/helpers.cjs b/packages/core/src/postcss/helpers.cjs index 4f5cd66..273672e 100644 --- a/packages/core/src/postcss/helpers.cjs +++ b/packages/core/src/postcss/helpers.cjs @@ -2,7 +2,8 @@ const fs = require('fs'); const path = require('path'); const { CONFIG, IGNORE_DIRS } = require('../config.cjs'); -const { extractVariants, parseJsonWithBabel, parseAndUpdatePostcssConfig, parseAndUpdateViteConfig } = require('./ast.cjs'); +const { parseJsonWithBabel, parseAndUpdatePostcssConfig, parseAndUpdateViteConfig } = require('./ast.cjs'); +const { extractVariants } = require('../dist/postcss/v2/ast-v2.cjs'); function toKebabCase(str) { if (typeof str !== 'string') { @@ -370,7 +371,6 @@ function hasViteConfig() { module.exports = { toKebabCase, findAllSourceFiles, - extractVariants, buildCss, patchConfigAlias, patchPostcssConfig, diff --git a/packages/core/src/postcss/v2/ast-v2.cts b/packages/core/src/postcss/v2/ast-v2.cts new file mode 100644 index 0000000..c67b6e1 --- /dev/null +++ b/packages/core/src/postcss/v2/ast-v2.cts @@ -0,0 +1,352 @@ +import { parse } from '@babel/parser'; +import * as babelTraverse from '@babel/traverse'; +import { Binding, NodePath } from '@babel/traverse'; +import * as t from '@babel/types'; +import { CONFIG } from '../../config.cjs'; +import { readFileSync } from 'fs'; +import { createHash } from 'crypto'; +const traverse = (babelTraverse as any).default; + +export interface SetterMeta { + /** Babel binding object — use `binding.referencePaths` in Pass 2 */ + binding: Binding; + /** Variable name (`setTheme`) */ + setterName: string; + /** State key passed to `useUI` (`'theme'`) */ + stateKey: string; + /** Literal initial value as string, or `null` if non-literal */ + initialValue: string | null; +} + +/** + * Collects every `[ , setter ] = useUI('key', 'initial')` in a file. + * @returns SetterMeta[] + */ +export function collectUseUISetters(ast: t.File): SetterMeta[] { + const setters: SetterMeta[] = []; + + traverse(ast, { + VariableDeclarator(path: any) { + const { id, init } = path.node; + + // Match: const [ , setX ] = useUI(...) + if (t.isArrayPattern(id) && id.elements.length === 2 && t.isCallExpression(init) && t.isIdentifier(init.callee, { name: CONFIG.HOOK_NAME })) { + const [, setterEl] = id.elements; + if (!t.isIdentifier(setterEl)) return; // hole or non-identifier + + // Validate & grab hook args + const [keyArg, initialArg] = init.arguments; + if (!t.isStringLiteral(keyArg)) return; // dynamic keys are ignored + + setters.push({ + binding: path.scope.getBinding(setterEl.name)!, // never null here + setterName: setterEl.name, + stateKey: keyArg.value, + initialValue: literalToString(initialArg as t.Expression), + }); + } + }, + }); + + return setters; +} + +export interface VariantData { + key: string; + values: string[]; + initialValue: string | null; +} + +/** + * Pass 2: Harvest all values from setter calls by examining their reference paths + * @param setters - Array of SetterMeta from Pass 1 + * @returns Map of stateKey -> Set of discovered values + */ +export function harvestSetterValues(setters: SetterMeta[]): Map> { + const variants = new Map>(); + + // Initialize with initial values from Pass 1 + for (const setter of setters) { + if (!variants.has(setter.stateKey)) { + variants.set(setter.stateKey, new Set()); + } + if (setter.initialValue) { + variants.get(setter.stateKey)!.add(setter.initialValue); + } + } + + // Examine each setter's reference paths + for (const setter of setters) { + const valueSet = variants.get(setter.stateKey)!; + + // Boolean optimization: if initial value is 'true' or 'false', + // we know all possible values without traversing + if (setter.initialValue === 'true' || setter.initialValue === 'false') { + valueSet.add('true'); + valueSet.add('false'); + continue; // Skip traversal for this setter + } + + // Look at every place this setter is referenced + for (const referencePath of setter.binding.referencePaths) { + // Check if this reference is being called as a function + const callPath = findCallExpression(referencePath); + if (callPath) { + // Extract values from the first argument of the call + const firstArg = callPath.node.arguments[0]; + if (firstArg) { + const extractedValues = extractLiteralsRecursively(firstArg as t.Expression, callPath); + extractedValues.forEach((value) => valueSet.add(value)); + } + } + } + } + + return variants; +} + +/** + * Check if a reference path is part of a function call + * Handles: setTheme('dark'), obj.setTheme('dark'), etc. + */ +function findCallExpression(referencePath: NodePath): NodePath | null { + const parent = referencePath.parent; + + // Direct call: setTheme('dark') + if (t.isCallExpression(parent) && parent.callee === referencePath.node) { + return referencePath.parentPath as NodePath; + } + + // Member expression call: obj.setTheme('dark') + if (t.isMemberExpression(parent) && parent.property === referencePath.node) { + const grandParent = referencePath.parentPath?.parent; + if (t.isCallExpression(grandParent) && grandParent.callee === parent) { + return referencePath.parentPath?.parentPath as NodePath; + } + } + + return null; +} + +/** + * Recursively extract literal values from an expression + * Handles: literals, ternaries, logical expressions, functions, identifiers + */ +function extractLiteralsRecursively(node: t.Expression, path: NodePath): string[] { + const results: string[] = []; + + // Base case: direct literals + const literal = literalToString(node); + if (literal !== null) { + results.push(literal); + return results; + } + + // Ternary: condition ? 'value1' : 'value2' + if (t.isConditionalExpression(node)) { + results.push(...extractLiteralsRecursively(node.consequent, path)); + results.push(...extractLiteralsRecursively(node.alternate, path)); + } + + // Logical expressions: a && 'value' || 'default' + else if (t.isLogicalExpression(node)) { + results.push(...extractLiteralsRecursively(node.left, path)); + results.push(...extractLiteralsRecursively(node.right, path)); + } + + // Arrow functions: () => 'value' + else if (t.isArrowFunctionExpression(node)) { + if (t.isExpression(node.body)) { + results.push(...extractLiteralsRecursively(node.body, path)); + } else if (t.isBlockStatement(node.body)) { + // Look for return statements + results.push(...extractFromBlockStatement(node.body, path)); + } + } + + // Function expressions: function() { return 'value'; } + else if (t.isFunctionExpression(node)) { + results.push(...extractFromBlockStatement(node.body, path)); + } + + // Identifiers: resolve to their values if possible + else if (t.isIdentifier(node)) { + const resolved = resolveIdentifier(node, path); + if (resolved) { + results.push(...extractLiteralsRecursively(resolved, path)); + } + } + + // Binary expressions: might contain literals in some cases + else if (t.isBinaryExpression(node)) { + // Only extract if it's a simple concatenation that might resolve to a literal + if (node.operator === '+') { + results.push(...extractLiteralsRecursively(node.left as t.Expression, path)); + results.push(...extractLiteralsRecursively(node.right as t.Expression, path)); + } + } + + return results; +} + +/** + * Extract literals from block statements by finding return statements + */ +function extractFromBlockStatement(block: t.BlockStatement, path: NodePath): string[] { + const results: string[] = []; + + for (const stmt of block.body) { + if (t.isReturnStatement(stmt) && stmt.argument) { + results.push(...extractLiteralsRecursively(stmt.argument as t.Expression, path)); + } + } + + return results; +} + +/** + * Try to resolve an identifier to its value within the current scope + */ +function resolveIdentifier(node: t.Identifier, path: NodePath): t.Expression | null { + const binding = path.scope.getBinding(node.name); + if (!binding) return null; + + // Look at the binding's initialization + const bindingPath = binding.path; + + // Variable declarator: const x = 'value' + if (bindingPath.isVariableDeclarator() && bindingPath.node.init) { + return bindingPath.node.init as t.Expression; + } + + // Function parameter, import, etc. - could be extended + return null; +} + +/** + * Convert various literal types to strings + */ +function literalToString(node: t.Expression): string | null { + if (t.isStringLiteral(node) || t.isNumericLiteral(node)) { + return String(node.value); + } + if (t.isBooleanLiteral(node)) { + return node.value ? 'true' : 'false'; + } + if (t.isNullLiteral(node)) { + return 'null'; + } + if (t.isTemplateLiteral(node) && node.expressions.length === 0) { + // Simple template literal with no expressions: `hello` + return node.quasis[0]?.value.cooked || null; + } + return null; +} + +/** + * Convert the harvested variants map to the final output format + */ +export function normalizeVariants(variants: Map>, setters: SetterMeta[]): VariantData[] { + const result: VariantData[] = []; + + for (const [stateKey, valueSet] of variants) { + // Find the initial value from the original setter + const setter = setters.find((s) => s.stateKey === stateKey); + const initialValue = setter?.initialValue || null; + + // Sort values for deterministic output + const sortedValues = Array.from(valueSet).sort(); + + result.push({ key: stateKey, values: sortedValues, initialValue }); + } + + // Sort by key for deterministic output + return result.sort((a, b) => a.key.localeCompare(b.key)); +} + +// File cache to avoid re-parsing unchanged files +interface CacheEntry { + hash: string; + variants: VariantData[]; +} + +const fileCache = new Map(); + +/** + * Main function: Extract all variant tokens from a JS/TS file + * @param filePath - Path to the source file + * @returns Array of variant data objects + */ +export function extractVariants(filePath: string): VariantData[] { + try { + // Read and hash the file for caching + const sourceCode = readFileSync(filePath, 'utf-8'); + const fileHash = createHash('md5').update(sourceCode).digest('hex'); + + // Check cache first + const cached = fileCache.get(filePath); + if (cached && cached.hash === fileHash) { + return cached.variants; + } + + // Parse the file once + const ast = parse(sourceCode, { + sourceType: 'module', + plugins: ['jsx', 'typescript', 'decorators-legacy'], + allowImportExportEverywhere: true, + allowReturnOutsideFunction: true, + }); + + // Pass 1: Collect all useUI setters and their initial values + const setters = collectUseUISetters(ast); + + // Early return if no setters found + if (setters.length === 0) { + const result: VariantData[] = []; + fileCache.set(filePath, { hash: fileHash, variants: result }); + return result; + } + + // Pass 2: Harvest all values from setter calls + const variantsMap = harvestSetterValues(setters); + + // Normalize to final format + const variants = normalizeVariants(variantsMap, setters); + + // Cache and return + fileCache.set(filePath, { hash: fileHash, variants }); + return variants; + } catch (error) { + console.error(`Error extracting variants from ${filePath}:`, error); + return []; + } +} + +/** + * Extract variants from multiple files + * @param filePaths - Array of file paths to analyze + * @returns Combined and deduplicated variant data + */ +export function extractVariantsFromFiles(filePaths: string[]): VariantData[] { + const allVariants = new Map>(); + const initialValues = new Map(); + + for (const filePath of filePaths) { + const fileVariants = extractVariants(filePath); + + for (const variant of fileVariants) { + if (!allVariants.has(variant.key)) { + allVariants.set(variant.key, new Set()); + initialValues.set(variant.key, variant.initialValue); + } + + // Merge values + variant.values.forEach((value) => allVariants.get(variant.key)!.add(value)); + } + } + + // Convert back to VariantData format + return Array.from(allVariants.entries()) + .map(([key, valueSet]) => ({ key, values: Array.from(valueSet).sort(), initialValue: initialValues.get(key) || null })) + .sort((a, b) => a.key.localeCompare(b.key)); +} diff --git a/packages/core/src/postcss/v2/build-tool.cts b/packages/core/src/postcss/v2/build-tool.cts new file mode 100644 index 0000000..442f835 --- /dev/null +++ b/packages/core/src/postcss/v2/build-tool.cts @@ -0,0 +1,180 @@ +import { readFileSync, writeFileSync } from 'fs'; +import { glob } from 'glob'; +import { extractVariantsFromFiles } from './ast-v2.cts'; +import { batchInjectDataAttributes, SEMANTIC_CONFIG } from './inject-attributes.cts'; +import { RefLocationTracker } from './collect-refs.cts'; + +const globalRefTracker = new RefLocationTracker(); + +/** + * Complete build process example + */ +export async function buildWithDataAttributes() { + console.log('🔍 Scanning for useUI usage...'); + + // 1. Find all relevant files + const files = await glob('src/**/*.{ts,tsx,js,jsx}', { ignore: ['**/*.d.ts', '**/node_modules/**'] }); + + // 2. Extract variants and populate ref tracker + console.log(`📋 Processing ${files.length} files...`); + const allVariants = extractVariantsFromFiles(files); + + console.log( + '✅ Found variants:', + allVariants.map((v) => `${v.key}: [${v.values.join(', ')}]`) + ); + + // 3. Read all file contents + const fileContents = new Map(); + for (const filePath of files) { + const content = readFileSync(filePath, 'utf-8'); + fileContents.set(filePath, content); + } + + // 4. Inject data attributes + console.log('💉 Injecting data attributes...'); + const modifiedFiles = batchInjectDataAttributes(fileContents, globalRefTracker, allVariants, SEMANTIC_CONFIG); + + // 5. Write modified files (or handle according to your build system) + let modifiedCount = 0; + for (const [filePath, originalContent] of fileContents) { + const modifiedContent = modifiedFiles.get(filePath)!; + + if (modifiedContent !== originalContent) { + // In a real build system, you might return the modified content + // instead of writing directly to disk + writeFileSync(filePath, modifiedContent); + modifiedCount++; + } + } + + console.log(`✨ Modified ${modifiedCount} files with data attributes`); + + // 6. Generate CSS or other assets based on variants + generateCSS(allVariants); + + return { variants: allVariants, modifiedFiles: modifiedCount, refLocations: globalRefTracker.getAllRefs() }; +} + +/** + * Vite plugin example + */ +export function createViteUIPlugin() { + let refTracker: any; + let variants: any[]; + + return { + name: 'vite-ui-data-attributes', + buildStart() { + // Initialize on build start + console.log('🚀 Initializing UI data attribute injection...'); + }, + async buildEnd() { + // Extract variants from all processed files + const files = await glob('src/**/*.{ts,tsx,js,jsx}'); + variants = extractVariantsFromFiles(files); + refTracker = globalRefTracker; + + console.log('📊 Extracted variants:', variants); + }, + transform(code: string, id: string) { + // Only process relevant files + if (!/\.(tsx?|jsx?)$/.test(id)) return null; + + // Check if this file has any ref locations + const refLocations = refTracker?.getRefsByFile(id) || []; + if (refLocations.length === 0) return null; + + // Inject data attributes + try { + const { injectDataAttributes } = require('./data_attribute_injector'); + const transformedCode = injectDataAttributes(code, refLocations, variants || [], SEMANTIC_CONFIG); + + return { + code: transformedCode, + map: null, // You might want to generate source maps + }; + } catch (error) { + console.error('Error injecting data attributes:', error); + return null; + } + }, + }; +} + +/** + * Webpack loader example + */ +export function createWebpackUILoader() { + return function uiDataAttributeLoader(source: string) { + const callback = this.async(); + const resourcePath = this.resourcePath; + + // Only process relevant files + if (!/\.(tsx?|jsx?)$/.test(resourcePath)) { + return callback(null, source); + } + + try { + // Get ref locations for this file + const refLocations = globalRefTracker.getRefsByFile(resourcePath); + + if (refLocations.length === 0) { + return callback(null, source); + } + + // Get all variants (you'd need to make this available globally) + const variants = []; // This would come from your build context + + const { injectDataAttributes } = require('./data_attribute_injector'); + const transformedSource = injectDataAttributes(source, refLocations, variants, SEMANTIC_CONFIG); + + callback(null, transformedSource); + } catch (error) { + callback(error); + } + }; +} + +/** + * Generate CSS based on variants + */ +function generateCSS(variants: any[]) { + const cssRules: string[] = []; + + for (const variant of variants) { + for (const value of variant.values) { + // Generate CSS for each variant value + cssRules.push(`[data-ui-${variant.key}="${value}"] { + /* Styles for ${variant.key}=${value} */ +}`); + } + } + + const cssContent = cssRules.join('\n\n'); + writeFileSync('dist/ui-variants.css', cssContent); + + console.log('📝 Generated CSS with variant selectors'); +} + +/** + * Example of the transformation result + */ +const EXAMPLE_TRANSFORMATION = ` +// BEFORE: + + +// AFTER: + +`; + +console.log('Example transformation:', EXAMPLE_TRANSFORMATION); + +// CLI usage example +if (require.main === module) { + buildWithDataAttributes().catch(console.error); +} diff --git a/packages/core/src/postcss/v2/collect-refs.cts b/packages/core/src/postcss/v2/collect-refs.cts new file mode 100644 index 0000000..15bf38b --- /dev/null +++ b/packages/core/src/postcss/v2/collect-refs.cts @@ -0,0 +1,189 @@ +import * as t from '@babel/types'; +import { NodePath } from '@babel/traverse'; +import * as babelTraverse from '@babel/traverse'; +import type { SetterMeta } from './ast-v2.cts'; +const traverse = (babelTraverse as any).default; + +export interface RefLocation { + filePath: string; //'./src/components/ThemeToggle.tsx', + elementName: string; // 'button', + line: number; // 15 + column: number; // 8 + stateKey: string; // 'theme', + refProperty: string; // 'ref', +} + +/** + * Find all JSX attributes that reference setter.ref patterns + * @param ast - Parsed AST + * @param filePath - Current file path for location tracking + * @param setters - SetterMeta array from Pass 1 + * @returns RefLocation[] + */ +export function collectRefLocations(ast: t.File, filePath: string, setters: SetterMeta[]): RefLocation[] { + const refLocations: RefLocation[] = []; + + // Create a map of setter variable names to their state keys + const setterNameToStateKey = new Map(); + for (const setter of setters) { + setterNameToStateKey.set(setter.setterName, setter.stateKey); + } + + traverse(ast, { + JSXAttribute(path: NodePath) { + const { name, value } = path.node; + + // Must be a regular attribute (not spread) + if (!t.isJSXIdentifier(name)) return; + + // Must be exactly "ref" attribute + if (name.name !== 'ref') return; + + // Must be an expression container + if (!t.isJSXExpressionContainer(value)) return; + + // Must be exactly: setterFn.ref (no other variations allowed) + const expression = value.expression; + if (t.isMemberExpression(expression) && t.isIdentifier(expression.object) && t.isIdentifier(expression.property, { name: 'ref' })) { + const setterName = expression.object.name; + const stateKey = setterNameToStateKey.get(setterName); + + if (stateKey) { + // Find the JSX element name + const elementName = getJSXElementName(path); + const loc = path.node.loc; + + refLocations.push({ + filePath, + elementName, + line: loc?.start.line || 0, + column: loc?.start.column || 0, + stateKey, + refProperty: 'ref', // Always 'ref' now + }); + } + } + }, + }); + + return refLocations; +} + +/** + * Get the JSX element name from a JSX attribute path + */ +function getJSXElementName(attributePath: NodePath): string { + const jsxElement = attributePath.findParent((path) => path.isJSXElement()); + + if (jsxElement && jsxElement.isJSXElement()) { + const openingElement = jsxElement.node.openingElement; + const name = openingElement.name; + + if (t.isJSXIdentifier(name)) { + return name.name; + } else if (t.isJSXMemberExpression(name)) { + // Handle cases like + return getJSXMemberExpressionName(name); + } else if (t.isJSXNamespacedName(name)) { + // Handle cases like + return `${name.namespace.name}:${name.name.name}`; + } + } + + return 'unknown'; +} + +/** + * Convert JSX member expression to string (e.g., Component.SubComponent) + */ +function getJSXMemberExpressionName(expr: t.JSXMemberExpression): string { + const parts: string[] = []; + + let current: t.JSXMemberExpression | t.JSXIdentifier = expr; + while (t.isJSXMemberExpression(current)) { + if (t.isJSXIdentifier(current.property)) { + parts.unshift(current.property.name); + } + current = current.object; + } + + if (t.isJSXIdentifier(current)) { + parts.unshift(current.name); + } + + return parts.join('.'); +} + +/** + * Global ref location tracker for the entire app + */ +export class RefLocationTracker { + private refMap = new Map(); + + /** + * Add ref locations from a file + */ + addFile(filePath: string, refLocations: RefLocation[]): void { + this.refMap.set(filePath, refLocations); + } + + /** + * Get all ref locations for a specific state key + */ + getRefsByStateKey(stateKey: string): RefLocation[] { + const results: RefLocation[] = []; + + for (const locations of this.refMap.values()) { + results.push(...locations.filter((loc) => loc.stateKey === stateKey)); + } + + return results; + } + + /** + * Get all ref locations in a specific file + */ + getRefsByFile(filePath: string): RefLocation[] { + return this.refMap.get(filePath) || []; + } + + /** + * Get all ref locations for a specific element type + */ + getRefsByElement(elementName: string): RefLocation[] { + const results: RefLocation[] = []; + + for (const locations of this.refMap.values()) { + results.push(...locations.filter((loc) => loc.elementName === elementName)); + } + + return results; + } + + /** + * Get all ref locations in the entire app + */ + getAllRefs(): RefLocation[] { + const results: RefLocation[] = []; + + for (const locations of this.refMap.values()) { + results.push(...locations); + } + + return results; + } + + /** + * Clear all stored locations + */ + clear(): void { + this.refMap.clear(); + } + + /** + * Remove a specific file from tracking + */ + removeFile(filePath: string): void { + this.refMap.delete(filePath); + } +} diff --git a/packages/core/src/postcss/v2/inject-attributes.cts b/packages/core/src/postcss/v2/inject-attributes.cts new file mode 100644 index 0000000..8ea7ae4 --- /dev/null +++ b/packages/core/src/postcss/v2/inject-attributes.cts @@ -0,0 +1,207 @@ +import { parse } 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'; +import type { RefLocation, RefLocationTracker } from './collect-refs.cts'; +import type { VariantData } from './ast-v2.cts'; + +const traverse = (babelTraverse as any).default; + +export interface DataAttributeConfig { + /** Attribute name pattern. Use {stateKey} for substitution */ + attributeName: string; + /** Attribute value pattern. Use {stateKey}, {elementName}, {initialValue} for substitution */ + attributeValue?: string; + /** Whether to include the initial value in the attribute */ + includeInitialValue?: boolean; +} + +/** + * Default configuration for data attributes + */ +export const DEFAULT_DATA_ATTRIBUTE_CONFIG: DataAttributeConfig = { + attributeName: 'data-ui-{stateKey}', + attributeValue: '{initialValue}', // e.g., data-ui-theme="light" + includeInitialValue: true, +}; + +/** + * Inject data attributes into JSX elements based on ref locations + * @param sourceCode - Original source code + * @param refLocations - Array of ref locations from the ref tracker + * @param variantData - Variant data to get initial values + * @param config - Configuration for attribute generation + * @returns Modified source code with injected data attributes + */ +export function injectDataAttributes( + sourceCode: string, + refLocations: RefLocation[], + variantData: VariantData[], + config: DataAttributeConfig = DEFAULT_DATA_ATTRIBUTE_CONFIG +): string { + // Create a map of stateKey -> initial value for quick lookup + const initialValueMap = new Map(); + for (const variant of variantData) { + if (variant.initialValue) { + initialValueMap.set(variant.key, variant.initialValue); + } + } + + // Parse the source code + const ast = parse(sourceCode, { + sourceType: 'module', + plugins: ['jsx', 'typescript', 'decorators-legacy'], + allowImportExportEverywhere: true, + allowReturnOutsideFunction: true, + }); + + // Create a map of line:column -> RefLocation for quick lookup + const locationMap = new Map(); + for (const refLoc of refLocations) { + const key = `${refLoc.line}:${refLoc.column}`; + locationMap.set(key, refLoc); + } + + // Traverse and inject data attributes + traverse(ast, { + JSXOpeningElement(path: NodePath) { + const loc = path.node.loc; + if (!loc) return; + + const key = `${loc.start.line}:${loc.start.column}`; + const refLocation = locationMap.get(key); + + if (refLocation) { + // Check if the element already has a ref={setterFn.ref} attribute + const hasTargetRef = path.node.attributes.some((attr) => { + if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: 'ref' }) && t.isJSXExpressionContainer(attr.value)) { + const expr = attr.value.expression; + return t.isMemberExpression(expr) && t.isIdentifier(expr.property, { name: 'ref' }); + } + return false; + }); + + if (hasTargetRef) { + // Generate the data attribute + const dataAttr = createDataAttribute(refLocation, initialValueMap, config); + if (dataAttr) { + // Check if this data attribute already exists + const existingAttr = path.node.attributes.find( + (attr) => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name) && attr.name.name === dataAttr.name.name + ); + + if (!existingAttr) { + path.node.attributes.push(dataAttr); + } + } + } + } + }, + }); + + // Generate the modified code + const result = generate(ast, { retainLines: true, compact: false }); + + return result.code; +} + +/** + * Create a JSX data attribute based on ref location and config + */ +function createDataAttribute(refLocation: RefLocation, initialValueMap: Map, config: DataAttributeConfig): t.JSXAttribute | null { + const { stateKey, elementName } = refLocation; + const initialValue = initialValueMap.get(stateKey) || ''; + + // Generate attribute name + const attributeName = config.attributeName.replace('{stateKey}', stateKey).replace('{elementName}', elementName); + + // Generate attribute value + let attributeValue = ''; + if (config.attributeValue) { + attributeValue = config.attributeValue.replace('{stateKey}', stateKey).replace('{elementName}', elementName).replace('{initialValue}', initialValue); + } else if (config.includeInitialValue) { + attributeValue = initialValue; + } + + // Create the JSX attribute + const name = t.jsxIdentifier(attributeName); + const value = attributeValue ? t.stringLiteral(attributeValue) : null; + + return t.jsxAttribute(name, value); +} + +/** + * Batch process multiple files + * @param fileContents - Map of filePath -> source code + * @param refTracker - Global ref tracker with all locations + * @param variantData - All variant data + * @param config - Data attribute configuration + * @returns Map of filePath -> modified source code + */ +export function batchInjectDataAttributes( + fileContents: Map, + refTracker: RefLocationTracker, + variantData: VariantData[], + config: DataAttributeConfig = DEFAULT_DATA_ATTRIBUTE_CONFIG +): Map { + const results = new Map(); + + for (const [filePath, sourceCode] of fileContents) { + const refLocations = refTracker.getRefsByFile(filePath); + + if (refLocations.length > 0) { + const modifiedCode = injectDataAttributes(sourceCode, refLocations, variantData, config); + results.set(filePath, modifiedCode); + } else { + // No refs in this file, return unchanged + results.set(filePath, sourceCode); + } + } + + return results; +} + +/** + * Webpack/Vite plugin helper + * Transform source code during build + */ +export function createDataAttributeTransformer( + refTracker: RefLocationTracker, + variantData: VariantData[], + config: DataAttributeConfig = DEFAULT_DATA_ATTRIBUTE_CONFIG +) { + return function transformCode(sourceCode: string, filePath: string): string { + const refLocations = refTracker.getRefsByFile(filePath); + + if (refLocations.length === 0) { + return sourceCode; // No changes needed + } + + return injectDataAttributes(sourceCode, refLocations, variantData, config); + }; +} + +// Example usage configurations: + +/** + * Configuration for semantic data attributes + */ +export const SEMANTIC_CONFIG: DataAttributeConfig = { attributeName: 'data-ui-{stateKey}', attributeValue: '{initialValue}', includeInitialValue: true }; +// Result:
- {/*
+
answer
-
*/} +
{ } `, }, - result => { + (result) => { // Check attributes file exists assert(fs.existsSync(getAttrFile()), 'Attributes file should exist'); @@ -97,7 +97,7 @@ test('generates body attributes file correctly when kebab-case is used', async ( } `, }, - result => { + (result) => { // Check attributes file exists assert(fs.existsSync(getAttrFile()), 'Attributes file should exist'); @@ -116,35 +116,6 @@ test('generates body attributes file correctly when kebab-case is used', async ( ); }); -test('handles TypeScript generic types', async () => { - await runTest( - { - 'src/component.tsx': ` - import { useUI } from 'react-zero-ui'; - - function Component() { - const [status, setStatus] = useUI<'idle' | 'loading' | 'success' | 'error'>('status', 'idle'); - return
Status: {status}
; - } - `, - }, - result => { - console.log('\n🔍 TypeScript Generic Test:'); - - // Check all variants were generated - const variants = ['idle', 'loading', 'success', 'error']; - variants.forEach(variant => { - assert(result.css.includes(`@custom-variant status-${variant}`), `Should have status-${variant} variant`); - }); - - // Check attributes file - const content = fs.readFileSync(getAttrFile(), 'utf-8'); - console.log('Attributes:', content); - assert(content.includes('"data-status": "idle"'), 'Should use initial value'); - } - ); -}); - test('detects JavaScript setValue calls', async () => { await runTest( { @@ -171,11 +142,11 @@ test('detects JavaScript setValue calls', async () => { } `, }, - result => { + (result) => { console.log('\n🔍 JavaScript Detection Test:'); const states = ['closed', 'open', 'minimized', 'fullscreen']; - states.forEach(state => { + states.forEach((state) => { assert(result.css.includes(`@custom-variant modal-${state}`), `Should detect modal-${state}`); }); @@ -203,7 +174,7 @@ test('handles boolean values', async () => { } `, }, - result => { + (result) => { console.log('\n🔍 Boolean Values Test:'); assert(result.css.includes('@custom-variant drawer-true'), 'Should have drawer-true'); @@ -236,7 +207,7 @@ test('handles kebab-case conversion', async () => { } `, }, - result => { + (result) => { console.log('\n🔍 Kebab-case Test:'); // Check CSS has kebab-case @@ -279,11 +250,11 @@ test('handles conditional expressions', async () => { } `, }, - result => { + (result) => { console.log('\n🔍 Conditional Expressions Test:'); const expectedStates = ['default', 'active', 'inactive', 'night', 'day', 'fallback']; - expectedStates.forEach(state => { + expectedStates.forEach((state) => { assert(result.css.includes(`@custom-variant state-${state}`), `Should detect state-${state}`); }); } @@ -304,23 +275,32 @@ test('handles multiple files and deduplication', async () => { import { useUI } from 'react-zero-ui'; function Footer() { const [theme, setTheme] = useUI('theme', 'light'); - return ; + return
+ + + + Footer +
; } `, 'app/sidebar.tsx': ` import { useUI } from 'react-zero-ui'; function Sidebar() { const [theme, setTheme] = useUI<'light' | 'dark' | 'auto'>('theme', 'light'); - return
Sidebar
; + return
+ + + + + Sidebar +
; } `, }, - result => { - console.log('\n🔍 Multiple Files Test: ', result.css); - + (result) => { // Should combine all theme values from all files const themeVariants = ['light', 'dark', 'blue', 'auto']; - themeVariants.forEach(variant => { + themeVariants.forEach((variant) => { assert(result.css.includes(`@custom-variant theme-${variant}`), `Should have theme-${variant}`); }); @@ -349,9 +329,8 @@ test('handles parsing errors gracefully', async () => { } `, }, - result => { + (result) => { console.log('\n🔍 Parse Error Test:'); - console.log('result: ', result.css); // Should still process valid files assert(result.css.includes('@custom-variant valid-working'), 'Should process valid files'); @@ -378,7 +357,7 @@ test('valid edge cases: underscores + missing initial', async () => { } `, }, - result => { + (result) => { console.log('result: ', result.css); assert(result.css.includes('@custom-variant only-setter-key-set-later')); assert(!result.css.includes('@custom-variant no-initial-value')); @@ -402,7 +381,7 @@ test('watches for file changes', async () => { } `, }, - async result => { + async (result) => { // Initial state assert(result.css.includes('@custom-variant watch-test-initial')); @@ -419,7 +398,7 @@ test('watches for file changes', async () => { ); // Wait for file watcher to process - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 500)); // Re-process to check if watcher picked up changes const result2 = await postcss([plugin()]).process('', { from: undefined }); @@ -435,7 +414,7 @@ test('ignores node_modules and hidden directories', async () => { 'src/valid.jsx': ` import { useUI } from 'react-zero-ui'; function Valid() { - const [state] = useUI('valid', 'yes'); + const [state, setState] = useUI('valid', 'yes'); return
Valid
; } `, @@ -454,7 +433,7 @@ test('ignores node_modules and hidden directories', async () => { } `, }, - result => { + (result) => { console.log('result: ', result.css); assert(result.css.includes('@custom-variant valid-yes'), 'Should process valid files'); assert(!result.css.includes('ignored'), 'Should ignore node_modules'); @@ -474,58 +453,22 @@ test('handles deeply nested file structures', async () => { } `, }, - result => { + (result) => { assert(result.css.includes('@custom-variant auth-state-logged-out')); assert(result.css.includes('@custom-variant auth-state-logged-in')); } ); }); -test('handles complex TypeScript scenarios', async () => { - await runTest( - { - 'src/complex.tsx': ` - import { useUI } from 'react-zero-ui'; - - type Status = 'idle' | 'loading' | 'success' | 'error'; - - function Complex() { - // Type reference - const [status] = useUI('status', 'idle'); - - // Inline boolean - const [open] = useUI('modal', false); - - // String literal union with many values - const [size] = useUI<'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'>('size', 'md'); - - return
Complex types
; - } - `, - }, - result => { - // Should extract all size variants - ['xs', 'sm', 'md', 'lg', 'xl', '2xl'].forEach(size => { - assert(result.css.includes(`@custom-variant size-${size}`), `Should have size-${size}`); - }); - // Check attributes file - const content = fs.readFileSync(getAttrFile(), 'utf-8'); - assert(content.includes('"data-size": "md"'), 'Should have size-md'); - assert(content.includes('"data-status": "idle"'), 'Should have status-idle'); - assert(content.includes('"data-modal": "false"'), 'Should have modal-false'); - } - ); -}); - -test('handles large projects efficiently', async function () { +test('handles large projects efficiently - 500 files', async function () { const files = {}; // Generate 50 files - for (let i = 0; i < 50; i++) { + for (let i = 0; i < 500; i++) { files[`src/component${i}.jsx`] = ` import { useUI } from 'react-zero-ui'; function Component${i}() { - const [state${i}] = useUI('state${i}', 'value${i}'); + const [state${i}, setState${i}] = useUI('state${i}', 'value${i}'); return
Component ${i}
; } `; @@ -533,11 +476,12 @@ test('handles large projects efficiently', async function () { const startTime = Date.now(); - await runTest(files, result => { + await runTest(files, (result) => { + console.log('handles large projects efficiently-result: ', result.css); const endTime = Date.now(); const duration = endTime - startTime; - console.log(`\n⚡ Performance: Processed 50 files in ${duration}ms`); + console.log(`\n⚡ Performance: Processed 500 files in ${duration}ms`); // Should process all files assert(result.css.includes('@custom-variant state49-value49'), 'Should process all files'); @@ -564,7 +508,7 @@ test('handles special characters in values', async () => { } `, }, - result => { + (result) => { assert(result.css.includes('@custom-variant special-with-dash')); assert(result.css.includes('@custom-variant special-with-underscore')); assert(result.css.includes('@custom-variant special-123numeric')); @@ -599,7 +543,7 @@ test('handles concurrent file modifications', async () => { ); // Small delay to simulate real editing - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); } // Final processing should work correctly @@ -610,7 +554,7 @@ test('handles concurrent file modifications', async () => { ); }); -test('patchConfigAlias - config file patching', async t => { +test('patchConfigAlias - config file patching', async (t) => { await t.test('patches tsconfig.json when it exists', async () => { const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zero-ui-config-test')); const originalCwd = process.cwd(); diff --git a/packages/core/__tests__/unit/inject-attributes.test.cjs b/packages/core/__tests__/unit/inject-attributes.test.cjs new file mode 100644 index 0000000..e69de29 diff --git a/packages/core/package.json b/packages/core/package.json index c06abc7..d182524 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -79,10 +79,12 @@ "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/traverse": "^7.27.4", - "@babel/types": "^7.27.4" + "@babel/types": "^7.27.4", + "glob": "^11.0.0" }, "devDependencies": { "@playwright/test": "^1.53.0", + "@types/babel__generator": "^7.27.0", "@types/babel__traverse": "^7.20.7" } } \ No newline at end of file diff --git a/packages/core/src/config.cjs b/packages/core/src/config.cjs index 93279a2..6df329f 100644 --- a/packages/core/src/config.cjs +++ b/packages/core/src/config.cjs @@ -5,8 +5,20 @@ const CONFIG = { MAX_HOOK_ARGUMENTS: 2, HEADER: '/* AUTO-GENERATED - DO NOT EDIT */', ZERO_UI_DIR: '.zero-ui', + CONTENT: ['src/**/*.{ts,tsx,js,jsx}', 'app/**/*.{ts,tsx,js,jsx}'], }; -const IGNORE_DIRS = new Set(['node_modules', '.next', '.turbo', '.vercel', '.git', 'coverage', 'out', 'public', 'dist', 'build']); +const IGNORE_DIRS = new Set([ + '**/node_modules/**', + '**/.next/**', + '**/.turbo/**', + '**/.vercel/**', + '**/.git/**', + '**/coverage/**', + '**/out/**', + '**/public/**', + '**/dist/**', + '**/build/**', +]); module.exports = { CONFIG, IGNORE_DIRS }; diff --git a/packages/core/src/dist/config.cjs b/packages/core/src/dist/config.cjs new file mode 100644 index 0000000..f4a0ee9 --- /dev/null +++ b/packages/core/src/dist/config.cjs @@ -0,0 +1,23 @@ +"use strict"; +const CONFIG = { + SUPPORTED_EXTENSIONS: { TYPESCRIPT: ['.ts', '.tsx'], JAVASCRIPT: ['.js', '.jsx'] }, + HOOK_NAME: 'useUI', + MIN_HOOK_ARGUMENTS: 2, + MAX_HOOK_ARGUMENTS: 2, + HEADER: '/* AUTO-GENERATED - DO NOT EDIT */', + ZERO_UI_DIR: '.zero-ui', + CONTENT: ['src/**/*.{ts,tsx,js,jsx}', 'app/**/*.{ts,tsx,js,jsx}'], +}; +const IGNORE_DIRS = new Set([ + '**/node_modules/**', + '**/.next/**', + '**/.turbo/**', + '**/.vercel/**', + '**/.git/**', + '**/coverage/**', + '**/out/**', + '**/public/**', + '**/dist/**', + '**/build/**', +]); +module.exports = { CONFIG, IGNORE_DIRS }; diff --git a/packages/core/src/dist/config.d.cts b/packages/core/src/dist/config.d.cts new file mode 100644 index 0000000..2035770 --- /dev/null +++ b/packages/core/src/dist/config.d.cts @@ -0,0 +1,13 @@ +export namespace CONFIG { + namespace SUPPORTED_EXTENSIONS { + let TYPESCRIPT: string[]; + let JAVASCRIPT: string[]; + } + let HOOK_NAME: string; + let MIN_HOOK_ARGUMENTS: number; + let MAX_HOOK_ARGUMENTS: number; + let HEADER: string; + let ZERO_UI_DIR: string; + let CONTENT: string[]; +} +export const IGNORE_DIRS: Set; diff --git a/packages/core/src/dist/postcss/v2/ast-v2.cjs b/packages/core/src/dist/postcss/v2/ast-v2.cjs new file mode 100644 index 0000000..a3a3bd6 --- /dev/null +++ b/packages/core/src/dist/postcss/v2/ast-v2.cjs @@ -0,0 +1,321 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.collectUseUISetters = collectUseUISetters; +exports.harvestSetterValues = harvestSetterValues; +exports.normalizeVariants = normalizeVariants; +exports.extractVariants = extractVariants; +exports.extractVariantsFromFiles = extractVariantsFromFiles; +const parser_1 = require("@babel/parser"); +const babelTraverse = __importStar(require("@babel/traverse")); +const t = __importStar(require("@babel/types")); +const config_cjs_1 = require("../../config.cjs"); +const fs_1 = require("fs"); +const crypto_1 = require("crypto"); +const traverse = babelTraverse.default; +/** + * Collects every `[ , setter ] = useUI('key', 'initial')` in a file. + * @returns SetterMeta[] + */ +function collectUseUISetters(ast) { + const setters = []; + traverse(ast, { + VariableDeclarator(path) { + const { id, init } = path.node; + // Match: const [ , setX ] = useUI(...) + if (t.isArrayPattern(id) && id.elements.length === 2 && t.isCallExpression(init) && t.isIdentifier(init.callee, { name: config_cjs_1.CONFIG.HOOK_NAME })) { + const [, setterEl] = id.elements; + if (!t.isIdentifier(setterEl)) + return; // hole or non-identifier + // Validate & grab hook args + const [keyArg, initialArg] = init.arguments; + if (!t.isStringLiteral(keyArg)) + return; // dynamic keys are ignored + setters.push({ + binding: path.scope.getBinding(setterEl.name), // never null here + setterName: setterEl.name, + stateKey: keyArg.value, + initialValue: literalToString(initialArg), + }); + } + }, + }); + return setters; +} +/** + * Pass 2: Harvest all values from setter calls by examining their reference paths + * @param setters - Array of SetterMeta from Pass 1 + * @returns Map of stateKey -> Set of discovered values + */ +function harvestSetterValues(setters) { + const variants = new Map(); + // Initialize with initial values from Pass 1 + for (const setter of setters) { + if (!variants.has(setter.stateKey)) { + variants.set(setter.stateKey, new Set()); + } + if (setter.initialValue) { + variants.get(setter.stateKey).add(setter.initialValue); + } + } + // Examine each setter's reference paths + for (const setter of setters) { + const valueSet = variants.get(setter.stateKey); + // Boolean optimization: if initial value is 'true' or 'false', + // we know all possible values without traversing + if (setter.initialValue === 'true' || setter.initialValue === 'false') { + valueSet.add('true'); + valueSet.add('false'); + continue; // Skip traversal for this setter + } + // Look at every place this setter is referenced + for (const referencePath of setter.binding.referencePaths) { + // Check if this reference is being called as a function + const callPath = findCallExpression(referencePath); + if (callPath) { + // Extract values from the first argument of the call + const firstArg = callPath.node.arguments[0]; + if (firstArg) { + const extractedValues = extractLiteralsRecursively(firstArg, callPath); + extractedValues.forEach((value) => valueSet.add(value)); + } + } + } + } + return variants; +} +/** + * Check if a reference path is part of a function call + * Handles: setTheme('dark'), obj.setTheme('dark'), etc. + */ +function findCallExpression(referencePath) { + const parent = referencePath.parent; + // Direct call: setTheme('dark') + if (t.isCallExpression(parent) && parent.callee === referencePath.node) { + return referencePath.parentPath; + } + // Member expression call: obj.setTheme('dark') + if (t.isMemberExpression(parent) && parent.property === referencePath.node) { + const grandParent = referencePath.parentPath?.parent; + if (t.isCallExpression(grandParent) && grandParent.callee === parent) { + return referencePath.parentPath?.parentPath; + } + } + return null; +} +/** + * Recursively extract literal values from an expression + * Handles: literals, ternaries, logical expressions, functions, identifiers + */ +function extractLiteralsRecursively(node, path) { + const results = []; + // Base case: direct literals + const literal = literalToString(node); + if (literal !== null) { + results.push(literal); + return results; + } + // Ternary: condition ? 'value1' : 'value2' + if (t.isConditionalExpression(node)) { + results.push(...extractLiteralsRecursively(node.consequent, path)); + results.push(...extractLiteralsRecursively(node.alternate, path)); + } + // Logical expressions: a && 'value' || 'default' + else if (t.isLogicalExpression(node)) { + results.push(...extractLiteralsRecursively(node.left, path)); + results.push(...extractLiteralsRecursively(node.right, path)); + } + // Arrow functions: () => 'value' or prev => prev==='a' ? 'b':'a' + else if (t.isArrowFunctionExpression(node)) { + if (t.isExpression(node.body)) { + results.push(...extractLiteralsRecursively(node.body, path)); + } + else if (t.isBlockStatement(node.body)) { + // Look for return statements + results.push(...extractFromBlockStatement(node.body, path)); + } + } + // Function expressions: function() { return 'value'; } + else if (t.isFunctionExpression(node)) { + results.push(...extractFromBlockStatement(node.body, path)); + } + // Identifiers: resolve to their values if possible + else if (t.isIdentifier(node)) { + const resolved = resolveIdentifier(node, path); + if (resolved) { + results.push(...extractLiteralsRecursively(resolved, path)); + } + } + // Binary expressions: might contain literals in some cases + else if (t.isBinaryExpression(node)) { + // Only extract if it's a simple concatenation that might resolve to a literal + if (node.operator === '+') { + results.push(...extractLiteralsRecursively(node.left, path)); + results.push(...extractLiteralsRecursively(node.right, path)); + } + } + return results; +} +/** + * Extract literals from block statements by finding return statements + */ +function extractFromBlockStatement(block, path) { + const results = []; + for (const stmt of block.body) { + if (t.isReturnStatement(stmt) && stmt.argument) { + results.push(...extractLiteralsRecursively(stmt.argument, path)); + } + } + return results; +} +/** + * Try to resolve an identifier to its value within the current scope + */ +function resolveIdentifier(node, path) { + const binding = path.scope.getBinding(node.name); + if (!binding) + return null; + // Look at the binding's initialization + const bindingPath = binding.path; + // Variable declarator: const x = 'value' + if (bindingPath.isVariableDeclarator() && bindingPath.node.init) { + return bindingPath.node.init; + } + // Function parameter, import, etc. - could be extended + return null; +} +/** + * Convert various literal types to strings + */ +function literalToString(node) { + if (t.isStringLiteral(node) || t.isNumericLiteral(node)) { + return String(node.value); + } + if (t.isBooleanLiteral(node)) { + return node.value ? 'true' : 'false'; + } + if (t.isNullLiteral(node)) { + return 'null'; + } + if (t.isTemplateLiteral(node) && node.expressions.length === 0) { + // Simple template literal with no expressions: `hello` + return node.quasis[0]?.value.cooked || null; + } + return null; +} +/** + * Convert the harvested variants map to the final output format + */ +function normalizeVariants(variants, setters) { + const result = []; + for (const [stateKey, valueSet] of variants) { + // Find the initial value from the original setter + const setter = setters.find((s) => s.stateKey === stateKey); + const initialValue = setter?.initialValue || null; + // Sort values for deterministic output + const sortedValues = Array.from(valueSet).sort(); + result.push({ key: stateKey, values: sortedValues, initialValue }); + } + // Sort by key for deterministic output + return result.sort((a, b) => a.key.localeCompare(b.key)); +} +const fileCache = new Map(); +/** + * Main function: Extract all variant tokens from a JS/TS file + * @param filePath - Path to the source file + * @returns Array of variant data objects + */ +function extractVariants(filePath) { + try { + // Read and hash the file for caching + const sourceCode = (0, fs_1.readFileSync)(filePath, 'utf-8'); + const fileHash = (0, crypto_1.createHash)('md5').update(sourceCode).digest('hex'); + // Check cache first + const cached = fileCache.get(filePath); + if (cached && cached.hash === fileHash) { + return cached.variants; + } + // Parse the file once + const ast = (0, parser_1.parse)(sourceCode, { + sourceType: 'module', + plugins: ['jsx', 'typescript', 'decorators-legacy'], + allowImportExportEverywhere: true, + allowReturnOutsideFunction: true, + }); + // Pass 1: Collect all useUI setters and their initial values + const setters = collectUseUISetters(ast); + // Early return if no setters found + if (setters.length === 0) { + const result = []; + fileCache.set(filePath, { hash: fileHash, variants: result }); + return result; + } + // Pass 2: Harvest all values from setter calls + const variantsMap = harvestSetterValues(setters); + // Normalize to final format + const variants = normalizeVariants(variantsMap, setters); + // Cache and return + fileCache.set(filePath, { hash: fileHash, variants }); + return variants; + } + catch (error) { + console.error(`Error extracting variants from ${filePath}:`, error); + return []; + } +} +/** + * Extract variants from multiple files + * @param filePaths - Array of file paths to analyze + * @returns Combined and deduplicated variant data + */ +function extractVariantsFromFiles(filePaths) { + const allVariants = new Map(); + const initialValues = new Map(); + for (const filePath of filePaths) { + const fileVariants = extractVariants(filePath); + for (const variant of fileVariants) { + if (!allVariants.has(variant.key)) { + allVariants.set(variant.key, new Set()); + initialValues.set(variant.key, variant.initialValue); + } + // Merge values + variant.values.forEach((value) => allVariants.get(variant.key).add(value)); + } + } + // Convert back to VariantData format + return Array.from(allVariants.entries()) + .map(([key, valueSet]) => ({ key, values: Array.from(valueSet).sort(), initialValue: initialValues.get(key) || null })) + .sort((a, b) => a.key.localeCompare(b.key)); +} diff --git a/packages/core/src/dist/postcss/v2/ast-v2.d.cts b/packages/core/src/dist/postcss/v2/ast-v2.d.cts new file mode 100644 index 0000000..3ec0d26 --- /dev/null +++ b/packages/core/src/dist/postcss/v2/ast-v2.d.cts @@ -0,0 +1,44 @@ +import { Binding } from '@babel/traverse'; +import * as t from '@babel/types'; +export interface SetterMeta { + /** Babel binding object — use `binding.referencePaths` in Pass 2 */ + binding: Binding; + /** Variable name (`setTheme`) */ + setterName: string; + /** State key passed to `useUI` (`'theme'`) */ + stateKey: string; + /** Literal initial value as string, or `null` if non-literal */ + initialValue: string | null; +} +/** + * Collects every `[ , setter ] = useUI('key', 'initial')` in a file. + * @returns SetterMeta[] + */ +export declare function collectUseUISetters(ast: t.File): SetterMeta[]; +export interface VariantData { + key: string; + values: string[]; + initialValue: string | null; +} +/** + * Pass 2: Harvest all values from setter calls by examining their reference paths + * @param setters - Array of SetterMeta from Pass 1 + * @returns Map of stateKey -> Set of discovered values + */ +export declare function harvestSetterValues(setters: SetterMeta[]): Map>; +/** + * Convert the harvested variants map to the final output format + */ +export declare function normalizeVariants(variants: Map>, setters: SetterMeta[]): VariantData[]; +/** + * Main function: Extract all variant tokens from a JS/TS file + * @param filePath - Path to the source file + * @returns Array of variant data objects + */ +export declare function extractVariants(filePath: string): VariantData[]; +/** + * Extract variants from multiple files + * @param filePaths - Array of file paths to analyze + * @returns Combined and deduplicated variant data + */ +export declare function extractVariantsFromFiles(filePaths: string[]): VariantData[]; diff --git a/packages/core/src/postcss/helpers.cjs b/packages/core/src/postcss/helpers.cjs index 273672e..231feba 100644 --- a/packages/core/src/postcss/helpers.cjs +++ b/packages/core/src/postcss/helpers.cjs @@ -18,36 +18,16 @@ function toKebabCase(str) { .toLowerCase(); } -function findAllSourceFiles(rootDirs = ['src', 'app']) { - const exts = ['.ts', '.tsx', '.js', '.jsx']; - const files = []; +function findAllSourceFiles(patterns = CONFIG.CONTENT) { + const { globSync } = require('glob'); const cwd = process.cwd(); - rootDirs.forEach((dir) => { - const dirPath = path.join(cwd, dir); - if (!fs.existsSync(dirPath)) return; - - const walk = (current) => { - try { - for (const entry of fs.readdirSync(current)) { - const full = path.join(current, entry); - const stat = fs.statSync(full); - // TODO upgrade to fast-glob - if (stat.isDirectory() && !entry.startsWith('.') && !IGNORE_DIRS.has(entry)) { - walk(full); - } else if (stat.isFile() && exts.some((ext) => full.endsWith(ext))) { - files.push(full); - } - } - } catch (error) { - console.warn(`[Zero-UI] Error reading directory ${current}:`, error.message); - } - }; - - walk(dirPath); - }); - - return files; + try { + return globSync(patterns, { cwd, ignore: Array.from(IGNORE_DIRS), absolute: true }); + } catch (error) { + console.warn('[Zero-UI] Error finding source files:', error.message); + return []; + } } /** diff --git a/packages/core/src/postcss/v2/ast-v2.cts b/packages/core/src/postcss/v2/ast-v2.cts index c67b6e1..1224117 100644 --- a/packages/core/src/postcss/v2/ast-v2.cts +++ b/packages/core/src/postcss/v2/ast-v2.cts @@ -154,7 +154,7 @@ function extractLiteralsRecursively(node: t.Expression, path: NodePath): string[ results.push(...extractLiteralsRecursively(node.right, path)); } - // Arrow functions: () => 'value' + // Arrow functions: () => 'value' or prev => prev==='a' ? 'b':'a' else if (t.isArrowFunctionExpression(node)) { if (t.isExpression(node.body)) { results.push(...extractLiteralsRecursively(node.body, path)); diff --git a/packages/core/src/postcss/v2/build-tool.cts b/packages/core/src/postcss/v2/build-tool.ts similarity index 77% rename from packages/core/src/postcss/v2/build-tool.cts rename to packages/core/src/postcss/v2/build-tool.ts index 442f835..3a99b53 100644 --- a/packages/core/src/postcss/v2/build-tool.cts +++ b/packages/core/src/postcss/v2/build-tool.ts @@ -1,8 +1,8 @@ import { readFileSync, writeFileSync } from 'fs'; import { glob } from 'glob'; -import { extractVariantsFromFiles } from './ast-v2.cts'; -import { batchInjectDataAttributes, SEMANTIC_CONFIG } from './inject-attributes.cts'; -import { RefLocationTracker } from './collect-refs.cts'; +import { extractVariantsFromFiles } from './ast-v2.cjs'; +import { batchInjectDataAttributes, SEMANTIC_CONFIG } from './inject-attributes.js'; +import { RefLocationTracker } from './collect-refs.cjs'; const globalRefTracker = new RefLocationTracker(); @@ -104,40 +104,42 @@ export function createViteUIPlugin() { /** * Webpack loader example - */ -export function createWebpackUILoader() { - return function uiDataAttributeLoader(source: string) { - const callback = this.async(); - const resourcePath = this.resourcePath; - - // Only process relevant files - if (!/\.(tsx?|jsx?)$/.test(resourcePath)) { - return callback(null, source); - } - - try { - // Get ref locations for this file - const refLocations = globalRefTracker.getRefsByFile(resourcePath); - - if (refLocations.length === 0) { - return callback(null, source); - } - - // Get all variants (you'd need to make this available globally) - const variants = []; // This would come from your build context - - const { injectDataAttributes } = require('./data_attribute_injector'); - const transformedSource = injectDataAttributes(source, refLocations, variants, SEMANTIC_CONFIG); - - callback(null, transformedSource); - } catch (error) { - callback(error); - } - }; -} +// */ +// export function createWebpackUILoader() { +// return function uiDataAttributeLoader(source: string) { +// const callback = this.async(); +// const resourcePath = this.resourcePath; + +// // Only process relevant files +// if (!/\.(tsx?|jsx?)$/.test(resourcePath)) { +// return callback(null, source); +// } + +// try { +// // Get ref locations for this file +// const refLocations = globalRefTracker.getRefsByFile(resourcePath); + +// if (refLocations.length === 0) { +// return callback(null, source); +// } + +// // Get all variants (you'd need to make this available globally) +// const variants = []; // This would come from your build context + +// const { injectDataAttributes } = require('./data_attribute_injector'); +// const transformedSource = injectDataAttributes(source, refLocations, variants, SEMANTIC_CONFIG); + +// callback(null, transformedSource); +// } catch (error) { +// callback(error); +// } +// }; +// } /** * Generate CSS based on variants + * TODO MAKE ONE GLOBAL GENERATE CSS FILE Function + * TODO MAKE ONE LOCAL GENERATE CSS FILE Function */ function generateCSS(variants: any[]) { const cssRules: string[] = []; diff --git a/packages/core/src/postcss/v2/collect-refs.cts b/packages/core/src/postcss/v2/collect-refs.cts index 15bf38b..a29a829 100644 --- a/packages/core/src/postcss/v2/collect-refs.cts +++ b/packages/core/src/postcss/v2/collect-refs.cts @@ -1,7 +1,7 @@ import * as t from '@babel/types'; import { NodePath } from '@babel/traverse'; import * as babelTraverse from '@babel/traverse'; -import type { SetterMeta } from './ast-v2.cts'; +import type { SetterMeta } from './ast-v2.cjs'; const traverse = (babelTraverse as any).default; export interface RefLocation { diff --git a/packages/core/src/postcss/v2/inject-attributes.cts b/packages/core/src/postcss/v2/inject-attributes.ts similarity index 98% rename from packages/core/src/postcss/v2/inject-attributes.cts rename to packages/core/src/postcss/v2/inject-attributes.ts index 8ea7ae4..f3abeee 100644 --- a/packages/core/src/postcss/v2/inject-attributes.cts +++ b/packages/core/src/postcss/v2/inject-attributes.ts @@ -2,9 +2,9 @@ import { parse } 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'; -import type { RefLocation, RefLocationTracker } from './collect-refs.cts'; -import type { VariantData } from './ast-v2.cts'; +import { generate } from '@babel/generator'; +import type { RefLocation, RefLocationTracker } from './collect-refs.cjs'; +import type { VariantData } from './ast-v2.cjs'; const traverse = (babelTraverse as any).default; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 23280d5..406ba97 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -9,6 +9,9 @@ "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + // "allowImportingTsExtensions": true, "rootDir": "./src", // keeps relative paths clean "outDir": "./src/dist", // compiled JS → dist/ // "declarationDir": "build/postcss", // .d.ts alongside JS diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dab17c9..03090bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.1.10 version: 4.1.10 + glob: + specifier: ^11.0.0 + version: 11.0.3 postcss: specifier: ^8.5.5 version: 8.5.6 @@ -81,6 +84,9 @@ importers: '@playwright/test': specifier: ^1.53.0 version: 1.53.1 + '@types/babel__generator': + specifier: ^7.27.0 + version: 7.27.0 '@types/babel__traverse': specifier: ^7.20.7 version: 7.20.7 @@ -359,6 +365,18 @@ packages: '@iarna/toml@3.0.0': resolution: {integrity: sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==} + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -547,6 +565,9 @@ packages: '@tailwindcss/postcss@4.1.10': resolution: {integrity: sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==} + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} @@ -602,10 +623,18 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -762,9 +791,15 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enhanced-resolve@5.18.2: resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} engines: {node: '>=10.13.0'} @@ -887,6 +922,10 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -914,6 +953,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@11.0.3: + resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} + engines: {node: 20 || >=22} + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -1018,6 +1062,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jackspeak@4.1.1: + resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + engines: {node: 20 || >=22} + jiti@2.4.2: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true @@ -1150,6 +1198,10 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} + lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -1173,6 +1225,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.0.3: + resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1258,6 +1314,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1287,6 +1346,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1387,6 +1450,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1414,10 +1481,18 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -1529,6 +1604,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -1784,6 +1863,21 @@ snapshots: '@iarna/toml@3.0.0': {} + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.2 @@ -1965,6 +2059,10 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.10 + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.27.7 + '@types/babel__traverse@7.20.7': dependencies: '@babel/types': 7.27.7 @@ -2010,10 +2108,14 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.1.0: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.1: {} + argparse@2.0.1: {} array-ify@1.0.0: {} @@ -2173,8 +2275,12 @@ snapshots: dependencies: is-obj: 2.0.0 + eastasianwidth@0.2.0: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + enhanced-resolve@5.18.2: dependencies: graceful-fs: 4.2.11 @@ -2342,6 +2448,11 @@ snapshots: flatted@3.3.3: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fs.realpath@1.0.0: {} fsevents@2.3.2: @@ -2362,6 +2473,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@11.0.3: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.1.1 + minimatch: 10.0.3 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.0 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -2454,6 +2574,10 @@ snapshots: isexe@2.0.0: {} + jackspeak@4.1.1: + dependencies: + '@isaacs/cliui': 8.0.2 + jiti@2.4.2: {} js-tokens@4.0.0: {} @@ -2552,6 +2676,8 @@ snapshots: lodash.merge@4.6.2: {} + lru-cache@11.1.0: {} + lru-cache@6.0.0: dependencies: yallist: 4.0.0 @@ -2580,6 +2706,10 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.0.3: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -2668,6 +2798,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -2691,6 +2823,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@2.0.0: + dependencies: + lru-cache: 11.1.0 + minipass: 7.1.2 + picocolors@1.1.1: {} playwright-core@1.53.1: {} @@ -2799,6 +2936,8 @@ snapshots: shebang-regex@3.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} source-map@0.6.1: {} @@ -2827,10 +2966,20 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -2926,6 +3075,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + wrappy@1.0.2: {} xpath@0.0.34: {} From 5d946bcabde34c30064a69ffc7e73141feb24f63 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Sun, 29 Jun 2025 22:05:32 -0700 Subject: [PATCH 10/34] feat:start writing vite tests for scoped styles --- .../__tests__/fixtures/next/app/test/page.jsx | 4 +- .../fixtures/vite/.zero-ui/attributes.d.ts | 7 + .../fixtures/vite/.zero-ui/attributes.js | 9 +- .../core/__tests__/fixtures/vite/src/App.css | 4 + .../core/__tests__/fixtures/vite/src/App.tsx | 236 ++++++++++++++---- .../core/__tests__/fixtures/vite/src/FAQ.tsx | 25 ++ .../fixtures/vite/src/UseEffectComponent.tsx | 29 +++ .../__tests__/fixtures/vite/tsconfig.app.json | 13 +- .../fixtures/vite/tsconfig.node.json | 11 +- .../__tests__/fixtures/vite/vite.config.ts | 3 +- packages/core/src/index.js | 18 +- packages/core/src/postcss/index.cjs | 2 - packages/core/src/postcss/vite.js | 2 +- 13 files changed, 294 insertions(+), 69 deletions(-) create mode 100644 packages/core/__tests__/fixtures/vite/src/FAQ.tsx create mode 100644 packages/core/__tests__/fixtures/vite/src/UseEffectComponent.tsx diff --git a/packages/core/__tests__/fixtures/next/app/test/page.jsx b/packages/core/__tests__/fixtures/next/app/test/page.jsx index eea62dc..5a380b7 100644 --- a/packages/core/__tests__/fixtures/next/app/test/page.jsx +++ b/packages/core/__tests__/fixtures/next/app/test/page.jsx @@ -180,14 +180,14 @@ export default function Page() {
- {/*
+
answer
-
*/} +
{ +export default function Page() { const [, setTheme] = useUI<'light' | 'dark'>('theme', 'light'); const [, setTheme2] = useUI<'light' | 'dark'>('theme-2', 'light'); const [, setThemeThree] = useUI<'light' | 'dark'>('themeThree', 'light'); + const [, setToggle] = useUI('toggle-boolean', true); + const [, setNumber] = useUI<1 | 2>('number', 1); + const [, setOpen] = useUI<'open' | 'closed'>('faq', 'closed'); // Same key everywhere! + const [, setScope] = useUI<'off' | 'on'>('scope', 'off'); + const [, setMobile] = useUI('mobile', false); + + const [, setToggleFunction] = useUI<'white' | 'black'>('toggle-function', 'white'); + + const toggleFunction = () => { + setToggleFunction((prev) => (prev === 'white' ? 'black' : 'white')); + }; return ( - <> -
- -
- Theme: Dark Light +
+

Global State

+
+
+ {/* Auto Theme Component */} + + +
+ +
+ +
+ Theme: Dark Light +
-
-
+
-
- -
- Theme: Dark Light +
+ +
+ Theme: Dark Light +
-
+
+ +
+ +
+ Theme: Dark Light +
+
+ +
+ +
+ +
+ Boolean: True False +
+
+
+ +
+ +
+ Number: 1 2 +
+
+
+ +
+ +
+ Function: White Black +
+
+
+
+

Scoped Style Tests


-
+
+
+ +
+ Scope: False + True +
+
+
+ +
+ +
+ Mobile: False + True +
+
+
+ +
-
- Theme: Dark Light -
+
answer
- - ); -}; -export default App; + + + +
+ ); +} diff --git a/packages/core/__tests__/fixtures/vite/src/FAQ.tsx b/packages/core/__tests__/fixtures/vite/src/FAQ.tsx new file mode 100644 index 0000000..628ddee --- /dev/null +++ b/packages/core/__tests__/fixtures/vite/src/FAQ.tsx @@ -0,0 +1,25 @@ +import { useUI } from '@react-zero-ui/core'; + +function FAQ({ question, answer, index }) { + const [, setOpen] = useUI<'open' | 'closed'>('faq', 'closed'); // Same key everywhere! + + return ( +
+ +
+ {answer} +
+
+ ); +} + +export default FAQ; diff --git a/packages/core/__tests__/fixtures/vite/src/UseEffectComponent.tsx b/packages/core/__tests__/fixtures/vite/src/UseEffectComponent.tsx new file mode 100644 index 0000000..b33ebd5 --- /dev/null +++ b/packages/core/__tests__/fixtures/vite/src/UseEffectComponent.tsx @@ -0,0 +1,29 @@ +'use client'; +import { useEffect, useState } from 'react'; +import useUI from '@react-zero-ui/core'; + +// Component that automatically cycles through themes using useEffect +export default function UseEffectComponent() { + const [, setAutoTheme] = useUI<'light' | 'dark'>('use-effect-theme', 'light'); + const [state, setState] = useState(false); + + useEffect(() => { + setAutoTheme(state ? 'dark' : 'light'); + }, [state]); + + return ( +
+ +
+ Theme: Dark Light +
+
+ ); +} diff --git a/packages/core/__tests__/fixtures/vite/tsconfig.app.json b/packages/core/__tests__/fixtures/vite/tsconfig.app.json index 5b7f80a..0dfe01a 100644 --- a/packages/core/__tests__/fixtures/vite/tsconfig.app.json +++ b/packages/core/__tests__/fixtures/vite/tsconfig.app.json @@ -3,7 +3,11 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ @@ -17,9 +21,10 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] -} + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/core/__tests__/fixtures/vite/tsconfig.node.json b/packages/core/__tests__/fixtures/vite/tsconfig.node.json index 042221e..4834d2a 100644 --- a/packages/core/__tests__/fixtures/vite/tsconfig.node.json +++ b/packages/core/__tests__/fixtures/vite/tsconfig.node.json @@ -2,7 +2,9 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "target": "ES2022", - "lib": ["ES2022"], + "lib": [ + "ES2022" + ], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ @@ -15,9 +17,10 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] -} + "include": [ + "vite.config.ts" + ] +} \ No newline at end of file diff --git a/packages/core/__tests__/fixtures/vite/vite.config.ts b/packages/core/__tests__/fixtures/vite/vite.config.ts index 3b300ec..5377e3a 100644 --- a/packages/core/__tests__/fixtures/vite/vite.config.ts +++ b/packages/core/__tests__/fixtures/vite/vite.config.ts @@ -5,4 +5,5 @@ import react from '@vitejs/plugin-react'; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), zeroUI()] -}); \ No newline at end of file +}); + diff --git a/packages/core/src/index.js b/packages/core/src/index.js index f22d10b..34f1e7f 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -19,8 +19,8 @@ function useUI(key, initialValue) { if (prev !== undefined && prev !== initialValue) { console.error( `[useUI] Inconsistent initial values for key "${key}": ` + - `expected "${prev}", got "${initialValue}". ` + - `Use the same initial value everywhere or namespace your keys.` + `expected "${prev}", got "${initialValue}". ` + + `Use the same initial value everywhere or namespace your keys.` ); } else if (prev === undefined) { registry.set(key, initialValue); @@ -63,12 +63,12 @@ function useUI(key, initialValue) { !value ? initialValue : // If initial was boolean, parse "true"/"false" string to boolean - typeof initialValue === 'boolean' + typeof initialValue === 'boolean' ? value === 'true' : // If initial was number, convert string to number with NaN fallback - typeof initialValue === 'number' + typeof initialValue === 'number' ? // The double conversion of +value is very fast and is the same as isNaN check without the function overhead - +value === +value + +value === +value ? +value : initialValue : value @@ -96,12 +96,12 @@ function useUI(key, initialValue) { if (node !== null) { refAttachCount.current++; if (refAttachCount.current > 1) { + // TODO add documentation link throw new Error( - // TODO add documentation link `[useUI] Multiple ref attachments detected for key "${key}". ` + - `Each useUI hook supports only one ref attachment per component. ` + - `Solution: Create separate component. and reuse.\n` + - `Example: instead of multiple refs in one component.` + `Each useUI hook supports only one ref attachment per component. ` + + `Solution: Create separate component. and reuse.\n` + + `Example: instead of multiple refs in one component.` ); } } else { diff --git a/packages/core/src/postcss/index.cjs b/packages/core/src/postcss/index.cjs index b58290a..c186a57 100644 --- a/packages/core/src/postcss/index.cjs +++ b/packages/core/src/postcss/index.cjs @@ -17,8 +17,6 @@ module.exports = () => { // Generate CSS const cssBlock = buildCss(finalVariants); - console.log('cssBlock: ', cssBlock); - // Inject new CSS - prepend so it's before any @tailwind directives if (cssBlock.trim()) { root.prepend(cssBlock + '\n'); diff --git a/packages/core/src/postcss/vite.js b/packages/core/src/postcss/vite.js index 21f1602..4ec07fb 100644 --- a/packages/core/src/postcss/vite.js +++ b/packages/core/src/postcss/vite.js @@ -9,7 +9,7 @@ export default function zeroUI() { enforce: 'pre', // run before other Vite plugins async config() { const { default: zeroUiPostcss } = await import('./index.cjs'); - return { css: { postcss: { plugins: [zeroUiPostcss(), tailwindcss] } } }; + return { css: { postcss: { plugins: [zeroUiPostcss(), tailwindcss()] } } }; }, async transformIndexHtml(html) { const { bodyAttributes } = await import(path.join(process.cwd(), './.zero-ui/attributes.js')); From e2812ab6a92bc62dda17fcd281478da9337329d0 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Sun, 29 Jun 2025 22:38:35 -0700 Subject: [PATCH 11/34] test: added vite tests --- .../config/playwright.vite.config.js | 5 + packages/core/__tests__/e2e/next.spec.js | 15 ++ .../core/__tests__/e2e/vite-scopes.spec.js | 179 +++++++++++++++++ packages/core/__tests__/e2e/vite.spec.js | 190 +++++++++++++++--- .../core/__tests__/fixtures/next/app/page.tsx | 4 +- .../core/__tests__/fixtures/vite/src/App.tsx | 8 +- .../__tests__/fixtures/vite/vite.config.ts | 3 +- packages/core/package.json | 3 +- .../postcss/{v2 => coming-soon}/build-tool.ts | 2 +- .../{v2 => coming-soon}/collect-refs.cts | 2 +- .../{v2 => coming-soon}/inject-attributes.ts | 2 +- 11 files changed, 374 insertions(+), 39 deletions(-) create mode 100644 packages/core/__tests__/e2e/vite-scopes.spec.js rename packages/core/src/postcss/{v2 => coming-soon}/build-tool.ts (98%) rename packages/core/src/postcss/{v2 => coming-soon}/collect-refs.cts (98%) rename packages/core/src/postcss/{v2 => coming-soon}/inject-attributes.ts (99%) diff --git a/packages/core/__tests__/config/playwright.vite.config.js b/packages/core/__tests__/config/playwright.vite.config.js index d37ab18..cabc1ef 100644 --- a/packages/core/__tests__/config/playwright.vite.config.js +++ b/packages/core/__tests__/config/playwright.vite.config.js @@ -37,6 +37,11 @@ export default defineConfig({ dependencies: ['vite-cli-e2e'], testMatch: /vite\.spec\.js/, // Matches both cli-vite.spec.js and vite.spec.js }, + { + name: 'vite-scopes-e2e', + dependencies: ['vite-e2e'], + testMatch: /vite-scopes\.spec\.js/, // Matches both cli-vite.spec.js and vite.spec.js + }, ], webServer: { command: 'pnpm run build-and-preview', diff --git a/packages/core/__tests__/e2e/next.spec.js b/packages/core/__tests__/e2e/next.spec.js index 3512a93..975d444 100644 --- a/packages/core/__tests__/e2e/next.spec.js +++ b/packages/core/__tests__/e2e/next.spec.js @@ -62,6 +62,16 @@ const scenarios = [ initialText: 'Light', toggledText: 'Dark', }, + { + name: 'Toggle Function', + toggle: 'toggle-function', + container: 'toggle-function-container', + attr: 'data-toggle-function', + initialValue: 'white', + toggledValue: 'black', + initialText: 'White', + toggledText: 'Black', + }, ]; test.describe.configure({ mode: 'serial' }); @@ -162,4 +172,9 @@ test.describe('Zero-UI Next.js Integration Tests', () => { await expect(body).toHaveAttribute('data-theme', 'dark'); await expect(body).toHaveAttribute('data-toggle-boolean', 'false'); }); + + test('Tailwind is generated correctly', async ({ page }) => { + const pageContainer = page.getByTestId('page-container'); + await expect(pageContainer).toHaveCSS('background-color', 'rgb(255, 255, 255)'); + }); }); diff --git a/packages/core/__tests__/e2e/vite-scopes.spec.js b/packages/core/__tests__/e2e/vite-scopes.spec.js new file mode 100644 index 0000000..3f9cc1f --- /dev/null +++ b/packages/core/__tests__/e2e/vite-scopes.spec.js @@ -0,0 +1,179 @@ +import { test, expect } from '@playwright/test'; + +test.describe.configure({ mode: 'serial' }); + +test.describe('Zero-UI Scoped State Tests', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/', { waitUntil: 'networkidle' }); + }); + + test('FAQ components start in closed state', async ({ page }) => { + console.log('\n🧪 Testing initial FAQ states'); + + // All FAQ answers should be hidden initially + for (let i = 1; i <= 3; i++) { + const answer = page.getByTestId(`faq-${i}-answer`); + await expect(answer).toBeHidden(); + console.log(`✅ FAQ ${i} answer is initially hidden`); + } + }); + + test('Each FAQ can be opened and closed independently', async ({ page }) => { + console.log('\n🧪 Testing independent FAQ state management'); + + // Open FAQ 1 + console.log('🖱️ Opening FAQ 1...'); + await page.getByTestId('faq-1-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeVisible(); + console.log('✅ FAQ 1 is open'); + + // Open FAQ 2 - FAQ 1 should stay open + console.log('🖱️ Opening FAQ 2...'); + await page.getByTestId('faq-2-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeVisible(); + await expect(page.getByTestId('faq-2-answer')).toBeVisible(); + console.log('✅ FAQ 1 and FAQ 2 are both open'); + + // Open FAQ 3 - FAQ 1 and 2 should stay open + console.log('🖱️ Opening FAQ 3...'); + await page.getByTestId('faq-3-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeVisible(); + await expect(page.getByTestId('faq-2-answer')).toBeVisible(); + await expect(page.getByTestId('faq-3-answer')).toBeVisible(); + console.log('✅ All FAQs are open simultaneously'); + }); + + test('Individual FAQ toggle functionality works correctly', async ({ page }) => { + console.log('\n🧪 Testing individual FAQ toggle functionality'); + + // Open all FAQs first + await page.getByTestId('faq-1-toggle').click(); + await page.getByTestId('faq-2-toggle').click(); + await page.getByTestId('faq-3-toggle').click(); + + // Verify all are open + await expect(page.getByTestId('faq-1-answer')).toBeVisible(); + await expect(page.getByTestId('faq-2-answer')).toBeVisible(); + await expect(page.getByTestId('faq-3-answer')).toBeVisible(); + console.log('✅ All FAQs opened'); + + // Close FAQ 2 - others should stay open + console.log('🖱️ Closing FAQ 2...'); + await page.getByTestId('faq-2-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeVisible(); + await expect(page.getByTestId('faq-2-answer')).toBeHidden(); + await expect(page.getByTestId('faq-3-answer')).toBeVisible(); + console.log('✅ FAQ 2 closed, FAQ 1 and 3 remain open'); + + // Close FAQ 1 - FAQ 3 should stay open + console.log('🖱️ Closing FAQ 1...'); + await page.getByTestId('faq-1-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeHidden(); + await expect(page.getByTestId('faq-2-answer')).toBeHidden(); + await expect(page.getByTestId('faq-3-answer')).toBeVisible(); + console.log('✅ FAQ 1 closed, only FAQ 3 remains open'); + }); + + test('Scoped state allows multiple FAQs to be open', async ({ page }) => { + console.log('\n🧪 Testing scoped state - multiple FAQs can be open'); + + // Function to count visible FAQ answers + const countVisibleAnswers = async () => { + let visibleCount = 0; + for (let i = 1; i <= 3; i++) { + const answer = page.getByTestId(`faq-${i}-answer`); + if (await answer.isVisible()) { + visibleCount++; + } + } + return visibleCount; + }; + + // Initially, no FAQs should be open + expect(await countVisibleAnswers()).toBe(0); + console.log('✅ Initially 0 FAQs are open'); + + // Open FAQ 1 + await page.getByTestId('faq-1-toggle').click(); + expect(await countVisibleAnswers()).toBe(1); + console.log('✅ 1 FAQ is open'); + + // Open FAQ 2 - should have 2 open + await page.getByTestId('faq-2-toggle').click(); + expect(await countVisibleAnswers()).toBe(2); + console.log('✅ 2 FAQs are open'); + + // Open FAQ 3 - should have 3 open + await page.getByTestId('faq-3-toggle').click(); + expect(await countVisibleAnswers()).toBe(3); + console.log('✅ All 3 FAQs are open'); + + // Close FAQ 2 - should have 2 open + await page.getByTestId('faq-2-toggle').click(); + expect(await countVisibleAnswers()).toBe(2); + console.log('✅ 2 FAQs remain open after closing one'); + }); + + test('FAQ components have correct data attributes and structure', async ({ page }) => { + console.log('\n🧪 Testing FAQ component structure and attributes'); + + // Check that each FAQ has the correct data-index + for (let i = 1; i <= 3; i++) { + const faqContainer = page.locator(`[data-index="${i}"]`); + await expect(faqContainer).toBeVisible(); + console.log(`✅ FAQ ${i} has correct data-index="${i}"`); + } + + // Check button text content + await expect(page.getByTestId('faq-1-toggle')).toHaveText('Question 1 +'); + await expect(page.getByTestId('faq-2-toggle')).toHaveText('Question 2 +'); + await expect(page.getByTestId('faq-3-toggle')).toHaveText('Question 3 +'); + console.log('✅ All FAQ buttons have correct text'); + + // Check answer content when opened + await page.getByTestId('faq-1-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toHaveText('Answer 1'); + console.log('✅ FAQ answer content is correct'); + }); + + test('FAQ styling classes are applied correctly', async ({ page }) => { + console.log('\n🧪 Testing FAQ CSS classes'); + + // Check that closed state has correct classes + const answer1 = page.getByTestId('faq-1-answer'); + await expect(answer1).toHaveClass(/faq-closed:hidden/); + await expect(answer1).toHaveClass(/faq-open:block/); + console.log('✅ FAQ has correct CSS classes for open/closed states'); + + // Open FAQ and verify styling + await page.getByTestId('faq-1-toggle').click(); + // The answer should now be visible due to faq-open:block class + await expect(answer1).toBeVisible(); + console.log('✅ FAQ styling works correctly when opened'); + }); + + test('Each FAQ manages its own scoped state independently', async ({ page }) => { + console.log('\n🧪 Testing scoped state independence'); + + // Test that each FAQ can be toggled independently + // Open FAQ 1 + await page.getByTestId('faq-1-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeVisible(); + + // Toggle FAQ 1 multiple times while keeping others in different states + await page.getByTestId('faq-2-toggle').click(); // Open FAQ 2 + await expect(page.getByTestId('faq-2-answer')).toBeVisible(); + + // Close FAQ 1, FAQ 2 should stay open + await page.getByTestId('faq-1-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeHidden(); + await expect(page.getByTestId('faq-2-answer')).toBeVisible(); + + // Reopen FAQ 1, FAQ 2 should still be open + await page.getByTestId('faq-1-toggle').click(); + await expect(page.getByTestId('faq-1-answer')).toBeVisible(); + await expect(page.getByTestId('faq-2-answer')).toBeVisible(); + + console.log('✅ Each FAQ maintains independent scoped state'); + }); +}); diff --git a/packages/core/__tests__/e2e/vite.spec.js b/packages/core/__tests__/e2e/vite.spec.js index e3b0faf..975d444 100644 --- a/packages/core/__tests__/e2e/vite.spec.js +++ b/packages/core/__tests__/e2e/vite.spec.js @@ -1,48 +1,180 @@ import { test, expect } from '@playwright/test'; +// Define test scenarios with proper expected values const scenarios = [ - { toggle: 'theme-toggle', attr: 'data-theme' }, - { toggle: 'theme-toggle-secondary', attr: 'data-theme-2' }, - { toggle: 'theme-toggle-3', attr: 'data-theme-three' }, + { + name: 'Primary Theme Toggle', + toggle: 'theme-toggle', + container: 'theme-container', + attr: 'data-theme', + initialValue: 'light', + toggledValue: 'dark', + initialText: 'Light', + toggledText: 'Dark', + }, + { + name: 'Secondary Theme Toggle', + toggle: 'theme-toggle-secondary', + container: 'theme-container-secondary', + attr: 'data-theme-2', + initialValue: 'light', + toggledValue: 'dark', + initialText: 'Light', + toggledText: 'Dark', + }, + { + name: 'Tertiary Theme Toggle', + toggle: 'theme-toggle-3', + container: 'theme-container-3', + attr: 'data-theme-three', + initialValue: 'light', + toggledValue: 'dark', + initialText: 'Light', + toggledText: 'Dark', + }, + { + name: 'Boolean Toggle', + toggle: 'toggle-boolean', + container: 'toggle-boolean-container', + attr: 'data-toggle-boolean', + initialValue: 'true', + toggledValue: 'false', + initialText: 'True', + toggledText: 'False', + }, + { + name: 'Number Toggle', + toggle: 'toggle-number', + container: 'toggle-number-container', + attr: 'data-number', + initialValue: '1', + toggledValue: '2', + initialText: '1', + toggledText: '2', + }, + { + name: 'UseEffect Component', + toggle: 'use-effect-theme', + container: 'use-effect-theme-container', + attr: 'data-use-effect-theme', + initialValue: 'light', + toggledValue: 'dark', + initialText: 'Light', + toggledText: 'Dark', + }, + { + name: 'Toggle Function', + toggle: 'toggle-function', + container: 'toggle-function-container', + attr: 'data-toggle-function', + initialValue: 'white', + toggledValue: 'black', + initialText: 'White', + toggledText: 'Black', + }, ]; -test.describe.configure({ mode: 'serial' }); // run one after another -test.describe(`Zero-UI Vite integration ${scenarios.map(({ toggle }) => toggle).join(', ')}`, () => { - for (const { toggle, attr } of scenarios) { - test(`starts "light" and flips <${attr}> → "dark"`, async ({ page }) => { - console.log(`\n🧪 Testing ${toggle} with attribute ${attr}`); +test.describe.configure({ mode: 'serial' }); - await page.goto('/', { waitUntil: 'networkidle' }); - console.log('📄 Page loaded'); +test.describe('Zero-UI Next.js Integration Tests', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/', { waitUntil: 'networkidle' }); + }); + + // Test each scenario + for (const scenario of scenarios) { + test(`${scenario.name}: toggles from ${scenario.initialValue} to ${scenario.toggledValue}`, async ({ page }) => { + console.log(`\n🧪 Testing ${scenario.name}`); const body = page.locator('body'); - const button = page.getByTestId(toggle); + const button = page.getByTestId(scenario.toggle); + const container = page.getByTestId(scenario.container); - /* ① Wait until the attribute is "light" */ - console.log(`🔍 Checking initial ${attr} attribute...`); + // Verify button exists + await expect(button).toBeVisible(); + console.log(`✅ Button ${scenario.toggle} is visible`); - // Debug: Check what the actual attribute value is - const actualValue = await body.getAttribute(attr); - console.log(`🐛 DEBUG: Current ${attr} value is:`, actualValue); + // Check initial state + console.log(`🔍 Checking initial state...`); + await expect(body).toHaveAttribute(scenario.attr, scenario.initialValue); - // Check if button exists - const buttonExists = await button.count(); - console.log(`🐛 DEBUG: Button ${toggle} count:`, buttonExists); + // Verify initial text is visible (use more specific selector) + const initialTextElement = container.locator('span').filter({ hasText: scenario.initialText }); + await expect(initialTextElement).toBeVisible(); - await expect(body).toHaveAttribute(attr, 'light'); - console.log(`✅ Initial ${attr} is "light"`); + // Also verify the other text is hidden + const toggledTextElement = container.locator('span').filter({ hasText: scenario.toggledText }); + await expect(toggledTextElement).toBeHidden(); + console.log(`✅ Initial state: ${scenario.attr}="${scenario.initialValue}", text="${scenario.initialText}"`); - /* ② Click & assert "dark" */ - console.log(`🖱️ Clicking ${toggle} button...`); + // Click to toggle + console.log(`🖱️ Clicking ${scenario.toggle}...`); await button.click(); - console.log(`🔍 Checking ${attr} attribute after click...`); - // Debug: Check what the actual attribute value is after click - const actualValueAfter = await body.getAttribute(attr); - console.log(`🐛 DEBUG: ${attr} value after click is:`, actualValueAfter); + // Check toggled state + console.log(`🔍 Checking toggled state...`); + await expect(body).toHaveAttribute(scenario.attr, scenario.toggledValue); + + // Verify toggled text is visible + const newVisibleElement = container.locator('span').filter({ hasText: scenario.toggledText }); + const newHiddenElement = container.locator('span').filter({ hasText: scenario.initialText }); + await expect(newVisibleElement).toBeVisible(); + await expect(newHiddenElement).toBeHidden(); + console.log(`✅ Toggled state: ${scenario.attr}="${scenario.toggledValue}", text="${scenario.toggledText}"`); + + // Click again to toggle back + console.log(`🖱️ Clicking ${scenario.toggle} again to toggle back...`); + await button.click(); - await expect(body).toHaveAttribute(attr, 'dark'); - console.log(`✅ ${attr} is now "dark"`); + // Verify it returns to initial state + await expect(body).toHaveAttribute(scenario.attr, scenario.initialValue); + const finalVisibleElement = container.locator('span').filter({ hasText: scenario.initialText }); + const finalHiddenElement = container.locator('span').filter({ hasText: scenario.toggledText }); + await expect(finalVisibleElement).toBeVisible(); + await expect(finalHiddenElement).toBeHidden(); + console.log(`✅ Returned to initial state: ${scenario.attr}="${scenario.initialValue}"`); }); } + + // Separate test for visual styling + test('Visual styling changes work correctly', async ({ page }) => { + const body = page.locator('body'); + const themeButton = page.getByTestId('theme-toggle'); + + await themeButton.click(); + await expect(body).toHaveAttribute('data-theme', 'dark'); + }); + + // Test all toggles work independently + test('Multiple toggles work independently', async ({ page }) => { + const body = page.locator('body'); + + // Click theme toggle + await page.getByTestId('theme-toggle').click(); + await expect(body).toHaveAttribute('data-theme', 'dark'); + + // Click boolean toggle + await page.getByTestId('toggle-boolean').click(); + await expect(body).toHaveAttribute('data-toggle-boolean', 'false'); + + // Verify theme is still dark + await expect(body).toHaveAttribute('data-theme', 'dark'); + + // Click number toggle + await page.getByTestId('toggle-number').click(); + await expect(body).toHaveAttribute('data-number', '2'); + + // Click scope toggle + await page.getByTestId('scope-toggle').click(); + await expect(body).toHaveAttribute('data-scope', 'off'); + + // Verify other states are preserved + await expect(body).toHaveAttribute('data-theme', 'dark'); + await expect(body).toHaveAttribute('data-toggle-boolean', 'false'); + }); + + test('Tailwind is generated correctly', async ({ page }) => { + const pageContainer = page.getByTestId('page-container'); + await expect(pageContainer).toHaveCSS('background-color', 'rgb(255, 255, 255)'); + }); }); diff --git a/packages/core/__tests__/fixtures/next/app/page.tsx b/packages/core/__tests__/fixtures/next/app/page.tsx index de3380e..86aca17 100644 --- a/packages/core/__tests__/fixtures/next/app/page.tsx +++ b/packages/core/__tests__/fixtures/next/app/page.tsx @@ -21,7 +21,9 @@ export default function Page() { }; return ( -
+

Global State


diff --git a/packages/core/__tests__/fixtures/vite/src/App.tsx b/packages/core/__tests__/fixtures/vite/src/App.tsx index ddce6ce..fb84a65 100644 --- a/packages/core/__tests__/fixtures/vite/src/App.tsx +++ b/packages/core/__tests__/fixtures/vite/src/App.tsx @@ -1,9 +1,9 @@ -'use client'; import useUI from '@react-zero-ui/core'; import UseEffectComponent from './UseEffectComponent'; import FAQ from './FAQ'; +import './App.css'; -export default function Page() { +export default function App() { const [, setTheme] = useUI<'light' | 'dark'>('theme', 'light'); const [, setTheme2] = useUI<'light' | 'dark'>('theme-2', 'light'); const [, setThemeThree] = useUI<'light' | 'dark'>('themeThree', 'light'); @@ -20,7 +20,9 @@ export default function Page() { }; return ( -
+

Global State


diff --git a/packages/core/__tests__/fixtures/vite/vite.config.ts b/packages/core/__tests__/fixtures/vite/vite.config.ts index 5377e3a..3b300ec 100644 --- a/packages/core/__tests__/fixtures/vite/vite.config.ts +++ b/packages/core/__tests__/fixtures/vite/vite.config.ts @@ -5,5 +5,4 @@ import react from '@vitejs/plugin-react'; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), zeroUI()] -}); - +}); \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json index d182524..d250db1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,7 +11,8 @@ "files": [ "src/**/*", "README.md", - "LICENSE" + "LICENSE", + "!src/postcss/coming-soon/**/*" ], "exports": { ".": { diff --git a/packages/core/src/postcss/v2/build-tool.ts b/packages/core/src/postcss/coming-soon/build-tool.ts similarity index 98% rename from packages/core/src/postcss/v2/build-tool.ts rename to packages/core/src/postcss/coming-soon/build-tool.ts index 3a99b53..be8cf6e 100644 --- a/packages/core/src/postcss/v2/build-tool.ts +++ b/packages/core/src/postcss/coming-soon/build-tool.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync } from 'fs'; import { glob } from 'glob'; -import { extractVariantsFromFiles } from './ast-v2.cjs'; +import { extractVariantsFromFiles } from '../v2/ast-v2.cjs'; import { batchInjectDataAttributes, SEMANTIC_CONFIG } from './inject-attributes.js'; import { RefLocationTracker } from './collect-refs.cjs'; diff --git a/packages/core/src/postcss/v2/collect-refs.cts b/packages/core/src/postcss/coming-soon/collect-refs.cts similarity index 98% rename from packages/core/src/postcss/v2/collect-refs.cts rename to packages/core/src/postcss/coming-soon/collect-refs.cts index a29a829..5041a94 100644 --- a/packages/core/src/postcss/v2/collect-refs.cts +++ b/packages/core/src/postcss/coming-soon/collect-refs.cts @@ -1,7 +1,7 @@ import * as t from '@babel/types'; import { NodePath } from '@babel/traverse'; import * as babelTraverse from '@babel/traverse'; -import type { SetterMeta } from './ast-v2.cjs'; +import type { SetterMeta } from '../v2/ast-v2.cjs'; const traverse = (babelTraverse as any).default; export interface RefLocation { diff --git a/packages/core/src/postcss/v2/inject-attributes.ts b/packages/core/src/postcss/coming-soon/inject-attributes.ts similarity index 99% rename from packages/core/src/postcss/v2/inject-attributes.ts rename to packages/core/src/postcss/coming-soon/inject-attributes.ts index f3abeee..7320707 100644 --- a/packages/core/src/postcss/v2/inject-attributes.ts +++ b/packages/core/src/postcss/coming-soon/inject-attributes.ts @@ -4,7 +4,7 @@ import { NodePath } from '@babel/traverse'; import * as t from '@babel/types'; import { generate } from '@babel/generator'; import type { RefLocation, RefLocationTracker } from './collect-refs.cjs'; -import type { VariantData } from './ast-v2.cjs'; +import type { VariantData } from '../v2/ast-v2.cjs'; const traverse = (babelTraverse as any).default; From 693213fbeb00f494f7aafd7959f1becff653ff35 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Mon, 30 Jun 2025 10:05:37 -0700 Subject: [PATCH 12/34] feat:start moving more files to typescript --- packages/core/__tests__/unit/index.test.cjs | 658 +++++++++++++++++- packages/core/src/config.cjs | 3 +- packages/core/src/dist/config.cjs | 1 + packages/core/src/dist/config.d.cts | 1 + packages/core/src/dist/postcss/v2/ast-v2.cjs | 67 +- .../core/src/dist/postcss/v2/ast-v2.d.cts | 2 +- packages/core/src/index.js | 1 - packages/core/src/postcss/helpers.cjs | 5 +- packages/core/src/postcss/v2/ast-v2.cts | 80 ++- packages/core/src/postcss/v2/helpers.cts | 363 ++++++++++ 10 files changed, 1149 insertions(+), 32 deletions(-) create mode 100644 packages/core/src/postcss/v2/helpers.cts diff --git a/packages/core/__tests__/unit/index.test.cjs b/packages/core/__tests__/unit/index.test.cjs index 673990d..11f825c 100644 --- a/packages/core/__tests__/unit/index.test.cjs +++ b/packages/core/__tests__/unit/index.test.cjs @@ -4,6 +4,7 @@ const assert = require('node:assert'); const fs = require('fs'); const path = require('path'); 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('../../src/postcss/index.cjs'); const { patchConfigAlias, toKebabCase, patchPostcssConfig, patchViteConfig } = require('../../src/postcss/helpers.cjs'); @@ -50,7 +51,7 @@ test('generates body attributes file correctly', async () => { await runTest( { 'app/test.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Component() { const [theme, setTheme] = useUI('theme', 'light'); @@ -84,7 +85,7 @@ test('generates body attributes file correctly when kebab-case is used', async ( await runTest( { 'app/test.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Component() { const [theme, setTheme] = useUI('theme-secondary', 'light'); @@ -120,7 +121,7 @@ test('detects JavaScript setValue calls', async () => { await runTest( { 'src/modal.js': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Modal() { const [modal, setModal] = useUI('modal', 'closed'); @@ -160,7 +161,7 @@ test('handles boolean values', async () => { await runTest( { 'app/toggle.tsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Toggle() { const [isOpen, setIsOpen] = useUI('drawer', false); @@ -192,7 +193,7 @@ test('handles kebab-case conversion', async () => { await runTest( { 'src/styles.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function StyledComponent() { const [primaryColor, setPrimaryColor] = useUI('primaryColor', 'deepBlue'); @@ -229,7 +230,7 @@ test('handles conditional expressions', async () => { await runTest( { 'app/conditional.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function ConditionalComponent({ isActive, mode }) { const [state, setState] = useUI('state', 'default'); @@ -265,14 +266,14 @@ test('handles multiple files and deduplication', async () => { await runTest( { 'src/header.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Header() { const [theme, setTheme] = useUI('theme', 'light'); return ; } `, 'src/footer.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Footer() { const [theme, setTheme] = useUI('theme', 'light'); return
@@ -284,7 +285,7 @@ test('handles multiple files and deduplication', async () => { } `, 'app/sidebar.tsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Sidebar() { const [theme, setTheme] = useUI<'light' | 'dark' | 'auto'>('theme', 'light'); return
@@ -315,14 +316,14 @@ test('handles parsing errors gracefully', async () => { await runTest( { 'src/valid.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Valid() { const [state, setState] = useUI('valid', 'working'); return
Valid
; } `, 'src/invalid.js': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Invalid() { const [state, setState] = useUI('test' 'missing-comma'); {{{ invalid syntax @@ -348,7 +349,7 @@ test('valid edge cases: underscores + missing initial', async () => { await runTest( { 'src/edge.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function EdgeCases() { const [noInitial] = useUI('noInitial_value'); const [, setOnlySetter] = useUI('only_setter_key', 'yes'); @@ -374,7 +375,7 @@ test('watches for file changes', async () => { await runTest( { 'src/initial.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Initial() { const [state, setState] = useUI('watchTest', 'initial'); return
Initial
;e @@ -389,7 +390,7 @@ test('watches for file changes', async () => { fs.writeFileSync( 'src/new.jsx', ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function New() { const [state, setState] = useUI('watchTest', 'initial'); return ; @@ -412,21 +413,21 @@ test('ignores node_modules and hidden directories', async () => { await runTest( { 'src/valid.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Valid() { const [state, setState] = useUI('valid', 'yes'); return
Valid
; } `, 'node_modules/package/file.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Ignored() { const [state] = useUI('ignored', 'shouldNotAppear'); return
Should be ignored
; } `, '.next/file.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Hidden() { const [state] = useUI('hidden', 'shouldNotAppear'); return
Should be ignored
; @@ -446,7 +447,7 @@ test('handles deeply nested file structures', async () => { await runTest( { 'src/features/auth/components/login/LoginForm.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function LoginForm() { const [authState, setAuthState] = useUI('authState', 'loggedOut'); return ; @@ -466,7 +467,7 @@ test('handles large projects efficiently - 500 files', async function () { // Generate 50 files for (let i = 0; i < 500; i++) { files[`src/component${i}.jsx`] = ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Component${i}() { const [state${i}, setState${i}] = useUI('state${i}', 'value${i}'); return
Component ${i}
; @@ -495,7 +496,7 @@ test('handles special characters in values', async () => { await runTest( { 'src/special.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Special() { const [state, setState] = useUI('special', 'default'); return ( @@ -521,7 +522,7 @@ test('handles concurrent file modifications', async () => { await runTest( { 'src/rapid.jsx': ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Rapid() { const [count] = useUI('count', 'zero'); return
Initial
; @@ -534,7 +535,7 @@ test('handles concurrent file modifications', async () => { fs.writeFileSync( 'src/rapid.jsx', ` - import { useUI } from 'react-zero-ui'; + import { useUI } from '@react-zero-ui/core'; function Rapid() { const [count, setCount] = useUI('count', 'zero'); return ; @@ -1428,3 +1429,616 @@ export default defineConfig({ fs.rmSync(testDir, { recursive: true, force: true }); } }); + +/* +The following tests are for advanced edge cases +-------------------------------------------------------------------------------------------- +---------------------------------------------- +---------------------------------------------- +---------------------------------------------- +---------------------------------------------- +---------------------------------------------- +-------------------------------------------------------------------------------------------- +---------------------------------------------- +---------------------------------------------- +---------------------------------------------- +---------------------------------------------- +---------------------------------------------- +---------------------------------------------- +*/ + +test('generated variants for initial value without setterFn', async () => { + await runTest( + { + 'app/initial-value.jsx': ` + import { useUI } from '@react-zero-ui/core'; + function Component() { + const [theme,setTheme] = useUI('theme', 'light'); + return
Test
; + } + `, + }, + (result) => { + console.log('\n📄 Initial value without setterFn:'); + console.log(result.css); + + assert(result.css.includes('@custom-variant theme-light')); + } + ); +}); + +test('handles complex boolean toggle patterns', async () => { + await runTest( + { + 'app/boolean-edge-cases.tsx': ` + import { useUI } from '@react-zero-ui/core'; + + function Component() { + const [isVisible, setIsVisible] = useUI('modal-visible', false); + const [isEnabled, setIsEnabled] = useUI('feature-enabled', true); + + // Complex boolean patterns that should still result in true/false + const handleToggle = () => setIsVisible(prev => !prev); + const handleConditional = () => setIsVisible(condition ? true : false); + const handleLogical = () => setIsEnabled(loading && false || true); + + return
Test
; + } + `, + }, + (result) => { + const content = fs.readFileSync(getAttrFile(), 'utf-8'); + console.log('\n📄 Boolean edge cases:'); + console.log(content); + + // Should only have true/false variants for booleans + assert(result.css.includes('@custom-variant modal-visible-true')); + assert(result.css.includes('@custom-variant modal-visible-false')); + assert(result.css.includes('@custom-variant feature-enabled-true')); + assert(result.css.includes('@custom-variant feature-enabled-false')); + + // Should NOT have any other variants + assert(!result.css.includes('@custom-variant modal-visible-prev')); + assert(!result.css.includes('@custom-variant modal-visible-condition')); + } + ); +}); + +test('extracts values from deeply nested function calls', async () => { + await runTest( + { + 'app/nested-calls.jsx': ` + import { useUI } from '@react-zero-ui/core'; + + function Component() { + const [theme, setTheme] = useUI('theme', 'light'); + + // Nested in useEffect + useEffect(() => { + if (darkMode) { + setTheme('dark'); + } else { + setTheme('auto'); + } + }, [darkMode]); + + // Nested in event handler + const handleClick = useCallback(() => { + const newTheme = calculateTheme(); + setTheme(newTheme === 'system' ? 'system' : 'manual'); + }, []); + + // Nested in JSX + return ( +
+ + +
+ ); + } + `, + }, + (result) => { + console.log('\n📄 Nested calls extraction:'); + console.log(result.css); + + // Should extract all literal values + assert(result.css.includes('@custom-variant theme-light')); // initial + assert(result.css.includes('@custom-variant theme-dark')); // from useEffect + assert(result.css.includes('@custom-variant theme-auto')); // from useEffect + assert(result.css.includes('@custom-variant theme-system')); // from ternary + assert(result.css.includes('@custom-variant theme-manual')); // from ternary + assert(result.css.includes('@custom-variant theme-contrast')); // from onClick + assert(result.css.includes('@custom-variant theme-neon')); // from onChange + assert(result.css.includes('@custom-variant theme-retro')); // from onChange + // Note: 'neon' and 'retro' from option values won't be extracted since they're not setter calls + } + ); +}); + +test('handles ternary and logical expressions', async () => { + await runTest( + { + 'app/expressions.tsx': ` + import { useUI } from '@react-zero-ui/core'; + + function Component({ isDark, isLoading, userPreference }) { + const [status, setStatus] = useUI('status', 'idle'); + const [mode, setMode] = useUI('display-mode', 'normal'); + + // Ternary expressions + const updateStatus = () => { + setStatus(isLoading ? 'loading' : 'ready'); + }; + + // Nested ternary + const updateMode = () => { + setMode(isDark ? 'dark' : (userPreference === 'auto' ? 'auto' : 'light')); + }; + + // Logical expressions + const handleError = () => { + setStatus(error && 'error' || 'success'); + }; + + // Complex logical + const handleComplex = () => { + setMode(loading && 'disabled' || ready && 'active' || 'pending'); + }; + + return
Test
; + } + `, + }, + (result) => { + console.log('\n📄 Expression handling:'); + console.log(result.css); + + // Ternary values + assert(result.css.includes('@custom-variant status-loading')); + assert(result.css.includes('@custom-variant status-ready')); + assert(result.css.includes('@custom-variant status-error')); + assert(result.css.includes('@custom-variant status-success')); + + // Nested ternary values + assert(result.css.includes('@custom-variant display-mode-dark')); + assert(result.css.includes('@custom-variant display-mode-auto')); + assert(result.css.includes('@custom-variant display-mode-light')); + + // Complex logical values + assert(result.css.includes('@custom-variant display-mode-disabled')); + assert(result.css.includes('@custom-variant display-mode-active')); + assert(result.css.includes('@custom-variant display-mode-pending')); + } + ); +}); + +test('resolves constants', async () => { + await runTest( + { + 'app/constants-resolve.jsx': ` + import { useUI } from '@react-zero-ui/core'; +export const THEME_DARK = 'dark'; +export const THEME_LIGHT = 'light'; +export const SIZES = { SMALL: 'sm', LARGE: 'lg' }; +const isDark = true; +const isSmall = true; +export function Pages() { + const [theme, setTheme] = useUI('theme', 'default'); + const [size, setSize] = useUI('size', 'medium'); + // Using constants + const toggleTheme = () => { + setTheme(isDark ? THEME_LIGHT : THEME_DARK); + }; + // Using object properties + const updateSize = () => { + setSize(isSmall ? SIZES.SMALL : SIZES.LARGE); + }; + // Local constants + const STATUS_PENDING = 'pending-state'; + const handleStatus = () => { + setTheme(STATUS_PENDING); + }; + return
Test
; +} + + `, + }, + (result) => { + console.log('\n📄 Constants:'); + console.log(result.css); + + assert(result.css.includes('@custom-variant theme-dark')); + assert(result.css.includes('@custom-variant theme-default')); + assert(result.css.includes('@custom-variant theme-light')); + // Does not work for VARIABLE.PROPERTY + assert(result.css.includes('@custom-variant size-sm')); + assert(result.css.includes('@custom-variant size-medium')); + assert(result.css.includes('@custom-variant size-lg')); + assert(result.css.includes('@custom-variant theme-pending-state')); + } + ); +}); + +test.skip('resolves constants and imported values -- COMPLEX --', async () => { + await runTest( + { + 'app/constants.js': ` + export const THEME_DARK = 'dark'; + export const THEME_LIGHT = 'light'; + export const SIZES = { + SMALL: 'sm', + LARGE: 'lg' + }; + `, + 'app/component.jsx': ` + import { useUI } from '@react-zero-ui/core'; + import { THEME_DARK, THEME_LIGHT, SIZES } from './constants'; + + function Component() { + const [theme, setTheme] = useUI('theme', 'default'); + const [size, setSize] = useUI('size', 'medium'); + + // Using constants + const toggleTheme = () => { + setTheme(isDark ? THEME_LIGHT : THEME_DARK); + }; + + // Using object properties + const updateSize = () => { + setSize(isSmall ? SIZES.SMALL : SIZES.LARGE); + }; + + // Local constants + const STATUS_PENDING = 'pending-state'; + const handleStatus = () => { + setTheme(STATUS_PENDING); + }; + + return
Test
; + } + `, + }, + (result) => { + console.log('\n📄 Constants resolution:'); + console.log(result.css); + + // Should resolve local constants + assert(result.css.includes('@custom-variant theme-pending-state')); + assert(result.css.includes('@custom-variant theme-dark')); + assert(result.css.includes('@custom-variant theme-light')); + assert(result.css.includes('@custom-variant size-small')); + assert(result.css.includes('@custom-variant size-large')); + + // Note: Import resolution is complex and might not work initially + // This test documents the expected behavior for future enhancement + } + ); +}); + +test.skip('handles all common setter patterns - full coverage sanity check - COMPLEX', async () => { + await runTest( + { + 'app/component.tsx': ` + import { useUI } from '@react-zero-ui/core'; + + const DARK = 'dark'; + const LIGHT = 'light'; + + function Component() { + const [theme, setTheme] = useUI<'light' | 'dark' | 'contrast' | 'neon' | 'retro'>('theme', 'light'); + const [size, setSize] = useUI<'sm' | 'lg'>('size', 'sm'); + + setTheme('dark'); // direct + setTheme(DARK); // identifier + setTheme(() => 'light'); // arrow fn + setTheme(prev => prev === 'light' ? 'dark' : 'light'); // updater + setTheme(prev => { if (a) return 'neon'; return 'retro'; }); // block + setTheme(userPref || 'contrast'); // logical fallback + setSize(SIZES.SMALL); // object constant + + return ( + <> + + + + ); + } + + const SIZES = { + SMALL: 'sm', + LARGE: 'lg' + }; + `, + }, + (result) => { + console.log('\n📄 Full coverage test:'); + console.log(result.css); + + // ✅ things that MUST be included + assert(result.css.includes('@custom-variant theme-dark')); + assert(result.css.includes('@custom-variant theme-light')); + assert(result.css.includes('@custom-variant theme-contrast')); + assert(result.css.includes('@custom-variant theme-neon')); + assert(result.css.includes('@custom-variant theme-retro')); + assert(result.css.includes('@custom-variant size-sm')); + assert(result.css.includes('@custom-variant size-lg')); + + // ❌ known misses: test exposes what won't work without resolution + // assert(result.css.includes('@custom-variant theme-dynamic-from-e-target')); + } + ); +}); + +test('handles arrow functions and function expressions', async () => { + await runTest( + { + 'app/functions.jsx': ` + import { useUI } from '@react-zero-ui/core'; + + function Component() { + const [state, setState] = useUI('component-state', 'initial'); + + // Arrow function with expression body + const quickSet = () => setState('quick'); + + // Arrow function with block body + const blockSet = () => { + return setState('block'); + }; + + // Function expression + const funcExpr = function() { + setState('function'); + }; + + // Immediately invoked + (() => setState('immediate'))(); + + // Passed as callback + setTimeout(() => setState('delayed'), 1000); + + // Complex return logic + const conditionalSet = () => { + if (condition) return setState('conditional-true'); + return setState('conditional-false'); + }; + + return
Test
; + } + `, + }, + (result) => { + console.log('\n📄 Function expressions:'); + console.log(result.css); + + assert(result.css.includes('@custom-variant component-state-quick')); + assert(result.css.includes('@custom-variant component-state-block')); + assert(result.css.includes('@custom-variant component-state-function')); + assert(result.css.includes('@custom-variant component-state-immediate')); + assert(result.css.includes('@custom-variant component-state-delayed')); + assert(result.css.includes('@custom-variant component-state-conditional-true')); + assert(result.css.includes('@custom-variant component-state-conditional-false')); + } + ); +}); + +test('handles multiple setters for same state key', async () => { + await runTest( + { + 'app/multiple-setters.jsx': ` + import { useUI } from '@react-zero-ui/core'; + + function ComponentA() { + const [theme, setTheme] = useUI('global-theme', 'light'); + const handleClick = () => setTheme('dark'); + return
A
; + } + + function ComponentB() { + const [theme, setGlobalTheme] = useUI('global-theme', 'light'); + const handleToggle = () => setGlobalTheme('auto'); + return
B
; + } + + function ComponentC() { + const [, updateTheme] = useUI('global-theme', 'light'); + const handleSystem = () => updateTheme('system'); + return
C
; + } + `, + }, + (result) => { + console.log('\n📄 Multiple setters:'); + console.log(result.css); + + // Should combine all values from different setters for same key + assert(result.css.includes('@custom-variant global-theme-light')); + assert(result.css.includes('@custom-variant global-theme-dark')); + assert(result.css.includes('@custom-variant global-theme-auto')); + assert(result.css.includes('@custom-variant global-theme-system')); + } + ); +}); + +test('ignores dynamic and non-literal values', async () => { + await runTest( + { + 'app/dynamic-values.jsx': ` + import { useUI } from '@react-zero-ui/core'; + + function Component({ userTheme, config }) { + const [theme, setTheme] = useUI('theme', 'default'); + const [status, setStatus] = useUI('status', 'idle'); + + // Dynamic values that should be ignored + const handleDynamic = () => { + setTheme(userTheme); // prop value + setTheme(config.theme); // object property + setTheme(calculateTheme()); // function call + setTheme(\`theme-\${mode}\`); // template literal with expression + setTheme(themes[index]); // array access + }; + + // But still catch literals mixed in + const handleMixed = () => { + setStatus(error ? 'error' : userStatus); // only 'error' should be caught + setTheme(loading ? 'loading' : calculated); // only 'loading' should be caught + }; + + return
Test
; + } + `, + }, + (result) => { + console.log('\n📄 Dynamic values filtering:'); + console.log(result.css); + + // Should only have literals + assert(result.css.includes('@custom-variant theme-default')); // initial + assert(result.css.includes('@custom-variant status-idle')); // initial + assert(result.css.includes('@custom-variant status-error')); // from ternary + assert(result.css.includes('@custom-variant theme-loading')); // from ternary + + // Should NOT have dynamic values + assert(!result.css.includes('@custom-variant theme-userTheme')); + assert(!result.css.includes('@custom-variant theme-calculateTheme')); + assert(!result.css.includes('@custom-variant theme-theme-')); + } + ); +}); + +test('handles edge cases with unusual syntax', async () => { + await runTest( + { + 'app/edge-cases.jsx': ` + import { useUI } from '@react-zero-ui/core'; + + function Component() { + const [state, setState] = useUI('edge-state', 'normal'); + + // Destructured setter + const { setState: altSetState } = { setState }; + + // Setter in array + const setters = [setState]; + + // Multiple calls in one expression + const multi = () => (setState('first'), setState('second')); + + // Chained calls (unusual but possible) + const chained = () => setState('chain') && setState('link'); + + // In try/catch + const safe = () => { + try { + setState('trying'); + } catch { + setState('caught'); + } + }; + + // Template literal without expressions + const template = () => setState(\`static\`); + + return
Test
; + } + `, + }, + (result) => { + console.log('\n📄 Edge cases:'); + console.log(result.css); + + // Basic cases that should work + assert(result.css.includes('@custom-variant edge-state-first')); + assert(result.css.includes('@custom-variant edge-state-second')); + assert(result.css.includes('@custom-variant edge-state-chain')); + assert(result.css.includes('@custom-variant edge-state-link')); + assert(result.css.includes('@custom-variant edge-state-trying')); + assert(result.css.includes('@custom-variant edge-state-caught')); + assert(result.css.includes('@custom-variant edge-state-static')); + } + ); +}); + +test('performance with large files and many variants', async () => { + // Generate a large file with many useUI calls + const generateLargeFile = () => { + let content = `import { useUI } from '@react-zero-ui/core';\n\n`; + + // Create many components with different state keys + for (let i = 0; i < 50; i++) { + content += ` + function Component${i}() { + const [state${i}, setState${i}] = useUI('state-${i}', 'initial-${i}'); + const [toggle${i}, setToggle${i}] = useUI('toggle-${i}', ${i % 2 === 0}); + + const handler${i} = () => { + setState${i}('value-${i}-a'); + setState${i}('value-${i}-b'); + setState${i}('value-${i}-c'); + setToggle${i}(prev => !prev); + }; + + return
Component ${i}
; + } + `; + } + + return content; + }; + + const startTime = Date.now(); + + await runTest({ 'app/large-file.jsx': generateLargeFile() }, (result) => { + const endTime = Date.now(); + const duration = endTime - startTime; + + console.log(`\n⏱️ Large file processing took: ${duration}ms`); + + // Should handle large files reasonably quickly (< 5 seconds) + assert(duration < 5000, `Processing took too long: ${duration}ms`); + + // Should still extract all variants correctly + assert(result.css.includes('@custom-variant state-0-initial-0')); + assert(result.css.includes('@custom-variant state-49-value-49-c')); + assert(result.css.includes('@custom-variant toggle-0-true')); + assert(result.css.includes('@custom-variant toggle-0-false')); + }); +}); + +test('caching works correctly', async () => { + const testFiles = { + 'app/cached.jsx': ` + import { useUI } from '@react-zero-ui/core'; + + function Component() { + const [theme, setTheme] = useUI('theme', 'light'); + return
Test
; + } + `, + }; + + // First run + const start1 = Date.now(); + await runTest(testFiles, () => {}); + const duration1 = Date.now() - start1; + + // Second run with same files (should be faster due to caching) + const start2 = Date.now(); + await runTest(testFiles, () => {}); + const duration2 = Date.now() - start2; + + console.log(`\n📊 First run: ${duration1}ms, Second run: ${duration2}ms`); + + // Note: This test might be flaky in CI, but useful for development + // Second run should generally be faster, but timing can vary +}); diff --git a/packages/core/src/config.cjs b/packages/core/src/config.cjs index 6df329f..12c8c54 100644 --- a/packages/core/src/config.cjs +++ b/packages/core/src/config.cjs @@ -1,11 +1,12 @@ const CONFIG = { SUPPORTED_EXTENSIONS: { TYPESCRIPT: ['.ts', '.tsx'], JAVASCRIPT: ['.js', '.jsx'] }, HOOK_NAME: 'useUI', + IMPORT_NAME: '@react-zero-ui/core', MIN_HOOK_ARGUMENTS: 2, MAX_HOOK_ARGUMENTS: 2, HEADER: '/* AUTO-GENERATED - DO NOT EDIT */', ZERO_UI_DIR: '.zero-ui', - CONTENT: ['src/**/*.{ts,tsx,js,jsx}', 'app/**/*.{ts,tsx,js,jsx}'], + CONTENT: ['src/**/*.{ts,tsx,js,jsx}', 'app/**/*.{ts,tsx,js,jsx}', 'pages/**/*.{ts,tsx,js,jsx}'], }; const IGNORE_DIRS = new Set([ diff --git a/packages/core/src/dist/config.cjs b/packages/core/src/dist/config.cjs index f4a0ee9..f16c787 100644 --- a/packages/core/src/dist/config.cjs +++ b/packages/core/src/dist/config.cjs @@ -2,6 +2,7 @@ const CONFIG = { SUPPORTED_EXTENSIONS: { TYPESCRIPT: ['.ts', '.tsx'], JAVASCRIPT: ['.js', '.jsx'] }, HOOK_NAME: 'useUI', + IMPORT_NAME: '@react-zero-ui/core', MIN_HOOK_ARGUMENTS: 2, MAX_HOOK_ARGUMENTS: 2, HEADER: '/* AUTO-GENERATED - DO NOT EDIT */', diff --git a/packages/core/src/dist/config.d.cts b/packages/core/src/dist/config.d.cts index 2035770..571cb24 100644 --- a/packages/core/src/dist/config.d.cts +++ b/packages/core/src/dist/config.d.cts @@ -4,6 +4,7 @@ export namespace CONFIG { let JAVASCRIPT: string[]; } let HOOK_NAME: string; + let IMPORT_NAME: string; let MIN_HOOK_ARGUMENTS: number; let MAX_HOOK_ARGUMENTS: number; let HEADER: string; diff --git a/packages/core/src/dist/postcss/v2/ast-v2.cjs b/packages/core/src/dist/postcss/v2/ast-v2.cjs index a3a3bd6..8239788 100644 --- a/packages/core/src/dist/postcss/v2/ast-v2.cjs +++ b/packages/core/src/dist/postcss/v2/ast-v2.cjs @@ -46,7 +46,30 @@ const fs_1 = require("fs"); const crypto_1 = require("crypto"); const traverse = babelTraverse.default; /** - * Collects every `[ , setter ] = useUI('key', 'initial')` in a file. + * Check if the file imports useUI (or the configured hook name) + * @param ast - The parsed AST + * @returns true if useUI is imported, false otherwise + */ +function hasUseUIImport(ast) { + let hasImport = false; + traverse(ast, { + ImportDeclaration(path) { + const source = path.node.source.value; + // Check if importing from @react-zero-ui/core + if (source === config_cjs_1.CONFIG.IMPORT_NAME) { + // Only look for named import: import { useUI } from '...' + const hasUseUISpecifier = path.node.specifiers.some((spec) => t.isImportSpecifier(spec) && t.isIdentifier(spec.imported) && spec.imported.name === config_cjs_1.CONFIG.HOOK_NAME); + if (hasUseUISpecifier) { + hasImport = true; + path.stop(); // Early exit + } + } + }, + }); + return hasImport; +} +/** + * Collects every `[ staleValue, setterFn ] = useUI('key', 'initial')` in a file. * @returns SetterMeta[] */ function collectUseUISetters(ast) { @@ -178,6 +201,22 @@ function extractLiteralsRecursively(node, path) { results.push(...extractLiteralsRecursively(resolved, path)); } } + // Member expressions: SIZES.SMALL, obj.prop, etc. + else if (t.isMemberExpression(node) && !node.computed && t.isIdentifier(node.property)) { + const objectResolved = resolveIdentifier(node.object, path); + if (objectResolved && t.isObjectExpression(objectResolved)) { + // Look for the property in the object + const propertyName = node.property.name; + for (const prop of objectResolved.properties) { + if (t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === propertyName) { + const propertyValue = literalToString(prop.value); + if (propertyValue !== null) { + results.push(propertyValue); + } + } + } + } + } // Binary expressions: might contain literals in some cases else if (t.isBinaryExpression(node)) { // Only extract if it's a simple concatenation that might resolve to a literal @@ -202,18 +241,40 @@ function extractFromBlockStatement(block, path) { } /** * Try to resolve an identifier to its value within the current scope + * @param node - The identifier to resolve + * @param path - The path to the identifier + * @returns The value of the identifier, or null if it cannot be resolved */ function resolveIdentifier(node, path) { const binding = path.scope.getBinding(node.name); if (!binding) return null; - // Look at the binding's initialization const bindingPath = binding.path; // Variable declarator: const x = 'value' if (bindingPath.isVariableDeclarator() && bindingPath.node.init) { return bindingPath.node.init; } - // Function parameter, import, etc. - could be extended + // Import specifier: import { THEME_DARK } from './constants' + if (bindingPath.isImportSpecifier() || bindingPath.isImportDefaultSpecifier()) { + // For now, we can't easily resolve cross-file imports + // But we could enhance this later to parse the imported file + return null; + } + // Function declaration: function getName() { return 'value'; } + if (bindingPath.isFunctionDeclaration()) { + // Could try to extract return values, but that's complex + return null; + } + // Try to look at the scope for block-scoped variables + // that might be defined higher up + let currentScope = path.scope; + while (currentScope) { + const scopeBinding = currentScope.getOwnBinding(node.name); + if (scopeBinding && scopeBinding.path.isVariableDeclarator() && scopeBinding.path.node.init) { + return scopeBinding.path.node.init; + } + currentScope = currentScope.parent; + } return null; } /** diff --git a/packages/core/src/dist/postcss/v2/ast-v2.d.cts b/packages/core/src/dist/postcss/v2/ast-v2.d.cts index 3ec0d26..e951c8d 100644 --- a/packages/core/src/dist/postcss/v2/ast-v2.d.cts +++ b/packages/core/src/dist/postcss/v2/ast-v2.d.cts @@ -11,7 +11,7 @@ export interface SetterMeta { initialValue: string | null; } /** - * Collects every `[ , setter ] = useUI('key', 'initial')` in a file. + * Collects every `[ staleValue, setterFn ] = useUI('key', 'initial')` in a file. * @returns SetterMeta[] */ export declare function collectUseUISetters(ast: t.File): SetterMeta[]; diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 34f1e7f..2597564 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -123,4 +123,3 @@ function useUI(key, initialValue) { } export { useUI }; -export default useUI; diff --git a/packages/core/src/postcss/helpers.cjs b/packages/core/src/postcss/helpers.cjs index 231feba..db22786 100644 --- a/packages/core/src/postcss/helpers.cjs +++ b/packages/core/src/postcss/helpers.cjs @@ -23,7 +23,9 @@ function findAllSourceFiles(patterns = CONFIG.CONTENT) { const cwd = process.cwd(); try { - return globSync(patterns, { cwd, ignore: Array.from(IGNORE_DIRS), absolute: true }); + const files = globSync(patterns, { cwd, ignore: Array.from(IGNORE_DIRS), absolute: true }); + console.log('files: ', files); + return files; } catch (error) { console.warn('[Zero-UI] Error finding source files:', error.message); return []; @@ -41,6 +43,7 @@ async function processVariants(files = null) { const allVariants = sourceFiles.flatMap((file) => { return extractVariants(file); }); + console.log('allVariants: ', allVariants); // Deduplicate and merge variants const variantMap = new Map(); diff --git a/packages/core/src/postcss/v2/ast-v2.cts b/packages/core/src/postcss/v2/ast-v2.cts index 1224117..4466e4a 100644 --- a/packages/core/src/postcss/v2/ast-v2.cts +++ b/packages/core/src/postcss/v2/ast-v2.cts @@ -5,6 +5,8 @@ import * as t from '@babel/types'; import { CONFIG } from '../../config.cjs'; import { readFileSync } from 'fs'; import { createHash } from 'crypto'; +import parser from '@babel/parser'; +import { generate } from '@babel/generator'; const traverse = (babelTraverse as any).default; export interface SetterMeta { @@ -19,7 +21,37 @@ export interface SetterMeta { } /** - * Collects every `[ , setter ] = useUI('key', 'initial')` in a file. + * Check if the file imports useUI (or the configured hook name) + * @param ast - The parsed AST + * @returns true if useUI is imported, false otherwise + */ +function hasUseUIImport(ast: t.File): boolean { + let hasImport = false; + + traverse(ast, { + ImportDeclaration(path: any) { + const source = path.node.source.value; + + // Check if importing from @react-zero-ui/core + if (source === CONFIG.IMPORT_NAME) { + // Only look for named import: import { useUI } from '...' + const hasUseUISpecifier = path.node.specifiers.some( + (spec: any) => t.isImportSpecifier(spec) && t.isIdentifier(spec.imported) && spec.imported.name === CONFIG.HOOK_NAME + ); + + if (hasUseUISpecifier) { + hasImport = true; + path.stop(); // Early exit + } + } + }, + }); + + return hasImport; +} + +/** + * Collects every `[ staleValue, setterFn ] = useUI('key', 'initial')` in a file. * @returns SetterMeta[] */ export function collectUseUISetters(ast: t.File): SetterMeta[] { @@ -177,6 +209,23 @@ function extractLiteralsRecursively(node: t.Expression, path: NodePath): string[ } } + // Member expressions: SIZES.SMALL, obj.prop, etc. + else if (t.isMemberExpression(node) && !node.computed && t.isIdentifier(node.property)) { + const objectResolved = resolveIdentifier(node.object as t.Identifier, path); + if (objectResolved && t.isObjectExpression(objectResolved)) { + // Look for the property in the object + const propertyName = node.property.name; + for (const prop of objectResolved.properties) { + if (t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === propertyName) { + const propertyValue = literalToString(prop.value as t.Expression); + if (propertyValue !== null) { + results.push(propertyValue); + } + } + } + } + } + // Binary expressions: might contain literals in some cases else if (t.isBinaryExpression(node)) { // Only extract if it's a simple concatenation that might resolve to a literal @@ -206,12 +255,14 @@ function extractFromBlockStatement(block: t.BlockStatement, path: NodePath): str /** * Try to resolve an identifier to its value within the current scope + * @param node - The identifier to resolve + * @param path - The path to the identifier + * @returns The value of the identifier, or null if it cannot be resolved */ function resolveIdentifier(node: t.Identifier, path: NodePath): t.Expression | null { const binding = path.scope.getBinding(node.name); if (!binding) return null; - // Look at the binding's initialization const bindingPath = binding.path; // Variable declarator: const x = 'value' @@ -219,7 +270,30 @@ function resolveIdentifier(node: t.Identifier, path: NodePath): t.Expression | n return bindingPath.node.init as t.Expression; } - // Function parameter, import, etc. - could be extended + // Import specifier: import { THEME_DARK } from './constants' + if (bindingPath.isImportSpecifier() || bindingPath.isImportDefaultSpecifier()) { + // For now, we can't easily resolve cross-file imports + // But we could enhance this later to parse the imported file + return null; + } + + // Function declaration: function getName() { return 'value'; } + if (bindingPath.isFunctionDeclaration()) { + // Could try to extract return values, but that's complex + return null; + } + + // Try to look at the scope for block-scoped variables + // that might be defined higher up + let currentScope = path.scope; + while (currentScope) { + const scopeBinding = currentScope.getOwnBinding(node.name); + if (scopeBinding && scopeBinding.path.isVariableDeclarator() && scopeBinding.path.node.init) { + return scopeBinding.path.node.init as t.Expression; + } + currentScope = currentScope.parent; + } + return null; } diff --git a/packages/core/src/postcss/v2/helpers.cts b/packages/core/src/postcss/v2/helpers.cts new file mode 100644 index 0000000..942eae2 --- /dev/null +++ b/packages/core/src/postcss/v2/helpers.cts @@ -0,0 +1,363 @@ +// src/postcss/helpers.cjs +import fs from 'fs'; +import path from 'path'; +import { CONFIG, IGNORE_DIRS } from '../../config.cjs'; +import { parseJsonWithBabel, parseAndUpdatePostcssConfig, parseAndUpdateViteConfig } from '../ast.cjs'; +import { extractVariants } from './ast-v2.cjs'; + +function toKebabCase(str: string) { + if (typeof str !== 'string') { + throw new Error(`Expected string but got: ${typeof str}`); + } + if (!/^[a-zA-Z0-9_-]+$/.test(str)) { + throw new Error(`Invalid state key/value "${str}". Only alphanumerics, underscores, and dashes are allowed.`); + } + return str + .replace(/_/g, '-') + .replace(/([a-z])([A-Z])/g, '$1-$2') + .toLowerCase(); +} + +function findAllSourceFiles(patterns = CONFIG.CONTENT) { + const { globSync } = require('glob'); + const cwd = process.cwd(); + + try { + return globSync(patterns, { cwd, ignore: Array.from(IGNORE_DIRS), absolute: true }); + } catch (error) { + console.warn('[Zero-UI] Error finding source files:', error.message); + return []; + } +} + +/** + * Process all variants from source files and return deduplicated, sorted variants + * This is the core processing logic used by both PostCSS plugin and init script + * @param {string[]} files - Array of file paths to process - if not provided, all source files will be found and processed + * @returns {Object} - Object containing final variants, initial values, and source files + */ +async function processVariants(files = null) { + const sourceFiles = files || findAllSourceFiles(); + const allVariants = sourceFiles.flatMap((file) => { + return extractVariants(file); + }); + console.log('allVariants: ', allVariants); + + // Deduplicate and merge variants + const variantMap = new Map(); + const initialValueMap = new Map(); + + for (const variant of allVariants) { + const { key, values, initialValue } = variant; + + if (!variantMap.has(key)) { + variantMap.set(key, new Set()); + if (initialValue !== null && initialValue !== undefined) { + initialValueMap.set(key, initialValue); + } + } + + if (Array.isArray(values)) { + values.forEach((v) => variantMap.get(key).add(v)); + } + } + + // Convert to final format + const finalVariants = Array.from(variantMap.entries()) + .map(([key, set]) => ({ key, values: Array.from(set).sort(), initialValue: initialValueMap.get(key) })) + .sort((a, b) => a.key.localeCompare(b.key)); + + // Generate initial values object + const initialValues = {}; + for (const { key, values, initialValue } of finalVariants) { + const keySlug = toKebabCase(key); + initialValues[`data-${keySlug}`] = initialValue || values[0] || ''; + } + + return { finalVariants, initialValues, sourceFiles }; +} + +function buildCss(variants) { + const lines = variants.flatMap(({ key, values }) => { + 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); + + return `@custom-variant ${keySlug}-${valSlug} { + &:where(body[data-${keySlug}="${valSlug}"] *) { @slot; } + [data-${keySlug}="${valSlug}"] &, &[data-${keySlug}="${valSlug}"] { @slot; } +}`; + }); + }); + + return CONFIG.HEADER + '\n' + lines.join('\n') + '\n'; +} + +async function generateAttributesFile(finalVariants, initialValues) { + const cwd = process.cwd(); + const ATTR_DIR = path.join(cwd, CONFIG.ZERO_UI_DIR); + const ATTR_FILE = path.join(ATTR_DIR, 'attributes.js'); + const ATTR_TYPE_FILE = path.join(ATTR_DIR, 'attributes.d.ts'); + + // Generate JavaScript export + const attrExport = `${CONFIG.HEADER}\nexport const bodyAttributes = ${JSON.stringify(initialValues, null, 2)};\n`; + + // Generate TypeScript definitions + const toLiteral = (v) => (typeof v === 'string' ? `"${v.replace(/"/g, '\\"')}"` : v); + const variantLines = finalVariants.map(({ key, values }) => { + const slug = `data-${toKebabCase(key)}`; + const union = values.length ? values.map(toLiteral).join(' | ') : 'string'; // ← fallback + return ` "${slug}": ${union};`; + }); + + // Always include an index signature so TS doesn't optimize + // the declaration away when no variants exist. + if (variantLines.length === 0) { + variantLines.push(' [key: string]: string;'); + } + + const typeLines = [CONFIG.HEADER, 'export declare const bodyAttributes: {', ...variantLines, '};', '']; + const attrTypeExport = typeLines.join('\n'); + + // Create directory if it doesn't exist + fs.mkdirSync(ATTR_DIR, { recursive: true }); + + // Only write if content has changed + const writeIfChanged = (file, content) => { + const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : ''; + if (existing !== content) { + fs.writeFileSync(file, content); + return true; + } + return false; + }; + + const jsChanged = writeIfChanged(ATTR_FILE, attrExport); + const tsChanged = writeIfChanged(ATTR_TYPE_FILE, attrTypeExport); + + return { jsChanged, tsChanged }; +} + +function isZeroUiInitialized() { + const cwd = process.cwd(); + const ATTR_DIR = path.join(cwd, CONFIG.ZERO_UI_DIR); + const ATTR_FILE = path.join(ATTR_DIR, 'attributes.js'); + const ATTR_TYPE_FILE = path.join(ATTR_DIR, 'attributes.d.ts'); + + if (!fs.existsSync(ATTR_FILE) || !fs.existsSync(ATTR_TYPE_FILE)) { + return false; + } + + try { + const attrContent = fs.readFileSync(ATTR_FILE, 'utf-8'); + const typeContent = fs.readFileSync(ATTR_TYPE_FILE, 'utf-8'); + + return attrContent.includes('export const bodyAttributes') && typeContent.includes('export declare const bodyAttributes'); + } catch (error) { + console.error('[Zero-UI] Error checking if Zero-UI is initialized:', error); + return false; + } +} + +/** + * Adds the @zero-ui/attributes path alias and ensures the generated .d.ts files + * are inside the TypeScript program. Writes to ts/ jsconfig only if something + * actually changes. + */ +async function patchConfigAlias() { + const cwd = process.cwd(); + + const configFile = fs.existsSync(path.join(cwd, 'tsconfig.json')) ? 'tsconfig.json' : fs.existsSync(path.join(cwd, 'jsconfig.json')) ? 'jsconfig.json' : null; + + // Ignore Vite fixtures — they patch their own config + if (fs.existsSync(path.join(cwd, 'vite.config.ts'))) return; + if (!configFile) { + return console.warn(`[Zero-UI] No ts/ jsconfig found in ${cwd}`); + } + + const configPath = path.join(cwd, configFile); + const raw = fs.readFileSync(configPath, 'utf-8'); + const config = parseJsonWithBabel(raw, configPath); + if (!config) { + return console.warn(`[Zero-UI] Could not parse ${configFile}`); + } + + /* ---------- ensure alias ---------- */ + config.compilerOptions ??= {}; + config.compilerOptions.baseUrl ??= '.'; + config.compilerOptions.paths ??= {}; + + const expectedPaths = ['./.zero-ui/attributes.js']; + const currentPaths = config.compilerOptions.paths['@zero-ui/attributes']; + let changed = false; + + if (!Array.isArray(currentPaths) || JSON.stringify(currentPaths) !== JSON.stringify(expectedPaths)) { + config.compilerOptions.paths['@zero-ui/attributes'] = expectedPaths; + changed = true; + } + + /* ---------- ensure .d.ts includes ---------- */ + const extraIncludes = ['.zero-ui/**/*.d.ts', '.next/**/*.d.ts']; + config.include = Array.from(new Set([...(config.include ?? []), ...extraIncludes])); + + if (config.include.length !== (config.include ?? []).length) { + changed = true; + } + + /* ---------- write only if modified ---------- */ + if (changed) { + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + console.log(`[Zero-UI] Patched ${configFile} (paths &/or includes)`); + } +} + +/** + * Patches postcss.config.js to include Zero-UI plugin before Tailwind CSS + * Only runs for Next.js projects and uses AST parsing for robust config modification + */ +async function patchPostcssConfig() { + const cwd = process.cwd(); + const postcssConfigJsPath = path.join(cwd, 'postcss.config.js'); + const postcssConfigMjsPath = path.join(cwd, 'postcss.config.mjs'); + const packageJsonPath = path.join(cwd, 'package.json'); + + // Determine which config file exists (prefer .js over .mjs) + let postcssConfigPath = null; + let isESModule = false; + + if (fs.existsSync(postcssConfigJsPath)) { + postcssConfigPath = postcssConfigJsPath; + isESModule = false; + } else if (fs.existsSync(postcssConfigMjsPath)) { + postcssConfigPath = postcssConfigMjsPath; + isESModule = true; + } + + const zeroUiPlugin = '@react-zero-ui/core/postcss'; + + let createMjs = false; + + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + createMjs = packageJson.type === 'module'; + } + + // If no config exists, create a .js file (more widely supported) + if (!postcssConfigPath) { + const newConfigPath = createMjs ? postcssConfigMjsPath : postcssConfigJsPath; + const configJs = `// postcss.config.js +module.exports = { + plugins: { + '${zeroUiPlugin}': {}, + // Tailwind MUST come AFTER Zero-UI + '@tailwindcss/postcss': {} + } +}; +`; + const configMjs = `// postcss.config.mjs +export default { + plugins: { + '${zeroUiPlugin}': {}, + // Tailwind MUST come AFTER Zero-UI + '@tailwindcss/postcss': {} + } +}; +`; + const newConfigJs = createMjs ? configMjs : configJs; + fs.writeFileSync(newConfigPath, newConfigJs); + console.log(`[Zero-UI] Created ${path.basename(newConfigPath)} with Zero-UI plugin`); + return; + } + + // Parse existing config using AST + const existingContent = fs.readFileSync(postcssConfigPath, 'utf-8'); + const updatedConfig = parseAndUpdatePostcssConfig(existingContent, zeroUiPlugin, isESModule); + + if (updatedConfig && updatedConfig !== existingContent) { + fs.writeFileSync(postcssConfigPath, updatedConfig); + const configFileName = path.basename(postcssConfigPath); + console.log(`[Zero-UI] Updated ${configFileName} to include Zero-UI plugin`); + } else if (updatedConfig === null) { + const configFileName = path.basename(postcssConfigPath); + console.log(`[Zero-UI] PostCSS config exists but missing Zero-UI plugin.`); + console.warn(`[Zero-UI] Please manually add "@react-zero-ui/core/postcss" before Tailwind in your ${configFileName}`); + } +} + +/** + * Patches vite.config.ts/js to include Zero-UI plugin and replace Tailwind CSS v4+ plugin if present + * Only runs for Vite projects and uses AST parsing for robust config modification + */ +async function patchViteConfig() { + const cwd = process.cwd(); + const viteConfigTsPath = path.join(cwd, 'vite.config.ts'); + const viteConfigJsPath = path.join(cwd, 'vite.config.js'); + const viteConfigMjsPath = path.join(cwd, 'vite.config.mjs'); + + // Determine which config file exists (prefer .ts over .js) + let viteConfigPath = null; + + if (fs.existsSync(viteConfigTsPath)) { + viteConfigPath = viteConfigTsPath; + } else if (fs.existsSync(viteConfigJsPath)) { + viteConfigPath = viteConfigJsPath; + } else if (fs.existsSync(viteConfigMjsPath)) { + viteConfigPath = viteConfigMjsPath; + } else { + return; // No Vite config found, skip patching + } + + const zeroUiPlugin = '@react-zero-ui/core/vite'; + + try { + // Read existing config + const existingContent = fs.readFileSync(viteConfigPath, 'utf-8'); + + // Parse and update config using AST + const updatedConfig = parseAndUpdateViteConfig(existingContent, zeroUiPlugin); + + if (updatedConfig && updatedConfig !== existingContent) { + fs.writeFileSync(viteConfigPath, updatedConfig); + const configFileName = path.basename(viteConfigPath); + console.log(`[Zero-UI] Updated ${configFileName} with Zero-UI plugin`); + + // Check if Tailwind was replaced + if (existingContent.includes('@tailwindcss/vite')) { + console.log(`[Zero-UI] Replaced @tailwindcss/vite with Zero-UI plugin`); + } + } else if (updatedConfig === null) { + const configFileName = path.basename(viteConfigPath); + console.log(`[Zero-UI] Could not automatically update ${configFileName}`); + console.log(`[Zero-UI] Please manually add "import zeroUI from '${zeroUiPlugin}'" and "zeroUI()" to your plugins array`); + } + // If updatedConfig === existingContent, config is already properly configured + } catch (error) { + console.error('[Zero-UI] Error patching Vite config:', error.message); + } +} + +/** + * Check if the current project has Vite config files + * @returns {boolean} True if Vite config files are found + */ +function hasViteConfig() { + const cwd = process.cwd(); + const viteConfigFiles = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']; + return viteConfigFiles.some((configFile) => fs.existsSync(path.join(cwd, configFile))); +} + +module.exports = { + toKebabCase, + findAllSourceFiles, + buildCss, + patchConfigAlias, + patchPostcssConfig, + patchViteConfig, + generateAttributesFile, + processVariants, + isZeroUiInitialized, + hasViteConfig, +}; From acd021cdcb314b91de9d71fac5341be9835f584a Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Mon, 30 Jun 2025 14:13:35 -0700 Subject: [PATCH 13/34] merge: typescript v1 merge complete --- package.json | 2 +- packages/core/__tests__/e2e/nextSetup.js | 2 +- .../fixtures/next/app/UseEffectComponent.tsx | 2 +- .../core/__tests__/fixtures/next/app/page.tsx | 17 +- .../__tests__/fixtures/next/app/test/page.jsx | 14 +- .../core/__tests__/fixtures/vite/src/App.tsx | 17 +- packages/core/__tests__/helpers/loadCli.js | 2 +- packages/core/__tests__/unit/cli.test.cjs | 42 +- packages/core/__tests__/unit/index.test.cjs | 7 +- packages/core/package.json | 31 +- packages/core/src/cli/init.cjs | 25 - packages/core/src/cli/init.cts | 20 + packages/core/src/cli/init.d.ts | 1 - .../cli/{postInstall.cjs => postInstall.cts} | 10 +- packages/core/src/{config.cjs => config.cts} | 6 +- packages/core/src/dist/config.cjs | 24 - packages/core/src/dist/config.d.cts | 14 - packages/core/src/dist/postcss/v2/ast-v2.cjs | 382 ----------- .../core/src/dist/postcss/v2/ast-v2.d.cts | 44 -- packages/core/src/{index.js => index.ts} | 65 +- packages/core/src/postcss/{v2 => }/ast-v2.cts | 343 +++++++++- packages/core/src/postcss/ast.cjs | 607 ------------------ .../src/postcss/coming-soon/build-tool.ts | 2 +- .../src/postcss/coming-soon/collect-refs.cts | 2 +- .../postcss/coming-soon/inject-attributes.ts | 2 +- packages/core/src/postcss/helpers.cjs | 365 ----------- .../core/src/postcss/{v2 => }/helpers.cts | 99 +-- .../core/src/postcss/{index.cjs => index.cts} | 24 +- packages/core/src/postcss/index.d.ts | 10 - packages/core/src/postcss/vite.d.ts | 9 - .../core/src/postcss/{vite.js => vite.ts} | 8 +- packages/core/tsconfig.json | 17 +- pnpm-lock.yaml | 3 + tsconfig.base.json | 4 +- 34 files changed, 552 insertions(+), 1670 deletions(-) delete mode 100755 packages/core/src/cli/init.cjs create mode 100755 packages/core/src/cli/init.cts delete mode 100644 packages/core/src/cli/init.d.ts rename packages/core/src/cli/{postInstall.cjs => postInstall.cts} (76%) rename packages/core/src/{config.cjs => config.cts} (85%) delete mode 100644 packages/core/src/dist/config.cjs delete mode 100644 packages/core/src/dist/config.d.cts delete mode 100644 packages/core/src/dist/postcss/v2/ast-v2.cjs delete mode 100644 packages/core/src/dist/postcss/v2/ast-v2.d.cts rename packages/core/src/{index.js => index.ts} (72%) rename packages/core/src/postcss/{v2 => }/ast-v2.cts (53%) delete mode 100644 packages/core/src/postcss/ast.cjs delete mode 100644 packages/core/src/postcss/helpers.cjs rename packages/core/src/postcss/{v2 => }/helpers.cts (81%) rename packages/core/src/postcss/{index.cjs => index.cts} (58%) delete mode 100644 packages/core/src/postcss/index.d.ts delete mode 100644 packages/core/src/postcss/vite.d.ts rename packages/core/src/postcss/{vite.js => vite.ts} (72%) diff --git a/package.json b/package.json index 1c652ee..f6dc8ac 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "format": "prettier --write .", "lint": "eslint .", "lint:fix": "eslint . --fix", - "size": "npx esbuild ./packages/core/src/index.js --bundle --minify --format=esm --external:react --define:process.env.NODE_ENV='\"production\"' | gzip -c | wc -c" + "size": "npx esbuild ./packages/core/dist/index.js --bundle --minify --format=esm --external:react --define:process.env.NODE_ENV='\"production\"' | gzip -c | wc -c" }, "devDependencies": { "@eslint/js": "^9.0.0", diff --git a/packages/core/__tests__/e2e/nextSetup.js b/packages/core/__tests__/e2e/nextSetup.js index cbfbf4d..71823c9 100644 --- a/packages/core/__tests__/e2e/nextSetup.js +++ b/packages/core/__tests__/e2e/nextSetup.js @@ -17,7 +17,7 @@ export default async function globalSetup() { // Wait for 5 seconds to make sure the file system is stable console.log('[Global Setup] ⏳ Waiting 5 seconds for file system to stabilize...'); - await new Promise(resolve => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, 5000)); console.log('[Global Setup] ✅ Next.js fixture setup complete!✅'); } diff --git a/packages/core/__tests__/fixtures/next/app/UseEffectComponent.tsx b/packages/core/__tests__/fixtures/next/app/UseEffectComponent.tsx index b33ebd5..2df0e9a 100644 --- a/packages/core/__tests__/fixtures/next/app/UseEffectComponent.tsx +++ b/packages/core/__tests__/fixtures/next/app/UseEffectComponent.tsx @@ -1,6 +1,6 @@ 'use client'; import { useEffect, useState } from 'react'; -import useUI from '@react-zero-ui/core'; +import { useUI } from '@react-zero-ui/core'; // Component that automatically cycles through themes using useEffect export default function UseEffectComponent() { diff --git a/packages/core/__tests__/fixtures/next/app/page.tsx b/packages/core/__tests__/fixtures/next/app/page.tsx index 86aca17..77889dc 100644 --- a/packages/core/__tests__/fixtures/next/app/page.tsx +++ b/packages/core/__tests__/fixtures/next/app/page.tsx @@ -1,6 +1,5 @@ 'use client'; - -import useUI from '@react-zero-ui/core'; +import { useUI } from '@react-zero-ui/core'; import UseEffectComponent from './UseEffectComponent'; import FAQ from './FAQ'; @@ -8,11 +7,11 @@ export default function Page() { const [, setTheme] = useUI<'light' | 'dark'>('theme', 'light'); const [, setTheme2] = useUI<'light' | 'dark'>('theme-2', 'light'); const [, setThemeThree] = useUI<'light' | 'dark'>('themeThree', 'light'); - const [, setToggle] = useUI('toggle-boolean', true); - const [, setNumber] = useUI<1 | 2>('number', 1); + const [, setToggle] = useUI<'true' | 'false'>('toggle-boolean', 'true'); + const [, setNumber] = useUI<'1' | '2'>('number', '1'); const [, setOpen] = useUI<'open' | 'closed'>('faq', 'closed'); // Same key everywhere! const [, setScope] = useUI<'off' | 'on'>('scope', 'off'); - const [, setMobile] = useUI('mobile', false); + const [, setMobile] = useUI<'true' | 'false'>('mobile', 'false'); const [, setToggleFunction] = useUI<'white' | 'black'>('toggle-function', 'white'); @@ -88,7 +87,7 @@ export default function Page() { data-testid="toggle-boolean-container"> diff --git a/examples/demo/src/app/zero-ui/Dashboard.tsx b/examples/demo/src/app/zero-ui/Dashboard.tsx index 69ae890..189795c 100644 --- a/examples/demo/src/app/zero-ui/Dashboard.tsx +++ b/examples/demo/src/app/zero-ui/Dashboard.tsx @@ -10,7 +10,7 @@ export const Dashboard: React.FC = () => {
+ +
+ ); +}; +export default Component; + `, + }, + + async () => { + const variants = extractVariants('src/app/Component.jsx'); + console.log('variants: ', variants); + assert.strictEqual(variants.length, 2); + assert.ok(variants.some((v) => v.key === 'theme' && v.values.includes('light') && v.values.includes('dark'))); + assert.ok(variants.some((v) => v.key === 'size' && v.values.includes('medium') && v.values.includes('large'))); + } + ); +}); + +test('Extract Variant without setter', async () => { + await runTest( + { + 'src/app/Component.jsx': ` + import { useUI } from '@react-zero-ui/core'; +const Component = () => { + const [, setTheme] = useUI('theme', 'light'); + const [, setSize] = useUI('size', 'medium'); + return ( +
+ +
+ ); +}; +export default Component; + `, + }, + + async () => { + const variants = extractVariants('src/app/Component.jsx'); + console.log('variants without setter: ', variants); + assert.strictEqual(variants.length, 2); + assert.ok(variants.some((v) => v.key === 'theme' && v.values.includes('light'))); + assert.ok(variants.some((v) => v.key === 'size' && v.values.includes('medium'))); + } + ); +}); diff --git a/packages/core/__tests__/unit/index.test.cjs b/packages/core/__tests__/unit/index.test.cjs index 90a0748..291207b 100644 --- a/packages/core/__tests__/unit/index.test.cjs +++ b/packages/core/__tests__/unit/index.test.cjs @@ -158,26 +158,31 @@ test('detects JavaScript setValue calls', async () => { ); }); -test('handles boolean values', async () => { +test('handles string boolean values', async () => { await runTest( { 'app/toggle.tsx': ` import { useUI } from '@react-zero-ui/core'; function Toggle() { - const [isOpen, setIsOpen] = useUI('drawer', false); - const [checked, setChecked] = useUI('checkbox', true); + const [isOpen, setIsOpen] = useUI<'true' | 'false'>('drawer', 'false'); + const [checked, setChecked] = useUI<'true' | 'false'>('checkbox', 'true'); return ( - + +
); } `, }, (result) => { - console.log('\n🔍 Boolean Values Test:'); + console.log('\n🔍 String Boolean Values Test:'); assert(result.css.includes('@custom-variant drawer-true'), 'Should have drawer-true'); assert(result.css.includes('@custom-variant drawer-false'), 'Should have drawer-false'); @@ -185,7 +190,7 @@ test('handles boolean values', async () => { assert(result.css.includes('@custom-variant checkbox-false'), 'Should have checkbox-false'); const content = fs.readFileSync(getAttrFile(), 'utf-8'); - console.log('Boolean attributes:', content); + console.log('String boolean attributes:', content); } ); }); @@ -1468,20 +1473,20 @@ test('generated variants for initial value without setterFn', async () => { ); }); -test('handles complex boolean toggle patterns', async () => { +test('handles complex string boolean toggle patterns', async () => { await runTest( { 'app/boolean-edge-cases.tsx': ` import { useUI } from '@react-zero-ui/core'; function Component() { - const [isVisible, setIsVisible] = useUI('modal-visible', false); - const [isEnabled, setIsEnabled] = useUI('feature-enabled', true); + const [isVisible, setIsVisible] = useUI('modal-visible', 'false'); + const [isEnabled, setIsEnabled] = useUI('feature-enabled', 'true'); - // Complex boolean patterns that should still result in true/false - const handleToggle = () => setIsVisible(prev => !prev); - const handleConditional = () => setIsVisible(condition ? true : false); - const handleLogical = () => setIsEnabled(loading && false || true); + // Complex string boolean patterns that should result in true/false + const handleToggle = () => setIsVisible(prev => prev === 'false' ? 'true' : 'false'); + const handleConditional = () => setIsVisible(condition ? 'true' : 'false'); + const handleLogical = () => setIsEnabled(loading ? 'false' : 'true'); return
Test
; } @@ -1489,10 +1494,10 @@ test('handles complex boolean toggle patterns', async () => { }, (result) => { const content = fs.readFileSync(getAttrFile(), 'utf-8'); - console.log('\n📄 Boolean edge cases:'); + console.log('\n📄 String boolean edge cases:'); console.log(content); - // Should only have true/false variants for booleans + // Should only have true/false variants for string booleans assert(result.css.includes('@custom-variant modal-visible-true')); assert(result.css.includes('@custom-variant modal-visible-false')); assert(result.css.includes('@custom-variant feature-enabled-true')); @@ -1666,10 +1671,10 @@ export function Pages() { ); }); -test.skip('resolves constants and imported values -- COMPLEX --', async () => { +test('resolves constants and imported values -- COMPLEX --', async () => { await runTest( { - 'app/constants.js': ` + 'app/constants.ts': ` export const THEME_DARK = 'dark'; export const THEME_LIGHT = 'light'; export const SIZES = { @@ -1680,7 +1685,7 @@ test.skip('resolves constants and imported values -- COMPLEX --', async () => { 'app/component.jsx': ` import { useUI } from '@react-zero-ui/core'; import { THEME_DARK, THEME_LIGHT, SIZES } from './constants'; - + console.log('THEME_DARK', THEME_DARK); function Component() { const [theme, setTheme] = useUI('theme', 'default'); const [size, setSize] = useUI('size', 'medium'); @@ -1977,16 +1982,17 @@ test('performance with large files and many variants', async () => { // Create many components with different state keys for (let i = 0; i < 50; i++) { + const toggleInitial = i % 2 === 0 ? "'true'" : "'false'"; content += ` function Component${i}() { const [state${i}, setState${i}] = useUI('state-${i}', 'initial-${i}'); - const [toggle${i}, setToggle${i}] = useUI('toggle-${i}', ${i % 2 === 0}); + const [toggle${i}, setToggle${i}] = useUI('toggle-${i}', ${toggleInitial}); const handler${i} = () => { setState${i}('value-${i}-a'); setState${i}('value-${i}-b'); setState${i}('value-${i}-c'); - setToggle${i}(prev => !prev); + setToggle${i}(prev => prev === 'true' ? 'false' : 'true'); }; return
Component ${i}
; diff --git a/packages/core/package.json b/packages/core/package.json index 2a2b150..76d505b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -90,4 +90,4 @@ "@types/babel__traverse": "^7.20.7", "@types/react": "^19.1.8" } -} \ No newline at end of file +} diff --git a/packages/core/src/index.d.ts b/packages/core/src/index.d.ts deleted file mode 100644 index 35eb402..0000000 --- a/packages/core/src/index.d.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as React from 'react'; - -/** - * A setter function that updates data-* attributes on DOM elements. - * Includes a .ref property for scoping the updates to specific elements. - */ -export interface UISetter { - /** - * Updates the data-* attribute. Supports both direct values and updater functions. - * @param valueOrUpdater - Either a direct value or function that receives current parsed value - */ - (valueOrUpdater: T | ((currentValue: T) => T)): void; - - /** - * Attach to any HTML element whose dataset you want to mutate. - * If not attached, updates will target document.body. - */ - readonly ref: React.RefObject; -} - -/** - * A render-less React hook for managing UI state via data-* attributes. - * - * @param key - The data-* attribute key (kebab-case, e.g., "my-key" becomes data-my-key) - * @param initialValue - The initial value, determines the type for all operations - * @returns A tuple [staleValue, setter] where staleValue is always the initialValue - * - * @example - * ```tsx - * const [, setCount] = useUI('count', 0); - * const [, setVisible] = useUI('visible', false); - * - * Scoped to specific element - *
- * - *
- * - * Global (updates document.body) - * setVisible(true); - * ``` - */ -declare function useUI(key: string, initialValue: T): readonly [T, UISetter]; - -export { useUI }; -export default useUI; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 051c140..484cbc7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -65,7 +65,7 @@ function useUI(key: string, initialValue: T): [T, UIS const target = scopeRef.current ?? document.body; let newValue: T; - // Check if caller passed an updater function (like React's setState(prev => prev) pattern) + // Check if caller passed an updater function (like React's setState(prev => prev === 'true' ? 'false' : 'true') pattern) if (typeof valueOrUpdater === 'function') { const value = target.dataset[camelKey] as T; diff --git a/packages/core/src/postcss/ast-v2.cts b/packages/core/src/postcss/ast-v2.cts index 461693b..f9034a6 100644 --- a/packages/core/src/postcss/ast-v2.cts +++ b/packages/core/src/postcss/ast-v2.cts @@ -71,11 +71,18 @@ export function collectUseUISetters(ast: t.File): SetterMeta[] { const [keyArg, initialArg] = init.arguments; if (!t.isStringLiteral(keyArg)) return; // dynamic keys are ignored + // Since useUI now only accepts strings, validate initial value + const initialValue = literalToString(initialArg as t.Expression); + if (initialValue === null) { + console.error(`[Zero-UI] Non-string initial value found for key "${keyArg.value}". Only string literals are supported.`); + return; + } + setters.push({ binding: path.scope.getBinding(setterEl.name)!, // never null here setterName: setterEl.name, stateKey: keyArg.value, - initialValue: literalToString(initialArg as t.Expression), + initialValue, }); } }, @@ -115,14 +122,6 @@ export function harvestSetterValues(setters: SetterMeta[]): Map | null { - const parent = referencePath.parent; + try { + const parent = referencePath.parent; - // Direct call: setTheme('dark') - if (t.isCallExpression(parent) && parent.callee === referencePath.node) { - return referencePath.parentPath as NodePath; - } + // Direct call: setTheme('dark') + if (t.isCallExpression(parent) && parent.callee === referencePath.node) { + return referencePath.parentPath as NodePath; + } - // Member expression call: obj.setTheme('dark') - if (t.isMemberExpression(parent) && parent.property === referencePath.node) { - const grandParent = referencePath.parentPath?.parent; - if (t.isCallExpression(grandParent) && grandParent.callee === parent) { - return referencePath.parentPath?.parentPath as NodePath; + // Member expression call: obj.setTheme('dark') + if (t.isMemberExpression(parent) && parent.property === referencePath.node) { + const grandParent = referencePath.parentPath?.parent; + if (t.isCallExpression(grandParent) && grandParent.callee === parent) { + return referencePath.parentPath?.parentPath as NodePath; + } } - } + console.warn(`[Zero-UI] Failed to find call expression for ${referencePath.node.type} in ${referencePath.opts?.filename}`); - return null; + return null; + } catch (error) { + console.warn(`[Zero-UI] Failed to find call expression for ${referencePath.node.type} in ${referencePath.opts?.filename}`); + return null; + } } /** - * Recursively extract literal values from an expression - * Handles: literals, ternaries, logical expressions, functions, identifiers + * Recursively extract string literal values from an expression + * Optimized for common useUI patterns: + * - Direct strings: 'light', 'dark' + * - Ternaries: condition ? 'light' : 'dark' + * - Constants: THEMES.LIGHT + * - Functions: prev => prev === 'light' ? 'dark' : 'light' + * - Member expressions: obj.prop, obj.prop.prop, etc. */ function extractLiteralsRecursively(node: t.Expression, path: NodePath): string[] { const results: string[] = []; @@ -177,69 +187,78 @@ function extractLiteralsRecursively(node: t.Expression, path: NodePath): string[ results.push(literal); return results; } + try { + // Ternary: condition ? 'value1' : 'value2' + if (t.isConditionalExpression(node)) { + results.push(...extractLiteralsRecursively(node.consequent, path)); + results.push(...extractLiteralsRecursively(node.alternate, path)); + } - // Ternary: condition ? 'value1' : 'value2' - if (t.isConditionalExpression(node)) { - results.push(...extractLiteralsRecursively(node.consequent, path)); - results.push(...extractLiteralsRecursively(node.alternate, path)); - } + // Logical expressions: a && 'value' || 'default' + else if (t.isLogicalExpression(node)) { + results.push(...extractLiteralsRecursively(node.left, path)); + results.push(...extractLiteralsRecursively(node.right, path)); + } - // Logical expressions: a && 'value' || 'default' - else if (t.isLogicalExpression(node)) { - results.push(...extractLiteralsRecursively(node.left, path)); - results.push(...extractLiteralsRecursively(node.right, path)); - } + // Arrow functions: () => 'value' or prev => prev==='a' ? 'b':'a' + else if (t.isArrowFunctionExpression(node)) { + if (t.isExpression(node.body)) { + results.push(...extractLiteralsRecursively(node.body, path)); + } else if (t.isBlockStatement(node.body)) { + // Look for return statements + // example: const a = () => 'a' + 'b' + results.push(...extractFromBlockStatement(node.body, path)); + } + } - // Arrow functions: () => 'value' or prev => prev==='a' ? 'b':'a' - else if (t.isArrowFunctionExpression(node)) { - if (t.isExpression(node.body)) { - results.push(...extractLiteralsRecursively(node.body, path)); - } else if (t.isBlockStatement(node.body)) { - // Look for return statements + // Function expressions: function() { return 'value'; } + else if (t.isFunctionExpression(node)) { + // example: function() { return 'a' + 'b' } results.push(...extractFromBlockStatement(node.body, path)); } - } - // Function expressions: function() { return 'value'; } - else if (t.isFunctionExpression(node)) { - results.push(...extractFromBlockStatement(node.body, path)); - } - - // Identifiers: resolve to their values if possible - else if (t.isIdentifier(node)) { - const resolved = resolveIdentifier(node, path); - if (resolved) { - results.push(...extractLiteralsRecursively(resolved, path)); + // Identifiers: resolve to their values if possible + else if (t.isIdentifier(node)) { + const resolved = resolveIdentifier(node, path); + if (resolved) { + // example: const a = 'a' + 'b' + results.push(...extractLiteralsRecursively(resolved, path)); + } } - } - // Member expressions: SIZES.SMALL, obj.prop, etc. - else if (t.isMemberExpression(node) && !node.computed && t.isIdentifier(node.property)) { - const objectResolved = resolveIdentifier(node.object as t.Identifier, path); - if (objectResolved && t.isObjectExpression(objectResolved)) { - // Look for the property in the object - const propertyName = node.property.name; - for (const prop of objectResolved.properties) { - if (t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === propertyName) { - const propertyValue = literalToString(prop.value as t.Expression); - if (propertyValue !== null) { - results.push(propertyValue); + // Member expressions: SIZES.SMALL, obj.prop, etc. + else if (t.isMemberExpression(node) && !node.computed && t.isIdentifier(node.property)) { + const objectResolved = resolveIdentifier(node.object as t.Identifier, path); + if (objectResolved && t.isObjectExpression(objectResolved)) { + // Look for the property in the object + const propertyName = node.property.name; + for (const prop of objectResolved.properties) { + if (t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === propertyName) { + const propertyValue = literalToString(prop.value as t.Expression); + if (propertyValue !== null) { + // example: const a = { b: 'c' } + results.push(propertyValue); + } } } } } - } - // Binary expressions: might contain literals in some cases - else if (t.isBinaryExpression(node)) { - // Only extract if it's a simple concatenation that might resolve to a literal - if (node.operator === '+') { - results.push(...extractLiteralsRecursively(node.left as t.Expression, path)); - results.push(...extractLiteralsRecursively(node.right as t.Expression, path)); + // Binary expressions: might contain literals in some cases + else if (t.isBinaryExpression(node)) { + // Only extract if it's a simple concatenation that might resolve to a literal + if (node.operator === '+') { + // example: 'a' + 'b' + results.push(...extractLiteralsRecursively(node.left as t.Expression, path)); + results.push(...extractLiteralsRecursively(node.right as t.Expression, path)); + } } + } catch (error) { + console.warn(`[Zero-UI] Failed to extract literals from ${node.type} in ${path?.opts?.filename}`, error); + return []; + } finally { + return results; } - - return results; } /** @@ -304,17 +323,11 @@ function resolveIdentifier(node: t.Identifier, path: NodePath): t.Expression | n } /** - * Convert various literal types to strings + * Convert string literals to strings (only strings are supported) */ function literalToString(node: t.Expression): string | null { - if (t.isStringLiteral(node) || t.isNumericLiteral(node)) { - return String(node.value); - } - if (t.isBooleanLiteral(node)) { - return node.value ? 'true' : 'false'; - } - if (t.isNullLiteral(node)) { - return 'null'; + if (t.isStringLiteral(node)) { + return node.value; } if (t.isTemplateLiteral(node) && node.expressions.length === 0) { // Simple template literal with no expressions: `hello` @@ -397,7 +410,9 @@ export function extractVariants(filePath: string): VariantData[] { fileCache.set(filePath, { hash: fileHash, variants }); return variants; } catch (error) { - console.error(`Error extracting variants from ${filePath}:`, error); + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn(`[Zero-UI] Failed to parse ${filePath}: ${errorMessage}`); + console.warn(`[Zero-UI] Ensure useUI calls use string literals only: useUI('key', 'value')`); return []; } } @@ -502,6 +517,7 @@ export function parseAndUpdatePostcssConfig(source: string, zeroUiPlugin: string if (modified) { return generate(ast).code; } else { + console.warn(`[Zero-UI] Failed to automatically modify PostCSS config: ${source}`); return null; // Could not automatically modify } } catch (err: unknown) { From 147cc84fd3e9af4ba473e7acac80390c1e9d6d3e Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Tue, 1 Jul 2025 15:09:44 -0700 Subject: [PATCH 18/34] Added new babel resolve logic --- .../core/__tests__/fixtures/next/app/page.tsx | 2 + .../__tests__/fixtures/next/app/variables.ts | 2 +- packages/core/__tests__/unit/ast.test.cjs | 184 ++++++----- .../unit/fixtures/TestComponent1.jsx | 14 + .../unit/fixtures/TestComponent2.jsx | 9 + .../unit/fixtures/TestComponentImports.jsx | 16 + .../core/__tests__/unit/fixtures/variables.js | 5 + .../__tests__/unit/inject-attributes.test.cjs | 0 packages/core/src/index.ts | 5 + packages/core/src/postcss/ast-v2.cts | 156 ++++++---- packages/core/src/postcss/resolvers.cts | 286 ++++++++++++++++++ 11 files changed, 516 insertions(+), 163 deletions(-) create mode 100644 packages/core/__tests__/unit/fixtures/TestComponent1.jsx create mode 100644 packages/core/__tests__/unit/fixtures/TestComponent2.jsx create mode 100644 packages/core/__tests__/unit/fixtures/TestComponentImports.jsx create mode 100644 packages/core/__tests__/unit/fixtures/variables.js delete mode 100644 packages/core/__tests__/unit/inject-attributes.test.cjs create mode 100644 packages/core/src/postcss/resolvers.cts diff --git a/packages/core/__tests__/fixtures/next/app/page.tsx b/packages/core/__tests__/fixtures/next/app/page.tsx index 77889dc..e965418 100644 --- a/packages/core/__tests__/fixtures/next/app/page.tsx +++ b/packages/core/__tests__/fixtures/next/app/page.tsx @@ -2,6 +2,7 @@ import { useUI } from '@react-zero-ui/core'; import UseEffectComponent from './UseEffectComponent'; import FAQ from './FAQ'; +import { THEMES } from './variables'; export default function Page() { const [, setTheme] = useUI<'light' | 'dark'>('theme', 'light'); @@ -12,6 +13,7 @@ export default function Page() { const [, setOpen] = useUI<'open' | 'closed'>('faq', 'closed'); // Same key everywhere! const [, setScope] = useUI<'off' | 'on'>('scope', 'off'); const [, setMobile] = useUI<'true' | 'false'>('mobile', 'false'); + const [, setThemeBlue] = useUI(`theme-${THEMES.blueee}`, THEMES.blueee); const [, setToggleFunction] = useUI<'white' | 'black'>('toggle-function', 'white'); diff --git a/packages/core/__tests__/fixtures/next/app/variables.ts b/packages/core/__tests__/fixtures/next/app/variables.ts index fa479eb..e4b0dd7 100644 --- a/packages/core/__tests__/fixtures/next/app/variables.ts +++ b/packages/core/__tests__/fixtures/next/app/variables.ts @@ -1,4 +1,4 @@ export const THEME_BLUE = 'blue'; export const THEME_RED = 'red'; -export const THEMES: Record<'light' | 'dark', string> = { light: 'light', dark: 'dark' }; +export const THEMES: Record<'blueee' | 'dark', string> = { blueee: 'blue', dark: 'dark' }; diff --git a/packages/core/__tests__/unit/ast.test.cjs b/packages/core/__tests__/unit/ast.test.cjs index 9912ccd..b27c6c3 100644 --- a/packages/core/__tests__/unit/ast.test.cjs +++ b/packages/core/__tests__/unit/ast.test.cjs @@ -36,103 +36,89 @@ async function runTest(files, callback) { } } -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(); - console.log('sourceFiles: ', sourceFiles); - assert.ok(sourceFiles.length > 0); - } - ); -}); - -test('collectUseUISetters - 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 <> - - -
- ); -}; -export default Component; - `, - }, - - async () => { - const variants = extractVariants('src/app/Component.jsx'); - console.log('variants: ', variants); - assert.strictEqual(variants.length, 2); - assert.ok(variants.some((v) => v.key === 'theme' && v.values.includes('light') && v.values.includes('dark'))); - assert.ok(variants.some((v) => v.key === 'size' && v.values.includes('medium') && v.values.includes('large'))); - } - ); -}); - -test('Extract Variant without setter', async () => { - await runTest( - { - 'src/app/Component.jsx': ` - import { useUI } from '@react-zero-ui/core'; -const Component = () => { - const [, setTheme] = useUI('theme', 'light'); - const [, setSize] = useUI('size', 'medium'); - return ( -
- -
- ); -}; -export default Component; - `, - }, - - async () => { - const variants = extractVariants('src/app/Component.jsx'); - console.log('variants without setter: ', variants); - assert.strictEqual(variants.length, 2); - assert.ok(variants.some((v) => v.key === 'theme' && v.values.includes('light'))); - assert.ok(variants.some((v) => v.key === 'size' && v.values.includes('medium'))); - } - ); +// 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('collectUseUISetters - 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 <> +// + +
+ ); +}; + +export default Component; diff --git a/packages/core/__tests__/unit/fixtures/TestComponent2.jsx b/packages/core/__tests__/unit/fixtures/TestComponent2.jsx new file mode 100644 index 0000000..429348a --- /dev/null +++ b/packages/core/__tests__/unit/fixtures/TestComponent2.jsx @@ -0,0 +1,9 @@ +import { useUI } from '@react-zero-ui/core'; + +const Component = () => { + const [, setTheme] = useUI('theme', 'light'); + const [, setSize] = useUI('size', 'medium'); + return
; +}; + +export default Component; diff --git a/packages/core/__tests__/unit/fixtures/TestComponentImports.jsx b/packages/core/__tests__/unit/fixtures/TestComponentImports.jsx new file mode 100644 index 0000000..c560444 --- /dev/null +++ b/packages/core/__tests__/unit/fixtures/TestComponentImports.jsx @@ -0,0 +1,16 @@ +import { useUI } from '@react-zero-ui/core'; +import { THEME, MENU_SIZES, VARS } from './variables'; + +const Component = () => { + const [, setTheme] = useUI(VARS, THEME); + const [, setSize] = useUI('size', MENU_SIZES.medium); + + return ( +
+ + +
+ ); +}; + +export default Component; diff --git a/packages/core/__tests__/unit/fixtures/variables.js b/packages/core/__tests__/unit/fixtures/variables.js new file mode 100644 index 0000000..b87369d --- /dev/null +++ b/packages/core/__tests__/unit/fixtures/variables.js @@ -0,0 +1,5 @@ +export const THEME = 'dark'; + +export const MENU_SIZES = { small: 'sm', medium: 'md', large: 'lg' }; + +export const VARS = 'theme'; diff --git a/packages/core/__tests__/unit/inject-attributes.test.cjs b/packages/core/__tests__/unit/inject-attributes.test.cjs deleted file mode 100644 index e69de29..0000000 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 484cbc7..cefe1fc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,11 @@ export interface UISetterFn { function useUI(key: string, initialValue: T): [T, UISetterFn] { /* ─ DEV-ONLY COLLISION GUARD (removed in production by modern bundlers) ─ */ if (process.env.NODE_ENV !== 'production') { + //validate key and initialValue make sure there are no spaces + if (key.includes(' ') || initialValue.includes(' ')) { + throw new Error(`[Zero-UI] useUI(key, initialValue); key and initialValue must not contain spaces, got "${key}" and "${initialValue}"`); + } + // Validate inputs with helpful error messages if (!key || typeof key !== 'string' || key.trim() === '') { throw new Error(`useUI(key, initialValue); key must be a non-empty string, got "${key}"`); diff --git a/packages/core/src/postcss/ast-v2.cts b/packages/core/src/postcss/ast-v2.cts index f9034a6..8f8fb8a 100644 --- a/packages/core/src/postcss/ast-v2.cts +++ b/packages/core/src/postcss/ast-v2.cts @@ -1,3 +1,5 @@ +// src/core/postcss/ast-v2.cts + import { parse, parseExpression } from '@babel/parser'; import * as babelTraverse from '@babel/traverse'; import { Binding, NodePath } from '@babel/traverse'; @@ -8,6 +10,7 @@ import { createHash } from 'crypto'; import { generate } from '@babel/generator'; import * as fs from 'fs'; import * as path from 'path'; +import { literalFromNode } from './resolvers.cjs'; const traverse = (babelTraverse as any).default; export interface SetterMeta { @@ -21,70 +24,83 @@ export interface SetterMeta { initialValue: string | null; } +// /** +// * Check if the file imports useUI (or the configured hook name) +// * @param ast - The parsed AST +// * @returns true if useUI is imported, false otherwise +// */ +// function hasUseUIImport(ast: t.File): boolean { +// let hasImport = false; + +// traverse(ast, { +// ImportDeclaration(path: any) { +// const source = path.node.source.value; + +// // Check if importing from @react-zero-ui/core +// if (source === CONFIG.IMPORT_NAME) { +// // Only look for named import: import { useUI } from '...' +// const hasUseUISpecifier = path.node.specifiers.some( +// (spec: any) => t.isImportSpecifier(spec) && t.isIdentifier(spec.imported) && spec.imported.name === CONFIG.HOOK_NAME +// ); + +// if (hasUseUISpecifier) { +// hasImport = true; +// path.stop(); // Early exit +// } +// } +// }, +// }); + +// return hasImport; +// } /** - * Check if the file imports useUI (or the configured hook name) - * @param ast - The parsed AST - * @returns true if useUI is imported, false otherwise + * Collect every `[ value, setterFn ] = useUI('key', 'initial')` in a file. + * Re-uses `literalFromNode` so **initialArg** can be: + * • literal `'dark'` + * • local const `DARK` + * • static template `` `da${'rk'}` `` + * Throws if the key is dynamic or if the initial value cannot be + * reduced to a space-free string. */ -function hasUseUIImport(ast: t.File): boolean { - let hasImport = false; +export function collectUseUISetters(ast: t.File): SetterMeta[] { + const setters: SetterMeta[] = []; traverse(ast, { - ImportDeclaration(path: any) { - const source = path.node.source.value; + VariableDeclarator(path: NodePath) { + const { id, init } = path.node; - // Check if importing from @react-zero-ui/core - if (source === CONFIG.IMPORT_NAME) { - // Only look for named import: import { useUI } from '...' - const hasUseUISpecifier = path.node.specifiers.some( - (spec: any) => t.isImportSpecifier(spec) && t.isIdentifier(spec.imported) && spec.imported.name === CONFIG.HOOK_NAME - ); + // match: const [ , setX ] = useUI(...) + if (!t.isArrayPattern(id) || !t.isCallExpression(init) || !t.isIdentifier(init.callee, { name: CONFIG.HOOK_NAME })) return; - if (hasUseUISpecifier) { - hasImport = true; - path.stop(); // Early exit - } + if (id.elements.length !== 2) { + throw path.buildCodeFrameError(`[Zero-UI] useUI() must destructure two values: [value, setter].`); } - }, - }); - return hasImport; -} - -/** - * Collects every `[ staleValue, setterFn ] = useUI('key', 'initial')` in a file. - * @returns SetterMeta[] - */ -export function collectUseUISetters(ast: t.File): SetterMeta[] { - const setters: SetterMeta[] = []; + const [, setterEl] = id.elements; + if (!setterEl || !t.isIdentifier(setterEl)) { + throw path.buildCodeFrameError(`[Zero-UI] useUI() setter must be a variable name.`); + } - traverse(ast, { - VariableDeclarator(path: any) { - const { id, init } = path.node; + const [keyArg, initialArg] = init.arguments; - // Match: const [ , setX ] = useUI(...) - if (t.isArrayPattern(id) && id.elements.length === 2 && t.isCallExpression(init) && t.isIdentifier(init.callee, { name: CONFIG.HOOK_NAME })) { - const [, setterEl] = id.elements; - if (!t.isIdentifier(setterEl)) return; // hole or non-identifier + // key must be string literal + if (!t.isStringLiteral(keyArg)) { + throw path.buildCodeFrameError(`[Zero-UI] useUI() key must be a string literal.`); + } - // Validate & grab hook args - const [keyArg, initialArg] = init.arguments; - if (!t.isStringLiteral(keyArg)) return; // dynamic keys are ignored + // resolve initial value with new helpers + const initialValue = literalFromNode(initialArg as t.Expression, path as unknown as NodePath); - // Since useUI now only accepts strings, validate initial value - const initialValue = literalToString(initialArg as t.Expression); - if (initialValue === null) { - console.error(`[Zero-UI] Non-string initial value found for key "${keyArg.value}". Only string literals are supported.`); - return; - } + if (initialValue === null) { + throw path.buildCodeFrameError(`[Zero-UI] Initial value for "${keyArg.value}" must be a local, space-free string literal.`); + } - setters.push({ - binding: path.scope.getBinding(setterEl.name)!, // never null here - setterName: setterEl.name, - stateKey: keyArg.value, - initialValue, - }); + const binding = path.scope.getBinding(setterEl.name); + if (!binding) { + throw path.buildCodeFrameError(`[Zero-UI] Could not resolve binding for setter "${setterEl.name}".`); } + + setters.push({ binding, setterName: setterEl.name, stateKey: keyArg.value, initialValue }); }, }); @@ -294,7 +310,9 @@ function resolveIdentifier(node: t.Identifier, path: NodePath): t.Expression | n } // Import specifier: import { THEME_DARK } from './constants' + if (bindingPath.isImportSpecifier() || bindingPath.isImportDefaultSpecifier()) { + console.log('import specifier: ', bindingPath.node); // For now, we can't easily resolve cross-file imports // But we could enhance this later to parse the imported file // TODO: Implement this @@ -339,22 +357,19 @@ function literalToString(node: t.Expression): string | null { /** * Convert the harvested variants map to the final output format */ -export function normalizeVariants(variants: Map>, setters: SetterMeta[]): VariantData[] { +export function normalizeVariants(variants: Map>, setters: SetterMeta[], shouldSort = true): VariantData[] { + const setterMap = new Map(setters.map((s) => [s.stateKey, s])); const result: VariantData[] = []; for (const [stateKey, valueSet] of variants) { - // Find the initial value from the original setter - const setter = setters.find((s) => s.stateKey === stateKey); - const initialValue = setter?.initialValue || null; - - // Sort values for deterministic output - const sortedValues = Array.from(valueSet).sort(); - - result.push({ key: stateKey, values: sortedValues, initialValue }); + const initialValue = setterMap.get(stateKey)?.initialValue || null; + const values = Array.from(valueSet); + if (shouldSort) values.sort(); + result.push({ key: stateKey, values, initialValue }); } - // Sort by key for deterministic output - return result.sort((a, b) => a.key.localeCompare(b.key)); + if (shouldSort) result.sort((a, b) => a.key.localeCompare(b.key)); + return result; } // File cache to avoid re-parsing unchanged files @@ -386,12 +401,19 @@ export function extractVariants(filePath: string): VariantData[] { const ast = parse(sourceCode, { sourceType: 'module', plugins: ['jsx', 'typescript', 'decorators-legacy'], - allowImportExportEverywhere: true, - allowReturnOutsideFunction: true, + allowImportExportEverywhere: false, + allowReturnOutsideFunction: false, + attachComment: false, + createImportExpressions: true, }); // Pass 1: Collect all useUI setters and their initial values const setters = collectUseUISetters(ast); + // returns an array of objects with the following properties: + // - stateKey: string + // - initialValue: string | null + // - binding: NodePath + // - setterName: string // Early return if no setters found if (setters.length === 0) { @@ -402,9 +424,17 @@ export function extractVariants(filePath: string): VariantData[] { // Pass 2: Harvest all values from setter calls const variantsMap = harvestSetterValues(setters); + // returns a Map of keys, and a Set of values + // Map{'theme' => Set(2) { 'light', 'dark' },} // Normalize to final format const variants = normalizeVariants(variantsMap, setters); + console.log('variants: ', variants); + // returns an array of objects with the following properties: + // - key: string + // - values: string[] + // - initialValue: string | null + // [{ key: 'theme', values: [ 'dark', 'light' ], initialValue: 'light' },] // Cache and return fileCache.set(filePath, { hash: fileHash, variants }); diff --git a/packages/core/src/postcss/resolvers.cts b/packages/core/src/postcss/resolvers.cts new file mode 100644 index 0000000..1278640 --- /dev/null +++ b/packages/core/src/postcss/resolvers.cts @@ -0,0 +1,286 @@ +import * as t from '@babel/types'; +import { NodePath } from '@babel/traverse'; + +/*──────────────────────────────────────────────────────────*\ + importedVariableErr + ------------------- + Centralized error helper: **always** throw the same, actionable message + whenever we detect a value coming from an *imported* binding. + + Why centralize? + • We'll call this from `resolveTemplateLiteral`, `literalFromNode`, and the + setter-value pass — consistency matters. +\*──────────────────────────────────────────────────────────*/ +export function importedVariableErr(path: NodePath, ident: t.Identifier): never { + throw path.buildCodeFrameError( + `[Zero-UI] "${ident.name}" is imported. ` + + 'Inline it or alias to a local const first:\n' + + ' import { theme } from "lib";\n' + + ' const THEME = theme;\n' + + ' useUI("theme", THEME);' + ); +} + +/*──────────────────────────────────────────────────────────*\ + resolveTemplateLiteral + ---------------------- + Accepts a **TemplateLiteral** node that *may* contain `${}` placeholders + *and* a NodePath for scope look-ups. + + Rules enforced + -------------- + 1. The *final* resolved string **must have zero whitespace** (`/^\S+$/`). + 2. Each `${expr}` must resolve (via `literalFromNode`) to a **local** + string literal **without spaces**. + 3. If an expression's binding is *imported*, we delegate to + `importedVariableErr`. + 4. Any failure → return `null` so the caller can emit its own error. + + Returned value + -------------- + • `string` → safe, space-free literal. + • `null` → invalid (dynamic / contains spaces / unresolved). +\*──────────────────────────────────────────────────────────*/ +export function resolveTemplateLiteral( + node: t.TemplateLiteral, + path: NodePath, + literalFromNode: (expr: t.Expression, p: NodePath) => string | null +): string | null { + let result = ''; + + // ── fast path: `` `dark` `` + if (node.expressions.length === 0) { + const text = node.quasis[0].value.cooked ?? node.quasis[0].value.raw; + return text && /^\S+$/.test(text) ? text : null; + } + + // ── slow path: template with ${} + for (let i = 0; i < node.quasis.length; i++) { + // 1. Add quasi piece + const q = node.quasis[i]; + const text = q.value.cooked ?? q.value.raw; + if (!text || /\s/.test(text)) return null; // contains space + result += text; + + // 2. Add expression piece (if any) + const expr = node.expressions[i]; + if (expr) { + const lit = literalFromNode(expr as t.Expression, path); + if (lit === null) return null; // dynamic or invalid + + if (/\s/.test(lit)) return null; // space inside expr literal + result += lit; + } + } + + // Guard: final string still space-free? + return /^\S+$/.test(result) ? result : null; +} + +/*──────────────────────────────────────────────────────────*\ + resolveLocalConstIdentifier + --------------------------- + Resolve an **Identifier** node to a *space-free string literal* **only when** + + 1. It is bound in the **same file** (Program scope), + 2. Declared with **`const`** (not `let` / `var`), + 3. Initialised to a **string literal** or a **static template literal**, + 4. The final string has **no whitespace** (`/^\S+$/`). + + Anything else (inner-scope `const`, dynamic value, imported binding, spaces) + ➜ return `null` — the caller will decide whether to throw or keep searching. + + If the binding is *imported*, we delegate to `importedVariableErr()` so the + developer gets a consistent, actionable error message. +\*──────────────────────────────────────────────────────────*/ +export function resolveLocalConstIdentifier( + node: t.Expression, // <- widened + path: NodePath +): string | null { + /* Fast-exit when node isn't an Identifier */ + if (!t.isIdentifier(node)) return null; + + const binding = path.scope.getBinding(node.name); + if (!binding) return null; + + /* 1. Reject imported bindings */ + if (binding.path.isImportSpecifier() || binding.path.isImportDefaultSpecifier() || binding.path.isImportNamespaceSpecifier()) { + importedVariableErr(path, node); // throws + } + + /* 2. Allow only top-level `const` */ + if (!binding.path.isVariableDeclarator() || binding.scope.block.type !== 'Program' || (binding.path.parent as t.VariableDeclaration).kind !== 'const') { + return null; + } + + /* 3. Inspect initializer */ + const init = binding.path.node.init; + if (!init) return null; + + let text: string | null = null; + + if (t.isStringLiteral(init)) { + text = init.value; + } else if (t.isTemplateLiteral(init)) { + text = resolveTemplateLiteral(init, binding.path, resolveLocalConstIdentifier); + } + + /* 4. Final space-free check */ + return text && /^\S+$/.test(text) ? text : null; +} + +/** + * This function will decide which function to call based on the node type + * + * 1. String literal + * 2. Template literal with no expressions + * 3. Identifier bound to local const + * 4. Template literal with expressions + * 5. Everything else is illegal + * @param node - The node to convert + * @param path - The path to the node + * @returns The string literal or null if the node is not a string literal or template literal with no expressions or identifier bound to local const + */ +export function literalFromNode(node: t.Expression, path: NodePath): string | null { + // String / template (no ${}) + if (t.isStringLiteral(node)) return /^\S+$/.test(node.value) ? node.value : null; + if (t.isTemplateLiteral(node) && node.expressions.length === 0) { + const text = node.quasis[0].value.cooked ?? node.quasis[0].value.raw; + return text && /^\S+$/.test(text) ? text : null; + } + + // Identifier bound to local const (also handles object/array literals + // via the recursive call inside resolveLocalConstIdentifier) + const idLit = resolveLocalConstIdentifier(node, path); + if (idLit !== null) return idLit; + + // Template literal with ${expr} + if (t.isTemplateLiteral(node)) { + return resolveTemplateLiteral(node, path, literalFromNode); + } + + if (t.isMemberExpression(node)) { + return resolveMemberExpression(node, path, literalFromNode); + } + + return null; // everything else is illegal +} + +/*────────────────────────────────────*\ + resolveMemberExpression + ----------------------- + Resolve a **MemberExpression** like `THEMES.dark` or `THEMES['dark']` + (optionally nested: `THEMES.brand.primary`) to a **space-free string** + **iff**: + + • The **base identifier** is a top-level `const` **ObjectExpression** + • Every hop in the chain exists and is either + - another ObjectExpression (→ continue) or + - a **StringLiteral** terminal value + • All keys are static (`Identifier`, `StringLiteral`, or numeric index on + an ArrayExpression) + • No imported bindings are involved. + + Returns `string` on success, otherwise `null`. Throws via + `importedVariableErr` when the base identifier is imported. + + Re-uses: + • `resolveTemplateLiteral` - so a template like THEMES[`da${'rk'}`] would + work if the inner template is static & space-free. +\*──────────────────────────────────────────────────────────*/ +export function resolveMemberExpression( + node: t.MemberExpression, + path: NodePath, + literalFromNode: (expr: t.Expression, p: NodePath) => string | null +): string | null { + /** Collect the property chain (deep → shallow) */ + const props: (string | number)[] = []; + let current: t.Expression | t.PrivateName = node; + + // Walk up until we hit the root Identifier + while (t.isMemberExpression(current)) { + // ── Step 1: push property key + if (current.computed) { + // obj['prop'] or obj[0] + if (t.isStringLiteral(current.property)) { + if (/\s/.test(current.property.value)) return null; + props.unshift(current.property.value); + } else if (t.isNumericLiteral(current.property)) { + props.unshift(current.property.value); + } else if (t.isTemplateLiteral(current.property)) { + const lit = resolveTemplateLiteral(current.property, path, literalFromNode); + if (lit === null) return null; + props.unshift(lit); + } else { + return null; // dynamic key + } + } else { + // obj.prop + const pn = current.property as t.Identifier; + props.unshift(pn.name); + if (/\s/.test(pn.name)) return null; + } + + current = current.object; + } + + /* current should now be the base Identifier */ + if (!t.isIdentifier(current)) return null; + + /* Resolve the base identifier to an in-file const object/array literal */ + const binding = path.scope.getBinding(current.name); + if (!binding) return null; + + // Imported? -> hard error + if (binding.path.isImportSpecifier() || binding.path.isImportDefaultSpecifier() || binding.path.isImportNamespaceSpecifier()) { + importedVariableErr(path, current); + } + + // Must be `const` in Program scope + if (!binding.path.isVariableDeclarator() || binding.scope.block.type !== 'Program' || (binding.path.parent as t.VariableDeclaration).kind !== 'const') { + return null; + } + + let value: t.Expression | null | undefined = binding.path.node.init; + + /* Traverse the collected property chain */ + for (const key of props) { + if (t.isObjectExpression(value)) { + const objLit = value; + value = resolveObjectValue(objLit, String(key)); + } else if (t.isArrayExpression(value) && typeof key === 'number') { + value = value.elements[key] as t.Expression | null | undefined; + } else { + return null; // chain breaks (not an object/array) + } + if (!value) return null; + } + + /* Final value must be a space-free string */ + if (t.isStringLiteral(value)) { + return /^\S+$/.test(value.value) ? value.value : null; + } + if (t.isTemplateLiteral(value)) { + return resolveTemplateLiteral(value, path, literalFromNode); + } + + return null; // not a string +} + +/*──────────────────────────────────────────────────────────*\ + resolveObjectValue + ------------- + Helper: given an ObjectExpression, return the value associated with `key` + when that value is a **StringLiteral** | ObjectExpression | ArrayExpression. +\*──────────────────────────────────────────────────────────*/ +function resolveObjectValue(obj: t.ObjectExpression, key: string): t.Expression | null | undefined { + for (const p of obj.properties) { + if (t.isObjectProperty(p) && !p.computed && t.isIdentifier(p.key) && p.key.name === key) { + return p.value as t.Expression; + } + if (t.isObjectProperty(p) && p.computed && t.isStringLiteral(p.key) && p.key.value === key) { + return p.value as t.Expression; + } + } + return null; +} From 381a60b7f1ccc4d386e26027c5360d70b6e35f68 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Wed, 2 Jul 2025 00:29:27 -0700 Subject: [PATCH 19/34] support all the way up to option chaining --- eslint.config.js | 42 +- package.json | 1 + packages/core/__tests__/unit/ast.test.cjs | 48 +- .../unit/fixtures/TestComponent1.jsx | 14 - .../unit/fixtures/TestComponent2.jsx | 9 - .../unit/fixtures/TestComponentImports.jsx | 16 - .../unit/fixtures/test-components.jsx | 26 + .../unit/fixtures/ts-test-components.tsx | 86 ++ packages/core/package.json | 2 + packages/core/src/postcss/ast-v2.cts | 50 +- packages/core/src/postcss/resolvers.cts | 249 ++-- pnpm-lock.yaml | 1116 +++++++++++++++++ 12 files changed, 1487 insertions(+), 172 deletions(-) delete mode 100644 packages/core/__tests__/unit/fixtures/TestComponent1.jsx delete mode 100644 packages/core/__tests__/unit/fixtures/TestComponent2.jsx delete mode 100644 packages/core/__tests__/unit/fixtures/TestComponentImports.jsx create mode 100644 packages/core/__tests__/unit/fixtures/test-components.jsx create mode 100644 packages/core/__tests__/unit/fixtures/ts-test-components.tsx diff --git a/eslint.config.js b/eslint.config.js index 495cbe8..a1a8396 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,6 +1,7 @@ // eslint.config.js - ESLint 9 flat-config, JS + CJS import js from '@eslint/js'; import nodePlugin from 'eslint-plugin-node'; +import importPlugin from 'eslint-plugin-import'; const nodeGlobals = { require: 'readonly', @@ -32,7 +33,7 @@ const browserGlobals = { export default [ /* 1 - never lint generated / vendor files */ - { ignores: ['**/node_modules/**', '**/dist/**', '**/.next/**', 'eslint.config.js'] }, + { ignores: ['**/node_modules/**', '**/dist/**', '**/.next/**', 'eslint.config.js', './packages/core/__tests__/unit/fixtures'] }, /* 2 - baseline rules */ js.configs.recommended, @@ -40,16 +41,47 @@ export default [ /* 3 - CommonJS (*.cjs) */ { files: ['**/*.cjs'], - plugins: { node: nodePlugin }, + plugins: { node: nodePlugin, import: importPlugin }, languageOptions: { ecmaVersion: 'latest', sourceType: 'script', globals: nodeGlobals }, - rules: { 'node/no-unsupported-features/es-syntax': 'off' }, + rules: { + 'node/no-unsupported-features/es-syntax': 'off', + 'import/no-unresolved': 'error', // Catch unresolved imports + 'import/named': 'error', // Catch missing named exports + 'import/default': 'error', // Catch missing default exports + 'import/no-absolute-path': 'error', // Prevent absolute paths + }, }, /* 4 - ES-module / browser (*.js) */ { files: ['**/*.js'], - plugins: { node: nodePlugin }, + plugins: { node: nodePlugin, import: importPlugin }, languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...nodeGlobals, ...browserGlobals } }, - rules: { 'node/no-unsupported-features/es-syntax': 'off' }, + rules: { + 'node/no-unsupported-features/es-syntax': 'off', + 'import/no-unresolved': 'error', + 'import/named': 'error', + 'import/default': 'error', + 'import/no-absolute-path': 'error', + }, + }, + + /* 5 - JSX files */ + { + files: ['**/*.jsx'], + plugins: { node: nodePlugin, import: importPlugin }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { ...nodeGlobals, ...browserGlobals }, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + rules: { + 'node/no-unsupported-features/es-syntax': 'off', + 'import/no-unresolved': 'error', + 'import/named': 'error', + 'import/default': 'error', + 'import/no-absolute-path': 'error', + }, }, ]; diff --git a/package.json b/package.json index 8953a72..2781907 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@types/node": "^24.0.7", "esbuild": "^0.25.5", "eslint": "^9.0.0", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-node": "^11.1.0", "prettier": "^3.5.3", "release-please": "^17.0.0", diff --git a/packages/core/__tests__/unit/ast.test.cjs b/packages/core/__tests__/unit/ast.test.cjs index b27c6c3..9d20a86 100644 --- a/packages/core/__tests__/unit/ast.test.cjs +++ b/packages/core/__tests__/unit/ast.test.cjs @@ -4,11 +4,18 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { findAllSourceFiles } = require('../../dist/postcss/helpers.cjs'); -const { collectUseUISetters, extractVariants } = require('../../dist/postcss/ast-v2.cjs'); +// const { findAllSourceFiles } = require('../../dist/postcss/helpers.cjs'); +const { collectUseUISetters } = require('../../dist/postcss/ast-v2.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')); @@ -110,15 +117,34 @@ async function runTest(files, callback) { // ); // }); -test('Extract Variant with imports', async () => { - // Read fixture file for proper syntax highlighting - const fixtureContent = fs.readFileSync(path.join(__dirname, './fixtures/TestComponentImports.jsx'), 'utf-8'); +test('Extract Variant with imports and throw error', async () => { + await runTest({ 'src/app/Component.jsx': ComponentImports }, async () => { + assert.throws(() => { + collectUseUISetters(parse(ComponentImports, { sourceType: 'module', plugins: ['jsx', 'typescript'] }), ComponentImports); + // tests that the error message contains the correct text + }, /const VARSLocal = VARS/); + }); +}); - await runTest({ 'src/app/Component.jsx': fixtureContent }, async () => { - const variants = collectUseUISetters(parse(fixtureContent, { sourceType: 'module', plugins: ['jsx', 'typescript'] })); - console.log('variants: ', variants); - assert.strictEqual(variants.length, 2); - assert.ok(variants.some((v) => v.key === 'theme' && v.values.includes('dark'))); - assert.ok(variants.some((v) => v.key === 'size' && v.values.includes('lg'))); +test('testKeyInitialValue', async () => { + await runTest({ 'src/app/Component.jsx': AllPatternsComponent }, async () => { + const setters = collectUseUISetters(parse(AllPatternsComponent, { sourceType: 'module', plugins: ['jsx', 'typescript'] }), AllPatternsComponent); + console.log('setters: ', setters); + 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].stateKey, 'variant'); + assert.strictEqual(setters[6].initialValue, 'th-dark'); + assert.strictEqual(setters[7].stateKey, 'variant'); + assert.strictEqual(setters[7].initialValue, 'th-blue'); }); }); diff --git a/packages/core/__tests__/unit/fixtures/TestComponent1.jsx b/packages/core/__tests__/unit/fixtures/TestComponent1.jsx deleted file mode 100644 index 85cb328..0000000 --- a/packages/core/__tests__/unit/fixtures/TestComponent1.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useUI } from '@react-zero-ui/core'; - -const Component = () => { - const [, setTheme] = useUI('theme', 'light'); - const [, setSize] = useUI('size', 'medium'); - return ( -
- - -
- ); -}; - -export default Component; diff --git a/packages/core/__tests__/unit/fixtures/TestComponent2.jsx b/packages/core/__tests__/unit/fixtures/TestComponent2.jsx deleted file mode 100644 index 429348a..0000000 --- a/packages/core/__tests__/unit/fixtures/TestComponent2.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import { useUI } from '@react-zero-ui/core'; - -const Component = () => { - const [, setTheme] = useUI('theme', 'light'); - const [, setSize] = useUI('size', 'medium'); - return
; -}; - -export default Component; diff --git a/packages/core/__tests__/unit/fixtures/TestComponentImports.jsx b/packages/core/__tests__/unit/fixtures/TestComponentImports.jsx deleted file mode 100644 index c560444..0000000 --- a/packages/core/__tests__/unit/fixtures/TestComponentImports.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useUI } from '@react-zero-ui/core'; -import { THEME, MENU_SIZES, VARS } from './variables'; - -const Component = () => { - const [, setTheme] = useUI(VARS, THEME); - const [, setSize] = useUI('size', MENU_SIZES.medium); - - return ( -
- - -
- ); -}; - -export default Component; diff --git a/packages/core/__tests__/unit/fixtures/test-components.jsx b/packages/core/__tests__/unit/fixtures/test-components.jsx new file mode 100644 index 0000000..f98880e --- /dev/null +++ b/packages/core/__tests__/unit/fixtures/test-components.jsx @@ -0,0 +1,26 @@ +/* eslint-disable import/no-unresolved */ +import { useUI } from '@react-zero-ui/core'; +import { THEME, MENU_SIZES, VARS } from './variables'; + +export function ComponentImports() { + const [, setTheme] = useUI(VARS, THEME); + const [, setSize] = useUI('size', MENU_SIZES.medium); + + return ( +
+ + +
+ ); +} + +export function ComponentSimple() { + const [, setTheme] = useUI('theme', 'light'); + const [, setSize] = useUI('size', 'medium'); + return ( +
+ + +
+ ); +} diff --git a/packages/core/__tests__/unit/fixtures/ts-test-components.tsx b/packages/core/__tests__/unit/fixtures/ts-test-components.tsx new file mode 100644 index 0000000..4beb99d --- /dev/null +++ b/packages/core/__tests__/unit/fixtures/ts-test-components.tsx @@ -0,0 +1,86 @@ +/* eslint-disable import/no-unresolved */ + +import React, { useEffect } from 'react'; +// @ts-ignore +import { useUI } from '@zero-ui/core'; + +/*───────────────────────────────────────────┐ +│ Top-level local constants (legal sources) │ +└───────────────────────────────────────────*/ +const DARK = 'dark' as const; +const PREFIX = `th-${DARK}` as const; +const SIZES = { small: 'sm', large: 'lg' } as const; +const COLORS = { primary: 'blue', secondary: 'green' } as const; +const MODES = ['auto', 'manual'] as const; + +const VARIANTS = { dark: `th-${DARK}`, light: COLORS.primary } as const; + +/*───────────────────────────────────────────┐ +│ Component covering every legal pattern │ +└───────────────────────────────────────────*/ +export function AllPatternsComponent() { + /* ① 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); + /* ⑧ nested template + member */ + const [variant3, setVariant3] = useUI('variant', `th-${VARIANTS.light}`); + /* ⑨ BinaryExpression (template + member) */ + const [variant4, setVariant4] = useUI('variant', `th-${VARIANTS.light + '-inverse'}`); + /* ⑩ BinaryExpression (member + template) */ + const [variant5, setVariant5] = useUI('variant', `${VARIANTS.light}-inverse`); + /* ⑪ BinaryExpression (member + member) */ + const [variant6, setVariant6] = useUI('variant', `${VARIANTS.light}-${VARIANTS.dark}`); + /* ⑫ BinaryExpression (template + member) */ + const [variant7, setVariant7] = useUI('variant', `th-${VARIANTS.light}-${VARIANTS.dark}`); + /* ⑬ Optional-chaining */ + // const [variant8, setVariant8] = useUI('variant', VARIANTS.light?.primary); + /* ⑭ nullish-coalesce */ + const [variant9, setVariant9] = useUI('variant', VARIANTS.light.primary ?? 'th-light'); + /* ⑮ Optional-chaining + nullish-coalesce */ + // const [variant10, setVariant10] = useUI('variant', VARIANTS.light?.primary ?? 'th-light'); + + /* ── setters exercised in every allowed style ── */ + const clickHandler = () => { + /* direct literals */ + setTheme('dark'); + + /* identifier */ + setSize(SIZES.small); + + /* template literal */ + setVariant(`th-${DARK}-inverse`); + + /* member expression */ + setColor(COLORS.secondary); + + /* array index */ + setMode(MODES[1]); + }; + + /* conditional toggle with prev-state */ + const toggleAlt = () => setAltTheme((prev: string) => (prev === 'dark' ? 'light' : 'dark')); + + /* logical expression setter */ + useEffect(() => { + mode === 'auto' && setMode(MODES[1]); + }, [mode]); + + return ( +
+ + +
{JSON.stringify({ theme, altTheme, variant, size, mode, color }, null, 2)}
+
+ ); +} diff --git a/packages/core/package.json b/packages/core/package.json index 76d505b..ba5616f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -85,7 +85,9 @@ "glob": "^11.0.0" }, "devDependencies": { + "@babel/code-frame": "^7.27.1", "@playwright/test": "^1.53.0", + "@types/babel__code-frame": "^7.0.6", "@types/babel__generator": "^7.27.0", "@types/babel__traverse": "^7.20.7", "@types/react": "^19.1.8" diff --git a/packages/core/src/postcss/ast-v2.cts b/packages/core/src/postcss/ast-v2.cts index 8f8fb8a..67f6e00 100644 --- a/packages/core/src/postcss/ast-v2.cts +++ b/packages/core/src/postcss/ast-v2.cts @@ -11,6 +11,7 @@ import { generate } from '@babel/generator'; import * as fs from 'fs'; import * as path from 'path'; import { literalFromNode } from './resolvers.cjs'; +import { codeFrameColumns } from '@babel/code-frame'; const traverse = (babelTraverse as any).default; export interface SetterMeta { @@ -62,7 +63,7 @@ export interface SetterMeta { * Throws if the key is dynamic or if the initial value cannot be * reduced to a space-free string. */ -export function collectUseUISetters(ast: t.File): SetterMeta[] { +export function collectUseUISetters(ast: t.File, sourceCode: string): SetterMeta[] { const setters: SetterMeta[] = []; traverse(ast, { @@ -73,34 +74,52 @@ export function collectUseUISetters(ast: t.File): SetterMeta[] { if (!t.isArrayPattern(id) || !t.isCallExpression(init) || !t.isIdentifier(init.callee, { name: CONFIG.HOOK_NAME })) return; if (id.elements.length !== 2) { - throw path.buildCodeFrameError(`[Zero-UI] useUI() must destructure two values: [value, setter].`); + throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] useUI() must destructure two values: [value, setter].`); } const [, setterEl] = id.elements; if (!setterEl || !t.isIdentifier(setterEl)) { - throw path.buildCodeFrameError(`[Zero-UI] useUI() setter must be a variable name.`); + throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] useUI() setter must be a variable name.`); } const [keyArg, initialArg] = init.arguments; - // key must be string literal - if (!t.isStringLiteral(keyArg)) { - throw path.buildCodeFrameError(`[Zero-UI] useUI() key must be a string literal.`); + // resolve state key with new helpers + const stateKey = literalFromNode(keyArg as t.Expression, path as NodePath, { throwOnFail: true, source: sourceCode, hook: 'stateKey' }); + + if (stateKey === null) { + throwCodeFrame( + path, + path.opts?.filename, + sourceCode, + // TODO add link to docs + `[Zero-UI] State key for ${stateKey} must be a local, space-free string literal. - collectUseUISetters` + ); } // resolve initial value with new helpers - const initialValue = literalFromNode(initialArg as t.Expression, path as unknown as NodePath); + const initialValue = literalFromNode(initialArg as t.Expression, path as NodePath, { + throwOnFail: true, + source: sourceCode, + hook: 'initialValue', + }); if (initialValue === null) { - throw path.buildCodeFrameError(`[Zero-UI] Initial value for "${keyArg.value}" must be a local, space-free string literal.`); + throwCodeFrame( + path, + path.opts?.filename, + sourceCode, + // TODO add link to docs + `[Zero-UI] Initial value for "${stateKey}" must be a local, space-free string literal. - collectUseUISetters` + ); } const binding = path.scope.getBinding(setterEl.name); if (!binding) { - throw path.buildCodeFrameError(`[Zero-UI] Could not resolve binding for setter "${setterEl.name}".`); + throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] Could not resolve binding for setter "${setterEl.name}".`); } - setters.push({ binding, setterName: setterEl.name, stateKey: keyArg.value, initialValue }); + setters.push({ binding, setterName: setterEl.name, stateKey, initialValue }); }, }); @@ -405,10 +424,11 @@ export function extractVariants(filePath: string): VariantData[] { allowReturnOutsideFunction: false, attachComment: false, createImportExpressions: true, + sourceFilename: filePath, }); // Pass 1: Collect all useUI setters and their initial values - const setters = collectUseUISetters(ast); + const setters = collectUseUISetters(ast, sourceCode); // returns an array of objects with the following properties: // - stateKey: string // - initialValue: string | null @@ -429,7 +449,6 @@ export function extractVariants(filePath: string): VariantData[] { // Normalize to final format const variants = normalizeVariants(variantsMap, setters); - console.log('variants: ', variants); // returns an array of objects with the following properties: // - key: string // - values: string[] @@ -807,3 +826,10 @@ export function parseJsonWithBabel(source: string): any { return null; } } + +export function throwCodeFrame(path: NodePath, filename: string, source: string, msg: string): never { + const loc = path.node.loc; + const head = loc ? `${filename}:${loc.start.line}:${loc.start.column}\n` : ''; + const frame = loc ? codeFrameColumns(source, { start: loc.start, end: loc.end }, { highlightCode: true, linesAbove: 2, linesBelow: 2 }) : ''; + throw new Error(`${head}${msg}\n${frame}`); +} diff --git a/packages/core/src/postcss/resolvers.cts b/packages/core/src/postcss/resolvers.cts index 1278640..c9fb28a 100644 --- a/packages/core/src/postcss/resolvers.cts +++ b/packages/core/src/postcss/resolvers.cts @@ -1,80 +1,61 @@ import * as t from '@babel/types'; import { NodePath } from '@babel/traverse'; +import { throwCodeFrame } from './ast-v2.cjs'; -/*──────────────────────────────────────────────────────────*\ - importedVariableErr - ------------------- - Centralized error helper: **always** throw the same, actionable message - whenever we detect a value coming from an *imported* binding. - - Why centralize? - • We'll call this from `resolveTemplateLiteral`, `literalFromNode`, and the - setter-value pass — consistency matters. -\*──────────────────────────────────────────────────────────*/ -export function importedVariableErr(path: NodePath, ident: t.Identifier): never { - throw path.buildCodeFrameError( - `[Zero-UI] "${ident.name}" is imported. ` + - 'Inline it or alias to a local const first:\n' + - ' import { theme } from "lib";\n' + - ' const THEME = theme;\n' + - ' useUI("theme", THEME);' - ); +export interface ResolveOpts { + throwOnFail?: boolean; // default false + source?: string; // optional; fall back to path.hub.file.code + hook?: 'stateKey' | 'initialValue' | 'setterName'; // default 'stateKey' } -/*──────────────────────────────────────────────────────────*\ - resolveTemplateLiteral - ---------------------- - Accepts a **TemplateLiteral** node that *may* contain `${}` placeholders - *and* a NodePath for scope look-ups. - - Rules enforced - -------------- - 1. The *final* resolved string **must have zero whitespace** (`/^\S+$/`). - 2. Each `${expr}` must resolve (via `literalFromNode`) to a **local** - string literal **without spaces**. - 3. If an expression's binding is *imported*, we delegate to - `importedVariableErr`. - 4. Any failure → return `null` so the caller can emit its own error. - - Returned value - -------------- - • `string` → safe, space-free literal. - • `null` → invalid (dynamic / contains spaces / unresolved). -\*──────────────────────────────────────────────────────────*/ -export function resolveTemplateLiteral( - node: t.TemplateLiteral, - path: NodePath, - literalFromNode: (expr: t.Expression, p: NodePath) => string | null -): string | null { - let result = ''; - - // ── fast path: `` `dark` `` - if (node.expressions.length === 0) { +/** + * This function will decide which function to call based on the node type + * + * 1. String literal + * 2. Template literal with no expressions + * 3. Identifier bound to local const + * 4. Template literal with expressions + * 5. Everything else is illegal + * @param node - The node to convert + * @param path - The path to the node + * @returns The string literal or null if the node is not a string literal or template literal with no expressions or identifier bound to local const + */ +export function literalFromNode(node: t.Expression, path: NodePath, opts: ResolveOpts): string | null { + // String / template (no ${}) + if (t.isStringLiteral(node)) return node.value; + if (t.isTemplateLiteral(node) && node.expressions.length === 0) { const text = node.quasis[0].value.cooked ?? node.quasis[0].value.raw; - return text && /^\S+$/.test(text) ? text : null; + return text; + } + if (t.isBinaryExpression(node) && node.operator === '+') { + const left = literalFromNode(node.left as t.Expression, path, opts); + const right = literalFromNode(node.right as t.Expression, path, opts); + return left !== null && right !== null ? left + right : null; } - // ── slow path: template with ${} - for (let i = 0; i < node.quasis.length; i++) { - // 1. Add quasi piece - const q = node.quasis[i]; - const text = q.value.cooked ?? q.value.raw; - if (!text || /\s/.test(text)) return null; // contains space - result += text; + /* ── Logical fallback (a || b , a ?? b) ───────────── */ + if (t.isLogicalExpression(node) && (node.operator === '||' || node.operator === '??')) { + // try left; if it resolves, use it, otherwise fall back to right + const left = literalFromNode(node.left as t.Expression, path, opts); + if (left !== null) return left; + return literalFromNode(node.right as t.Expression, path, opts); + } - // 2. Add expression piece (if any) - const expr = node.expressions[i]; - if (expr) { - const lit = literalFromNode(expr as t.Expression, path); - if (lit === null) return null; // dynamic or invalid + // Identifier bound to local const (also handles object/array literals + // via the recursive call inside resolveLocalConstIdentifier) + const idLit = resolveLocalConstIdentifier(node, path, opts); + if (idLit !== null) return idLit; - if (/\s/.test(lit)) return null; // space inside expr literal - result += lit; - } + // Template literal with ${expr} or ${CONSTANT} + if (t.isTemplateLiteral(node)) { + return resolveTemplateLiteral(node, path, literalFromNode, opts); + } + + if (t.isMemberExpression(node)) { + return resolveMemberExpression(node, path, literalFromNode, opts); } - // Guard: final string still space-free? - return /^\S+$/.test(result) ? result : null; + return null; // everything else is illegal } /*──────────────────────────────────────────────────────────*\ @@ -95,7 +76,8 @@ export function resolveTemplateLiteral( \*──────────────────────────────────────────────────────────*/ export function resolveLocalConstIdentifier( node: t.Expression, // <- widened - path: NodePath + path: NodePath, + opts: ResolveOpts ): string | null { /* Fast-exit when node isn't an Identifier */ if (!t.isIdentifier(node)) return null; @@ -105,7 +87,23 @@ export function resolveLocalConstIdentifier( /* 1. Reject imported bindings */ if (binding.path.isImportSpecifier() || binding.path.isImportDefaultSpecifier() || binding.path.isImportNamespaceSpecifier()) { - importedVariableErr(path, node); // throws + throwCodeFrame( + path, + path.opts?.filename, + opts.source ?? path.opts?.source?.code, + `[Zero-UI] Cannot use imported variables. Assign to a local const first.\n` + + `Example:\n import { ${node.name} } from "./filePath";\n ` + + `const ${node.name}Local = ${node.name};\n\n ` + + `${ + opts.hook === 'stateKey' + ? `useUI(${node.name}Local, initialValue);` + : opts.hook === 'initialValue' + ? `useUI(stateKey, ${node.name}Local);` + : opts.hook === 'setterName' + ? `setterFunction(${node.name}Local)` + : '' + }` + ); } /* 2. Allow only top-level `const` */ @@ -114,56 +112,79 @@ export function resolveLocalConstIdentifier( } /* 3. Inspect initializer */ - const init = binding.path.node.init; + let init = binding.path.node.init; if (!init) return null; + /* unwrap '... as const' or foo */ + // @ts-expect-error Babel lacks helper for TSConstAssertion + if (t.isTSAsExpression(init) || t.isTSTypeAssertion(init) || init.type === 'TSConstAssertion') { + init = (init as any).expression; // step into the real value + } let text: string | null = null; if (t.isStringLiteral(init)) { text = init.value; } else if (t.isTemplateLiteral(init)) { - text = resolveTemplateLiteral(init, binding.path, resolveLocalConstIdentifier); + text = resolveTemplateLiteral(init, binding.path, literalFromNode, opts); } - /* 4. Final space-free check */ - return text && /^\S+$/.test(text) ? text : null; + return text; } -/** - * This function will decide which function to call based on the node type - * - * 1. String literal - * 2. Template literal with no expressions - * 3. Identifier bound to local const - * 4. Template literal with expressions - * 5. Everything else is illegal - * @param node - The node to convert - * @param path - The path to the node - * @returns The string literal or null if the node is not a string literal or template literal with no expressions or identifier bound to local const - */ -export function literalFromNode(node: t.Expression, path: NodePath): string | null { - // String / template (no ${}) - if (t.isStringLiteral(node)) return /^\S+$/.test(node.value) ? node.value : null; - if (t.isTemplateLiteral(node) && node.expressions.length === 0) { - const text = node.quasis[0].value.cooked ?? node.quasis[0].value.raw; - return text && /^\S+$/.test(text) ? text : null; - } +/*──────────────────────────────────────────────────────────*\ + resolveTemplateLiteral + ---------------------- + Accepts a **TemplateLiteral** node that *may* contain `${}` placeholders + *and* a NodePath for scope look-ups. - // Identifier bound to local const (also handles object/array literals - // via the recursive call inside resolveLocalConstIdentifier) - const idLit = resolveLocalConstIdentifier(node, path); - if (idLit !== null) return idLit; + Rules enforced + -------------- + 1. The *final* resolved string **must have zero whitespace** (`/^\S+$/`). + 2. Each `${expr}` must resolve (via `literalFromNode`) to a **local** + string literal **without spaces**. + 3. If an expression's binding is *imported*, we delegate to + `importedVariableErr`. + 4. Any failure → return `null` so the caller can emit its own error. - // Template literal with ${expr} - if (t.isTemplateLiteral(node)) { - return resolveTemplateLiteral(node, path, literalFromNode); + Returned value + -------------- + • `string` → safe, space-free literal. + • `null` → invalid (dynamic / contains spaces / unresolved). +\*──────────────────────────────────────────────────────────*/ +export function resolveTemplateLiteral( + node: t.TemplateLiteral, + path: NodePath, + literalFromNode: (expr: t.Expression, p: NodePath, opts: ResolveOpts) => string | null, + opts: ResolveOpts +): string | null { + let result = ''; + + // ── fast path: `` `dark` `` + if (node.expressions.length === 0) { + const text = node.quasis[0].value.cooked ?? node.quasis[0].value.raw; + return text; } - if (t.isMemberExpression(node)) { - return resolveMemberExpression(node, path, literalFromNode); + // ── slow path: template with ${} + for (let i = 0; i < node.quasis.length; i++) { + // 1. Add quasi piece + const q = node.quasis[i]; + const text = q.value.cooked ?? q.value.raw; + if (text == null) return null; + result += text; + + // 2. Add expression piece (if any) + const expr = node.expressions[i]; + if (expr) { + const lit = literalFromNode(expr as t.Expression, path, opts); + if (lit === null && opts.throwOnFail) { + throwCodeFrame(path, path.opts?.filename, opts.source ?? path.opts?.source?.code, '[Zero-UI] Template literal must resolve to a space-free string.'); + } + result += lit; + } } - return null; // everything else is illegal + return result; } /*────────────────────────────────────*\ @@ -191,7 +212,8 @@ export function literalFromNode(node: t.Expression, path: NodePath): str export function resolveMemberExpression( node: t.MemberExpression, path: NodePath, - literalFromNode: (expr: t.Expression, p: NodePath) => string | null + literalFromNode: (expr: t.Expression, p: NodePath, opts: ResolveOpts) => string | null, + opts: ResolveOpts ): string | null { /** Collect the property chain (deep → shallow) */ const props: (string | number)[] = []; @@ -203,12 +225,11 @@ export function resolveMemberExpression( if (current.computed) { // obj['prop'] or obj[0] if (t.isStringLiteral(current.property)) { - if (/\s/.test(current.property.value)) return null; props.unshift(current.property.value); } else if (t.isNumericLiteral(current.property)) { props.unshift(current.property.value); } else if (t.isTemplateLiteral(current.property)) { - const lit = resolveTemplateLiteral(current.property, path, literalFromNode); + const lit = resolveTemplateLiteral(current.property, path, literalFromNode, opts); if (lit === null) return null; props.unshift(lit); } else { @@ -233,7 +254,12 @@ export function resolveMemberExpression( // Imported? -> hard error if (binding.path.isImportSpecifier() || binding.path.isImportDefaultSpecifier() || binding.path.isImportNamespaceSpecifier()) { - importedVariableErr(path, current); + throwCodeFrame( + path, + path.opts?.filename, + opts.source ?? path.opts?.source?.code, + `[Zero-UI] Imports Not Allowed: \n\n Inline it or alias to a local const first.` + ); } // Must be `const` in Program scope @@ -245,6 +271,10 @@ export function resolveMemberExpression( /* Traverse the collected property chain */ for (const key of props) { + // unwrap `... as const` + if (t.isTSAsExpression(value)) { + value = value.expression; + } if (t.isObjectExpression(value)) { const objLit = value; value = resolveObjectValue(objLit, String(key)); @@ -256,12 +286,21 @@ export function resolveMemberExpression( if (!value) return null; } + /* Unwrap x as const once more */ + if (t.isTSAsExpression(value)) value = value.expression; + + /* If we landed on another member chain, recurse */ + if (t.isMemberExpression(value)) { + return resolveMemberExpression(value, path, literalFromNode, opts); + } + /* Final value must be a space-free string */ + if (t.isTSAsExpression(value)) value = value.expression; if (t.isStringLiteral(value)) { return /^\S+$/.test(value.value) ? value.value : null; } if (t.isTemplateLiteral(value)) { - return resolveTemplateLiteral(value, path, literalFromNode); + return resolveTemplateLiteral(value, path, literalFromNode, opts); } return null; // not a string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a20e04..a55a11a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: eslint: specifier: ^9.0.0 version: 9.29.0(jiti@2.4.2) + eslint-plugin-import: + specifier: ^2.32.0 + version: 2.32.0(eslint@9.29.0(jiti@2.4.2)) eslint-plugin-node: specifier: ^11.1.0 version: 11.1.0(eslint@9.29.0(jiti@2.4.2)) @@ -127,9 +130,15 @@ importers: specifier: ^4.1.10 version: 4.1.10 devDependencies: + '@babel/code-frame': + specifier: ^7.27.1 + version: 7.27.1 '@playwright/test': specifier: ^1.53.0 version: 1.53.1 + '@types/babel__code-frame': + specifier: ^7.0.6 + version: 7.0.6 '@types/babel__generator': specifier: ^7.27.0 version: 7.27.0 @@ -687,6 +696,9 @@ packages: react: '>=16.8.0' tailwindcss: ^4.1.10 + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -781,6 +793,9 @@ packages: '@tailwindcss/postcss@4.1.10': resolution: {integrity: sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==} + '@types/babel__code-frame@7.0.6': + resolution: {integrity: sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==} + '@types/babel__generator@7.27.0': resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} @@ -793,6 +808,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} @@ -891,16 +909,48 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + arrify@1.0.1: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} engines: {node: '>=0.10.0'} + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -920,6 +970,18 @@ packages: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1009,9 +1071,29 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + dateformat@3.0.3: resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==} + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -1032,6 +1114,14 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + deprecation@2.3.1: resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} @@ -1051,6 +1141,10 @@ packages: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} engines: {node: '>=0.3.1'} + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -1068,6 +1162,10 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -1088,6 +1186,34 @@ packages: error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + es-abstract@1.24.0: + resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + esbuild@0.25.5: resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} engines: {node: '>=18'} @@ -1105,12 +1231,46 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + eslint-plugin-es@3.0.1: resolution: {integrity: sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==} engines: {node: '>=8.10.0'} peerDependencies: eslint: '>=4.19.1' + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint-plugin-node@11.1.0: resolution: {integrity: sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==} engines: {node: '>=8.10.0'} @@ -1199,6 +1359,10 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -1233,10 +1397,29 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} @@ -1261,6 +1444,14 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1273,10 +1464,29 @@ packages: resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} engines: {node: '>=6'} + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -1323,28 +1533,80 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} is-arrayish@0.3.2: resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-generator-function@1.1.0: + resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} + engines: {node: '>= 0.4'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + is-obj@2.0.0: resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} engines: {node: '>=8'} @@ -1353,6 +1615,45 @@ packages: resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} engines: {node: '>=0.10.0'} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1395,6 +1696,10 @@ packages: json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + jsonpath-plus@10.3.0: resolution: {integrity: sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==} engines: {node: '>=18.0.0'} @@ -1511,6 +1816,10 @@ packages: resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} engines: {node: '>=8'} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + meow@8.1.2: resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==} engines: {node: '>=10'} @@ -1622,6 +1931,30 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1629,6 +1962,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -1698,6 +2035,10 @@ packages: engines: {node: '>=18'} hasBin: true + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -1744,6 +2085,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + regexpp@3.2.0: resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} engines: {node: '>=8'} @@ -1773,6 +2122,18 @@ packages: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} @@ -1789,6 +2150,18 @@ packages: engines: {node: '>=10'} hasBin: true + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + sharp@0.34.2: resolution: {integrity: sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1801,6 +2174,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -1831,6 +2220,10 @@ packages: split@1.0.1: resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -1843,6 +2236,18 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1851,6 +2256,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -1898,6 +2307,9 @@ packages: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1926,6 +2338,22 @@ packages: resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} engines: {node: '>=14.16'} + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + typescript@4.9.5: resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} engines: {node: '>=4.2.0'} @@ -1941,6 +2369,10 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -1965,6 +2397,22 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2467,6 +2915,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@rtsao/scc@1.1.0': {} + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.15': @@ -2545,6 +2995,8 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.10 + '@types/babel__code-frame@7.0.6': {} + '@types/babel__generator@7.27.0': dependencies: '@babel/types': 7.27.6 @@ -2557,6 +3009,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/json5@0.0.29': {} + '@types/minimist@1.2.5': {} '@types/node@20.19.2': @@ -2621,14 +3075,70 @@ snapshots: argparse@2.0.1: {} + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + array-ify@1.0.0: {} + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + arrify@1.0.1: {} + async-function@1.0.0: {} + async-retry@1.3.3: dependencies: retry: 0.13.1 + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + balanced-match@1.0.2: {} before-after-hook@2.2.3: {} @@ -2648,6 +3158,23 @@ snapshots: dependencies: streamsearch: 1.1.0 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} camelcase-keys@6.2.2: @@ -2755,8 +3282,30 @@ snapshots: csstype@3.1.3: {} + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + dateformat@3.0.3: {} + debug@3.2.7: + dependencies: + ms: 2.1.3 + debug@4.4.1: dependencies: ms: 2.1.3 @@ -2770,6 +3319,18 @@ snapshots: deep-is@0.1.4: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + deprecation@2.3.1: {} detect-indent@6.1.0: {} @@ -2780,6 +3341,10 @@ snapshots: diff@7.0.0: {} + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -2802,6 +3367,12 @@ snapshots: dependencies: is-obj: 2.0.0 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + eastasianwidth@0.2.0: {} emoji-regex@8.0.0: {} @@ -2819,6 +3390,88 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-abstract@1.24.0: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.19 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + esbuild@0.25.5: optionalDependencies: '@esbuild/aix-ppc64': 0.25.5 @@ -2853,12 +3506,56 @@ snapshots: escape-string-regexp@4.0.0: {} + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 1.22.10 + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint@9.29.0(jiti@2.4.2)): + dependencies: + debug: 3.2.7 + optionalDependencies: + eslint: 9.29.0(jiti@2.4.2) + eslint-import-resolver-node: 0.3.9 + transitivePeerDependencies: + - supports-color + eslint-plugin-es@3.0.1(eslint@9.29.0(jiti@2.4.2)): dependencies: eslint: 9.29.0(jiti@2.4.2) eslint-utils: 2.1.0 regexpp: 3.2.0 + eslint-plugin-import@2.32.0(eslint@9.29.0(jiti@2.4.2)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.29.0(jiti@2.4.2) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint@9.29.0(jiti@2.4.2)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-node@11.1.0(eslint@9.29.0(jiti@2.4.2)): dependencies: eslint: 9.29.0(jiti@2.4.2) @@ -2975,6 +3672,10 @@ snapshots: flatted@3.3.3: {} + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -2999,8 +3700,43 @@ snapshots: function-bind@1.1.2: {} + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + get-tsconfig@4.10.1: dependencies: resolve-pkg-maps: 1.0.0 @@ -3031,6 +3767,13 @@ snapshots: globals@14.0.0: {} + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + graceful-fs@4.2.11: {} handlebars@4.7.8: @@ -3044,8 +3787,24 @@ snapshots: hard-rejection@2.1.0: {} + has-bigints@1.1.0: {} + has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -3090,27 +3849,130 @@ snapshots: inherits@2.0.4: {} + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-arrayish@0.2.1: {} is-arrayish@0.3.2: optional: true + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-extglob@2.1.1: {} + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.0: + dependencies: + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-obj@2.0.0: {} is-plain-obj@1.1.0: {} + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.19 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + isexe@2.0.0: {} jackspeak@4.1.1: @@ -3139,6 +4001,10 @@ snapshots: json-stringify-safe@5.0.1: {} + json5@1.0.2: + dependencies: + minimist: 1.2.8 + jsonpath-plus@10.3.0: dependencies: '@jsep-plugin/assignment': 1.3.0(jsep@1.4.0) @@ -3229,6 +4095,8 @@ snapshots: map-obj@4.3.0: {} + math-intrinsics@1.1.0: {} + meow@8.1.2: dependencies: '@types/minimist': 1.2.5 @@ -3346,6 +4214,39 @@ snapshots: dependencies: boolbase: 1.0.0 + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -3359,6 +4260,12 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -3417,6 +4324,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} + postcss@8.4.31: dependencies: nanoid: 3.3.11 @@ -3462,6 +4371,26 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + regexpp@3.2.0: {} release-please@17.1.0: @@ -3514,6 +4443,25 @@ snapshots: retry@0.13.1: {} + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + scheduler@0.26.0: {} semver@5.7.2: {} @@ -3522,6 +4470,28 @@ snapshots: semver@7.7.2: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + sharp@0.34.2: dependencies: color: 4.2.3 @@ -3557,6 +4527,34 @@ snapshots: shebang-regex@3.0.0: {} + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + signal-exit@4.1.0: {} simple-swizzle@0.2.2: @@ -3586,6 +4584,11 @@ snapshots: dependencies: through: 2.3.8 + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + streamsearch@1.1.0: {} string-width@4.2.3: @@ -3600,6 +4603,29 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.0 + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -3608,6 +4634,8 @@ snapshots: dependencies: ansi-regex: 6.1.0 + strip-bom@3.0.0: {} + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -3642,6 +4670,13 @@ snapshots: trim-newlines@3.0.1: {} + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + tslib@2.8.1: {} tsx@4.20.3: @@ -3663,6 +4698,39 @@ snapshots: type-fest@3.13.1: {} + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + typescript@4.9.5: {} typescript@5.8.3: {} @@ -3670,6 +4738,13 @@ snapshots: uglify-js@3.19.3: optional: true + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + undici-types@6.21.0: {} undici-types@7.8.0: {} @@ -3698,6 +4773,47 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.0 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@2.0.2: dependencies: isexe: 2.0.0 From ec9bdbce734934b2c8baea883af1ca4980b3d4be Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Wed, 2 Jul 2025 09:25:02 -0700 Subject: [PATCH 20/34] Adding quick-lru --- packages/core/__tests__/unit/ast.test.cjs | 46 +++++++++++++++++-- .../unit/fixtures/ts-test-components.tsx | 17 +++---- packages/core/src/postcss/ast-v2.cts | 4 +- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/packages/core/__tests__/unit/ast.test.cjs b/packages/core/__tests__/unit/ast.test.cjs index 9d20a86..30d2d38 100644 --- a/packages/core/__tests__/unit/ast.test.cjs +++ b/packages/core/__tests__/unit/ast.test.cjs @@ -129,7 +129,6 @@ test('Extract Variant with imports and throw error', async () => { test('testKeyInitialValue', async () => { await runTest({ 'src/app/Component.jsx': AllPatternsComponent }, async () => { const setters = collectUseUISetters(parse(AllPatternsComponent, { sourceType: 'module', plugins: ['jsx', 'typescript'] }), AllPatternsComponent); - console.log('setters: ', setters); assert.strictEqual(setters[0].stateKey, 'theme'); assert.strictEqual(setters[0].initialValue, 'light'); assert.strictEqual(setters[1].stateKey, 'altTheme'); @@ -142,9 +141,50 @@ test('testKeyInitialValue', async () => { assert.strictEqual(setters[4].initialValue, 'auto'); assert.strictEqual(setters[5].stateKey, 'color'); assert.strictEqual(setters[5].initialValue, 'bg-blue'); - assert.strictEqual(setters[6].stateKey, 'variant'); assert.strictEqual(setters[6].initialValue, 'th-dark'); - assert.strictEqual(setters[7].stateKey, 'variant'); 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'); + assert.strictEqual(setters[14].initialValue, 'th-light'); + }); +}); + +test('speed', async () => { + // Simple inline component with a few useUI hooks, no conflicting constants + const simpleComponent = ` + function TestComponent() { + const [theme, setTheme] = useUI('theme', 'light'); + const [size, setSize] = useUI('size', 'medium'); + const [color, setColor] = useUI('color', 'blue'); + const [variant, setVariant] = useUI('variant', 'primary'); + + return
+ + + + +
; + } + `; + + // Create 1000 copies of the same component with unique names + const components = Array.from({ length: 1000 }, (_, i) => { + return simpleComponent.replace(/TestComponent/g, `TestComponent${i}`); + }); + + const bigFile = `import { useUI } from '@zero-ui/core';\n\n` + components.join('\n\n'); + + await runTest({ 'src/app/Component.jsx': bigFile }, async () => { + const start = Date.now(); + const ast = parse(bigFile, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); + const setters = collectUseUISetters(ast, bigFile); + const end = Date.now(); + + console.log(`Processed 1000 components in ${end - start}ms`); + console.log(`Total setters found: ${setters.length}`); }); }); diff --git a/packages/core/__tests__/unit/fixtures/ts-test-components.tsx b/packages/core/__tests__/unit/fixtures/ts-test-components.tsx index 4beb99d..fda11bc 100644 --- a/packages/core/__tests__/unit/fixtures/ts-test-components.tsx +++ b/packages/core/__tests__/unit/fixtures/ts-test-components.tsx @@ -1,6 +1,5 @@ /* eslint-disable import/no-unresolved */ -import React, { useEffect } from 'react'; // @ts-ignore import { useUI } from '@zero-ui/core'; @@ -10,9 +9,9 @@ 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 COLORS = { primary: 'blue', secondary: 'green' } 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; /*───────────────────────────────────────────┐ @@ -43,12 +42,14 @@ export function AllPatternsComponent() { const [variant6, setVariant6] = useUI('variant', `${VARIANTS.light}-${VARIANTS.dark}`); /* ⑫ BinaryExpression (template + member) */ const [variant7, setVariant7] = useUI('variant', `th-${VARIANTS.light}-${VARIANTS.dark}`); - /* ⑬ Optional-chaining */ - // const [variant8, setVariant8] = useUI('variant', VARIANTS.light?.primary); - /* ⑭ nullish-coalesce */ - const [variant9, setVariant9] = useUI('variant', VARIANTS.light.primary ?? 'th-light'); - /* ⑮ Optional-chaining + nullish-coalesce */ - // const [variant10, setVariant10] = useUI('variant', VARIANTS.light?.primary ?? 'th-light'); + /* ⑬ Optional-chaining w/ unresolvable member */ + // @ts-ignore + const [variant8, setVariant8] = useUI('variant', VARIANTS?.light.d ?? 'th-light'); + /* ⑭ nullish-coalesce */ + const [variant9, setVariant9] = useUI('variant', VARIANTS.light ?? 'th-light'); + /* ⑮ Optional-chaining w/ unresolvable member */ + // @ts-ignore + const [variant10, setVariant10] = useUI('variant', VARIANTS.light?.primary ?? 'th-light'); /* ── setters exercised in every allowed style ── */ const clickHandler = () => { diff --git a/packages/core/src/postcss/ast-v2.cts b/packages/core/src/postcss/ast-v2.cts index 67f6e00..ac82a30 100644 --- a/packages/core/src/postcss/ast-v2.cts +++ b/packages/core/src/postcss/ast-v2.cts @@ -450,12 +450,10 @@ export function extractVariants(filePath: string): VariantData[] { // Normalize to final format const variants = normalizeVariants(variantsMap, setters); // returns an array of objects with the following properties: - // - key: string - // - values: string[] - // - initialValue: string | null // [{ key: 'theme', values: [ 'dark', 'light' ], initialValue: 'light' },] // Cache and return + //TODO better cache fileCache.set(filePath, { hash: fileHash, variants }); return variants; } catch (error) { From 30e09513e3d12349b9cfc6c90752bd61cd60b3e8 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Wed, 2 Jul 2025 10:52:52 -0700 Subject: [PATCH 21/34] feat:added advanced caching --- packages/core/__tests__/unit/ast.test.cjs | 160 ++++++++++++++-------- packages/core/package.json | 7 +- packages/core/src/postcss/ast-v2.cts | 98 ++++++------- packages/core/src/postcss/resolvers.cts | 2 + packages/core/tsconfig.json | 13 +- pnpm-lock.yaml | 8 ++ 6 files changed, 169 insertions(+), 119 deletions(-) diff --git a/packages/core/__tests__/unit/ast.test.cjs b/packages/core/__tests__/unit/ast.test.cjs index 30d2d38..b68c012 100644 --- a/packages/core/__tests__/unit/ast.test.cjs +++ b/packages/core/__tests__/unit/ast.test.cjs @@ -3,9 +3,9 @@ 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 { collectUseUISetters } = require('../../dist/postcss/ast-v2.cjs'); +const { collectUseUISetters, extractVariants } = require('../../dist/postcss/ast-v2.cjs'); const ComponentImports = readFile(path.join(__dirname, './fixtures/test-components.jsx')); const AllPatternsComponent = readFile(path.join(__dirname, './fixtures/ts-test-components.tsx')); @@ -117,74 +117,116 @@ async function runTest(files, callback) { // ); // }); -test('Extract Variant with imports and throw error', async () => { - await runTest({ 'src/app/Component.jsx': ComponentImports }, async () => { - assert.throws(() => { - collectUseUISetters(parse(ComponentImports, { sourceType: 'module', plugins: ['jsx', 'typescript'] }), ComponentImports); - // tests that the error message contains the correct text - }, /const VARSLocal = VARS/); - }); -}); +// test('Extract Variant with imports and throw error', async () => { +// await runTest({ 'src/app/Component.jsx': ComponentImports }, async () => { +// assert.throws(() => { +// collectUseUISetters(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 = collectUseUISetters(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'); - assert.strictEqual(setters[14].initialValue, 'th-light'); - }); -}); +// test('testKeyInitialValue', async () => { +// await runTest({ 'src/app/Component.jsx': AllPatternsComponent }, async () => { +// const setters = collectUseUISetters(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'); +// assert.strictEqual(setters[14].initialValue, 'th-light'); +// }); +// }); + +// test('speed', async () => { +// // Simple inline component with a few useUI hooks, no conflicting constants +// const simpleComponent = ` +// function TestComponent() { +// const [theme, setTheme] = useUI('theme', 'light'); +// const [size, setSize] = useUI('size', 'medium'); +// const [color, setColor] = useUI('color', 'blue'); +// const [variant, setVariant] = useUI('variant', 'primary'); + +// return
+// +// +// +// +//
; +// } +// `; + +// // Create 1000 copies of the same component with unique names +// const components = Array.from({ length: 1000 }, (_, i) => { +// return simpleComponent.replace(/TestComponent/g, `TestComponent${i}`); +// }); + +// const bigFile = `import { useUI } from '@zero-ui/core';\n\n` + components.join('\n\n'); + +// await runTest({ 'src/app/Component.jsx': bigFile }, async () => { +// const start = Date.now(); +// const ast = parse(bigFile, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); +// const setters = collectUseUISetters(ast, bigFile); +// const end = Date.now(); + +// console.log(`Processed 1000 components in ${end - start}ms`); +// console.log(`Total setters found: ${setters.length}`); +// }); +// }); -test('speed', async () => { - // Simple inline component with a few useUI hooks, no conflicting constants +test('cache performance', async () => { const simpleComponent = ` + import { useUI } from '@zero-ui/core'; + function TestComponent() { const [theme, setTheme] = useUI('theme', 'light'); const [size, setSize] = useUI('size', 'medium'); - const [color, setColor] = useUI('color', 'blue'); - const [variant, setVariant] = useUI('variant', 'primary'); - - return
- - - - -
; + return
setTheme('dark')}>Test
; } `; - // Create 1000 copies of the same component with unique names - const components = Array.from({ length: 1000 }, (_, i) => { - return simpleComponent.replace(/TestComponent/g, `TestComponent${i}`); - }); + 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; - const bigFile = `import { useUI } from '@zero-ui/core';\n\n` + components.join('\n\n'); + console.log('=== THIRD CALL ==='); + const start3 = performance.now(); + const result3 = extractVariants(filePath); + const thirdCall = performance.now() - start3; - await runTest({ 'src/app/Component.jsx': bigFile }, async () => { - const start = Date.now(); - const ast = parse(bigFile, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); - const setters = collectUseUISetters(ast, bigFile); - const end = Date.now(); + 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`); + } - console.log(`Processed 1000 components in ${end - start}ms`); - console.log(`Total setters found: ${setters.length}`); + assert.strictEqual(result1, result2); + assert.strictEqual(result2, result3); }); }); diff --git a/packages/core/package.json b/packages/core/package.json index ba5616f..1f491e9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,7 +34,7 @@ } }, "scripts": { - "prepare": "pnpm run build", + "prepack": "pnpm run build", "build": "tsc -p tsconfig.json", "test:next": "playwright test -c __tests__/config/playwright.next.config.js", "test:vite": "playwright test -c __tests__/config/playwright.vite.config.js", @@ -90,6 +90,7 @@ "@types/babel__code-frame": "^7.0.6", "@types/babel__generator": "^7.27.0", "@types/babel__traverse": "^7.20.7", - "@types/react": "^19.1.8" + "@types/react": "^19.1.8", + "lru-cache": "^10.4.3" } -} +} \ No newline at end of file diff --git a/packages/core/src/postcss/ast-v2.cts b/packages/core/src/postcss/ast-v2.cts index ac82a30..9229e52 100644 --- a/packages/core/src/postcss/ast-v2.cts +++ b/packages/core/src/postcss/ast-v2.cts @@ -13,6 +13,7 @@ import * as path from 'path'; import { literalFromNode } from './resolvers.cjs'; import { codeFrameColumns } from '@babel/code-frame'; const traverse = (babelTraverse as any).default; +import { LRUCache as LRU } from 'lru-cache'; export interface SetterMeta { /** Babel binding object — use `binding.referencePaths` in Pass 2 */ @@ -392,78 +393,69 @@ export function normalizeVariants(variants: Map>, setters: S } // File cache to avoid re-parsing unchanged files -interface CacheEntry { +export interface CacheEntry { hash: string; variants: VariantData[]; } -const fileCache = new Map(); +const fileCache = new LRU({ max: 5000 }); -/** - * Main function: Extract all variant tokens from a JS/TS file - * @param filePath - Path to the source file - * @returns Array of variant data objects - */ export function extractVariants(filePath: string): VariantData[] { + // console.log(`[CACHE] Checking: ${filePath}`); + try { - // Read and hash the file for caching - const sourceCode = readFileSync(filePath, 'utf-8'); - const fileHash = createHash('md5').update(sourceCode).digest('hex'); + const { mtimeMs, size } = fs.statSync(filePath); + const sig = `${mtimeMs}:${size}`; - // Check cache first const cached = fileCache.get(filePath); - if (cached && cached.hash === fileHash) { - return cached.variants; + if (cached && cached.hash === sig) { + // console.log(`[CACHE] HIT (sig): ${filePath}`); + return cached.variants; // Fast path: file unchanged } - // Parse the file once - const ast = parse(sourceCode, { - sourceType: 'module', - plugins: ['jsx', 'typescript', 'decorators-legacy'], - allowImportExportEverywhere: false, - allowReturnOutsideFunction: false, - attachComment: false, - createImportExpressions: true, - sourceFilename: filePath, - }); + const source = fs.readFileSync(filePath, 'utf8'); + const hash = createHash('md5').update(source).digest('hex'); - // Pass 1: Collect all useUI setters and their initial values - const setters = collectUseUISetters(ast, sourceCode); - // returns an array of objects with the following properties: - // - stateKey: string - // - initialValue: string | null - // - binding: NodePath - // - setterName: string - - // Early return if no setters found - if (setters.length === 0) { - const result: VariantData[] = []; - fileCache.set(filePath, { hash: fileHash, variants: result }); - return result; + // Fallback: content unchanged despite mtime/size change + if (cached && cached.hash === hash) { + // console.log(`[CACHE] HIT (hash): ${filePath}`); + // Update cache with new sig for next time + const entry = { hash: sig, variants: cached.variants }; + fileCache.set(filePath, entry); + return cached.variants; } - // Pass 2: Harvest all values from setter calls - const variantsMap = harvestSetterValues(setters); - // returns a Map of keys, and a Set of values - // Map{'theme' => Set(2) { 'light', 'dark' },} + // console.log(`[CACHE] MISS: ${filePath} (parsing...)`); + // Parse the file + const ast = parse(source, { sourceType: 'module', plugins: ['jsx', 'typescript', 'decorators-legacy'], sourceFilename: filePath }); + const setters = collectUseUISetters(ast, source); + if (!setters.length) return []; + const variants = normalizeVariants(harvestSetterValues(setters), setters); - // Normalize to final format - const variants = normalizeVariants(variantsMap, setters); - // returns an array of objects with the following properties: - // [{ key: 'theme', values: [ 'dark', 'light' ], initialValue: 'light' },] - - // Cache and return - //TODO better cache - fileCache.set(filePath, { hash: fileHash, variants }); + // Store with signature for fast future lookups + fileCache.set(filePath, { hash: sig, variants }); return variants; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn(`[Zero-UI] Failed to parse ${filePath}: ${errorMessage}`); - console.warn(`[Zero-UI] Ensure useUI calls use string literals only: useUI('key', 'value')`); - return []; + // console.log(`[CACHE] ERROR (fallback): ${filePath}`); + // Fallback for virtual/non-existent files - use content hash only + const source = fs.readFileSync(filePath, 'utf8'); + const hash = createHash('md5').update(source).digest('hex'); + + const cached = fileCache.get(filePath); + if (cached && cached.hash === hash) { + return cached.variants; + } + + // Parse and cache... + const ast = parse(source, { sourceType: 'module', plugins: ['jsx', 'typescript', 'decorators-legacy'], sourceFilename: filePath }); + const setters = collectUseUISetters(ast, source); + if (!setters.length) return []; + const variants = normalizeVariants(harvestSetterValues(setters), setters); + + fileCache.set(filePath, { hash, variants }); + return variants; } } - /** * Extract variants from multiple files * @param filePaths - Array of file paths to analyze diff --git a/packages/core/src/postcss/resolvers.cts b/packages/core/src/postcss/resolvers.cts index c9fb28a..e8ba655 100644 --- a/packages/core/src/postcss/resolvers.cts +++ b/packages/core/src/postcss/resolvers.cts @@ -20,6 +20,8 @@ export interface ResolveOpts { * @param path - The path to the node * @returns The string literal or null if the node is not a string literal or template literal with no expressions or identifier bound to local const */ + +// TODO: add memoization export function literalFromNode(node: t.Expression, path: NodePath, opts: ResolveOpts): string | null { // String / template (no ${}) if (t.isStringLiteral(node)) return node.value; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index d418cdd..4c20535 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,8 +1,12 @@ { "extends": "../../tsconfig.base.json", /* — compile exactly one file — */ - "include": ["src/**/*"], - "exclude": ["src/postcss/coming-soon"], + "include": [ + "src/**/*" + ], + "exclude": [ + "src/postcss/coming-soon" + ], /* — compiler output — */ "compilerOptions": { "target": "ES2020", @@ -14,6 +18,7 @@ "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 + "strict": true, // enable all strict type-checking options + "skipLibCheck": true // Hides all errors coming from node_modules } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a55a11a..98dd8bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: '@types/react': specifier: ^19.1.8 version: 19.1.8 + lru-cache: + specifier: ^10.4.3 + version: 10.4.3 packages: @@ -1797,6 +1800,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.1.0: resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} engines: {node: 20 || >=22} @@ -4081,6 +4087,8 @@ snapshots: lodash.merge@4.6.2: {} + lru-cache@10.4.3: {} + lru-cache@11.1.0: {} lru-cache@6.0.0: From 8a8ba5e103eed93e2165f98c32341d9a4da6f2b1 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Wed, 2 Jul 2025 11:36:49 -0700 Subject: [PATCH 22/34] collectUseUIsetters function complete --- .../__tests__/fixtures/next/app/layout.tsx | 19 +- .../core/__tests__/fixtures/next/app/page.tsx | 2 - packages/core/__tests__/unit/ast.test.cjs | 277 ++++++++---------- .../unit/fixtures/test-components.jsx | 12 - .../unit/fixtures/ts-test-components.tsx | 2 + packages/core/__tests__/unit/index.test.cjs | 8 +- packages/core/src/postcss/ast-v2.cts | 7 +- packages/core/src/postcss/resolvers.cts | 3 +- 8 files changed, 148 insertions(+), 182 deletions(-) diff --git a/packages/core/__tests__/fixtures/next/app/layout.tsx b/packages/core/__tests__/fixtures/next/app/layout.tsx index e9b8664..5456a59 100644 --- a/packages/core/__tests__/fixtures/next/app/layout.tsx +++ b/packages/core/__tests__/fixtures/next/app/layout.tsx @@ -1,14 +1,11 @@ -import { bodyAttributes } from '@zero-ui/attributes'; +import { bodyAttributes } from "@zero-ui/attributes"; import './globals.css'; -export default function RootLayout({ children }) { - return ( - - +export default function RootLayout({ + children +}) { + return + {children} - - ); -} + ; +} \ No newline at end of file diff --git a/packages/core/__tests__/fixtures/next/app/page.tsx b/packages/core/__tests__/fixtures/next/app/page.tsx index e965418..77889dc 100644 --- a/packages/core/__tests__/fixtures/next/app/page.tsx +++ b/packages/core/__tests__/fixtures/next/app/page.tsx @@ -2,7 +2,6 @@ import { useUI } from '@react-zero-ui/core'; import UseEffectComponent from './UseEffectComponent'; import FAQ from './FAQ'; -import { THEMES } from './variables'; export default function Page() { const [, setTheme] = useUI<'light' | 'dark'>('theme', 'light'); @@ -13,7 +12,6 @@ export default function Page() { const [, setOpen] = useUI<'open' | 'closed'>('faq', 'closed'); // Same key everywhere! const [, setScope] = useUI<'off' | 'on'>('scope', 'off'); const [, setMobile] = useUI<'true' | 'false'>('mobile', 'false'); - const [, setThemeBlue] = useUI(`theme-${THEMES.blueee}`, THEMES.blueee); const [, setToggleFunction] = useUI<'white' | 'black'>('toggle-function', 'white'); diff --git a/packages/core/__tests__/unit/ast.test.cjs b/packages/core/__tests__/unit/ast.test.cjs index b68c012..d11895f 100644 --- a/packages/core/__tests__/unit/ast.test.cjs +++ b/packages/core/__tests__/unit/ast.test.cjs @@ -4,7 +4,7 @@ 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 { findAllSourceFiles } = require('../../dist/postcss/helpers.cjs'); const { collectUseUISetters, extractVariants } = require('../../dist/postcss/ast-v2.cjs'); const ComponentImports = readFile(path.join(__dirname, './fixtures/test-components.jsx')); @@ -43,159 +43,138 @@ async function runTest(files, callback) { } } -// 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('collectUseUISetters - 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 <> -// -// -// -// -//
; -// } -// `; - -// // Create 1000 copies of the same component with unique names -// const components = Array.from({ length: 1000 }, (_, i) => { -// return simpleComponent.replace(/TestComponent/g, `TestComponent${i}`); -// }); - -// const bigFile = `import { useUI } from '@zero-ui/core';\n\n` + components.join('\n\n'); - -// await runTest({ 'src/app/Component.jsx': bigFile }, async () => { -// const start = Date.now(); -// const ast = parse(bigFile, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); -// const setters = collectUseUISetters(ast, bigFile); -// const end = Date.now(); - -// console.log(`Processed 1000 components in ${end - start}ms`); -// console.log(`Total setters found: ${setters.length}`); -// }); -// }); +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('collectUseUISetters - 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.ok(variants.some((v) => v.key === 'theme' && v.values.includes('light'))); + assert.ok(variants.some((v) => v.key === 'size' && v.values.includes('medium'))); + } + ); +}); + +test('Extract Variant with imports and throw error', async () => { + await runTest({ 'src/app/Component.jsx': ComponentImports }, async () => { + assert.throws(() => { + collectUseUISetters(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 = collectUseUISetters(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'); + assert.strictEqual(setters[14].initialValue, 'th-light'); + }); +}); 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() { - const [theme, setTheme] = useUI('theme', 'light'); - const [size, setSize] = useUI('size', 'medium'); + /* ① 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
; } `; diff --git a/packages/core/__tests__/unit/fixtures/test-components.jsx b/packages/core/__tests__/unit/fixtures/test-components.jsx index f98880e..eb812e1 100644 --- a/packages/core/__tests__/unit/fixtures/test-components.jsx +++ b/packages/core/__tests__/unit/fixtures/test-components.jsx @@ -1,5 +1,4 @@ /* eslint-disable import/no-unresolved */ -import { useUI } from '@react-zero-ui/core'; import { THEME, MENU_SIZES, VARS } from './variables'; export function ComponentImports() { @@ -13,14 +12,3 @@ export function ComponentImports() {
); } - -export function ComponentSimple() { - const [, setTheme] = useUI('theme', 'light'); - const [, setSize] = useUI('size', 'medium'); - return ( -
- - -
- ); -} diff --git a/packages/core/__tests__/unit/fixtures/ts-test-components.tsx b/packages/core/__tests__/unit/fixtures/ts-test-components.tsx index fda11bc..35fa1b4 100644 --- a/packages/core/__tests__/unit/fixtures/ts-test-components.tsx +++ b/packages/core/__tests__/unit/fixtures/ts-test-components.tsx @@ -50,6 +50,8 @@ export function AllPatternsComponent() { /* ⑮ Optional-chaining w/ unresolvable member */ // @ts-ignore const [variant10, setVariant10] = useUI('variant', VARIANTS.light?.primary ?? 'th-light'); + /* ⑯ local const identifier */ + const [variant11, setVariant11] = useUI('variant', VARIANTS['blue']); /* ── setters exercised in every allowed style ── */ const clickHandler = () => { diff --git a/packages/core/__tests__/unit/index.test.cjs b/packages/core/__tests__/unit/index.test.cjs index 291207b..5b18d8e 100644 --- a/packages/core/__tests__/unit/index.test.cjs +++ b/packages/core/__tests__/unit/index.test.cjs @@ -364,10 +364,8 @@ test('valid edge cases: underscores + missing initial', async () => { } `, }, - (result) => { - console.log('result: ', result.css); - assert(result.css.includes('@custom-variant only-setter-key-set-later')); - assert(!result.css.includes('@custom-variant no-initial-value')); + () => { + assert.throws(() => {}); } ); }); @@ -530,7 +528,7 @@ test('handles concurrent file modifications', async () => { 'src/rapid.jsx': ` import { useUI } from '@react-zero-ui/core'; function Rapid() { - const [count] = useUI('count', 'zero'); + const [count,setCount] = useUI('count', 'zero'); return
Initial
; } `, diff --git a/packages/core/src/postcss/ast-v2.cts b/packages/core/src/postcss/ast-v2.cts index 9229e52..876f5c5 100644 --- a/packages/core/src/postcss/ast-v2.cts +++ b/packages/core/src/postcss/ast-v2.cts @@ -5,7 +5,6 @@ import * as babelTraverse from '@babel/traverse'; import { Binding, NodePath } from '@babel/traverse'; import * as t from '@babel/types'; import { CONFIG } from '../config.cjs'; -import { readFileSync } from 'fs'; import { createHash } from 'crypto'; import { generate } from '@babel/generator'; import * as fs from 'fs'; @@ -428,8 +427,12 @@ export function extractVariants(filePath: string): VariantData[] { // console.log(`[CACHE] MISS: ${filePath} (parsing...)`); // Parse the file const ast = parse(source, { sourceType: 'module', plugins: ['jsx', 'typescript', 'decorators-legacy'], sourceFilename: filePath }); + + // Collect useUI setters const setters = collectUseUISetters(ast, source); if (!setters.length) return []; + + // Normalize variants const variants = normalizeVariants(harvestSetterValues(setters), setters); // Store with signature for fast future lookups @@ -450,6 +453,8 @@ export function extractVariants(filePath: string): VariantData[] { const ast = parse(source, { sourceType: 'module', plugins: ['jsx', 'typescript', 'decorators-legacy'], sourceFilename: filePath }); const setters = collectUseUISetters(ast, source); if (!setters.length) return []; + + // final normalization of variants const variants = normalizeVariants(harvestSetterValues(setters), setters); fileCache.set(filePath, { hash, variants }); diff --git a/packages/core/src/postcss/resolvers.cts b/packages/core/src/postcss/resolvers.cts index e8ba655..145421f 100644 --- a/packages/core/src/postcss/resolvers.cts +++ b/packages/core/src/postcss/resolvers.cts @@ -21,7 +21,6 @@ export interface ResolveOpts { * @returns The string literal or null if the node is not a string literal or template literal with no expressions or identifier bound to local const */ -// TODO: add memoization export function literalFromNode(node: t.Expression, path: NodePath, opts: ResolveOpts): string | null { // String / template (no ${}) if (t.isStringLiteral(node)) return node.value; @@ -104,7 +103,7 @@ export function resolveLocalConstIdentifier( : opts.hook === 'setterName' ? `setterFunction(${node.name}Local)` : '' - }` + }\n` ); } From 5785009f830e72b10686114a82793812c4586fb3 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Wed, 2 Jul 2025 12:47:33 -0700 Subject: [PATCH 23/34] finished parser --- .../unit/fixtures/ts-test-components.tsx | 2 + packages/core/src/postcss/ast-v2.cts | 309 +++++------------- packages/core/src/postcss/resolvers.cts | 76 +++-- 3 files changed, 126 insertions(+), 261 deletions(-) diff --git a/packages/core/__tests__/unit/fixtures/ts-test-components.tsx b/packages/core/__tests__/unit/fixtures/ts-test-components.tsx index 35fa1b4..e645ead 100644 --- a/packages/core/__tests__/unit/fixtures/ts-test-components.tsx +++ b/packages/core/__tests__/unit/fixtures/ts-test-components.tsx @@ -69,6 +69,8 @@ export function AllPatternsComponent() { /* array index */ setMode(MODES[1]); + + setVariant2(VARIANTS['dark']); }; /* conditional toggle with prev-state */ diff --git a/packages/core/src/postcss/ast-v2.cts b/packages/core/src/postcss/ast-v2.cts index 876f5c5..26f8778 100644 --- a/packages/core/src/postcss/ast-v2.cts +++ b/packages/core/src/postcss/ast-v2.cts @@ -65,6 +65,16 @@ export interface SetterMeta { */ export function collectUseUISetters(ast: t.File, sourceCode: string): SetterMeta[] { const setters: SetterMeta[] = []; + /* ---------- cache resolved literals per AST node ---------- */ + const memo = new WeakMap(); + const optsBase = { throwOnFail: false, source: sourceCode } as const; + + const lit = (node: t.Expression, path: NodePath, hook: 'stateKey' | 'initialValue' | 'setterName') => { + if (memo.has(node)) return memo.get(node)!; + const v = literalFromNode(node, path, { ...optsBase, hook }); + memo.set(node, v); + return v; + }; traverse(ast, { VariableDeclarator(path: NodePath) { @@ -85,7 +95,7 @@ export function collectUseUISetters(ast: t.File, sourceCode: string): SetterMeta const [keyArg, initialArg] = init.arguments; // resolve state key with new helpers - const stateKey = literalFromNode(keyArg as t.Expression, path as NodePath, { throwOnFail: true, source: sourceCode, hook: 'stateKey' }); + const stateKey = lit(keyArg as t.Expression, path as NodePath, 'stateKey'); if (stateKey === null) { throwCodeFrame( @@ -98,11 +108,7 @@ export function collectUseUISetters(ast: t.File, sourceCode: string): SetterMeta } // resolve initial value with new helpers - const initialValue = literalFromNode(initialArg as t.Expression, path as NodePath, { - throwOnFail: true, - source: sourceCode, - hook: 'initialValue', - }); + const initialValue = lit(initialArg as t.Expression, path as NodePath, 'initialValue'); if (initialValue === null) { throwCodeFrame( @@ -140,237 +146,84 @@ export interface VariantData { * @param setters - Array of SetterMeta from Pass 1 * @returns Map of stateKey -> Set of discovered values */ -export function harvestSetterValues(setters: SetterMeta[]): Map> { +export function harvestSetterValues( + setters: SetterMeta[], + fileSource: string // extra so we can throw frames +): Map> { + /* ---------- bootstrap with initial values ---------- */ const variants = new Map>(); - - // Initialize with initial values from Pass 1 - for (const setter of setters) { - if (!variants.has(setter.stateKey)) { - variants.set(setter.stateKey, new Set()); - } - if (setter.initialValue) { - variants.get(setter.stateKey)!.add(setter.initialValue); - } + for (const { stateKey, initialValue } of setters) { + if (!variants.has(stateKey)) variants.set(stateKey, new Set()); + if (initialValue) variants.get(stateKey)!.add(initialValue); } - // Examine each setter's reference paths - for (const setter of setters) { - const valueSet = variants.get(setter.stateKey)!; - - // Look at every place this setter is referenced - for (const referencePath of setter.binding.referencePaths) { - // Check if this reference is being called as a function - const callPath = findCallExpression(referencePath); - if (callPath) { - // Extract values from the first argument of the call - const firstArg = callPath.node.arguments[0]; - if (firstArg) { - const extractedValues = extractLiteralsRecursively(firstArg as t.Expression, callPath); - extractedValues.forEach((value) => valueSet.add(value)); - } + /* ---------- cache resolved literals per AST node ---------- */ + const memo = new WeakMap(); + const optsBase = { throwOnFail: false, source: fileSource, hook: 'setterName' } as const; + + const lit = (node: t.Expression, path: NodePath) => { + if (memo.has(node)) return memo.get(node)!; + const v = literalFromNode(node, path, optsBase); + memo.set(node, v); + return v; + }; + + /* ---------- walk every setter reference ---------- */ + for (const { binding, stateKey } of setters) { + const bucket = variants.get(stateKey)!; + + binding.referencePaths.forEach((refPath) => { + // Only care about call expressions: setX(...) + const call = refPath.parentPath; + if (!call?.isCallExpression() || call.node.callee !== refPath.node) return; + + const arg = call.node.arguments[0] as t.Expression | undefined; + if (!arg) return; + + /* ① plain literal / identifier / template / member etc. */ + const direct = lit(arg, call); + if (direct) { + bucket.add(direct); + return; } - } - } - - return variants; -} -/** - * Check if a reference path is part of a function call - * Handles: setTheme('dark'), obj.setTheme('dark'), etc. - */ -function findCallExpression(referencePath: NodePath): NodePath | null { - try { - const parent = referencePath.parent; - - // Direct call: setTheme('dark') - if (t.isCallExpression(parent) && parent.callee === referencePath.node) { - return referencePath.parentPath as NodePath; - } - - // Member expression call: obj.setTheme('dark') - if (t.isMemberExpression(parent) && parent.property === referencePath.node) { - const grandParent = referencePath.parentPath?.parent; - if (t.isCallExpression(grandParent) && grandParent.callee === parent) { - return referencePath.parentPath?.parentPath as NodePath; + /* ② conditional setX(cond ? 'a' : 'b') */ + if (t.isConditionalExpression(arg)) { + ['consequent', 'alternate'].forEach((k) => { + const v = lit(k === 'consequent' ? arg.consequent : arg.alternate, call); + if (v) bucket.add(v); + }); + return; } - } - console.warn(`[Zero-UI] Failed to find call expression for ${referencePath.node.type} in ${referencePath.opts?.filename}`); - return null; - } catch (error) { - console.warn(`[Zero-UI] Failed to find call expression for ${referencePath.node.type} in ${referencePath.opts?.filename}`); - return null; - } -} - -/** - * Recursively extract string literal values from an expression - * Optimized for common useUI patterns: - * - Direct strings: 'light', 'dark' - * - Ternaries: condition ? 'light' : 'dark' - * - Constants: THEMES.LIGHT - * - Functions: prev => prev === 'light' ? 'dark' : 'light' - * - Member expressions: obj.prop, obj.prop.prop, etc. - */ -function extractLiteralsRecursively(node: t.Expression, path: NodePath): string[] { - const results: string[] = []; - - // Base case: direct literals - const literal = literalToString(node); - if (literal !== null) { - results.push(literal); - return results; - } - try { - // Ternary: condition ? 'value1' : 'value2' - if (t.isConditionalExpression(node)) { - results.push(...extractLiteralsRecursively(node.consequent, path)); - results.push(...extractLiteralsRecursively(node.alternate, path)); - } - - // Logical expressions: a && 'value' || 'default' - else if (t.isLogicalExpression(node)) { - results.push(...extractLiteralsRecursively(node.left, path)); - results.push(...extractLiteralsRecursively(node.right, path)); - } - - // Arrow functions: () => 'value' or prev => prev==='a' ? 'b':'a' - else if (t.isArrowFunctionExpression(node)) { - if (t.isExpression(node.body)) { - results.push(...extractLiteralsRecursively(node.body, path)); - } else if (t.isBlockStatement(node.body)) { - // Look for return statements - // example: const a = () => 'a' + 'b' - results.push(...extractFromBlockStatement(node.body, path)); + /* ③ logical fallback setX(a || 'b') or a ?? 'b' */ + if (t.isLogicalExpression(arg) && (arg.operator === '||' || arg.operator === '??')) { + [arg.left, arg.right].forEach((side) => { + const v = lit(side as t.Expression, call); + if (v) bucket.add(v); + }); + return; } - } - // Function expressions: function() { return 'value'; } - else if (t.isFunctionExpression(node)) { - // example: function() { return 'a' + 'b' } - results.push(...extractFromBlockStatement(node.body, path)); - } - - // Identifiers: resolve to their values if possible - else if (t.isIdentifier(node)) { - const resolved = resolveIdentifier(node, path); - if (resolved) { - // example: const a = 'a' + 'b' - results.push(...extractLiteralsRecursively(resolved, path)); - } - } - - // Member expressions: SIZES.SMALL, obj.prop, etc. - else if (t.isMemberExpression(node) && !node.computed && t.isIdentifier(node.property)) { - const objectResolved = resolveIdentifier(node.object as t.Identifier, path); - if (objectResolved && t.isObjectExpression(objectResolved)) { - // Look for the property in the object - const propertyName = node.property.name; - for (const prop of objectResolved.properties) { - if (t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === propertyName) { - const propertyValue = literalToString(prop.value as t.Expression); - if (propertyValue !== null) { - // example: const a = { b: 'c' } - results.push(propertyValue); - } - } - } + /* ④ arrow / fn body setX(() => 'dark') */ + if ((t.isArrowFunctionExpression(arg) || t.isFunctionExpression(arg)) && arg.body) { + const body = t.isBlockStatement(arg.body) + ? // grab every `return value` + arg.body.body + .filter((ret) => t.isReturnStatement(ret)) + .map((ret) => ret.argument) + .filter(Boolean) + : [arg.body]; + + body.forEach((expr) => { + const v = lit(expr as t.Expression, call); + if (v) bucket.add(v); + }); } - } - - // Binary expressions: might contain literals in some cases - else if (t.isBinaryExpression(node)) { - // Only extract if it's a simple concatenation that might resolve to a literal - if (node.operator === '+') { - // example: 'a' + 'b' - results.push(...extractLiteralsRecursively(node.left as t.Expression, path)); - results.push(...extractLiteralsRecursively(node.right as t.Expression, path)); - } - } - } catch (error) { - console.warn(`[Zero-UI] Failed to extract literals from ${node.type} in ${path?.opts?.filename}`, error); - return []; - } finally { - return results; - } -} - -/** - * Extract literals from block statements by finding return statements - */ -function extractFromBlockStatement(block: t.BlockStatement, path: NodePath): string[] { - const results: string[] = []; - - for (const stmt of block.body) { - if (t.isReturnStatement(stmt) && stmt.argument) { - results.push(...extractLiteralsRecursively(stmt.argument as t.Expression, path)); - } - } - - return results; -} - -/** - * Try to resolve an identifier to its value within the current scope - * @param node - The identifier to resolve - * @param path - The path to the identifier - * @returns The value of the identifier, or null if it cannot be resolved - */ -function resolveIdentifier(node: t.Identifier, path: NodePath): t.Expression | null { - const binding = path.scope.getBinding(node.name); - if (!binding) return null; - - const bindingPath = binding.path; - - // Variable declarator: const x = 'value' - if (bindingPath.isVariableDeclarator() && bindingPath.node.init) { - return bindingPath.node.init as t.Expression; - } - - // Import specifier: import { THEME_DARK } from './constants' - - if (bindingPath.isImportSpecifier() || bindingPath.isImportDefaultSpecifier()) { - console.log('import specifier: ', bindingPath.node); - // For now, we can't easily resolve cross-file imports - // But we could enhance this later to parse the imported file - // TODO: Implement this - return null; - } - - // Function declaration: function getName() { return 'value'; } - if (bindingPath.isFunctionDeclaration()) { - // Could try to extract return values, but that's complex - // TODO: Implement this - return null; - } - - // Try to look at the scope for block-scoped variables - // that might be defined higher up - let currentScope = path.scope; - while (currentScope) { - const scopeBinding = currentScope.getOwnBinding(node.name); - if (scopeBinding && scopeBinding.path.isVariableDeclarator() && scopeBinding.path.node.init) { - return scopeBinding.path.node.init as t.Expression; - } - currentScope = currentScope.parent; + }); } - return null; -} - -/** - * Convert string literals to strings (only strings are supported) - */ -function literalToString(node: t.Expression): string | null { - if (t.isStringLiteral(node)) { - return node.value; - } - if (t.isTemplateLiteral(node) && node.expressions.length === 0) { - // Simple template literal with no expressions: `hello` - return node.quasis[0]?.value.cooked || null; - } - return null; + return variants; } /** @@ -433,7 +286,7 @@ export function extractVariants(filePath: string): VariantData[] { if (!setters.length) return []; // Normalize variants - const variants = normalizeVariants(harvestSetterValues(setters), setters); + const variants = normalizeVariants(harvestSetterValues(setters, source), setters); // Store with signature for fast future lookups fileCache.set(filePath, { hash: sig, variants }); @@ -455,7 +308,7 @@ export function extractVariants(filePath: string): VariantData[] { if (!setters.length) return []; // final normalization of variants - const variants = normalizeVariants(harvestSetterValues(setters), setters); + const variants = normalizeVariants(harvestSetterValues(setters, source), setters); fileCache.set(filePath, { hash, variants }); return variants; @@ -825,6 +678,6 @@ export function parseJsonWithBabel(source: string): any { export function throwCodeFrame(path: NodePath, filename: string, source: string, msg: string): never { const loc = path.node.loc; const head = loc ? `${filename}:${loc.start.line}:${loc.start.column}\n` : ''; - const frame = loc ? codeFrameColumns(source, { start: loc.start, end: loc.end }, { highlightCode: true, linesAbove: 2, linesBelow: 2 }) : ''; + const frame = loc ? codeFrameColumns(source, { start: loc.start, end: loc.end }, { highlightCode: true, linesAbove: 1, linesBelow: 1 }) : ''; throw new Error(`${head}${msg}\n${frame}`); } diff --git a/packages/core/src/postcss/resolvers.cts b/packages/core/src/postcss/resolvers.cts index 145421f..fd7cea7 100644 --- a/packages/core/src/postcss/resolvers.cts +++ b/packages/core/src/postcss/resolvers.cts @@ -1,7 +1,7 @@ import * as t from '@babel/types'; import { NodePath } from '@babel/traverse'; import { throwCodeFrame } from './ast-v2.cjs'; - +import { generate } from '@babel/generator'; export interface ResolveOpts { throwOnFail?: boolean; // default false source?: string; // optional; fall back to path.hub.file.code @@ -94,7 +94,7 @@ export function resolveLocalConstIdentifier( opts.source ?? path.opts?.source?.code, `[Zero-UI] Cannot use imported variables. Assign to a local const first.\n` + `Example:\n import { ${node.name} } from "./filePath";\n ` + - `const ${node.name}Local = ${node.name};\n\n ` + + `const ${node.name}Local = ${node.name};\n ` + `${ opts.hook === 'stateKey' ? `useUI(${node.name}Local, initialValue);` @@ -222,25 +222,33 @@ export function resolveMemberExpression( // Walk up until we hit the root Identifier while (t.isMemberExpression(current)) { - // ── Step 1: push property key if (current.computed) { - // obj['prop'] or obj[0] - if (t.isStringLiteral(current.property)) { - props.unshift(current.property.value); - } else if (t.isNumericLiteral(current.property)) { - props.unshift(current.property.value); - } else if (t.isTemplateLiteral(current.property)) { - const lit = resolveTemplateLiteral(current.property, path, literalFromNode, opts); - if (lit === null) return null; - props.unshift(lit); + /** Any *expression* inside `[]` → run through the same pipeline */ + const expr = current.property as t.Expression; + + /* fast paths for perf-critical hot cases */ + if (t.isStringLiteral(expr)) { + props.unshift(expr.value); + } else if (t.isNumericLiteral(expr)) { + props.unshift(expr.value); } else { - return null; // dynamic key + const lit = literalFromNode(expr, path, opts); + if (lit === null) + throwCodeFrame( + path, + path.opts?.filename, + opts.source ?? path.opts?.source?.code, + '[Zero-UI] Member expression must resolve to a static space-free string.' + ); + + /* ensure we store number vs string correctly */ + const num = Number(lit); + props.unshift(Number.isFinite(num) ? num : lit); } } else { // obj.prop const pn = current.property as t.Identifier; props.unshift(pn.name); - if (/\s/.test(pn.name)) return null; } current = current.object; @@ -259,7 +267,7 @@ export function resolveMemberExpression( path, path.opts?.filename, opts.source ?? path.opts?.source?.code, - `[Zero-UI] Imports Not Allowed: \n\n Inline it or alias to a local const first.` + `[Zero-UI] Imports Not Allowed:\n Inline it or alias to a local const first.` ); } @@ -270,41 +278,43 @@ export function resolveMemberExpression( let value: t.Expression | null | undefined = binding.path.node.init; - /* Traverse the collected property chain */ + /* ── walk the collected props ─────────────────────────── */ for (const key of props) { - // unwrap `... as const` - if (t.isTSAsExpression(value)) { - value = value.expression; - } + if (t.isTSAsExpression(value)) value = value.expression; // unwrap …as const + if (t.isObjectExpression(value)) { - const objLit = value; - value = resolveObjectValue(objLit, String(key)); + value = resolveObjectValue(value, String(key)); } else if (t.isArrayExpression(value) && typeof key === 'number') { value = value.elements[key] as t.Expression | null | undefined; } else { - return null; // chain breaks (not an object/array) + value = null; // chain broke - handled below + break; } - if (!value) return null; } - /* Unwrap x as const once more */ + /* ── NEW: bail-out with an explicit error if nothing was found ───────── */ + if (value == null) { + throwCodeFrame( + path, + path.opts?.filename, + opts.source ?? path.opts?.source?.code, + `[Zero-UI] '${generate(node).code}' cannot be resolved at build-time.\n` + `Only local, fully-static objects/arrays are supported.` + ); + } + /* ─────────────────────────────────────────────────────── */ + + /* existing tail logic (unwrap, recurse, return string)… */ if (t.isTSAsExpression(value)) value = value.expression; - /* If we landed on another member chain, recurse */ if (t.isMemberExpression(value)) { return resolveMemberExpression(value, path, literalFromNode, opts); } - /* Final value must be a space-free string */ - if (t.isTSAsExpression(value)) value = value.expression; - if (t.isStringLiteral(value)) { - return /^\S+$/.test(value.value) ? value.value : null; - } + if (t.isStringLiteral(value)) return value.value; if (t.isTemplateLiteral(value)) { return resolveTemplateLiteral(value, path, literalFromNode, opts); } - - return null; // not a string + return null; } /*──────────────────────────────────────────────────────────*\ From ab76ea33821c75889f81f70aa7833ac5567ed633 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Wed, 2 Jul 2025 16:50:16 -0700 Subject: [PATCH 24/34] Test Suite Pass --- .../fixtures/next/.zero-ui/attributes.d.ts | 16 ++-- .../fixtures/vite/.zero-ui/attributes.d.ts | 16 ++-- packages/core/__tests__/unit/ast.test.cjs | 26 +++++- .../unit/fixtures/ts-test-components.tsx | 11 +-- packages/core/src/postcss/ast-v2.cts | 90 ++++++++++--------- packages/core/src/postcss/resolvers.cts | 23 +++++ 6 files changed, 115 insertions(+), 67 deletions(-) diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts index eb87a3f..be590d5 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts @@ -1,13 +1,13 @@ /* AUTO-GENERATED - DO NOT EDIT */ export declare const bodyAttributes: { - "data-faq": "closed" | "open"; + "data-faq": "closed"; "data-mobile": "false" | "true"; - "data-number": "1" | "2"; - "data-scope": "off" | "on"; - "data-theme": "dark" | "light"; - "data-theme-2": "dark" | "light"; - "data-theme-three": "dark" | "light"; - "data-toggle-boolean": "false" | "true"; - "data-toggle-function": "black" | "white"; + "data-number": "1"; + "data-scope": "off"; + "data-theme": "light"; + "data-theme-2": "light"; + "data-theme-three": "light"; + "data-toggle-boolean": "true"; + "data-toggle-function": "white"; "data-use-effect-theme": "dark" | "light"; }; diff --git a/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts b/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts index eb87a3f..be590d5 100644 --- a/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts +++ b/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts @@ -1,13 +1,13 @@ /* AUTO-GENERATED - DO NOT EDIT */ export declare const bodyAttributes: { - "data-faq": "closed" | "open"; + "data-faq": "closed"; "data-mobile": "false" | "true"; - "data-number": "1" | "2"; - "data-scope": "off" | "on"; - "data-theme": "dark" | "light"; - "data-theme-2": "dark" | "light"; - "data-theme-three": "dark" | "light"; - "data-toggle-boolean": "false" | "true"; - "data-toggle-function": "black" | "white"; + "data-number": "1"; + "data-scope": "off"; + "data-theme": "light"; + "data-theme-2": "light"; + "data-theme-three": "light"; + "data-toggle-boolean": "true"; + "data-toggle-function": "white"; "data-use-effect-theme": "dark" | "light"; }; diff --git a/packages/core/__tests__/unit/ast.test.cjs b/packages/core/__tests__/unit/ast.test.cjs index d11895f..049296b 100644 --- a/packages/core/__tests__/unit/ast.test.cjs +++ b/packages/core/__tests__/unit/ast.test.cjs @@ -144,10 +144,34 @@ test('testKeyInitialValue', async () => { assert.strictEqual(setters[11].initialValue, 'th-blue-th-dark'); assert.strictEqual(setters[12].initialValue, 'th-light'); assert.strictEqual(setters[13].initialValue, 'blue'); - assert.strictEqual(setters[14].initialValue, 'th-light'); }); }); +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 [variant, setVariant] = useUI('variant', 'th-light'); + + + setVariant(isMobile ? VARIANTS.light : 'th-light'); + +} +`, + }, + async () => { + const variants = extractVariants('src/app/Component.jsx'); + console.log('variants: ', variants); + } + ); +}); + test('cache performance', async () => { // Simple inline component with a few useUI hooks, no conflicting constants const simpleComponent = ` diff --git a/packages/core/__tests__/unit/fixtures/ts-test-components.tsx b/packages/core/__tests__/unit/fixtures/ts-test-components.tsx index e645ead..290116e 100644 --- a/packages/core/__tests__/unit/fixtures/ts-test-components.tsx +++ b/packages/core/__tests__/unit/fixtures/ts-test-components.tsx @@ -14,6 +14,8 @@ const MODES = ['auto', 'manual'] as const; const COLORS = { primary: 'blue', secondary: 'green' } as const; const VARIANTS = { dark: `th-${DARK}`, light: COLORS.primary } as const; +const isMobile = false; + /*───────────────────────────────────────────┐ │ Component covering every legal pattern │ └───────────────────────────────────────────*/ @@ -42,16 +44,11 @@ export function AllPatternsComponent() { const [variant6, setVariant6] = useUI('variant', `${VARIANTS.light}-${VARIANTS.dark}`); /* ⑫ BinaryExpression (template + member) */ const [variant7, setVariant7] = useUI('variant', `th-${VARIANTS.light}-${VARIANTS.dark}`); - /* ⑬ Optional-chaining w/ unresolvable member */ + /* ⑬ Optional-chaining w/ unresolvable member should pick th-light */ // @ts-ignore const [variant8, setVariant8] = useUI('variant', VARIANTS?.light.d ?? 'th-light'); /* ⑭ nullish-coalesce */ const [variant9, setVariant9] = useUI('variant', VARIANTS.light ?? 'th-light'); - /* ⑮ Optional-chaining w/ unresolvable member */ - // @ts-ignore - const [variant10, setVariant10] = useUI('variant', VARIANTS.light?.primary ?? 'th-light'); - /* ⑯ local const identifier */ - const [variant11, setVariant11] = useUI('variant', VARIANTS['blue']); /* ── setters exercised in every allowed style ── */ const clickHandler = () => { @@ -70,7 +67,7 @@ export function AllPatternsComponent() { /* array index */ setMode(MODES[1]); - setVariant2(VARIANTS['dark']); + setVariant9(isMobile ? VARIANTS.light : 'th-light'); }; /* conditional toggle with prev-state */ diff --git a/packages/core/src/postcss/ast-v2.cts b/packages/core/src/postcss/ast-v2.cts index 26f8778..10093fc 100644 --- a/packages/core/src/postcss/ast-v2.cts +++ b/packages/core/src/postcss/ast-v2.cts @@ -2,14 +2,14 @@ import { parse, parseExpression } from '@babel/parser'; import * as babelTraverse from '@babel/traverse'; -import { Binding, NodePath } from '@babel/traverse'; +import { Binding, NodePath, Node } from '@babel/traverse'; import * as t from '@babel/types'; import { CONFIG } from '../config.cjs'; import { createHash } from 'crypto'; import { generate } from '@babel/generator'; import * as fs from 'fs'; import * as path from 'path'; -import { literalFromNode } from './resolvers.cjs'; +import { literalFromNode, ResolveOpts } from './resolvers.cjs'; import { codeFrameColumns } from '@babel/code-frame'; const traverse = (babelTraverse as any).default; import { LRUCache as LRU } from 'lru-cache'; @@ -67,14 +67,20 @@ export function collectUseUISetters(ast: t.File, sourceCode: string): SetterMeta const setters: SetterMeta[] = []; /* ---------- cache resolved literals per AST node ---------- */ const memo = new WeakMap(); - const optsBase = { throwOnFail: false, source: sourceCode } as const; - - const lit = (node: t.Expression, path: NodePath, hook: 'stateKey' | 'initialValue' | 'setterName') => { - if (memo.has(node)) return memo.get(node)!; - const v = literalFromNode(node, path, { ...optsBase, hook }); - memo.set(node, v); - return v; - }; + const optsBase = { throwOnFail: false, source: sourceCode } as ResolveOpts; + + function lit(node: t.Expression, p: NodePath, hook: 'stateKey' | 'initialValue' | 'setterName'): string | null { + optsBase.hook = hook; + if (!memo.has(node)) { + const ev = p.evaluate?.(); + if (ev?.confident && typeof ev.value === 'string') { + memo.set(node, ev.value); + } else { + memo.set(node, literalFromNode(node, p, optsBase)); + } + } + return memo.get(node)!; + } traverse(ast, { VariableDeclarator(path: NodePath) { @@ -84,12 +90,13 @@ export function collectUseUISetters(ast: t.File, sourceCode: string): SetterMeta if (!t.isArrayPattern(id) || !t.isCallExpression(init) || !t.isIdentifier(init.callee, { name: CONFIG.HOOK_NAME })) return; if (id.elements.length !== 2) { - throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] useUI() must destructure two values: [value, setter].`); + throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] useUI() must destructure two values: [value, setterFn].`); } const [, setterEl] = id.elements; + if (!setterEl || !t.isIdentifier(setterEl)) { - throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] useUI() setter must be a variable name.`); + throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] useUI() setterFn must be a variable name.`); } const [keyArg, initialArg] = init.arguments; @@ -99,11 +106,11 @@ export function collectUseUISetters(ast: t.File, sourceCode: string): SetterMeta if (stateKey === null) { throwCodeFrame( - path, + keyArg, path.opts?.filename, sourceCode, // TODO add link to docs - `[Zero-UI] State key for ${stateKey} must be a local, space-free string literal. - collectUseUISetters` + `[Zero-UI] State key cannot be resolved at build-time.\n` + `Only local, fully-static strings are supported. - collectUseUISetters-stateKey` ); } @@ -112,11 +119,12 @@ export function collectUseUISetters(ast: t.File, sourceCode: string): SetterMeta if (initialValue === null) { throwCodeFrame( - path, + initialArg, path.opts?.filename, sourceCode, // TODO add link to docs - `[Zero-UI] Initial value for "${stateKey}" must be a local, space-free string literal. - collectUseUISetters` + `[Zero-UI] initial value cannot be resolved at build-time.\n` + + `Only local, fully-static objects/arrays are supported. - collectUseUISetters-initialValue` ); } @@ -172,40 +180,31 @@ export function harvestSetterValues( for (const { binding, stateKey } of setters) { const bucket = variants.get(stateKey)!; - binding.referencePaths.forEach((refPath) => { + for (const refPath of binding.referencePaths) { // Only care about call expressions: setX(...) const call = refPath.parentPath; - if (!call?.isCallExpression() || call.node.callee !== refPath.node) return; + if (!call?.isCallExpression() || call.node.callee !== refPath.node) continue; const arg = call.node.arguments[0] as t.Expression | undefined; - if (!arg) return; + if (!arg) continue; /* ① plain literal / identifier / template / member etc. */ const direct = lit(arg, call); if (direct) { bucket.add(direct); - return; + continue; } /* ② conditional setX(cond ? 'a' : 'b') */ if (t.isConditionalExpression(arg)) { - ['consequent', 'alternate'].forEach((k) => { - const v = lit(k === 'consequent' ? arg.consequent : arg.alternate, call); - if (v) bucket.add(v); - }); - return; + const v1 = lit(arg.consequent, call); + if (v1) bucket.add(v1); + const v2 = lit(arg.alternate, call); + if (v2) bucket.add(v2); + continue; } - /* ③ logical fallback setX(a || 'b') or a ?? 'b' */ - if (t.isLogicalExpression(arg) && (arg.operator === '||' || arg.operator === '??')) { - [arg.left, arg.right].forEach((side) => { - const v = lit(side as t.Expression, call); - if (v) bucket.add(v); - }); - return; - } - - /* ④ arrow / fn body setX(() => 'dark') */ + /* ③ arrow / fn body setX(() => 'dark') */ if ((t.isArrowFunctionExpression(arg) || t.isFunctionExpression(arg)) && arg.body) { const body = t.isBlockStatement(arg.body) ? // grab every `return value` @@ -215,12 +214,12 @@ export function harvestSetterValues( .filter(Boolean) : [arg.body]; - body.forEach((expr) => { + for (const expr of body) { const v = lit(expr as t.Expression, call); if (v) bucket.add(v); - }); + } } - }); + } } return variants; @@ -674,10 +673,15 @@ export function parseJsonWithBabel(source: string): any { return null; } } +export function throwCodeFrame(nodeOrPath: Node | NodePath, filename: string, source: string, msg: string): never { + // Accept either a raw node or a NodePath + const node = (nodeOrPath as NodePath).node ?? (nodeOrPath as Node); + + if (!node.loc) throw new Error(msg); // defensive + + const { start, end } = node.loc; + const header = `${filename ?? ''}:${start.line}:${start.column}\n`; + const frame = codeFrameColumns(source, { start, end }, { highlightCode: true, linesAbove: 1, linesBelow: 1 }); -export function throwCodeFrame(path: NodePath, filename: string, source: string, msg: string): never { - const loc = path.node.loc; - const head = loc ? `${filename}:${loc.start.line}:${loc.start.column}\n` : ''; - const frame = loc ? codeFrameColumns(source, { start: loc.start, end: loc.end }, { highlightCode: true, linesAbove: 1, linesBelow: 1 }) : ''; - throw new Error(`${head}${msg}\n${frame}`); + throw new Error(`${header}${msg}\n${frame}`); } diff --git a/packages/core/src/postcss/resolvers.cts b/packages/core/src/postcss/resolvers.cts index fd7cea7..c851f96 100644 --- a/packages/core/src/postcss/resolvers.cts +++ b/packages/core/src/postcss/resolvers.cts @@ -22,6 +22,10 @@ export interface ResolveOpts { */ export function literalFromNode(node: t.Expression, path: NodePath, opts: ResolveOpts): string | null { + /* ── Fast path via Babel constant-folder ───────────── */ + const ev = fastEval(node, path); + if (ev.confident && typeof ev.value === 'string') return ev.value; + // String / template (no ${}) if (t.isStringLiteral(node)) return node.value; if (t.isTemplateLiteral(node) && node.expressions.length === 0) { @@ -334,3 +338,22 @@ function resolveObjectValue(obj: t.ObjectExpression, key: string): t.Expression } return null; } + +function fastEval(node: t.Expression, path: NodePath) { + // ❶ If the node *is* the current visitor path, we can evaluate directly. + if (node === path.node && (path as any).evaluate) { + return (path as any).evaluate(); // safe, returns {confident, value} + } + + // ❷ Otherwise try to locate a child-path that wraps `node`. + // (Babel exposes .get() only for *named* keys, so we must scan.) + for (const key of Object.keys(path.node)) { + const sub = (path as any).get?.(key); + if (sub?.node === node && sub.evaluate) { + return sub.evaluate(); + } + } + + // ❸ Give up → undefined (caller falls back to manual resolver) + return { confident: false }; +} From b175602c0286e3b6a43a952c9de2115cdca87956 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Sat, 5 Jul 2025 22:21:29 -0600 Subject: [PATCH 25/34] chore: format files --- packages/core/__tests__/e2e/next.spec.js | 2 + packages/core/__tests__/e2e/vite.spec.js | 1 + .../fixtures/next/.zero-ui/attributes.d.ts | 16 +- .../__tests__/fixtures/next/app/layout.tsx | 19 +- .../fixtures/vite/.zero-ui/attributes.d.ts | 16 +- packages/core/__tests__/unit/ast.test.cjs | 8 +- packages/core/package.json | 2 +- packages/core/src/postcss/ast-v2.cts | 234 ++++++++++++++---- packages/core/src/postcss/resolvers.cts | 63 +++-- packages/core/tsconfig.json | 10 +- 10 files changed, 251 insertions(+), 120 deletions(-) diff --git a/packages/core/__tests__/e2e/next.spec.js b/packages/core/__tests__/e2e/next.spec.js index 975d444..4b98eae 100644 --- a/packages/core/__tests__/e2e/next.spec.js +++ b/packages/core/__tests__/e2e/next.spec.js @@ -1,3 +1,5 @@ +/* eslint-disable import/named */ + import { test, expect } from '@playwright/test'; // Define test scenarios with proper expected values diff --git a/packages/core/__tests__/e2e/vite.spec.js b/packages/core/__tests__/e2e/vite.spec.js index 975d444..335d58e 100644 --- a/packages/core/__tests__/e2e/vite.spec.js +++ b/packages/core/__tests__/e2e/vite.spec.js @@ -1,3 +1,4 @@ +/* eslint-disable import/named */ import { test, expect } from '@playwright/test'; // Define test scenarios with proper expected values diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts index be590d5..eb87a3f 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts @@ -1,13 +1,13 @@ /* AUTO-GENERATED - DO NOT EDIT */ export declare const bodyAttributes: { - "data-faq": "closed"; + "data-faq": "closed" | "open"; "data-mobile": "false" | "true"; - "data-number": "1"; - "data-scope": "off"; - "data-theme": "light"; - "data-theme-2": "light"; - "data-theme-three": "light"; - "data-toggle-boolean": "true"; - "data-toggle-function": "white"; + "data-number": "1" | "2"; + "data-scope": "off" | "on"; + "data-theme": "dark" | "light"; + "data-theme-2": "dark" | "light"; + "data-theme-three": "dark" | "light"; + "data-toggle-boolean": "false" | "true"; + "data-toggle-function": "black" | "white"; "data-use-effect-theme": "dark" | "light"; }; diff --git a/packages/core/__tests__/fixtures/next/app/layout.tsx b/packages/core/__tests__/fixtures/next/app/layout.tsx index 5456a59..e9b8664 100644 --- a/packages/core/__tests__/fixtures/next/app/layout.tsx +++ b/packages/core/__tests__/fixtures/next/app/layout.tsx @@ -1,11 +1,14 @@ -import { bodyAttributes } from "@zero-ui/attributes"; +import { bodyAttributes } from '@zero-ui/attributes'; import './globals.css'; -export default function RootLayout({ - children -}) { - return - +export default function RootLayout({ children }) { + return ( + + {children} - ; -} \ No newline at end of file + + ); +} diff --git a/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts b/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts index be590d5..eb87a3f 100644 --- a/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts +++ b/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts @@ -1,13 +1,13 @@ /* AUTO-GENERATED - DO NOT EDIT */ export declare const bodyAttributes: { - "data-faq": "closed"; + "data-faq": "closed" | "open"; "data-mobile": "false" | "true"; - "data-number": "1"; - "data-scope": "off"; - "data-theme": "light"; - "data-theme-2": "light"; - "data-theme-three": "light"; - "data-toggle-boolean": "true"; - "data-toggle-function": "white"; + "data-number": "1" | "2"; + "data-scope": "off" | "on"; + "data-theme": "dark" | "light"; + "data-theme-2": "dark" | "light"; + "data-theme-three": "dark" | "light"; + "data-toggle-boolean": "false" | "true"; + "data-toggle-function": "black" | "white"; "data-use-effect-theme": "dark" | "light"; }; diff --git a/packages/core/__tests__/unit/ast.test.cjs b/packages/core/__tests__/unit/ast.test.cjs index 049296b..4567ee3 100644 --- a/packages/core/__tests__/unit/ast.test.cjs +++ b/packages/core/__tests__/unit/ast.test.cjs @@ -157,17 +157,21 @@ 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'); console.log('variants: ', variants); + assert.strictEqual(variants.length, 3); } ); }); @@ -199,6 +203,8 @@ test('cache performance', async () => { /* ⑦ object-member */ const [variant2, setVariant2] = useUI('variant', VARIANTS.dark); + + return
setTheme('dark')}>Test
; } `; diff --git a/packages/core/package.json b/packages/core/package.json index 1f491e9..204184f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -93,4 +93,4 @@ "@types/react": "^19.1.8", "lru-cache": "^10.4.3" } -} \ No newline at end of file +} diff --git a/packages/core/src/postcss/ast-v2.cts b/packages/core/src/postcss/ast-v2.cts index 10093fc..a172188 100644 --- a/packages/core/src/postcss/ast-v2.cts +++ b/packages/core/src/postcss/ast-v2.cts @@ -69,17 +69,17 @@ export function collectUseUISetters(ast: t.File, sourceCode: string): SetterMeta const memo = new WeakMap(); const optsBase = { throwOnFail: false, source: sourceCode } as ResolveOpts; - function lit(node: t.Expression, p: NodePath, hook: 'stateKey' | 'initialValue' | 'setterName'): string | null { - optsBase.hook = hook; - if (!memo.has(node)) { - const ev = p.evaluate?.(); - if (ev?.confident && typeof ev.value === 'string') { - memo.set(node, ev.value); - } else { - memo.set(node, literalFromNode(node, p, optsBase)); - } - } - return memo.get(node)!; + function lit(node: t.Expression, p: NodePath, hook: ResolveOpts['hook']): string | null { + if (memo.has(node)) return memo.get(node)!; + + // clone instead of mutate + const localOpts: ResolveOpts = { ...optsBase, hook }; + + const ev = (p as NodePath).evaluate?.(); + const value = ev?.confident && typeof ev.value === 'string' ? ev.value : literalFromNode(node, p, localOpts); + + memo.set(node, value); + return value; } traverse(ast, { @@ -148,81 +148,205 @@ export interface VariantData { /** Literal initial value as string, or `null` if non-literal */ initialValue: string | null; } - -/** - * Pass 2: Harvest all values from setter calls by examining their reference paths - * @param setters - Array of SetterMeta from Pass 1 - * @returns Map of stateKey -> Set of discovered values - */ +// ──────────────────────────────────────────────────────────── +// Pass 2 – harvest every literal that can possibly flow +// through a setter call ​setX(...) +// ──────────────────────────────────────────────────────────── export function harvestSetterValues( setters: SetterMeta[], - fileSource: string // extra so we can throw frames + fileSource: string // for code-frame errors ): Map> { - /* ---------- bootstrap with initial values ---------- */ + /* 1 ─ bootstrap with the initial values found in Pass 1 ───────── */ const variants = new Map>(); for (const { stateKey, initialValue } of setters) { if (!variants.has(stateKey)) variants.set(stateKey, new Set()); if (initialValue) variants.get(stateKey)!.add(initialValue); } - /* ---------- cache resolved literals per AST node ---------- */ + /* 2 ─ one-shot memoised literal resolver ──────────────────────── */ const memo = new WeakMap(); - const optsBase = { throwOnFail: false, source: fileSource, hook: 'setterName' } as const; + const litOpt = { throwOnFail: false, source: fileSource, hook: 'setterName' } as const; const lit = (node: t.Expression, path: NodePath) => { if (memo.has(node)) return memo.get(node)!; - const v = literalFromNode(node, path, optsBase); + const v = literalFromNode(node, path, litOpt); memo.set(node, v); return v; }; - /* ---------- walk every setter reference ---------- */ + /* 3 ─ helper: extract every literal hidden in an *expression* ─── */ + function extract(expr: t.Expression, p: NodePath, bucket: Set) { + /* direct literal / identifier / member / template */ + const vDir = lit(expr, p); + if (vDir) { + bucket.add(vDir); + return; + } + + /* ternary cond ? x : y */ + if (t.isConditionalExpression(expr)) { + extract(expr.consequent, p, bucket); + extract(expr.alternate, p, bucket); + return; + } + + /* logical a || b a ?? b */ + if (t.isLogicalExpression(expr) && (expr.operator === '||' || expr.operator === '??')) { + extract(expr.left, p, bucket); + extract(expr.right, p, bucket); + return; + } + + /* binary 'a' + 'b' (only +) */ + if (t.isBinaryExpression(expr) && expr.operator === '+') { + extract(expr.left as t.Expression, p, bucket); + extract(expr.right as t.Expression, p, bucket); + return; + } + + /* nothing else produces compile-time strings */ + } + + /* 4 ─ walk every reference (call-site) of each setter ─────────── */ for (const { binding, stateKey } of setters) { const bucket = variants.get(stateKey)!; - for (const refPath of binding.referencePaths) { - // Only care about call expressions: setX(...) - const call = refPath.parentPath; - if (!call?.isCallExpression() || call.node.callee !== refPath.node) continue; + binding.referencePaths.forEach((ref) => { + const call = ref.parentPath; + if (!call?.isCallExpression() || call.node.callee !== ref.node) return; const arg = call.node.arguments[0] as t.Expression | undefined; - if (!arg) continue; + if (!arg) return; - /* ① plain literal / identifier / template / member etc. */ - const direct = lit(arg, call); - if (direct) { - bucket.add(direct); - continue; + /* 4-a ▸ INLINE () => … function() {…} ------------------ */ + if (t.isArrowFunctionExpression(arg) || t.isFunctionExpression(arg)) { + consumeFunctionBody(arg, call, bucket); + return; } - /* ② conditional setX(cond ? 'a' : 'b') */ - if (t.isConditionalExpression(arg)) { - const v1 = lit(arg.consequent, call); - if (v1) bucket.add(v1); - const v2 = lit(arg.alternate, call); - if (v2) bucket.add(v2); - continue; - } + /* 4-b ▸ IDENTIFIER setX(toggle) -------------------------- */ + if (t.isIdentifier(arg)) { + const bind = call.scope.getBinding(arg.name); + if (!bind) return; // unresolved → ignore - /* ③ arrow / fn body setX(() => 'dark') */ - if ((t.isArrowFunctionExpression(arg) || t.isFunctionExpression(arg)) && arg.body) { - const body = t.isBlockStatement(arg.body) - ? // grab every `return value` - arg.body.body - .filter((ret) => t.isReturnStatement(ret)) - .map((ret) => ret.argument) - .filter(Boolean) - : [arg.body]; - - for (const expr of body) { - const v = lit(expr as t.Expression, call); - if (v) bucket.add(v); + // ‼️ imported function ‼️ + if (bind.path.isImportSpecifier() || bind.path.isImportDefaultSpecifier() || bind.path.isImportNamespaceSpecifier()) { + throwCodeFrame(call, call.opts?.filename, fileSource, `[Zero-UI] Setter functions must be defined locally – ` + `“${arg.name}” is imported.`); } + + // variable like const toggle = () => 'dark' + if ( + bind.path.isVariableDeclarator() && + bind.path.node.init && + (t.isArrowFunctionExpression(bind.path.node.init) || t.isFunctionExpression(bind.path.node.init)) + ) { + consumeFunctionBody(bind.path.node.init, bind.path, bucket); + return; + } + + // plain identifier that eventually resolves to a literal + const v = lit(arg, call); + if (v) bucket.add(v); + return; } - } + + /* 4-c ▸ everything else (direct literals, ternaries, …) ----- */ + extract(arg, call, bucket); + }); } return variants; + + /* ───────── local helper ── visits every `return …` ─────────── */ + function consumeFunctionBody(fn: t.ArrowFunctionExpression | t.FunctionExpression, p: NodePath, set: Set) { + if (t.isBlockStatement(fn.body)) { + fn.body.body.forEach((stmt) => { + if (t.isReturnStatement(stmt) && stmt.argument) { + extract(stmt.argument as t.Expression, p, set); + } + }); + } else { + // concise body () => expr + extract(fn.body as t.Expression, p, set); + } + } +} + +/** + * Recursively collect every *string literal value* that can be proven at + * build-time inside `expr` (ternaries, logical fallbacks, arrow & fn bodies …). + * + * memo-ized per-node → O(N) over the whole file. + */ +export function gatherLiterals( + expr: t.Expression, + path: NodePath, + opts: ResolveOpts, + /** WeakMap cache seeded once per file */ + memo: WeakMap +): string[] { + /* ─── memo fast path ──────────────────────────────── */ + if (memo.has(expr)) return memo.get(expr)!; + + /* ─── confident string via Babel-s partial evaluator ─*/ + const evalRes = path.evaluate?.() ?? { confident: false }; + if (evalRes.confident && typeof evalRes.value === 'string') { + memo.set(expr, [evalRes.value]); + return [evalRes.value]; + } + + /* ─── try single-step literal resolver ────────────── */ + const lit = literalFromNode(expr, path, opts); + if (lit !== null) { + memo.set(expr, [lit]); + return [lit]; + } + + /* ─── recursive syntactic cases ─────────────────────*/ + let out: string[] = []; + + /* 1. a ? b : c */ + if (t.isConditionalExpression(expr)) { + out = [...gatherLiterals(expr.consequent, path, opts, memo), ...gatherLiterals(expr.alternate, path, opts, memo)]; + } else if (t.isLogicalExpression(expr) && (expr.operator === '||' || expr.operator === '??')) { + /* 2. a || b or a ?? b */ + out = [...gatherLiterals(expr.left, path, opts, memo), ...gatherLiterals(expr.right, path, opts, memo)]; + } else if (t.isBinaryExpression(expr, { operator: '+' })) { + /* 3. 'a' + 'b' (pure string concatenation) */ + const left = gatherLiterals(expr.left as t.Expression, path, opts, memo); + const right = gatherLiterals(expr.right as t.Expression, path, opts, memo); + // concat only when each side collapses to exactly ONE literal + if (left.length === 1 && right.length === 1) { + out = [left[0] + right[0]]; + } + } else if ((t.isArrowFunctionExpression(expr) || t.isFunctionExpression(expr)) && expr.body) { + /* 4. () => 'x' / prev => prev==='a'?'b':'a' */ + const bodies: t.Expression[] = []; + + if (t.isExpression(expr.body)) { + bodies.push(expr.body); + } else if (t.isBlockStatement(expr.body)) { + // every `return ` + expr.body.body.forEach((stmt) => { + if (t.isReturnStatement(stmt) && stmt.argument) { + bodies.push(stmt.argument as t.Expression); + } + }); + } + + for (const b of bodies) { + out.push(...gatherLiterals(b, path, opts, memo)); + } + } else if (t.isSequenceExpression(expr)) { + /* 5. SequenceExpression (rare but cheap to support) */ + expr.expressions.forEach((e) => { + out.push(...gatherLiterals(e as t.Expression, path, opts, memo)); + }); + } + + /* ─── finalise ───────────────────────────────────────*/ + memo.set(expr, out); + return out; } /** diff --git a/packages/core/src/postcss/resolvers.cts b/packages/core/src/postcss/resolvers.cts index c851f96..7c835ab 100644 --- a/packages/core/src/postcss/resolvers.cts +++ b/packages/core/src/postcss/resolvers.cts @@ -56,11 +56,31 @@ export function literalFromNode(node: t.Expression, path: NodePath, opts return resolveTemplateLiteral(node, path, literalFromNode, opts); } - if (t.isMemberExpression(node)) { - return resolveMemberExpression(node, path, literalFromNode, opts); + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + // treat optional-member exactly the same + return resolveMemberExpression(node as t.MemberExpression, path, literalFromNode, opts); } - return null; // everything else is illegal + return null; +} + +function fastEval(node: t.Expression, path: NodePath) { + // ❶ If the node *is* the current visitor path, we can evaluate directly. + if (node === path.node && (path as any).evaluate) { + return (path as any).evaluate(); // safe, returns {confident, value} + } + + // ❷ Otherwise try to locate a child-path that wraps `node`. + // (Babel exposes .get() only for *named* keys, so we must scan.) + for (const key of Object.keys(path.node)) { + const sub = (path as any).get?.(key); + if (sub?.node === node && sub.evaluate) { + return sub.evaluate(); + } + } + + // ❸ Give up → undefined (caller falls back to manual resolver) + return { confident: false }; } /*──────────────────────────────────────────────────────────*\ @@ -225,12 +245,12 @@ export function resolveMemberExpression( let current: t.Expression | t.PrivateName = node; // Walk up until we hit the root Identifier - while (t.isMemberExpression(current)) { - if (current.computed) { - /** Any *expression* inside `[]` → run through the same pipeline */ - const expr = current.property as t.Expression; + while (t.isMemberExpression(current) || t.isOptionalMemberExpression(current)) { + const mem = current as t.MemberExpression; // ← common shape - /* fast paths for perf-critical hot cases */ + if (mem.computed) { + const expr = mem.property as t.Expression; + // fast paths … if (t.isStringLiteral(expr)) { props.unshift(expr.value); } else if (t.isNumericLiteral(expr)) { @@ -245,17 +265,15 @@ export function resolveMemberExpression( '[Zero-UI] Member expression must resolve to a static space-free string.' ); - /* ensure we store number vs string correctly */ const num = Number(lit); props.unshift(Number.isFinite(num) ? num : lit); } } else { - // obj.prop - const pn = current.property as t.Identifier; - props.unshift(pn.name); + const id = mem.property as t.Identifier; + props.unshift(id.name); } - current = current.object; + current = mem.object; } /* current should now be the base Identifier */ @@ -338,22 +356,3 @@ function resolveObjectValue(obj: t.ObjectExpression, key: string): t.Expression } return null; } - -function fastEval(node: t.Expression, path: NodePath) { - // ❶ If the node *is* the current visitor path, we can evaluate directly. - if (node === path.node && (path as any).evaluate) { - return (path as any).evaluate(); // safe, returns {confident, value} - } - - // ❷ Otherwise try to locate a child-path that wraps `node`. - // (Babel exposes .get() only for *named* keys, so we must scan.) - for (const key of Object.keys(path.node)) { - const sub = (path as any).get?.(key); - if (sub?.node === node && sub.evaluate) { - return sub.evaluate(); - } - } - - // ❸ Give up → undefined (caller falls back to manual resolver) - return { confident: false }; -} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 4c20535..ea9aa67 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,12 +1,8 @@ { "extends": "../../tsconfig.base.json", /* — compile exactly one file — */ - "include": [ - "src/**/*" - ], - "exclude": [ - "src/postcss/coming-soon" - ], + "include": ["src/**/*"], + "exclude": ["src/postcss/coming-soon"], /* — compiler output — */ "compilerOptions": { "target": "ES2020", @@ -21,4 +17,4 @@ "strict": true, // enable all strict type-checking options "skipLibCheck": true // Hides all errors coming from node_modules } -} \ No newline at end of file +} From cfc37835c9616eca56da45bd2ecfac75405ec2ea Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Sat, 5 Jul 2025 22:25:26 -0600 Subject: [PATCH 26/34] feat:write internal doc on babel compiler --- packages/core/src/postcss/resolvers.cts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/postcss/resolvers.cts b/packages/core/src/postcss/resolvers.cts index 7c835ab..1303afd 100644 --- a/packages/core/src/postcss/resolvers.cts +++ b/packages/core/src/postcss/resolvers.cts @@ -20,7 +20,6 @@ export interface ResolveOpts { * @param path - The path to the node * @returns The string literal or null if the node is not a string literal or template literal with no expressions or identifier bound to local const */ - export function literalFromNode(node: t.Expression, path: NodePath, opts: ResolveOpts): string | null { /* ── Fast path via Babel constant-folder ───────────── */ const ev = fastEval(node, path); From 1c051904d60cad5c23fe079305967624427f5b30 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Mon, 7 Jul 2025 16:19:01 -0700 Subject: [PATCH 27/34] added babel doc --- .gitignore | 2 - docs/assets/internal.md | 146 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 docs/assets/internal.md diff --git a/.gitignore b/.gitignore index 8f21f24..2338da7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,6 @@ yarn-error.log* # tarballs produced during local tests *.tgz -# local scratch file -internal.md # keep these files !next-env.d.ts diff --git a/docs/assets/internal.md b/docs/assets/internal.md new file mode 100644 index 0000000..2d8b36f --- /dev/null +++ b/docs/assets/internal.md @@ -0,0 +1,146 @@ +Below is a **“mental model”** of the Zero-UI variant extractor. distilled so that *another* human (or LLM) can reason about, extend, or safely refactor the code-base. + +--- + +## 1. Top-level goal + +* **Locate every call** to a user-supplied React hook + + ```js + const [value, setterFn] = useUI('stateKey', 'initialValue') + ``` +* Statically discover **all possible string values** that flow into + `stateKey`, `initialValue`, and `setterFn()` arguments. + * `stateKey` can resolve to a local static string. + * `initialValue` is the same rule as above. + * `setterFn()` argument is many forms allowed (see table 3.1) but **must be resolvable**; otherwise the value is ignored (silent) *unless* it looked resolvable but failed inside the helpers, in which case a targeted error is thrown. + * Imported bindings are never allowed - the dev must re-cast them through a local `const`. + + +* Report the result as a list of `VariantData` objects. + +```ts +type VariantData = { + key: string; // 'stateKey' + values: string[]; // ['light','dark',…] (unique, sorted) + initialValue: string; // from 2nd arg of useUI() +} +``` + +--- + +## 2. Two-pass pipeline + +| Pass | File-scope work | Output | +| --- | --- | --- | +| **Pass 1 - `collectUseUISetters`** | 1. Traverse the AST once.
2. For each `useUI()` destructuring:
• validate shapes & count.
• resolve the **state key** and **initial value** with **`literalFromNode`** (see rules below).
• grab the **binding** of the setter variable. | `SetterMeta[]` = `{ binding, setterName, stateKey, initialValue }[]` | +| **Pass 2 - `harvestSetterValues`** | 1. For every `binding.referencePaths` (i.e. every place the setter is used)
2. Only keep `CallExpression`s: `setX(…)`
3. Examine the first argument:
• direct literal / identifier / template → resolve via `literalFromNode`.
• conditional `cond ? a : b` → resolve both arms.
• logical fallback `a \|\| b`, `a ?? b` → resolve each side.
   arrow / function bodies → collect every returned expression and resolve.
4. Add every successfully-resolved string to a `Set` bucket **per stateKey**. | `Map< stateKey, Set >` | + +`normalizeVariants` just converts that map back into the +`VariantData[]` shape (keeping initial values, sorting, etc.). + +--- + +## 3. The **literal-resolution micro-framework** + +Everything funnels through **`literalFromNode`**. +Think of it as a deterministic *static evaluator* restricted to a +*very* small grammar. + +### 3.1 Supported input forms (setterFn()) + +``` +┌────────────────────────────────┬─────────────────────────┐ +│ Expression │ Accepted? → Returns │ +├────────────────────────────────┼─────────────────────────┤ +│ dark │ ✔ string literal │ +│ `th-${COLOR}` │ ✔ if every `${}`resolves│ +│ const DARK │ ✔ if IDENT → const str │ +│ THEMES.dark/[idx]/?. │ ✔ if the whole chain is │ +│ │ top-level `const` │ +│ "a" + "b" │ ✔ "ab" │ +│ a ?? b or a || b │ ✔ Attempts to resolve │ +│ │ both sides │ +│prev =>(prev=== 'a' ? 'b' : 'a')│ "a" ,"b" │ +│ Anything imported │ ❌ Hard error │ +│ Anything dynamic at runtime │ ❌ Returns null / error │ +└────────────────────────────────┴─────────────────────────┘ +``` + +### 3.2 Helpers + +| Helper | Job | +| --- | --- | +| **`resolveTemplateLiteral`** | Ensures every `${expr}` itself resolves to a string via `literalFromNode`.| +| **`resolveLocalConstIdentifier`** | Maps an `Identifier` → its `const` initializer *if* that initializer is an accepted string/ template. Rejects imported bindings with a *single* descriptive error.| +| **`resolveMemberExpression`** | Static walk of `obj.prop`, `obj['prop']`, `obj?.prop`, etc. Works through `as const`, optional-chaining, arrays, numbers, nested chains. Throws if any hop can't be resolved. | +| **`literalFromNode`** | Router that calls the above; memoised (`WeakMap`) so each AST node is evaluated once.| + +All helpers accept `opts:{ throwOnFail, source, hook }` so *contextual* +error messages can be emitted with **`throwCodeFrame`** +(using `@babel/code-frame` to show a coloured snippet). + +--- + +## 4. Validation rules (why errors occur) + +| Position in `useUI` | Allowed value | Example error | +| --- | --- | --- | +| **stateKey (arg 0)** | *Local* static string | `State key cannot be resolved at build-time.` | +| **initialValue (arg 1)** | Same rule as above. | `Initial value cannot be resolved …` | +| **setter argument** | Many forms allowed (see table 3.1) but **must be resolvable**; otherwise the value is ignored (silent) *unless* it looked resolvable but failed inside the helpers, in which case a targeted error is thrown. | | + +Imported bindings are never allowed - the dev must re-cast them +through a local `const`. + +--- + +## 5. Optional-chain & optional-member details + +* The updated `resolveMemberExpression` loop iterates while + `isMemberExpression || isOptionalMemberExpression`. +* Inside array/obj traversal it throws a clear error if a link is + missing instead of silently returning `null`. +* `props` collects mixed `string | number` keys in **reverse** (deep → shallow) order, so they can be replayed from the root identifier outward. + +--- + +## 6. Performance enhancements + +* **Memoisation** (`WeakMap`) across *both* passes. +* **Quick literals** - string & number keys handled without extra calls. +* `throwCodeFrame` (and thus `generate(node).code`) runs **only** on + failing branches. +* A small **LRU file-cache** (<5k entries) avoids re-parsing unchanged + files (mtime + size signature, with hash fallback). + +--- + +## 7. What is **not** supported + +* Runtime-only constructs (`import.meta`, env checks, dynamic imports …). +* Cross-file constant propagation - the extractor is intentionally + single-file to keep the build independent of user bundler config. +* Non-string variants (numbers, booleans) - strings only. +* Private class fields in member chains. +* Setter arguments that are **imported functions**. + +--- + +## 8. How to extend + +* **Add more expression kinds**: extend `literalFromNode` with new + cases *and* unit-test them. +* **Cross-file constants**: in `resolveLocalConstIdentifier`, detect + `ImportSpecifier`, read & parse the target file, then recurse - but + beware performance. +* **Boolean / number variants**: relax `literalToString` and adjust + variant schema. + +--- + +> **In one sentence**: +> The extractor turns *purely static, in-file JavaScript* around `useUI` +> into a deterministic list of variant strings, throwing early and with +> helpful frames whenever something would otherwise need runtime +> evaluation. From 8ac85e183742670347ff5221b5096a0d33706c66 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Tue, 8 Jul 2025 12:52:52 -0700 Subject: [PATCH 28/34] unit tests written --- .../fixtures/next/.zero-ui/attributes.d.ts | 6 +- .../__tests__/fixtures/next/app/layout.tsx | 19 +- .../fixtures/vite/.zero-ui/attributes.d.ts | 6 +- .../__tests__/fixtures/vite/vite.config.ts | 2 +- packages/core/__tests__/unit/ast.test.cjs | 16 +- packages/core/__tests__/unit/index.test.cjs | 72 +- packages/core/package.json | 13 +- packages/core/src/cli/postInstall.cts | 6 +- packages/core/src/config.cts | 6 +- packages/core/src/postcss/ast-generating.cts | 355 ++++++++ .../core/src/postcss/ast-generating.test.ts | 91 ++ packages/core/src/postcss/ast-parsing.cts | 236 +++++ packages/core/src/postcss/ast-parsing.test.ts | 64 ++ packages/core/src/postcss/ast-v2.cts | 811 ------------------ .../src/postcss/coming-soon/build-tool.ts | 6 +- .../src/postcss/coming-soon/collect-refs.cts | 2 +- .../postcss/coming-soon/inject-attributes.ts | 2 +- packages/core/src/postcss/helpers.cts | 127 ++- packages/core/src/postcss/helpers.test.ts | 293 +++++++ packages/core/src/postcss/resolvers.cts | 2 +- packages/core/src/postcss/scanner.cts | 37 + packages/core/src/postcss/scanner.test.ts | 17 + packages/core/src/utilities.ts | 31 + packages/core/tsconfig.build.json | 26 + packages/core/tsconfig.json | 44 +- pnpm-lock.yaml | 267 +++--- 26 files changed, 1420 insertions(+), 1137 deletions(-) create mode 100644 packages/core/src/postcss/ast-generating.cts create mode 100644 packages/core/src/postcss/ast-generating.test.ts create mode 100644 packages/core/src/postcss/ast-parsing.cts create mode 100644 packages/core/src/postcss/ast-parsing.test.ts delete mode 100644 packages/core/src/postcss/ast-v2.cts create mode 100644 packages/core/src/postcss/helpers.test.ts create mode 100644 packages/core/src/postcss/scanner.cts create mode 100644 packages/core/src/postcss/scanner.test.ts create mode 100644 packages/core/src/utilities.ts create mode 100644 packages/core/tsconfig.build.json diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts index eb87a3f..f043994 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts @@ -4,10 +4,10 @@ export declare const bodyAttributes: { "data-mobile": "false" | "true"; "data-number": "1" | "2"; "data-scope": "off" | "on"; - "data-theme": "dark" | "light"; + "data-theme": "dark" | "light" | "three-dark" | "three-light"; "data-theme-2": "dark" | "light"; - "data-theme-three": "dark" | "light"; + "data-theme-three": "light"; "data-toggle-boolean": "false" | "true"; - "data-toggle-function": "black" | "white"; + "data-toggle-function": "black" | "blue" | "green" | "red" | "white"; "data-use-effect-theme": "dark" | "light"; }; diff --git a/packages/core/__tests__/fixtures/next/app/layout.tsx b/packages/core/__tests__/fixtures/next/app/layout.tsx index e9b8664..5456a59 100644 --- a/packages/core/__tests__/fixtures/next/app/layout.tsx +++ b/packages/core/__tests__/fixtures/next/app/layout.tsx @@ -1,14 +1,11 @@ -import { bodyAttributes } from '@zero-ui/attributes'; +import { bodyAttributes } from "@zero-ui/attributes"; import './globals.css'; -export default function RootLayout({ children }) { - return ( - - +export default function RootLayout({ + children +}) { + return + {children} - - ); -} + ; +} \ No newline at end of file diff --git a/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts b/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts index eb87a3f..f043994 100644 --- a/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts +++ b/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts @@ -4,10 +4,10 @@ export declare const bodyAttributes: { "data-mobile": "false" | "true"; "data-number": "1" | "2"; "data-scope": "off" | "on"; - "data-theme": "dark" | "light"; + "data-theme": "dark" | "light" | "three-dark" | "three-light"; "data-theme-2": "dark" | "light"; - "data-theme-three": "dark" | "light"; + "data-theme-three": "light"; "data-toggle-boolean": "false" | "true"; - "data-toggle-function": "black" | "white"; + "data-toggle-function": "black" | "blue" | "green" | "red" | "white"; "data-use-effect-theme": "dark" | "light"; }; diff --git a/packages/core/__tests__/fixtures/vite/vite.config.ts b/packages/core/__tests__/fixtures/vite/vite.config.ts index 3b300ec..1d610da 100644 --- a/packages/core/__tests__/fixtures/vite/vite.config.ts +++ b/packages/core/__tests__/fixtures/vite/vite.config.ts @@ -4,5 +4,5 @@ import react from '@vitejs/plugin-react'; // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), zeroUI()] + plugins: [zeroUI(), react()] }); \ No newline at end of file diff --git a/packages/core/__tests__/unit/ast.test.cjs b/packages/core/__tests__/unit/ast.test.cjs index 4567ee3..ab552ca 100644 --- a/packages/core/__tests__/unit/ast.test.cjs +++ b/packages/core/__tests__/unit/ast.test.cjs @@ -5,7 +5,7 @@ const path = require('path'); const os = require('os'); const { performance } = require('node:perf_hooks'); const { findAllSourceFiles } = require('../../dist/postcss/helpers.cjs'); -const { collectUseUISetters, extractVariants } = require('../../dist/postcss/ast-v2.cjs'); +const { collectUseUISetters, 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')); @@ -70,7 +70,7 @@ test('collectUseUISetters - basic functionality', async () => { const [theme, setTheme] = useUI('theme', 'light'); const [size, setSize] = useUI('size', 'medium'); return <> - - + +
); } @@ -105,9 +105,13 @@ export function ComponentSimple() { async () => { const variants = extractVariants('src/app/Component.jsx'); + console.log('variants: ', variants); + assert.strictEqual(variants.length, 2); assert.strictEqual(variants.length, 2); - assert.ok(variants.some((v) => v.key === 'theme' && v.values.includes('light'))); - assert.ok(variants.some((v) => v.key === 'size' && v.values.includes('medium'))); + + 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)))); } ); }); diff --git a/packages/core/__tests__/unit/index.test.cjs b/packages/core/__tests__/unit/index.test.cjs index 5b18d8e..1705a41 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 { patchConfigAlias, toKebabCase, patchPostcssConfig, patchViteConfig } = require('../../dist/postcss/helpers.cjs'); +const { patchTsConfig, toKebabCase, patchPostcssConfig, patchViteConfig } = require('../../dist/postcss/helpers.cjs'); function getAttrFile() { return path.join(process.cwd(), '.zero-ui', 'attributes.js'); @@ -68,7 +68,6 @@ test('generates body attributes file correctly', async () => { // Read and parse attributes const content = fs.readFileSync(getAttrFile(), 'utf-8'); console.log('\n📄 Generated attributes file:'); - console.log(content); // Verify content assert(content.includes('export const bodyAttributes'), 'Should export bodyAttributes'); @@ -104,7 +103,6 @@ test('generates body attributes file correctly when kebab-case is used', async ( assert(fs.existsSync(getAttrFile()), 'Attributes file should exist'); const content = fs.readFileSync(getAttrFile(), 'utf-8'); - console.log('result.css: ', result.css); // Verify content assert(content.includes('export const bodyAttributes'), 'Should export bodyAttributes'); @@ -318,9 +316,9 @@ test('handles multiple files and deduplication', async () => { ); }); -test('handles parsing errors gracefully', async () => { - await runTest( - { +test('throws on invalid syntax', async () => { + await assert.rejects(async () => { + await runTest({ 'src/valid.jsx': ` import { useUI } from '@react-zero-ui/core'; function Valid() { @@ -335,16 +333,8 @@ test('handles parsing errors gracefully', async () => { {{{ invalid syntax } `, - }, - (result) => { - console.log('\n🔍 Parse Error Test:'); - // Should still process valid files - assert(result.css.includes('@custom-variant valid-working'), 'Should process valid files'); - - // Should not crash on invalid files - assert(result.css.includes('AUTO-GENERATED'), 'Should complete processing'); - } - ); + }); + }, /Unexpected token, expected ","/); }); test('throws on empty string initial value', () => { @@ -559,7 +549,7 @@ test('handles concurrent file modifications', async () => { ); }); -test('patchConfigAlias - config file patching', async (t) => { +test('patchTsConfig - config file patching', async (t) => { await t.test('patches tsconfig.json when it exists', async () => { const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zero-ui-config-test')); const originalCwd = process.cwd(); @@ -571,8 +561,8 @@ test('patchConfigAlias - config file patching', async (t) => { const tsconfigContent = { compilerOptions: { target: 'ES2015', module: 'ESNext' } }; fs.writeFileSync('tsconfig.json', JSON.stringify(tsconfigContent, null, 2)); - // Run patchConfigAlias - patchConfigAlias(); + // Run patchTsConfig + patchTsConfig(); // Read the updated config const updatedConfig = JSON.parse(fs.readFileSync('tsconfig.json', 'utf-8')); @@ -600,8 +590,8 @@ test('patchConfigAlias - config file patching', async (t) => { const jsconfigContent = { compilerOptions: { target: 'ES2015' } }; fs.writeFileSync('jsconfig.json', JSON.stringify(jsconfigContent, null, 2)); - // Run patchConfigAlias - patchConfigAlias(); + // Run patchTsConfig + patchTsConfig(); // Read the updated config const updatedConfig = JSON.parse(fs.readFileSync('jsconfig.json', 'utf-8')); @@ -626,8 +616,8 @@ test('patchConfigAlias - config file patching', async (t) => { try { process.chdir(testDir); - // Run patchConfigAlias (should not throw) - patchConfigAlias(); + // Run patchTsConfig (should not throw) + patchTsConfig(); // Verify no files were created assert(!fs.existsSync('tsconfig.json'), 'Should not create tsconfig.json'); @@ -656,8 +646,8 @@ test('patchConfigAlias - config file patching', async (t) => { fs.writeFileSync('tsconfig.json', JSON.stringify(tsconfigContent, null, 2)); const originalContent = fs.readFileSync('tsconfig.json', 'utf-8'); - // Run patchConfigAlias - patchConfigAlias(); + // Run patchTsConfig + patchTsConfig(); // Verify the config was not modified const updatedContent = fs.readFileSync('tsconfig.json', 'utf-8'); @@ -679,8 +669,8 @@ test('patchConfigAlias - config file patching', async (t) => { const tsconfigContent = { include: ['src/**/*'] }; fs.writeFileSync('tsconfig.json', JSON.stringify(tsconfigContent, null, 2)); - // Run patchConfigAlias - patchConfigAlias(); + // Run patchTsConfig + patchTsConfig(); // Read the updated config const updatedConfig = JSON.parse(fs.readFileSync('tsconfig.json', 'utf-8')); @@ -717,8 +707,8 @@ test('patchConfigAlias - config file patching', async (t) => { }`; fs.writeFileSync('tsconfig.json', tsconfigContent); - // Run patchConfigAlias - patchConfigAlias(); + // Run patchTsConfig + patchTsConfig(); // Verify file was updated (should parse despite comments) const updatedConfig = JSON.parse(fs.readFileSync('tsconfig.json', 'utf-8')); @@ -733,7 +723,7 @@ test('patchConfigAlias - config file patching', async (t) => { } }); - await t.test('patchConfigAlias prefers tsconfig.json over jsconfig.json', async () => { + await t.test('patchTsConfig prefers tsconfig.json over jsconfig.json', async () => { const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zero-ui-config-test')); const originalCwd = process.cwd(); @@ -744,8 +734,8 @@ test('patchConfigAlias - config file patching', async (t) => { fs.writeFileSync('tsconfig.json', JSON.stringify({ compilerOptions: {} }, null, 2)); fs.writeFileSync('jsconfig.json', JSON.stringify({ compilerOptions: {} }, null, 2)); - // Run patchConfigAlias - patchConfigAlias(); + // Run patchTsConfig + patchTsConfig(); // Verify tsconfig.json was modified const tsconfigContent = JSON.parse(fs.readFileSync('tsconfig.json', 'utf-8')); @@ -1464,7 +1454,6 @@ test('generated variants for initial value without setterFn', async () => { }, (result) => { console.log('\n📄 Initial value without setterFn:'); - console.log(result.css); assert(result.css.includes('@custom-variant theme-light')); } @@ -1491,9 +1480,8 @@ test('handles complex string boolean toggle patterns', async () => { `, }, (result) => { - const content = fs.readFileSync(getAttrFile(), 'utf-8'); + // const content = fs.readFileSync(getAttrFile(), 'utf-8'); console.log('\n📄 String boolean edge cases:'); - console.log(content); // Should only have true/false variants for string booleans assert(result.css.includes('@custom-variant modal-visible-true')); @@ -1549,7 +1537,6 @@ test.skip('extracts values from deeply nested function calls', async () => { }, (result) => { console.log('\n📄 Nested calls extraction:'); - console.log(result.css); // Should extract all literal values assert(result.css.includes('@custom-variant theme-light')); // initial @@ -1587,7 +1574,7 @@ test('handles ternary and logical expressions', async () => { // Logical expressions const handleError = () => { - setStatus(error && 'error' || 'success'); + setStatus(hasError && 'error' || 'success'); }; // Complex logical @@ -1601,11 +1588,11 @@ test('handles ternary and logical expressions', async () => { }, (result) => { console.log('\n📄 Expression handling:'); - console.log(result.css); // Ternary values assert(result.css.includes('@custom-variant status-loading')); assert(result.css.includes('@custom-variant status-ready')); + assert(result.css.includes('@custom-variant status-idle')); assert(result.css.includes('@custom-variant status-error')); assert(result.css.includes('@custom-variant status-success')); @@ -1655,7 +1642,6 @@ export function Pages() { }, (result) => { console.log('\n📄 Constants:'); - console.log(result.css); assert(result.css.includes('@custom-variant theme-dark')); assert(result.css.includes('@custom-variant theme-default')); @@ -1710,7 +1696,6 @@ test('resolves constants and imported values -- COMPLEX --', async () => { }, (result) => { console.log('\n📄 Constants resolution:'); - console.log(result.css); // Should resolve local constants assert(result.css.includes('@custom-variant theme-pending-state')); @@ -1765,7 +1750,6 @@ test.skip('handles all common setter patterns - full coverage sanity check - COM }, (result) => { console.log('\n📄 Full coverage test:'); - console.log(result.css); // ✅ things that MUST be included assert(result.css.includes('@custom-variant theme-dark')); @@ -1822,7 +1806,6 @@ test('handles arrow functions and function expressions', async () => { }, (result) => { console.log('\n📄 Function expressions:'); - console.log(result.css); assert(result.css.includes('@custom-variant component-state-quick')); assert(result.css.includes('@custom-variant component-state-block')); @@ -1862,7 +1845,6 @@ test('handles multiple setters for same state key', async () => { }, (result) => { console.log('\n📄 Multiple setters:'); - console.log(result.css); // Should combine all values from different setters for same key assert(result.css.includes('@custom-variant global-theme-light')); @@ -1904,7 +1886,6 @@ test('ignores dynamic and non-literal values', async () => { }, (result) => { console.log('\n📄 Dynamic values filtering:'); - console.log(result.css); // Should only have literals assert(result.css.includes('@custom-variant theme-default')); // initial @@ -1958,9 +1939,6 @@ test('handles edge cases with unusual syntax', async () => { `, }, (result) => { - console.log('\n📄 Edge cases:'); - console.log(result.css); - // Basic cases that should work assert(result.css.includes('@custom-variant edge-state-first')); assert(result.css.includes('@custom-variant edge-state-second')); diff --git a/packages/core/package.json b/packages/core/package.json index 204184f..67db817 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,13 +35,15 @@ }, "scripts": { "prepack": "pnpm run build", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.build.json", + "dev": "tsc -p tsconfig.json --watch", "test:next": "playwright test -c __tests__/config/playwright.next.config.js", "test:vite": "playwright test -c __tests__/config/playwright.vite.config.js", "test:unit": "node --test __tests__/unit/index.test.cjs", "test:cli": "node --test __tests__/unit/cli.test.cjs", "test:all": "pnpm run test:vite && pnpm run test:next && pnpm run test:unit && pnpm run test:cli", - "test": "pnpm run test:all" + "test": "pnpm run test:all", + "test:ts": "tsx --test src/**/*.test.ts" }, "keywords": [ "react", @@ -82,7 +84,7 @@ "@babel/parser": "^7.27.5", "@babel/traverse": "^7.27.4", "@babel/types": "^7.27.4", - "glob": "^11.0.0" + "fast-glob": "^3.3.3" }, "devDependencies": { "@babel/code-frame": "^7.27.1", @@ -91,6 +93,7 @@ "@types/babel__generator": "^7.27.0", "@types/babel__traverse": "^7.20.7", "@types/react": "^19.1.8", - "lru-cache": "^10.4.3" + "lru-cache": "^10.4.3", + "tsx": "^4.20.3" } -} +} \ No newline at end of file diff --git a/packages/core/src/cli/postInstall.cts b/packages/core/src/cli/postInstall.cts index eaa3066..6e3b36e 100644 --- a/packages/core/src/cli/postInstall.cts +++ b/packages/core/src/cli/postInstall.cts @@ -1,6 +1,6 @@ // src/cli/postInstall.cts -import { processVariants, generateAttributesFile, patchConfigAlias, patchPostcssConfig, patchViteConfig, hasViteConfig } from '../postcss/helpers.cjs'; -import { patchNextBodyTag } from '../postcss/ast-v2.cjs'; +import { patchNextBodyTag } from '../postcss/ast-generating.cjs'; +import { processVariants, generateAttributesFile, patchTsConfig, patchPostcssConfig, patchViteConfig, hasViteConfig } from '../postcss/helpers.cjs'; export async function runZeroUiInit() { try { @@ -13,7 +13,7 @@ export async function runZeroUiInit() { if (!hasViteConfig()) { // Patch config for module resolution - await patchConfigAlias(); + await patchTsConfig(); // Patch PostCSS config for Next.js projects await patchPostcssConfig(); } diff --git a/packages/core/src/config.cts b/packages/core/src/config.cts index 289a994..8db7e6e 100644 --- a/packages/core/src/config.cts +++ b/packages/core/src/config.cts @@ -7,9 +7,11 @@ export const CONFIG = { HEADER: '/* AUTO-GENERATED - DO NOT EDIT */', ZERO_UI_DIR: '.zero-ui', CONTENT: ['src/**/*.{ts,tsx,js,jsx}', 'app/**/*.{ts,tsx,js,jsx}', 'pages/**/*.{ts,tsx,js,jsx}'], + POSTCSS_PLUGIN: '@react-zero-ui/core/postcss', + VITE_PLUGIN: '@react-zero-ui/core/vite', }; -export const IGNORE_DIRS = new Set([ +export const IGNORE_DIRS = [ '**/node_modules/**', '**/.next/**', '**/.turbo/**', @@ -20,4 +22,4 @@ export const IGNORE_DIRS = new Set([ '**/public/**', '**/dist/**', '**/build/**', -]); +]; diff --git a/packages/core/src/postcss/ast-generating.cts b/packages/core/src/postcss/ast-generating.cts new file mode 100644 index 0000000..78169e5 --- /dev/null +++ b/packages/core/src/postcss/ast-generating.cts @@ -0,0 +1,355 @@ +import { parse, parseExpression } 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 path from 'path'; + +/** + * Parse PostCSS config JavaScript file and add Zero-UI plugin if not present + * Uses Babel AST for robust parsing and modification + * Supports both CommonJS (.js) and ES Modules (.mjs) formats + * @param {string} source - The PostCSS config source code + * @param {string} zeroUiPlugin - The Zero-UI plugin name + * @param {boolean} isESModule - Whether the config is an ES module + * @returns {string | null} The modified config code or null if no changes were made + */ +export function parseAndUpdatePostcssConfig(source: string, zeroUiPlugin: string, isESModule: boolean = false): string | null { + try { + const ast = parse(source, { sourceType: 'module', plugins: ['jsonStrings'] }); + + let modified = false; + + // Check if Zero-UI plugin already exists + if (source.includes(zeroUiPlugin)) { + return source; // Already configured + } + + traverse(ast, { + // Handle CommonJS: module.exports = { ... } and exports = { ... } + AssignmentExpression(path: NodePath) { + const { left, right } = path.node; + + // Check for module.exports or exports assignment + const isModuleExports = + t.isMemberExpression(left) && t.isIdentifier(left.object, { name: 'module' }) && t.isIdentifier(left.property, { name: 'exports' }); + const isExportsAssignment = t.isIdentifier(left, { name: 'exports' }); + + if ((isModuleExports || isExportsAssignment) && t.isObjectExpression(right)) { + const pluginsProperty = right.properties.find( + (prop): prop is t.ObjectProperty => t.isObjectProperty(prop) && t.isIdentifier(prop.key, { name: 'plugins' }) + ); + + if (pluginsProperty && t.isExpression(pluginsProperty.value)) { + modified = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); + } + } + }, + + // Handle ES Modules: export default { ... } + ExportDefaultDeclaration(path: NodePath) { + if (isESModule && t.isObjectExpression(path.node.declaration)) { + const pluginsProperty = path.node.declaration.properties.find( + (prop): prop is t.ObjectProperty => t.isObjectProperty(prop) && t.isIdentifier(prop.key, { name: 'plugins' }) + ); + + if (pluginsProperty && t.isExpression(pluginsProperty.value)) { + modified = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); + } + } + }, + + // Handle: const config = { plugins: ... }; export default config + VariableDeclarator(path: NodePath) { + if (isESModule && path.node.init && t.isObjectExpression(path.node.init)) { + const pluginsProperty = path.node.init.properties.find( + (prop): prop is t.ObjectProperty => t.isObjectProperty(prop) && t.isIdentifier(prop.key, { name: 'plugins' }) + ); + + if (pluginsProperty && t.isExpression(pluginsProperty.value)) { + modified = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); + } + } + }, + }); + + if (modified) { + return generate(ast).code; + } else { + console.warn(`[Zero-UI] Failed to automatically modify PostCSS config: ${source}`); + return null; // Could not automatically modify + } + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + console.warn(`[Zero-UI] Failed to parse PostCSS config: ${errorMessage}`); + return null; + } +} + +/** + * Helper function to add Zero-UI plugin to plugins configuration + * Handles both object format {plugin: {}} and array format [plugin] + */ +function addZeroUiToPlugins(pluginsNode: t.Expression, zeroUiPlugin: string): boolean { + if (t.isObjectExpression(pluginsNode)) { + // Object format: { 'plugin': {} } + pluginsNode.properties.unshift(t.objectProperty(t.stringLiteral(zeroUiPlugin), t.objectExpression([]))); + return true; + } else if (t.isArrayExpression(pluginsNode)) { + // Array format: ['plugin'] + pluginsNode.elements.unshift(t.stringLiteral(zeroUiPlugin)); + return true; + } + return false; +} + +/** + * Helper to create a zeroUI() call AST node + */ +function createZeroUICallNode(): t.CallExpression { + return t.callExpression(t.identifier('zeroUI'), []); +} + +/** + * Helper to create a zeroUI import AST node + */ +function createZeroUIImportNode(importPath: string): t.ImportDeclaration { + return t.importDeclaration([t.importDefaultSpecifier(t.identifier('zeroUI'))], t.stringLiteral(importPath)); +} +/** + * Ensure the plugins array contains `zeroUI()` and **no** Tailwind entry. + * @returns true if the array was modified + */ +function processPluginsArray(elements: (t.Expression | null)[]): boolean { + let modified = false; + + // 1 remove "@tailwindcss/vite" string or tailwindcss() call + for (let i = elements.length - 1; i >= 0; i--) { + const el = elements[i]; + if ((t.isStringLiteral(el) && el.value === '@tailwindcss/vite') || (t.isCallExpression(el) && t.isIdentifier(el.callee, { name: 'tailwindcss' }))) { + elements.splice(i, 1); + modified = true; + } + } + + // 2 inject zeroUI() if absent + const hasZero = elements.some( + (el) => (t.isCallExpression(el) && t.isIdentifier(el.callee, { name: 'zeroUI' })) || (t.isStringLiteral(el) && el.value === 'zeroUI()') + ); + + if (!hasZero) { + elements.push(createZeroUICallNode()); + modified = true; + } + + return modified; +} + +/** + * Parse Vite config TypeScript/JavaScript file and add Zero-UI plugin + * Removes tailwindcss plugin if present, and adds zeroUI plugin if missing + * @param {string} source - The Vite config source code + * @param {string} zeroUiImportPath - The Zero-UI plugin import path + * @returns {string | null} The modified config code or null if no changes were made + */ +export function parseAndUpdateViteConfig(source: string, zeroUiImportPath: string): string | null { + if (source.includes(zeroUiImportPath) && source.includes('zeroUI()') && !source.includes('@tailwindcss/')) { + return source; + } + + let ast: t.File; + try { + ast = parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx', 'importMeta', 'decorators-legacy'] }); + } catch (e) { + throw new Error(`[Zero-UI] Failed to parse vite.config: ${e instanceof Error ? e.message : String(e)}`); + } + + let modified = false; + + traverse(ast, { + Program(p: NodePath) { + // inject import once + if (!source.includes(zeroUiImportPath)) { + p.node.body.unshift(t.importDeclaration([t.importDefaultSpecifier(t.identifier('zeroUI'))], t.stringLiteral(zeroUiImportPath))); + modified = true; + } + }, + + CallExpression(p: NodePath) { + if (t.isIdentifier(p.node.callee, { name: 'defineConfig' }) && t.isObjectExpression(p.node.arguments[0])) { + if (processConfigObject(p.node.arguments[0])) modified = true; + } + }, + + ExportDefaultDeclaration(p: NodePath) { + const decl = p.node.declaration; + let obj: t.ObjectExpression | null = null; + + if (t.isObjectExpression(decl)) obj = decl; + else if (t.isIdentifier(decl)) { + const b = p.scope.getBinding(decl.name); + if (b?.path.isVariableDeclarator() && t.isObjectExpression(b.path.node.init)) obj = b.path.node.init; + } else if ((t.isFunctionDeclaration(decl) || t.isArrowFunctionExpression(decl) || t.isFunctionExpression(decl)) && t.isObjectExpression(decl.body)) { + obj = decl.body; + } + + if (obj && processConfigObject(obj)) modified = true; + }, + + ReturnStatement(p: NodePath) { + const arg = p.node.argument; + if (t.isObjectExpression(arg) && processConfigObject(arg)) modified = true; + }, + + ObjectExpression(p: NodePath) { + if (processConfigObject(p.node)) modified = true; + }, + + ImportDeclaration(p: NodePath) { + if (p.node.source.value.startsWith('@tailwindcss/')) { + p.remove(); + modified = true; + } + }, + }); + + return modified ? generate(ast).code : null; +} + +/* ------------------------------------------------------------------ * + * processConfigObject - mutate a { plugins: [...] } + * ------------------------------------------------------------------ */ +function processConfigObject(obj: t.ObjectExpression): boolean { + let touched = false; + + // locate/create plugins array + let prop = obj.properties.find((p): p is t.ObjectProperty => t.isObjectProperty(p) && t.isIdentifier(p.key, { name: 'plugins' })); + + if (!prop) { + prop = t.objectProperty(t.identifier('plugins'), t.arrayExpression([])); + obj.properties.push(prop); + touched = true; + } + if (!t.isArrayExpression(prop.value)) return touched; + const arr = prop.value; + + // strip Tailwind + arr.elements = arr.elements.filter( + (el) => + !( + (t.isStringLiteral(el) && el.value.startsWith('@tailwindcss/')) || // ← ❸ filter all Tailwind strings + (t.isCallExpression(el) && t.isIdentifier(el.callee, { name: 'tailwindcss' })) + ) + ); + // prepend zeroUI() if missing + if (!arr.elements.some((el) => t.isCallExpression(el) && t.isIdentifier(el.callee, { name: 'zeroUI' }))) { + arr.elements.unshift(t.callExpression(t.identifier('zeroUI'), [])); + touched = true; + } + + return touched; +} + +function findLayoutWithBody(root = process.cwd()): string[] { + const matches: string[] = []; + function walk(dir: string): void { + for (const file of fs.readdirSync(dir)) { + const fullPath = path.join(dir, file); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + walk(fullPath); + } else if (/^layout\.(tsx|jsx|js|ts)$/.test(file)) { + const source = fs.readFileSync(fullPath, 'utf8'); + if (source.includes(' { + const matches = findLayoutWithBody(); + + if (matches.length !== 1) { + console.warn(`[Zero-UI] ⚠️ Found ${matches.length} layout files with tags. ` + `Expected exactly one. Skipping automatic injection.`); + return; + } + + const filePath = matches[0]; + const code = fs.readFileSync(filePath, 'utf8'); + + // Parse the file into an AST + const ast = parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); + + let hasImport = false; + traverse(ast, { + ImportDeclaration(path: NodePath) { + const specifiers = path.node.specifiers; + const source = path.node.source.value; + if (source === '@zero-ui/attributes') { + for (const spec of specifiers) { + if (t.isImportSpecifier(spec) && t.isIdentifier(spec.imported) && spec.imported.name === 'bodyAttributes') { + hasImport = true; + } + } + } + }, + }); + + traverse(ast, { + Program(path: NodePath) { + if (!hasImport) { + const importDecl = t.importDeclaration( + [t.importSpecifier(t.identifier('bodyAttributes'), t.identifier('bodyAttributes'))], + t.stringLiteral('@zero-ui/attributes') + ); + path.node.body.unshift(importDecl); + } + }, + }); + + // Inject JSX spread into + let injected = false; + traverse(ast, { + JSXOpeningElement(path: NodePath) { + if (!injected && t.isJSXIdentifier(path.node.name, { name: 'body' })) { + // Prevent duplicate injection + const hasSpread = path.node.attributes.some((attr) => t.isJSXSpreadAttribute(attr) && t.isIdentifier(attr.argument, { name: 'bodyAttributes' })); + if (!hasSpread) { + path.node.attributes.unshift(t.jsxSpreadAttribute(t.identifier('bodyAttributes'))); + injected = true; + } + } + }, + }); + + const output = generate( + ast, + { + /* retain lines, formatting */ + }, + code + ).code; + fs.writeFileSync(filePath, output, 'utf8'); + console.log(`[Zero-UI] ✅ Patched in ${filePath} with {...bodyAttributes}`); +} + +/** + * Parse a tsconfig/jsconfig JSON file using Babel (handles comments, trailing commas) + */ +export function parseJsonWithBabel(source: string): any { + try { + const ast = parseExpression(source, { sourceType: 'module', plugins: ['jsonStrings'] }); + // Convert Babel AST back to plain JS object + return eval(`(${generate(ast).code})`); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + console.warn(`[Zero-UI] Failed to parse ${source}: ${errorMessage}`); + return null; + } +} diff --git a/packages/core/src/postcss/ast-generating.test.ts b/packages/core/src/postcss/ast-generating.test.ts new file mode 100644 index 0000000..0a4c8ea --- /dev/null +++ b/packages/core/src/postcss/ast-generating.test.ts @@ -0,0 +1,91 @@ +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'; + +const zeroUiPlugin = '@react-zero-ui/core/postcss'; +const zeroUiVitePlugin = '@react-zero-ui/core/vite'; + +test('parseAndUpdatePostcssConfig should parse and update PostCSS config', async () => { + await runTest( + { + 'postcss.config.js': ` + module.exports = { + plugins: [require('tailwindcss'), require('autoprefixer')], + }`, + 'postcss.config2.mjs': ` + const config = { plugins: [ '@tailwindcss/postcss'] }; +export default config; +`, + }, + async () => { + const config = parseAndUpdatePostcssConfig(readFile('postcss.config.js'), zeroUiPlugin, false); + assert(config?.includes(zeroUiPlugin)); + const config2 = parseAndUpdatePostcssConfig(readFile('postcss.config2.mjs'), zeroUiPlugin, true); + assert(config2?.includes(zeroUiPlugin)); + } + ); +}); + +const FIXTURES = { + // 1. ESM object + 'vite.config.js': ` + + export default { plugins: [react(),'@tailwindcss/vite'] }; + `, + + // 2. defineConfig helper + 'vite.config.ts': ` + import { defineConfig } from 'vite'; + export default defineConfig({ plugins: [react(),'@tailwindcss/vite'] }); + `, + + // 3. sync factory + 'vite.config.mjs': ` + export default ({ mode }) => ({ plugins: [react(),'@tailwindcss/vite'] }); + `, + + // 4. async factory + indirection + 'vite.config.mts': ` + import { defineConfig } from 'vite'; + import react from '@vitejs/plugin-react'; + const cfg = defineConfig({ plugins: [react(),'@tailwindcss/vite'] }); + export default async () => cfg; + `, +}; + +test('parseAndUpdateViteConfig inserts Zero-UI and removes Tailwind', () => { + for (const [name, src] of Object.entries(FIXTURES)) { + const out = parseAndUpdateViteConfig(src, zeroUiVitePlugin); + console.log('out: ', out); + assert(out && out.includes(zeroUiVitePlugin), `${name} missing zeroUI`); + assert(out.includes('zeroUI()'), `${name} missing zeroUI`); + assert(out.includes('react()'), `${name} missing react`); + assert(!out.includes('@tailwindcss/vite'), `${name} still contains tailwind`); + } +}); + +test('patchNextBodyTag should patch the body tag with the {...bodyAttributes}', async () => { + await runTest( + { + 'src/app/layout.tsx': ` + + +
Hello
+ + + `, + }, + async () => { + await patchNextBodyTag(); + const result = readFile('src/app/layout.tsx'); + assert(result.includes('{...bodyAttributes}'), 'body tag not patched'); + assert(result.includes('import { bodyAttributes } from "@zero-ui/attributes";'), 'import not found'); + } + ); +}); + +test('parseJsonWithBabel should parse a JSON file', () => { + const result = parseJsonWithBabel(readFile('package.json')); + assert(result, 'package.json not parsed'); +}); diff --git a/packages/core/src/postcss/ast-parsing.cts b/packages/core/src/postcss/ast-parsing.cts new file mode 100644 index 0000000..0b7b896 --- /dev/null +++ b/packages/core/src/postcss/ast-parsing.cts @@ -0,0 +1,236 @@ +// src/core/postcss/ast-parsing.cts + +import { parse } 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 { createHash } from 'crypto'; +import * as fs from 'fs'; +import { literalFromNode, ResolveOpts } from './resolvers.cjs'; +import { codeFrameColumns } from '@babel/code-frame'; +const traverse = (babelTraverse as any).default; +import { LRUCache as LRU } from 'lru-cache'; +import { scanVariantTokens } from './scanner.cjs'; + +export interface SetterMeta { + /** Babel binding object — use `binding.referencePaths` in Pass 2 */ + binding: Binding; + /** Variable name (`setTheme`) */ + setterName: string; + /** State key passed to `useUI` (`'theme'`) */ + stateKey: string; + /** Literal initial value as string, or `null` if non-literal */ + initialValue: string | null; +} + +/** + * Collect every `[ value, setterFn ] = useUI('key', 'initial')` in a file. + * Re-uses `literalFromNode` so **initialArg** can be: + * • literal `'dark'` + * • local const `DARK` + * • static template `` `da${'rk'}` `` + * Throws if the key is dynamic or if the initial value cannot be + * reduced to a space-free string. + */ +export function collectUseUISetters(ast: t.File, sourceCode: string): SetterMeta[] { + const setters: SetterMeta[] = []; + /* ---------- cache resolved literals per AST node ---------- */ + const memo = new WeakMap(); + const optsBase = { throwOnFail: false, source: sourceCode } as ResolveOpts; + + function lit(node: t.Expression, p: NodePath, hook: ResolveOpts['hook']): string | null { + if (memo.has(node)) return memo.get(node)!; + + // clone instead of mutate + const localOpts: ResolveOpts = { ...optsBase, hook }; + + const ev = (p as NodePath).evaluate?.(); + const value = ev?.confident && typeof ev.value === 'string' ? ev.value : literalFromNode(node, p, localOpts); + + memo.set(node, value); + return value; + } + + 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; + + if (id.elements.length !== 2) { + throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] useUI() must destructure two values: [value, setterFn].`); + } + + const [, setterEl] = id.elements; + + if (!setterEl || !t.isIdentifier(setterEl)) { + throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] useUI() setterFn must be a variable name.`); + } + + const [keyArg, initialArg] = init.arguments; + + // resolve state key with new helpers + const stateKey = lit(keyArg as t.Expression, path as NodePath, 'stateKey'); + + if (stateKey === null) { + throwCodeFrame( + keyArg, + path.opts?.filename, + sourceCode, + // TODO add link to docs + `[Zero-UI] State key cannot be resolved at build-time.\n` + `Only local, fully-static strings are supported. - collectUseUISetters-stateKey` + ); + } + + // resolve initial value with new helpers + const initialValue = lit(initialArg as t.Expression, path as NodePath, 'initialValue'); + + if (initialValue === null) { + throwCodeFrame( + initialArg, + path.opts?.filename, + sourceCode, + // TODO add link to docs + `[Zero-UI] initial value cannot be resolved at build-time.\n` + + `Only local, fully-static objects/arrays are supported. - collectUseUISetters-initialValue` + ); + } + + 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}".`); + } + + setters.push({ binding, setterName: setterEl.name, stateKey, initialValue }); + }, + }); + + return setters; +} + +export interface VariantData { + /** The state key (e.g., 'theme') */ + key: string; + /** Array of unique values discovered in the file */ + values: string[]; + /** Literal initial value as string, or `null` if non-literal */ + initialValue: string | null; +} +/** + * Convert the harvested variants map to the final output format + */ +export function normalizeVariants(scanned: Map>, setters: SetterMeta[], sort = true): VariantData[] { + // key → initialValue (may be null) + const initialMap = new Map(setters.map((s) => [s.stateKey, s.initialValue ?? null])); + + const out: VariantData[] = []; + + // 1️⃣ merge scan-results with initial values + for (const [key, init] of initialMap) { + // ensure we have a bucket + if (!scanned.has(key)) scanned.set(key, new Set()); + + if (init) scanned.get(key)!.add(init); + } + + // 2️⃣ convert to the public shape + for (const [key, set] of scanned) { + const values = Array.from(set); + if (sort) values.sort(); + + out.push({ key, values, initialValue: initialMap.get(key) ?? null }); + } + + if (sort) out.sort((a, b) => a.key.localeCompare(b.key)); + return out; +} + +// File cache to avoid re-parsing unchanged files +export interface CacheEntry { + hash: string; + variants: VariantData[]; +} + +const fileCache = new LRU({ max: 5000 }); + +function keysFrom(setters: SetterMeta[]): Set { + return new Set(setters.map((s) => s.stateKey)); // already kebab-cased by collectUseUISetters +} + +export function extractVariants(filePath: string): VariantData[] { + // console.log(`[CACHE] Checking: ${filePath}`); + + try { + const { mtimeMs, size } = fs.statSync(filePath); + const sig = `${mtimeMs}:${size}`; + + const cached = fileCache.get(filePath); + if (cached && cached.hash === sig) { + // console.log(`[CACHE] HIT (sig): ${filePath}`); + return cached.variants; // Fast path: file unchanged + } + + const source = fs.readFileSync(filePath, 'utf8'); + const hash = createHash('md5').update(source).digest('hex'); + + // Fallback: content unchanged despite mtime/size change + if (cached && cached.hash === hash) { + // console.log(`[CACHE] HIT (hash): ${filePath}`); + // Update cache with new sig for next time + const entry = { hash: sig, variants: cached.variants }; + fileCache.set(filePath, entry); + return cached.variants; + } + + // console.log(`[CACHE] MISS: ${filePath} (parsing...)`); + // Parse the file + const ast = parse(source, { sourceType: 'module', plugins: ['jsx', 'typescript', 'decorators-legacy'], sourceFilename: filePath }); + + // Collect useUI setters + const setters = collectUseUISetters(ast, source); + if (!setters.length) return []; + + // Normalize variants + const variants = normalizeVariants(scanVariantTokens(source, keysFrom(setters)), setters); + + // Store with signature for fast future lookups + fileCache.set(filePath, { hash: sig, variants }); + return variants; + } catch (error) { + // Fallback for virtual/non-existent files - use content hash only + const source = fs.readFileSync(filePath, 'utf8'); + const hash = createHash('md5').update(source).digest('hex'); + + const cached = fileCache.get(filePath); + if (cached && cached.hash === hash) { + return cached.variants; + } + + // Parse and cache... + const ast = parse(source, { sourceType: 'module', plugins: ['jsx', 'typescript', 'decorators-legacy'], sourceFilename: filePath }); + const setters = collectUseUISetters(ast, source); + if (!setters.length) return []; + + // final normalization of variants + const variants = normalizeVariants(scanVariantTokens(source, keysFrom(setters)), setters); + console.log('variants extractVariants: ', variants); + + fileCache.set(filePath, { hash, variants }); + return variants; + } +} + +export function throwCodeFrame(nodeOrPath: Node | NodePath, filename: string, source: string, msg: string): never { + // Accept either a raw node or a NodePath + const node = (nodeOrPath as NodePath).node ?? (nodeOrPath as Node); + + if (!node.loc) throw new Error(msg); // defensive + + const { start, end } = node.loc; + const header = `${filename ?? ''}:${start.line}:${start.column}\n`; + const frame = codeFrameColumns(source, { start, end }, { highlightCode: true, linesAbove: 1, linesBelow: 1 }); + + throw new Error(`${header}${msg}\n${frame}`); +} diff --git a/packages/core/src/postcss/ast-parsing.test.ts b/packages/core/src/postcss/ast-parsing.test.ts new file mode 100644 index 0000000..d31e6a6 --- /dev/null +++ b/packages/core/src/postcss/ast-parsing.test.ts @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import fs from 'fs'; +import { collectUseUISetters, extractVariants, normalizeVariants } from './ast-parsing.cts'; +import { parse } from '@babel/parser'; +import { runTest } from '../utilities.ts'; + +test('collectUseUISetters should collect setters from a component', async () => { + await runTest( + { + 'app/boolean-edge-cases.tsx': ` + import { useUI } from '@react-zero-ui/core'; + + const featureEnabled = 'feature-enabled'; + const bool = false; + + const theme = ['true']; + +export function Component() { + const [isVisible, setIsVisible] = useUI('modal-visible', 'false'); + const [isEnabled, setIsEnabled] = useUI(bool ?? featureEnabled, theme[0]); +return ( +
+
+ Open Modal +
+
+); }`, + }, + + async () => { + const src = fs.readFileSync('app/boolean-edge-cases.tsx', 'utf8'); + const ast = parse(src, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); + const setters = collectUseUISetters(ast, src); + assert(setters[0].binding !== null); + assert(setters[0].initialValue === 'false'); + assert(setters[0].stateKey === 'modal-visible'); + assert(setters[0].setterName === 'setIsVisible'); + assert(setters[1].binding !== null); + assert(setters[1].initialValue === 'true'); + assert(setters[1].stateKey === 'feature-enabled'); + assert(setters[1].setterName === 'setIsEnabled'); + + const variants = normalizeVariants(new Map(), setters, false); + // variants: [ + // { key: 'modal-visible', values: [ 'false' ], initialValue: 'false' }, + // { key: 'feature-enabled', values: [ 'true' ], initialValue: 'true' } + // ] + assert(variants.length === 2); + assert(variants[0].key === 'modal-visible'); + assert(variants[0].values[0] === 'false'); + assert(variants[1].key === 'feature-enabled'); + assert(variants[1].values[0] === 'true'); + + const finalVariants = extractVariants('app/boolean-edge-cases.tsx'); + + assert(finalVariants[0].key === 'feature-enabled'); + assert(finalVariants[0].values.includes('true')); + assert(finalVariants[1].key === 'modal-visible'); + assert(finalVariants[1].values.includes('false')); + } + ); +}); diff --git a/packages/core/src/postcss/ast-v2.cts b/packages/core/src/postcss/ast-v2.cts deleted file mode 100644 index a172188..0000000 --- a/packages/core/src/postcss/ast-v2.cts +++ /dev/null @@ -1,811 +0,0 @@ -// src/core/postcss/ast-v2.cts - -import { parse, parseExpression } 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 { createHash } from 'crypto'; -import { generate } from '@babel/generator'; -import * as fs from 'fs'; -import * as path from 'path'; -import { literalFromNode, ResolveOpts } from './resolvers.cjs'; -import { codeFrameColumns } from '@babel/code-frame'; -const traverse = (babelTraverse as any).default; -import { LRUCache as LRU } from 'lru-cache'; - -export interface SetterMeta { - /** Babel binding object — use `binding.referencePaths` in Pass 2 */ - binding: Binding; - /** Variable name (`setTheme`) */ - setterName: string; - /** State key passed to `useUI` (`'theme'`) */ - stateKey: string; - /** Literal initial value as string, or `null` if non-literal */ - initialValue: string | null; -} - -// /** -// * Check if the file imports useUI (or the configured hook name) -// * @param ast - The parsed AST -// * @returns true if useUI is imported, false otherwise -// */ -// function hasUseUIImport(ast: t.File): boolean { -// let hasImport = false; - -// traverse(ast, { -// ImportDeclaration(path: any) { -// const source = path.node.source.value; - -// // Check if importing from @react-zero-ui/core -// if (source === CONFIG.IMPORT_NAME) { -// // Only look for named import: import { useUI } from '...' -// const hasUseUISpecifier = path.node.specifiers.some( -// (spec: any) => t.isImportSpecifier(spec) && t.isIdentifier(spec.imported) && spec.imported.name === CONFIG.HOOK_NAME -// ); - -// if (hasUseUISpecifier) { -// hasImport = true; -// path.stop(); // Early exit -// } -// } -// }, -// }); - -// return hasImport; -// } -/** - * Collect every `[ value, setterFn ] = useUI('key', 'initial')` in a file. - * Re-uses `literalFromNode` so **initialArg** can be: - * • literal `'dark'` - * • local const `DARK` - * • static template `` `da${'rk'}` `` - * Throws if the key is dynamic or if the initial value cannot be - * reduced to a space-free string. - */ -export function collectUseUISetters(ast: t.File, sourceCode: string): SetterMeta[] { - const setters: SetterMeta[] = []; - /* ---------- cache resolved literals per AST node ---------- */ - const memo = new WeakMap(); - const optsBase = { throwOnFail: false, source: sourceCode } as ResolveOpts; - - function lit(node: t.Expression, p: NodePath, hook: ResolveOpts['hook']): string | null { - if (memo.has(node)) return memo.get(node)!; - - // clone instead of mutate - const localOpts: ResolveOpts = { ...optsBase, hook }; - - const ev = (p as NodePath).evaluate?.(); - const value = ev?.confident && typeof ev.value === 'string' ? ev.value : literalFromNode(node, p, localOpts); - - memo.set(node, value); - return value; - } - - 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; - - if (id.elements.length !== 2) { - throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] useUI() must destructure two values: [value, setterFn].`); - } - - const [, setterEl] = id.elements; - - if (!setterEl || !t.isIdentifier(setterEl)) { - throwCodeFrame(path, path.opts?.filename, sourceCode, `[Zero-UI] useUI() setterFn must be a variable name.`); - } - - const [keyArg, initialArg] = init.arguments; - - // resolve state key with new helpers - const stateKey = lit(keyArg as t.Expression, path as NodePath, 'stateKey'); - - if (stateKey === null) { - throwCodeFrame( - keyArg, - path.opts?.filename, - sourceCode, - // TODO add link to docs - `[Zero-UI] State key cannot be resolved at build-time.\n` + `Only local, fully-static strings are supported. - collectUseUISetters-stateKey` - ); - } - - // resolve initial value with new helpers - const initialValue = lit(initialArg as t.Expression, path as NodePath, 'initialValue'); - - if (initialValue === null) { - throwCodeFrame( - initialArg, - path.opts?.filename, - sourceCode, - // TODO add link to docs - `[Zero-UI] initial value cannot be resolved at build-time.\n` + - `Only local, fully-static objects/arrays are supported. - collectUseUISetters-initialValue` - ); - } - - 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}".`); - } - - setters.push({ binding, setterName: setterEl.name, stateKey, initialValue }); - }, - }); - - return setters; -} - -export interface VariantData { - /** The state key (e.g., 'theme') */ - key: string; - /** Array of unique values discovered in the file */ - values: string[]; - /** Literal initial value as string, or `null` if non-literal */ - initialValue: string | null; -} -// ──────────────────────────────────────────────────────────── -// Pass 2 – harvest every literal that can possibly flow -// through a setter call ​setX(...) -// ──────────────────────────────────────────────────────────── -export function harvestSetterValues( - setters: SetterMeta[], - fileSource: string // for code-frame errors -): Map> { - /* 1 ─ bootstrap with the initial values found in Pass 1 ───────── */ - const variants = new Map>(); - for (const { stateKey, initialValue } of setters) { - if (!variants.has(stateKey)) variants.set(stateKey, new Set()); - if (initialValue) variants.get(stateKey)!.add(initialValue); - } - - /* 2 ─ one-shot memoised literal resolver ──────────────────────── */ - const memo = new WeakMap(); - const litOpt = { throwOnFail: false, source: fileSource, hook: 'setterName' } as const; - - const lit = (node: t.Expression, path: NodePath) => { - if (memo.has(node)) return memo.get(node)!; - const v = literalFromNode(node, path, litOpt); - memo.set(node, v); - return v; - }; - - /* 3 ─ helper: extract every literal hidden in an *expression* ─── */ - function extract(expr: t.Expression, p: NodePath, bucket: Set) { - /* direct literal / identifier / member / template */ - const vDir = lit(expr, p); - if (vDir) { - bucket.add(vDir); - return; - } - - /* ternary cond ? x : y */ - if (t.isConditionalExpression(expr)) { - extract(expr.consequent, p, bucket); - extract(expr.alternate, p, bucket); - return; - } - - /* logical a || b a ?? b */ - if (t.isLogicalExpression(expr) && (expr.operator === '||' || expr.operator === '??')) { - extract(expr.left, p, bucket); - extract(expr.right, p, bucket); - return; - } - - /* binary 'a' + 'b' (only +) */ - if (t.isBinaryExpression(expr) && expr.operator === '+') { - extract(expr.left as t.Expression, p, bucket); - extract(expr.right as t.Expression, p, bucket); - return; - } - - /* nothing else produces compile-time strings */ - } - - /* 4 ─ walk every reference (call-site) of each setter ─────────── */ - for (const { binding, stateKey } of setters) { - const bucket = variants.get(stateKey)!; - - binding.referencePaths.forEach((ref) => { - const call = ref.parentPath; - if (!call?.isCallExpression() || call.node.callee !== ref.node) return; - - const arg = call.node.arguments[0] as t.Expression | undefined; - if (!arg) return; - - /* 4-a ▸ INLINE () => … function() {…} ------------------ */ - if (t.isArrowFunctionExpression(arg) || t.isFunctionExpression(arg)) { - consumeFunctionBody(arg, call, bucket); - return; - } - - /* 4-b ▸ IDENTIFIER setX(toggle) -------------------------- */ - if (t.isIdentifier(arg)) { - const bind = call.scope.getBinding(arg.name); - if (!bind) return; // unresolved → ignore - - // ‼️ imported function ‼️ - if (bind.path.isImportSpecifier() || bind.path.isImportDefaultSpecifier() || bind.path.isImportNamespaceSpecifier()) { - throwCodeFrame(call, call.opts?.filename, fileSource, `[Zero-UI] Setter functions must be defined locally – ` + `“${arg.name}” is imported.`); - } - - // variable like const toggle = () => 'dark' - if ( - bind.path.isVariableDeclarator() && - bind.path.node.init && - (t.isArrowFunctionExpression(bind.path.node.init) || t.isFunctionExpression(bind.path.node.init)) - ) { - consumeFunctionBody(bind.path.node.init, bind.path, bucket); - return; - } - - // plain identifier that eventually resolves to a literal - const v = lit(arg, call); - if (v) bucket.add(v); - return; - } - - /* 4-c ▸ everything else (direct literals, ternaries, …) ----- */ - extract(arg, call, bucket); - }); - } - - return variants; - - /* ───────── local helper ── visits every `return …` ─────────── */ - function consumeFunctionBody(fn: t.ArrowFunctionExpression | t.FunctionExpression, p: NodePath, set: Set) { - if (t.isBlockStatement(fn.body)) { - fn.body.body.forEach((stmt) => { - if (t.isReturnStatement(stmt) && stmt.argument) { - extract(stmt.argument as t.Expression, p, set); - } - }); - } else { - // concise body () => expr - extract(fn.body as t.Expression, p, set); - } - } -} - -/** - * Recursively collect every *string literal value* that can be proven at - * build-time inside `expr` (ternaries, logical fallbacks, arrow & fn bodies …). - * - * memo-ized per-node → O(N) over the whole file. - */ -export function gatherLiterals( - expr: t.Expression, - path: NodePath, - opts: ResolveOpts, - /** WeakMap cache seeded once per file */ - memo: WeakMap -): string[] { - /* ─── memo fast path ──────────────────────────────── */ - if (memo.has(expr)) return memo.get(expr)!; - - /* ─── confident string via Babel-s partial evaluator ─*/ - const evalRes = path.evaluate?.() ?? { confident: false }; - if (evalRes.confident && typeof evalRes.value === 'string') { - memo.set(expr, [evalRes.value]); - return [evalRes.value]; - } - - /* ─── try single-step literal resolver ────────────── */ - const lit = literalFromNode(expr, path, opts); - if (lit !== null) { - memo.set(expr, [lit]); - return [lit]; - } - - /* ─── recursive syntactic cases ─────────────────────*/ - let out: string[] = []; - - /* 1. a ? b : c */ - if (t.isConditionalExpression(expr)) { - out = [...gatherLiterals(expr.consequent, path, opts, memo), ...gatherLiterals(expr.alternate, path, opts, memo)]; - } else if (t.isLogicalExpression(expr) && (expr.operator === '||' || expr.operator === '??')) { - /* 2. a || b or a ?? b */ - out = [...gatherLiterals(expr.left, path, opts, memo), ...gatherLiterals(expr.right, path, opts, memo)]; - } else if (t.isBinaryExpression(expr, { operator: '+' })) { - /* 3. 'a' + 'b' (pure string concatenation) */ - const left = gatherLiterals(expr.left as t.Expression, path, opts, memo); - const right = gatherLiterals(expr.right as t.Expression, path, opts, memo); - // concat only when each side collapses to exactly ONE literal - if (left.length === 1 && right.length === 1) { - out = [left[0] + right[0]]; - } - } else if ((t.isArrowFunctionExpression(expr) || t.isFunctionExpression(expr)) && expr.body) { - /* 4. () => 'x' / prev => prev==='a'?'b':'a' */ - const bodies: t.Expression[] = []; - - if (t.isExpression(expr.body)) { - bodies.push(expr.body); - } else if (t.isBlockStatement(expr.body)) { - // every `return ` - expr.body.body.forEach((stmt) => { - if (t.isReturnStatement(stmt) && stmt.argument) { - bodies.push(stmt.argument as t.Expression); - } - }); - } - - for (const b of bodies) { - out.push(...gatherLiterals(b, path, opts, memo)); - } - } else if (t.isSequenceExpression(expr)) { - /* 5. SequenceExpression (rare but cheap to support) */ - expr.expressions.forEach((e) => { - out.push(...gatherLiterals(e as t.Expression, path, opts, memo)); - }); - } - - /* ─── finalise ───────────────────────────────────────*/ - memo.set(expr, out); - return out; -} - -/** - * Convert the harvested variants map to the final output format - */ -export function normalizeVariants(variants: Map>, setters: SetterMeta[], shouldSort = true): VariantData[] { - const setterMap = new Map(setters.map((s) => [s.stateKey, s])); - const result: VariantData[] = []; - - for (const [stateKey, valueSet] of variants) { - const initialValue = setterMap.get(stateKey)?.initialValue || null; - const values = Array.from(valueSet); - if (shouldSort) values.sort(); - result.push({ key: stateKey, values, initialValue }); - } - - if (shouldSort) result.sort((a, b) => a.key.localeCompare(b.key)); - return result; -} - -// File cache to avoid re-parsing unchanged files -export interface CacheEntry { - hash: string; - variants: VariantData[]; -} - -const fileCache = new LRU({ max: 5000 }); - -export function extractVariants(filePath: string): VariantData[] { - // console.log(`[CACHE] Checking: ${filePath}`); - - try { - const { mtimeMs, size } = fs.statSync(filePath); - const sig = `${mtimeMs}:${size}`; - - const cached = fileCache.get(filePath); - if (cached && cached.hash === sig) { - // console.log(`[CACHE] HIT (sig): ${filePath}`); - return cached.variants; // Fast path: file unchanged - } - - const source = fs.readFileSync(filePath, 'utf8'); - const hash = createHash('md5').update(source).digest('hex'); - - // Fallback: content unchanged despite mtime/size change - if (cached && cached.hash === hash) { - // console.log(`[CACHE] HIT (hash): ${filePath}`); - // Update cache with new sig for next time - const entry = { hash: sig, variants: cached.variants }; - fileCache.set(filePath, entry); - return cached.variants; - } - - // console.log(`[CACHE] MISS: ${filePath} (parsing...)`); - // Parse the file - const ast = parse(source, { sourceType: 'module', plugins: ['jsx', 'typescript', 'decorators-legacy'], sourceFilename: filePath }); - - // Collect useUI setters - const setters = collectUseUISetters(ast, source); - if (!setters.length) return []; - - // Normalize variants - const variants = normalizeVariants(harvestSetterValues(setters, source), setters); - - // Store with signature for fast future lookups - fileCache.set(filePath, { hash: sig, variants }); - return variants; - } catch (error) { - // console.log(`[CACHE] ERROR (fallback): ${filePath}`); - // Fallback for virtual/non-existent files - use content hash only - const source = fs.readFileSync(filePath, 'utf8'); - const hash = createHash('md5').update(source).digest('hex'); - - const cached = fileCache.get(filePath); - if (cached && cached.hash === hash) { - return cached.variants; - } - - // Parse and cache... - const ast = parse(source, { sourceType: 'module', plugins: ['jsx', 'typescript', 'decorators-legacy'], sourceFilename: filePath }); - const setters = collectUseUISetters(ast, source); - if (!setters.length) return []; - - // final normalization of variants - const variants = normalizeVariants(harvestSetterValues(setters, source), setters); - - fileCache.set(filePath, { hash, variants }); - return variants; - } -} -/** - * Extract variants from multiple files - * @param filePaths - Array of file paths to analyze - * @returns Combined and deduplicated variant data - */ -export function extractVariantsFromFiles(filePaths: string[]): VariantData[] { - const allVariants = new Map>(); - const initialValues = new Map(); - - for (const filePath of filePaths) { - const fileVariants = extractVariants(filePath); - - for (const variant of fileVariants) { - if (!allVariants.has(variant.key)) { - allVariants.set(variant.key, new Set()); - initialValues.set(variant.key, variant.initialValue); - } - - // Merge values - variant.values.forEach((value) => allVariants.get(variant.key)!.add(value)); - } - } - - // Convert back to VariantData format - return Array.from(allVariants.entries()) - .map(([key, valueSet]) => ({ key, values: Array.from(valueSet).sort(), initialValue: initialValues.get(key) || null })) - .sort((a, b) => a.key.localeCompare(b.key)); -} - -/** - * Parse PostCSS config JavaScript file and add Zero-UI plugin if not present - * Uses Babel AST for robust parsing and modification - * Supports both CommonJS (.js) and ES Modules (.mjs) formats - * @param {string} source - The PostCSS config source code - * @param {string} zeroUiPlugin - The Zero-UI plugin name - * @param {boolean} isESModule - Whether the config is an ES module - * @returns {string | null} The modified config code or null if no changes were made - */ -export function parseAndUpdatePostcssConfig(source: string, zeroUiPlugin: string, isESModule: boolean = false): string | null { - try { - const ast = parse(source, { sourceType: 'module', plugins: ['jsonStrings'] }); - - let modified = false; - - // Check if Zero-UI plugin already exists - if (source.includes(zeroUiPlugin)) { - return source; // Already configured - } - - traverse(ast, { - // Handle CommonJS: module.exports = { ... } and exports = { ... } - AssignmentExpression(path: NodePath) { - const { left, right } = path.node; - - // Check for module.exports or exports assignment - const isModuleExports = - t.isMemberExpression(left) && t.isIdentifier(left.object, { name: 'module' }) && t.isIdentifier(left.property, { name: 'exports' }); - const isExportsAssignment = t.isIdentifier(left, { name: 'exports' }); - - if ((isModuleExports || isExportsAssignment) && t.isObjectExpression(right)) { - const pluginsProperty = right.properties.find( - (prop): prop is t.ObjectProperty => t.isObjectProperty(prop) && t.isIdentifier(prop.key, { name: 'plugins' }) - ); - - if (pluginsProperty && t.isExpression(pluginsProperty.value)) { - modified = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); - } - } - }, - - // Handle ES Modules: export default { ... } - ExportDefaultDeclaration(path: NodePath) { - if (isESModule && t.isObjectExpression(path.node.declaration)) { - const pluginsProperty = path.node.declaration.properties.find( - (prop): prop is t.ObjectProperty => t.isObjectProperty(prop) && t.isIdentifier(prop.key, { name: 'plugins' }) - ); - - if (pluginsProperty && t.isExpression(pluginsProperty.value)) { - modified = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); - } - } - }, - - // Handle: const config = { plugins: ... }; export default config - VariableDeclarator(path: NodePath) { - if (isESModule && path.node.init && t.isObjectExpression(path.node.init)) { - const pluginsProperty = path.node.init.properties.find( - (prop): prop is t.ObjectProperty => t.isObjectProperty(prop) && t.isIdentifier(prop.key, { name: 'plugins' }) - ); - - if (pluginsProperty && t.isExpression(pluginsProperty.value)) { - modified = addZeroUiToPlugins(pluginsProperty.value, zeroUiPlugin); - } - } - }, - }); - - if (modified) { - return generate(ast).code; - } else { - console.warn(`[Zero-UI] Failed to automatically modify PostCSS config: ${source}`); - return null; // Could not automatically modify - } - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - console.warn(`[Zero-UI] Failed to parse PostCSS config: ${errorMessage}`); - return null; - } -} - -/** - * Helper function to add Zero-UI plugin to plugins configuration - * Handles both object format {plugin: {}} and array format [plugin] - */ -function addZeroUiToPlugins(pluginsNode: t.Expression, zeroUiPlugin: string): boolean { - if (t.isObjectExpression(pluginsNode)) { - // Object format: { 'plugin': {} } - pluginsNode.properties.unshift(t.objectProperty(t.stringLiteral(zeroUiPlugin), t.objectExpression([]))); - return true; - } else if (t.isArrayExpression(pluginsNode)) { - // Array format: ['plugin'] - pluginsNode.elements.unshift(t.stringLiteral(zeroUiPlugin)); - return true; - } - return false; -} - -/** - * Helper to create a zeroUI() call AST node - */ -function createZeroUICallNode(): t.CallExpression { - return t.callExpression(t.identifier('zeroUI'), []); -} - -/** - * Helper to create a zeroUI import AST node - */ -function createZeroUIImportNode(importPath: string): t.ImportDeclaration { - return t.importDeclaration([t.importDefaultSpecifier(t.identifier('zeroUI'))], t.stringLiteral(importPath)); -} - -/** - * Helper to process a plugins array - replaces Tailwind with zeroUI or adds zeroUI - */ -function processPluginsArray(pluginsArray: (t.Expression | t.SpreadElement | null)[]): boolean { - let tailwindIndex = -1; - let zeroUIIndex = -1; - - // Find existing plugins - pluginsArray.forEach((element, index) => { - if (element && t.isCallExpression(element)) { - if (t.isIdentifier(element.callee, { name: 'tailwindcss' })) { - tailwindIndex = index; - } else if (t.isIdentifier(element.callee, { name: 'zeroUI' })) { - zeroUIIndex = index; - } - } - }); - - // Replace Tailwind with Zero-UI - if (tailwindIndex >= 0) { - pluginsArray[tailwindIndex] = createZeroUICallNode(); - return true; - } - // Add Zero-UI if not present - else if (zeroUIIndex === -1) { - pluginsArray.push(createZeroUICallNode()); - return true; - } - - return false; -} - -/** - * Helper to handle config object (creates plugins array if needed) - */ -function processConfigObject(configObject: t.ObjectExpression): boolean { - const pluginsProperty = configObject.properties.find( - (prop): prop is t.ObjectProperty => t.isObjectProperty(prop) && t.isIdentifier(prop.key, { name: 'plugins' }) - ); - - if (pluginsProperty && t.isArrayExpression(pluginsProperty.value)) { - // Process existing plugins array - return processPluginsArray(pluginsProperty.value.elements); - } else if (!pluginsProperty) { - // Create new plugins array with zeroUI - configObject.properties.push(t.objectProperty(t.identifier('plugins'), t.arrayExpression([createZeroUICallNode()]))); - return true; - } - - return false; -} - -/** - * Helper to add zeroUI import to program - * Uses the FAANG approach: add at the beginning and let tooling handle organization - */ -function addZeroUIImport(programPath: NodePath, zeroUiPlugin: string): void { - const zeroUIImport = createZeroUIImportNode(zeroUiPlugin); - // Simple approach: add at the beginning - programPath.node.body.unshift(zeroUIImport); -} - -/** - * Parse Vite config TypeScript/JavaScript file and add Zero-UI plugin - * Replaces @tailwindcss/vite plugin if present, otherwise adds zeroUI plugin - * @param {string} source - The Vite config source code - * @param {string} zeroUiPlugin - The Zero-UI plugin import path - * @returns {string | null} The modified config code or null if no changes were made - */ -export function parseAndUpdateViteConfig(source: string, zeroUiPlugin: string): string | null { - try { - // Quick check - if already configured correctly, return original - const hasZeroUIImport = source.includes(zeroUiPlugin); - const hasZeroUIPlugin = source.includes('zeroUI()'); - const hasTailwindPlugin = source.includes('@tailwindcss/vite'); - - if (hasZeroUIImport && hasZeroUIPlugin && !hasTailwindPlugin) { - return source; - } - - const ast = parse(source, { sourceType: 'module', plugins: ['typescript', 'importMeta'] }); - - let modified = false; - - traverse(ast, { - Program(path: NodePath) { - if (!hasZeroUIImport) { - addZeroUIImport(path, zeroUiPlugin); - modified = true; - } - }, - - // Handle both direct export and variable assignment patterns - CallExpression(path: NodePath) { - if (t.isIdentifier(path.node.callee, { name: 'defineConfig' }) && path.node.arguments.length > 0 && t.isObjectExpression(path.node.arguments[0])) { - if (processConfigObject(path.node.arguments[0])) { - modified = true; - } - } - }, - - // Remove Tailwind import if we're replacing it - ImportDeclaration(path: NodePath) { - if (path.node.source.value === '@tailwindcss/vite' && hasTailwindPlugin) { - path.remove(); - modified = true; - } - }, - }); - - return modified ? generate(ast).code : null; - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - console.warn(`[Zero-UI] Failed to parse Vite config: ${errorMessage}`); - return null; - } -} - -function findLayoutWithBody(root = process.cwd()): string[] { - const matches: string[] = []; - function walk(dir: string): void { - for (const file of fs.readdirSync(dir)) { - const fullPath = path.join(dir, file); - const stat = fs.statSync(fullPath); - if (stat.isDirectory()) { - walk(fullPath); - } else if (/^layout\.(tsx|jsx|js|ts)$/.test(file)) { - const source = fs.readFileSync(fullPath, 'utf8'); - if (source.includes(' { - const matches = findLayoutWithBody(); - - if (matches.length !== 1) { - console.warn(`[Zero-UI] ⚠️ Found ${matches.length} layout files with tags. ` + `Expected exactly one. Skipping automatic injection.`); - return; - } - - const filePath = matches[0]; - const code = fs.readFileSync(filePath, 'utf8'); - - // Parse the file into an AST - const ast = parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); - - let hasImport = false; - traverse(ast, { - ImportDeclaration(path: NodePath) { - const specifiers = path.node.specifiers; - const source = path.node.source.value; - if (source === '@zero-ui/attributes') { - for (const spec of specifiers) { - if (t.isImportSpecifier(spec) && t.isIdentifier(spec.imported) && spec.imported.name === 'bodyAttributes') { - hasImport = true; - } - } - } - }, - }); - - traverse(ast, { - Program(path: NodePath) { - if (!hasImport) { - const importDecl = t.importDeclaration( - [t.importSpecifier(t.identifier('bodyAttributes'), t.identifier('bodyAttributes'))], - t.stringLiteral('@zero-ui/attributes') - ); - path.node.body.unshift(importDecl); - } - }, - }); - - // Inject JSX spread into - let injected = false; - traverse(ast, { - JSXOpeningElement(path: NodePath) { - if (!injected && t.isJSXIdentifier(path.node.name, { name: 'body' })) { - // Prevent duplicate injection - const hasSpread = path.node.attributes.some((attr) => t.isJSXSpreadAttribute(attr) && t.isIdentifier(attr.argument, { name: 'bodyAttributes' })); - if (!hasSpread) { - path.node.attributes.unshift(t.jsxSpreadAttribute(t.identifier('bodyAttributes'))); - injected = true; - } - } - }, - }); - - const output = generate( - ast, - { - /* retain lines, formatting */ - }, - code - ).code; - fs.writeFileSync(filePath, output, 'utf8'); - console.log(`[Zero-UI] ✅ Patched in ${filePath} with {...bodyAttributes}`); -} - -/** - * Parse a tsconfig/jsconfig JSON file using Babel (handles comments, trailing commas) - */ -export function parseJsonWithBabel(source: string): any { - try { - const ast = parseExpression(source, { sourceType: 'module', plugins: ['jsonStrings'] }); - // Convert Babel AST back to plain JS object - return eval(`(${generate(ast).code})`); - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - console.warn(`[Zero-UI] Failed to parse ${source}: ${errorMessage}`); - return null; - } -} -export function throwCodeFrame(nodeOrPath: Node | NodePath, filename: string, source: string, msg: string): never { - // Accept either a raw node or a NodePath - const node = (nodeOrPath as NodePath).node ?? (nodeOrPath as Node); - - if (!node.loc) throw new Error(msg); // defensive - - const { start, end } = node.loc; - const header = `${filename ?? ''}:${start.line}:${start.column}\n`; - const frame = codeFrameColumns(source, { start, end }, { highlightCode: true, linesAbove: 1, linesBelow: 1 }); - - throw new Error(`${header}${msg}\n${frame}`); -} diff --git a/packages/core/src/postcss/coming-soon/build-tool.ts b/packages/core/src/postcss/coming-soon/build-tool.ts index fb9f71f..1cf9aa7 100644 --- a/packages/core/src/postcss/coming-soon/build-tool.ts +++ b/packages/core/src/postcss/coming-soon/build-tool.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync } from 'fs'; import { glob } from 'glob'; -import { extractVariantsFromFiles } from '../ast-v2.cjs'; +import { extractVariants } from '../ast-parsing.cjs'; import { batchInjectDataAttributes, SEMANTIC_CONFIG } from './inject-attributes.js'; import { RefLocationTracker } from './collect-refs.cjs'; @@ -17,7 +17,7 @@ export async function buildWithDataAttributes() { // 2. Extract variants and populate ref tracker console.log(`📋 Processing ${files.length} files...`); - const allVariants = extractVariantsFromFiles(files); + const allVariants = extractVariants(files); console.log( '✅ Found variants:', @@ -72,7 +72,7 @@ export function createViteUIPlugin() { async buildEnd() { // Extract variants from all processed files const files = await glob('src/**/*.{ts,tsx,js,jsx}'); - variants = extractVariantsFromFiles(files); + variants = extractVariants(files); refTracker = globalRefTracker; console.log('📊 Extracted variants:', variants); diff --git a/packages/core/src/postcss/coming-soon/collect-refs.cts b/packages/core/src/postcss/coming-soon/collect-refs.cts index e14d14b..4650e04 100644 --- a/packages/core/src/postcss/coming-soon/collect-refs.cts +++ b/packages/core/src/postcss/coming-soon/collect-refs.cts @@ -1,7 +1,7 @@ import * as t from '@babel/types'; import { NodePath } from '@babel/traverse'; import * as babelTraverse from '@babel/traverse'; -import type { SetterMeta } from '../ast-v2.cjs'; +import type { SetterMeta } from '../ast-parsing.cjs'; const traverse = (babelTraverse as any).default; export interface RefLocation { diff --git a/packages/core/src/postcss/coming-soon/inject-attributes.ts b/packages/core/src/postcss/coming-soon/inject-attributes.ts index c6adc72..b80df85 100644 --- a/packages/core/src/postcss/coming-soon/inject-attributes.ts +++ b/packages/core/src/postcss/coming-soon/inject-attributes.ts @@ -4,7 +4,7 @@ import { NodePath } from '@babel/traverse'; import * as t from '@babel/types'; import { generate } from '@babel/generator'; import type { RefLocation, RefLocationTracker } from './collect-refs.cjs'; -import type { VariantData } from '../ast-v2.cjs'; +import type { VariantData } from '../ast-parsing.cjs'; const traverse = (babelTraverse as any).default; diff --git a/packages/core/src/postcss/helpers.cts b/packages/core/src/postcss/helpers.cts index 97907e5..62a7414 100644 --- a/packages/core/src/postcss/helpers.cts +++ b/packages/core/src/postcss/helpers.cts @@ -1,9 +1,10 @@ // src/postcss/helpers.cts 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-v2.cjs'; -import { extractVariants, VariantData } from './ast-v2.cjs'; +import { parseJsonWithBabel, parseAndUpdatePostcssConfig, parseAndUpdateViteConfig } from './ast-generating.cjs'; +import { extractVariants, VariantData } from './ast-parsing.cjs'; export interface ProcessVariantsResult { /** Array of deduplicated and sorted variant data */ @@ -34,17 +35,19 @@ export function toKebabCase(str: string): string { .toLowerCase(); } -export function findAllSourceFiles(patterns: string[] = CONFIG.CONTENT): string[] { - const { globSync } = require('glob'); - const cwd = process.cwd(); - - try { - return globSync(patterns, { cwd, ignore: Array.from(IGNORE_DIRS), absolute: true }); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn('[Zero-UI] Error finding source files:', errorMessage); - return []; - } +/** Return *absolute* paths of every JS/TS file we care about (à la Tailwind). */ +export function findAllSourceFiles(patterns: string[] = CONFIG.CONTENT, cwd: string = process.cwd()): string[] { + return fg + .sync(patterns, { + cwd, + ignore: IGNORE_DIRS, // <-- pass the actual ignore list + absolute: true, // give back absolute paths + onlyFiles: true, // skip directories + followSymbolicLinks: false, // Tailwind does the same + dot: false, // don't match dot-files (change if you need) + unique: true, // guard against duplicates + }) + .map((p) => path.resolve(p)); // normalize on Windows } /** @@ -181,50 +184,50 @@ export function isZeroUiInitialized(): boolean { * are inside the TypeScript program. Writes to ts/ jsconfig only if something * actually changes. */ -export async function patchConfigAlias(): Promise { +export async function patchTsConfig(): Promise { const cwd = process.cwd(); const configFile = fs.existsSync(path.join(cwd, 'tsconfig.json')) ? 'tsconfig.json' : fs.existsSync(path.join(cwd, 'jsconfig.json')) ? 'jsconfig.json' : null; // Ignore Vite fixtures — they patch their own config - if (fs.existsSync(path.join(cwd, 'vite.config.ts'))) return; + const hasViteConfig = ['js', 'mjs', 'ts', 'mts'].some((ext) => fs.existsSync(path.join(cwd, `vite.config.${ext}`))); + if (hasViteConfig) return; // Vite template patches its own tsconfig + if (!configFile) { return console.warn(`[Zero-UI] No ts/ jsconfig found in ${cwd}`); } const configPath = path.join(cwd, configFile); - const raw = fs.readFileSync(configPath, 'utf-8'); - const config = parseJsonWithBabel(raw); - if (!config) { - return console.warn(`[Zero-UI] Could not parse ${configFile}`); - } + const raw = fs.readFileSync(configPath, 'utf8'); + const config = parseJsonWithBabel(raw) ?? {}; - /* ---------- ensure alias ---------- */ config.compilerOptions ??= {}; config.compilerOptions.baseUrl ??= '.'; config.compilerOptions.paths ??= {}; - const expectedPaths = ['./.zero-ui/attributes.js']; - const currentPaths = config.compilerOptions.paths['@zero-ui/attributes']; let changed = false; - if (!Array.isArray(currentPaths) || JSON.stringify(currentPaths) !== JSON.stringify(expectedPaths)) { - config.compilerOptions.paths['@zero-ui/attributes'] = expectedPaths; + /* ---------- alias ---------- */ + const expectedAlias = ['./.zero-ui/attributes.js']; + if ( + !Array.isArray(config.compilerOptions.paths['@zero-ui/attributes']) || + JSON.stringify(config.compilerOptions.paths['@zero-ui/attributes']) !== JSON.stringify(expectedAlias) + ) { + config.compilerOptions.paths['@zero-ui/attributes'] = expectedAlias; changed = true; } - /* ---------- ensure .d.ts includes ---------- */ + /* ---------- includes ---------- */ + const beforeInclude = config.include ?? []; const extraIncludes = ['.zero-ui/**/*.d.ts', '.next/**/*.d.ts']; - config.include = Array.from(new Set([...(config.include ?? []), ...extraIncludes])); - - if (config.include.length !== (config.include ?? []).length) { - changed = true; - } - - /* ---------- write only if modified ---------- */ - if (changed) { - fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); - console.log(`[Zero-UI] Patched ${configFile} (paths &/or includes)`); + config.include = Array.from(new Set([...beforeInclude, ...extraIncludes])).sort(); + if (!changed && JSON.stringify(config.include) !== JSON.stringify(beforeInclude.sort())) changed = true; + + /* ---------- write ---------- */ + const output = JSON.stringify(config, null, 2) + '\n'; + if (changed && output !== raw) { + fs.writeFileSync(configPath, output); + console.log(`[Zero-UI] Patched ${configFile} (paths + includes)`); } } @@ -305,52 +308,23 @@ export default { * Patches vite.config.ts/js to include Zero-UI plugin and replace Tailwind CSS v4+ plugin if present * Only runs for Vite projects and uses AST parsing for robust config modification */ + export async function patchViteConfig(): Promise { const cwd = process.cwd(); - const viteConfigTsPath = path.join(cwd, 'vite.config.ts'); - const viteConfigJsPath = path.join(cwd, 'vite.config.js'); - const viteConfigMjsPath = path.join(cwd, 'vite.config.mjs'); - - // Determine which config file exists (prefer .ts over .js) - let viteConfigPath: string | undefined; - - if (fs.existsSync(viteConfigTsPath)) { - viteConfigPath = viteConfigTsPath; - } else if (fs.existsSync(viteConfigJsPath)) { - viteConfigPath = viteConfigJsPath; - } else if (fs.existsSync(viteConfigMjsPath)) { - viteConfigPath = viteConfigMjsPath; - } else { - return; // No Vite config found, skip patching - } + const candidates = ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs']; - const zeroUiPlugin = '@react-zero-ui/core/vite'; + const viteConfigPath = candidates.map((f) => path.join(cwd, f)).find((p) => fs.existsSync(p)); - try { - // Read existing config - const existingContent = fs.readFileSync(viteConfigPath, 'utf-8'); + if (!viteConfigPath) return console.warn(`[Zero-UI] No vite.config.ts/js found in ${cwd}`); // not a Vite project - // Parse and update config using AST - const updatedConfig = parseAndUpdateViteConfig(existingContent, zeroUiPlugin); + const zeroUiImportPath = CONFIG.VITE_PLUGIN; + const original = fs.readFileSync(viteConfigPath, 'utf8'); - if (updatedConfig && updatedConfig !== existingContent) { - fs.writeFileSync(viteConfigPath, updatedConfig); - const configFileName = path.basename(viteConfigPath); - console.log(`[Zero-UI] Updated ${configFileName} with Zero-UI plugin`); + const patched = parseAndUpdateViteConfig(original, zeroUiImportPath); - // Check if Tailwind was replaced - if (existingContent.includes('@tailwindcss/vite')) { - console.log(`[Zero-UI] Replaced @tailwindcss/vite with Zero-UI plugin`); - } - } else if (updatedConfig === null) { - const configFileName = path.basename(viteConfigPath); - console.log(`[Zero-UI] Could not automatically update ${configFileName}`); - console.log(`[Zero-UI] Please manually add "import zeroUI from '${zeroUiPlugin}'" and "zeroUI()" to your plugins array`); - } - // If updatedConfig === existingContent, config is already properly configured - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error('[Zero-UI] Error patching Vite config:', errorMessage); + if (patched && patched !== original) { + fs.writeFileSync(viteConfigPath, patched); + console.log(`[Zero-UI] Updated ${path.basename(viteConfigPath)} (added Zero-UI, removed Tailwind)`); } } @@ -359,6 +333,5 @@ export async function patchViteConfig(): Promise { */ export function hasViteConfig(): boolean { const cwd = process.cwd(); - const viteConfigFiles = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']; - return viteConfigFiles.some((configFile) => fs.existsSync(path.join(cwd, configFile))); + return ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs'].some((f) => fs.existsSync(path.join(cwd, f))); } diff --git a/packages/core/src/postcss/helpers.test.ts b/packages/core/src/postcss/helpers.test.ts new file mode 100644 index 0000000..abc760b --- /dev/null +++ b/packages/core/src/postcss/helpers.test.ts @@ -0,0 +1,293 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { + buildCss, + findAllSourceFiles, + generateAttributesFile, + isZeroUiInitialized, + patchPostcssConfig, + patchTsConfig, + patchViteConfig, + processVariants, + toKebabCase, +} from './helpers.cts'; +import { readFile, runTest } from '../utilities.ts'; +import { CONFIG } from '../config.cts'; +import path from 'node:path'; + +test('toKebabCase should convert a string to kebab case', () => { + assert.equal(toKebabCase('helloWorld'), 'hello-world'); +}); + +test('toKebabCase should throw an error if the input is not a string', () => { + assert.throws(() => toKebabCase(123 as unknown as string)); +}); + +const src = ` + import { useUI } from '@react-zero-ui/core'; + const featureEnabled = 'feature-enabled'; + const bool = false; + const theme = ['true']; +export function Component() { + const [isVisible, setIsVisible] = useUI('modal-visible', 'false'); + const [isEnabled, setIsEnabled] = useUI(bool ?? featureEnabled, theme[0]); +return ( +
+
+ Open Modal +
+
+); }`; + +const expectedVariants = [ + { key: 'feature-enabled', values: ['false', 'true'], initialValue: 'true' }, + { key: 'modal-visible', values: ['false', 'true'], initialValue: 'false' }, +]; +const initialValues = { 'data-feature-enabled': 'true', 'data-modal-visible': 'false' }; + +test('findAllSourceFiles Return *absolute* paths of every JS/TS file we care about', () => { + const result = findAllSourceFiles(); + assert.equal(result.length > 0, true); +}); + +test('processVariants should process variants', async () => { + await runTest({ 'src/app/component.tsx': src }, async () => { + const { finalVariants, initialValues, sourceFiles } = await processVariants(); + assert.deepStrictEqual(finalVariants, expectedVariants); + assert.deepStrictEqual(initialValues, initialValues); + assert.equal(sourceFiles.length, 1); + }); +}); + +// tiny helper - avoids CRLF ↔ LF failures on Windows runners +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 feature-enabled-true { + &:where(body[data-feature-enabled="true"] *) { @slot; } + [data-feature-enabled="true"] &, &[data-feature-enabled="true"] { @slot; } +} +@custom-variant modal-visible-false { + &:where(body[data-modal-visible="false"] *) { @slot; } + [data-modal-visible="false"] &, &[data-modal-visible="false"] { @slot; } +} +@custom-variant modal-visible-true { + &:where(body[data-modal-visible="true"] *) { @slot; } + [data-modal-visible="true"] &, &[data-modal-visible="true"] { @slot; } +} +`; + assert.strictEqual(norm(css), norm(expected), 'CSS snapshot mismatch'); +}); + +test('generateAttributesFile writes files once and stays stable', async () => { + await runTest({}, async () => { + /* ------------------------------------------------------------------ * + * 1. first call — files should be created + * ------------------------------------------------------------------ */ + const first = await generateAttributesFile(expectedVariants, initialValues); + assert.deepStrictEqual(first, { jsChanged: true, tsChanged: true }, 'first run must write both files'); + + const attrDir = path.join(process.cwd(), CONFIG.ZERO_UI_DIR); + const jsPath = path.join(attrDir, 'attributes.js'); + const tsPath = path.join(attrDir, 'attributes.d.ts'); + + const jsText = readFile(jsPath); + const tsText = readFile(tsPath); + // --- JS snapshot ----------------------------------------------------- + const expectedJs = `${CONFIG.HEADER} +export const bodyAttributes = { + "data-feature-enabled": "true", + "data-modal-visible": "false" +}; +`; + assert.strictEqual(norm(jsText), norm(expectedJs), 'attributes.js snapshot mismatch'); + // --- TS snapshot ----------------------------------------------------- + const expectedTs = `${CONFIG.HEADER} +export declare const bodyAttributes: { + "data-feature-enabled": "false" | "true"; + "data-modal-visible": "false" | "true"; +}; +`; + assert.strictEqual(norm(tsText), norm(expectedTs), 'attributes.d.ts snapshot mismatch'); + /* ------------------------------------------------------------------ * + * 2. second call — nothing should change + * ------------------------------------------------------------------ */ + const second = await generateAttributesFile(expectedVariants, initialValues); + assert.deepStrictEqual(second, { jsChanged: false, tsChanged: false }, 'second run must be a no-op'); + // files still identical + assert.strictEqual(norm(readFile(jsPath)), norm(expectedJs)); + assert.strictEqual(norm(readFile(tsPath)), norm(expectedTs)); + }); +}); + +test('isZeroUiInitialized returns false when attribute files are missing', async () => { + await runTest({}, async () => { + assert.equal(isZeroUiInitialized(), false); + }); +}); + +test('isZeroUiInitialized returns true when both attribute files exist and contain signatures', async () => { + await runTest( + { + [`${CONFIG.ZERO_UI_DIR}/attributes.js`]: `${CONFIG.HEADER} +export const bodyAttributes = {}; +`, + [`${CONFIG.ZERO_UI_DIR}/attributes.d.ts`]: `${CONFIG.HEADER} +export declare const bodyAttributes: {}; +`, + }, + async () => { + assert.equal(isZeroUiInitialized(), true); + } + ); +}); + +test('isZeroUiInitialized returns false when no attribute files exist', async () => { + assert.equal(isZeroUiInitialized(), false); +}); + +test('patchTsConfig writes alias & includes once, then stays stable', async () => { + await runTest( + { + 'tsconfig.json': `{ + "compilerOptions": { "target": "ES2022" }, + "include": ["src"] +}`, + }, + async () => { + /* 1 ▸ first run patches the file -------------------------------- */ + await patchTsConfig(); + + const after1 = JSON.parse(readFile('tsconfig.json')); + assert.deepStrictEqual(after1.compilerOptions.paths, { '@zero-ui/attributes': ['./.zero-ui/attributes.js'] }); + assert.deepStrictEqual(after1.include.sort(), ['src', '.zero-ui/**/*.d.ts', '.next/**/*.d.ts'].sort()); + + const snapshot = norm(readFile('tsconfig.json')); + + /* 2 ▸ second run is a no-op ------------------------------------- */ + await patchTsConfig(); + assert.strictEqual(norm(readFile('tsconfig.json')), snapshot); + } + ); +}); + +test('patchTsConfig is skipped when vite.config.ts exists', async () => { + await runTest({ 'tsconfig.json': `{ "compilerOptions": {} }`, 'vite.config.ts': `export default {}` /* triggers early return */ }, async () => { + await patchTsConfig(); + const ts = JSON.parse(readFile('tsconfig.json')); + assert.equal(ts.compilerOptions.paths?.['@zero-ui/attributes'], undefined); + }); +}); + +const ZERO = CONFIG.POSTCSS_PLUGIN; +const TAIL = '@tailwindcss/postcss'; + +// tiny helper for ordering assertion +const zeroBeforeTail = (s: string) => s.indexOf(ZERO) > -1 && s.indexOf(TAIL) > -1 && s.indexOf(ZERO) < s.indexOf(TAIL); + +/* 1 ▸ create postcss.config.js when absent (CommonJS default) */ +test('patchPostcssConfig creates postcss.config.js when no config exists', async () => { + await runTest({}, async () => { + await patchPostcssConfig(); + + const cfg = readFile('postcss.config.js'); + assert.ok(cfg.includes(ZERO), 'Zero-UI plugin missing'); + assert.ok(cfg.includes(TAIL), 'Tailwind plugin missing'); + assert.ok(zeroBeforeTail(cfg), 'Zero-UI must precede Tailwind'); + }); +}); + +/* 2 ▸ create postcss.config.mjs if package.json sets "type":"module" */ +test('patchPostcssConfig creates postcss.config.mjs for ESM package', async () => { + await runTest({ 'package.json': '{ "name":"x", "type":"module" }' }, async () => { + await patchPostcssConfig(); + + const cfg = readFile('postcss.config.mjs'); + assert.ok(cfg.includes(ZERO), 'Zero-UI plugin missing'); + assert.ok(zeroBeforeTail(cfg), 'Zero-UI must precede Tailwind'); + }); +}); + +/* 3 ▸ update existing config that lacks Zero-UI plugin */ +test('patchPostcssConfig inserts Zero-UI before Tailwind in existing config', async () => { + await runTest( + { + 'postcss.config.js': `module.exports = { + plugins: { + "${TAIL}": {}, + }, +};`, + }, + async () => { + await patchPostcssConfig(); + + const cfg = readFile('postcss.config.js'); + + assert.ok(cfg.includes(ZERO), 'Zero-UI plugin missing'); + assert.ok(zeroBeforeTail(cfg), 'Zero-UI must precede Tailwind'); + } + ); +}); + +/* 4 ▸ no-op when Zero-UI already present */ +test('patchPostcssConfig is idempotent when Zero-UI plugin already present', async () => { + const original = `module.exports = { + plugins: { + ${ZERO}: {}, + ${TAIL}: {}, + }, +};`; + + await runTest({ 'postcss.config.js': original }, async () => { + await patchPostcssConfig(); + assert.strictEqual(readFile('postcss.config.js'), original, 'Zero-UI plugin must be idempotent'); + }); +}); + +test('patchViteConfig creates defineConfig setup when no plugin array exists', async () => { + await runTest( + { + 'vite.config.ts': `import { defineConfig } from 'vite'; +export default defineConfig({ plugins: [] });`, + }, + async () => { + await patchViteConfig(); + const cfg = readFile('vite.config.ts'); + assert.ok(cfg.includes('@react-zero-ui/core/vite'), 'Zero-UI plugin missing'); + assert.ok(!cfg.includes('@tailwindcss/vite'), 'Tailwind should be removed'); + } + ); +}); + +test('patchViteConfig replaces Tailwind string literal', async () => { + await runTest({ 'vite.config.js': `export default { plugins: ['${TAIL}'] };` }, async () => { + await patchViteConfig(); + const cfg = readFile('vite.config.js'); + assert.ok(cfg.includes('@react-zero-ui/core/vite'), 'Zero-UI plugin missing'); + assert.ok(!cfg.includes('@tailwindcss/vite'), 'Tailwind should be removed'); + }); +}); + +test('patchViteConfig replaces tailwindcss() call', async () => { + await runTest( + { + 'vite.config.mjs': `import tailwindcss from '${TAIL}'; +export default { plugins: [react(),tailwindcss()] };`, + }, + async () => { + await patchViteConfig(); + const cfg = readFile('vite.config.mjs'); + console.log('cfg: ', cfg); + assert.ok(cfg.includes('@react-zero-ui/core/vite'), 'Zero-UI plugin missing'); + assert.ok(!cfg.includes('@tailwindcss/vite'), 'Tailwind should be removed'); + } + ); +}); diff --git a/packages/core/src/postcss/resolvers.cts b/packages/core/src/postcss/resolvers.cts index 1303afd..293eea0 100644 --- a/packages/core/src/postcss/resolvers.cts +++ b/packages/core/src/postcss/resolvers.cts @@ -1,6 +1,6 @@ import * as t from '@babel/types'; import { NodePath } from '@babel/traverse'; -import { throwCodeFrame } from './ast-v2.cjs'; +import { throwCodeFrame } from './ast-parsing.cjs'; import { generate } from '@babel/generator'; export interface ResolveOpts { throwOnFail?: boolean; // default false diff --git a/packages/core/src/postcss/scanner.cts b/packages/core/src/postcss/scanner.cts new file mode 100644 index 0000000..0fb9ddf --- /dev/null +++ b/packages/core/src/postcss/scanner.cts @@ -0,0 +1,37 @@ +// scanner.cts +export function scanVariantTokens(src: string, keys: Set): Map> { + /* 1. bootstrap the output */ + const out = new Map>(); + keys.forEach((k) => out.set(k, new Set())); + + if (keys.size === 0) return out; + + /* 2. tokenize exactly the same way Tailwind splits a class string + (see packages/tailwindcss/src/candidate.ts → TOK1 / TOK2) */ + const TOK1 = /[^<>"'`\s]*[^<>"'`\s:]/g; + const TOK2 = /[^<>"'`\s.(){}[\]#=%]*[^<>"'`\s.(){}[\]#=%:]/g; + const tokens = [...(src.match(TOK1) ?? []), ...(src.match(TOK2) ?? [])]; + + if (tokens.length === 0) return out; + + /* 3. longest-key-first prevents "modal" matching before "modal-visible" */ + const sortedKeys = [...keys].sort((a, b) => b.length - a.length); + + /* 4. scan every token */ + for (const tokRaw of tokens) { + // Split on ":" → everything **before** the final utility part + const segments = tokRaw.split(':').slice(0, -1); + + for (const seg of segments) { + for (const key of sortedKeys) { + if (seg.startsWith(key + '-')) { + const value = seg.slice(key.length + 1); + if (value) out.get(key)!.add(value); + break; // no smaller key can match + } + } + } + } + + return out; +} diff --git a/packages/core/src/postcss/scanner.test.ts b/packages/core/src/postcss/scanner.test.ts new file mode 100644 index 0000000..202b766 --- /dev/null +++ b/packages/core/src/postcss/scanner.test.ts @@ -0,0 +1,17 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { scanVariantTokens } from './scanner.cts'; + +test('scanVariantTokens', () => { + const src = ` +
+ Open Modal +
+ `; + // should return modal-visible-true, feature-enabled-false + const keys = new Set(['modal-visible', 'feature-enabled']); + const result = scanVariantTokens(src, keys); + + assert.deepStrictEqual(result.get('modal-visible'), new Set(['true'])); + assert.deepStrictEqual(result.get('feature-enabled'), new Set(['false'])); +}); diff --git a/packages/core/src/utilities.ts b/packages/core/src/utilities.ts new file mode 100644 index 0000000..dfb4c68 --- /dev/null +++ b/packages/core/src/utilities.ts @@ -0,0 +1,31 @@ +import path from 'path'; +import fs from 'fs'; +import os from 'os'; + +// Helper to create temp directory and run test +export async function runTest(files: Record, callback: () => Promise) { + 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 }); + } +} + +export function readFile(path: string) { + return fs.readFileSync(path, 'utf-8'); +} diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 0000000..b3552e8 --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.base.json", + /* — compile exactly one file — */ + "include": [ + "src/**/*" + ], + "exclude": [ + "src/postcss/coming-soon", + "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 + } +} \ No newline at end of file diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index ea9aa67..aa89b91 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,20 +1,26 @@ { - "extends": "../../tsconfig.base.json", - /* — compile exactly one file — */ - "include": ["src/**/*"], - "exclude": ["src/postcss/coming-soon"], - /* — 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 - } -} + "extends": "../../tsconfig.base.json", + /* — compile exactly one file — */ + "include": [ + "src/**/*" + ], + "exclude": [ + "src/postcss/coming-soon" + ], + /* — 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 + }, +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98dd8bc..c82a71e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,9 +117,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.1.10 version: 4.1.10 - glob: - specifier: ^11.0.0 - version: 11.0.3 + fast-glob: + specifier: ^3.3.3 + version: 3.3.3 postcss: specifier: ^8.5.5 version: 8.5.6 @@ -151,6 +151,9 @@ importers: lru-cache: specifier: ^10.4.3 version: 10.4.3 + tsx: + specifier: ^4.20.3 + version: 4.20.3 packages: @@ -536,18 +539,6 @@ packages: cpu: [x64] os: [win32] - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} - engines: {node: 20 || >=22} - - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -633,6 +624,18 @@ packages: cpu: [x64] os: [win32] + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@octokit/auth-token@4.0.0': resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} engines: {node: '>= 18'} @@ -897,18 +900,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.1.0: - resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} - engines: {node: '>=12'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@6.2.1: - resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} - engines: {node: '>=12'} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -969,6 +964,10 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -1169,15 +1168,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - enhanced-resolve@5.18.2: resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} engines: {node: '>=10.13.0'} @@ -1333,12 +1326,19 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -1347,6 +1347,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -1366,10 +1370,6 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - framer-motion@12.19.2: resolution: {integrity: sha512-0cWMLkYr+i0emeXC4hkLF+5aYpzo32nRdQ0D/5DI460B3O7biQ3l2BpDzIGsAHYuZ0fpBP0DC8XBkVf6RPAlZw==} peerDependencies: @@ -1426,15 +1426,14 @@ packages: get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@11.0.3: - resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} - engines: {node: 20 || >=22} - hasBin: true - glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -1610,6 +1609,10 @@ packages: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-obj@2.0.0: resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} engines: {node: '>=8'} @@ -1660,10 +1663,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jackspeak@4.1.1: - resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} - engines: {node: 20 || >=22} - jiti@2.4.2: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true @@ -1803,10 +1802,6 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.1.0: - resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} - engines: {node: 20 || >=22} - lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -1830,14 +1825,18 @@ packages: resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==} engines: {node: '>=10'} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minimatch@10.0.3: - resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} - engines: {node: 20 || >=22} - minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1992,9 +1991,6 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2024,13 +2020,13 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@2.0.0: - resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} - engines: {node: 20 || >=22} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + playwright-core@1.53.1: resolution: {integrity: sha512-Z46Oq7tLAyT0lGoFx4DOuB1IA9D1TPj0QkYxpPVUnGDqHHvDpCftu1J2hM2PiWsNMoZh8+LQaarAWcDfPBc6zg==} engines: {node: '>=18'} @@ -2066,6 +2062,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-lru@4.0.1: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} @@ -2128,6 +2127,13 @@ packages: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -2196,10 +2202,6 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} @@ -2238,10 +2240,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -2258,10 +2256,6 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.0: - resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} - engines: {node: '>=12'} - strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -2309,6 +2303,10 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + trim-newlines@3.0.1: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} @@ -2435,10 +2433,6 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2771,21 +2765,6 @@ snapshots: '@img/sharp-win32-x64@0.34.2': optional: true - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.0': - dependencies: - '@isaacs/balanced-match': 4.0.1 - - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.2 @@ -2841,6 +2820,18 @@ snapshots: '@next/swc-win32-x64-msvc@15.3.3': optional: true + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + '@octokit/auth-token@4.0.0': {} '@octokit/core@5.2.1': @@ -3071,14 +3062,10 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.1.0: {} - ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 - ansi-styles@6.2.1: {} - argparse@2.0.1: {} array-buffer-byte-length@1.0.2: @@ -3160,6 +3147,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -3379,12 +3370,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} - emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} - enhanced-resolve@5.18.2: dependencies: graceful-fs: 4.2.11 @@ -3649,10 +3636,22 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -3661,6 +3660,10 @@ snapshots: dependencies: flat-cache: 4.0.1 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -3682,11 +3685,6 @@ snapshots: dependencies: is-callable: 1.2.7 - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - framer-motion@12.19.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: motion-dom: 12.19.0 @@ -3747,18 +3745,13 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - glob-parent@6.0.2: + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 - glob@11.0.3: + glob-parent@6.0.2: dependencies: - foreground-child: 3.3.1 - jackspeak: 4.1.1 - minimatch: 10.0.3 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 2.0.0 + is-glob: 4.0.3 glob@7.2.3: dependencies: @@ -3934,6 +3927,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-number@7.0.0: {} + is-obj@2.0.0: {} is-plain-obj@1.1.0: {} @@ -3981,10 +3976,6 @@ snapshots: isexe@2.0.0: {} - jackspeak@4.1.1: - dependencies: - '@isaacs/cliui': 8.0.2 - jiti@2.4.2: {} js-tokens@4.0.0: {} @@ -4089,8 +4080,6 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.1.0: {} - lru-cache@6.0.0: dependencies: yallist: 4.0.0 @@ -4119,11 +4108,14 @@ snapshots: type-fest: 0.18.1 yargs-parser: 20.2.9 - min-indent@1.0.1: {} + merge2@1.4.1: {} - minimatch@10.0.3: + micromatch@4.0.8: dependencies: - '@isaacs/brace-expansion': 5.0.0 + braces: 3.0.3 + picomatch: 2.3.1 + + min-indent@1.0.1: {} minimatch@3.1.2: dependencies: @@ -4292,8 +4284,6 @@ snapshots: p-try@2.2.0: {} - package-json-from-dist@1.0.1: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -4317,13 +4307,10 @@ snapshots: path-parse@1.0.7: {} - path-scurry@2.0.0: - dependencies: - lru-cache: 11.1.0 - minipass: 7.1.2 - picocolors@1.1.1: {} + picomatch@2.3.1: {} + playwright-core@1.53.1: {} playwright@1.53.1: @@ -4352,6 +4339,8 @@ snapshots: punycode@2.3.1: {} + queue-microtask@1.2.3: {} + quick-lru@4.0.1: {} react-dom@19.1.0(react@19.1.0): @@ -4451,6 +4440,12 @@ snapshots: retry@0.13.1: {} + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -4563,8 +4558,6 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - signal-exit@4.1.0: {} - simple-swizzle@0.2.2: dependencies: is-arrayish: 0.3.2 @@ -4605,12 +4598,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.0 - string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 @@ -4638,10 +4625,6 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.0: - dependencies: - ansi-regex: 6.1.0 - strip-bom@3.0.0: {} strip-indent@3.0.0: @@ -4676,6 +4659,10 @@ snapshots: through@2.3.8: {} + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + trim-newlines@3.0.1: {} tsconfig-paths@3.15.0: @@ -4836,12 +4823,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.1 - string-width: 5.1.2 - strip-ansi: 7.1.0 - wrappy@1.0.2: {} xpath@0.0.34: {} From 6cd98152965d3efa86b68a7b7fda011becdf9943 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Tue, 8 Jul 2025 13:35:19 -0700 Subject: [PATCH 29/34] updated tests --- .../fixtures/next/.zero-ui/attributes.d.ts | 4 +- .../core/__tests__/fixtures/next/app/page.tsx | 2 +- .../__tests__/fixtures/next/app/test/page.jsx | 2 +- .../__tests__/fixtures/next/tsconfig.json | 6 +- .../fixtures/vite/.zero-ui/attributes.d.ts | 4 +- .../core/__tests__/fixtures/vite/src/App.tsx | 2 +- packages/core/__tests__/unit/index.test.cjs | 742 +----------------- packages/core/package.json | 2 +- packages/core/src/index.ts | 7 + packages/core/src/postcss/helpers.cts | 11 +- packages/core/src/postcss/scanner.test.ts | 23 + 11 files changed, 78 insertions(+), 727 deletions(-) diff --git a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts index f043994..f0f376e 100644 --- a/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts +++ b/packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts @@ -4,9 +4,9 @@ export declare const bodyAttributes: { "data-mobile": "false" | "true"; "data-number": "1" | "2"; "data-scope": "off" | "on"; - "data-theme": "dark" | "light" | "three-dark" | "three-light"; + "data-theme": "dark" | "light"; "data-theme-2": "dark" | "light"; - "data-theme-three": "light"; + "data-theme-three": "dark" | "light"; "data-toggle-boolean": "false" | "true"; "data-toggle-function": "black" | "blue" | "green" | "red" | "white"; "data-use-effect-theme": "dark" | "light"; diff --git a/packages/core/__tests__/fixtures/next/app/page.tsx b/packages/core/__tests__/fixtures/next/app/page.tsx index 77889dc..ac22b5c 100644 --- a/packages/core/__tests__/fixtures/next/app/page.tsx +++ b/packages/core/__tests__/fixtures/next/app/page.tsx @@ -6,7 +6,7 @@ import FAQ from './FAQ'; export default function Page() { const [, setTheme] = useUI<'light' | 'dark'>('theme', 'light'); const [, setTheme2] = useUI<'light' | 'dark'>('theme-2', 'light'); - const [, setThemeThree] = useUI<'light' | 'dark'>('themeThree', 'light'); + const [, setThemeThree] = useUI<'light' | 'dark'>('theme-three', 'light'); const [, setToggle] = useUI<'true' | 'false'>('toggle-boolean', 'true'); const [, setNumber] = useUI<'1' | '2'>('number', '1'); const [, setOpen] = useUI<'open' | 'closed'>('faq', 'closed'); // Same key everywhere! diff --git a/packages/core/__tests__/fixtures/next/app/test/page.jsx b/packages/core/__tests__/fixtures/next/app/test/page.jsx index b1e0bcc..3c0a41d 100644 --- a/packages/core/__tests__/fixtures/next/app/test/page.jsx +++ b/packages/core/__tests__/fixtures/next/app/test/page.jsx @@ -7,7 +7,7 @@ import FAQ from '../FAQ'; export default function Page() { const [, setTheme] = useUI('theme', 'light'); const [, setTheme2] = useUI('theme-2', 'light'); - const [, setThemeThree] = useUI('themeThree', 'light'); + const [, setThemeThree] = useUI('theme-three', 'light'); const [, setToggle] = useUI('toggle-boolean', 'true'); const [, setNumber] = useUI('number', '1'); const [, setOpen] = useUI('faq', 'closed'); // Same key everywhere! diff --git a/packages/core/__tests__/fixtures/next/tsconfig.json b/packages/core/__tests__/fixtures/next/tsconfig.json index 2da9377..d73e90d 100644 --- a/packages/core/__tests__/fixtures/next/tsconfig.json +++ b/packages/core/__tests__/fixtures/next/tsconfig.json @@ -30,12 +30,12 @@ } }, "include": [ - "next-env.d.ts", - ".next/types/**/*.ts", "**/*.ts", "**/*.tsx", ".next/**/*.d.ts", - ".zero-ui/**/*.d.ts" + ".next/types/**/*.ts", + ".zero-ui/**/*.d.ts", + "next-env.d.ts" ], "exclude": [ "node_modules" diff --git a/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts b/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts index f043994..f0f376e 100644 --- a/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts +++ b/packages/core/__tests__/fixtures/vite/.zero-ui/attributes.d.ts @@ -4,9 +4,9 @@ export declare const bodyAttributes: { "data-mobile": "false" | "true"; "data-number": "1" | "2"; "data-scope": "off" | "on"; - "data-theme": "dark" | "light" | "three-dark" | "three-light"; + "data-theme": "dark" | "light"; "data-theme-2": "dark" | "light"; - "data-theme-three": "light"; + "data-theme-three": "dark" | "light"; "data-toggle-boolean": "false" | "true"; "data-toggle-function": "black" | "blue" | "green" | "red" | "white"; "data-use-effect-theme": "dark" | "light"; diff --git a/packages/core/__tests__/fixtures/vite/src/App.tsx b/packages/core/__tests__/fixtures/vite/src/App.tsx index 4bd9761..180fc03 100644 --- a/packages/core/__tests__/fixtures/vite/src/App.tsx +++ b/packages/core/__tests__/fixtures/vite/src/App.tsx @@ -7,7 +7,7 @@ import './App.css'; export default function App() { const [, setTheme] = useUI<'light' | 'dark'>('theme', 'light'); const [, setTheme2] = useUI<'light' | 'dark'>('theme-2', 'light'); - const [, setThemeThree] = useUI<'light' | 'dark'>('themeThree', 'light'); + const [, setThemeThree] = useUI<'light' | 'dark'>('theme-three', 'light'); const [, setToggle] = useUI<'true' | 'false'>('toggle-boolean', 'true'); const [, setNumber] = useUI<'1' | '2'>('number', '1'); const [, setOpen] = useUI<'open' | 'closed'>('faq', 'closed'); // Same key everywhere! diff --git a/packages/core/__tests__/unit/index.test.cjs b/packages/core/__tests__/unit/index.test.cjs index 1705a41..895d5d2 100644 --- a/packages/core/__tests__/unit/index.test.cjs +++ b/packages/core/__tests__/unit/index.test.cjs @@ -116,156 +116,6 @@ test('generates body attributes file correctly when kebab-case is used', async ( ); }); -test('detects JavaScript setValue calls', async () => { - await runTest( - { - 'src/modal.js': ` - import { useUI } from '@react-zero-ui/core'; - - function Modal() { - const [modal, setModal] = useUI('modal', 'closed'); - - return ( -
- - - -
- ); - } - `, - }, - (result) => { - console.log('\n🔍 JavaScript Detection Test:'); - - const states = ['closed', 'open', 'minimized', 'fullscreen']; - states.forEach((state) => { - assert(result.css.includes(`@custom-variant modal-${state}`), `Should detect modal-${state}`); - }); - - const content = fs.readFileSync(getAttrFile(), 'utf-8'); - console.log('Initial value:', content.match(/"data-modal": "[^"]+"/)[0]); - } - ); -}); - -test('handles string boolean values', async () => { - await runTest( - { - 'app/toggle.tsx': ` - import { useUI } from '@react-zero-ui/core'; - - function Toggle() { - const [isOpen, setIsOpen] = useUI<'true' | 'false'>('drawer', 'false'); - const [checked, setChecked] = useUI<'true' | 'false'>('checkbox', 'true'); - - return ( -
- - -
- ); - } - `, - }, - (result) => { - console.log('\n🔍 String Boolean Values Test:'); - - assert(result.css.includes('@custom-variant drawer-true'), 'Should have drawer-true'); - assert(result.css.includes('@custom-variant drawer-false'), 'Should have drawer-false'); - assert(result.css.includes('@custom-variant checkbox-true'), 'Should have checkbox-true'); - assert(result.css.includes('@custom-variant checkbox-false'), 'Should have checkbox-false'); - - const content = fs.readFileSync(getAttrFile(), 'utf-8'); - console.log('String boolean attributes:', content); - } - ); -}); - -test('handles kebab-case conversion', async () => { - await runTest( - { - 'src/styles.jsx': ` - import { useUI } from '@react-zero-ui/core'; - - function StyledComponent() { - const [primaryColor, setPrimaryColor] = useUI('primaryColor', 'deepBlue'); - const [bgStyle, setBgStyle] = useUI('backgroundColor', 'lightGray'); - - return ( -
- - -
- ); - } - `, - }, - (result) => { - console.log('\n🔍 Kebab-case Test:'); - - // Check CSS has kebab-case - assert(result.css.includes('@custom-variant primary-color-deep-blue'), 'Should convert to kebab-case'); - assert(result.css.includes('@custom-variant primary-color-dark-red'), 'Should convert to kebab-case'); - assert(result.css.includes('@custom-variant background-color-light-gray'), 'Should convert to kebab-case'); - assert(result.css.includes('@custom-variant background-color-pale-yellow'), 'Should convert to kebab-case'); - - // Check attributes use kebab-case keys - const content = fs.readFileSync(getAttrFile(), 'utf-8'); - assert(content.includes('"data-primary-color"'), 'Attribute key should be kebab-case'); - assert(content.includes('"data-background-color"'), 'Attribute key should be kebab-case'); - console.log('Kebab-case attributes:', content); - } - ); -}); - -test('handles conditional expressions', async () => { - await runTest( - { - 'app/conditional.jsx': ` - import { useUI } from '@react-zero-ui/core'; - - function ConditionalComponent({ isActive, mode }) { - const [state, setState] = useUI('state', 'default'); - - return ( -
- - - -
- ); - } - `, - }, - (result) => { - console.log('\n🔍 Conditional Expressions Test:'); - - const expectedStates = ['default', 'active', 'inactive', 'night', 'day', 'fallback']; - expectedStates.forEach((state) => { - assert(result.css.includes(`@custom-variant state-${state}`), `Should detect state-${state}`); - }); - } - ); -}); - test('handles multiple files and deduplication', async () => { await runTest( { @@ -273,7 +123,7 @@ test('handles multiple files and deduplication', async () => { import { useUI } from '@react-zero-ui/core'; function Header() { const [theme, setTheme] = useUI('theme', 'light'); - return ; + return ; } `, 'src/footer.jsx': ` @@ -281,9 +131,8 @@ test('handles multiple files and deduplication', async () => { function Footer() { const [theme, setTheme] = useUI('theme', 'light'); return
- - - + + Footer
; } @@ -293,10 +142,7 @@ test('handles multiple files and deduplication', async () => { function Sidebar() { const [theme, setTheme] = useUI<'light' | 'dark' | 'auto'>('theme', 'light'); return
- - - - + Sidebar
; } @@ -341,25 +187,6 @@ test('throws on empty string initial value', () => { assert.throws(() => toKebabCase('')); }); -test('valid edge cases: underscores + missing initial', async () => { - await runTest( - { - 'src/edge.jsx': ` - import { useUI } from '@react-zero-ui/core'; - function EdgeCases() { - const [noInitial] = useUI('noInitial_value'); - const [, setOnlySetter] = useUI('only_setter_key', 'yes'); - setOnlySetter('set_later'); - return
Edge cases
; - } - `, - }, - () => { - assert.throws(() => {}); - } - ); -}); - test('watches for file changes', async () => { if (process.env.NODE_ENV === 'production') { console.log('Skipping watch test in production'); @@ -371,8 +198,8 @@ test('watches for file changes', async () => { 'src/initial.jsx': ` import { useUI } from '@react-zero-ui/core'; function Initial() { - const [state, setState] = useUI('watchTest', 'initial'); - return
Initial
;e + const [state, setState] = useUI('watch-test', 'initial'); + return
Initial
;e } `, }, @@ -386,8 +213,8 @@ test('watches for file changes', async () => { ` import { useUI } from '@react-zero-ui/core'; function New() { - const [state, setState] = useUI('watchTest', 'initial'); - return ; + const [state, setState] = useUI('watch-test', 'initial'); + return ; } ` ); @@ -437,24 +264,6 @@ test('ignores node_modules and hidden directories', async () => { ); }); -test('handles deeply nested file structures', async () => { - await runTest( - { - 'src/features/auth/components/login/LoginForm.jsx': ` - import { useUI } from '@react-zero-ui/core'; - function LoginForm() { - const [authState, setAuthState] = useUI('authState', 'loggedOut'); - return ; - } - `, - }, - (result) => { - assert(result.css.includes('@custom-variant auth-state-logged-out')); - assert(result.css.includes('@custom-variant auth-state-logged-in')); - } - ); -}); - test('handles large projects efficiently - 500 files', async function () { const files = {}; @@ -486,31 +295,6 @@ test('handles large projects efficiently - 500 files', async function () { }); }); -test('handles special characters in values', async () => { - await runTest( - { - 'src/special.jsx': ` - import { useUI } from '@react-zero-ui/core'; - function Special() { - const [state, setState] = useUI('special', 'default'); - return ( -
- - - -
- ); - } - `, - }, - (result) => { - assert(result.css.includes('@custom-variant special-with-dash')); - assert(result.css.includes('@custom-variant special-with-underscore')); - assert(result.css.includes('@custom-variant special-123numeric')); - } - ); -}); - test('handles concurrent file modifications', async () => { // Test that rapid changes don't cause issues await runTest( @@ -1327,37 +1111,6 @@ export default config`; } }); -test('Vite config - handles parse errors gracefully', async () => { - const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zero-ui-vite-test')); - const originalCwd = process.cwd(); - - try { - process.chdir(testDir); - - // Create invalid Vite config with syntax errors - const invalidConfig = `import { defineConfig } from 'vite' -import tailwindcss from '@tailwindcss/vite' - -export default defineConfig({ - plugins: [ - tailwindcss( // missing closing parenthesis - ] - // missing closing brace`; - fs.writeFileSync('vite.config.ts', invalidConfig); - const originalContent = fs.readFileSync('vite.config.ts', 'utf-8'); - - // Run patchViteConfig (should not throw) - patchViteConfig(); - - // Verify config was not modified due to parse error - const updatedContent = fs.readFileSync('vite.config.ts', 'utf-8'); - assert.equal(originalContent, updatedContent, 'Should not modify config with parse errors'); - } finally { - process.chdir(originalCwd); - fs.rmSync(testDir, { recursive: true, force: true }); - } -}); - test('Vite config - handles config with no plugins array', async () => { const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zero-ui-vite-test')); const originalCwd = process.cwd(); @@ -1424,23 +1177,6 @@ export default defineConfig({ } }); -/* -The following tests are for advanced edge cases --------------------------------------------------------------------------------------------- ----------------------------------------------- ----------------------------------------------- ----------------------------------------------- ----------------------------------------------- ----------------------------------------------- --------------------------------------------------------------------------------------------- ----------------------------------------------- ----------------------------------------------- ----------------------------------------------- ----------------------------------------------- ----------------------------------------------- ----------------------------------------------- -*/ - test('generated variants for initial value without setterFn', async () => { await runTest( { @@ -1459,256 +1195,22 @@ test('generated variants for initial value without setterFn', async () => { } ); }); - -test('handles complex string boolean toggle patterns', async () => { - await runTest( - { - 'app/boolean-edge-cases.tsx': ` - import { useUI } from '@react-zero-ui/core'; - - function Component() { - const [isVisible, setIsVisible] = useUI('modal-visible', 'false'); - const [isEnabled, setIsEnabled] = useUI('feature-enabled', 'true'); - - // Complex string boolean patterns that should result in true/false - const handleToggle = () => setIsVisible(prev => prev === 'false' ? 'true' : 'false'); - const handleConditional = () => setIsVisible(condition ? 'true' : 'false'); - const handleLogical = () => setIsEnabled(loading ? 'false' : 'true'); - - return
Test
; - } - `, - }, - (result) => { - // const content = fs.readFileSync(getAttrFile(), 'utf-8'); - console.log('\n📄 String boolean edge cases:'); - - // Should only have true/false variants for string booleans - assert(result.css.includes('@custom-variant modal-visible-true')); - assert(result.css.includes('@custom-variant modal-visible-false')); - assert(result.css.includes('@custom-variant feature-enabled-true')); - assert(result.css.includes('@custom-variant feature-enabled-false')); - - // Should NOT have any other variants - assert(!result.css.includes('@custom-variant modal-visible-prev')); - assert(!result.css.includes('@custom-variant modal-visible-condition')); - } - ); -}); - -test.skip('extracts values from deeply nested function calls', async () => { - await runTest( - { - 'app/nested-calls.jsx': ` - import { useUI } from '@react-zero-ui/core'; - - function Component() { - const [theme, setTheme] = useUI('theme', 'light'); - - // Nested in useEffect - useEffect(() => { - if (darkMode) { - setTheme('dark'); - } else { - setTheme('auto'); - } - }, [darkMode]); - - // Nested in event handler - const handleClick = useCallback(() => { - const newTheme = calculateTheme(); - setTheme(newTheme === 'system' ? 'system' : 'manual'); - }, []); - - // Nested in JSX - return ( -
- - -
- ); - } - `, - }, - (result) => { - console.log('\n📄 Nested calls extraction:'); - - // Should extract all literal values - assert(result.css.includes('@custom-variant theme-light')); // initial - assert(result.css.includes('@custom-variant theme-dark')); // from useEffect - assert(result.css.includes('@custom-variant theme-auto')); // from useEffect - assert(result.css.includes('@custom-variant theme-system')); // from ternary - assert(result.css.includes('@custom-variant theme-manual')); // from ternary - assert(result.css.includes('@custom-variant theme-contrast')); // from onClick - assert(result.css.includes('@custom-variant theme-neon')); // from onChange - assert(result.css.includes('@custom-variant theme-retro')); // from onChange - // Note: 'neon' and 'retro' from option values won't be extracted since they're not setter calls - } - ); -}); - -test('handles ternary and logical expressions', async () => { - await runTest( - { - 'app/expressions.tsx': ` - import { useUI } from '@react-zero-ui/core'; - - function Component({ isDark, isLoading, userPreference }) { - const [status, setStatus] = useUI('status', 'idle'); - const [mode, setMode] = useUI('display-mode', 'normal'); - - // Ternary expressions - const updateStatus = () => { - setStatus(isLoading ? 'loading' : 'ready'); - }; - - // Nested ternary - const updateMode = () => { - setMode(isDark ? 'dark' : (userPreference === 'auto' ? 'auto' : 'light')); - }; - - // Logical expressions - const handleError = () => { - setStatus(hasError && 'error' || 'success'); - }; - - // Complex logical - const handleComplex = () => { - setMode(loading && 'disabled' || ready && 'active' || 'pending'); - }; - - return
Test
; - } - `, - }, - (result) => { - console.log('\n📄 Expression handling:'); - - // Ternary values - assert(result.css.includes('@custom-variant status-loading')); - assert(result.css.includes('@custom-variant status-ready')); - assert(result.css.includes('@custom-variant status-idle')); - assert(result.css.includes('@custom-variant status-error')); - assert(result.css.includes('@custom-variant status-success')); - - // Nested ternary values - assert(result.css.includes('@custom-variant display-mode-dark')); - assert(result.css.includes('@custom-variant display-mode-auto')); - assert(result.css.includes('@custom-variant display-mode-light')); - - // Complex logical values - assert(result.css.includes('@custom-variant display-mode-disabled')); - assert(result.css.includes('@custom-variant display-mode-active')); - assert(result.css.includes('@custom-variant display-mode-pending')); - } - ); -}); - -test('resolves constants', async () => { - await runTest( - { - 'app/constants-resolve.jsx': ` - import { useUI } from '@react-zero-ui/core'; -export const THEME_DARK = 'dark'; -export const THEME_LIGHT = 'light'; -export const SIZES = { SMALL: 'sm', LARGE: 'lg' }; -const isDark = true; -const isSmall = true; -export function Pages() { - const [theme, setTheme] = useUI('theme', 'default'); - const [size, setSize] = useUI('size', 'medium'); - // Using constants - const toggleTheme = () => { - setTheme(isDark ? THEME_LIGHT : THEME_DARK); - }; - // Using object properties - const updateSize = () => { - setSize(isSmall ? SIZES.SMALL : SIZES.LARGE); - }; - // Local constants - const STATUS_PENDING = 'pending-state'; - const handleStatus = () => { - setTheme(STATUS_PENDING); - }; - return
Test
; -} - - `, - }, - (result) => { - console.log('\n📄 Constants:'); - - assert(result.css.includes('@custom-variant theme-dark')); - assert(result.css.includes('@custom-variant theme-default')); - assert(result.css.includes('@custom-variant theme-light')); - // Does not work for VARIABLE.PROPERTY - assert(result.css.includes('@custom-variant size-sm')); - assert(result.css.includes('@custom-variant size-medium')); - assert(result.css.includes('@custom-variant size-lg')); - assert(result.css.includes('@custom-variant theme-pending-state')); - } - ); -}); - -test('resolves constants and imported values -- COMPLEX --', async () => { - await runTest( - { - 'app/constants.ts': ` - export const THEME_DARK = 'dark'; - export const THEME_LIGHT = 'light'; - export const SIZES = { - SMALL: 'sm', - LARGE: 'lg' - }; - `, - 'app/component.jsx': ` - import { useUI } from '@react-zero-ui/core'; - import { THEME_DARK, THEME_LIGHT, SIZES } from './constants'; - console.log('THEME_DARK', THEME_DARK); - function Component() { - const [theme, setTheme] = useUI('theme', 'default'); - const [size, setSize] = useUI('size', 'medium'); - - // Using constants - const toggleTheme = () => { - setTheme(isDark ? THEME_LIGHT : THEME_DARK); - }; - - // Using object properties - const updateSize = () => { - setSize(isSmall ? SIZES.SMALL : SIZES.LARGE); - }; - - // Local constants - const STATUS_PENDING = 'pending-state'; - const handleStatus = () => { - setTheme(STATUS_PENDING); - }; - - return
Test
; - } - `, - }, - (result) => { - console.log('\n📄 Constants resolution:'); - - // Should resolve local constants - assert(result.css.includes('@custom-variant theme-pending-state')); - assert(result.css.includes('@custom-variant theme-dark')); - assert(result.css.includes('@custom-variant theme-light')); - assert(result.css.includes('@custom-variant size-small')); - assert(result.css.includes('@custom-variant size-large')); - - // Note: Import resolution is complex and might not work initially - // This test documents the expected behavior for future enhancement - } - ); -}); +/* +The following tests are for advanced edge cases +-------------------------------------------------------------------------------------------- +---------------------------------------------- +---------------------------------------------- +---------------------------------------------- +---------------------------------------------- +---------------------------------------------- +-------------------------------------------------------------------------------------------- +---------------------------------------------- +---------------------------------------------- +---------------------------------------------- +---------------------------------------------- +---------------------------------------------- +---------------------------------------------- +*/ test.skip('handles all common setter patterns - full coverage sanity check - COMPLEX', async () => { await runTest( @@ -1766,191 +1268,6 @@ test.skip('handles all common setter patterns - full coverage sanity check - COM ); }); -test('handles arrow functions and function expressions', async () => { - await runTest( - { - 'app/functions.jsx': ` - import { useUI } from '@react-zero-ui/core'; - - function Component() { - const [state, setState] = useUI('component-state', 'initial'); - - // Arrow function with expression body - const quickSet = () => setState('quick'); - - // Arrow function with block body - const blockSet = () => { - return setState('block'); - }; - - // Function expression - const funcExpr = function() { - setState('function'); - }; - - // Immediately invoked - (() => setState('immediate'))(); - - // Passed as callback - setTimeout(() => setState('delayed'), 1000); - - // Complex return logic - const conditionalSet = () => { - if (condition) return setState('conditional-true'); - return setState('conditional-false'); - }; - - return
Test
; - } - `, - }, - (result) => { - console.log('\n📄 Function expressions:'); - - assert(result.css.includes('@custom-variant component-state-quick')); - assert(result.css.includes('@custom-variant component-state-block')); - assert(result.css.includes('@custom-variant component-state-function')); - assert(result.css.includes('@custom-variant component-state-immediate')); - assert(result.css.includes('@custom-variant component-state-delayed')); - assert(result.css.includes('@custom-variant component-state-conditional-true')); - assert(result.css.includes('@custom-variant component-state-conditional-false')); - } - ); -}); - -test('handles multiple setters for same state key', async () => { - await runTest( - { - 'app/multiple-setters.jsx': ` - import { useUI } from '@react-zero-ui/core'; - - function ComponentA() { - const [theme, setTheme] = useUI('global-theme', 'light'); - const handleClick = () => setTheme('dark'); - return
A
; - } - - function ComponentB() { - const [theme, setGlobalTheme] = useUI('global-theme', 'light'); - const handleToggle = () => setGlobalTheme('auto'); - return
B
; - } - - function ComponentC() { - const [, updateTheme] = useUI('global-theme', 'light'); - const handleSystem = () => updateTheme('system'); - return
C
; - } - `, - }, - (result) => { - console.log('\n📄 Multiple setters:'); - - // Should combine all values from different setters for same key - assert(result.css.includes('@custom-variant global-theme-light')); - assert(result.css.includes('@custom-variant global-theme-dark')); - assert(result.css.includes('@custom-variant global-theme-auto')); - assert(result.css.includes('@custom-variant global-theme-system')); - } - ); -}); - -test('ignores dynamic and non-literal values', async () => { - await runTest( - { - 'app/dynamic-values.jsx': ` - import { useUI } from '@react-zero-ui/core'; - - function Component({ userTheme, config }) { - const [theme, setTheme] = useUI('theme', 'default'); - const [status, setStatus] = useUI('status', 'idle'); - - // Dynamic values that should be ignored - const handleDynamic = () => { - setTheme(userTheme); // prop value - setTheme(config.theme); // object property - setTheme(calculateTheme()); // function call - setTheme(\`theme-\${mode}\`); // template literal with expression - setTheme(themes[index]); // array access - }; - - // But still catch literals mixed in - const handleMixed = () => { - setStatus(error ? 'error' : userStatus); // only 'error' should be caught - setTheme(loading ? 'loading' : calculated); // only 'loading' should be caught - }; - - return
Test
; - } - `, - }, - (result) => { - console.log('\n📄 Dynamic values filtering:'); - - // Should only have literals - assert(result.css.includes('@custom-variant theme-default')); // initial - assert(result.css.includes('@custom-variant status-idle')); // initial - assert(result.css.includes('@custom-variant status-error')); // from ternary - assert(result.css.includes('@custom-variant theme-loading')); // from ternary - - // Should NOT have dynamic values - assert(!result.css.includes('@custom-variant theme-userTheme')); - assert(!result.css.includes('@custom-variant theme-calculateTheme')); - assert(!result.css.includes('@custom-variant theme-theme-')); - } - ); -}); - -test('handles edge cases with unusual syntax', async () => { - await runTest( - { - 'app/edge-cases.jsx': ` - import { useUI } from '@react-zero-ui/core'; - - function Component() { - const [state, setState] = useUI('edge-state', 'normal'); - - // Destructured setter - const { setState: altSetState } = { setState }; - - // Setter in array - const setters = [setState]; - - // Multiple calls in one expression - const multi = () => (setState('first'), setState('second')); - - // Chained calls (unusual but possible) - const chained = () => setState('chain') && setState('link'); - - // In try/catch - const safe = () => { - try { - setState('trying'); - } catch { - setState('caught'); - } - }; - - // Template literal without expressions - const template = () => setState(\`static\`); - - return
Test
; - } - `, - }, - (result) => { - // Basic cases that should work - assert(result.css.includes('@custom-variant edge-state-first')); - assert(result.css.includes('@custom-variant edge-state-second')); - assert(result.css.includes('@custom-variant edge-state-chain')); - assert(result.css.includes('@custom-variant edge-state-link')); - assert(result.css.includes('@custom-variant edge-state-trying')); - assert(result.css.includes('@custom-variant edge-state-caught')); - assert(result.css.includes('@custom-variant edge-state-static')); - } - ); -}); - test('performance with large files and many variants', async () => { // Generate a large file with many useUI calls const generateLargeFile = () => { @@ -1964,14 +1281,9 @@ test('performance with large files and many variants', async () => { const [state${i}, setState${i}] = useUI('state-${i}', 'initial-${i}'); const [toggle${i}, setToggle${i}] = useUI('toggle-${i}', ${toggleInitial}); - const handler${i} = () => { - setState${i}('value-${i}-a'); - setState${i}('value-${i}-b'); - setState${i}('value-${i}-c'); - setToggle${i}(prev => prev === 'true' ? 'false' : 'true'); - }; + - return
Component ${i}
; + return
Component ${i}
; } `; } @@ -1992,7 +1304,7 @@ test('performance with large files and many variants', async () => { // Should still extract all variants correctly assert(result.css.includes('@custom-variant state-0-initial-0')); - assert(result.css.includes('@custom-variant state-49-value-49-c')); + assert(result.css.includes('@custom-variant state-49-initial-49')); assert(result.css.includes('@custom-variant toggle-0-true')); assert(result.css.includes('@custom-variant toggle-0-false')); }); diff --git a/packages/core/package.json b/packages/core/package.json index 67db817..2b0d9e4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,7 +41,7 @@ "test:vite": "playwright test -c __tests__/config/playwright.vite.config.js", "test:unit": "node --test __tests__/unit/index.test.cjs", "test:cli": "node --test __tests__/unit/cli.test.cjs", - "test:all": "pnpm run test:vite && pnpm run test:next && pnpm run test:unit && pnpm run test:cli", + "test:all": "pnpm run test:vite && pnpm run test:next && pnpm run test:unit && pnpm run test:cli && pnpm run test:ts", "test": "pnpm run test:all", "test:ts": "tsx --test src/**/*.test.ts" }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cefe1fc..2a66a8d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,13 @@ function useUI(key: string, initialValue: T): [T, UIS throw new Error(`[Zero-UI] useUI(key, initialValue); key and initialValue must not contain spaces, got "${key}" and "${initialValue}"`); } + // enforce kebab-case for the key: lowercase letters, digits and single dashes + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(key)) { + throw new Error( + `[Zero-UI] useUI(key, …); key must be kebab-case (e.g. "theme-dark"), got "${key}". ` + `Avoid camelCase or uppercase — they break variant generation.` + ); + } + // Validate inputs with helpful error messages if (!key || typeof key !== 'string' || key.trim() === '') { throw new Error(`useUI(key, initialValue); key must be a non-empty string, got "${key}"`); diff --git a/packages/core/src/postcss/helpers.cts b/packages/core/src/postcss/helpers.cts index 62a7414..de8b69c 100644 --- a/packages/core/src/postcss/helpers.cts +++ b/packages/core/src/postcss/helpers.cts @@ -191,7 +191,10 @@ export async function patchTsConfig(): Promise { // Ignore Vite fixtures — they patch their own config const hasViteConfig = ['js', 'mjs', 'ts', 'mts'].some((ext) => fs.existsSync(path.join(cwd, `vite.config.${ext}`))); - if (hasViteConfig) return; // Vite template patches its own tsconfig + if (hasViteConfig) { + console.log('[Zero-UI] Vite config found, skipping tsconfig patch'); + return; + } if (!configFile) { return console.warn(`[Zero-UI] No ts/ jsconfig found in ${cwd}`); @@ -205,6 +208,12 @@ export async function patchTsConfig(): Promise { config.compilerOptions.baseUrl ??= '.'; config.compilerOptions.paths ??= {}; + /* ---------- migrate misplaced include ---------- */ + if ('include' in config.compilerOptions && !config.include) { + config.include = config.compilerOptions.include; + delete config.compilerOptions.include; + } + let changed = false; /* ---------- alias ---------- */ diff --git a/packages/core/src/postcss/scanner.test.ts b/packages/core/src/postcss/scanner.test.ts index 202b766..503735a 100644 --- a/packages/core/src/postcss/scanner.test.ts +++ b/packages/core/src/postcss/scanner.test.ts @@ -15,3 +15,26 @@ test('scanVariantTokens', () => { assert.deepStrictEqual(result.get('modal-visible'), new Set(['true'])); assert.deepStrictEqual(result.get('feature-enabled'), new Set(['false'])); }); + +test('scanVariantTokens', () => { + const src = ` +
+ +
+ Theme: Dark Light +
+
+ `; + // should return theme-three-light, theme-three-dark + const result = scanVariantTokens(src, new Set(['theme-three'])); + console.log('result: ', result); + assert.deepStrictEqual(result.get('theme-three'), new Set(['light', 'dark'])); +}); From 33d8cbbfce78f85250c52fcc5812e01a1c302306 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Tue, 8 Jul 2025 13:47:31 -0700 Subject: [PATCH 30/34] Feat: Scoped UI State --- .github/workflows/ci.yml | 20 +- docs/assets/internal.md | 102 ++--- packages/cli/bin.js | 1 + .../config/playwright.next.config.js | 2 + .../config/playwright.vite.config.js | 2 + packages/core/__tests__/e2e/cli-next.spec.js | 2 + packages/core/__tests__/e2e/cli-vite.spec.js | 1 + .../core/__tests__/e2e/next-scoped.spec.js | 1 + .../core/__tests__/e2e/vite-scopes.spec.js | 1 + .../__tests__/fixtures/next/app/test/page.jsx | 418 +++++++++--------- packages/core/package.json | 2 +- packages/core/tsconfig.build.json | 12 +- packages/core/tsconfig.json | 46 +- 13 files changed, 298 insertions(+), 312 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc49866..35d2cb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,22 +70,8 @@ jobs: # 7 ▸ Run Build - name: Run Build - run: pnpm build - - # 8 ▸ Pack → inject tar-ball → run whole test suite - - name: Pack core tarball - run: pnpm run prepack:core - - - name: Inject tarball into fixtures - run: node scripts/install-local-tarball.js + run: pnpm bootstrap + # 8 ▸ Run all tests - name: Run Vite tests - run: pnpm test:vite - - name: Run Next.js tests - run: pnpm test:next - - - name: Run unit tests - run: pnpm test:unit - - - name: Run CLI tests - run: pnpm test:cli + run: pnpm test diff --git a/docs/assets/internal.md b/docs/assets/internal.md index 2d8b36f..30b1804 100644 --- a/docs/assets/internal.md +++ b/docs/assets/internal.md @@ -1,40 +1,40 @@ -Below is a **“mental model”** of the Zero-UI variant extractor. distilled so that *another* human (or LLM) can reason about, extend, or safely refactor the code-base. +Below is a **“mental model”** of the Zero-UI variant extractor. distilled so that _another_ human (or LLM) can reason about, extend, or safely refactor the code-base. --- ## 1. Top-level goal -* **Locate every call** to a user-supplied React hook +- **Locate every call** to a user-supplied React hook ```js - const [value, setterFn] = useUI('stateKey', 'initialValue') + const [value, setterFn] = useUI('stateKey', 'initialValue'); ``` -* Statically discover **all possible string values** that flow into - `stateKey`, `initialValue`, and `setterFn()` arguments. - * `stateKey` can resolve to a local static string. - * `initialValue` is the same rule as above. - * `setterFn()` argument is many forms allowed (see table 3.1) but **must be resolvable**; otherwise the value is ignored (silent) *unless* it looked resolvable but failed inside the helpers, in which case a targeted error is thrown. - * Imported bindings are never allowed - the dev must re-cast them through a local `const`. +- Statically discover **all possible string values** that flow into + `stateKey`, `initialValue`, and `setterFn()` arguments. + - `stateKey` can resolve to a local static string. + - `initialValue` is the same rule as above. + - `setterFn()` argument is many forms allowed (see table 3.1) but **must be resolvable**; otherwise the value is ignored (silent) _unless_ it looked resolvable but failed inside the helpers, in which case a targeted error is thrown. + - Imported bindings are never allowed - the dev must re-cast them through a local `const`. -* Report the result as a list of `VariantData` objects. +- Report the result as a list of `VariantData` objects. ```ts type VariantData = { - key: string; // 'stateKey' - values: string[]; // ['light','dark',…] (unique, sorted) - initialValue: string; // from 2nd arg of useUI() -} + key: string; // 'stateKey' + values: string[]; // ['light','dark',…] (unique, sorted) + initialValue: string; // from 2nd arg of useUI() +}; ``` --- ## 2. Two-pass pipeline -| Pass | File-scope work | Output | -| --- | --- | --- | -| **Pass 1 - `collectUseUISetters`** | 1. Traverse the AST once.
2. For each `useUI()` destructuring:
• validate shapes & count.
• resolve the **state key** and **initial value** with **`literalFromNode`** (see rules below).
• grab the **binding** of the setter variable. | `SetterMeta[]` = `{ binding, setterName, stateKey, initialValue }[]` | -| **Pass 2 - `harvestSetterValues`** | 1. For every `binding.referencePaths` (i.e. every place the setter is used)
2. Only keep `CallExpression`s: `setX(…)`
3. Examine the first argument:
• direct literal / identifier / template → resolve via `literalFromNode`.
• conditional `cond ? a : b` → resolve both arms.
• logical fallback `a \|\| b`, `a ?? b` → resolve each side.
   arrow / function bodies → collect every returned expression and resolve.
4. Add every successfully-resolved string to a `Set` bucket **per stateKey**. | `Map< stateKey, Set >` | +| Pass | File-scope work | Output | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| **Pass 1 - `collectUseUISetters`** | 1. Traverse the AST once.
2. For each `useUI()` destructuring:
• validate shapes & count.
• resolve the **state key** and **initial value** with **`literalFromNode`** (see rules below).
• grab the **binding** of the setter variable. | `SetterMeta[]` = `{ binding, setterName, stateKey, initialValue }[]` | +| **Pass 2 - `harvestSetterValues`** | 1. For every `binding.referencePaths` (i.e. every place the setter is used)
2. Only keep `CallExpression`s: `setX(…)`
3. Examine the first argument:
• direct literal / identifier / template → resolve via `literalFromNode`.
• conditional `cond ? a : b` → resolve both arms.
• logical fallback `a \|\| b`, `a ?? b` → resolve each side.
   arrow / function bodies → collect every returned expression and resolve.
4. Add every successfully-resolved string to a `Set` bucket **per stateKey**. | `Map< stateKey, Set >` | `normalizeVariants` just converts that map back into the `VariantData[]` shape (keeping initial values, sorting, etc.). @@ -44,10 +44,10 @@ type VariantData = { ## 3. The **literal-resolution micro-framework** Everything funnels through **`literalFromNode`**. -Think of it as a deterministic *static evaluator* restricted to a -*very* small grammar. +Think of it as a deterministic _static evaluator_ restricted to a +_very_ small grammar. -### 3.1 Supported input forms (setterFn()) +### 3.1 Supported input forms (setterFn()) ``` ┌────────────────────────────────┬─────────────────────────┐ @@ -67,16 +67,16 @@ Think of it as a deterministic *static evaluator* restricted to a └────────────────────────────────┴─────────────────────────┘ ``` -### 3.2 Helpers +### 3.2 Helpers -| Helper | Job | -| --- | --- | -| **`resolveTemplateLiteral`** | Ensures every `${expr}` itself resolves to a string via `literalFromNode`.| -| **`resolveLocalConstIdentifier`** | Maps an `Identifier` → its `const` initializer *if* that initializer is an accepted string/ template. Rejects imported bindings with a *single* descriptive error.| -| **`resolveMemberExpression`** | Static walk of `obj.prop`, `obj['prop']`, `obj?.prop`, etc. Works through `as const`, optional-chaining, arrays, numbers, nested chains. Throws if any hop can't be resolved. | -| **`literalFromNode`** | Router that calls the above; memoised (`WeakMap`) so each AST node is evaluated once.| +| Helper | Job | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`resolveTemplateLiteral`** | Ensures every `${expr}` itself resolves to a string via `literalFromNode`. | +| **`resolveLocalConstIdentifier`** | Maps an `Identifier` → its `const` initializer _if_ that initializer is an accepted string/ template. Rejects imported bindings with a _single_ descriptive error. | +| **`resolveMemberExpression`** | Static walk of `obj.prop`, `obj['prop']`, `obj?.prop`, etc. Works through `as const`, optional-chaining, arrays, numbers, nested chains. Throws if any hop can't be resolved. | +| **`literalFromNode`** | Router that calls the above; memoised (`WeakMap`) so each AST node is evaluated once. | -All helpers accept `opts:{ throwOnFail, source, hook }` so *contextual* +All helpers accept `opts:{ throwOnFail, source, hook }` so _contextual_ error messages can be emitted with **`throwCodeFrame`** (using `@babel/code-frame` to show a coloured snippet). @@ -84,11 +84,11 @@ error messages can be emitted with **`throwCodeFrame`** ## 4. Validation rules (why errors occur) -| Position in `useUI` | Allowed value | Example error | -| --- | --- | --- | -| **stateKey (arg 0)** | *Local* static string | `State key cannot be resolved at build-time.` | -| **initialValue (arg 1)** | Same rule as above. | `Initial value cannot be resolved …` | -| **setter argument** | Many forms allowed (see table 3.1) but **must be resolvable**; otherwise the value is ignored (silent) *unless* it looked resolvable but failed inside the helpers, in which case a targeted error is thrown. | | +| Position in `useUI` | Allowed value | Example error | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| **stateKey (arg 0)** | _Local_ static string | `State key cannot be resolved at build-time.` | +| **initialValue (arg 1)** | Same rule as above. | `Initial value cannot be resolved …` | +| **setter argument** | Many forms allowed (see table 3.1) but **must be resolvable**; otherwise the value is ignored (silent) _unless_ it looked resolvable but failed inside the helpers, in which case a targeted error is thrown. | | Imported bindings are never allowed - the dev must re-cast them through a local `const`. @@ -97,50 +97,50 @@ through a local `const`. ## 5. Optional-chain & optional-member details -* The updated `resolveMemberExpression` loop iterates while +- The updated `resolveMemberExpression` loop iterates while `isMemberExpression || isOptionalMemberExpression`. -* Inside array/obj traversal it throws a clear error if a link is +- Inside array/obj traversal it throws a clear error if a link is missing instead of silently returning `null`. -* `props` collects mixed `string | number` keys in **reverse** (deep → shallow) order, so they can be replayed from the root identifier outward. +- `props` collects mixed `string | number` keys in **reverse** (deep → shallow) order, so they can be replayed from the root identifier outward. --- ## 6. Performance enhancements -* **Memoisation** (`WeakMap`) across *both* passes. -* **Quick literals** - string & number keys handled without extra calls. -* `throwCodeFrame` (and thus `generate(node).code`) runs **only** on +- **Memoisation** (`WeakMap`) across _both_ passes. +- **Quick literals** - string & number keys handled without extra calls. +- `throwCodeFrame` (and thus `generate(node).code`) runs **only** on failing branches. -* A small **LRU file-cache** (<5k entries) avoids re-parsing unchanged +- A small **LRU file-cache** (<5k entries) avoids re-parsing unchanged files (mtime + size signature, with hash fallback). --- ## 7. What is **not** supported -* Runtime-only constructs (`import.meta`, env checks, dynamic imports …). -* Cross-file constant propagation - the extractor is intentionally +- Runtime-only constructs (`import.meta`, env checks, dynamic imports …). +- Cross-file constant propagation - the extractor is intentionally single-file to keep the build independent of user bundler config. -* Non-string variants (numbers, booleans) - strings only. -* Private class fields in member chains. -* Setter arguments that are **imported functions**. +- Non-string variants (numbers, booleans) - strings only. +- Private class fields in member chains. +- Setter arguments that are **imported functions**. --- ## 8. How to extend -* **Add more expression kinds**: extend `literalFromNode` with new - cases *and* unit-test them. -* **Cross-file constants**: in `resolveLocalConstIdentifier`, detect +- **Add more expression kinds**: extend `literalFromNode` with new + cases _and_ unit-test them. +- **Cross-file constants**: in `resolveLocalConstIdentifier`, detect `ImportSpecifier`, read & parse the target file, then recurse - but beware performance. -* **Boolean / number variants**: relax `literalToString` and adjust +- **Boolean / number variants**: relax `literalToString` and adjust variant schema. --- > **In one sentence**: -> The extractor turns *purely static, in-file JavaScript* around `useUI` +> The extractor turns _purely static, in-file JavaScript_ around `useUI` > into a deterministic list of variant strings, throwing early and with > helpful frames whenever something would otherwise need runtime > evaluation. diff --git a/packages/cli/bin.js b/packages/cli/bin.js index 50d806c..2b3f121 100755 --- a/packages/cli/bin.js +++ b/packages/cli/bin.js @@ -22,6 +22,7 @@ exec(pm === 'yarn' ? 'add' : 'install', ['@react-zero-ui/core']); exec(pm === 'yarn' ? 'add' : 'install', ['postcss', 'tailwindcss', '@tailwindcss/postcss', '--save-dev']); /* 4️⃣ handoff */ +// eslint-disable-next-line import/no-unresolved const { default: zeroUiCli } = await import('@react-zero-ui/core/cli'); if (typeof zeroUiCli === 'function') { zeroUiCli(process.argv.slice(3)); diff --git a/packages/core/__tests__/config/playwright.next.config.js b/packages/core/__tests__/config/playwright.next.config.js index 138cd42..cf26c3a 100644 --- a/packages/core/__tests__/config/playwright.next.config.js +++ b/packages/core/__tests__/config/playwright.next.config.js @@ -1,4 +1,6 @@ +// eslint-disable-next-line import/named import { defineConfig } from '@playwright/test'; + import path from 'node:path'; import { fileURLToPath } from 'node:url'; diff --git a/packages/core/__tests__/config/playwright.vite.config.js b/packages/core/__tests__/config/playwright.vite.config.js index cabc1ef..5d09613 100644 --- a/packages/core/__tests__/config/playwright.vite.config.js +++ b/packages/core/__tests__/config/playwright.vite.config.js @@ -1,4 +1,6 @@ +// eslint-disable-next-line import/named import { defineConfig } from '@playwright/test'; + import path from 'node:path'; import { fileURLToPath } from 'node:url'; diff --git a/packages/core/__tests__/e2e/cli-next.spec.js b/packages/core/__tests__/e2e/cli-next.spec.js index e7af76d..b476b8f 100644 --- a/packages/core/__tests__/e2e/cli-next.spec.js +++ b/packages/core/__tests__/e2e/cli-next.spec.js @@ -1,4 +1,6 @@ +// eslint-disable-next-line import/named import { test, expect } from '@playwright/test'; + import path from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; diff --git a/packages/core/__tests__/e2e/cli-vite.spec.js b/packages/core/__tests__/e2e/cli-vite.spec.js index d6f5303..09e8865 100644 --- a/packages/core/__tests__/e2e/cli-vite.spec.js +++ b/packages/core/__tests__/e2e/cli-vite.spec.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/named import { test, expect } from '@playwright/test'; import path from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; diff --git a/packages/core/__tests__/e2e/next-scoped.spec.js b/packages/core/__tests__/e2e/next-scoped.spec.js index 3f9cc1f..6d8c25e 100644 --- a/packages/core/__tests__/e2e/next-scoped.spec.js +++ b/packages/core/__tests__/e2e/next-scoped.spec.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/named import { test, expect } from '@playwright/test'; test.describe.configure({ mode: 'serial' }); diff --git a/packages/core/__tests__/e2e/vite-scopes.spec.js b/packages/core/__tests__/e2e/vite-scopes.spec.js index 3f9cc1f..6d8c25e 100644 --- a/packages/core/__tests__/e2e/vite-scopes.spec.js +++ b/packages/core/__tests__/e2e/vite-scopes.spec.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/named import { test, expect } from '@playwright/test'; test.describe.configure({ mode: 'serial' }); diff --git a/packages/core/__tests__/fixtures/next/app/test/page.jsx b/packages/core/__tests__/fixtures/next/app/test/page.jsx index 3c0a41d..509b696 100644 --- a/packages/core/__tests__/fixtures/next/app/test/page.jsx +++ b/packages/core/__tests__/fixtures/next/app/test/page.jsx @@ -1,209 +1,209 @@ -'use client'; - -import useUI from '@react-zero-ui/core'; -import UseEffectComponent from '../UseEffectComponent'; -import FAQ from '../FAQ'; - -export default function Page() { - const [, setTheme] = useUI('theme', 'light'); - const [, setTheme2] = useUI('theme-2', 'light'); - const [, setThemeThree] = useUI('theme-three', 'light'); - const [, setToggle] = useUI('toggle-boolean', 'true'); - const [, setNumber] = useUI('number', '1'); - const [, setOpen] = useUI('faq', 'closed'); // Same key everywhere! - const [, setScope] = useUI('scope', 'off'); - const [, setMobile] = useUI('mobile', 'false'); - - const [, setToggleFunction] = useUI('toggle-function', 'white'); - - const toggleFunction = () => { - setToggleFunction((prev) => (prev === 'white' ? 'black' : 'white')); - }; - - return ( -
-

Global State

-
-
- {/* Auto Theme Component */} - - -
- -
- -
- Theme: Dark Light -
-
- -
- -
- -
- Theme: Dark Light -
-
- -
- -
- -
- Theme: Dark Light -
-
- -
- -
- -
- Boolean: True False -
-
-
- -
- -
- Number: 1 2 -
-
-
- -
- -
- Function: White Black -
-
-
-
-

Scoped Style Tests

-
- -
-
- -
- Scope: False - True -
-
-
- -
- -
- Mobile: False - True -
-
-
- -
- -
answer
-
- - - - -
- ); -} +// 'use client'; + +// import useUI from '@react-zero-ui/core'; +// import UseEffectComponent from '../UseEffectComponent'; +// import FAQ from '../FAQ'; + +// export default function Page() { +// const [, setTheme] = useUI('theme', 'light'); +// const [, setTheme2] = useUI('theme-2', 'light'); +// const [, setThemeThree] = useUI('theme-three', 'light'); +// const [, setToggle] = useUI('toggle-boolean', 'true'); +// const [, setNumber] = useUI('number', '1'); +// const [, setOpen] = useUI('faq', 'closed'); // Same key everywhere! +// const [, setScope] = useUI('scope', 'off'); +// const [, setMobile] = useUI('mobile', 'false'); + +// const [, setToggleFunction] = useUI('toggle-function', 'white'); + +// const toggleFunction = () => { +// setToggleFunction((prev) => (prev === 'white' ? 'black' : 'white')); +// }; + +// return ( +//
+//

Global State

+//
+//
+// {/* Auto Theme Component */} +// + +//
+ +//
+// +//
+// Theme: Dark Light +//
+//
+ +//
+ +//
+// +//
+// Theme: Dark Light +//
+//
+ +//
+ +//
+// +//
+// Theme: Dark Light +//
+//
+ +//
+ +//
+// +//
+// Boolean: True False +//
+//
+//
+ +//
+// +//
+// Number: 1 2 +//
+//
+//
+ +//
+// +//
+// Function: White Black +//
+//
+//
+//
+//

Scoped Style Tests

+//
+ +//
+//
+// +//
+// Scope: False +// True +//
+//
+//
+ +//
+// +//
+// Mobile: False +// True +//
+//
+//
+ +//
+// +//
answer
+//
+ +// +// +// +//
+// ); +// } diff --git a/packages/core/package.json b/packages/core/package.json index 2b0d9e4..fb21e63 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -96,4 +96,4 @@ "lru-cache": "^10.4.3", "tsx": "^4.20.3" } -} \ No newline at end of file +} diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json index b3552e8..08de3ef 100644 --- a/packages/core/tsconfig.build.json +++ b/packages/core/tsconfig.build.json @@ -1,14 +1,8 @@ { "extends": "../../tsconfig.base.json", /* — compile exactly one file — */ - "include": [ - "src/**/*" - ], - "exclude": [ - "src/postcss/coming-soon", - "src/**/*.test.ts", - "./utilities.ts" - ], + "include": ["src/**/*"], + "exclude": ["src/postcss/coming-soon", "src/**/*.test.ts", "./utilities.ts"], /* — compiler output — */ "compilerOptions": { "target": "ES2020", @@ -23,4 +17,4 @@ "strict": true, // enable all strict type-checking options "skipLibCheck": true // Hides all errors coming from node_modules } -} \ No newline at end of file +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index aa89b91..bfa4020 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,26 +1,22 @@ { - "extends": "../../tsconfig.base.json", - /* — compile exactly one file — */ - "include": [ - "src/**/*" - ], - "exclude": [ - "src/postcss/coming-soon" - ], - /* — 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 - }, -} \ No newline at end of file + "extends": "../../tsconfig.base.json", + /* — compile exactly one file — */ + "include": ["src/**/*"], + "exclude": ["src/postcss/coming-soon"], + /* — 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 + } +} From 0a1c79f973c17cae9383d0e884396dd921f9cc10 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Tue, 8 Jul 2025 14:54:26 -0700 Subject: [PATCH 31/34] chore: sync lockfile (next 15.3.5) --- examples/demo/package.json | 4 +- pnpm-lock.yaml | 88 +++++++++++++++++++------------------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/examples/demo/package.json b/examples/demo/package.json index e55f282..40bfdc0 100644 --- a/examples/demo/package.json +++ b/examples/demo/package.json @@ -19,7 +19,7 @@ "@vercel/analytics": "^1.5.0", "clsx": "^2.1.1", "motion": "12.18.1", - "next": "15.3.3", + "next": "^15.3.5", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -32,4 +32,4 @@ "tailwindcss": "^4.1.10", "typescript": "^5" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c82a71e..1e22a56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,7 +46,7 @@ importers: version: 0.1.0(@tailwindcss/postcss@4.1.10)(postcss@8.5.6)(react@19.1.0)(tailwindcss@4.1.10) '@vercel/analytics': specifier: ^1.5.0 - version: 1.5.0(next@15.3.3(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 1.5.0(next@15.3.5(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -54,8 +54,8 @@ importers: specifier: 12.18.1 version: 12.18.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: - specifier: 15.3.3 - version: 15.3.3(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^15.3.5 + version: 15.3.5(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: ^19.0.0 version: 19.1.0 @@ -573,53 +573,53 @@ packages: peerDependencies: jsep: ^0.4.0||^1.0.0 - '@next/env@15.3.3': - resolution: {integrity: sha512-OdiMrzCl2Xi0VTjiQQUK0Xh7bJHnOuET2s+3V+Y40WJBAXrJeGA3f+I8MZJ/YQ3mVGi5XGR1L66oFlgqXhQ4Vw==} + '@next/env@15.3.5': + resolution: {integrity: sha512-7g06v8BUVtN2njAX/r8gheoVffhiKFVt4nx74Tt6G4Hqw9HCLYQVx/GkH2qHvPtAHZaUNZ0VXAa0pQP6v1wk7g==} - '@next/swc-darwin-arm64@15.3.3': - resolution: {integrity: sha512-WRJERLuH+O3oYB4yZNVahSVFmtxRNjNF1I1c34tYMoJb0Pve+7/RaLAJJizyYiFhjYNGHRAE1Ri2Fd23zgDqhg==} + '@next/swc-darwin-arm64@15.3.5': + resolution: {integrity: sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.3.3': - resolution: {integrity: sha512-XHdzH/yBc55lu78k/XwtuFR/ZXUTcflpRXcsu0nKmF45U96jt1tsOZhVrn5YH+paw66zOANpOnFQ9i6/j+UYvw==} + '@next/swc-darwin-x64@15.3.5': + resolution: {integrity: sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.3.3': - resolution: {integrity: sha512-VZ3sYL2LXB8znNGcjhocikEkag/8xiLgnvQts41tq6i+wql63SMS1Q6N8RVXHw5pEUjiof+II3HkDd7GFcgkzw==} + '@next/swc-linux-arm64-gnu@15.3.5': + resolution: {integrity: sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.3.3': - resolution: {integrity: sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==} + '@next/swc-linux-arm64-musl@15.3.5': + resolution: {integrity: sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.3.3': - resolution: {integrity: sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==} + '@next/swc-linux-x64-gnu@15.3.5': + resolution: {integrity: sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.3.3': - resolution: {integrity: sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==} + '@next/swc-linux-x64-musl@15.3.5': + resolution: {integrity: sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.3.3': - resolution: {integrity: sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==} + '@next/swc-win32-arm64-msvc@15.3.5': + resolution: {integrity: sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.3.3': - resolution: {integrity: sha512-4QZG6F8enl9/S2+yIiOiju0iCTFd93d8VC1q9LZS4p/Xuk81W2QDjCFeoogmrWWkAD59z8ZxepBQap2dKS5ruw==} + '@next/swc-win32-x64-msvc@15.3.5': + resolution: {integrity: sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1902,8 +1902,8 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - next@15.3.3: - resolution: {integrity: sha512-JqNj29hHNmCLtNvd090SyRbXJiivQ+58XjCcrC50Crb5g5u2zi7Y2YivbsEfzk6AtVI80akdOQbaMZwWB1Hthw==} + next@15.3.5: + resolution: {integrity: sha512-RkazLBMMDJSJ4XZQ81kolSpwiCt907l0xcgcpF4xC2Vml6QVcPNXW0NQRwQ80FFtSn7UM52XN0anaw8TEJXaiw==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -2794,30 +2794,30 @@ snapshots: dependencies: jsep: 1.4.0 - '@next/env@15.3.3': {} + '@next/env@15.3.5': {} - '@next/swc-darwin-arm64@15.3.3': + '@next/swc-darwin-arm64@15.3.5': optional: true - '@next/swc-darwin-x64@15.3.3': + '@next/swc-darwin-x64@15.3.5': optional: true - '@next/swc-linux-arm64-gnu@15.3.3': + '@next/swc-linux-arm64-gnu@15.3.5': optional: true - '@next/swc-linux-arm64-musl@15.3.3': + '@next/swc-linux-arm64-musl@15.3.5': optional: true - '@next/swc-linux-x64-gnu@15.3.3': + '@next/swc-linux-x64-gnu@15.3.5': optional: true - '@next/swc-linux-x64-musl@15.3.3': + '@next/swc-linux-x64-musl@15.3.5': optional: true - '@next/swc-win32-arm64-msvc@15.3.3': + '@next/swc-win32-arm64-msvc@15.3.5': optional: true - '@next/swc-win32-x64-msvc@15.3.3': + '@next/swc-win32-x64-msvc@15.3.5': optional: true '@nodelib/fs.scandir@2.1.5': @@ -3038,9 +3038,9 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@vercel/analytics@1.5.0(next@15.3.3(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + '@vercel/analytics@1.5.0(next@15.3.5(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': optionalDependencies: - next: 15.3.3(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.5(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 '@xmldom/xmldom@0.8.10': {} @@ -4165,9 +4165,9 @@ snapshots: neo-async@2.6.2: {} - next@15.3.3(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@15.3.5(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@next/env': 15.3.3 + '@next/env': 15.3.5 '@swc/counter': 0.1.3 '@swc/helpers': 0.5.15 busboy: 1.6.0 @@ -4177,14 +4177,14 @@ snapshots: react-dom: 19.1.0(react@19.1.0) styled-jsx: 5.1.6(react@19.1.0) optionalDependencies: - '@next/swc-darwin-arm64': 15.3.3 - '@next/swc-darwin-x64': 15.3.3 - '@next/swc-linux-arm64-gnu': 15.3.3 - '@next/swc-linux-arm64-musl': 15.3.3 - '@next/swc-linux-x64-gnu': 15.3.3 - '@next/swc-linux-x64-musl': 15.3.3 - '@next/swc-win32-arm64-msvc': 15.3.3 - '@next/swc-win32-x64-msvc': 15.3.3 + '@next/swc-darwin-arm64': 15.3.5 + '@next/swc-darwin-x64': 15.3.5 + '@next/swc-linux-arm64-gnu': 15.3.5 + '@next/swc-linux-arm64-musl': 15.3.5 + '@next/swc-linux-x64-gnu': 15.3.5 + '@next/swc-linux-x64-musl': 15.3.5 + '@next/swc-win32-arm64-msvc': 15.3.5 + '@next/swc-win32-x64-msvc': 15.3.5 '@playwright/test': 1.53.1 sharp: 0.34.2 transitivePeerDependencies: From f9c924c35ce1ee9d7b541dd94f6704bde62769b0 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Tue, 8 Jul 2025 15:13:08 -0700 Subject: [PATCH 32/34] chore:update CI to individual tests vs test:all --- .github/workflows/ci.yml | 32 ++++++++++++++++--- package.json | 3 +- packages/core/__tests__/unit/ast.test.cjs | 2 -- packages/core/__tests__/unit/index.test.cjs | 4 +-- .../core/src/postcss/ast-generating.test.ts | 1 - packages/core/src/postcss/helpers.test.ts | 1 - packages/core/src/postcss/scanner.test.ts | 1 - 7 files changed, 31 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35d2cb0..9e448db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,10 +68,34 @@ jobs: - name: Lint run: pnpm lint - # 7 ▸ Run Build + # 7 ▸ Install Lockfile + - name: Install Lockfile + run: pnpm install --frozen-lockfile + + # 8 ▸ Run Build - name: Run Build - run: pnpm bootstrap + run: pnpm build + + # 9 ▸ Run Prepack + - name: Run Prepack + run: pnpm prepack:core + + # 10 ▸ Run Install Tarball + - name: Run Install Tarball + run: pnpm i-tarball - # 8 ▸ Run all tests + # 11 ▸ Run all tests - name: Run Vite tests - run: pnpm test + run: pnpm test:vite + + - name: Run Next.js tests + run: pnpm test:next + + - name: Run unit tests + run: pnpm test:unit + + - name: Run CLI tests + run: pnpm test:cli + + - name: Run unit tests 2 + run: pnpm test:unit-2 diff --git a/package.json b/package.json index 2781907..9a6faba 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:vite": "cd packages/core && pnpm test:vite", "test:next": "cd packages/core && pnpm test:next", "test:unit": "cd packages/core && pnpm test:unit", + "test:unit-2": "cd packages/core && pnpm test:ts", "test:cli": "cd packages/core && pnpm test:cli", "format": "prettier --write .", "lint": "eslint .", @@ -36,4 +37,4 @@ "tsx": "^4.20.3", "typescript": "^5.8.3" } -} +} \ No newline at end of file diff --git a/packages/core/__tests__/unit/ast.test.cjs b/packages/core/__tests__/unit/ast.test.cjs index ab552ca..8cde437 100644 --- a/packages/core/__tests__/unit/ast.test.cjs +++ b/packages/core/__tests__/unit/ast.test.cjs @@ -105,7 +105,6 @@ export function ComponentSimple() { async () => { const variants = extractVariants('src/app/Component.jsx'); - console.log('variants: ', variants); assert.strictEqual(variants.length, 2); assert.strictEqual(variants.length, 2); @@ -174,7 +173,6 @@ function TestComponent() { }, async () => { const variants = extractVariants('src/app/Component.jsx'); - console.log('variants: ', variants); assert.strictEqual(variants.length, 3); } ); diff --git a/packages/core/__tests__/unit/index.test.cjs b/packages/core/__tests__/unit/index.test.cjs index 895d5d2..945819a 100644 --- a/packages/core/__tests__/unit/index.test.cjs +++ b/packages/core/__tests__/unit/index.test.cjs @@ -256,7 +256,6 @@ test('ignores node_modules and hidden directories', async () => { `, }, (result) => { - console.log('result: ', result.css); assert(result.css.includes('@custom-variant valid-yes'), 'Should process valid files'); assert(!result.css.includes('ignored'), 'Should ignore node_modules'); assert(!result.css.includes('hidden'), 'Should ignore hidden directories'); @@ -281,7 +280,6 @@ test('handles large projects efficiently - 500 files', async function () { const startTime = Date.now(); await runTest(files, (result) => { - console.log('handles large projects efficiently-result: ', result.css); const endTime = Date.now(); const duration = endTime - startTime; @@ -291,7 +289,7 @@ test('handles large projects efficiently - 500 files', async function () { assert(result.css.includes('@custom-variant state49-value49'), 'Should process all files'); // Should complete in reasonable time - assert(duration < 300, 'Should process 50 files in under 300ms'); + assert(duration < 500, 'Should process 500 files in under 500ms'); }); }); diff --git a/packages/core/src/postcss/ast-generating.test.ts b/packages/core/src/postcss/ast-generating.test.ts index 0a4c8ea..9a5cbf7 100644 --- a/packages/core/src/postcss/ast-generating.test.ts +++ b/packages/core/src/postcss/ast-generating.test.ts @@ -57,7 +57,6 @@ const FIXTURES = { test('parseAndUpdateViteConfig inserts Zero-UI and removes Tailwind', () => { for (const [name, src] of Object.entries(FIXTURES)) { const out = parseAndUpdateViteConfig(src, zeroUiVitePlugin); - console.log('out: ', out); assert(out && out.includes(zeroUiVitePlugin), `${name} missing zeroUI`); assert(out.includes('zeroUI()'), `${name} missing zeroUI`); assert(out.includes('react()'), `${name} missing react`); diff --git a/packages/core/src/postcss/helpers.test.ts b/packages/core/src/postcss/helpers.test.ts index abc760b..3f5e293 100644 --- a/packages/core/src/postcss/helpers.test.ts +++ b/packages/core/src/postcss/helpers.test.ts @@ -285,7 +285,6 @@ export default { plugins: [react(),tailwindcss()] };`, async () => { await patchViteConfig(); const cfg = readFile('vite.config.mjs'); - console.log('cfg: ', cfg); assert.ok(cfg.includes('@react-zero-ui/core/vite'), 'Zero-UI plugin missing'); assert.ok(!cfg.includes('@tailwindcss/vite'), 'Tailwind should be removed'); } diff --git a/packages/core/src/postcss/scanner.test.ts b/packages/core/src/postcss/scanner.test.ts index 503735a..82c718b 100644 --- a/packages/core/src/postcss/scanner.test.ts +++ b/packages/core/src/postcss/scanner.test.ts @@ -35,6 +35,5 @@ test('scanVariantTokens', () => { `; // should return theme-three-light, theme-three-dark const result = scanVariantTokens(src, new Set(['theme-three'])); - console.log('result: ', result); assert.deepStrictEqual(result.get('theme-three'), new Set(['light', 'dark'])); }); From 35ba9aaf73eaea86c6310b97c7cbfc5202cb7717 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Tue, 8 Jul 2025 15:39:20 -0700 Subject: [PATCH 33/34] chore:update CI workflow --- .github/workflows/ci.yml | 39 ++++++++++++++------------------------ package.json | 4 ++-- packages/core/package.json | 9 ++++----- 3 files changed, 20 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e448db..d492f33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,18 +27,18 @@ jobs: with: fetch-depth: 0 # required for git-diff later - # 2 ▸ Set up pnpm - - uses: pnpm/action-setup@v3 - with: - version: 10.12.1 - run_install: false - - # 3 ▸ Node + pnpm-store cache + # 2 ▸ Node + pnpm-store cache - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm + # 3 ▸ Set up pnpm + - uses: pnpm/action-setup@v3 + with: + version: 10.12.1 + run_install: false + # 4 ▸ Install deps (deterministic) - name: Install dependencies run: pnpm install --frozen-lockfile @@ -49,7 +49,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} + key: ${{ runner.os }}-playwright-${{ hashFiles('packages/core/package.json') }} restore-keys: | ${{ runner.os }}-playwright- @@ -57,34 +57,23 @@ jobs: if: steps.pw-cache.outputs.cache-hit != 'true' run: pnpm --filter @react-zero-ui/core exec playwright install --with-deps - - name: Save Playwright cache - if: steps.pw-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 - with: - path: ~/.cache/ms-playwright - key: ${{ steps.pw-cache.outputs.cache-primary-key }} - # 6 ▸ Lint fast, fail fast - name: Lint run: pnpm lint - # 7 ▸ Install Lockfile - - name: Install Lockfile - run: pnpm install --frozen-lockfile - - # 8 ▸ Run Build + # 7 ▸ Run Build - name: Run Build run: pnpm build - # 9 ▸ Run Prepack + # 8 ▸ Run Prepack - name: Run Prepack run: pnpm prepack:core - # 10 ▸ Run Install Tarball + # 9 ▸ Run Install Tarball - name: Run Install Tarball run: pnpm i-tarball - # 11 ▸ Run all tests + # 10 ▸ Run all tests - name: Run Vite tests run: pnpm test:vite @@ -97,5 +86,5 @@ jobs: - name: Run CLI tests run: pnpm test:cli - - name: Run unit tests 2 - run: pnpm test:unit-2 + - name: Run integration tests + run: pnpm test:integration diff --git a/package.json b/package.json index 9a6faba..6eac93d 100644 --- a/package.json +++ b/package.json @@ -12,17 +12,17 @@ "bootstrap": "pnpm install --frozen-lockfile && pnpm build && pnpm prepack:core && pnpm i-tarball", "build": "cd packages/core && pnpm build", "test": "cd packages/core && pnpm test:all", - "test:all": "pnpm install --frozen-lockfile && pnpm prepack:core && pnpm lint && pnpm i-tarball && cd packages/core && pnpm test:all", "prepack:core": "pnpm -F @react-zero-ui/core pack --pack-destination ./dist", "i-tarball": "node scripts/install-local-tarball.js", "test:vite": "cd packages/core && pnpm test:vite", "test:next": "cd packages/core && pnpm test:next", + "test:integration": "cd packages/core && pnpm test:integration", "test:unit": "cd packages/core && pnpm test:unit", - "test:unit-2": "cd packages/core && pnpm test:ts", "test:cli": "cd packages/core && pnpm test:cli", "format": "prettier --write .", "lint": "eslint .", "lint:fix": "eslint . --fix", + "typecheck": "tsc --noEmit", "size": "npx esbuild ./packages/core/dist/index.js --bundle --minify --format=esm --external:react --define:process.env.NODE_ENV='\"production\"' | gzip -c | wc -c" }, "devDependencies": { diff --git a/packages/core/package.json b/packages/core/package.json index fb21e63..1e770c4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,11 +39,10 @@ "dev": "tsc -p tsconfig.json --watch", "test:next": "playwright test -c __tests__/config/playwright.next.config.js", "test:vite": "playwright test -c __tests__/config/playwright.vite.config.js", - "test:unit": "node --test __tests__/unit/index.test.cjs", + "test:integration": "node --test __tests__/unit/index.test.cjs", "test:cli": "node --test __tests__/unit/cli.test.cjs", - "test:all": "pnpm run test:vite && pnpm run test:next && pnpm run test:unit && pnpm run test:cli && pnpm run test:ts", - "test": "pnpm run test:all", - "test:ts": "tsx --test src/**/*.test.ts" + "test": "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" }, "keywords": [ "react", @@ -96,4 +95,4 @@ "lru-cache": "^10.4.3", "tsx": "^4.20.3" } -} +} \ No newline at end of file From 150225a833a7846379c9efbe994d84df26dce7a9 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Tue, 8 Jul 2025 16:00:48 -0700 Subject: [PATCH 34/34] fix: CI --- .github/workflows/ci.yml | 13 ++++++------- package.json | 10 +++++++++- packages/core/tsconfig.json | 11 ++++++++--- tsconfig.base.json | 17 ++++++++++++++--- 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d492f33..41b6ba4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,19 +26,18 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 # required for git-diff later + # 2 ▸ Set up pnpm + - uses: pnpm/action-setup@v3 + with: + version: 10.12.1 + run_install: false - # 2 ▸ Node + pnpm-store cache + # 3 ▸ Node + pnpm-store cache - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - # 3 ▸ Set up pnpm - - uses: pnpm/action-setup@v3 - with: - version: 10.12.1 - run_install: false - # 4 ▸ Install deps (deterministic) - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/package.json b/package.json index 6eac93d..090fcd4 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,14 @@ "packages/*" ], "packageManager": "pnpm@10.12.1", + "references": [ + { + "path": "packages/core" + }, + { + "path": "packages/cli" + } + ], "scripts": { "preinstall": "npx only-allow pnpm", "reset": "git clean -fdx && pnpm install --frozen-lockfile && pnpm prepack:core && pnpm i-tarball", @@ -22,7 +30,7 @@ "format": "prettier --write .", "lint": "eslint .", "lint:fix": "eslint . --fix", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit --project tsconfig.base.json", "size": "npx esbuild ./packages/core/dist/index.js --bundle --minify --format=esm --external:react --define:process.env.NODE_ENV='\"production\"' | gzip -c | wc -c" }, "devDependencies": { diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index bfa4020..bb2e510 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,8 +1,13 @@ { "extends": "../../tsconfig.base.json", /* — compile exactly one file — */ - "include": ["src/**/*"], - "exclude": ["src/postcss/coming-soon"], + "include": [ + "src/**/*" + ], + "exclude": [ + "src/postcss/coming-soon", + "node_modules", + ], /* — compiler output — */ "compilerOptions": { "target": "ES2020", @@ -19,4 +24,4 @@ "strict": true, // enable all strict type-checking options "skipLibCheck": true // Hides all errors coming from node_modules } -} +} \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index 764942f..53f743b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -12,10 +12,21 @@ "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": "src", + "rootDir": "./", "forceConsistentCasingInFileNames": true }, - "exclude": ["dist", "node_modules"] -} + "exclude": [ + "dist", + "**/*.tsx", + "**/*.jsx", + "**/*.test.ts", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "examples/**", + "**/fixtures/**", + "**/coming-soon/**" + ] +} \ No newline at end of file