@nexus_js/compiler 0.7.2 → 0.7.5

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.
Files changed (48) hide show
  1. package/dist/client-security-scan.d.ts +12 -0
  2. package/dist/client-security-scan.d.ts.map +1 -0
  3. package/dist/client-security-scan.js +54 -0
  4. package/dist/client-security-scan.js.map +1 -0
  5. package/dist/client-security-scan.test.d.ts +2 -0
  6. package/dist/client-security-scan.test.d.ts.map +1 -0
  7. package/dist/client-security-scan.test.js +31 -0
  8. package/dist/client-security-scan.test.js.map +1 -0
  9. package/dist/codegen.d.ts.map +1 -1
  10. package/dist/codegen.js +594 -46
  11. package/dist/codegen.js.map +1 -1
  12. package/dist/compile-lib.d.ts +20 -0
  13. package/dist/compile-lib.d.ts.map +1 -0
  14. package/dist/compile-lib.js +73 -0
  15. package/dist/compile-lib.js.map +1 -0
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +6 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/island-codegen.test.d.ts +2 -0
  21. package/dist/island-codegen.test.d.ts.map +1 -0
  22. package/dist/island-codegen.test.js +159 -0
  23. package/dist/island-codegen.test.js.map +1 -0
  24. package/dist/island-template-warnings.d.ts +8 -0
  25. package/dist/island-template-warnings.d.ts.map +1 -0
  26. package/dist/island-template-warnings.js +35 -0
  27. package/dist/island-template-warnings.js.map +1 -0
  28. package/dist/island-wrap.d.ts.map +1 -1
  29. package/dist/island-wrap.js +27 -9
  30. package/dist/island-wrap.js.map +1 -1
  31. package/dist/island-wrap.test.d.ts +2 -0
  32. package/dist/island-wrap.test.d.ts.map +1 -0
  33. package/dist/island-wrap.test.js +22 -0
  34. package/dist/island-wrap.test.js.map +1 -0
  35. package/dist/parser.d.ts.map +1 -1
  36. package/dist/parser.js +11 -2
  37. package/dist/parser.js.map +1 -1
  38. package/dist/pretext-extract.d.ts +29 -0
  39. package/dist/pretext-extract.d.ts.map +1 -0
  40. package/dist/pretext-extract.js +51 -0
  41. package/dist/pretext-extract.js.map +1 -0
  42. package/dist/pretext-extract.test.d.ts +2 -0
  43. package/dist/pretext-extract.test.d.ts.map +1 -0
  44. package/dist/pretext-extract.test.js +33 -0
  45. package/dist/pretext-extract.test.js.map +1 -0
  46. package/dist/types.d.ts +15 -0
  47. package/dist/types.d.ts.map +1 -1
  48. package/package.json +5 -1
package/dist/codegen.js CHANGED
@@ -1,26 +1,116 @@
1
- import { join, normalize } from 'node:path';
1
+ import { existsSync } from 'node:fs';
2
+ import { basename, join, normalize, relative } from 'node:path';
2
3
  import { pathToFileURL } from 'node:url';
3
4
  import { scopeCSS, scopeTemplate, unwrapOuterTemplateElement } from './css-scope.js';
4
5
  import { wrapSelfClientIslandMarkers } from './island-wrap.js';
5
- import { islandSsrStubLines } from './island-ssr-stubs.js';
6
+ import { scanSelfClientIslandTemplateWarnings } from './island-template-warnings.js';
7
+ import { islandSsrStubLines, listRuneBindingNames, extractDollarStateInitializers, } from './island-ssr-stubs.js';
8
+ import { transformPretextExport } from './pretext-extract.js';
9
+ import { scanIslandSecurity } from './client-security-scan.js';
6
10
  /** Generates a unique stable island ID from filepath + component name */
7
11
  function islandId(filepath, componentName) {
8
12
  const base = filepath.replace(/[^a-zA-Z0-9]/g, '_');
9
13
  return `island_${base}_${componentName}`.toLowerCase();
10
14
  }
