sinwan-compiler 0.3.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@
8
8
  * Nested patterns are left as snapshots. JSX spreads of the props (or rest)
9
9
  * identifier use `getSpreadProps` so native attributes keep getter functions.
10
10
  */
11
- import * as t from "@babel/types";
11
+ import * as t from "./ast/types";
12
12
  /**
13
13
  * Rewrite flat `cc` destructure and raw JSX spreads. Returns whether any
14
14
  * helper imports are required.
@@ -6,7 +6,7 @@
6
6
  * when the value changes.
7
7
  *
8
8
  * Supported reactive sources:
9
- * - `createMutable` / `createStore` from `sinwan/store`
9
+ * - `createMutable` / `createStore` from `sinway`
10
10
  * - `signal` / `computed` from `sinwan/reactivity`
11
11
  * - `useState` from `sinwan/react`
12
12
  * - `.value` reads of signals returned from other modules (`useTheme`,
@@ -20,7 +20,7 @@
20
20
  * <p>{count.value}</p> → <p>{() => count.value}</p>
21
21
  * <p>{getCount()}</p> → <p>{() => getCount()}</p>
22
22
  */
23
- import * as t from "@babel/types";
23
+ import * as t from "./ast/types";
24
24
  export interface ImportNames {
25
25
  createMutable: Set<string>;
26
26
  createStore: Set<string>;
@@ -98,6 +98,8 @@ export declare function collectComponentCallGraph(ast: t.Node, componentNames: M
98
98
  export declare function buildFullScope(localScopes: Map<t.Function, ReactiveScope>, fn: t.Function, reactiveProps: Set<string>): ReactiveScope | null;
99
99
  export declare function propagateReactiveProps(localScopes: Map<t.Function, ReactiveScope>, callGraph: Map<t.Function, CallSite[]>, componentFunctions: Set<t.Function>): Map<t.Function, Set<string>>;
100
100
  export declare function wrapReactiveExpressions(ast: t.Node, options?: {
101
+ derivedLocals?: boolean;
102
+ dev?: boolean;
101
103
  explicitBindings?: boolean;
102
104
  analyze?: string;
103
105
  analyzeMetadata?: Map<string, Map<string, Set<string>>>;
@@ -5,7 +5,9 @@
5
5
  * Static JSX elements are extracted to module-level template strings
6
6
  * and replaced with optimized template creation calls.
7
7
  */
8
+ import { t } from "./ast";
8
9
  export interface TransformOptions {
10
+ derivedLocals?: boolean;
9
11
  /** Enable template hoisting (default: true) */
10
12
  hoist?: boolean;
11
13
  /** Enable dev mode (source maps, etc.) */
@@ -39,6 +41,7 @@ export declare const COMPILER_TEMPLATE_SLOT_PROTOCOL: {
39
41
  encodeSlot(index: number): string;
40
42
  decodeSlot(data: string): number | null;
41
43
  };
44
+ export declare function jsxAttrName(attr: t.Node): string | undefined;
42
45
  export declare function transformJSX(code: string, filename: string, options?: TransformOptions): {
43
46
  code: string;
44
47
  map?: any;
package/docs/api.md CHANGED
@@ -29,11 +29,11 @@ See [Transform](transform.md) for wrapping rules and options.
29
29
 
30
30
  ### `autoWrapComponents(ast, filename?)`
31
31
 
32
- Mutates a Babel AST: wrap exported component-like functions with `cc(...)`. Returns `true` if anything changed. `transformJSX` already runs this.
32
+ Mutates an ESTree AST (oxc-parser): wrap exported component-like functions with `cc(...)`. Returns `true` if anything changed. `transformJSX` already runs this.
33
33
 
34
34
  ### `rewriteLiveCcDestructure(ast)`
35
35
 
36
- Mutates a Babel AST: rewrite flat `cc(({ value }) => …)` params to a `props` identifier and `{...props}` spreads to `getSpreadProps(props)`. Nested destructure is left as a snapshot. `transformJSX` already runs this before reactive wrap.
36
+ Mutates an ESTree AST (oxc-parser): rewrite flat `cc(({ value }) => …)` params to a `props` identifier and `{...props}` spreads to `getSpreadProps(props)`. Nested destructure is left as a snapshot. `transformJSX` already runs this before reactive wrap.
37
37
 
38
38
  ## Analysis
39
39
 
@@ -122,7 +122,7 @@ Parse `analyze [root] [outFile] [--tsconfig] [--bunfig] [--workspaces]` and call
122
122
 
123
123
  | Name | Role |
124
124
  | ---- | ---- |
125
- | `collectExportedComponents(code)` | File-local exported component names (no `cc` required) |
125
+ | `collectExportedComponents(code)` | File-local exported component names (no `cc` required). Skips type-only exports. |
126
126
  | `wrapReactiveExpressions(ast, options?)` | Reactive wrap without hoisting |
127
127
  | `trackReactiveImports(ast)` | Import-name sets for signals, `cc`, `useFetch`, … |
128
128
  | `collectComponentFunctions(ast, ccNames)` | `cc(...)` functions, names, and exports |
@@ -6,6 +6,7 @@ This page is for people changing the compiler. App authors can stay on [Transfor
6
6
 
7
7
  ```
8
8
  src/
9
+ ast/ oxc parse, Path/Scope, esrap print
9
10
  index.ts Public exports
10
11
  auto-cc.ts Exported function → cc(...)
11
12
  live-cc-destructure.ts Flat cc(({ value }) => …) → props.value
@@ -16,6 +17,21 @@ src/
16
17
  exports.ts collectExportedComponents
17
18
  ```
18
19
 
20
+ ## AST pipeline
21
+
22
+ `transformJSX` and analyze parse with **oxc-parser** (`parseSync`, ESTree / TS-ESTree). The compiler parses as TSX so JSX and TypeScript are accepted in `.js`, `.jsx`, `.ts`, and `.tsx`, matching the previous Babel plugin set. Comments are copied onto `leadingComments` / `trailingComments`. A JSX `{/* comment */}` stays on its empty expression. A comment that sits inside a function or object after the last child stays on that container. Rewrite passes walk a Babel-shaped **Path/Scope** facade built on oxc `visitorKeys` (parent pointers, `getBinding`, `replaceWith`). Print is **esrap** with `esrap/languages/tsx` and double quotes.
23
+
24
+ Babel is not used. oxc-parser JS/JSX matches Acorn ESTree; TS/TSX matches TS-ESTree. Passes must treat:
25
+
26
+ - `Literal` (not `StringLiteral` / `NumericLiteral`)
27
+ - `Property` (not `ObjectProperty`)
28
+ - `ChainExpression` around optional calls/members
29
+ - `ParenthesizedExpression` (`preserveParens` defaults true) — unwrap in reactive walks, do not require Babel’s implicit paren stripping
30
+
31
+ Print is esrap, not oxc-codegen or MagicString, so mutations stay on the tree. Acorn / acorn-walk cannot own this pipeline: Acorn does not parse TSX, and acorn-walk has no bindings or JSX/TS visitor keys. Bun.Transpiler and SWC are out of scope (no AST mutation API / not the primary engine).
32
+
33
+ `derivedLocals` uses the Path/Scope facade; a Rust rewrite of passes is not required while those tests pass.
34
+
19
35
  ## Data flow
20
36
 
21
37
  ```text
@@ -23,6 +39,8 @@ transformJSX
23
39
  autoWrapComponents
24
40
  rewriteLiveCcDestructure ← flat destructure stays live; raw JSX spreads
25
41
  wrapReactiveExpressions ← analyze / analyzeMetadata / resolveImport
42
+ promoteReactiveConstInitializers ← opt-in derivedLocals, snapshot diagnostics
43
+ rebuild scopes / call graph / JSX wrapping
26
44
  hoist JSXElement roots ← _$createTemplate + slots
27
45
 
28
46
  analyze / AnalyzerCache.update
package/docs/plugins.md CHANGED
@@ -48,14 +48,15 @@ The Vite plugin sets `resolve.dedupe: ["sinwan"]` for the same duplicate-runtime
48
48
 
49
49
  ## Shared plugin options
50
50
 
51
- | Option | Default | Role |
52
- | ------ | ------- | ---- |
53
- | `hoist` | `true` | Template hoisting |
54
- | `dev` | Bun: `NODE_ENV !== "production"` at `sinwan()`; Vite: `config.mode !== "production"` | Warn when hoisting is skipped. Pass `false` for library/`Bun.build` production bundles. |
55
- | `explicitBindings` | `false` | `_$bind*` helpers instead of bare `() =>` |
56
- | `analyze` | unset | Path to CLI JSON (production) |
57
- | `cache` | `true` | Incremental analyzer in dev. `false` turns it off. An object sets `root`, `tsConfigPath`, `bunfigPath`, `workspaces`, `cachePath` |
58
- | `fastRefresh` | Bun: same as `dev`; Vite: `true` in `serve` | Per-component `import.meta.hot.accept` so routed/looked-up pages hot-update |
51
+ | Option | Default | Role |
52
+ | ------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
53
+ | `hoist` | `true` | Template hoisting |
54
+ | `derivedLocals` | `false` | Promote eligible component-local reactive constants to live getters; see [Transform](transform.md#derived-locals-opt-in). Supports `/** @sinwan-pure */` JSDoc-annotated imports; see [`@sinwan-pure` JSDoc contract](transform.md#sinwan-pure-jsdoc-contract). Requires compiler, plugin and runtime builds with this feature. |
55
+ | `dev` | Bun: `NODE_ENV !== "production"` at `sinwan()`; Vite: `config.mode !== "production"` | Warn when hoisting is skipped. Pass `false` for library/`Bun.build` production bundles. |
56
+ | `explicitBindings` | `false` | `_$bind*` helpers instead of bare `() =>` |
57
+ | `analyze` | unset | Path to CLI JSON (production) |
58
+ | `cache` | `true` | Incremental analyzer in dev. `false` turns it off. An object sets `root`, `tsConfigPath`, `bunfigPath`, `workspaces`, `cachePath` |
59
+ | `fastRefresh` | Bun: same as `dev`; Vite: `true` in `serve` | Per-component `import.meta.hot.accept` so routed/looked-up pages hot-update |
59
60
 
60
61
  ## Dev vs production
61
62
 
package/docs/transform.md CHANGED
@@ -76,12 +76,12 @@ export const Child = cc((props) => <span>{() => props.value}</span>);
76
76
 
77
77
  Tracked imports:
78
78
 
79
- | Module | Names |
80
- | ------ | ----- |
81
- | `sinwan/store` | `createMutable`, `createStore` |
82
- | `sinwan/reactivity` | `signal`, `computed` |
83
- | `sinwan/react` | `useState` |
84
- | `sinwan/hook` | `useFetch` |
79
+ | Module | Names |
80
+ | ------------------- | ------------------------------ |
81
+ | `sinway` | `createMutable`, `createStore` |
82
+ | `sinwan/reactivity` | `signal`, `computed` |
83
+ | `sinwan/react` | `useState` |
84
+ | `sinwan/hook` | `useFetch` |
85
85
 
86
86
  Reads the compiler wraps:
87
87
 
@@ -98,11 +98,311 @@ Not wrapped:
98
98
 
99
99
  - Literals and plain identifiers
100
100
  - Event-handler functions (`onClick={fn}` or `onClick={() => …}`)
101
- - Nested function *values* passed as props (`title={() => s.value}` stays a render prop)
101
+ - Nested function _values_ passed as props (`title={() => s.value}` stays a render prop)
102
102
  - Functions stored as object/array values (`{ onClick: () => s.value }`) or returned from a factory (`const make = () => () => s.value`)
103
103
  - `useFetch` methods such as `abort` / `execute`
104
104
  - Static values the [analyzer](analyzer.md) proved never change
105
105
 
106
+ ## Derived locals (opt-in)
107
+
108
+ JSX expressions containing reactive reads already stay live without an author-written arrow. By default, moving that read into `const open = count.value > 0` still takes a **setup snapshot**. Inline one-off expressions in JSX; use a getter or `computed()` for shared derivations when promotion is disabled.
109
+
110
+ Enable `derivedLocals: true` in `transformJSX` or either plugin to promote eligible component-local constants:
111
+
112
+ ```tsx
113
+ export function App() {
114
+ const count = signal(0);
115
+ const positive = count.value > 0;
116
+ const label = positive ? "positive" : "zero";
117
+ return (
118
+ <Show when={positive}>
119
+ <span title={label}>{label}</span>
120
+ </Show>
121
+ );
122
+ }
123
+ ```
124
+
125
+ The derived declarations become zero-argument getters, and references become value reads:
126
+
127
+ ```tsx
128
+ const positive = () => count.value > 0;
129
+ const label = () => (positive() ? "positive" : "zero");
130
+ return (
131
+ <Show when={() => positive()}>
132
+ <span title={() => label()}>{() => label()}</span>
133
+ </Show>
134
+ );
135
+ ```
136
+
137
+ ### Eligibility and semantics
138
+
139
+ - Only referenced, identifier-named `const` declarations directly in a `cc` setup body (including auto-`cc` components) are candidates. Dependencies are processed in declaration order, including multiple declarators in one statement.
140
+ - Pure read expressions are supported: member/index/optional access, arithmetic, comparisons, boolean/conditional expressions, template strings, object/array literals, and TypeScript expression annotations. Direct imported `useState` getter calls and unshadowed single-argument `Boolean`, `Number`, and `String` coercions are also supported. Property reads and coercions must be free of user-defined side effects.
141
+ - Calls to arbitrary helpers, hooks, factories or methods, assignments, updates, allocations, destructuring, `let`/`var`, mutable captures, and forward/TDZ references remain eager — **unless** the callee is an imported function annotated with `/** @sinwan-pure */` (see [`@sinwan-pure` JSDoc contract](#sinwan-pure-jsdoc-contract) below). Explicit getter and `computed` initializers are unchanged. Module, event-handler, render-callback, loop and nested-block declarations are not promoted.
142
+ - Bare signal/store containers still pass through unchanged. Bare component prop-bag aliases retain their ordinary JavaScript value semantics. References are resolved by lexical binding, so shadowed locals and object keys are not rewritten.
143
+ - Every ordinary value use is called, including chained initializers, closures, event bodies, shorthand object values and setup `if` tests. A boolean does **not** become an accidentally truthy function. Setup control flow still runs once; use `Show`/`Switch` for reactive branches.
144
+ - Variables used as JSX tag names, in type queries, or as mutation/method-call receivers are left eager. Components containing direct `eval` are excluded.
145
+ - Getters are lazy, not cached. Use explicit `computed()` when caching matters. This is not whole-program dataflow, and does not turn arbitrary setup side effects into effects.
146
+
147
+ ### Intentional snapshots and diagnostics
148
+
149
+ Put `/* sinwan-snapshot */` immediately before a declaration or its initializer to retain a one-shot read:
150
+
151
+ ```tsx
152
+ /* sinwan-snapshot */ const initial = count.value;
153
+ const alsoInitial = /* sinwan-snapshot */ count.value;
154
+ ```
155
+
156
+ A declaration-level pragma applies to all its declarators. A snapshot can read another promoted local: the compiler calls that dependency once at the snapshot declaration.
157
+
158
+ With `dev: true`, the compiler warns when an eligible unpromoted derivation is used in JSX while promotion is disabled. It also warns about a promoted local used directly as a setup `if` test, explaining that the value is read once even though the compiler preserves boolean semantics. Intentional snapshot pragmas suppress the snapshot diagnostic; production (`dev: false`) is quiet.
159
+
160
+ ### `@sinwan-pure` JSDoc contract
161
+
162
+ The compiler can promote derived `const` initializers that call imported utility functions when those functions are annotated with `/** @sinwan-pure */` in their source file. This eliminates the need for manual `() =>` wrappers around common utilities like className mergers.
163
+
164
+ **Annotation:**
165
+
166
+ ```ts
167
+ /** @sinwan-pure */
168
+ export function cn(...inputs: ClassValue[]): string {
169
+ return twMerge(clsx(inputs));
170
+ }
171
+ ```
172
+
173
+ Both `export function` and `export const` declarations are recognized. The annotation must be a block comment (`/** */`) immediately before the `export` keyword.
174
+
175
+ **What the compiler guarantees:**
176
+
177
+ - When `derivedLocals: true` is enabled, calls to `@sinwan-pure`-annotated imported functions are treated as safe expressions. The containing `const` initializer is promoted to a zero-argument getter, and references inside the function arguments are rewritten (e.g. `seamInactive` → `seamInactive()`).
178
+ - The compiler resolves the import path, reads the source file, parses it, and checks for `/** @sinwan-pure */` on exported declarations. Results are cached per resolved file path, so each file is read at most once per compilation process.
179
+ - All import types are scanned: relative imports (`./`, `../`), bare specifiers (`clsx`, `sinwan/reactivity`), scoped packages (`@scope/utils`), and subpath imports (`@scope/utils/merge`). The compiler walks up the directory tree to find `node_modules/<pkg>`, reads `package.json`, and resolves the entry point via `exports`, `module`, `main`, `source`, or `index` files.
180
+ - If the `resolveImport` callback is provided by the plugin, it is used for path resolution (handles tsconfig paths, bunfig aliases, workspace packages, and Bun/Vite module resolution). Otherwise, the compiler falls back to direct filesystem resolution with common extensions (`.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.mts`, `.cts`) and `index` files.
181
+
182
+ **Supported export forms (annotate with `/** @sinwan-pure \*/`):\*\*
183
+
184
+ | Export form | Example | Call shape |
185
+ | ---------------------- | ----------------------------------------------------------- | -------------------- |
186
+ | Named function | `/** @sinwan-pure */ export function cn()` | `cn(...)` |
187
+ | Named const (function) | `/** @sinwan-pure */ export const merge = () =>` | `merge(...)` |
188
+ | Named const (object) | `/** @sinwan-pure */ export const helpers = { ... }` | `helpers.merge(...)` |
189
+ | Named class | `/** @sinwan-pure */ export class Utils { static merge() }` | `Utils.merge(...)` |
190
+ | Default function | `/** @sinwan-pure */ export default function helper()` | `helper(...)` |
191
+ | Default arrow | `/** @sinwan-pure */ export default () =>` | `helper(...)` |
192
+ | Default class | `/** @sinwan-pure */ export default class {}` | `helper.merge(...)` |
193
+
194
+ **Supported import forms:**
195
+
196
+ | Import form | Example | Call shape |
197
+ | ---------------------- | --------------------------------------- | ------------- |
198
+ | Named import | `import { cn } from "./utils"` | `cn(...)` |
199
+ | Named import (aliased) | `import { cn as merge } from "./utils"` | `merge(...)` |
200
+ | Default import | `import helper from "./utils"` | `helper(...)` |
201
+ | Namespace import | `import * as ns from "./utils"` | `ns.cn(...)` |
202
+
203
+ **Supported re-export forms:**
204
+
205
+ | Re-export form | Example |
206
+ | ------------------------- | -------------------------------------------- |
207
+ | Named re-export | `export { cn } from "./utils"` |
208
+ | Aliased re-export | `export { cn as merge } from "./utils"` |
209
+ | Default re-export | `export { default } from "./utils"` |
210
+ | Aliased default re-export | `export { default as merge } from "./utils"` |
211
+ | Namespace re-export | `export * from "./utils"` |
212
+
213
+ For class and object exports, any static method or property call on the imported binding is treated as pure (e.g. `Utils.merge(...)`, `Utils.format(...)`, `helpers.merge(...)`, `helpers.format(...)`).
214
+
215
+ **What the annotation author is responsible for:**
216
+
217
+ - **Purity:** The function must not produce side effects — no signal writes, no DOM mutations, no I/O, no global state changes. Calling the function with the same arguments must always return the same value. The compiler does not verify this; it trusts the annotation.
218
+ - **No hidden reactivity:** The function must not read signals, stores, or other reactive sources internally. If it does, those reads will not be tracked because the function is called inside a getter that the runtime evaluates in an effect — but only the arguments passed to the function are tracked, not reads inside the function body.
219
+ - **Export visibility:** The annotation must be on an exported declaration (`export function`, `export const`, `export class`, `export default`). Re-exports are followed recursively — the compiler resolves the re-export source and scans it for the annotation. Named, aliased, default, and `export *` re-exports are all supported. Circular re-exports are handled with a depth limit to prevent infinite loops.
220
+ - **Import resolvability:** The annotated file must be reachable via any import (relative, bare specifier, scoped package, or subpath). The compiler resolves `node_modules` packages via `package.json` `exports`/`module`/`main`/`source` fields and walks up the directory tree.
221
+
222
+ **What happens when the annotation is wrong:**
223
+
224
+ - **Annotated a non-pure function:** The compiler will promote the containing `const` to a getter. If the function has side effects, those side effects will run on every re-evaluation of the getter (whenever its tracked dependencies change), not just once at setup. This will not crash, but may cause unexpected repeated side effects.
225
+ - **Annotated a function that reads signals internally:** Those internal signal reads will not be tracked as dependencies of the promoted getter. The getter will not re-evaluate when those internal signals change, producing stale values. No crash, but incorrect reactivity.
226
+ - **Forgot to annotate a pure function:** The compiler will reject the containing `const` initializer as unsafe. The value will be a one-shot setup snapshot. No crash, but the value will not update when its dependencies change. The `dev: true` diagnostic will warn about eligible unpromoted derivations used in JSX.
227
+ - **Annotation on a non-exported function:** The annotation is ignored. Only exported declarations are scanned.
228
+ - **Import path unresolvable:** The annotation is not found. The function is treated as a non-pure call. No crash, but the containing `const` will not be promoted. This can happen if the package's `package.json` entry points don't lead to a source file, or if the `node_modules` directory is not in the expected location.
229
+
230
+ **Plugin forwarding:**
231
+
232
+ Both `bun-plugin-sinwan` and `vite-plugin-sinwan` forward the `resolveImport` callback to the compiler when the analyzer cache is enabled (`cache: true`, the default). This uses the plugin's module resolver, which handles workspace packages, tsconfig paths, and Bun/Vite resolution. When the cache is disabled or the minimal preload is used, the compiler falls back to direct filesystem resolution, which works for relative imports in a standard project layout.
233
+
234
+ **What is not scanned (by design):**
235
+
236
+ - **Dynamic imports** (`const { cn } = await import("./utils")`) — the binding is established at runtime, not compile time. The compiler cannot statically resolve which module a dynamic import loads, so it cannot scan the source for `@sinwan-pure` annotations. Use a static import instead.
237
+ - **`require()`** (`const { cn } = require("./utils")`) — CommonJS, not used in Sinwan's ESM target. Bun and Vite both use ESM for application code. Annotate the function and import it via a static ESM import.
238
+ - **Computed member access with non-literal keys** (`ns[key]()` where `key` is a variable, or `ns[\`cn`]()` with template literals) — the compiler cannot statically determine which property is being accessed. Use direct member access (`ns.cn()`) or string literal access (`ns["cn"]()`) instead.
239
+
240
+ ### Workarounds for unsupported patterns
241
+
242
+ When the compiler cannot auto-promote a derived local (dynamic import, `require()`, or computed member access), use one of these workarounds to preserve reactivity.
243
+
244
+ #### 1. Replace dynamic import with static import (recommended)
245
+
246
+ If the module is available at startup, use a static ESM import instead of `await import()`. The compiler can then scan the source file for `@sinwan-pure` annotations and auto-promote the derived local.
247
+
248
+ ```ts
249
+ // ❌ Does NOT work — dynamic import, binding is runtime
250
+ const { cn } = await import("./utils");
251
+ const cls = cn("base", size.value > 50 && "big");
252
+
253
+ // ✅ Works — static import, compiler auto-promotes
254
+ import { cn } from "./utils"; // /** @sinwan-pure */ export function cn(...)
255
+ const cls = cn("base", size.value > 50 && "big");
256
+
257
+ // Compiled output:
258
+ // const cls = () => cn("base", size.value > 50 && "big");
259
+ ```
260
+
261
+ #### 2. Replace `require()` with static ESM import (recommended)
262
+
263
+ Sinwan targets ESM. Use a static ESM import instead of `require()`.
264
+
265
+ ```ts
266
+ // ❌ Does NOT work — CommonJS require(), not ESM
267
+ const { cn } = require("./utils");
268
+ const cls = cn("base", size.value > 50 && "big");
269
+
270
+ // ✅ Works — static ESM import, compiler auto-promotes
271
+ import { cn } from "./utils";
272
+ const cls = cn("base", size.value > 50 && "big");
273
+ ```
274
+
275
+ #### 3. Replace computed member access with direct or string literal access (recommended)
276
+
277
+ If you control the call site, use direct member access (`ns.cn()`) or string literal access (`ns["cn"]()`) instead of a variable key.
278
+
279
+ ```ts
280
+ import * as ns from "./utils";
281
+
282
+ // ❌ Does NOT work — variable key, compiler can't resolve
283
+ const key = "cn";
284
+ const cls = ns[key]("base", size.value > 50 && "big");
285
+
286
+ // ❌ Does NOT work — template literal key, compiler can't resolve
287
+ const cls = ns[`cn`]("base", size.value > 50 && "big");
288
+
289
+ // ✅ Works — direct member access, compiler auto-promotes
290
+ const cls = ns.cn("base", size.value > 50 && "big");
291
+
292
+ // ✅ Works — string literal key, compiler auto-promotes
293
+ const cls = ns["cn"]("base", size.value > 50 && "big");
294
+ ```
295
+
296
+ #### 4. Use a manual `() =>` getter (fallback for truly dynamic patterns)
297
+
298
+ If you genuinely need dynamic import (e.g. for code-splitting) or computed member access with a variable key, wrap the expression in a manual `() =>` getter. The runtime will re-evaluate it in an effect whenever its tracked dependencies change.
299
+
300
+ ```ts
301
+ import * as ns from "./utils";
302
+ import { computed } from "sinwan/reactivity";
303
+
304
+ // Manual getter — the compiler does not promote it, but the runtime
305
+ // wraps it in an effect so it stays reactive.
306
+ const cls = () => {
307
+ const key = "cn";
308
+ return ns[key]("base", size.value > 50 && "big");
309
+ };
310
+
311
+ // Or use computed() for a memoized version (re-evaluates only when
312
+ // size.value changes, not on every access):
313
+ const clsMemo = computed(() => {
314
+ const key = "cn";
315
+ return ns[key]("base", size.value > 50 && "big");
316
+ });
317
+ ```
318
+
319
+ #### 5. Use `computed()` for expensive dynamic patterns
320
+
321
+ If the dynamic expression is expensive (e.g. involves an `await import()`), use `computed()` to memoize the result. The computation runs once and caches until a dependency changes.
322
+
323
+ ```ts
324
+ import { computed } from "sinwan/reactivity";
325
+
326
+ // computed() runs the async import lazily and caches the result.
327
+ // The compiler does not promote this (it's already a getter),
328
+ // but the runtime tracks size.value as a dependency.
329
+ const cls = computed(() => {
330
+ // For dynamic import, you would need to resolve the module
331
+ // outside the computed() and only use the dynamic key inside:
332
+ const key = "cn";
333
+ return ns[key]("base", size.value > 50 && "big");
334
+ });
335
+ ```
336
+
337
+ #### Complete demo
338
+
339
+ ```ts
340
+ // lib/utils.ts
341
+ /** @sinwan-pure */
342
+ export function cn(...inputs: unknown[]): string {
343
+ return String(inputs);
344
+ }
345
+
346
+ /** @sinwan-pure */
347
+ export const helpers = {
348
+ merge: (...inputs: unknown[]): string => String(inputs),
349
+ };
350
+
351
+ // comp.tsx
352
+ import { cc } from "sinwan/component";
353
+ import { signal, computed } from "sinwan/reactivity";
354
+ import { cn, helpers } from "../lib/utils";
355
+ import * as ns from "../lib/utils";
356
+
357
+ export const App = cc(() => {
358
+ const size = signal(30);
359
+
360
+ // ✅ Auto-promoted — static import + @sinwan-pure
361
+ const a = cn("base", size.value > 50 && "big");
362
+
363
+ // ✅ Auto-promoted — namespace import + direct member access
364
+ const b = ns.cn("base", size.value > 50 && "big");
365
+
366
+ // ✅ Auto-promoted — string literal key
367
+ const c = ns["cn"]("base", size.value > 50 && "big");
368
+
369
+ // ✅ Auto-promoted — object member call
370
+ const d = helpers.merge("base", size.value > 50 && "big");
371
+
372
+ // ✅ Manual getter — for computed member access with variable key
373
+ const e = () => {
374
+ const key = "cn";
375
+ return ns[key]("base", size.value > 50 && "big");
376
+ };
377
+
378
+ // ✅ computed() — memoized, re-evaluates only when size.value changes
379
+ const f = computed(() => ns.cn("base", size.value > 50 && "big"));
380
+
381
+ return <div class={a} data-b={b} data-c={c} data-d={d} data-e={e} data-f={f} />;
382
+ });
383
+ ```
384
+
385
+ Compiled output:
386
+
387
+ ```ts
388
+ const a = () => cn("base", size.value > 50 && "big");
389
+ const b = () => ns.cn("base", size.value > 50 && "big");
390
+ const c = () => ns["cn"]("base", size.value > 50 && "big");
391
+ const d = () => helpers.merge("base", size.value > 50 && "big");
392
+ const e = () => {
393
+ // manual getter — preserved as-is
394
+ const key = "cn";
395
+ return ns[key]("base", size.value > 50 && "big");
396
+ };
397
+ const f = computed(() => ns.cn("base", size.value > 50 && "big")); // already a getter
398
+ ```
399
+
400
+ ### Client, SSR and hydration
401
+
402
+ Generated getter calls use the existing JSX wrapping and explicit-binding rules; manual getter identifiers do not need a new blanket wrapping policy. Native attributes must use a runtime build with plain zero-argument getter hydration support. Text, attributes and `Show` remain reactive after hydration; event handlers and nonzero-arity callbacks are not invoked as getters.
403
+
404
+ The runtime's existing complex-template hydration fallback can replace a hoisted server subtree during initial hydration. Promotion does not change that policy; subsequent signal updates do not remount the native shell. Non-hoisted attribute hydration binds to the existing DOM in place.
405
+
106
406
  ## DOM vs component JSX
107
407
 
108
408
  **Native elements** (`div`, `p`, `button`, …): wrap reactive children and attributes.
@@ -110,18 +410,18 @@ Not wrapped:
110
410
  **User components** (`<Label>`, `<Child title={…} />`):
111
411
 
112
412
  - **Children:** wrap reactive reads the same way as DOM text (`{count.value}`, `{checked.value ? "On" : "Off"}`) so `cc()` parents do not snapshot the value. Use a getter (`() => …`), never `_$bindText`, because the child may render nodes as well as strings. Render-prop functions (`{(v) => …}`) stay untouched.
113
- - **Props:** wrap a prop if it is known-reactive for that component (built-in registry, local call graph, analyzer metadata). For *unknown* imported components, still wrap **derived reads** (`htmlFor={id.value}`, `disabled={off.value}`) and leave **container pass-through** alone (`checked={checked}`, `user={user}`) so the child receives the signal or store proxy. With `explicitBindings`, component props stay **getters** (`each={() => …}`), never `_$bindAttr` — `For` / `Show` / `Label` call `resolve(prop)`, which does not unwrap bind descriptors.
413
+ - **Props:** wrap a prop if it is known-reactive for that component (built-in registry, local call graph, analyzer metadata). For _unknown_ imported components, still wrap **derived reads** (`htmlFor={id.value}`, `disabled={off.value}`) and leave **container pass-through** alone (`checked={checked}`, `user={user}`) so the child receives the signal or store proxy. With `explicitBindings`, component props stay **getters** (`each={() => …}`), never `_$bindAttr` — `For` / `Show` / `Label` call `resolve(prop)`, which does not unwrap bind descriptors.
114
414
 
115
415
  **Built-in control-flow** components wrap specific props, and they wrap reactive expression children (those children render directly):
116
416
 
117
- | Component | Reactive props |
118
- | --------- | -------------- |
119
- | `For`, `Index`, `Virtual` | `each` |
120
- | `Show`, `Switch`, `Match`, `Key` | `when` |
121
- | `Dynamic` | `component` |
122
- | `Visible` | `when`, `style` |
123
- | `Portal` | `mount` |
124
- | `Activity` | `mode` |
417
+ | Component | Reactive props |
418
+ | -------------------------------- | --------------- |
419
+ | `For`, `Index`, `Virtual` | `each` |
420
+ | `Show`, `Switch`, `Match`, `Key` | `when` |
421
+ | `Dynamic` | `component` |
422
+ | `Visible` | `when`, `style` |
423
+ | `Portal` | `mount` |
424
+ | `Activity` | `mode` |
125
425
 
126
426
  `Suspense` is not in this list; its props are left alone.
127
427
 
@@ -131,12 +431,12 @@ Static native trees become a module-level template plus `_$createTemplate(tmpl,
131
431
 
132
432
  Slots:
133
433
 
134
- | Type | When |
135
- | ---- | ---- |
434
+ | Type | When |
435
+ | ------- | ------------------------------------------ |
136
436
  | `child` | `{expr}`, or a capitalized child component |
137
- | `attr` | Dynamic attribute (not `on*`) |
138
- | `event` | `onClick={…}` and other `on*` handlers |
139
- | `ref` | `ref={fn}` or `ref={object}` |
437
+ | `attr` | Dynamic attribute (not `on*`) |
438
+ | `event` | `onClick={…}` and other `on*` handlers |
439
+ | `ref` | `ref={fn}` or `ref={object}` |
140
440
 
141
441
  Static `style` objects and strings are written into the HTML (`backgroundColor` → `background-color`). Quoted style strings that contain `${…}` log a warning in the compiler: use a template literal, `style={\`…${x}\`}`.
142
442
 
@@ -150,6 +450,7 @@ The slot marker format is `COMPILER_TEMPLATE_SLOT_PROTOCOL` (`s:0`, `s:1`, …).
150
450
 
151
451
  ```ts
152
452
  transformJSX(code, "src/App.tsx", {
453
+ derivedLocals: false,
153
454
  hoist: true, // default true
154
455
  dev: false, // warn when hoisting is skipped
155
456
  explicitBindings: false, // emit _$bindText / _$bindAttr / …
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sinwan-compiler",
3
- "version": "0.3.1",
3
+ "version": "1.0.0",
4
4
  "description": "Shared compiler core for Sinwan plugins",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -37,18 +37,14 @@
37
37
  "prepublishOnly": "bun run clean && bun run typecheck && bun run test && bun run build"
38
38
  },
39
39
  "devDependencies": {
40
- "@types/babel__generator": "^7.27.0",
41
- "@types/babel__traverse": "^7.28.0",
40
+ "@oxc-project/types": "^0.150.0",
42
41
  "@types/bun": "latest"
43
42
  },
44
43
  "dependencies": {
45
- "@babel/core": "^7.24.0",
46
- "@babel/generator": "^7.24.0",
47
- "@babel/parser": "^7.24.0",
48
- "@babel/traverse": "^7.24.0",
49
- "@babel/types": "^7.24.0"
44
+ "esrap": "^2.3.7",
45
+ "oxc-parser": "^0.150.0"
50
46
  },
51
47
  "peerDependencies": {
52
- "typescript": "^5"
48
+ "typescript": ">=5.0.0 <8.0.0"
53
49
  }
54
50
  }