@nexus_js/compiler 0.7.2 → 0.7.4

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