@pyreon/compiler 0.15.0 → 0.18.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.
package/src/jsx.ts CHANGED
@@ -29,31 +29,21 @@
29
29
  */
30
30
 
31
31
  import { parseSync } from 'oxc-parser'
32
- import { createRequire } from 'node:module'
33
- import { fileURLToPath } from 'node:url'
34
- import { dirname, join } from 'node:path'
35
32
  import { REACT_EVENT_REMAP } from './event-names'
33
+ import { loadNativeBinding } from './load-native'
36
34
 
37
35
  // ─── Native binary auto-detection ────────────────────────────────────────────
38
- // Try to load the Rust napi-rs binary for 3.7-8.2x faster transforms.
39
- // Falls back to the JS implementation below if the binary isn't available
40
- // (wrong platform, CI environment, WASM runtime like StackBlitz, etc.)
36
+ // Two-path resolution: in-tree binary first (dev mode), then per-platform
37
+ // npm package (production install via optionalDependencies). Falls through
38
+ // to the JS implementation below when both paths fail (wrong platform, CI
39
+ // environment, WASM runtime like StackBlitz, missing per-platform package).
41
40
  //
42
- // Uses createRequire for ESM compatibility — __dirname and require() don't
43
- // exist in ESM modules.
41
+ // See `load-native.ts` for the resolution logic.
44
42
  type NativeTransformFn = (code: string, filename: string, ssr: boolean, knownSignals: string[] | null) => TransformResult
45
- let nativeTransformJsx: NativeTransformFn | null = null
46
-
47
- try {
48
- const __filename = fileURLToPath(import.meta.url)
49
- const __dirname = dirname(__filename)
50
- const nativeRequire = createRequire(import.meta.url)
51
- const nativePath = join(__dirname, '..', 'native', 'pyreon-compiler.node')
52
- const native = nativeRequire(nativePath) as { transformJsx: NativeTransformFn }
53
- nativeTransformJsx = native.transformJsx
54
- } catch {
55
- // Native binary not available — JS fallback will be used
56
- }
43
+ const nativeBinding = loadNativeBinding(import.meta.url)
44
+ const nativeTransformJsx: NativeTransformFn | null = nativeBinding
45
+ ? (nativeBinding.transformJsx as NativeTransformFn)
46
+ : null
57
47
 
