sinwan-compiler 0.2.4 → 0.2.6

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.
package/dist/cli.d.ts CHANGED
@@ -8,3 +8,5 @@
8
8
  * Writes `.sinwan/reactive-props.json` by default.
9
9
  */
10
10
  export declare function runAnalyzeCli(args?: string[]): void;
11
+ /** Runs the CLI only when this module is the process entry point. */
12
+ export declare function runCliIfMain(isMain?: boolean, args?: string[]): void;
package/dist/cli.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
3
  import {
4
- runAnalyzeCli
5
- } from "./chunk-w580fdrf.js";
4
+ runAnalyzeCli2,
5
+ runCliIfMain2
6
+ } from "./chunk-m4rk009c.js";
6
7
  export {
7
- runAnalyzeCli
8
+ runAnalyzeCli2 as runAnalyzeCli,
9
+ runCliIfMain2 as runCliIfMain
8
10
  };
package/dist/index.js CHANGED
@@ -1,23 +1,23 @@
1
1
  // @bun
2
2
  import {
3
- AnalyzerCache,
4
- analyze,
5
- analyzeModule,
6
- analyzeProject,
7
- autoWrapComponents,
8
- buildFullScope,
9
- collectComponentCallGraph,
10
- collectComponentFunctions,
11
- computeLocalScopes,
12
- containsReactiveValue,
13
- getAllPropNames,
14
- isReactiveValue,
15
- loadMetadata,
16
- propagateReactiveProps,
17
- runAnalyzeCli,
18
- trackReactiveImports,
19
- wrapReactiveExpressions
20
- } from "./chunk-w580fdrf.js";
3
+ autoWrapComponents2,
4
+ analyzeModule2,
5
+ analyzeProject2,
6
+ AnalyzerCache2,
7
+ analyze2,
8
+ loadMetadata2,
9
+ trackReactiveImports2,
10
+ getAllPropNames2,
11
+ isReactiveValue2,
12
+ containsReactiveValue2,
13
+ collectComponentFunctions2,
14
+ computeLocalScopes2,
15
+ collectComponentCallGraph2,
16
+ buildFullScope2,
17
+ propagateReactiveProps2,
18
+ wrapReactiveExpressions2,
19
+ runAnalyzeCli2
20
+ } from "./chunk-m4rk009c.js";
21
21
 
22
22
  // src/transform.ts
23
23
  import { parse } from "@babel/parser";
@@ -92,6 +92,63 @@ function cleanJSXText(value) {
92
92
  }
93
93
  return str;
94
94
  }
