@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.
- 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 +534 -37
- package/dist/codegen.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -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 +1 -1
package/dist/codegen.js
CHANGED
|
@@ -1,26 +1,88 @@
|
|
|
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
|
+
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,
|
|
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 =
|
|
18
|
-
|
|
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
|
-
|
|
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('// ──
|
|
87
|
-
lines.push(rewriteDollarLibImports(parsed.frontmatter.content.trim(), opts
|
|
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, '&').replace(/\"/g, '"').replace(/</g, '<');");
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
235
|
+
lines.push(transformRunesForClientRuntime(scriptSrc));
|
|
135
236
|
lines.push('');
|
|
136
237
|
}
|
|
137
|
-
|
|
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
|
|
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('
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
210
|
-
if (closeHeader
|
|
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
|
-
|
|
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
|
|
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
|
}
|