58
48
  export interface CompilerWarning {
59
49
  /** Warning message */
@@ -283,6 +273,7 @@ export function transformJSX_JS(
283
273
  let hoistIdx = 0
284
274
  let needsTplImport = false
285
275
  let needsRpImport = false
276
+ let needsWrapSpreadImport = false
286
277
  let needsBindTextImportGlobal = false
287
278
  let needsBindDirectImportGlobal = false
288
279
  let needsBindImportGlobal = false
@@ -354,6 +345,46 @@ export function transformJSX_JS(
354
345
  }
355
346
  }
356
347
 
348
+ /**
349
+ * Wrap component-JSX spread arguments with `_wrapSpread(...)` so
350
+ * getter-shaped reactive props survive esbuild's JS-level spread emit.
351
+ *
352
+ * esbuild compiles `<Comp {...source}>` to `jsx(Comp, { ...source })`.
353
+ * The JS spread fires every getter on `source` and stores the resolved
354
+ * values — collapsing compiler-emitted reactive props (`_rp` thunks
355
+ * later converted to getters by `makeReactiveProps`) to static values
356
+ * before the receiving component sees them.
357
+ *
358
+ * `_wrapSpread` replaces getter descriptors with `_rp`-branded thunks,
359
+ * so the JS-level spread carries function values instead. The runtime
360
+ * `makeReactiveProps` step converts them back to getters on the
361
+ * component's props object — preserving the live signal subscription.
362
+ *
363
+ * Lowercase tags (DOM elements) go through the template path's
364
+ * `_applyProps` which already handles spread reactively — no need to
365
+ * wrap there.
366
+ */
367
+ function handleJsxSpreadAttribute(attr: N, parentElement: N): void {
368
+ const tagName = jsxTagName(parentElement)
369
+ const isComponent =
370
+ tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()
371
+ if (!isComponent) return
372
+ const arg = attr.argument
373
+ if (!arg) return
374
+ // Skip already-wrapped sources (idempotent compilation guard).
375
+ if (
376
+ arg.type === 'CallExpression' &&
377
+ arg.callee?.type === 'Identifier' &&
378
+ arg.callee.name === '_wrapSpread'
379
+ )
380
+ return
381
+ const start = arg.start as number
382
+ const end = arg.end as number
383
+ const sliced = sliceExpr(arg)
384
+ replacements.push({ start, end, text: `_wrapSpread(${sliced})` })
385
+ needsWrapSpreadImport = true
386
+ }
387
+
357
388
  function handleJsxAttribute(node: N, parentElement: N): void {
358
389
  const name = node.name?.type === 'JSXIdentifier' ? node.name.name : ''
359
390
  if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return
@@ -743,6 +774,7 @@ export function transformJSX_JS(
743
774
  checkForWarnings(node)
744
775
  for (const attr of jsxAttrs(node)) {
745
776
  if (attr.type === 'JSXAttribute') handleJsxAttribute(attr, node)
777
+ else if (attr.type === 'JSXSpreadAttribute') handleJsxSpreadAttribute(attr, node)
746
778
  }
747
779
  for (const child of jsxChildren(node)) {
748
780
  if (child.type === 'JSXExpressionContainer') handleJsxExpression(child)
@@ -774,19 +806,19 @@ export function transformJSX_JS(
774
806
  if (replacements.length === 0 && hoists.length === 0) return { code, warnings }
775
807
 
776
808
  replacements.sort((a, b) => a.start - b.start)
777
- const parts: string[] = []
778
- let lastPos = 0
809
+ const outParts: string[] = []
810
+ let outPos = 0
779
811
  for (const r of replacements) {
780
- parts.push(code.slice(lastPos, r.start))
781
- parts.push(r.text)
782
- lastPos = r.end
812
+ outParts.push(code.slice(outPos, r.start))
813
+ outParts.push(r.text)
814
+ outPos = r.end
783
815
  }
784
- parts.push(code.slice(lastPos))
785
- let result = parts.join('')
816
+ outParts.push(code.slice(outPos))
817
+ let output = outParts.join('')
786
818
 
787
819
  if (hoists.length > 0) {
788
820
  const preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('')
789
- result = preamble + result
821
+ output = preamble + output
790
822
  }
791
823
 
792
824
  if (needsTplImport) {
@@ -798,16 +830,19 @@ export function transformJSX_JS(
798
830
  const reactivityImports = needsBindImportGlobal
799
831
  ? `\nimport { _bind } from "@pyreon/reactivity";`
800
832
  : ''
801
- result =
833
+ output =
802
834
  `import { ${runtimeDomImports.join(', ')} } from "@pyreon/runtime-dom";${reactivityImports}\n` +
803
- result
835
+ output
804
836
  }
805
837
 
806
- if (needsRpImport) {
807
- result = `import { _rp } from "@pyreon/core";\n` + result
838
+ if (needsRpImport || needsWrapSpreadImport) {
839
+ const coreImports: string[] = []
840
+ if (needsRpImport) coreImports.push('_rp')
841
+ if (needsWrapSpreadImport) coreImports.push('_wrapSpread')
842
+ output = `import { ${coreImports.join(', ')} } from "@pyreon/core";\n` + output
808
843
  }
809
844
 
810
- return { code: result, usesTemplates: needsTplImport, warnings }
845
+ return { code: output, usesTemplates: needsTplImport, warnings }
811
846
 
812
847
  // ── Template emission helpers ─────────────────────────────────────────────
813
848
 
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Native binding loader — resolves the @pyreon/compiler napi-rs binary
3
+ * via two paths in priority order:
4
+ *
5
+ * 1. **In-tree binary** at `<package>/native/pyreon-compiler.node`.
6
+ * Populated by `scripts/build-native.ts` during local development
7
+ * (Phase 2). Faster path because it skips npm-package resolution.
8
+ *
9
+ * 2. **Per-platform npm package** (Phase 5b — not active until per-
10
+ * platform packages are published). Resolves `@pyreon/compiler-
11
+ * <platform>-<arch>[-<libc>]` via the standard Node module
12
+ * resolution algorithm. End users on machines without a local
13
+ * `cargo` install will hit this path: `bun install` resolves
14
+ * `optionalDependencies` to the matching per-platform package and
15
+ * this loader picks it up.
16
+ *
17
+ * 3. **JS fallback** (caller's responsibility) — if both paths fail,
18
+ * `loadNativeBinding()` returns `null` and the caller uses the
19
+ * pure-JS implementation. Slower but correctness-equivalent.
20
+ *
21
+ * Platform detection follows the napi-rs convention. Linux variants
22
+ * include a `libc` suffix (`gnu` for glibc, `musl` for musl) per
23
+ * https://napi.rs/docs/cli/build#deployment.
24
+ *
25
+ * The two-path resolution lets dev-mode (where `cargo build` produced
26
+ * an in-tree binary) and production-mode (where the user has only the
27
+ * published per-platform package) coexist with no flag flipping.
28
+ */
29
+
30
+ import { createRequire } from 'node:module'
31
+ import { fileURLToPath } from 'node:url'
32
+ import { dirname, join } from 'node:path'
33
+
34
+ export interface NativeBinding {
35
+ transformJsx: (
36
+ code: string,
37
+ filename: string,
38
+ ssr: boolean,
39
+ knownSignals: string[] | null,
40
+ ) => unknown
41
+ }
42
+
43
+ // Local Node-process surface. `@pyreon/runtime-dom` ships an ambient
44
+ // `declare var process: { env: { NODE_ENV?: string } }` to enforce the
45
+ // bundler-agnostic dev-gate pattern, which narrows `process` for ANY
46
+ // file pulled in by runtime-dom's typecheck — including this one when
47
+ // imported via the `bun` condition. Casting through a local interface
48
+ // restores access to the platform/arch/report fields we genuinely need.
49
+ interface NodeProcess {
50
+ platform: string
51
+ arch: string
52
+ report?: {
53
+ getReport(): unknown
54
+ }
55
+ }
56
+ const nodeProcess = process as unknown as NodeProcess
57
+
58
+ /**
59
+ * Resolve the per-platform package name following the napi-rs naming
60
+ * convention: `@pyreon/compiler-<platform>-<arch>[-<libc>]`.
61
+ *
62
+ * Examples:
63
+ * darwin + arm64 → @pyreon/compiler-darwin-arm64
64
+ * darwin + x64 → @pyreon/compiler-darwin-x64
65
+ * linux + x64 + gnu → @pyreon/compiler-linux-x64-gnu
66
+ * linux + arm64 + gnu → @pyreon/compiler-linux-arm64-gnu
67
+ * win32 + x64 + msvc → @pyreon/compiler-win32-x64-msvc
68
+ *
69
+ * Returns `null` for unsupported (platform, arch) combinations — caller
70
+ * skips per-platform resolution entirely and falls through to JS.
71
+ */
72
+ export function getPlatformPackageName(
73
+ platform: string = nodeProcess.platform,
74
+ arch: string = nodeProcess.arch,
75
+ libc: string | null = detectLibc(platform),
76
+ ): string | null {
77
+ // Build the suffix for libc-bearing platforms (Linux glibc/musl,
78
+ // Windows MSVC). Single source of truth — no per-platform branching.
79
+ const suffix = libc ? `-${libc}` : ''
80
+ // Allowlist of (platform, arch) combos that the cross-platform CI
81
+ // workflow actually builds. Keep in sync with
82
+ // `.github/workflows/release-native.yml` matrix.
83
+ const supported: Record<string, string[]> = {
84
+ darwin: ['arm64', 'x64'],
85
+ linux: ['x64', 'arm64'],
86
+ win32: ['x64'],
87
+ }
88
+ if (!supported[platform]?.includes(arch)) return null
89
+ return `@pyreon/compiler-${platform}-${arch}${suffix}`
90
+ }
91
+
92
+ /**
93
+ * Detect the libc family for the current Linux runtime. Returns:
94
+ * - `'gnu'` on glibc-based distros (Debian, Ubuntu, RHEL, …)
95
+ * - `'musl'` on musl-based distros (Alpine, …)
96
+ * - `null` on macOS / Windows (no libc differentiation)
97
+ * - `'msvc'` on Windows (we only ship MSVC binaries)
98
+ *
99
+ * `process.report.getReport().header.glibcVersionRuntime` is the
100
+ * Node-canonical detection: present on glibc, absent on musl. Falls
101
+ * back to `gnu` on read failure since glibc is the more common case.
102
+ */
103
+ function detectLibc(platform: string): string | null {
104
+ if (platform === 'win32') return 'msvc'
105
+ if (platform !== 'linux') return null
106
+ try {
107
+ const report = nodeProcess.report?.getReport()
108
+ if (typeof report === 'object' && report !== null) {
109
+ const header = (report as { header?: { glibcVersionRuntime?: string } }).header
110
+ return header?.glibcVersionRuntime ? 'gnu' : 'musl'
111
+ }
112
+ } catch {
113
+ // Best-effort detection — fall through to glibc default.
114
+ }
115
+ return 'gnu'
116
+ }
117
+
118
+ /**
119
+ * Load the native binding by trying paths in order:
120
+ * 1. In-tree binary (`<package>/native/pyreon-compiler.node`)
121
+ * 2. Per-platform npm package (`@pyreon/compiler-<triple>`)
122
+ *
123
+ * Returns `null` if both paths fail — caller falls back to the
124
+ * pure-JS implementation. NEVER throws — every error path swallows
125
+ * silently because a missing native binary is a perf optimization
126
+ * miss, not a correctness failure.
127
+ */
128
+ export function loadNativeBinding(metaUrl: string): NativeBinding | null {
129
+ const nativeRequire = createRequire(metaUrl)
130
+
131
+ // Path 1: in-tree binary (dev mode + Phase 2 local-build path).
132
+ try {
133
+ const __filename = fileURLToPath(metaUrl)
134
+ const __dirname = dirname(__filename)
135
+ const nativePath = join(__dirname, '..', 'native', 'pyreon-compiler.node')
136
+ return nativeRequire(nativePath) as NativeBinding
137
+ } catch {
138
+ // In-tree binary not present — fall through to per-platform package.
139
+ }
140
+
141
+ // Path 2: per-platform npm package (production install path).
142
+ // Will start working once Phase 5b publishes the per-platform
143
+ // packages and `optionalDependencies` resolves them at install time.
144
+ const pkgName = getPlatformPackageName()
145
+ if (pkgName !== null) {
146
+ try {
147
+ return nativeRequire(pkgName) as NativeBinding
148
+ } catch {
149
+ // Per-platform package not installed (typical pre-Phase-5b
150
+ // state, or a platform we don't yet ship binaries for).
151
+ }
152
+ }
153
+
154
+ return null
155
+ }
@@ -41,6 +41,14 @@
41
41
  * - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
42
42
  * cast on JSX returns is unnecessary (`JSX.Element`
43
43
  * is already assignable to `VNodeChild`).
44
+ * - `island-never-with-registry-entry` — an `island()` declared with
45
+ * `hydrate: 'never'` is also registered in the same
46
+ * file's `hydrateIslands({ ... })` call. The whole
47
+ * point of `'never'` is shipping zero client JS;
48
+ * registering pulls the component module into the
49
+ * client bundle graph (the runtime short-circuits
50
+ * and never calls the loader, but the bundler still
51
+ * includes the import). Drop the registry entry.
44
52
  *
45
53
  * Two-mode surface mirrors `react-intercept.ts`:
46
54
  * - `detectPyreonPatterns(code)` — diagnostics only
@@ -81,6 +89,7 @@ export type PyreonDiagnosticCode =
81
89
  | 'signal-write-as-call'
82
90
  | 'static-return-null-conditional'
83
91
  | 'as-unknown-as-vnodechild'
92
+ | 'island-never-with-registry-entry'
84
93
 
85
94
  export interface PyreonDiagnostic {
86
95
  /** Machine-readable code for filtering + programmatic handling */
@@ -114,6 +123,21 @@ interface DetectContext {
114
123
  * patterns that should be `sig.set(value)`.
115
124
  */
116
125
  signalBindings: Set<string>
126
+ /**
127
+ * Names of `island()` declarations carrying `hydrate: 'never'`. Populated
128
+ * by `collectNeverIslandNames()` before the main detection walk. Used by
129
+ * `detectIslandNeverWithRegistry` to flag entries in
130
+ * `hydrateIslands({ ... })` whose key matches a never-strategy island.
131
+ *
132
+ * Cross-call detection: the never-vs-registry mismatch is only catchable
133
+ * when both sides live in the same source. In real apps the `island()`
134
+ * declarations sit in `src/islands.ts` and the `hydrateIslands()` call
135
+ * sits in `src/entry-client.ts`. The static detector covers the common
136
+ * "all in one file" case (which catches the bug while users are first
137
+ * learning the API); the cross-file case is the territory of `pyreon
138
+ * doctor --check-islands` (separate PR / future scope).
139
+ */
140
+ neverIslandNames: Set<string>
117
141
  }
118
142
 
119
143
  function getNodeText(ctx: DetectContext, node: ts.Node): string {
@@ -647,6 +671,101 @@ function detectAsUnknownAsVNodeChild(ctx: DetectContext, node: ts.AsExpression):
647
671
  )
648
672
  }
649
673
 
674
+ // ═══════════════════════════════════════════════════════════════════════════════
675
+ // Island never-with-registry detection
676
+ // ═══════════════════════════════════════════════════════════════════════════════
677
+
678
+ /**
679
+ * Pre-pass: walk the source for `island(loader, { name: 'X', hydrate: 'never' })`
680
+ * call expressions and collect the `name` field of each never-strategy island.
681
+ *
682
+ * Recognized shape (mirrors `@pyreon/vite-plugin`'s `scanIslandDeclarations`):
683
+ *
684
+ * island(() => import('./X'), { name: 'X', hydrate: 'never' })
685
+ *
686
+ * Edge cases the AST-walker deliberately doesn't cover (unrecognized calls
687
+ * fall through and don't populate the set — false-negatives, not false
688
+ * positives):
689
+ *
690
+ * - Loader is a variable, not an inline arrow
691
+ * - Name is a variable / template / spread, not a string literal
692
+ * - Options come from a spread (`island(loader, opts)`)
693
+ *
694
+ * The same rules apply on the registry side (`detectIslandNeverWithRegistry`):
695
+ * unrecognized keys won't match. Both halves are syntactic — a semantic
696
+ * cross-package audit lives in `pyreon doctor --check-islands` (separate PR).
697
+ */
698
+ function collectNeverIslandNames(sf: ts.SourceFile): Set<string> {
699
+ const names = new Set<string>()
700
+ function walk(node: ts.Node): void {
701
+ if (
702
+ ts.isCallExpression(node) &&
703
+ ts.isIdentifier(node.expression) &&
704
+ node.expression.text === 'island' &&
705
+ node.arguments.length >= 2
706
+ ) {
707
+ const opts = node.arguments[1]
708
+ if (opts && ts.isObjectLiteralExpression(opts)) {
709
+ let nameVal: string | undefined
710
+ let hydrateVal: string | undefined
711
+ for (const prop of opts.properties) {
712
+ if (!ts.isPropertyAssignment(prop)) continue
713
+ const key = prop.name
714
+ const keyText = ts.isIdentifier(key)
715
+ ? key.text
716
+ : ts.isStringLiteral(key)
717
+ ? key.text
718
+ : ''
719
+ if (keyText === 'name' && ts.isStringLiteral(prop.initializer)) {
720
+ nameVal = prop.initializer.text
721
+ } else if (keyText === 'hydrate' && ts.isStringLiteral(prop.initializer)) {
722
+ hydrateVal = prop.initializer.text
723
+ }
724
+ }
725
+ if (nameVal && hydrateVal === 'never') {
726
+ names.add(nameVal)
727
+ }
728
+ }
729
+ }
730
+ ts.forEachChild(node, walk)
731
+ }
732
+ walk(sf)
733
+ return names
734
+ }
735
+
736
+ /**
737
+ * Flag entries in `hydrateIslands({ X: () => import('./X'), ... })` whose
738
+ * key matches an `island()` name declared with `hydrate: 'never'` in the
739
+ * same file. Each matching entry produces one diagnostic at the property's
740
+ * location so the IDE highlights exactly which key needs to go.
741
+ */
742
+ function detectIslandNeverWithRegistry(ctx: DetectContext, node: ts.CallExpression): void {
743
+ if (ctx.neverIslandNames.size === 0) return
744
+ const callee = node.expression
745
+ if (!ts.isIdentifier(callee) || callee.text !== 'hydrateIslands') return
746
+ const arg = node.arguments[0]
747
+ if (!arg || !ts.isObjectLiteralExpression(arg)) return
748
+ for (const prop of arg.properties) {
749
+ if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue
750
+ const key = prop.name
751
+ const keyText = ts.isIdentifier(key)
752
+ ? key.text
753
+ : ts.isStringLiteral(key)
754
+ ? key.text
755
+ : ''
756
+ if (!keyText || !ctx.neverIslandNames.has(keyText)) continue
757
+ pushDiag(
758
+ ctx,
759
+ prop,
760
+ 'island-never-with-registry-entry',
761
+ `island "${keyText}" was declared with \`hydrate: 'never'\` and MUST NOT be registered in \`hydrateIslands({ ... })\`. The whole point of the \`'never'\` strategy is shipping zero client JS — registering pulls the component module into the client bundle graph (the runtime short-circuits never-strategy before the registry lookup, but the bundler still includes the import). Drop this entry; the framework handles never-strategy islands at SSR with no client-side wiring.`,
762
+ getNodeText(ctx, prop),
763
+ `// remove the "${keyText}" entry — never-strategy islands need no registry entry`,
764
+ false,
765
+ )
766
+ }
767
+ }
768
+
650
769
  // ═══════════════════════════════════════════════════════════════════════════════
651
770
  // Visitor
652
771
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -674,6 +793,7 @@ function visitNode(ctx: DetectContext, node: ts.Node): void {
674
793
  detectEmptyTheme(ctx, node)
675
794
  detectRawEventListener(ctx, node)
676
795
  detectSignalWriteAsCall(ctx, node)
796
+ detectIslandNeverWithRegistry(ctx, node)
677
797
  }
678
798
  if (ts.isJsxAttribute(node)) {
679
799
  detectOnClickUndefined(ctx, node)
@@ -701,6 +821,7 @@ export function detectPyreonPatterns(code: string, filename = 'input.tsx'): Pyre
701
821
  code,
702
822
  diagnostics: [],
703
823
  signalBindings: collectSignalBindings(sf),
824
+ neverIslandNames: collectNeverIslandNames(sf),
704
825
  }
705
826
  visit(ctx, sf)
706
827
  // Sort by (line, column) for stable ordering when multiple patterns fire.
@@ -723,6 +844,11 @@ export function hasPyreonPatterns(code: string): boolean {
723
844
  // static-return-null-conditional: `if (...) return null` anywhere
724
845
  /\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) ||
725
846
  // as-unknown-as-vnodechild
726
- /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code)
847
+ /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code) ||
848
+ // island-never-with-registry-entry: a never-strategy declaration AND a
849
+ // hydrateIslands call must both appear in the same source for the bug
850
+ // shape to trigger. Pre-filter on EITHER half — the AST walker fast-
851
+ // exits when the never-island set is empty.
852
+ (/\bisland\s*\(/.test(code) && /\bhydrate\s*:\s*['"]never['"]/.test(code))
727
853
  )
728
854
  }