sinwan-compiler 0.2.2 → 0.2.3

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.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * SinwanJS Compiler — Auto `cc` wrapping
3
+ *
4
+ * Detects top-level exported functions that look like components (uppercase
5
+ * name, 0–1 params, return JSX) and wraps them with `cc(...)` so the reactive
6
+ * analyzer treats them as components and the runtime gives them a component
7
+ * instance + displayName.
8
+ *
9
+ * This lets plain `export function App()` / `export const App = () => <div/>`
10
+ * work as Sinwan components without an explicit `cc(...)` call, matching the
11
+ * ergonomics of React function components while preserving Sinwan's explicit
12
+ * reactivity model (the compiler still only auto-wraps JSX expressions that
13
+ * read tracked reactive sources).
14
+ *
15
+ * Detection is conservative on purpose:
16
+ * - Only exported bindings are considered (named or default export).
17
+ * - The binding name must start with an uppercase letter (component naming
18
+ * convention). Anonymous default-exported function declarations are also
19
+ * accepted since they are conventionally components.
20
+ * - The function must have 0 or 1 parameters and must lexically return JSX
21
+ * (a JSXElement/JSXFragment in a return statement or arrow body).
22
+ * - Functions already wrapped in `cc(...)` (or an alias) are skipped to keep
23
+ * the transform idempotent.
24
+ *
25
+ * If any wrapping is performed and `cc` is not already imported from
26
+ * `sinwan` / `sinwan/component`, an `import { cc } from "sinwan/component"` is
27
+ * prepended so the subsequent reactive-wrap pass detects the components.
28
+ */
29
+ import * as t from "@babel/types";
30
+ /**
31
+ * Mutates the AST, wrapping exported component-like functions with `cc(...)`.
32
+ * Returns `true` if any wrapping was performed.
33
+ */
34
+ export declare function autoWrapComponents(ast: t.Node, filename?: string): boolean;
@@ -4,8 +4,8 @@ import * as path3 from "path";
4
4
 
5
5
  // src/analyze.ts
6
6
  import { parse } from "@babel/parser";
7
- import * as t2 from "@babel/types";
8
- import _traverse2 from "@babel/traverse";
7
+ import * as t3 from "@babel/types";
8
+ import _traverse3 from "@babel/traverse";
9
9
  import * as fs from "fs";
10
10
  import * as path2 from "path";
11
11
 
@@ -17,7 +17,8 @@ var traverse = typeof _traverse === "function" ? _traverse : _traverse.default ?
17
17
  var REACTIVE_SOURCE_MODULES = {
18
18
  "sinwan/store": new Set(["createMutable", "createStore"]),
19
19
  "sinwan/reactivity": new Set(["signal", "computed"]),
20
- "sinwan/react": new Set(["useState"])
20
+ "sinwan/react": new Set(["useState"]),
21
+ "sinwan/hook": new Set(["useFetch"])
21
22
  };