15
+ /** Extension preference for dev (source-first) and prod (compiled-first). */
16
+ const LIB_IMPORT_EXT_DEV = ['.ts', '.tsx', '.mts', '.js', '.mjs', '.cjs'];
17
+ const LIB_IMPORT_EXT_PROD = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.mts'];
18
+ function escapeRegExp(s) {
19
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
20
+ }
21
+ /**
22
+ * `export const x = createAction` so the generated `*.actions.mjs` can import the same
23
+ * binding SSR uses — otherwise registerAction patches a duplicate wrapper instance.
24
+ */
25
+ function exportCreateActionBindingsInSource(source, names) {
26
+ let out = source;
27
+ for (const name of names) {
28
+ const re = new RegExp(`(^|\\n)(\\s*)(?:export\\s+)?(const)\\s+(${escapeRegExp(name)})\\s*=\\s*createAction\\b`, 'gm');
29
+ out = out.replace(re, '$1$2export $3 $4 = createAction');
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * Server bundle filename adjacent to `*.actions.{mjs,js}`.
35
+ * Dev must match `devServerCachePath` in packages/server load-module.ts (`+` → `_` in the rel path).
36
+ */
37
+ function actionsServerImportFilename(opts, filepath) {
38
+ if (opts.dev) {
39
+ if (opts.appRoot) {
40
+ const rel = relative(normalize(opts.appRoot), normalize(filepath));
41
+ const safe = rel.replace(/[^a-zA-Z0-9._/-]/g, '_');
42
+ return basename(safe + '.mjs');
43
+ }
44
+ return basename(filepath).replace(/[^a-zA-Z0-9._/-]/g, '_') + '.mjs';
45
+ }
46
+ const p = opts.routePattern ?? '';
47
+ const seg = p === '/' ? 'index' : p.replace(/^\//u, '');
48
+ const full = seg || basename(filepath).replace(/\.nx$/u, '');
49
+ // The sidecar is ADJACENT to the server module, so use only the last path segment.
50
+ // e.g. pattern "/auth/login" → server: ".nexus/output/auth/login.js"
51
+ // → sidecar: ".nexus/output/auth/login.actions.js"
52
+ // → import: "./login.js" (not "./auth/login.js")
53
+ return `${basename(full)}.js`;
54
+ }
55
+ /**
56
+ * Resolve `$lib/…` to an on-disk path so Node ESM can load it.
57
+ *
58
+ * Production (`!dev`): checks `.nexus/lib/` (compiled JS output) first so Node
59
+ * never tries to execute raw `.ts` source files. Falls back to `src/lib/` with
60
+ * JS-first extension order for apps that ship pre-compiled lib files.
61
+ *
62
+ * Dev: prefers the TypeScript source (works with tsx / Node --experimental-strip-types).
63
+ */
64
+ function resolveDollarLibFilePath(appRoot, rel, dev) {
65
+ const root = normalize(appRoot);
66
+ const hasKnownExt = /\.(ts|tsx|mts|js|mjs|cjs)$/u.test(rel);
67
+ // Production: prefer the pre-compiled .nexus/lib/ output emitted by `nexus build`.
68
+ if (!dev) {
69
+ const nexusLibBase = join(root, '.nexus', 'lib', rel);
70
+ // Check with .ts → .js substitution first.
71
+ const nexusLibJs = hasKnownExt
72
+ ? nexusLibBase.replace(/\.(ts|tsx|mts)$/u, '.js')
73
+ : nexusLibBase + '.js';
74
+ if (existsSync(nexusLibJs))
75
+ return nexusLibJs;
76
+ if (existsSync(nexusLibBase))
77
+ return nexusLibBase;
78
+ }
79
+ const abs = join(root, 'src/lib', rel);
80
+ if (existsSync(abs))
81
+ return abs;
82
+ if (hasKnownExt)
83
+ return abs;
84
+ const order = dev ? LIB_IMPORT_EXT_DEV : LIB_IMPORT_EXT_PROD;
85
+ for (const ext of order) {
86
+ const candidate = abs + ext;
87
+ if (existsSync(candidate))
88
+ return candidate;
89
+ }
90
+ // Fallback: .ts in dev (needs tsx), .js in prod (will warn at runtime if missing).
91
+ return abs + (dev ? '.ts' : '.js');
92
+ }
11
93
  /** Resolve `$lib/…` in server frontmatter to absolute file URLs for Node ESM. */
12
- function rewriteDollarLibImports(code, appRoot) {
94
+ function rewriteDollarLibImports(code, opts) {
95
+ const appRoot = opts.appRoot;
13
96
  if (!appRoot)
14
97
  return code;
15
98
  const root = normalize(appRoot);
99
+ const libBust = opts.dev &&
100
+ typeof opts.libDepsMtime === 'number' &&
101
+ Number.isFinite(opts.libDepsMtime) &&
102
+ opts.libDepsMtime > 0
103
+ ? `?t=${Math.floor(opts.libDepsMtime)}`
104
+ : '';
16
105
  return code.replace(/from\s*['"]\$lib\/([^'"]+)['"]/gu, (_, rel) => {
17
- const abs = join(root, 'src/lib', rel);
18
- return `from ${JSON.stringify(pathToFileURL(abs).href)}`;
106
+ const abs = resolveDollarLibFilePath(root, rel, !!opts.dev);
107
+ const href = pathToFileURL(abs).href + libBust;
108
+ return `from ${JSON.stringify(href)}`;
19
109
  });
20
110
  }
21
111
  /** Compiles a parsed .nx component into server + client output */
22
112
  export function generate(parsed, opts) {
23
- const warnings = [];
113
+ const warnings = [...scanIslandSecurity(parsed)];
24
114
  // ── CSS (AOT hash scoping — zero runtime) ─────────────────────────────────
25
115
  // Computed first so it can be passed into generateServerModule
26
116
  let css = null;
@@ -35,6 +125,7 @@ export function generate(parsed, opts) {
35
125
  processedTemplate = unwrapOuterTemplateElement(processedTemplate);
36
126
  const islandWrap = wrapSelfClientIslandMarkers(processedTemplate, parsed.filepath, opts.appRoot);
37
127
  processedTemplate = islandWrap.template;
128
+ warnings.push(...scanSelfClientIslandTemplateWarnings(islandWrap, parsed.filepath));
38
129
  // ── Server module ──────────────────────────────────────────────────────────
39
130
  const serverCode = generateServerModule(parsed, opts, processedTemplate, islandWrap);
40
131
  // ── Client island code (only if there are reactive islands) ───────────────
@@ -61,7 +152,7 @@ export function generate(parsed, opts) {
61
152
  : null;
62
153
  // ── Server Actions module ──────────────────────────────────────────────────
63
154
  const actionsModule = parsed.serverActions.length > 0
64
- ? generateActionsModule(parsed.serverActions, parsed.filepath)
155
+ ? generateActionsModule(parsed.serverActions, parsed.filepath, opts)
65
156
  : null;
66
157
  return {
67
158
  serverCode,
@@ -81,10 +172,33 @@ function generateServerModule(parsed, opts, processedTemplate, islandWrap) {
81
172
  lines.push(`// [Nexus] Server module — generated from ${parsed.filepath}`);
82
173
  lines.push(`// DO NOT EDIT — this file is auto-generated`);
83
174
  lines.push('');
84
- // Frontmatter imports + data fetching
175
+ const createActionBindingNames = new Set(parsed.serverActions.filter((a) => a.createActionSource).map((a) => a.name));
176
+ // Leading imports + // nexus:server first so `export const x = createAction` in pretext closes over $lib.
85
177
  if (parsed.frontmatter) {
86
- lines.push('// ── Server-only data fetching (runs per request) ──');
87
- lines.push(rewriteDollarLibImports(parsed.frontmatter.content.trim(), opts.appRoot));
178
+ lines.push('// ── Imports & server-only (leading + // nexus:server) ──');
179
+ lines.push(rewriteDollarLibImports(exportCreateActionBindingsInSource(parsed.frontmatter.content.trim(), createActionBindingNames), opts));
180
+ lines.push('');
181
+ }
182
+ if (parsed.pretext) {
183
+ lines.push('// ── Pretext — merged into ctx.pretext (parallel across layout + page before render) ──');
184
+ lines.push(rewriteDollarLibImports(exportCreateActionBindingsInSource(transformPretextExport(parsed.pretext), createActionBindingNames), opts));
185
+ lines.push('');
186
+ }
187
+ // ── "use server" action exports ────────────────────────────────────────────
188
+ // Export each inline action as a named function so the sidecar can import it.
189
+ // This keeps the $lib imports in scope (they live in the same module) and
190
+ // avoids duplicating imports or inlining bodies into the sidecar.
191
+ const inlineActions = parsed.serverActions.filter((a) => !a.createActionSource);
192
+ for (const action of inlineActions) {
193
+ lines.push(`// ── Server Action export: ${action.name} ──`);
194
+ lines.push(`export async function __nexus_action_${action.name}(${action.params.join(', ')}) {`);
195
+ for (const line of action.body.split('\n')) {
196
+ const t = line.trim();
197
+ if (!t)
198
+ continue;
199
+ lines.push(` ${t}`);
200
+ }
201
+ lines.push('}');
88
202
  lines.push('');
89
203
  }
90
204
  // Runes from the client script — SSR must define matching locals whenever the template interpolates them.
@@ -101,6 +215,9 @@ function generateServerModule(parsed, opts, processedTemplate, islandWrap) {
101
215
  lines.push('');
102
216
  // Template renderer (simple expression interpolation → SSR)
103
217
  lines.push('async function renderTemplate(ctx) {');
218
+ lines.push(' // Primary context from nxPretext (layouts + page, parallel merge) — mirrors client $pretext()');
219
+ lines.push(' const pretext = ctx.pretext ?? {};');
220
+ lines.push(' const $pretext = () => (ctx.pretext ?? {});');
104
221
  lines.push(' // Server-side template rendering (CSS-scoped at compile time)');
105
222
  // Island-wrapped pages may reference only plain functions (e.g. onsubmit={preventSubmit}) with no $state.
106
223
  if (parsed.script?.content && (runes.length > 0 || islandWrap.didWrap)) {
@@ -109,7 +226,9 @@ function generateServerModule(parsed, opts, processedTemplate, islandWrap) {
109
226
  }
110
227
  }
111
228
  lines.push(" const __ssrAttr = (v) => String(v ?? '').replace(/&/g, '&amp;').replace(/\"/g, '&quot;').replace(/</g, '&lt;');");
112
- lines.push(` return \`${templateToSSR(processedTemplate)}\`;`);
229
+ // Concat — not nested template literal: SSR body can contain `` ` `` and `${` (nested if/each).
230
+ const actionNamesForSsr = new Set(parsed.serverActions.map((a) => a.name));
231
+ lines.push(' return ' + '`' + templateToSSR(processedTemplate, actionNamesForSsr) + '`;');
113
232
  lines.push('}');
114
233
  return lines.join('\n');
115
234
  }
@@ -121,57 +240,115 @@ function generateClientIsland(parsed, _opts, islandWrap) {
121
240
  lines.push(`// [Nexus] Client Island — ${parsed.filepath}`);
122
241
  lines.push(`// Hydration strategy: ${parsed.islandDirectives.map((d) => d.directive).join(', ') || 'client:load'}`);
123
242
  lines.push('');
124
- lines.push("import { createIsland, $state, $derived, $effect } from '/_nexus/rt/island.js';");
243
+ lines.push("import { createIsland, $state, $derived, $effect, $pretext } from '/_nexus/rt/island.js';");
125
244
  lines.push('');
126
245
  const fragments = islandWrap.clientFragments.length > 0
127
246
  ? islandWrap.clientFragments
128
247
  : [islandWrap.clientTemplate ?? parsed.template?.content ?? ''];
129
- lines.push(`const __nxTemplates = [${fragments.map((f) => JSON.stringify(f)).join(', ')}];`);
248
+ const scriptSrc = parsed.script?.content ?? '';
249
+ const bindingNames = new Set(listRuneBindingNames(scriptSrc));
250
+ const stateOnlyNames = new Set(extractDollarStateInitializers(scriptSrc).keys());
251
+ const actionNamesForClient = new Set(parsed.serverActions.map((a) => a.name));
252
+ const processedFragments = [];
253
+ const exprFnBlocks = [];
254
+ for (const frag of fragments) {
255
+ const { processed, exprLines } = processTemplateForClientIsland(frag, bindingNames, actionNamesForClient);
256
+ processedFragments.push(processed);
257
+ exprFnBlocks.push(exprLines);
258
+ }
259
+ lines.push('const __nxIslandProcessed = [');
260
+ for (const p of processedFragments) {
261
+ lines.push(` ${JSON.stringify(p)},`);
262
+ }
263
+ lines.push('];');
130
264
  lines.push('');
131
- // Script content with Runes (already Svelte-5-style, pass through)
265
+ lines.push('const __nxIslandFns = [');
266
+ for (const block of exprFnBlocks) {
267
+ lines.push(' [');
268
+ for (const line of block) {
269
+ lines.push(` ${line},`);
270
+ }
271
+ lines.push(' ],');
272
+ }
273
+ lines.push('];');
274
+ lines.push('');
275
+ const delegated = fragments.map((frag) => extractDelegatedClickFromFragment(frag, stateOnlyNames));
276
+ const delegatedSubmits = fragments.map((frag) => extractDelegatedSubmitFromFragment(frag));
277
+ // Script content — Nexus runes use .value; $derived needs () => fn
132
278
  if (parsed.script) {
133
279
  lines.push('// ── Reactive State (Runes) ──');
134
- lines.push(transformRunesToRuntime(parsed.script.content));
280
+ lines.push(transformRunesForClientRuntime(scriptSrc));
135
281
  lines.push('');
136
282
  }
137
- // Mount function — each <nexus-island> passes data-nexus-island-index to pick the right template slice
283
+ lines.push('const __nxDelegated = [');
284
+ for (const d of delegated) {
285
+ lines.push(d
286
+ ? ` { delegatedClickSelector: ${JSON.stringify(d.delegatedClickSelector)}, onDelegatedClick: ${d.onDelegatedClick} },`
287
+ : ' null,');
288
+ }
289
+ lines.push('];');
290
+ lines.push('');
291
+ lines.push('const __nxDelegatedSubmit = [');
292
+ for (const s of delegatedSubmits) {
293
+ if (!s) {
294
+ lines.push(' null,');
295
+ continue;
296
+ }
297
+ lines.push(` { delegatedSubmitFormId: ${JSON.stringify(s.delegatedSubmitFormId)}, onDelegatedSubmit: () => { void ${s.handlerName}(); } },`);
298
+ }
299
+ lines.push('];');
300
+ lines.push('');
138
301
  lines.push('export function mount(el, props = {}) {');
139
302
  lines.push(` const idx = Number(el.getAttribute('data-nexus-island-index') ?? '0');`);
140
- lines.push(' const tpl = __nxTemplates[idx] ?? __nxTemplates[0];');
303
+ lines.push(' const processedTemplate = __nxIslandProcessed[idx] ?? __nxIslandProcessed[0];');
304
+ lines.push(' const exprFns = __nxIslandFns[idx] ?? __nxIslandFns[0];');
141
305
  lines.push(' return createIsland(el, {');
142
- lines.push(' template: tpl,');
306
+ lines.push(' processedTemplate,');
307
+ lines.push(' exprFns,');
308
+ lines.push(' ...(__nxDelegated[idx] ?? {}),');
309
+ lines.push(' ...(__nxDelegatedSubmit[idx] ?? {}),');
143
310
  lines.push(' ...props,');
144
311
  lines.push(' });');
145
312
  lines.push('}');
146
313
  return lines.join('\n');
147
314
  }
148
315
  // ─────────────────────────────────────────────────────────────────────────────
149
- // Server Actions module: type-safe RPC stubs
316
+ // Server Actions module: imports handlers from server module + registers them
150
317
  // ─────────────────────────────────────────────────────────────────────────────
151
- function generateActionsModule(actions, filepath) {
318
+ /**
319
+ * Generates the sidecar `*.actions.{mjs,js}` file.
320
+ *
321
+ * All action implementations are imported from the co-located server module
322
+ * (which has the $lib imports in scope):
323
+ * - `createAction` bindings → imported by their original name
324
+ * - `"use server"` functions → imported as `__nexus_action_<name>` (exported
325
+ * by the server module via `generateServerModule`)
326
+ *
327
+ * This design means the sidecar never needs its own $lib imports — the server
328
+ * module already has everything in scope, and the handler runs there.
329
+ */
330
+ function generateActionsModule(actions, filepath, opts) {
152
331
  const lines = [];
153
332
  lines.push(`// [Nexus] Server Actions — generated from ${filepath}`);
154
333
  lines.push(`"use server";`);
155
334
  lines.push('');
156
- const needsCreateAction = actions.some((a) => a.createActionSource);
157
- lines.push(needsCreateAction
158
- ? "import { createAction, registerAction } from '@nexus_js/server/actions';"
159
- : "import { registerAction } from '@nexus_js/server/actions';");
335
+ lines.push("import { registerAction } from '@nexus_js/server/actions';");
336
+ lines.push('');
337
+ // Build the import specifier list: createAction original name, "use server" → __nexus_action_*
338
+ const importSpecifiers = [];
339
+ for (const action of actions) {
340
+ importSpecifiers.push(action.createActionSource ? action.name : `__nexus_action_${action.name}`);
341
+ }
342
+ const serverFile = actionsServerImportFilename(opts, filepath);
343
+ lines.push(`import { ${importSpecifiers.join(', ')} } from ${JSON.stringify('./' + serverFile)};`);
160
344
  lines.push('');
161
345
  for (const action of actions) {
162
346
  lines.push(`/** @nexus-action "${action.name}" */`);
163
347
  if (action.createActionSource) {
164
- lines.push(`registerAction(${JSON.stringify(action.name)}, ${action.createActionSource}, { csrf: false });`);
348
+ lines.push(`registerAction(${JSON.stringify(action.name)}, ${action.name});`);
165
349
  }
166
350
  else {
167
- lines.push(`registerAction(${JSON.stringify(action.name)}, async (${action.params.join(', ')}) => {`);
168
- for (const line of action.body.split('\n')) {
169
- const t = line.trim();
170
- if (!t)
171
- continue;
172
- lines.push(` ${t}`);
173
- }
174
- lines.push(`}, { csrf: false });`);
351
+ lines.push(`registerAction(${JSON.stringify(action.name)}, __nexus_action_${action.name});`);
175
352
  }
176
353
  lines.push('');
177
354
  }
@@ -190,14 +367,367 @@ function extractRuneDeclarations(code) {
190
367
  }
191
368
  return runes;
192
369
  }
193
- function transformRunesToRuntime(code) {
194
- // The rune primitives ($state, $derived, $effect, $props) are imported at the top of
195
- // the generated client module from '/_nexus/rt/island.js'. They work as plain function
196
- // calls — no namespace prefix needed. Strip any legacy "use server" directives that
197
- // must not appear in the browser bundle.
198
- return code.replace(/^\s*"use server"\s*;?\s*$/gm, '// [server-only removed]');
370
+ /**
371
+ * After `let x = $state` `const x = $state`, assignments like `x = 1` must become
372
+ * `x.value = 1`. Plain `x =` reassigns a const and throws at runtime (e.g. auth forms).
373
+ */
374
+ function transformStateAssignmentsForClientScript(script) {
375
+ const stateNames = extractDollarStateInitializers(script).keys();
376
+ let s = script;
377
+ for (const name of stateNames) {
378
+ const re = new RegExp(String.raw `(?<!const )\b${name}\s*=(?!=)`, 'g');
379
+ s = s.replace(re, `${name}.value =`);
380
+ }
381
+ return s;
382
+ }
383
+ /** Map Svelte-style rune usage in .nx to Nexus runtime ($state/$derived use `.value`). */
384
+ function transformRunesForClientRuntime(script) {
385
+ let s = script.replace(/^\s*"use server"\s*;?\s*$/gm, '// [server-only removed]');
386
+ const bindingNames = new Set(listRuneBindingNames(script));
387
+ s = s.replace(/\blet\s+(\w+)\s*=\s*\$state\b/g, 'const $1 = $state');
388
+ s = s.replace(/\b(?:let|const)\s+(\w+)\s*=\s*\$derived\s*\(\s*([^)]+)\s*\)/g, (full, name, body) => {
389
+ const b = body.trim();
390
+ if (b.startsWith('()'))
391
+ return full.replace(/^\blet\b/, 'const');
392
+ const expr = exprToValueExpr(b, bindingNames);
393
+ return `const ${name} = $derived(() => (${expr}))`;
394
+ });
395
+ s = transformStateAssignmentsForClientScript(s);
396
+ return s;
397
+ }
398
+ function exprToValueExpr(expr, bindingNames) {
399
+ let out = expr.trim();
400
+ for (const name of bindingNames) {
401
+ out = out.replace(new RegExp(`\\b${name}\\b(?!\\s*\\.\\s*value)`, 'g'), `${name}.value`);
402
+ }
403
+ return out;
404
+ }
405
+ /**
406
+ * HTML5 boolean attributes: presence means true — `disabled="false"` still disables.
407
+ * Emit `disabled` only when truthy: `${expr ? ' disabled' : ''}` (same order as `{expr}` in file).
408
+ */
409
+ function interpolateClientIslandPlaceholders(html, bindingNames, exprLines) {
410
+ const re = /\b(disabled|checked|readonly|required|selected|multiple|autofocus|open)\s*=\s*\{\s*([a-zA-Z_$][\w$]*)\s*\}|(?<!\$)\{([^}]+)\}/g;
411
+ return html.replace(re, (full, g1, g2, g3) => {
412
+ const idx = exprLines.length;
413
+ if (g1 !== undefined && g2 !== undefined) {
414
+ const expr = exprToValueExpr(g2.trim(), bindingNames);
415
+ exprLines.push(`() => (${expr}) ? ' ${g1}' : ''`);
416
+ return `__NX_${idx}__`;
417
+ }
418
+ if (g3 !== undefined) {
419
+ const code = exprToValueExpr(g3.trim(), bindingNames);
420
+ exprLines.push(`() => (${code})`);
421
+ return `__NX_${idx}__`;
422
+ }
423
+ return full;
424
+ });
425
+ }
426
+ /** Strip client event handlers and replace `{expr}` with `__NX_i__` + parallel expr functions. */
427
+ function processTemplateForClientIsland(html, bindingNames, actionNames) {
428
+ const cleaned = html.replace(/\s+on[a-zA-Z][a-zA-Z0-9-]*\s*=\s*\{[^}]+\}/g, '');
429
+ const withActions = rewriteServerActionHtmlActionAttr(cleaned, actionNames);
430
+ const controlFlowExpanded = expandEachBlocks(expandIfBlocks(withActions));
431
+ const exprLines = [];
432
+ const processed = interpolateClientIslandPlaceholders(controlFlowExpanded, bindingNames, exprLines);
433
+ return { processed, exprLines };
434
+ }
435
+ /** Length of `<tag ...>` from `start` (at `<`), quote-aware so `>` in strings does not close early. */
436
+ function openingTagScanLength(html, start) {
437
+ if (html[start] !== '<')
438
+ return 0;
439
+ let quote = null;
440
+ for (let i = start; i < html.length; i++) {
441
+ const c = html[i];
442
+ if (quote) {
443
+ if (c === quote)
444
+ quote = null;
445
+ continue;
446
+ }
447
+ if (c === '"' || c === "'")
448
+ quote = c;
449
+ else if (c === '>')
450
+ return i - start + 1;
451
+ }
452
+ return 0;
453
+ }
454
+ /**
455
+ * Resolves `#id` for delegated click from the same opening tag that carries `onclick`, not the first `id` in the
456
+ * fragment (forms often put `<input id=…>` before `<button onclick=…>`).
457
+ */
458
+ function extractDelegatedClickFromFragment(html, stateNames) {
459
+ const m = /onclick=\{([^}]+)\}/.exec(html);
460
+ if (!m?.[1])
461
+ return null;
462
+ const onclickIdx = m.index ?? 0;
463
+ let delegatedClickSelector = 'button';
464
+ outer: for (const tag of ['button', 'a']) {
465
+ const re = new RegExp(`<${tag}\\b`, 'gi');
466
+ let match;
467
+ while ((match = re.exec(html)) !== null) {
468
+ const s = match.index;
469
+ const len = openingTagScanLength(html, s);
470
+ if (len === 0)
471
+ continue;
472
+ if (onclickIdx < s || onclickIdx >= s + len)
473
+ continue;
474
+ const openTag = html.slice(s, s + len);
475
+ const idMatch = /\bid\s*=\s*"([^"]+)"/.exec(openTag);
476
+ if (idMatch?.[1])
477
+ delegatedClickSelector = `#${idMatch[1]}`;
478
+ else
479
+ delegatedClickSelector = tag === 'a' ? 'a' : 'button';
480
+ break outer;
481
+ }
482
+ }
483
+ const onDelegatedClick = rewriteClickHandlerBody(m[1].trim(), stateNames);
484
+ return { delegatedClickSelector, onDelegatedClick };
485
+ }
486
+ const DATA_NEXUS_SUBMIT_RE = /data-nexus-submit\s*=\s*"(\w+)"/;
487
+ /**
488
+ * `<form id="…" data-nexus-submit="handlerName">` — submit is delegated on the stable island root so it survives
489
+ * reactive `outerHTML` updates (listeners on the form element alone are lost each tick).
490
+ */
491
+ function extractDelegatedSubmitFromFragment(html) {
492
+ const formOpen = /<form\b[^>]*>/.exec(html);
493
+ if (!formOpen?.[0])
494
+ return null;
495
+ const openTag = formOpen[0];
496
+ const idMatch = /\bid\s*=\s*"([^"]+)"/.exec(openTag);
497
+ const submitMatch = DATA_NEXUS_SUBMIT_RE.exec(openTag);
498
+ if (!idMatch?.[1] || !submitMatch?.[1])
499
+ return null;
500
+ return { delegatedSubmitFormId: idMatch[1], handlerName: submitMatch[1] };
501
+ }
502
+ function rewriteClickHandlerBody(body, stateNames) {
503
+ const trimmed = body.trim();
504
+ const arrow = /^\(\s*\)\s*=>\s*(.+)$/.exec(trimmed);
505
+ if (!arrow?.[1])
506
+ return trimmed;
507
+ let inner = arrow[1].trim();
508
+ if (inner.endsWith(';'))
509
+ inner = inner.slice(0, -1);
510
+ for (const name of stateNames) {
511
+ inner = inner.replace(new RegExp(`^${name}\\s*\\+\\+$`), `${name}.value++`);
512
+ inner = inner.replace(new RegExp(`^${name}\\s*--$`), `${name}.value--`);
513
+ inner = inner.replace(new RegExp(`^${name}\\s*=\\s*(.+)$`), `${name}.value = $1`);
514
+ }
515
+ return `() => { ${inner}; }`;
199
516
  }
200
517
  const EACH_OPEN = '{#each ';
518
+ const IF_OPEN = '{#if ';
519
+ const ELSE_IF_OPEN = '{:else if ';
520
+ /**
521
+ * Index of the `}` that closes `{#if expr}`, `{:else if expr}`, or `{#each expr as x}` when `expr`
522
+ * may contain `}`, e.g. `.filter((r) => r.x)`. Plain `indexOf('}', start)` breaks those tags.
523
+ */
524
+ function findBlockTagExprEnd(t, exprStart) {
525
+ let curly = 0;
526
+ let paren = 0;
527
+ let bracket = 0;
528
+ let i = exprStart;
529
+ while (i < t.length) {
530
+ const c = t[i];
531
+ if (c === '"' || c === "'") {
532
+ const q = c;
533
+ i++;
534
+ while (i < t.length) {
535
+ const ch = t[i];
536
+ if (ch === '\\') {
537
+ i += 2;
538
+ continue;
539
+ }
540
+ if (ch === q) {
541
+ i++;
542
+ break;
543
+ }
544
+ i++;
545
+ }
546
+ continue;
547
+ }
548
+ if (c === '`') {
549
+ i++;
550
+ while (i < t.length) {
551
+ const ch = t[i];
552
+ if (ch === '\\') {
553
+ i += 2;
554
+ continue;
555
+ }
556
+ if (ch === '`') {
557
+ i++;
558
+ break;
559
+ }
560
+ if (ch === '$' && t[i + 1] === '{') {
561
+ const nest = findBlockTagExprEnd(t, i + 2);
562
+ if (nest < 0)
563
+ return -1;
564
+ i = nest + 1;
565
+ continue;
566
+ }
567
+ i++;
568
+ }
569
+ continue;
570
+ }
571
+ if (c === '/' && t[i + 1] === '/') {
572
+ i += 2;
573
+ while (i < t.length && t[i] !== '\n' && t[i] !== '\r')
574
+ i++;
575
+ continue;
576
+ }
577
+ switch (c) {
578
+ case '(':
579
+ paren++;
580
+ break;
581
+ case ')':
582
+ if (paren > 0)
583
+ paren--;
584
+ break;
585
+ case '[':
586
+ bracket++;
587
+ break;
588
+ case ']':
589
+ if (bracket > 0)
590
+ bracket--;
591
+ break;
592
+ case '{':
593
+ curly++;
594
+ break;
595
+ case '}':
596
+ if (curly > 0) {
597
+ curly--;
598
+ }
599
+ else if (paren === 0 && bracket === 0) {
600
+ return i;
601
+ }
602
+ break;
603
+ default:
604
+ break;
605
+ }
606
+ i++;
607
+ }
608
+ return -1;
609
+ }
610
+ /**
611
+ * Parse one `{#if}...{:else if}...{:else}...{/if}` starting at `openIdx` (must point at `{#if `).
612
+ * Nested `{#if}` inside branches is handled via depth counting.
613
+ */
614
+ function parseTopLevelIfBlock(t, openIdx) {
615
+ if (!t.startsWith(IF_OPEN, openIdx))
616
+ return null;
617
+ const condStart = openIdx + IF_OPEN.length;
618
+ const condEnd = findBlockTagExprEnd(t, condStart);
619
+ if (condEnd < 0)
620
+ return null;
621
+ const firstCond = t.slice(condStart, condEnd).trim();
622
+ const branches = [];
623
+ let currentCond = firstCond;
624
+ let bodyStart = condEnd + 1;
625
+ let depth = 1;
626
+ let i = bodyStart;
627
+ while (i < t.length && depth >= 1) {
628
+ const pIf = t.indexOf(IF_OPEN, i);
629
+ const pClose = t.indexOf('{/if}', i);
630
+ let pElseIf = -1;
631
+ let pElse = -1;
632
+ if (depth === 1) {
633
+ pElseIf = t.indexOf(ELSE_IF_OPEN, i);
634
+ let pe = t.indexOf('{:else}', i);
635
+ while (pe !== -1 && t.startsWith(ELSE_IF_OPEN, pe)) {
636
+ pe = t.indexOf('{:else}', pe + 1);
637
+ }
638
+ pElse = pe;
639
+ }
640
+ let nextPos = Infinity;
641
+ let kind = null;
642
+ const cand = (p, k) => {
643
+ if (p !== -1 && p >= i && p < nextPos) {
644
+ nextPos = p;
645
+ kind = k;
646
+ }
647
+ };
648
+ cand(pIf, 'if');
649
+ cand(pClose, 'close');
650
+ cand(pElseIf, 'elseif');
651
+ cand(pElse, 'else');
652
+ if (kind === null || nextPos === Infinity)
653
+ return null;
654
+ if (kind === 'if' && pIf === nextPos) {
655
+ depth++;
656
+ const hEnd = findBlockTagExprEnd(t, pIf + IF_OPEN.length);
657
+ if (hEnd < 0)
658
+ return null;
659
+ i = hEnd + 1;
660
+ continue;
661
+ }
662
+ if (kind === 'close' && pClose === nextPos) {
663
+ depth--;
664
+ if (depth === 0) {
665
+ branches.push({ cond: currentCond, body: t.slice(bodyStart, pClose) });
666
+ return { closeEnd: pClose + '{/if}'.length, branches };
667
+ }
668
+ i = pClose + '{/if}'.length;
669
+ continue;
670
+ }
671
+ if (depth === 1 && kind === 'elseif' && pElseIf === nextPos) {
672
+ branches.push({ cond: currentCond, body: t.slice(bodyStart, pElseIf) });
673
+ const cStart = pElseIf + ELSE_IF_OPEN.length;
674
+ const cEnd = findBlockTagExprEnd(t, cStart);
675
+ if (cEnd < 0)
676
+ return null;
677
+ currentCond = t.slice(cStart, cEnd).trim();
678
+ bodyStart = cEnd + 1;
679
+ i = bodyStart;
680
+ continue;
681
+ }
682
+ if (depth === 1 && kind === 'else' && pElse === nextPos) {
683
+ branches.push({ cond: currentCond, body: t.slice(bodyStart, pElse) });
684
+ currentCond = null;
685
+ bodyStart = pElse + '{:else}'.length;
686
+ i = bodyStart;
687
+ continue;
688
+ }
689
+ return null;
690
+ }
691
+ return null;
692
+ }
693
+ function buildIfTernary(branches) {
694
+ if (branches.length === 0)
695
+ return '';
696
+ function aux(idx) {
697
+ const b = branches[idx];
698
+ const last = idx === branches.length - 1;
699
+ if (last) {
700
+ if (b.cond === null) {
701
+ return '`' + b.body + '`';
702
+ }
703
+ return b.cond + ' ? `' + b.body + '` : \'\'';
704
+ }
705
+ if (b.cond === null) {
706
+ return '`' + b.body + '`';
707
+ }
708
+ return b.cond + ' ? `' + b.body + '` : (' + aux(idx + 1) + ')';
709
+ }
710
+ return '${' + aux(0) + '}';
711
+ }
712
+ /**
713
+ * Turns Svelte-style `{#if}` / `{:else if}` / `{:else}` into JS template ternary fragments.
714
+ * Must run before `interpolateExpressionsForSSR` — otherwise `{#if x}` is mistaken for `{expr}` and emits `${#if` (invalid private field `#if`).
715
+ */
716
+ function expandIfBlocks(template) {
717
+ if (!template.includes(IF_OPEN))
718
+ return template;
719
+ const open = template.indexOf(IF_OPEN);
720
+ const parsed = parseTopLevelIfBlock(template, open);
721
+ if (!parsed)
722
+ return template;
723
+ const expandedBranches = parsed.branches.map(({ cond, body }) => ({
724
+ cond,
725
+ body: expandIfBlocks(body.trim()),
726
+ }));
727
+ const piece = buildIfTernary(expandedBranches);
728
+ const next = template.slice(0, open) + piece + template.slice(parsed.closeEnd);
729
+ return expandIfBlocks(next);
730
+ }
201
731
  /**
202
732
  * Expands `{#each list as item}...{/each}` into `${list.map((item) => `...`).join('')}`.
203
733
  * Inner blocks are expanded first so nesting works.
@@ -206,8 +736,8 @@ function expandEachBlocks(template) {
206
736
  let t = template;
207
737
  while (t.includes(EACH_OPEN)) {
208
738
  const start = t.indexOf(EACH_OPEN);
209
- const closeHeader = t.indexOf('}', start);
210
- if (closeHeader === -1)
739
+ const closeHeader = findBlockTagExprEnd(t, start + EACH_OPEN.length);
740
+ if (closeHeader < 0)
211
741
  return t;
212
742
  const header = t.slice(start + EACH_OPEN.length, closeHeader);
213
743
  const hm = /^(.+?)\s+as\s+(\w+)\s*$/.exec(header.trim());
@@ -225,7 +755,10 @@ function expandEachBlocks(template) {
225
755
  return t;
226
756
  if (subEach !== -1 && subEach < subEnd) {
227
757
  depth++;
228
- pos = subEach + EACH_OPEN.length;
758
+ const innerHdrEnd = findBlockTagExprEnd(t, subEach + EACH_OPEN.length);
759
+ if (innerHdrEnd < 0)
760
+ return t;
761
+ pos = innerHdrEnd + 1;
229
762
  }
230
763
  else {
231
764
  depth--;
@@ -290,18 +823,33 @@ function interpolateExpressionsForSSR(s) {
290
823
  }
291
824
  return out;
292
825
  }
293
- function templateToSSR(template) {
294
- const attrSafe = transformDynamicAttributesForSSR(template);
295
- const expanded = expandEachBlocks(attrSafe);
826
+ function templateToSSR(template, actionNames) {
827
+ const attrSafe = transformDynamicAttributesForSSR(template, actionNames);
828
+ const ifExpanded = expandIfBlocks(attrSafe);
829
+ const expanded = expandEachBlocks(ifExpanded);
296
830
  return interpolateExpressionsForSSR(expanded);
297
831
  }
832
+ /** Registered server actions post to `/_nexus/action/:name` (see packages/server actions). */
833
+ const SERVER_ACTION_URL_PREFIX = '/_nexus/action/';
834
+ /**
835
+ * `action={myAction}` must not flow through SSR __ssrAttr(fn) or island String(fn) — both stringify the handler.
836
+ * Simple identifier + extracted server action name → static action URL.
837
+ */
838
+ function rewriteServerActionHtmlActionAttr(html, actionNames) {
839
+ if (!actionNames?.size)
840
+ return html;
841
+ return html.replace(/\baction\s*=\s*\{\s*([a-zA-Z_$][\w$]*)\s*\}/g, (full, name) => actionNames.has(name) ? `action="${SERVER_ACTION_URL_PREFIX}${name}"` : full);
842
+ }
298
843
  /**
299
844
  * SSR HTML must not emit unquoted `value=${nick}` (breaks tokenization) or
300
845
  * `onsubmit=${fn}` (function `toString()` injects `{}` into the document).
301
846
  * Event handlers are omitted here; the client island attaches them on hydrate.
302
847
  */
303
- function transformDynamicAttributesForSSR(html) {
848
+ function transformDynamicAttributesForSSR(html, actionNames) {
304
849
  let s = html.replace(/\s+on[a-zA-Z][a-zA-Z0-9-]*\s*=\s*\{[^}]+\}/g, '');
850
+ s = rewriteServerActionHtmlActionAttr(s, actionNames);
851
+ // Boolean attributes: omit when falsy (HTML5 — presence disables even if value is "false").
852
+ s = s.replace(/\b(disabled|checked|readonly|required|selected|multiple|autofocus|open)\s*=\s*\{\s*([a-zA-Z_$][\w$]*)\s*\}/g, (_full, name, expr) => '${' + expr + " ? ' " + name + "' : ''}");
305
853
  s = s.replace(/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*\{\s*([a-zA-Z_$][\w$.]*)\s*\}/g, (_, name, expr) => `${name}="\${__ssrAttr(${expr})}"`);
306
854
  return s;
307
855
  }