@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.
- package/dist/client-security-scan.d.ts +12 -0
- package/dist/client-security-scan.d.ts.map +1 -0
- package/dist/client-security-scan.js +54 -0
- package/dist/client-security-scan.js.map +1 -0
- package/dist/client-security-scan.test.d.ts +2 -0
- package/dist/client-security-scan.test.d.ts.map +1 -0
- package/dist/client-security-scan.test.js +31 -0
- package/dist/client-security-scan.test.js.map +1 -0
- package/dist/codegen.d.ts.map +1 -1
- package/dist/codegen.js +594 -46
- package/dist/codegen.js.map +1 -1
- package/dist/compile-lib.d.ts +20 -0
- package/dist/compile-lib.d.ts.map +1 -0
- package/dist/compile-lib.js +73 -0
- package/dist/compile-lib.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/island-codegen.test.d.ts +2 -0
- package/dist/island-codegen.test.d.ts.map +1 -0
- package/dist/island-codegen.test.js +159 -0
- package/dist/island-codegen.test.js.map +1 -0
- package/dist/island-template-warnings.d.ts +8 -0
- package/dist/island-template-warnings.d.ts.map +1 -0
- package/dist/island-template-warnings.js +35 -0
- package/dist/island-template-warnings.js.map +1 -0
- package/dist/island-wrap.d.ts.map +1 -1
- package/dist/island-wrap.js +27 -9
- package/dist/island-wrap.js.map +1 -1
- package/dist/island-wrap.test.d.ts +2 -0
- package/dist/island-wrap.test.d.ts.map +1 -0
- package/dist/island-wrap.test.js +22 -0
- package/dist/island-wrap.test.js.map +1 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +11 -2
- package/dist/parser.js.map +1 -1
- package/dist/pretext-extract.d.ts +29 -0
- package/dist/pretext-extract.d.ts.map +1 -0
- package/dist/pretext-extract.js +51 -0
- package/dist/pretext-extract.js.map +1 -0
- package/dist/pretext-extract.test.d.ts +2 -0
- package/dist/pretext-extract.test.d.ts.map +1 -0
- package/dist/pretext-extract.test.js +33 -0
- package/dist/pretext-extract.test.js.map +1 -0
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -1
package/dist/codegen.js
CHANGED
|
@@ -1,26 +1,116 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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,
|
|
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 =
|
|
18
|
-
|
|
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
|
-
|
|
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('// ──
|
|
87
|
-
lines.push(rewriteDollarLibImports(parsed.frontmatter.content.trim(), opts
|
|
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, '&').replace(/\"/g, '"').replace(/</g, '<');");
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
280
|
+
lines.push(transformRunesForClientRuntime(scriptSrc));
|
|
135
281
|
lines.push('');
|
|
136
282
|
}
|
|
137
|
-
|
|
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
|
|
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('
|
|
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:
|
|
316
|
+
// Server Actions module: imports handlers from server module + registers them
|
|
150
317
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
151
|
-
|
|
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
|
-
|
|
157
|
-
lines.push(
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
348
|
+
lines.push(`registerAction(${JSON.stringify(action.name)}, ${action.name});`);
|
|
165
349
|
}
|
|
166
350
|
else {
|
|
167
|
-
lines.push(`registerAction(${JSON.stringify(action.name)},
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
210
|
-
if (closeHeader
|
|
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
|
-
|
|
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
|
|
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
|
}
|