22
23
  var COMPONENT_FACTORY_MODULES = {
23
24
  sinwan: new Set(["cc"]),
@@ -42,6 +43,9 @@ function isBuiltinReactiveProp(componentName, attrName) {
42
43
  return false;
43
44
  return entry.reactiveProps.has(attrName);
44
45
  }
46
+ function isBuiltinControlFlowComponent(componentName) {
47
+ return !!componentName && BUILTIN_REACTIVE_PROPS.has(componentName);
48
+ }
45
49
  function trackReactiveImports(ast) {
46
50
  const names = {
47
51
  createMutable: new Set,
@@ -49,6 +53,7 @@ function trackReactiveImports(ast) {
49
53
  signal: new Set,
50
54
  computed: new Set,
51
55
  useState: new Set,
56
+ useFetch: new Set,
52
57
  cc: new Set
53
58
  };
54
59
  traverse(ast, {
@@ -96,6 +101,32 @@ function isReactiveSourceCall(expr, names) {
96
101
  }
97
102
  return null;
98
103
  }
104
+ var USEFETCH_CHAIN_METHODS = new Set([
105
+ "json",
106
+ "text",
107
+ "blob",
108
+ "arrayBuffer",
109
+ "formData",
110
+ "get",
111
+ "put",
112
+ "post",
113
+ "delete",
114
+ "patch",
115
+ "head",
116
+ "options"
117
+ ]);
118
+ function isUseFetchCall(expr, names) {
119
+ if (!t.isCallExpression(expr))
120
+ return false;
121
+ const callee = expr.callee;
122
+ if (t.isIdentifier(callee)) {
123
+ return names.useFetch.has(callee.name);
124
+ }
125
+ if (t.isMemberExpression(callee) && !callee.computed && t.isIdentifier(callee.property) && USEFETCH_CHAIN_METHODS.has(callee.property.name)) {
126
+ return isUseFetchCall(callee.object, names);
127
+ }
128
+ return false;
129
+ }
99
130
  function trackLocalScopeBindings(path2, names, scope) {
100
131
  path2.traverse({
101
132
  VariableDeclarator(p) {
@@ -104,27 +135,42 @@ function trackLocalScopeBindings(path2, names, scope) {
104
135
  if (!init)
105
136
  return;
106
137
  const source = isReactiveSourceCall(init, names);
107
- if (!source)
108
- return;
109
- if (source.isArray) {
110
- if (t.isArrayPattern(id) && id.elements[0]) {
111
- const first = id.elements[0];
112
- if (t.isIdentifier(first)) {
113
- scope.bindings.set(first.name, { kind: source.kind });
138
+ if (source) {
139
+ if (source.isArray) {
140
+ if (t.isArrayPattern(id) && id.elements[0]) {
141
+ const first = id.elements[0];
142
+ if (t.isIdentifier(first)) {
143
+ scope.bindings.set(first.name, { kind: source.kind });
144
+ }
145
+ }
146
+ return;
147
+ }
148
+ if (t.isIdentifier(id)) {
149
+ if (source.kind === "mutable") {
150
+ scope.bindings.set(id.name, {
151
+ kind: "mutable",
152
+ root: id.name,
153
+ path: []
154
+ });
155
+ } else {
156
+ scope.bindings.set(id.name, { kind: source.kind });
114
157
  }
115
158
  }
116
159
  return;
117
160
  }
118
- if (t.isIdentifier(id)) {
119
- if (source.kind === "mutable") {
120
- scope.bindings.set(id.name, {
121
- kind: "mutable",
122
- root: id.name,
123
- path: []
124
- });
125
- } else {
126
- scope.bindings.set(id.name, { kind: source.kind });
161
+ if (isUseFetchCall(init, names)) {
162
+ if (t.isObjectPattern(id)) {
163
+ for (const prop of id.properties) {
164
+ if (t.isObjectProperty(prop) && !prop.computed && t.isIdentifier(prop.value)) {
165
+ scope.bindings.set(prop.value.name, {
166
+ kind: "signal"
167
+ });
168
+ }
169
+ }
170
+ } else if (t.isIdentifier(id)) {
171
+ scope.bindings.set(id.name, { kind: "signalObject" });
127
172
  }
173
+ return;
128
174
  }
129
175
  }
130
176
  });
@@ -241,16 +287,27 @@ function analyzeObjectLiteralSpread(spread, fullScope, calleeReactive) {
241
287
  }
242
288
  }
243
289
  }
290
+ function isMemberLike(node) {
291
+ return t.isMemberExpression(node) || t.isOptionalMemberExpression(node);
292
+ }
244
293
  function getMemberExpressionRootAndPath(node) {
245
294
  const path2 = [];
246
295
  let current = node;
247
- while (t.isMemberExpression(current)) {
248
- if (current.computed)
249
- return null;
250
- if (!t.isIdentifier(current.property))
251
- return null;
252
- path2.push(current.property.name);
253
- current = current.object;
296
+ while (true) {
297
+ if (t.isTSNonNullExpression(current)) {
298
+ current = current.expression;
299
+ continue;
300
+ }
301
+ if (isMemberLike(current)) {
302
+ if (current.computed)
303
+ return null;
304
+ if (!t.isIdentifier(current.property))
305
+ return null;
306
+ path2.push(current.property.name);
307
+ current = current.object;
308
+ continue;
309
+ }
310
+ break;
254
311
  }
255
312
  if (t.isIdentifier(current)) {
256
313
  return { root: current.name, path: path2.reverse() };
@@ -258,6 +315,9 @@ function getMemberExpressionRootAndPath(node) {
258
315
  return null;
259
316
  }
260
317
  function isReactiveRead(node, scope) {
318
+ if (t.isTSNonNullExpression(node)) {
319
+ return isReactiveRead(node.expression, scope);
320
+ }
261
321
  if (t.isIdentifier(node)) {
262
322
  const binding2 = scope.bindings.get(node.name);
263
323
  if (!binding2)
@@ -270,6 +330,8 @@ function isReactiveRead(node, scope) {
270
330
  }
271
331
  return false;
272
332
  }
333
+ if (!isMemberLike(node))
334
+ return false;
273
335
  const rootPath = getMemberExpressionRootAndPath(node);
274
336
  if (!rootPath)
275
337
  return false;
@@ -285,6 +347,9 @@ function isReactiveRead(node, scope) {
285
347
  if (binding.kind === "signal" || binding.kind === "computed") {
286
348
  return rootPath.path[0] === "value";
287
349
  }
350
+ if (binding.kind === "signalObject") {
351
+ return rootPath.path.length >= 2 && rootPath.path[1] === "value";
352
+ }
288
353
  return false;
289
354
  }
290
355
  function isGetterCall(node, scope) {
@@ -295,12 +360,17 @@ function isGetterCall(node, scope) {
295
360
  return binding?.kind === "getter";
296
361
  }
297
362
  function isReactiveValue(node, scope) {
363
+ if (t.isTSNonNullExpression(node)) {
364
+ return isReactiveValue(node.expression, scope);
365
+ }
298
366
  if (t.isIdentifier(node)) {
299
367
  const binding2 = scope.bindings.get(node.name);
300
368
  if (!binding2)
301
369
  return false;
302
370
  return binding2.kind === "prop" || binding2.kind === "signal" || binding2.kind === "computed" || binding2.kind === "mutable";
303
371
  }
372
+ if (!isMemberLike(node))
373
+ return false;
304
374
  const rootPath = getMemberExpressionRootAndPath(node);
305
375
  if (!rootPath)
306
376
  return false;
@@ -313,6 +383,9 @@ function isReactiveValue(node, scope) {
313
383
  if (binding.kind === "signal" || binding.kind === "computed") {
314
384
  return rootPath.path[0] === "value";
315
385
  }
386
+ if (binding.kind === "signalObject") {
387
+ return rootPath.path.length >= 2 && rootPath.path[1] === "value";
388
+ }
316
389
  return false;
317
390
  }
318
391
  function containsReactiveValue(expr, scope) {
@@ -322,7 +395,7 @@ function containsReactiveValue(expr, scope) {
322
395
  return;
323
396
  if (!node || typeof node !== "object")
324
397
  return;
325
- if (t.isMemberExpression(node) || t.isIdentifier(node)) {
398
+ if (isMemberLike(node) || t.isTSNonNullExpression(node) || t.isIdentifier(node)) {
326
399
  if (isReactiveValue(node, scope)) {
327
400
  found = true;
328
401
  return;
@@ -359,7 +432,7 @@ function containsReactiveRead(expr, scope) {
359
432
  return;
360
433
  if (!node || typeof node !== "object")
361
434
  return;
362
- if (t.isMemberExpression(node) || t.isIdentifier(node)) {
435
+ if (isMemberLike(node) || t.isTSNonNullExpression(node) || t.isIdentifier(node)) {
363
436
  if (isReactiveRead(node, scope)) {
364
437
  found = true;
365
438
  return;
@@ -430,7 +503,7 @@ function getComponentExpressionInfo(exprPath) {
430
503
  }
431
504
  return { isComponent: true, componentName, attributeName };
432
505
  }
433
- function isReactiveComponentProp(info, componentNames, reactiveProps) {
506
+ function isReactiveComponentProp(info, componentNames, reactiveProps, importedReactiveProps) {
434
507
  if (!info.isComponent || !info.componentName || !info.attributeName) {
435
508
  return false;
436
509
  }
@@ -438,12 +511,16 @@ function isReactiveComponentProp(info, componentNames, reactiveProps) {
438
511
  return true;
439
512
  }
440
513
  const componentFn = componentNames.get(info.componentName);
441
- if (!componentFn)
442
- return false;
443
- const props = reactiveProps.get(componentFn);
444
- if (!props)
514
+ if (componentFn) {
515
+ const props = reactiveProps.get(componentFn);
516
+ if (!props)
517
+ return false;
518
+ return props.has(info.attributeName);
519
+ }
520
+ const importedProps = importedReactiveProps.get(info.componentName);
521
+ if (!importedProps)
445
522
  return false;
446
- return props.has(info.attributeName);
523
+ return importedProps.has(info.attributeName);
447
524
  }
448
525
  function shouldWrap(expr, scope) {
449
526
  if (t.isArrowFunctionExpression(expr) || t.isFunctionExpression(expr))
@@ -452,6 +529,65 @@ function shouldWrap(expr, scope) {
452
529
  return false;
453
530
  return containsReactiveRead(expr, scope);
454
531
  }
532
+ var RESOLVE_HELPER = "_$unwrap";
533
+ function transformPropMemberAccess(expr, scope) {
534
+ let changed = false;
535
+ function resolveRoot(rootName) {
536
+ return t.callExpression(t.identifier(RESOLVE_HELPER), [
537
+ t.identifier(rootName)
538
+ ]);
539
+ }
540
+ function replaceMemberRoot(node, rootName) {
541
+ let current = node;
542
+ let parent = null;
543
+ while (true) {
544
+ if (t.isTSNonNullExpression(current)) {
545
+ current = current.expression;
546
+ continue;
547
+ }
548
+ if (!isMemberLike(current))
549
+ break;
550
+ parent = current;
551
+ current = current.object;
552
+ }
553
+ if (parent && t.isIdentifier(current) && current.name === rootName) {
554
+ parent.object = resolveRoot(rootName);
555
+ }
556
+ }
557
+ function visit(node) {
558
+ if (!node || typeof node !== "object")
559
+ return;
560
+ if (t.isArrowFunctionExpression(node) || t.isFunctionExpression(node))
561
+ return;
562
+ if (isMemberLike(node)) {
563
+ const rootPath = getMemberExpressionRootAndPath(node);
564
+ if (rootPath) {
565
+ const binding = scope.bindings.get(rootPath.root);
566
+ if (binding && binding.kind === "prop") {
567
+ replaceMemberRoot(node, rootPath.root);
568
+ changed = true;
569
+ return;
570
+ }
571
+ }
572
+ return;
573
+ }
574
+ for (const key of Object.keys(node)) {
575
+ if (key === "loc" || key === "start" || key === "end" || key === "leadingComments" || key === "trailingComments")
576
+ continue;
577
+ const value = node[key];
578
+ if (Array.isArray(value)) {
579
+ for (const item of value) {
580
+ if (item && typeof item === "object")
581
+ visit(item);
582
+ }
583
+ } else if (value && typeof value === "object") {
584
+ visit(value);
585
+ }
586
+ }
587
+ }
588
+ visit(expr);
589
+ return changed;
590
+ }
455
591
  function wrapExpression(expr) {
456
592
  return t.arrowFunctionExpression([], expr);
457
593
  }
@@ -681,53 +817,219 @@ function propagateReactiveProps(localScopes, callGraph, componentFunctions) {
681
817
  return reactiveProps;
682
818
  }
683
819
  function wrapReactiveExpressions(ast, options = {}) {
684
- const names = trackReactiveImports(ast);
685
- const hasAnyImport = Object.values(names).some((s) => s.size > 0);
686
- if (!hasAnyImport)
687
- return;
688
- const {
689
- functions: componentFunctions,
690
- names: componentNames,
691
- exported
692
- } = collectComponentFunctions(ast, names.cc);
693
- const localScopes = computeLocalScopes(ast, names);
694
- const callGraph = collectComponentCallGraph(ast, componentNames);
695
- const reactiveProps = propagateReactiveProps(localScopes, callGraph, componentFunctions);
696
- const exportNameByFn = new Map;
697
- for (const [localName, fn] of componentNames) {
698
- if (exported.has(fn)) {
699
- exportNameByFn.set(fn, localName);
820
+ let metadata = null;
821
+ if (options.analyzeMetadata) {
822
+ metadata = options.analyzeMetadata;
823
+ } else if (options.analyze && options.filename) {
824
+ try {
825
+ metadata = loadMetadata(options.analyze);
826
+ } catch {
827
+ metadata = null;
700
828
  }
701
829
  }
830
+ const absoluteFilename = options.filename ? path.resolve(options.filename) : null;
831
+ const resolveImport = options.resolveImport;
832
+ const names = {
833
+ createMutable: new Set,
834
+ createStore: new Set,
835
+ signal: new Set,
836
+ computed: new Set,
837
+ useState: new Set,
838
+ useFetch: new Set,
839
+ cc: new Set
840
+ };
841
+ const componentFunctions = new Set;
842
+ const componentNames = new Map;
843
+ const exported = new Set;
844
+ const localScopes = new Map;
845
+ const callGraph = new Map;
846
+ const rawCallGraph = new Map;
847
+ const importedReactiveProps = new Map;
848
+ const exportNameByFn = new Map;
849
+ let hasUnwrapImport = false;
702
850
  traverse(ast, {
703
- ExportDefaultDeclaration(path2) {
704
- const decl = path2.node.declaration;
851
+ ImportDeclaration(p) {
852
+ const source = p.node.source.value;
853
+ const reactiveAllowed = REACTIVE_SOURCE_MODULES[source];
854
+ const componentAllowed = COMPONENT_FACTORY_MODULES[source];
855
+ if (reactiveAllowed || componentAllowed) {
856
+ for (const spec of p.node.specifiers) {
857
+ if (!t.isImportSpecifier(spec))
858
+ continue;
859
+ const imported = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
860
+ if (reactiveAllowed?.has(imported)) {
861
+ names[imported].add(spec.local.name);
862
+ } else if (componentAllowed?.has(imported)) {
863
+ names.cc.add(spec.local.name);
864
+ }
865
+ }
866
+ }
867
+ if (source === "sinwan/reactivity") {
868
+ for (const spec of p.node.specifiers) {
869
+ if (t.isImportSpecifier(spec) && t.isIdentifier(spec.imported) && spec.imported.name === "unwrap") {
870
+ hasUnwrapImport = true;
871
+ }
872
+ }
873
+ }
874
+ if (metadata && absoluteFilename && resolveImport && source.startsWith(".")) {
875
+ const resolved = resolveImport(source, absoluteFilename);
876
+ if (resolved) {
877
+ const fileProps = metadata.get(resolved);
878
+ if (fileProps) {
879
+ for (const spec of p.node.specifiers) {
880
+ if (!t.isImportSpecifier(spec))
881
+ continue;
882
+ const imported = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
883
+ if (!imported || !/^[A-Z]/.test(imported))
884
+ continue;
885
+ const props = fileProps.get(imported);
886
+ if (props && props.size > 0) {
887
+ importedReactiveProps.set(spec.local.name, new Set(props));
888
+ }
889
+ }
890
+ }
891
+ }
892
+ }
893
+ },
894
+ CallExpression(p) {
895
+ const callee = p.node.callee;
896
+ if (!t.isIdentifier(callee) || !names.cc.has(callee.name))
897
+ return;
898
+ const firstArg = p.node.arguments[0];
899
+ if (!firstArg || !t.isFunction(firstArg))
900
+ return;
901
+ const componentFn = firstArg;
902
+ componentFunctions.add(componentFn);
903
+ const parent = p.parentPath;
904
+ if (parent && parent.isVariableDeclarator && parent.isVariableDeclarator()) {
905
+ const id = parent.node.id;
906
+ if (t.isIdentifier(id)) {
907
+ componentNames.set(id.name, componentFn);
908
+ }
909
+ } else if (parent && parent.isExportNamedDeclaration && parent.isExportNamedDeclaration()) {
910
+ const declaration = parent.node.declaration;
911
+ if (t.isVariableDeclaration(declaration)) {
912
+ for (const decl of declaration.declarations) {
913
+ if (t.isIdentifier(decl.id)) {
914
+ componentNames.set(decl.id.name, componentFn);
915
+ }
916
+ }
917
+ }
918
+ }
919
+ },
920
+ ExportNamedDeclaration(p) {
921
+ const declaration = p.node.declaration;
922
+ if (!t.isVariableDeclaration(declaration))
923
+ return;
924
+ for (const decl of declaration.declarations) {
925
+ const init = decl.init;
926
+ if (!init || !t.isCallExpression(init))
927
+ continue;
928
+ const callee = init.callee;
929
+ if (!t.isIdentifier(callee) || !names.cc.has(callee.name))
930
+ continue;
931
+ const firstArg = init.arguments[0];
932
+ if (!firstArg || !t.isFunction(firstArg))
933
+ continue;
934
+ exported.add(firstArg);
935
+ }
936
+ },
937
+ ExportDefaultDeclaration(p) {
938
+ const decl = p.node.declaration;
705
939
  if (t.isIdentifier(decl)) {
706
940
  const fn = componentNames.get(decl.name);
707
- if (fn)
941
+ if (fn) {
942
+ exported.add(fn);
708
943
  exportNameByFn.set(fn, "default");
944
+ }
709
945
  } else if (t.isCallExpression(decl)) {
710
946
  const callee = decl.callee;
711
947
  if (t.isIdentifier(callee) && names.cc.has(callee.name)) {
712
948
  const firstArg = decl.arguments[0];
713
949
  if (firstArg && t.isFunction(firstArg)) {
950
+ exported.add(firstArg);
714
951
  exportNameByFn.set(firstArg, "default");
715
952
  }
716
953
  }
717
954
  }
955
+ },
956
+ Function(p) {
957
+ const scope = { bindings: new Map };
958
+ trackLocalScopeBindings(p, names, scope);
959
+ localScopes.set(p.node, scope);
960
+ },
961
+ JSXElement(p) {
962
+ const name = p.node.openingElement.name;
963
+ if (!t.isJSXIdentifier(name))
964
+ return;
965
+ const callerPath = p.findParent((pp) => pp.isFunction());
966
+ const callerFn = callerPath ? callerPath.node : null;
967
+ if (!callerFn)
968
+ return;
969
+ const props = [];
970
+ const spreads = [];
971
+ for (const attr of p.node.openingElement.attributes) {
972
+ if (t.isJSXSpreadAttribute(attr)) {
973
+ if (attr.argument) {
974
+ spreads.push(attr.argument);
975
+ }
976
+ continue;
977
+ }
978
+ if (t.isJSXAttribute(attr)) {
979
+ const attrName = attr.name.name;
980
+ if (!attrName)
981
+ continue;
982
+ if (t.isJSXExpressionContainer(attr.value)) {
983
+ const expr = attr.value.expression;
984
+ if (!expr || t.isJSXEmptyExpression(expr))
985
+ continue;
986
+ props.push({ name: attrName, value: expr });
987
+ }
988
+ }
989
+ }
990
+ const childExprs = [];
991
+ for (const child of p.node.children) {
992
+ if (t.isJSXExpressionContainer(child)) {
993
+ const expr = child.expression;
994
+ if (!expr || t.isJSXEmptyExpression(expr))
995
+ continue;
996
+ childExprs.push(expr);
997
+ }
998
+ }
999
+ if (childExprs.length === 1) {
1000
+ props.push({ name: "children", value: childExprs[0] });
1001
+ } else if (childExprs.length > 1) {
1002
+ props.push({ name: "children", value: t.arrayExpression(childExprs) });
1003
+ }
1004
+ const sites = rawCallGraph.get(callerFn) ?? [];
1005
+ sites.push({ calleeName: name.name, props, spreads });
1006
+ rawCallGraph.set(callerFn, sites);
718
1007
  }
719
1008
  });
720
- let metadata = null;
721
- if (options.analyzeMetadata) {
722
- metadata = options.analyzeMetadata;
723
- } else if (options.analyze && options.filename) {
724
- try {
725
- metadata = loadMetadata(options.analyze);
726
- } catch {
727
- metadata = null;
1009
+ for (const [callerFn, sites] of rawCallGraph) {
1010
+ const resolved = [];
1011
+ for (const site of sites) {
1012
+ const callee = componentNames.get(site.calleeName);
1013
+ if (callee) {
1014
+ resolved.push({ callee, props: site.props, spreads: site.spreads });
1015
+ }
1016
+ }
1017
+ if (resolved.length > 0) {
1018
+ callGraph.set(callerFn, resolved);
728
1019
  }
729
1020
  }
730
- const absoluteFilename = options.filename ? path.resolve(options.filename) : null;
1021
+ for (const [localName, fn] of componentNames) {
1022
+ if (exported.has(fn)) {
1023
+ if (!exportNameByFn.has(fn)) {
1024
+ exportNameByFn.set(fn, localName);
1025
+ }
1026
+ }
1027
+ }
1028
+ const hasAnyImport = Object.values(names).some((s) => s.size > 0);
1029
+ if (!hasAnyImport)
1030
+ return;
1031
+ const reactiveProps = propagateReactiveProps(localScopes, callGraph, componentFunctions);
1032
+ let needsResolve = false;
731
1033
  traverse(ast, {
732
1034
  Function(path2) {
733
1035
  const scope = localScopes.get(path2.node);
@@ -766,21 +1068,232 @@ function wrapReactiveExpressions(ast, options = {}) {
766
1068
  const expr = exprPath.node.expression;
767
1069
  const compInfo = getComponentExpressionInfo(exprPath);
768
1070
  if (compInfo.isComponent) {
769
- if (!isReactiveComponentProp(compInfo, componentNames, reactiveProps)) {
770
- return;
1071
+ if (compInfo.attributeName !== null) {
1072
+ if (!isReactiveComponentProp(compInfo, componentNames, reactiveProps, importedReactiveProps)) {
1073
+ return;
1074
+ }
1075
+ } else {
1076
+ if (!isBuiltinControlFlowComponent(compInfo.componentName)) {
1077
+ return;
1078
+ }
771
1079
  }
772
1080
  }
773
1081
  if (shouldWrap(expr, scope)) {
1082
+ if (transformPropMemberAccess(expr, scope)) {
1083
+ needsResolve = true;
1084
+ }
774
1085
  exprPath.replaceWith(t.jsxExpressionContainer(createBindingDescriptor(expr, exprPath, options)));
775
1086
  }
776
1087
  }
777
1088
  });
778
1089
  }
779
1090
  });
1091
+ if (needsResolve && !hasUnwrapImport) {
1092
+ ast.program.body.unshift(t.importDeclaration([
1093
+ t.importSpecifier(t.identifier(RESOLVE_HELPER), t.identifier("unwrap"))
1094
+ ], t.stringLiteral("sinwan/reactivity")));
1095
+ }
780
1096
  }
781
1097
 
782
- // src/analyze.ts
1098
+ // src/auto-cc.ts
1099
+ import * as t2 from "@babel/types";
1100
+ import _traverse2 from "@babel/traverse";
783
1101
  var traverse2 = typeof _traverse2 === "function" ? _traverse2 : _traverse2.default ?? _traverse2;
1102
+ var CC_SOURCE_MODULES = new Set(["sinwan", "sinwan/component"]);
1103
+ var EXCLUDE_KEYS = new Set([
1104
+ "loc",
1105
+ "start",
1106
+ "end",
1107
+ "type",
1108
+ "leadingComments",
1109
+ "trailingComments",
1110
+ "innerComments",
1111
+ "extra"
1112
+ ]);
1113
+ function isComponentName(name) {
1114
+ return !!name && /^[A-Z]/.test(name);
1115
+ }
1116
+ function containsJsx(node, enterFunctions) {
1117
+ if (!node || typeof node !== "object")
1118
+ return false;
1119
+ if (t2.isJSXElement(node) || t2.isJSXFragment(node))
1120
+ return true;
1121
+ if (!enterFunctions && (t2.isFunctionExpression(node) || t2.isArrowFunctionExpression(node) || t2.isFunctionDeclaration(node))) {
1122
+ return false;
1123
+ }
1124
+ for (const key of Object.keys(node)) {
1125
+ if (EXCLUDE_KEYS.has(key))
1126
+ continue;
1127
+ const val = node[key];
1128
+ if (Array.isArray(val)) {
1129
+ for (const item of val) {
1130
+ if (item && typeof item === "object" && containsJsx(item, enterFunctions)) {
1131
+ return true;
1132
+ }
1133
+ }
1134
+ } else if (val && typeof val === "object" && containsJsx(val, enterFunctions)) {
1135
+ return true;
1136
+ }
1137
+ }
1138
+ return false;
1139
+ }
1140
+ function functionReturnsJsx(fnPath) {
1141
+ const fn = fnPath.node;
1142
+ if (t2.isArrowFunctionExpression(fn) && !t2.isBlockStatement(fn.body)) {
1143
+ return containsJsx(fn.body, false);
1144
+ }
1145
+ let found = false;
1146
+ fnPath.traverse({
1147
+ ReturnStatement(p) {
1148
+ if (p.node.argument && containsJsx(p.node.argument, false)) {
1149
+ found = true;
1150
+ p.stop();
1151
+ }
1152
+ },
1153
+ Function(p) {
1154
+ p.skip();
1155
+ }
1156
+ });
1157
+ return found;
1158
+ }
1159
+ function isComponentArity(fn) {
1160
+ if (t2.isFunctionDeclaration(fn) && fn.generator)
1161
+ return false;
1162
+ if ((t2.isFunctionExpression(fn) || t2.isArrowFunctionExpression(fn)) && fn.generator) {
1163
+ return false;
1164
+ }
1165
+ return fn.params.length <= 1;
1166
+ }
1167
+ function collectCcAliases(ast) {
1168
+ const aliases = new Set;
1169
+ traverse2(ast, {
1170
+ ImportDeclaration(path2) {
1171
+ const source = path2.node.source.value;
1172
+ if (!CC_SOURCE_MODULES.has(source))
1173
+ return;
1174
+ for (const spec of path2.node.specifiers) {
1175
+ if (!t2.isImportSpecifier(spec))
1176
+ continue;
1177
+ const imported = t2.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
1178
+ if (imported === "cc") {
1179
+ aliases.add(spec.local.name);
1180
+ }
1181
+ }
1182
+ }
1183
+ });
1184
+ return { aliases, alreadyImported: aliases.size > 0 };
1185
+ }
1186
+ function ccCall(arg, ccName) {
1187
+ return t2.callExpression(t2.identifier(ccName), [arg]);
1188
+ }
1189
+ function autoWrapComponents(ast, filename) {
1190
+ const { aliases, alreadyImported } = collectCcAliases(ast);
1191
+ const ccName = aliases.values().next().value;
1192
+ let changed = false;
1193
+ const wrapIfCandidate = (fnPath, name) => {
1194
+ if (name !== null && !isComponentName(name))
1195
+ return "not-candidate";
1196
+ const fn = fnPath.node;
1197
+ if (!isComponentArity(fn))
1198
+ return "not-candidate";
1199
+ if (!functionReturnsJsx(fnPath))
1200
+ return "not-candidate";
1201
+ return "wrapped";
1202
+ };
1203
+ traverse2(ast, {
1204
+ ExportNamedDeclaration(path2) {
1205
+ const decl = path2.node.declaration;
1206
+ if (!decl)
1207
+ return;
1208
+ if (t2.isFunctionDeclaration(decl) && decl.id) {
1209
+ const name = decl.id.name;
1210
+ if (!isComponentName(name))
1211
+ return;
1212
+ const fnPath = path2.get("declaration");
1213
+ if (!isComponentArity(decl))
1214
+ return;
1215
+ if (!functionReturnsJsx(fnPath))
1216
+ return;
1217
+ const fnExpr = t2.functionExpression(decl.id, decl.params, decl.body, decl.generator, decl.async);
1218
+ const varDecl = t2.variableDeclaration("const", [
1219
+ t2.variableDeclarator(t2.identifier(name), ccCall(fnExpr, ccName ?? "cc"))
1220
+ ]);
1221
+ path2.replaceWith(t2.exportNamedDeclaration(varDecl, []));
1222
+ changed = true;
1223
+ return;
1224
+ }
1225
+ if (t2.isVariableDeclaration(decl)) {
1226
+ for (const dPath of path2.get("declaration.declarations")) {
1227
+ const id = dPath.node.id;
1228
+ if (!t2.isIdentifier(id) || !isComponentName(id.name))
1229
+ continue;
1230
+ const init = dPath.node.init;
1231
+ if (!init)
1232
+ continue;
1233
+ if (t2.isCallExpression(init) && t2.isIdentifier(init.callee) && aliases.has(init.callee.name)) {
1234
+ continue;
1235
+ }
1236
+ if (!t2.isFunction(init))
1237
+ continue;
1238
+ const initPath = dPath.get("init");
1239
+ if (!isComponentArity(init))
1240
+ continue;
1241
+ if (!functionReturnsJsx(initPath))
1242
+ continue;
1243
+ dPath.node.init = ccCall(init, ccName ?? "cc");
1244
+ changed = true;
1245
+ }
1246
+ return;
1247
+ }
1248
+ },
1249
+ ExportDefaultDeclaration(path2) {
1250
+ const decl = path2.node.declaration;
1251
+ if (t2.isFunctionDeclaration(decl) && decl.id) {
1252
+ const name = decl.id.name;
1253
+ if (!isComponentName(name))
1254
+ return;
1255
+ const fnPath = path2.get("declaration");
1256
+ if (!isComponentArity(decl))
1257
+ return;
1258
+ if (!functionReturnsJsx(fnPath))
1259
+ return;
1260
+ const fnDecl = t2.functionDeclaration(decl.id, decl.params, decl.body, decl.generator, decl.async);
1261
+ const exportDefault = t2.exportDefaultDeclaration(ccCall(t2.identifier(name), ccName ?? "cc"));
1262
+ path2.replaceWithMultiple([fnDecl, exportDefault]);
1263
+ changed = true;
1264
+ return;
1265
+ }
1266
+ if (t2.isFunctionDeclaration(decl) && !decl.id) {
1267
+ const fnPath = path2.get("declaration");
1268
+ if (!isComponentArity(decl))
1269
+ return;
1270
+ if (!functionReturnsJsx(fnPath))
1271
+ return;
1272
+ const fnExpr = t2.functionExpression(null, decl.params, decl.body, decl.generator, decl.async);
1273
+ path2.node.declaration = ccCall(fnExpr, ccName ?? "cc");
1274
+ changed = true;
1275
+ return;
1276
+ }
1277
+ if (t2.isArrowFunctionExpression(decl) || t2.isFunctionExpression(decl)) {
1278
+ const declPath = path2.get("declaration");
1279
+ if (!isComponentArity(decl))
1280
+ return;
1281
+ if (!functionReturnsJsx(declPath))
1282
+ return;
1283
+ path2.node.declaration = ccCall(decl, ccName ?? "cc");
1284
+ changed = true;
1285
+ return;
1286
+ }
1287
+ }
1288
+ });
1289
+ if (changed && !alreadyImported) {
1290
+ ast.program.body.unshift(t2.importDeclaration([t2.importSpecifier(t2.identifier("cc"), t2.identifier("cc"))], t2.stringLiteral("sinwan/component")));
1291
+ }
1292
+ return changed;
1293
+ }
1294
+
1295
+ // src/analyze.ts
1296
+ var traverse3 = typeof _traverse3 === "function" ? _traverse3 : _traverse3.default ?? _traverse3;
784
1297
  function functionRef(fn) {
785
1298
  return { start: fn.start ?? -1, end: fn.end ?? -1 };
786
1299
  }
@@ -789,7 +1302,7 @@ function refKey(ref) {
789
1302
  }
790
1303
  function findFunctionByRef(ast, ref) {
791
1304
  let found = null;
792
- traverse2(ast, {
1305
+ traverse3(ast, {
793
1306
  Function(path3) {
794
1307
  const node = path3.node;
795
1308
  if (node.start === ref.start && node.end === ref.end) {
@@ -945,15 +1458,15 @@ function parseRefKey(key) {
945
1458
  }
946
1459
  function collectImports(ast) {
947
1460
  const imports = new Map;
948
- traverse2(ast, {
1461
+ traverse3(ast, {
949
1462
  ImportDeclaration(path3) {
950
1463
  const source = path3.node.source.value;
951
1464
  for (const spec of path3.node.specifiers) {
952
- if (t2.isImportSpecifier(spec)) {
1465
+ if (t3.isImportSpecifier(spec)) {
953
1466
  const localName = spec.local.name;
954
- const importedName = t2.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
1467
+ const importedName = t3.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
955
1468
  imports.set(localName, { source, importedName });
956
- } else if (t2.isImportDefaultSpecifier(spec)) {
1469
+ } else if (t3.isImportDefaultSpecifier(spec)) {
957
1470
  imports.set(spec.local.name, { source, importedName: "default" });
958
1471
  }
959
1472
  }
@@ -963,10 +1476,10 @@ function collectImports(ast) {
963
1476
  }
964
1477
  function collectImportedCallSites(ast, componentNames, imports) {
965
1478
  const sites = [];
966
- traverse2(ast, {
1479
+ traverse3(ast, {
967
1480
  JSXElement(path3) {
968
1481
  const name = path3.node.openingElement.name;
969
- if (!t2.isJSXIdentifier(name))
1482
+ if (!t3.isJSXIdentifier(name))
970
1483
  return;
971
1484
  const tagName = name.name;
972
1485
  if (!/^[A-Z]/.test(tagName))
@@ -983,19 +1496,19 @@ function collectImportedCallSites(ast, componentNames, imports) {
983
1496
  const props = [];
984
1497
  const spreads = [];
985
1498
  for (const attr of path3.node.openingElement.attributes) {
986
- if (t2.isJSXSpreadAttribute(attr)) {
1499
+ if (t3.isJSXSpreadAttribute(attr)) {
987
1500
  if (attr.argument) {
988
1501
  spreads.push(attr.argument);
989
1502
  }
990
1503
  continue;
991
1504
  }
992
- if (t2.isJSXAttribute(attr)) {
1505
+ if (t3.isJSXAttribute(attr)) {
993
1506
  const attrName = attr.name.name;
994
1507
  if (!attrName)
995
1508
  continue;
996
- if (t2.isJSXExpressionContainer(attr.value)) {
1509
+ if (t3.isJSXExpressionContainer(attr.value)) {
997
1510
  const expr = attr.value.expression;
998
- if (!expr || t2.isJSXEmptyExpression(expr))
1511
+ if (!expr || t3.isJSXEmptyExpression(expr))
999
1512
  continue;
1000
1513
  props.push({ name: attrName, value: expr });
1001
1514
  }
@@ -1003,9 +1516,9 @@ function collectImportedCallSites(ast, componentNames, imports) {
1003
1516
  }
1004
1517
  const childExprs = [];
1005
1518
  for (const child of path3.node.children) {
1006
- if (t2.isJSXExpressionContainer(child)) {
1519
+ if (t3.isJSXExpressionContainer(child)) {
1007
1520
  const expr = child.expression;
1008
- if (!expr || t2.isJSXEmptyExpression(expr))
1521
+ if (!expr || t3.isJSXEmptyExpression(expr))
1009
1522
  continue;
1010
1523
  childExprs.push(expr);
1011
1524
  }
@@ -1013,7 +1526,7 @@ function collectImportedCallSites(ast, componentNames, imports) {
1013
1526
  if (childExprs.length === 1) {
1014
1527
  props.push({ name: "children", value: childExprs[0] });
1015
1528
  } else if (childExprs.length > 1) {
1016
- props.push({ name: "children", value: t2.arrayExpression(childExprs) });
1529
+ props.push({ name: "children", value: t3.arrayExpression(childExprs) });
1017
1530
  }
1018
1531
  sites.push({
1019
1532
  caller: callerFn,
@@ -1031,6 +1544,7 @@ function analyzeModule(code, filePath) {
1031
1544
  sourceType: "module",
1032
1545
  plugins: ["jsx", "typescript"]
1033
1546
  });
1547
+ autoWrapComponents(ast, filePath);
1034
1548
  const names = trackReactiveImports(ast);
1035
1549
  const {
1036
1550
  functions,
@@ -1048,18 +1562,18 @@ function analyzeModule(code, filePath) {
1048
1562
  exports.set(localName, fn);
1049
1563
  }
1050
1564
  }
1051
- traverse2(ast, {
1565
+ traverse3(ast, {
1052
1566
  ExportDefaultDeclaration(path3) {
1053
1567
  const decl = path3.node.declaration;
1054
- if (t2.isIdentifier(decl)) {
1568
+ if (t3.isIdentifier(decl)) {
1055
1569
  const fn = componentNames.get(decl.name);
1056
1570
  if (fn)
1057
1571
  defaultExport = fn;
1058
- } else if (t2.isCallExpression(decl)) {
1572
+ } else if (t3.isCallExpression(decl)) {
1059
1573
  const callee = decl.callee;
1060
- if (t2.isIdentifier(callee) && names.cc.has(callee.name)) {
1574
+ if (t3.isIdentifier(callee) && names.cc.has(callee.name)) {
1061
1575
  const firstArg = decl.arguments[0];
1062
- if (firstArg && t2.isFunction(firstArg)) {
1576
+ if (firstArg && t3.isFunction(firstArg)) {
1063
1577
  defaultExport = firstArg;
1064
1578
  }
1065
1579
  }
@@ -1845,4 +2359,4 @@ if (import.meta.main) {
1845
2359
  runAnalyzeCli();
1846
2360
  }
1847
2361
 
1848
- export { analyzeModule, analyzeProject, AnalyzerCache, analyze, loadMetadata, trackReactiveImports, getAllPropNames, isReactiveValue, containsReactiveValue, collectComponentFunctions, computeLocalScopes, collectComponentCallGraph, buildFullScope, propagateReactiveProps, wrapReactiveExpressions, runAnalyzeCli };
2362
+ export { autoWrapComponents, analyzeModule, analyzeProject, AnalyzerCache, analyze, loadMetadata, trackReactiveImports, getAllPropNames, isReactiveValue, containsReactiveValue, collectComponentFunctions, computeLocalScopes, collectComponentCallGraph, buildFullScope, propagateReactiveProps, wrapReactiveExpressions, runAnalyzeCli };
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // @bun
3
3
  import {
4
4
  runAnalyzeCli
5
- } from "./chunk-3bqqn28f.js";
5
+ } from "./chunk-w580fdrf.js";
6
6
  export {
7
7
  runAnalyzeCli
8
8
  };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * SinwanJS Compiler — Exported component detection.
3
+ *
4
+ * Shared utility for detecting top-level exported component bindings in a
5
+ * module. Used by the Vite and Bun plugins for HMR boundary injection.
6
+ *
7
+ * Detection is intentionally conservative: only top-level exports whose name
8
+ * starts with an uppercase letter (the component naming convention) are
9
+ * returned. The runtime safely ignores anything that is not used as a
10
+ * component, so over-matching is harmless.
11
+ */
12
+ export interface ExportedComponent {
13
+ /** Local binding name in the module (referenced in the accept callback). */
14
+ local: string;
15
+ /** Export key on the module namespace ("default" for the default export). */
16
+ key: string;
17
+ }
18
+ /**
19
+ * Collect top-level exported component bindings from a module source string.
20
+ *
21
+ * Parses the source with Babel, then walks top-level export declarations to
22
+ * find functions and variables whose name starts with an uppercase letter.
23
+ *
24
+ * Returns an array of `{ local, key }` pairs. Returns an empty array if the
25
+ * source cannot be parsed.
26
+ */
27
+ export declare function collectExportedComponents(code: string, filename: string): ExportedComponent[];
package/dist/index.d.ts CHANGED
@@ -5,6 +5,9 @@
5
5
  * used by the Bun and Vite plugins.
6
6
  */
7
7
  export { transformJSX, type TransformOptions } from "./transform";
8
+ export { COMPILER_TEMPLATE_SLOT_PROTOCOL } from "./transform";
9
+ export { autoWrapComponents } from "./auto-cc";
8
10
  export { analyze, analyzeProject, analyzeModule, loadMetadata, AnalyzerCache, type AnalyzeOptions, type ModuleAnalysis, type ProjectAnalysis, type ImportedCallSite, type ImportInfo, type WorkspacesConfig, } from "./analyze";
11
+ export { collectExportedComponents, type ExportedComponent } from "./exports";
9
12
  export { runAnalyzeCli } from "./cli";
10
13
  export { wrapReactiveExpressions, trackReactiveImports, collectComponentFunctions, computeLocalScopes, collectComponentCallGraph, propagateReactiveProps, buildFullScope, containsReactiveValue, getAllPropNames, isReactiveValue, type ReactiveScope, type Binding, type CallSite, type ImportNames, type ComponentExpressionInfo, } from "./reactive-wrap";
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  analyze,
5
5
  analyzeModule,
6
6
  analyzeProject,
7
+ autoWrapComponents,
7
8
  buildFullScope,
8
9
  collectComponentCallGraph,
9
10
  collectComponentFunctions,
@@ -16,7 +17,7 @@ import {
16
17
  runAnalyzeCli,
17
18
  trackReactiveImports,
18
19
  wrapReactiveExpressions
19
- } from "./chunk-3bqqn28f.js";
20
+ } from "./chunk-w580fdrf.js";
20
21
 
21
22
  // src/transform.ts
22
23
  import { parse } from "@babel/parser";
@@ -25,10 +26,21 @@ import * as t from "@babel/types";
25
26
  import _traverse from "@babel/traverse";
26
27
  var generate = typeof _generate === "function" ? _generate : _generate.default ?? _generate;
27
28
  var traverse = typeof _traverse === "function" ? _traverse : _traverse.default ?? _traverse;
28
- var SLOT_PREFIX = "s";
29
- var slotId = 0;
30
- function nextSlotId() {
31
- return `<!--${SLOT_PREFIX}:${slotId++}-->`;
29
+ var COMPILER_TEMPLATE_SLOT_PROTOCOL = {
30
+ slotPrefix: "s",
31
+ encodeSlot(index) {
32
+ return `s:${index}`;
33
+ },
34
+ decodeSlot(data) {
35
+ if (data.startsWith("s:")) {
36
+ const idx = parseInt(data.slice(2), 10);
37
+ return Number.isNaN(idx) ? null : idx;
38
+ }
39
+ return null;
40
+ }
41
+ };
42
+ function nextSlotId(counter) {
43
+ return `<!--${COMPILER_TEMPLATE_SLOT_PROTOCOL.encodeSlot(counter.value++)}-->`;
32
44
  }
33
45
  var VOID_ELEMENTS = new Set([
34
46
  "area",
@@ -81,9 +93,9 @@ function cleanJSXText(value) {
81
93
  return str;
82
94
  }
83
95
  function extractTemplate(node, filename) {
84
- slotId = 0;
96
+ const counter = { value: 0 };
85
97
  const slots = [];
86
- const html = elementToHtml(node, slots, [], filename);
98
+ const html = elementToHtml(node, slots, [], filename, counter);
87
99
  return { html, slots };
88
100
  }
89
101
  function warnSuspectedStyleInterpolation(attrValue, filename, line) {
@@ -153,7 +165,7 @@ function serializeStyleValue(expr) {
153
165
  }
154
166
  return parts.join(";");
155
167
  }
156
- function elementToHtml(node, slots, path, filename) {
168
+ function elementToHtml(node, slots, path, filename, counter) {
157
169
  const tagName = jsxNameToString(node.openingElement.name);
158
170
  if (tagName === "")
159
171
  return "";
@@ -204,8 +216,15 @@ function elementToHtml(node, slots, path, filename) {
204
216
  html += isVoid ? " />" : ">";
205
217
  if (isVoid)
206
218
  return html;
207
- let childIndex = 0;
208
- for (const child of node.children) {
219
+ const childResult = childrenToHtml(node.children, slots, path, filename, 0, counter);
220
+ html += childResult.html;
221
+ html += `</${tagName}>`;
222
+ return html;
223
+ }
224
+ function childrenToHtml(children, slots, path, filename, startIndex, counter) {
225
+ let html = "";
226
+ let childIndex = startIndex;
227
+ for (const child of children) {
209
228
  if (child.type === "JSXText") {
210
229
  const text = cleanJSXText(child.value ?? "");
211
230
  if (text) {
@@ -215,7 +234,7 @@ function elementToHtml(node, slots, path, filename) {
215
234
  } else if (child.type === "JSXExpressionContainer") {
216
235
  if (child.expression.type === "JSXEmptyExpression")
217
236
  continue;
218
- const slot = nextSlotId();
237
+ const slot = nextSlotId(counter);
219
238
  slots.push({
220
239
  path: [...path, childIndex],
221
240
  type: "child",
@@ -224,12 +243,29 @@ function elementToHtml(node, slots, path, filename) {
224
243
  html += slot;
225
244
  childIndex++;
226
245
  } else if (child.type === "JSXElement") {
227
- html += elementToHtml(child, slots, [...path, childIndex], filename);
228
- childIndex++;
246
+ const childName = child.openingElement.name;
247
+ const childTag = childName.type === "JSXIdentifier" ? childName.name : "";
248
+ const isFirstCharUpper = childTag && childTag.charAt(0) === childTag.charAt(0).toUpperCase();
249
+ if (isFirstCharUpper) {
250
+ const slot = nextSlotId(counter);
251
+ slots.push({
252
+ path: [...path, childIndex],
253
+ type: "child",
254
+ expr: child
255
+ });
256
+ html += slot;
257
+ childIndex++;
258
+ } else {
259
+ html += elementToHtml(child, slots, [...path, childIndex], filename, counter);
260
+ childIndex++;
261
+ }
262
+ } else if (child.type === "JSXFragment") {
263
+ const fragResult = childrenToHtml(child.children, slots, path, filename, childIndex, counter);
264
+ html += fragResult.html;
265
+ childIndex = fragResult.childIndex;
229
266
  }
230
267
  }
231
- html += `</${tagName}>`;
232
- return html;
268
+ return { html, childIndex };
233
269
  }
234
270
  function jsxNameToString(name) {
235
271
  if (name.type === "JSXIdentifier")
@@ -245,10 +281,12 @@ function transformJSX(code, filename, options = {}) {
245
281
  plugins: ["jsx", "typescript"],
246
282
  sourceFilename: filename
247
283
  });
284
+ autoWrapComponents(ast, filename);
248
285
  wrapReactiveExpressions(ast, {
249
286
  explicitBindings: options.explicitBindings,
250
287
  analyze: options.analyze,
251
288
  analyzeMetadata: options.analyzeMetadata,
289
+ resolveImport: options.resolveImport,
252
290
  filename
253
291
  });
254
292
  const hoist = options.hoist !== false;
@@ -275,10 +313,23 @@ function transformJSX(code, filename, options = {}) {
275
313
  const templateExpr = t.callExpression(t.identifier("_$createTemplate"), [t.identifier(tmplId), t.arrayExpression(dynamicExprs)]);
276
314
  const parentIsJSX = path.parentPath?.isJSXElement() || path.parentPath?.isJSXFragment();
277
315
  path.replaceWith(parentIsJSX ? t.jsxExpressionContainer(templateExpr) : templateExpr);
278
- } catch {}
316
+ } catch (err) {
317
+ if (options.dev) {
318
+ console.warn(`[Sinwan] template hoisting skipped in ${filename}: ${err.message}`);
319
+ }
320
+ }
279
321
  }
280
322
  });
281
323
  if (templates.length === 0) {
324
+ if (options.explicitBindings) {
325
+ const bindingImport = t.importDeclaration([
326
+ t.importSpecifier(t.identifier("_$bindText"), t.identifier("_$bindText")),
327
+ t.importSpecifier(t.identifier("_$bindAttr"), t.identifier("_$bindAttr")),
328
+ t.importSpecifier(t.identifier("_$bindStyle"), t.identifier("_$bindStyle")),
329
+ t.importSpecifier(t.identifier("_$bindClass"), t.identifier("_$bindClass"))
330
+ ], t.stringLiteral("sinwan/renderer"));
331
+ ast.program.body.unshift(bindingImport);
332
+ }
282
333
  const result2 = generate(ast, {
283
334
  sourceMaps: true,
284
335
  sourceFileName: filename
@@ -308,22 +359,81 @@ function transformJSX(code, filename, options = {}) {
308
359
  const result = generate(ast, { sourceMaps: true, sourceFileName: filename });
309
360
  return { code: result.code, map: result.map };
310
361
  }
362
+ // src/exports.ts
363
+ import { parse as parse2 } from "@babel/parser";
364
+ function isComponentName(name) {
365
+ return !!name && name[0] === name[0].toUpperCase() && /^[A-Z]/.test(name);
366
+ }
367
+ function collectExportedComponents(code, filename) {
368
+ let ast;
369
+ try {
370
+ ast = parse2(code, {
371
+ sourceType: "module",
372
+ plugins: ["jsx", "typescript"],
373
+ sourceFilename: filename
374
+ });
375
+ } catch {
376
+ return [];
377
+ }
378
+ const found = [];
379
+ const seen = new Set;
380
+ const add = (local, key) => {
381
+ const sig = `${local}::${key}`;
382
+ if (seen.has(sig))
383
+ return;
384
+ seen.add(sig);
385
+ found.push({ local, key });
386
+ };
387
+ for (const node of ast.program.body) {
388
+ if (node.type === "ExportNamedDeclaration") {
389
+ if (node.declaration) {
390
+ const decl = node.declaration;
391
+ if (decl.type === "FunctionDeclaration" && decl.id && isComponentName(decl.id.name)) {
392
+ add(decl.id.name, decl.id.name);
393
+ } else if (decl.type === "VariableDeclaration") {
394
+ for (const d of decl.declarations) {
395
+ if (d.id?.type === "Identifier" && isComponentName(d.id.name)) {
396
+ add(d.id.name, d.id.name);
397
+ }
398
+ }
399
+ }
400
+ } else if (node.specifiers && !node.source) {
401
+ for (const spec of node.specifiers) {
402
+ if (spec.type === "ExportSpecifier" && spec.local?.type === "Identifier" && spec.exported?.type === "Identifier" && isComponentName(spec.exported.name)) {
403
+ add(spec.local.name, spec.exported.name);
404
+ }
405
+ }
406
+ }
407
+ } else if (node.type === "ExportDefaultDeclaration") {
408
+ const decl = node.declaration;
409
+ if (decl.type === "Identifier") {
410
+ add(decl.name, "default");
411
+ } else if (decl.type === "FunctionDeclaration" && decl.id) {
412
+ add(decl.id.name, "default");
413
+ }
414
+ }
415
+ }
416
+ return found;
417
+ }
311
418
  export {
312
- wrapReactiveExpressions,
313
- transformJSX,
314
- trackReactiveImports,
315
- runAnalyzeCli,
316
- propagateReactiveProps,
317
- loadMetadata,
318
- isReactiveValue,
319
- getAllPropNames,
320
- containsReactiveValue,
321
- computeLocalScopes,
322
- collectComponentFunctions,
323
- collectComponentCallGraph,
324
- buildFullScope,
325
- analyzeProject,
326
- analyzeModule,
419
+ AnalyzerCache,
420
+ COMPILER_TEMPLATE_SLOT_PROTOCOL,
327
421
  analyze,
328
- AnalyzerCache
422
+ analyzeModule,
423
+ analyzeProject,
424
+ autoWrapComponents,
425
+ buildFullScope,
426
+ collectComponentCallGraph,
427
+ collectComponentFunctions,
428
+ collectExportedComponents,
429
+ computeLocalScopes,
430
+ containsReactiveValue,
431
+ getAllPropNames,
432
+ isReactiveValue,
433
+ loadMetadata,
434
+ propagateReactiveProps,
435
+ runAnalyzeCli,
436
+ trackReactiveImports,
437
+ transformJSX,
438
+ wrapReactiveExpressions
329
439
  };
@@ -8,7 +8,7 @@
8
8
  * Supported reactive sources:
9
9
  * - `createMutable` / `createStore` from `sinwan/store`
10
10
  * - `signal` / `computed` from `sinwan/reactivity`
11
- * - `useState` from `sinwan/react-client`
11
+ * - `useState` from `sinwan/react`
12
12
  *
13
13
  * Example:
14
14
  * <p>{state.name}</p> → <p>{() => state.name}</p>
@@ -22,6 +22,7 @@ export interface ImportNames {
22
22
  signal: Set<string>;
23
23
  computed: Set<string>;
24
24
  useState: Set<string>;
25
+ useFetch: Set<string>;
25
26
  cc: Set<string>;
26
27
  }
27
28
  export declare function trackReactiveImports(ast: t.Node): ImportNames;
@@ -39,15 +40,26 @@ export interface ComputedBinding {
39
40
  export interface GetterBinding {
40
41
  kind: "getter";
41
42
  }
43
+ /**
44
+ * Binding for a variable holding the object returned by `useFetch()` /
45
+ * `createFetch()(...)`. Every property on that object is a `Signal` or
46
+ * `Computed`, so a read like `f.data.value` (path `["data", "value"]`) is
47
+ * reactive. The shell object itself (`f`) and a bare property (`f.data`, the
48
+ * signal object) are NOT reactive reads — the runtime `resolve()` unwraps them
49
+ * inside effects when forwarded to control-flow props such as `<Show when>`.
50
+ */
51
+ export interface SignalObjectBinding {
52
+ kind: "signalObject";
53
+ }
42
54
  export interface PropBinding {
43
55
  kind: "prop";
44
56
  }
45
- export type Binding = MutableBinding | SignalBinding | ComputedBinding | GetterBinding | PropBinding;
57
+ export type Binding = MutableBinding | SignalBinding | ComputedBinding | GetterBinding | SignalObjectBinding | PropBinding;
46
58
  export interface ReactiveScope {
47
59
  bindings: Map<string, Binding>;
48
60
  }
49
61
  export declare function getAllPropNames(params: t.Node | undefined): Set<string>;
50
- export declare function isReactiveValue(node: t.Identifier | t.MemberExpression, scope: ReactiveScope): boolean;
62
+ export declare function isReactiveValue(node: t.Node, scope: ReactiveScope): boolean;
51
63
  export declare function containsReactiveValue(expr: t.Expression, scope: ReactiveScope): boolean;
52
64
  export interface ComponentExpressionInfo {
53
65
  isComponent: boolean;
@@ -76,5 +88,6 @@ export declare function wrapReactiveExpressions(ast: t.Node, options?: {
76
88
  explicitBindings?: boolean;
77
89
  analyze?: string;
78
90
  analyzeMetadata?: Map<string, Map<string, Set<string>>>;
91
+ resolveImport?: (source: string, fromFile: string) => string | null;
79
92
  filename?: string;
80
93
  }): void;
@@ -16,7 +16,29 @@ export interface TransformOptions {
16
16
  analyze?: string;
17
17
  /** In-memory reactive-props metadata (used by plugin-level caches). */
18
18
  analyzeMetadata?: Map<string, Map<string, Set<string>>>;
19
+ /**
20
+ * Resolves an import source (e.g. "./Child") to an absolute file path,
21
+ * relative to the file being transformed. Used to look up reactive-prop
22
+ * metadata for components imported from other modules so reactive values
23
+ * passed to them are wrapped at the call site. When omitted, cross-module
24
+ * component call sites fall back to the conservative (non-wrapping) behavior.
25
+ */
26
+ resolveImport?: (source: string, fromFile: string) => string | null;
19
27
  }
28
+ /**
29
+ * Template slot marker encoding.
30
+ *
31
+ * This mirrors the runtime's `DEFAULT_TEMPLATE_SLOT_PROTOCOL` in
32
+ * `sinwan/src/renderer/template-protocol.ts`. The compiler cannot import the
33
+ * runtime (separate packages), so the format is duplicated here. A drift test
34
+ * (`sinwan/__tests__/compiler-protocol-drift.test.ts`) asserts the two stay
35
+ * in sync.
36
+ */
37
+ export declare const COMPILER_TEMPLATE_SLOT_PROTOCOL: {
38
+ readonly slotPrefix: "s";
39
+ readonly encodeSlot: (index: number) => string;
40
+ readonly decodeSlot: (data: string) => number | null;
41
+ };
20
42
  export declare function transformJSX(code: string, filename: string, options?: TransformOptions): {
21
43
  code: string;
22
44
  map?: any;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sinwan-compiler",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Shared compiler core for Sinwan plugins",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",