95
+ function htmlAttributeName(attrName) {
96
+ if (attrName === "defaultValue")
97
+ return "value";
98
+ if (attrName === "defaultChecked")
99
+ return "checked";
100
+ return attrName;
101
+ }
102
+ function jsxAttrName(attr) {
103
+ if (attr.type !== "JSXAttribute" || attr.name.type !== "JSXIdentifier") {
104
+ return;
105
+ }
106
+ return attr.name.name;
107
+ }
108
+ function hasAttr(attributes, name) {
109
+ for (const attr of attributes) {
110
+ if (jsxAttrName(attr) === name)
111
+ return true;
112
+ }
113
+ return false;
114
+ }
115
+ function attrIsExpression(attributes, name) {
116
+ for (const attr of attributes) {
117
+ if (attr.type !== "JSXAttribute" || jsxAttrName(attr) !== name)
118
+ continue;
119
+ return attr.value?.type === "JSXExpressionContainer";
120
+ }
121
+ return false;
122
+ }
123
+ function elementNeedsJsxEnhancer(node) {
124
+ const tagName = jsxNameToString(node.openingElement.name);
125
+ const attrs = node.openingElement.attributes;
126
+ switch (tagName) {
127
+ case "select":
128
+ return hasAttr(attrs, "defaultValue") || hasAttr(attrs, "value");
129
+ case "textarea":
130
+ return hasAttr(attrs, "value");
131
+ case "option":
132
+ return hasAttr(attrs, "selected");
133
+ case "form":
134
+ return attrIsExpression(attrs, "action");
135
+ case "input":
136
+ case "button":
137
+ return attrIsExpression(attrs, "formAction");
138
+ case "progress":
139
+ return attrIsExpression(attrs, "value");
140
+ case "style":
141
+ return hasAttr(attrs, "href") && hasAttr(attrs, "precedence");
142
+ case "script":
143
+ return hasAttr(attrs, "src") && hasAttr(attrs, "async");
144
+ case "link":
145
+ case "meta":
146
+ case "title":
147
+ return !hasAttr(attrs, "itemProp");
148
+ default:
149
+ return false;
150
+ }
151
+ }
95
152
  function extractTemplate(node, filename) {
96
153
  const counter = { value: 0 };
97
154
  const slots = [];
@@ -175,6 +232,7 @@ function elementToHtml(node, slots, path, filename, counter) {
175
232
  }
176
233
  const isVoid = VOID_ELEMENTS.has(tagName);
177
234
  let html = `<${tagName}`;
235
+ let textareaDefault = "";
178
236
  for (const attr of node.openingElement.attributes) {
179
237
  if (attr.type === "JSXSpreadAttribute") {
180
238
  throw new Error("Cannot hoist element with spread attributes");
@@ -196,13 +254,14 @@ function elementToHtml(node, slots, path, filename, counter) {
196
254
  }
197
255
  continue;
198
256
  }
257
+ const htmlName = htmlAttributeName(attrName);
199
258
  if (attr.value?.type === "JSXExpressionContainer") {
200
259
  const expr = attr.value.expression;
201
260
  if (expr.type === "JSXEmptyExpression")
202
261
  continue;
203
262
  if (attrName === "style" && t.isExpression(expr) && isStaticStyleValue(expr)) {
204
263
  const serialized = serializeStyleValue(expr);
205
- html += ` ${attrName}="${escapeHtml(serialized)}"`;
264
+ html += ` ${htmlName}="${escapeHtml(serialized)}"`;
206
265
  continue;
207
266
  }
208
267
  slots.push({
@@ -211,7 +270,7 @@ function elementToHtml(node, slots, path, filename, counter) {
211
270
  name: attrName,
212
271
  expr
213
272
  });
214
- html += ` ${attrName}=""`;
273
+ html += ` ${htmlName}=""`;
215
274
  continue;
216
275
  }
217
276
  if (attr.value?.type === "JSXText" || attr.value?.type === "StringLiteral") {
@@ -219,9 +278,13 @@ function elementToHtml(node, slots, path, filename, counter) {
219
278
  if (attrName === "style") {
220
279
  warnSuspectedStyleInterpolation(rawValue, filename, attr.value.loc?.start?.line);
221
280
  }
222
- html += ` ${attrName}="${escapeHtml(rawValue)}"`;
281
+ if (tagName === "textarea" && attrName === "defaultValue") {
282
+ textareaDefault = escapeHtml(rawValue);
283
+ continue;
284
+ }
285
+ html += ` ${htmlName}="${escapeHtml(rawValue)}"`;
223
286
  } else if (!attr.value) {
224
- html += ` ${attrName}`;
287
+ html += ` ${htmlName}`;
225
288
  }
226
289
  }
227
290
  html += isVoid ? " />" : ">";
@@ -229,6 +292,7 @@ function elementToHtml(node, slots, path, filename, counter) {
229
292
  return html;
230
293
  const childResult = childrenToHtml(node.children, slots, path, filename, 0, counter);
231
294
  html += childResult.html;
295
+ html += textareaDefault;
232
296
  html += `</${tagName}>`;
233
297
  return html;
234
298
  }
@@ -257,7 +321,7 @@ function childrenToHtml(children, slots, path, filename, startIndex, counter) {
257
321
  const childName = child.openingElement.name;
258
322
  const childTag = childName.type === "JSXIdentifier" ? childName.name : "";
259
323
  const isFirstCharUpper = childTag && childTag.charAt(0) === childTag.charAt(0).toUpperCase();
260
- if (isFirstCharUpper) {
324
+ if (isFirstCharUpper || elementNeedsJsxEnhancer(child)) {
261
325
  const slot = nextSlotId(counter);
262
326
  slots.push({
263
327
  path: [...path, childIndex],
@@ -292,8 +356,8 @@ function transformJSX(code, filename, options = {}) {
292
356
  plugins: ["jsx", "typescript"],
293
357
  sourceFilename: filename
294
358
  });
295
- autoWrapComponents(ast, filename);
296
- wrapReactiveExpressions(ast, {
359
+ autoWrapComponents2(ast, filename);
360
+ wrapReactiveExpressions2(ast, {
297
361
  explicitBindings: options.explicitBindings,
298
362
  analyze: options.analyze,
299
363
  analyzeMetadata: options.analyzeMetadata,
@@ -316,6 +380,8 @@ function transformJSX(code, filename, options = {}) {
316
380
  const name = tagName.name;
317
381
  if (!name || name[0] !== name[0].toLowerCase())
318
382
  return;
383
+ if (elementNeedsJsxEnhancer(path.node))
384
+ return;
319
385
  try {
320
386
  const extracted = extractTemplate(path.node, filename);
321
387
  const tmplId = `_$tmpl_${templateCounter++}`;
@@ -341,11 +407,11 @@ function transformJSX(code, filename, options = {}) {
341
407
  ], t.stringLiteral("sinwan/renderer"));
342
408
  ast.program.body.unshift(bindingImport);
343
409
  }
344
- const result2 = generate(ast, {
410
+ const result = generate(ast, {
345
411
  sourceMaps: true,
346
412
  sourceFileName: filename
347
413
  });
348
- return { code: result2.code, map: result2.map };
414
+ return { code: result.code, map: result.map };
349
415
  }
350
416
  const templateDecls = templates.map(({ id, template }) => t.variableDeclaration("const", [
351
417
  t.variableDeclarator(t.identifier(id), t.objectExpression([
@@ -427,24 +493,24 @@ function collectExportedComponents(code, filename) {
427
493
  return found;
428
494
  }
429
495
  export {
430
- AnalyzerCache,
496
+ AnalyzerCache2 as AnalyzerCache,
431
497
  COMPILER_TEMPLATE_SLOT_PROTOCOL,
432
- analyze,
433
- analyzeModule,
434
- analyzeProject,
435
- autoWrapComponents,
436
- buildFullScope,
437
- collectComponentCallGraph,
438
- collectComponentFunctions,
498
+ analyze2 as analyze,
499
+ analyzeModule2 as analyzeModule,
500
+ analyzeProject2 as analyzeProject,
501
+ autoWrapComponents2 as autoWrapComponents,
502
+ buildFullScope2 as buildFullScope,
503
+ collectComponentCallGraph2 as collectComponentCallGraph,
504
+ collectComponentFunctions2 as collectComponentFunctions,
439
505
  collectExportedComponents,
440
- computeLocalScopes,
441
- containsReactiveValue,
442
- getAllPropNames,
443
- isReactiveValue,
444
- loadMetadata,
445
- propagateReactiveProps,
446
- runAnalyzeCli,
447
- trackReactiveImports,
506
+ computeLocalScopes2 as computeLocalScopes,
507
+ containsReactiveValue2 as containsReactiveValue,
508
+ getAllPropNames2 as getAllPropNames,
509
+ isReactiveValue2 as isReactiveValue,
510
+ loadMetadata2 as loadMetadata,
511
+ propagateReactiveProps2 as propagateReactiveProps,
512
+ runAnalyzeCli2 as runAnalyzeCli,
513
+ trackReactiveImports2 as trackReactiveImports,
448
514
  transformJSX,
449
- wrapReactiveExpressions
515
+ wrapReactiveExpressions2 as wrapReactiveExpressions
450
516
  };
@@ -59,6 +59,7 @@ export interface ReactiveScope {
59
59
  bindings: Map<string, Binding>;
60
60
  }
61
61
  export declare function getAllPropNames(params: t.Node | undefined): Set<string>;
62
+ export declare function resolveLocalObjectLiteral(fn: t.Function, name: string): t.ObjectExpression | null;
62
63
  export declare function isReactiveValue(node: t.Node, scope: ReactiveScope): boolean;
63
64
  export declare function containsReactiveValue(expr: t.Expression, scope: ReactiveScope): boolean;
64
65
  export interface ComponentExpressionInfo {
@@ -66,6 +67,9 @@ export interface ComponentExpressionInfo {
66
67
  componentName: string | null;
67
68
  attributeName: string | null;
68
69
  }
70
+ export declare function getComponentExpressionInfo(exprPath: {
71
+ parentPath?: unknown;
72
+ }): ComponentExpressionInfo;
69
73
  export declare function collectComponentFunctions(ast: t.Node, ccNames: Set<string>): {
70
74
  functions: Set<t.Function>;
71
75
  names: Map<string, t.Function>;
package/docs/README.md CHANGED
@@ -1,26 +1,45 @@
1
- # sinwan-compiler
1
+ # Compiler overview
2
2
 
3
- The shared compiler package for SinwanJS. It provides the JSX transform, the project-wide reactive-prop analyzer, and the dev/HMR cache used by both the Bun and Vite plugins.
3
+ `sinwan-compiler` is the shared engine behind the Bun and Vite plugins. It has three jobs: rewrite JSX, decide which props are reactive, and keep that decision cheap in dev.
4
4
 
5
- ## What it does
5
+ If you are building a Sinwan app, start with [Plugins](plugins.md). If you are changing the compiler, start with [Architecture](architecture.md).
6
6
 
7
- - **JSX transform**: Converts reactive JSX expressions into lazy zero-arity functions so the Sinwan runtime can update the DOM efficiently.
8
- - **Cross-file analysis**: Determines which component props are reactive across the whole project, allowing the transform to avoid wrapping static props in effects.
9
- - **Dev cache**: Maintains an incremental, persistent cache for Bun and Vite so dev startup and HMR are fast.
7
+ ## Pipeline
10
8
 
11
- ## Packages
9
+ 1. **Auto-`cc`** — exported functions that look like components (`App`, `Card`, …) are wrapped with `cc(...)` so the rest of the pipeline sees the same components the runtime will.
10
+ 2. **Reactive wrap** — JSX expressions that read signals, stores, `useState`, or `useFetch` become `() => …` (or explicit binding helpers when that mode is on).
11
+ 3. **Template hoist** — static native-element trees become `_$createTemplate(...)` calls with slots for dynamic bits (`child`, `attr`, `event`, `ref`).
12
+ 4. **Analyze (optional)** — a project-wide pass marks which *exported* component props are actually reactive, so static strings are not wrapped.
12
13
 
13
- - `src/transform.ts` — JSX transform and reactive expression wrapping.
14
- - `src/analyze.ts` — `analyze`, `analyzeProject`, `AnalyzerCache`, and import resolution.
15
- - `src/reactive-wrap.ts` — Helpers for detecting reactive sources, collecting component call graphs, and propagating reactivity.
16
- - `src/cli.ts` — `sinwan-compiler analyze` CLI.
17
- - `src/index.ts` — Public package exports.
14
+ ```text
15
+ source.tsx
16
+ │
17
+ ▼
18
+ autoWrapComponents() plain `export function App()` → cc(App)
19
+ │
20
+ ▼
21
+ wrapReactiveExpressions() {count.value} → {() => count.value}
22
+ │
23
+ ▼
24
+ hoist templates <div class="card">…</div> → _$tmpl_N
25
+ │
26
+ ▼
27
+ generated module
28
+ ```
18
29
 
19
- ## Documentation
30
+ ## When analysis runs
20
31
 
21
- - [`analyzer.md`](analyzer.md) — Cross-file reactive-prop analyzer and cache.
22
- - [`transform.md`](transform.md) — JSX transform and wrapping rules.
23
- - [`cli.md`](cli.md) — Command-line usage.
24
- - [`plugins.md`](plugins.md) — Using the compiler with Bun and Vite.
25
- - [`api.md`](api.md) — Public API reference.
26
- - [`architecture.md`](architecture.md) — Internal design and data flow.
32
+ | Mode | Who runs it | What the transform receives |
33
+ | ---- | ----------- | --------------------------- |
34
+ | Dev | Plugin `AnalyzerCache` | In-memory `analyzeMetadata` after each file update |
35
+ | Production | CLI / `analyze()` | JSON file via `analyze: "./.sinwan/reactive-props.json"` |
36
+ | Off | Nobody | Conservative wrapping (exported props treated as reactive) |
37
+
38
+ ## Guides
39
+
40
+ - [Transform](transform.md) — what gets wrapped, auto-`cc`, hoisting, built-in components
41
+ - [Analyzer](analyzer.md) — call graph, spreads, workspaces, cache
42
+ - [CLI](cli.md) — `sinwan analyze`
43
+ - [Plugins](plugins.md) — wiring Bun and Vite
44
+ - [API](api.md) — exported functions and types
45
+ - [Architecture](architecture.md) — data structures and propagation
package/docs/analyzer.md CHANGED
@@ -1,16 +1,18 @@
1
- # Cross-file reactive-prop analyzer
1
+ # Cross-file analyzer
2
2
 
3
- The analyzer determines which props of an exported component are reactive. It runs over the whole project (or a cached subset) and produces a map of `filePath → exportName → Set<propName>`.
3
+ The analyzer answers: **for each exported component, which props actually change?** The transform uses that map so a static `title="Hello"` does not become an effect.
4
+
5
+ Result shape: `filePath → exportName → Set<propName>`.
4
6
 
5
7
  ## How it works
6
8
 
7
- 1. **Parse** every `.tsx/.ts/.jsx/.js` file into a Babel AST.
8
- 2. **Track imports** of reactive sources (`createMutable`, `createStore`, `signal`, `computed`, `useState`) and the component factory `cc`.
9
- 3. **Collect component functions** defined with `cc(...)`.
10
- 4. **Build a call graph** of local and imported JSX calls (`<Child ... />`).
11
- 5. **Propagate reactivity** bottom-up through the call graph using a fixed-point algorithm.
9
+ 1. Scan `.tsx` / `.ts` / `.jsx` / `.js` (or the files you pass in).
10
+ 2. Auto-wrap the same exported components the transform would wrap.
11
+ 3. Track reactive imports and `cc(...)`.
12
+ 4. Build a call graph: local `<Child />` and imported `<Child />`.
13
+ 5. Propagate reactivity until the sets stop growing.
12
14
 
13
- A prop is reactive for a given component if any of its callers passes a value that reads a reactive source.
15
+ A prop is reactive if **any** caller passes a value that reads a reactive source (including through another component).
14
16
 
15
17
  ## Example
16
18
 
@@ -23,10 +25,12 @@ export const Child = cc(({ title }) => <h1>{title}</h1>);
23
25
  import { cc } from "sinwan/component";
24
26
  import { signal } from "sinwan/reactivity";
25
27
  import { Child } from "./Child";
26
- const Parent = cc(() => <Child title={signal.value} />);
27
- ```
28
28
 
29
- The analyzer produces:
29
+ const Parent = cc(() => {
30
+ const title = signal("Hello");
31
+ return <Child title={title.value} />;
32
+ });
33
+ ```
30
34
 
31
35
  ```json
32
36
  {
@@ -34,64 +38,71 @@ The analyzer produces:
34
38
  }
35
39
  ```
36
40
 
37
- ## Workspace packages
41
+ If every caller passed `title="Hello"`, `Child` would be `[]` and the transform would not wrap `title` inside `Child`.
38
42
 
39
- Cross-file analysis works for local monorepo packages. The analyzer discovers workspace packages from a `package.json` `workspaces` field, a `pnpm-workspace.yaml`, or explicit package paths. Only packages that are actually imported by the project are analyzed.
43
+ Default exports are stored under the name `"default"`.
40
44
 
41
- ```ts
42
- analyzeProject({
43
- root: "./apps/web",
44
- // From a workspace file
45
- workspaces: "../../package.json",
46
- // From a pnpm workspace
47
- workspaces: "../../pnpm-workspace.yaml",
48
- // Explicit package paths or globs
49
- workspaces: ["../../packages/sinwan-ui", "../../packages/shared"],
50
- // Or combine both
51
- workspaces: {
52
- file: "../../package.json",
53
- include: ["../../packages/sinwan-ui"],
54
- },
55
- });
56
- ```
45
+ ## Spreads
46
+
47
+ The analyzer is precise when it can see the object:
57
48
 
58
- Package imports are resolved to files inside the declared package, typically under the package `src` directory (or the directory of the `source` field in `package.json`). Subpath imports such as `@sinwan/ui/Button` map to `packages/sinwan-ui/src/Button.tsx`.
49
+ ```tsx
50
+ <Child {...{ title: "Hello", count: s.value }} />
51
+ // only `count` is reactive
59
52
 
53
+ const props = { title: "Hello", count: s.value };
54
+ <Child {...props} />
55
+ // same, if `props` is that object literal in the same function
56
+ ```
60
57
 
58
+ String keys work (`{ "title": s.value }`). If the spread is an unknown value (`{...rest}` from parameters), **every known prop of the callee**, including `children`, is marked reactive.
61
59
 
62
60
  ## Import resolution
63
61
 
64
- The analyzer resolves imports in this order:
62
+ Lookups run in this order:
63
+
64
+ 1. `tsconfig.json` `paths` (when a config is passed or `tsconfig.json` exists next to `root`)
65
+ 2. `bunfig.toml` aliases (`[install] alias = { … }` or `[install.alias]`)
66
+ 3. Workspace packages (see below)
67
+ 4. Relative imports (`./Child`, `./ui` → `ui.tsx` or `ui/index.tsx`)
68
+
69
+ You can replace that with `resolve(source, fromFile)`.
70
+
71
+ `AnalyzerCache` also resolves **in-memory** modules first (HMR can update a file before it hits disk), including `./ui` → `ui/index.tsx` already in the cache.
65
72
 
66
- 1. `tsconfig.json` `paths` (if configured).
67
- 2. `bunfig.toml` aliases (if configured or auto-detected).
68
- 3. Workspace packages (if configured).
69
- 4. Relative imports with the configured extensions.
73
+ ## Workspaces
70
74
 
71
- You can also provide a custom resolver:
75
+ Only packages that the app actually imports are pulled in.
72
76
 
73
77
  ```ts
74
78
  analyzeProject({
75
- root: "/project",
76
- resolve: (source, fromFile) => {
77
- // return absolute path or null
78
- },
79
+ root: "./apps/web",
80
+ workspaces: "../../package.json", // npm/bun `workspaces`
81
+ // or "../../pnpm-workspace.yaml"
82
+ // or ["../../packages/ui"]
83
+ // or { file: "../../package.json", include: ["../../packages/ui"] }
79
84
  });
80
85
  ```
81
86
 
82
- ## Spread props
87
+ `@scope/ui/Button` maps to `packages/ui/src/Button.tsx` (or the directory of `package.json` `"source"`).
88
+
89
+ ## Cache (`AnalyzerCache`)
90
+
91
+ Dev plugins call `update(file, code)` on each transform. The cache:
92
+
93
+ - Re-analyzes that file
94
+ - Walks importers **and** imported modules
95
+ - Writes `cachePath` when you set one
96
+
97
+ `remove(file)` is for deletes / HMR unlinks. It drops the module and **recomputes both**:
83
98
 
84
- Spread props are handled precisely when possible:
99
+ - files that imported it (parents)
100
+ - files it imported (children — their props may become static again)
85
101
 
86
- - `<Child {...{ title: "Hello", count: signal.value }} />` is treated as two named props; only `count` becomes reactive.
87
- - `<Child {...props} />` is also resolved precisely if `props` is a local variable initialized with an object literal in the same function:
88
- ```tsx
89
- const props = { title: "Hello", count: signal.value };
90
- return <Child {...props} />;
91
- ```
92
- - Spreads of function parameters or other unknown runtime objects fall back to the conservative rule: all known props of the callee are marked reactive.
102
+ If `cachePath` exists, the constructor restores it. Function identities are start/end offsets. If a file changed so those spans no longer match, that module is skipped (no throw); the next `update` rebuilds it.
93
103
 
94
- ## Limitations
104
+ ## Conservative rules
95
105
 
96
- - Unknown runtime objects (`{...props}` where `props` is not a local object literal) are conservatively treated as reactive.
97
- - Named slot props and `children` are regular props; they are reactive only when the passed value is reactive.
106
+ - Unknown spreads are conservative (all callee props).
107
+ - `children` is a normal prop.
108
+ - Without metadata, the transform treats **exported** component props as reactive so a parent in another file cannot silently go stale.