diff --git a/.changeset/deep-brooms-repeat.md b/.changeset/deep-brooms-repeat.md new file mode 100644 index 0000000..0e73bd0 --- /dev/null +++ b/.changeset/deep-brooms-repeat.md @@ -0,0 +1,25 @@ +--- +"@michthemaker/vanjs": minor +--- + +Added dedicated SVG and MathML element support + +VanJS now provides specialized tag creation for SVG and MathML elements through `van.svgTags` and `van.mathmlTags`, giving you proper type safety and element creation for graphics and mathematical markup alongside regular HTML elements. + +```typescript +const { svg, path, circle } = van.svgTags; +const { div, h1, button } = van.tags; +const { math, mi, mo } = van.mathmlTags; + +// Create SVG graphics +svg({ + viewBox: "0 0 24 24", + width: "24", + height: "24" +}, + path({ d: "M12 2L2 7v10c0 5.55 3.84 10 9 10s9-4.45 9-10V7l-10-5z" }) +); + +// Create mathematical notation +math(mi("x"), mo("+"), mi("y")); +``` diff --git a/.changeset/eager-lies-behave.md b/.changeset/eager-lies-behave.md deleted file mode 100644 index aa02563..0000000 --- a/.changeset/eager-lies-behave.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@michthemaker/vanjs": minor ---- - -Add Context API for logical state sharing across component trees - -Introduces `createContext` and `useContext` functions that enable sharing reactive state without prop drilling. Context uses logical scoping (not DOM-based) and integrates seamlessly with VanJS's reactive state system. - -**New exports:** - -- `createContext()`: Creates a new context object with a Provider method -- `useContext(context)`: Retrieves the current context value (must be called within a Provider) - -**Example usage:** - -```typescript -import van, { createContext, useContext } from "@michthemaker/vanjs"; - -const { div, button } = van.tags; -const ThemeContext = createContext<{ color: string }>(); - -const theme = van.state({ color: "blue" }); - -ThemeContext.Provider(theme, () => { - const currentTheme = useContext(ThemeContext); - return div( - () => `Theme: ${currentTheme.val.color}`, - button( - { - onclick: () => (theme.val = { color: "red" }), - }, - "Change Theme" - ) - ); -}); -``` - -**Features:** - -- Shallow reactivity: context values must be VanJS state objects -- Supports nested providers of the same context -- Type-safe with TypeScript generics -- Throws helpful errors when used incorrectly diff --git a/.changeset/funky-humans-open.md b/.changeset/funky-humans-open.md deleted file mode 100644 index bbf69e9..0000000 --- a/.changeset/funky-humans-open.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@michthemaker/vanjs": patch ---- - -Add JSDoc documentation for Context API diff --git a/.changeset/pink-frogs-enjoy.md b/.changeset/pink-frogs-enjoy.md new file mode 100644 index 0000000..84c32c5 --- /dev/null +++ b/.changeset/pink-frogs-enjoy.md @@ -0,0 +1,7 @@ +--- +"@michthemaker/vite-plugin-vanjs": patch +--- + +Fixed duplicate export statements during hot reload + +The Vite plugin was incorrectly duplicating export statements like `export { App }` when processing components for hot module replacement. This has been resolved, ensuring clean exports without duplication. diff --git a/.changeset/small-moles-arrive.md b/.changeset/small-moles-arrive.md new file mode 100644 index 0000000..9a27758 --- /dev/null +++ b/.changeset/small-moles-arrive.md @@ -0,0 +1,68 @@ +--- +"create-van-app": major +--- + +Scaffold Your First Van Project using `create-van-app` + +> **Compatibility Note:** +> Create Van App requires [Node.js](https://nodejs.org/en/) version 20.19+, 22.12+. However, some templates require a higher Node.js version to work, please upgrade if your package manager warns about it. + +With NPM: + +```bash +npm create van-app@latest +``` + +With Yarn: + +```bash +yarn create van-app +``` + +With PNPM: + +```bash +pnpm create van-app +``` + +With Bun: + +```bash +bun create van-app +``` + +With Deno: + +```bash +deno init --npm van-app +``` + +Then follow the prompts! + +You can also directly specify the project name and the template you want to use via additional command line options. For example, to scaffold a VanJS + Tailwind project, run: + +```bash +# npm 7+, extra double-dash is needed: +npm create van-app@latest my-van-app -- --template vanjs-ts-tailwind + +# yarn +yarn create van-app my-van-app --template vanjs-ts-tailwind + +# pnpm +pnpm create van-app my-van-app --template vanjs-ts-tailwind + +# Bun +bun create van-app my-van-app --template vanjs-ts-tailwind + +# Deno +deno init --npm van-app my-van-app --template vanjs-ts-tailwind +``` + +Currently supported template presets include: + +- `vanjs-ts` + `tailwind` ← default +- `vanjs-ts` + `css` +- `vanjs` + `tailwind` +- `vanjs` + `css` + +You can use `.` for the project name to scaffold in the current directory. diff --git a/.changeset/ten-moles-nail.md b/.changeset/ten-moles-nail.md new file mode 100644 index 0000000..96ec942 --- /dev/null +++ b/.changeset/ten-moles-nail.md @@ -0,0 +1,7 @@ +--- +"@michthemaker/vite-plugin-vanjs": minor +--- + +Improved error display during hot module replacement + +Error messages during development now display in a better-styled container during hot module replacement, making debugging issues easier to spot and read. diff --git a/.changeset/twelve-trams-shop.md b/.changeset/twelve-trams-shop.md new file mode 100644 index 0000000..47290d0 --- /dev/null +++ b/.changeset/twelve-trams-shop.md @@ -0,0 +1,7 @@ +--- +"@michthemaker/vanjs": minor +--- + +Improved type definitions for DOM element properties + +Rewrote the VanJS type system to provide better type resolution when setting attributes and properties on DOM elements. This gives you more accurate autocompletion and compile-time checks when using `van.tags`. diff --git a/.changeset/wet-masks-slide.md b/.changeset/wet-masks-slide.md new file mode 100644 index 0000000..602924a --- /dev/null +++ b/.changeset/wet-masks-slide.md @@ -0,0 +1,8 @@ +--- +"@michthemaker/vite-plugin-vanjs": minor +--- + +Improved context support during Hot Module Replacement (HMR) + +- **Stable contexts across reloads**: `createContext()` is intercepted and keyed by `hmrId`, so the same context object is reused between HMR updates. +- **Context snapshot + replay**: active context stacks are captured on `registerRender` and replayed on `rerender`, allowing `useContext` to keep working even when called outside the original render call tree. diff --git a/.changeset/wicked-meteors-turn.md b/.changeset/wicked-meteors-turn.md new file mode 100644 index 0000000..d6cb6f1 --- /dev/null +++ b/.changeset/wicked-meteors-turn.md @@ -0,0 +1,8 @@ +--- +"@michthemaker/vite-plugin-vanjs": minor +--- + +Better handling for multiple and aliased exports + +- Multiple named exports like `export { Foo, Bar }` are now correctly preserved during transformation. +- Aliased exports such as `export { Foo as Bar }` now generate the correct wrapper and transform logic. diff --git a/.zed/settings.json b/.zed/settings.json index fb41076..248c650 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -57,7 +57,7 @@ // settings for all languages "languages": { "TypeScript": { - "language_servers": ["tsgo"] + "language_servers": ["vtsls", "oxfmt"] }, "JavaScript": { "language_servers": ["tsgo", "vtsls"], diff --git a/apps/examples/plugin-test/index.html b/apps/examples/plugin-test/index.html index 598d39a..597b5b1 100644 --- a/apps/examples/plugin-test/index.html +++ b/apps/examples/plugin-test/index.html @@ -1,12 +1,24 @@ - + Example Repository VanJS + - + diff --git a/apps/examples/plugin-test/src/App.ts b/apps/examples/plugin-test/src/App.ts new file mode 100644 index 0000000..1bc8000 --- /dev/null +++ b/apps/examples/plugin-test/src/App.ts @@ -0,0 +1,68 @@ +import van, { type Ref } from "@michthemaker/vanjs"; +import { + QueryContextProvider, + ThemeContextProvider, + useQueryFt, + useTheme, +} from "./context-test"; +import { GifModal } from "./GifModal"; +import { UserProfile } from "./UserProfile"; + +const { div, h1, p, span, button } = van.tags; + +// Edge case 1: nested providers (ThemeContext inside QueryContext) +// Expected: both contexts available to deep children on rerender +export const App = (props: { name: string }) => { + const myName = van.state("Mich"); + const ref: Ref = { current: null }; + + return QueryContextProvider(() => + ThemeContextProvider(() => + div( + { + style: + "padding-inline: 4px; font-family: sans-serif; margin: 0; overflow: auto;", + class: "no-scrollbar", + }, + h1( + { + style: + "width: 90%; color: #333; border-bottom: 2px solid #eee; padding-bottom: 10px;", + ref: ref, + }, + "VanJS HMR Context Edge Cases — ", + props.name, + " ", + myName + ), + + // Edge case 1: child component using ONE context (QueryContext) + // Save GifModal.ts → should rerender fine with QueryContext snapshot + GifModal(), + + // Edge case 2: inline fn using BOTH contexts simultaneously + // Save App.ts → should have both QueryContext + ThemeContext in snapshot + () => { + const query = useQueryFt(); + const theme = useTheme(); + return span( + { style: `color: ${theme.color};` }, + `query=${query.name} dark=${theme.dark}` + ); + }, + + // Edge case 3: same context used twice in same tree + () => { + const a = useQueryFt(); + const b = useQueryFt(); + return p(`same context twice: ${a.name} === ${b.name}`); + }, + + // Edge case 4: component that owns its OWN Provider internally + // UserBadge inside UserProfile uses UserContext + // Save UserProfile.ts → UserBadge should rerender with UserContext snapshot + UserProfile() + ) + ) + ); +}; diff --git a/apps/examples/plugin-test/src/GifModal.ts b/apps/examples/plugin-test/src/GifModal.ts new file mode 100644 index 0000000..e3cd679 --- /dev/null +++ b/apps/examples/plugin-test/src/GifModal.ts @@ -0,0 +1,13 @@ +// just a file using dummy context to test context with hmr + +import van from "@michthemaker/vanjs"; +import { useQueryFt } from "./context-test"; + +const { p } = van.tags; + +const GifModal = () => { + const queryContext = useQueryFt(); + return p(queryContext.name, "name is usss and them eight"); +}; + +export { GifModal }; diff --git a/apps/examples/plugin-test/src/UserProfile.ts b/apps/examples/plugin-test/src/UserProfile.ts new file mode 100644 index 0000000..d9a4242 --- /dev/null +++ b/apps/examples/plugin-test/src/UserProfile.ts @@ -0,0 +1,34 @@ +// Edge case: component that owns its own Context.Provider +// and has a deeply nested consumer in same file + +import van from "@michthemaker/vanjs"; +import { UserContext, useUser } from "./context-test"; + +const { div, p, span, strong } = van.tags; + +// Deep consumer — no Provider above it in its own file +// relies on snapshot replay during HMR rerender +const UserBadge = () => { + const user = useUser(); + return span( + { style: "background: #eee; padding: 4px 8px; border-radius: 4px;" }, + strong(user.role + "me and you"), + " — ", + user.username + ); +}; + +// Owns the Provider + renders UserBadge inside it +const UserProfile = () => { + const userState = van.state({ username: "michthemaker", role: "admin" }); + return UserContext.Provider(userState, () => + div( + { style: "border: 1px solid #ccc; padding: 12px; margin-top: 12px;" }, + p("UserProfile component owns this Provider"), + UserBadge(), + p(() => `role is: ${userState.val.role}`) + ) + ); +}; + +export { UserProfile, UserBadge }; diff --git a/apps/examples/plugin-test/src/barrel-export.ts b/apps/examples/plugin-test/src/barrel-export.ts deleted file mode 100644 index 8a55baa..0000000 --- a/apps/examples/plugin-test/src/barrel-export.ts +++ /dev/null @@ -1 +0,0 @@ -export { Counter } from "./counter"; diff --git a/apps/examples/plugin-test/src/context-test.ts b/apps/examples/plugin-test/src/context-test.ts index bf23770..c921e10 100644 --- a/apps/examples/plugin-test/src/context-test.ts +++ b/apps/examples/plugin-test/src/context-test.ts @@ -1,33 +1,32 @@ import van, { createContext, useContext } from "@michthemaker/vanjs"; -const { div, button } = van.tags; -const PopoverContext = createContext(); +const QueryContext = createContext<{ name: string }>(); +const ThemeContext = createContext<{ color: string; dark: boolean }>(); +export const UserContext = createContext<{ username: string; role: string }>(); -const ContextTest = (): any => { - const value = van.state(2); - const value2 = van.state(89); +const QueryContextProvider = (childrenFn: () => any) => { + const state = van.state({ name: "kashiba" }); + return QueryContext.Provider(state, childrenFn); +}; + +const ThemeContextProvider = (childrenFn: () => any) => { + const state = van.state({ color: "blue", dark: false }); + return ThemeContext.Provider(state, childrenFn); +}; + +const useQueryFt = () => { + const { val: queryContext } = useContext(QueryContext); + return queryContext; +}; + +const useTheme = () => { + const { val: theme } = useContext(ThemeContext); + return theme; +}; - return div( - { style: "padding: 2px;" }, - PopoverContext.Provider(value, () => { - const value = useContext(PopoverContext); - return [ - value.val, - PopoverContext.Provider(value2, () => { - const value = useContext(PopoverContext); - return [ - () => value.val, - button( - { - onclick: () => (value.val = Math.random() * 200), - }, - "Change Second one" - ), - ]; - }), - ]; - }) - ); +export const useUser = () => { + const { val: user } = useContext(UserContext); + return user; }; -export { ContextTest }; +export { QueryContextProvider, ThemeContextProvider, useQueryFt, useTheme }; diff --git a/apps/examples/plugin-test/src/counter.ts b/apps/examples/plugin-test/src/counter.ts deleted file mode 100644 index b7284fa..0000000 --- a/apps/examples/plugin-test/src/counter.ts +++ /dev/null @@ -1,80 +0,0 @@ -import van from "@michthemaker/vanjs"; -const { div, h1, p, button, input } = van.tags; - -const Counter = (): any => { - const counter = van.state({ number: 9 }); - const textInput = van.state("Edit Me!"); - - // Test van.derive - now preserved across HMR with createDerived - const doubled = van.derive(() => counter.val.number * 2); - const tripled = van.derive(() => counter.val.number * 3); - - return div( - { style: "padding: 2px;" }, - - // Counter section - div( - { - style: - "margin-bottom: 20px; border: 2px solid #4CAF50; border-radius: 8px; padding: 16px;", - }, - h1("Counter testing meee waitng for u "), - p(() => `Count: ${counter.val.number} + name`), - p(() => `Doubled (inline): ${counter.val.number * 2}`), - p(() => `derived 😉 Doubled : ${doubled.val}`), - p(() => `Tripled (derived): ${tripled.val}`), - button( - { - onclick: () => counter.val.number++, - style: - "margin: 5px; padding: 10px 20px; cursor: pointer; background-color: #4CAF50; color: white; border: none; border-radius: 4px;", - }, - "Increment" - ), - button( - { - onclick: () => counter.val.number--, - style: - "margin: 5px; padding: 10px 20px; cursor: pointer; background-color: #FF9800; color: white; border: none; border-radius: 4px;", - }, - "Decrement" - ), - button( - { - onclick: () => { - counter.val = { number: 0 }; - }, - style: - "margin: 5px; padding: 10px 20px; cursor: pointer; background: #f44336; color: white; border: none; border-radius: 4px;", - }, - "Reset" - ) - ), - - // Text input section - div( - { - style: - "margin-bottom: 20px; border: 2px solid #2196F3; border-radius: 8px; padding: 16px; display: none;", - }, - h1("Text Input "), - input({ - type: "text", - value: () => textInput.val, - oninput: (e: any) => { - textInput.val = e.target.value; - }, - style: - "padding: 8px; font-size: 16px; width: 300px; border: 1px solid #ccc; border-radius: 4px;", - }), - p(() => `You typed: me ls`), - p(() => `Length: ${textInput.val.length}`) - ) - ); -}; - -export const OtherName = (): any => { - return div("name"); -}; - -export { Counter }; diff --git a/apps/examples/plugin-test/src/main.ts b/apps/examples/plugin-test/src/main.ts index f2fc643..7c977b4 100644 --- a/apps/examples/plugin-test/src/main.ts +++ b/apps/examples/plugin-test/src/main.ts @@ -1,41 +1,6 @@ -import van, { type Ref } from "@michthemaker/vanjs"; -import { Counter } from "./barrel-export"; -import { ContextTest } from "./context-test"; +import van from "@michthemaker/vanjs"; +import { App } from "./App.ts"; -const { div, h1, button } = van.tags; +const root = document.body!; -// Component with props - using named export -const App = (props: { name: string }) => { - const myName = van.state("Mich"); - const ref: Ref = { current: null }; - return div( - { - style: - "padding: 20px; font-family: sans-serif; max-width: 800px; margin: 0 auto;", - }, - ContextTest(), - h1( - { - style: - "color: #333; border-bottom: 2px solid #eee; padding-bottom: 10px;", - ref: ref, - }, - "VanJS Multi-File HMR Test - me us ", - props.name, - myName - ), - Counter(), - button( - { - onclick() { - myName.val = "Michthemaker"; - }, - }, - "Click me" - ) - ); -}; - -van.add(document.body, App({ name: "Mice" })); - -export default App; +van.add(root, App({ name: "okolo" })); diff --git a/apps/examples/plugin-test/vite.config.ts b/apps/examples/plugin-test/vite.config.ts index 456a04e..13a2cbf 100644 --- a/apps/examples/plugin-test/vite.config.ts +++ b/apps/examples/plugin-test/vite.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ }, }, plugins: [ - inspect(), + inspect({}), vanjs({ hmr: { smartStateChecking: true, diff --git a/packages/create-van-app/template-js-css/package.json b/packages/create-van-app/template-js-css/package.json index cd09d8e..b7db04e 100644 --- a/packages/create-van-app/template-js-css/package.json +++ b/packages/create-van-app/template-js-css/package.json @@ -9,10 +9,10 @@ "preview": "vite preview" }, "dependencies": { - "@michthemaker/vanjs": "^0.2.0" + "@michthemaker/vanjs": "^0.4.0" }, "devDependencies": { - "@michthemaker/vite-plugin-vanjs": "^0.1.2", + "@michthemaker/vite-plugin-vanjs": "^0.2.0", "@types/node": "^25.3.0", "vite": "latest" } diff --git a/packages/create-van-app/template-js-tailwind/package.json b/packages/create-van-app/template-js-tailwind/package.json index 1e37c3f..94c8a89 100644 --- a/packages/create-van-app/template-js-tailwind/package.json +++ b/packages/create-van-app/template-js-tailwind/package.json @@ -9,12 +9,12 @@ "preview": "vite preview" }, "dependencies": { - "@michthemaker/vanjs": "^0.2.0", + "@michthemaker/vanjs": "^0.4.0", "clsx": "^2.1.1", "tailwind-merge": "^2.6.0" }, "devDependencies": { - "@michthemaker/vite-plugin-vanjs": "^0.1.2", + "@michthemaker/vite-plugin-vanjs": "^0.2.0", "autoprefixer": "^10.4.21", "postcss": "^8.5.6", "tailwindcss": "^3.4.9", diff --git a/packages/create-van-app/template-ts-css/package.json b/packages/create-van-app/template-ts-css/package.json index adcfb7f..a592aca 100644 --- a/packages/create-van-app/template-ts-css/package.json +++ b/packages/create-van-app/template-ts-css/package.json @@ -10,10 +10,10 @@ "check": "tsc -b --noEmit" }, "dependencies": { - "@michthemaker/vanjs": "^0.2.0" + "@michthemaker/vanjs": "^0.4.0" }, "devDependencies": { - "@michthemaker/vite-plugin-vanjs": "^0.1.2", + "@michthemaker/vite-plugin-vanjs": "^0.2.0", "@types/node": "^25.3.0", "typescript": "latest", "vite": "latest" diff --git a/packages/create-van-app/template-ts-tailwind/package.json b/packages/create-van-app/template-ts-tailwind/package.json index d8ba04b..453dbcf 100644 --- a/packages/create-van-app/template-ts-tailwind/package.json +++ b/packages/create-van-app/template-ts-tailwind/package.json @@ -10,12 +10,12 @@ "check": "tsc -b --noEmit" }, "dependencies": { - "@michthemaker/vanjs": "^0.2.0", + "@michthemaker/vanjs": "^0.4.0", "clsx": "^2.1.1", "tailwind-merge": "^2.6.0" }, "devDependencies": { - "@michthemaker/vite-plugin-vanjs": "^0.1.2", + "@michthemaker/vite-plugin-vanjs": "^0.2.0", "@types/node": "^25.3.0", "autoprefixer": "^10.4.21", "postcss": "^8.5.6", diff --git a/packages/vanjs/src/event-handlers.ts b/packages/vanjs/src/event-handlers.ts index 6d0bf8e..cda24e2 100644 --- a/packages/vanjs/src/event-handlers.ts +++ b/packages/vanjs/src/event-handlers.ts @@ -13,7 +13,7 @@ import type { StateView } from "./van.ts"; * @example `((e: MouseEvent) => any) | null` -> `MouseEvent` */ -type ExtractEventType = [T] extends [ +export type ExtractEventType = [T] extends [ ((e: infer E extends Event) => any) | null, ] ? E diff --git a/packages/vanjs/src/index.ts b/packages/vanjs/src/index.ts index 5f7a143..42f34bf 100644 --- a/packages/vanjs/src/index.ts +++ b/packages/vanjs/src/index.ts @@ -334,6 +334,14 @@ let van: Van = { (ns: string) => new Proxy(tag, handler(ns)), handler() ) as Van["tags"], + svgTags: new Proxy( + tag, + handler("http://www.w3.org/2000/svg") + ) as Van["svgTags"], + mathMlTags: new Proxy( + tag, + handler("http://www.w3.org/1998/Math/MathML") + ) as Van["mathMlTags"], hydrate: (dom: T, f: (dom: T) => T | null | undefined): T => ( update(dom as unknown as ChildNode, bind(f as BindingFunc, dom)), dom ), diff --git a/packages/vanjs/src/utils/context.ts b/packages/vanjs/src/utils/context.ts index 83bdcdb..8d4f8d8 100644 --- a/packages/vanjs/src/utils/context.ts +++ b/packages/vanjs/src/utils/context.ts @@ -14,7 +14,11 @@ export type Context = { ) => ChildDom; }; -const contextStacks = new Map, StateView[]>(); +/** + * @internal do not use this in app + * reserved from framework/vite plugin authors + */ +export const contextStacks = new Map, StateView[]>(); /** * Creates a context object for sharing state without prop drilling. diff --git a/packages/vanjs/src/van.ts b/packages/vanjs/src/van.ts index fe7bf9b..9843af8 100644 --- a/packages/vanjs/src/van.ts +++ b/packages/vanjs/src/van.ts @@ -1,9 +1,58 @@ import type { ElementEventHandlers } from "./event-handlers.ts"; -export type { - ElementEventHandlers, - ReactiveEventHandler, -} from "./event-handlers.ts"; +export type * from "./event-handlers.ts"; + +// SVGAnimated union for fast Attributable lookup +type SVGAnimatedTypes = + | SVGAnimatedLength + | SVGAnimatedLengthList + | SVGAnimatedNumber + | SVGAnimatedNumberList + | SVGAnimatedInteger + | SVGAnimatedBoolean + | SVGAnimatedString + | SVGAnimatedAngle + | SVGAnimatedEnumeration + | SVGAnimatedRect + | SVGAnimatedPreserveAspectRatio + | SVGAnimatedTransformList; + +// Readonly/unsettable DOM object union +type UnsettableDOMTypes = + | DOMPointReadOnly + | DOMRectReadOnly + | DOMStringMap + | DOMStringList + | NamedNodeMap + | HTMLCollection + | NodeListOf + | ShadowRoot + | Document + | DocumentFragment + | ValidityState + | TimeRanges + | TextTrackList + | RemotePlayback + | MediaKeys + | MediaError + | FileList + | HTMLElement + | SVGElement + | MathMLElement; + +type Attributable = T extends SVGAnimatedTypes + ? string | number | boolean + : T extends UnsettableDOMTypes | null + ? never + : T extends Date | null + ? string | number | null + : T extends MediaProvider | null + ? MediaProvider | null + : T extends Element | null + ? Element | null + : T extends object + ? never + : T; /** * A reactive state object that automatically triggers updates when its value changes. @@ -45,22 +94,28 @@ export type Primitive = string | number | boolean | bigint; export type PropValue = Primitive | ((e: any) => void) | null; -export type PropValueOrDerived = - | PropValue - | StateView - | (() => PropValue); - -export type Props = Record & { - class?: PropValueOrDerived; +export type PropValueOrDerived = T | StateView | (() => T); +// Symbol brand — invisible in completions (unlike string-keyed brands), but still makes +// Props nominally distinct from ChildDom so tsserver resolves only the props overload when typing `{`. +declare const vanPropsBrand: unique symbol; +export type Props = Record> & { + readonly [vanPropsBrand]?: never; + class?: PropValueOrDerived; is?: string; }; +// Exclude static numeric constants inherited from Node (ATTRIBUTE_NODE, CDATA_SECTION_NODE, etc.) +// which are `number` primitives that pass through Attributable and cause doubled completions in the LSP. export type PropsWithKnownKeys = Partial<{ [K in keyof ElementType as K extends `on${string}` ? ((ev: Event) => any) | null extends ElementType[K] ? never : K - : K]: PropValueOrDerived; + : K extends keyof Node + ? never + : Attributable extends never + ? never + : K]: PropValueOrDerived>; }>; /** @@ -72,6 +127,10 @@ export type Ref = { current: T | null }; export type RefProp = { ref?: Ref }; +export type TagFuncProps = Props & + PropsWithKnownKeys & + ElementEventHandlers & { ref?: Ref }; + export type ValidChildDomValue = | Primitive | Node @@ -87,13 +146,10 @@ export type ChildDom = | BindingFunc | readonly ChildDom[]; -export type TagFunc = ( - first?: - | (Props & PropsWithKnownKeys & ElementEventHandlers) - | ChildDom - | RefProp, - ...rest: readonly ChildDom[] -) => Result; +export interface TagFunc { + (first?: TagFuncProps, ...rest: readonly ChildDom[]): Result; + (first: ChildDom, ...rest: readonly ChildDom[]): Result; +} /** * HTML tag functions typed for all known HTML elements. @@ -166,9 +222,17 @@ export interface Van { ) => Element; /** - * Tag functions for creating HTML, SVG, and MathML elements. + * Tag functions for creating HTML elements. */ readonly tags: HTMLTags & NamespacedTags; + /** + * Tag functions for creating SVG elements. + */ + readonly svgTags: SVGTags; + /** + * Tag functions for creating MathML elements. + */ + readonly mathMlTags: MathMLTags; /** * Hydrates existing DOM with VanJS reactivity. diff --git a/packages/vite-plugin-vanjs/src/plugin.ts b/packages/vite-plugin-vanjs/src/plugin.ts index 6d2b00b..c39be9d 100644 --- a/packages/vite-plugin-vanjs/src/plugin.ts +++ b/packages/vite-plugin-vanjs/src/plugin.ts @@ -26,6 +26,7 @@ interface ComponentInfo { name: string; exportedName: string; isDefault: boolean; + isExportConst: boolean; declarationStart: number; nameStart: number; nameEnd: number; @@ -101,7 +102,8 @@ function detectComponents(code: string): ComponentInfo[] { // Extract tag names from van.tags destructuring const tagNames = new Set(); - const tagsDestructurePattern = /const\s*\{\s*([^}]+)\s*\}\s*=\s*van\.tags/g; + const tagsDestructurePattern = + /const\s*\{\s*([^}]+)\s*\}\s*=\s*van\.\w*[Tt]ags/g; while ((match = tagsDestructurePattern.exec(code)) !== null) { const names = match[1].split(",").map((s) => s.trim()); for (const name of names) { @@ -158,7 +160,7 @@ function detectComponents(code: string): ComponentInfo[] { // Pattern 1: export const Name = (...) => ... const exportConstPattern = - /export\s+const\s+([A-Z][a-zA-Z0-9]*)\s*=\s*(?:async\s+)?(?:<[^>]*>\s*)?(\([^)]*\)|[a-zA-Z_][a-zA-Z0-9_]*)(?:\s*:\s*[^{=>][^=>]*?)?\s*=>/g; + /export[^\S\n]+const[^\S\n]+([A-Z][a-zA-Z0-9]*)\s*=\s*(?:async\s+)?(?:<[^>]*>\s*)?(\([^)]*\)|[a-zA-Z_][a-zA-Z0-9_]*)(?:\s*:\s*[^{=>][^=>]*?)?\s*=>/g; while ((match = exportConstPattern.exec(code)) !== null) { const name = match[1]; const body = extractFunctionBody(match.index + match[0].length); @@ -169,6 +171,7 @@ function detectComponents(code: string): ComponentInfo[] { name, exportedName: name, isDefault, + isExportConst: true, declarationStart: match.index, nameStart, nameEnd: nameStart + name.length, @@ -192,6 +195,7 @@ function detectComponents(code: string): ComponentInfo[] { name, exportedName: name, isDefault: true, + isExportConst: false, declarationStart: match.index, nameStart, nameEnd: nameStart + name.length, @@ -223,8 +227,17 @@ function detectComponents(code: string): ComponentInfo[] { // Skip non-components (lowercase first letter) if (!/^[A-Z]/.test(internalName)) continue; - // Skip already detected by Pattern 1 or 2 - if (components.some((c) => c.name === internalName)) continue; + // If already detected by Pattern 1 or 2, backfill exportStatementRange so + // transformSubmoduleComponents knows to remove the export { } statement + const alreadyDetected = components.find((c) => c.name === internalName); + if (alreadyDetected) { + if (!alreadyDetected.exportStatementRange) { + alreadyDetected.exportStatementRange = exportStatementRange; + // Update exportedName in case of `export { Foo as Bar }` + alreadyDetected.exportedName = exportedName; + } + continue; + } // Locate the plain const declaration const constDeclPattern = new RegExp( @@ -238,6 +251,7 @@ function detectComponents(code: string): ComponentInfo[] { const body = extractFunctionBody( constMatch.index + constMatch[0].length ); + if (usesVanTags(body)) { const declarationStart = constMatch.index; const nameStart = @@ -247,6 +261,7 @@ function detectComponents(code: string): ComponentInfo[] { name, exportedName, isDefault: false, + isExportConst: false, declarationStart, nameStart, nameEnd, @@ -260,6 +275,40 @@ function detectComponents(code: string): ComponentInfo[] { return components; } +/** Detect local name of `createContext` imported from @michthemaker/vanjs */ +function detectCreateContextLocalName(code: string): string | null { + const match = + /import\s+(?:\w+\s*,\s*)?\{[^}]*\bcreateContext\s*(?:as\s+(\w+))?[^}]*\}\s*from\s+['"]@michthemaker\/vanjs['"]/.exec( + code + ); + if (!match) return null; + // if aliased: `createContext as foo` → foo, else → createContext + const aliasMatch = /\bcreateContext\s+as\s+(\w+)/.exec(match[0]); + return aliasMatch ? aliasMatch[1] : "createContext"; +} + +/** Transform createContext() → __VAN_HMR__.createContext('hmrId') */ +function transformCreateContext( + ctx: TransformContext, + localName: string +): void { + const { code, s, relPath, getLineCol } = ctx; + const pattern = new RegExp( + `\\b${localName}\\s*(?:<[^>]*>)?\\s*\\(\\s*\\)`, + "g" + ); + let match; + while ((match = pattern.exec(code)) !== null) { + const start = match.index; + const hmrId = `${relPath}:${getLineCol(start)}`; + s.overwrite( + start, + start + match[0].length, + `__VAN_HMR__.createContext('${hmrId}')` + ); + } +} + /** Inject the HMR runtime import */ function injectHmrImport(s: MagicString): void { s.prepend(`import { __VAN_HMR__ } from 'virtual:vanjs-hmr-runtime';\n`); @@ -377,6 +426,7 @@ function transformSubmoduleComponents( name, exportedName, isDefault, + isExportConst, declarationStart, nameStart, nameEnd, @@ -385,23 +435,36 @@ function transformSubmoduleComponents( const slotId = `${relPath}:${name}`; const hmrName = `$$__hmr__${name}`; - // Rename the original component declaration in-place - s.overwrite(nameStart, nameEnd, hmrName); - if (exportStatementRange) { - // Pattern 3: plain const + separate export { } statement + // Pattern 3: plain const (or export const) + separate export { } statement + + // 1. Remove the export { } block (deduplicated for multi-export statements) const rangeKey = `${exportStatementRange.start}:${exportStatementRange.end}`; if (!removedExportRanges.has(rangeKey)) { s.remove(exportStatementRange.start, exportStatementRange.end); removedExportRanges.add(rangeKey); } - // Ensure $$__hmr__Name is exported so newModule.$$__hmr__Name works in HMR accept - s.prependLeft(declarationStart, "export "); + + // 2. Rename + ensure exported in one overwrite to avoid MagicString conflicts + if (!isExportConst) { + // `const Name` → `export const $$__hmr__Name` + s.overwrite( + nameStart - "const ".length, + nameEnd, + `export const ${hmrName}` + ); + } else { + // `export const Name` → `export const $$__hmr__Name` (just rename) + s.overwrite(nameStart, nameEnd, hmrName); + } + + // 3. Append public wrapper s.append( `\nexport const ${exportedName} = (props) => __VAN_HMR__.registerRender('${slotId}', ${hmrName}, props);\n` ); } else if (isDefault) { - // Pattern 2: plain const + export default + // Pattern 2: plain const + export default Name + s.overwrite(nameStart, nameEnd, hmrName); const isAlreadyExported = code.slice(declarationStart, declarationStart + 6) === "export"; if (!isAlreadyExported) { @@ -417,7 +480,8 @@ function transformSubmoduleComponents( ); } } else { - // Pattern 1: export const — append wrapper using exportedName + // Pattern 1: export const Name — rename + append wrapper + s.overwrite(nameStart, nameEnd, hmrName); s.append( `\nexport const ${exportedName} = (props) => __VAN_HMR__.registerRender('${slotId}', ${hmrName}, props);\n` ); @@ -469,16 +533,19 @@ export function vanjsRefresh(options: VanJSHMROptions = {}): Plugin { }, transform(code, id) { - // Skip non-matching files + // Skip non-matching files and virtual modules + if (id.startsWith("\0")) return null; if (!include.test(id) || exclude.test(id)) return null; + const hasCreateContext = + code.includes("createContext") && code.includes("@michthemaker/vanjs"); if ( !code.includes("van.state") && !code.includes("van.tags") && - !code.includes("van.derive") + !code.includes("van.derive") && + !hasCreateContext ) { return null; } - const s = new MagicString(code); const relPath = posix .normalize(id.replace(projectRoot, "").replace(/\\/g, "/")) @@ -496,6 +563,9 @@ export function vanjsRefresh(options: VanJSHMROptions = {}): Plugin { // === Common transformations === transformVanState(ctx); // transformVanDerive(ctx); + const createContextLocalName = detectCreateContextLocalName(code); + if (createContextLocalName) + transformCreateContext(ctx, createContextLocalName); const components = detectComponents(code); injectHmrImport(s); @@ -538,10 +608,11 @@ export function vanjsRefresh(options: VanJSHMROptions = {}): Plugin { // Source of truth: apps/examples/plugin-test/src/hmr-runtime.ts // ============================================ const HMR_RUNTIME_CODE = (shapeMatching = true) => ` -import van from "@michthemaker/vanjs"; +import van, { createContext as __vanCreateContext, contextStacks as __contextStacks } from "@michthemaker/vanjs"; class VanJSHMRRuntime { stateRegistry = new Map(); + contextRegistry = new Map(); // derivedRegistry = new Map(); renderSlots = new Map(); currentStateContext = null; @@ -580,7 +651,6 @@ class VanJSHMRRuntime { // Shape changed — reset in place to keep derived subscriptions intact this.log('summary', '[VanJS HMR] 🔄 State shape changed for "' + hmrId + '" — resetting'); preserved.val = initial; - console.log(preserved) return preserved; } const state = this.originalVanState(initial); @@ -616,6 +686,15 @@ class VanJSHMRRuntime { return state; } + createContext(hmrId) { + if (this.contextRegistry.has(hmrId)) { + return this.contextRegistry.get(hmrId); + } + const ctx = __vanCreateContext(); + this.contextRegistry.set(hmrId, ctx); + return ctx; + } + /** * @dev it's like we won't create derived anymore */ @@ -723,7 +802,14 @@ class VanJSHMRRuntime { const startMarker = new Comment('hmr:' + id + ':start'); const endMarker = new Comment('hmr:' + id + ':end'); - this.renderSlots.set(id, { startMarker, endMarker, props }); + + // Snapshot active context values at render time + const contextSnapshot = new Map(); + for (const [ctx, stack] of __contextStacks.entries()) { + if (stack.length > 0) contextSnapshot.set(ctx, stack[stack.length - 1]); + } + + this.renderSlots.set(id, { startMarker, endMarker, props, contextSnapshot }); const element = fn(props); this.currentInstanceId = prevInstanceId; @@ -740,27 +826,29 @@ class VanJSHMRRuntime { } showErrorOverlay(slotId, error) { - this.dismissErrorOverlay(); - const message = error instanceof Error ? error.message : String(error); - const stack = error instanceof Error ? (error.stack || '') : ''; - const overlay = document.createElement('div'); - overlay.id = '__vanjs-hmr-error-overlay'; - overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);color:#fff;z-index:99999;font-family:monospace;padding:40px;box-sizing:border-box;overflow:auto;display:flex;flex-direction:column;'; - overlay.innerHTML = '
' - + '
' - + '

HMR Error in ' + slotId + '

' - + '' - + '
' - + '

Old DOM preserved. Fix the error and save to retry.

' - + '
'
-      + this.escapeHtml(message) + '\\n\\n' + this.escapeHtml(stack) + '
'; - const dismiss = () => this.dismissErrorOverlay(); - overlay.querySelector('#__vanjs-hmr-dismiss')?.addEventListener('click', dismiss); - const onKey = (e) => { if (e.key === 'Escape') { dismiss(); document.removeEventListener('keydown', onKey); } }; - document.addEventListener('keydown', onKey); - document.body.appendChild(overlay); - this.errorOverlay = overlay; - } + this.dismissErrorOverlay(); + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? (error.stack || '') : ''; + const overlay = document.createElement('div'); + overlay.id = '__vanjs-hmr-error-overlay'; + overlay.className = 'vanjs-hmr-no-scrollbar'; + overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);color:#fff;z-index:99999;font-family:monospace;padding:12px;box-sizing:border-box;overflow:auto;display:flex;flex-direction:column;'; + overlay.innerHTML = '' + + '
' + + '
' + + '

HMR Error in ' + slotId + '

' + + '' + + '
' + + '

Old DOM preserved. Fix the error and save to retry.

' + + '
'
+        + this.escapeHtml(message) + '\\n\\n' + this.escapeHtml(stack) + '
'; + const dismiss = () => this.dismissErrorOverlay(); + overlay.querySelector('#__vanjs-hmr-dismiss')?.addEventListener('click', dismiss); + const onKey = (e) => { if (e.key === 'Escape') { dismiss(); document.removeEventListener('keydown', onKey); } }; + document.addEventListener('keydown', onKey); + document.body.appendChild(overlay); + this.errorOverlay = overlay; + } dismissErrorOverlay() { if (this.errorOverlay) { this.errorOverlay.remove(); this.errorOverlay = null; } @@ -807,7 +895,24 @@ class VanJSHMRRuntime { if (props !== undefined) slot.props = props; try { - const newElement = fn(slot.props); + // Replay context snapshot so useContext works outside original call tree + const snapshot = slot.contextSnapshot; + if (snapshot) { + for (const [ctx, value] of snapshot.entries()) { + if (!__contextStacks.has(ctx)) __contextStacks.set(ctx, []); + __contextStacks.get(ctx).push(value); + } + } + let newElement; + try { + newElement = fn(slot.props); + } finally { + if (snapshot) { + for (const [ctx] of snapshot.entries()) { + __contextStacks.get(ctx)?.pop(); + } + } + } this.currentInstanceId = prevInstanceId; parent.insertBefore(newElement, endMarker); this.dismissErrorOverlay();