@cfdez11/vex 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/vex.js +3 -0
- package/dist/client/services/cache.js +1 -0
- package/dist/client/services/hmr-client.js +1 -0
- package/dist/client/services/html.js +1 -0
- package/dist/client/services/hydrate-client-components.js +1 -0
- package/dist/client/services/hydrate.js +1 -0
- package/dist/client/services/index.js +1 -0
- package/dist/client/services/navigation/create-layouts.js +1 -0
- package/dist/client/services/navigation/create-navigation.js +1 -0
- package/dist/client/services/navigation/index.js +1 -0
- package/dist/client/services/navigation/link-interceptor.js +1 -0
- package/dist/client/services/navigation/metadata.js +1 -0
- package/dist/client/services/navigation/navigate.js +1 -0
- package/dist/client/services/navigation/prefetch.js +1 -0
- package/dist/client/services/navigation/render-page.js +1 -0
- package/dist/client/services/navigation/render-ssr.js +1 -0
- package/dist/client/services/navigation/router.js +1 -0
- package/dist/client/services/navigation/use-query-params.js +1 -0
- package/dist/client/services/navigation/use-route-params.js +1 -0
- package/dist/client/services/navigation.js +1 -0
- package/dist/client/services/reactive.js +1 -0
- package/dist/server/build-static.js +6 -0
- package/dist/server/index.js +4 -0
- package/dist/server/prebuild.js +1 -0
- package/dist/server/utils/cache.js +1 -0
- package/dist/server/utils/component-processor.js +68 -0
- package/dist/server/utils/data-cache.js +1 -0
- package/dist/server/utils/esbuild-plugin.js +1 -0
- package/dist/server/utils/files.js +28 -0
- package/dist/server/utils/hmr.js +1 -0
- package/dist/server/utils/router.js +11 -0
- package/dist/server/utils/streaming.js +1 -0
- package/dist/server/utils/template.js +1 -0
- package/package.json +8 -7
- package/bin/vex.js +0 -69
- package/client/favicon.ico +0 -0
- package/client/services/cache.js +0 -55
- package/client/services/hmr-client.js +0 -22
- package/client/services/html.js +0 -377
- package/client/services/hydrate-client-components.js +0 -97
- package/client/services/hydrate.js +0 -25
- package/client/services/index.js +0 -9
- package/client/services/navigation/create-layouts.js +0 -172
- package/client/services/navigation/create-navigation.js +0 -103
- package/client/services/navigation/index.js +0 -8
- package/client/services/navigation/link-interceptor.js +0 -39
- package/client/services/navigation/metadata.js +0 -23
- package/client/services/navigation/navigate.js +0 -64
- package/client/services/navigation/prefetch.js +0 -43
- package/client/services/navigation/render-page.js +0 -45
- package/client/services/navigation/render-ssr.js +0 -157
- package/client/services/navigation/router.js +0 -48
- package/client/services/navigation/use-query-params.js +0 -225
- package/client/services/navigation/use-route-params.js +0 -76
- package/client/services/navigation.js +0 -6
- package/client/services/reactive.js +0 -247
- package/server/build-static.js +0 -138
- package/server/index.js +0 -135
- package/server/prebuild.js +0 -13
- package/server/utils/cache.js +0 -89
- package/server/utils/component-processor.js +0 -1631
- package/server/utils/data-cache.js +0 -62
- package/server/utils/delay.js +0 -1
- package/server/utils/esbuild-plugin.js +0 -110
- package/server/utils/files.js +0 -845
- package/server/utils/hmr.js +0 -21
- package/server/utils/router.js +0 -375
- package/server/utils/streaming.js +0 -324
- package/server/utils/template.js +0 -274
- /package/{client → dist/client}/app.webmanifest +0 -0
- /package/{server → dist/server}/root.html +0 -0
|
@@ -1,324 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
processClientComponent,
|
|
3
|
-
renderHtmlFile,
|
|
4
|
-
} from "./component-processor.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Parses a string of raw HTML-like attributes into a structured object.
|
|
9
|
-
* Return object will be used to pass props to components through template
|
|
10
|
-
*
|
|
11
|
-
* Supports:
|
|
12
|
-
* - Dynamic props with `:` prefix (e.g., `:prop="value"`).
|
|
13
|
-
* - Event handlers with `@` prefix (e.g., `@click="handler"`).
|
|
14
|
-
* - Static attributes (e.g., `id="my-id"` or `class="my-class"`).
|
|
15
|
-
*
|
|
16
|
-
* @param {string} rawAttrs - The raw attribute string extracted from an element tag.
|
|
17
|
-
*
|
|
18
|
-
* @returns {Record<string, string>} An object mapping attribute names to their values.
|
|
19
|
-
* Dynamic props and event handlers retain their raw
|
|
20
|
-
* string representations (e.g., template expressions).
|
|
21
|
-
*
|
|
22
|
-
* @example
|
|
23
|
-
* parseAttributes(':links="${links}" @click="handleClick" id="my-component"');
|
|
24
|
-
* // Returns:
|
|
25
|
-
* // {
|
|
26
|
-
* // links: '${links}',
|
|
27
|
-
* // click: 'handleClick',
|
|
28
|
-
* // id: 'my-component'
|
|
29
|
-
* // }
|
|
30
|
-
*/
|
|
31
|
-
function decodeAttrValue(raw) {
|
|
32
|
-
const decoded = raw.replace(/"/g, '"');
|
|
33
|
-
if (decoded.startsWith("[") || decoded.startsWith("{")) {
|
|
34
|
-
try { return JSON.parse(decoded); } catch {}
|
|
35
|
-
}
|
|
36
|
-
return decoded;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function parseAttributes(rawAttrs) {
|
|
40
|
-
const attrs = {};
|
|
41
|
-
const regex =
|
|
42
|
-
/:([\w-]+)=(?:"([^"]*)"|'([^']*)')|@([\w-]+)=(?:"([^"]*)"|'([^']*)')|([\w:-]+)=(?:"([^"]*)"|'([^']*)')/g;
|
|
43
|
-
let match;
|
|
44
|
-
|
|
45
|
-
while ((match = regex.exec(rawAttrs)) !== null) {
|
|
46
|
-
if (match[1]) {
|
|
47
|
-
// Dynamic prop :prop
|
|
48
|
-
attrs[match[1]] = match[2] ?? match[3] ?? "";
|
|
49
|
-
} else if (match[4]) {
|
|
50
|
-
// Event handler @event
|
|
51
|
-
attrs[match[4]] = match[5] ?? match[6] ?? "";
|
|
52
|
-
} else if (match[7]) {
|
|
53
|
-
// Static prop — decode " and recover arrays/objects serialized by template.js
|
|
54
|
-
attrs[match[7]] = decodeAttrValue(match[8] ?? match[9] ?? "");
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return attrs;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Renders components in HTML
|
|
64
|
-
* @param {string} html
|
|
65
|
-
* @param {Map<string, { path: string }>} serverComponents
|
|
66
|
-
* @returns {Promise<string>}
|
|
67
|
-
*/
|
|
68
|
-
/**
|
|
69
|
-
* Renders server component instances in parallel.
|
|
70
|
-
*
|
|
71
|
-
* Each component type found in `serverComponents` may appear multiple times in
|
|
72
|
-
* the HTML (e.g. three `<UserCard>` tags). Previously they were rendered one by
|
|
73
|
-
* one in a serial `for` loop, even though each instance is fully independent.
|
|
74
|
-
*
|
|
75
|
-
* Now, all instances of a given component are kicked off at the same time with
|
|
76
|
-
* `Promise.all` and their results are applied in reverse-index order so that
|
|
77
|
-
* string offsets stay valid (replacing from the end of the string backwards).
|
|
78
|
-
*
|
|
79
|
-
* @param {string} html
|
|
80
|
-
* @param {Map<string, { path: string }>} serverComponents
|
|
81
|
-
* @returns {Promise<string>}
|
|
82
|
-
*/
|
|
83
|
-
async function processServerComponents(html, serverComponents) {
|
|
84
|
-
let processedHtml = html;
|
|
85
|
-
|
|
86
|
-
for (const [componentName, componentData] of serverComponents.entries()) {
|
|
87
|
-
const escapedName = componentName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
88
|
-
const componentRegex = new RegExp(
|
|
89
|
-
`<${escapedName}(?![a-zA-Z0-9_-])\\s*([^>]*?)\\s*(?:\\/>|>\\s*<\\/${escapedName}(?![a-zA-Z0-9_-])>)`,
|
|
90
|
-
"gi"
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
const replacements = [];
|
|
94
|
-
let match;
|
|
95
|
-
|
|
96
|
-
while ((match = componentRegex.exec(processedHtml)) !== null) {
|
|
97
|
-
replacements.push({
|
|
98
|
-
name: componentName,
|
|
99
|
-
attrs: parseAttributes(match[1]),
|
|
100
|
-
fullMatch: match[0],
|
|
101
|
-
start: match.index,
|
|
102
|
-
end: match.index + match[0].length,
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (replacements.length === 0) continue;
|
|
107
|
-
|
|
108
|
-
// Render all instances of this component concurrently, then apply results
|
|
109
|
-
// from the end of the string backwards so earlier offsets stay valid.
|
|
110
|
-
const rendered = await Promise.all(
|
|
111
|
-
replacements.map(({ attrs }) => renderHtmlFile(componentData.path, attrs))
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
115
|
-
const { start, end } = replacements[i];
|
|
116
|
-
processedHtml =
|
|
117
|
-
processedHtml.slice(0, start) +
|
|
118
|
-
rendered[i].html +
|
|
119
|
-
processedHtml.slice(end);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return processedHtml;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Renders components in HTML and client scripts to load them
|
|
128
|
-
* @param {string} html
|
|
129
|
-
* @param {Map<string, { path: string }>} clientComponents
|
|
130
|
-
* @returns {Promise<{
|
|
131
|
-
* html: string,
|
|
132
|
-
* allScripts: Array<string>,
|
|
133
|
-
* }>}
|
|
134
|
-
*/
|
|
135
|
-
async function renderClientComponents(html, clientComponents) {
|
|
136
|
-
let processedHtml = html;
|
|
137
|
-
const allScripts = [];
|
|
138
|
-
|
|
139
|
-
for (const [componentName, { path: componentAbsPath }] of clientComponents.entries()) {
|
|
140
|
-
const escapedName = componentName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
141
|
-
|
|
142
|
-
const componentRegex = new RegExp(
|
|
143
|
-
`<${escapedName}\\b((?:\\s+(?:[^\\s>"'=]+(?:=(?:"[^"]*"|'[^']*'|[^\\s"'=<>]+))?))*?)\\s*\\/?>`,
|
|
144
|
-
"gi"
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
const replacements = [];
|
|
148
|
-
let match;
|
|
149
|
-
const htmlToProcess = processedHtml;
|
|
150
|
-
|
|
151
|
-
while ((match = componentRegex.exec(htmlToProcess)) !== null) {
|
|
152
|
-
const matchData = {
|
|
153
|
-
name: componentName,
|
|
154
|
-
attrs: parseAttributes(match[1]),
|
|
155
|
-
fullMatch: match[0],
|
|
156
|
-
start: match.index,
|
|
157
|
-
end: match.index + match[0].length,
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
replacements.push(matchData);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Render in reverse order to maintain indices
|
|
164
|
-
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
165
|
-
const { start, end, attrs } = replacements[i];
|
|
166
|
-
|
|
167
|
-
const htmlComponent = await processClientComponent(componentName, componentAbsPath, attrs);
|
|
168
|
-
|
|
169
|
-
processedHtml =
|
|
170
|
-
processedHtml.slice(0, start) +
|
|
171
|
-
htmlComponent +
|
|
172
|
-
processedHtml.slice(end);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return { html: processedHtml, allScripts };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Renders server components, handling both regular and suspense boundaries
|
|
181
|
-
* Server components without suspense are rendered immediately.
|
|
182
|
-
* Server components inside <Suspense> boundaries are saved in suspenseComponents.
|
|
183
|
-
* @param {string} pageHtml
|
|
184
|
-
* @param {Map<string, { path: string }>} serverComponents
|
|
185
|
-
* @param {boolean} awaitSuspenseComponents - If true, renders suspense components immediately
|
|
186
|
-
* @returns {Promise<{
|
|
187
|
-
* html: string,
|
|
188
|
-
* suspenseComponents: Array<{
|
|
189
|
-
* id: string,
|
|
190
|
-
* content: string,
|
|
191
|
-
* }>
|
|
192
|
-
* }>}
|
|
193
|
-
*/
|
|
194
|
-
async function renderServerComponents(pageHtml, serverComponents = new Map(), awaitSuspenseComponents = false) {
|
|
195
|
-
const suspenseComponents = [];
|
|
196
|
-
let suspenseId = 0;
|
|
197
|
-
let html = pageHtml;
|
|
198
|
-
|
|
199
|
-
// Fresh regex per call — avoids the shared-lastIndex race condition.
|
|
200
|
-
// Each request gets its own regex instance with lastIndex starting at 0.
|
|
201
|
-
const suspenseRegex = /<Suspense\s+fallback="([^"]*)">([\s\S]*?)<\/Suspense>/g;
|
|
202
|
-
|
|
203
|
-
let match;
|
|
204
|
-
while ((match = suspenseRegex.exec(html)) !== null) {
|
|
205
|
-
const id = `suspense-${suspenseId++}`;
|
|
206
|
-
const [fullMatch, fallback, content] = match;
|
|
207
|
-
|
|
208
|
-
const suspenseContent = awaitSuspenseComponents ? content : fallback;
|
|
209
|
-
|
|
210
|
-
// Render components in fallback if not awaiting suspense or in content if awaiting suspense
|
|
211
|
-
const fallbackHtml = await processServerComponents(
|
|
212
|
-
suspenseContent,
|
|
213
|
-
serverComponents
|
|
214
|
-
);
|
|
215
|
-
|
|
216
|
-
suspenseComponents.push({
|
|
217
|
-
id,
|
|
218
|
-
content: content,
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
// Replace suspense block with container and restart the search from the
|
|
222
|
-
// beginning of the modified string (indices have shifted after the replace).
|
|
223
|
-
const replacement = `<div id="${id}">${fallbackHtml}</div>`;
|
|
224
|
-
html = html.replace(fullMatch, replacement);
|
|
225
|
-
suspenseRegex.lastIndex = 0;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Render all non-suspended components
|
|
229
|
-
html = await processServerComponents(html, serverComponents);
|
|
230
|
-
|
|
231
|
-
return { html, suspenseComponents };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Renders server components, client components in HTML, suspense components and client scripts to load client components
|
|
236
|
-
* @param {{
|
|
237
|
-
* pageHtml: string,
|
|
238
|
-
* serverComponents: Map<string, { path: string, originalPath: string, importStatement: string }>,
|
|
239
|
-
* clientComponents: Map<string, { path: string, originalPath: string, importStatement: string }>,
|
|
240
|
-
* awaitSuspenseComponents: boolean,
|
|
241
|
-
* }}
|
|
242
|
-
* @returns {Promise<{
|
|
243
|
-
* html: string,
|
|
244
|
-
* clientComponentsScripts: Array<string>,
|
|
245
|
-
* suspenseComponents: Array<{
|
|
246
|
-
* id: string,
|
|
247
|
-
* content: string,
|
|
248
|
-
* }>
|
|
249
|
-
* }>}
|
|
250
|
-
*/
|
|
251
|
-
export async function renderComponents({
|
|
252
|
-
html,
|
|
253
|
-
serverComponents = new Map(),
|
|
254
|
-
clientComponents = new Map(),
|
|
255
|
-
awaitSuspenseComponents = false,
|
|
256
|
-
}) {
|
|
257
|
-
const hasServerComponents = serverComponents.size > 0;
|
|
258
|
-
const hasClientComponents = clientComponents.size > 0;
|
|
259
|
-
|
|
260
|
-
const { html: htmlServerComponents, suspenseComponents } = hasServerComponents ?
|
|
261
|
-
await renderServerComponents(html, serverComponents, awaitSuspenseComponents) :
|
|
262
|
-
{
|
|
263
|
-
html,
|
|
264
|
-
suspenseComponents: [],
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
const { html: htmlClientComponents, allScripts: clientComponentsScripts } =
|
|
268
|
-
hasClientComponents ?
|
|
269
|
-
await renderClientComponents(htmlServerComponents, clientComponents) :
|
|
270
|
-
{
|
|
271
|
-
html: htmlServerComponents,
|
|
272
|
-
allScripts: [],
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
return {
|
|
276
|
-
html: htmlClientComponents,
|
|
277
|
-
suspenseComponents,
|
|
278
|
-
clientComponentsScripts,
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Generates the streaming HTML payload that replaces a Suspense fallback with
|
|
284
|
-
* the real rendered content once it is ready.
|
|
285
|
-
*
|
|
286
|
-
* The payload consists of two parts streamed back-to-back:
|
|
287
|
-
* 1. A `<template id="…">` holding the rendered HTML (invisible to the user).
|
|
288
|
-
* 2. A tiny inline `<script>` that calls `window.hydrateTarget(targetId, sourceId)`.
|
|
289
|
-
*
|
|
290
|
-
* `window.hydrateTarget` is defined once in root.html via a single
|
|
291
|
-
* `<script src="hydrate.js">`. Using an inline call instead of a per-boundary
|
|
292
|
-
* `<script src="hydrate.js">` avoids the browser parsing and initialising the
|
|
293
|
-
* same script N times.
|
|
294
|
-
*
|
|
295
|
-
* @param {string} suspenseId - The id of the fallback <div> to replace.
|
|
296
|
-
* @param {string} renderedContent - The real HTML to swap in.
|
|
297
|
-
* @returns {string}
|
|
298
|
-
*/
|
|
299
|
-
export function generateReplacementContent(suspenseId, renderedContent) {
|
|
300
|
-
const contentId = `${suspenseId}-content`;
|
|
301
|
-
return `<template id="${contentId}">${renderedContent}</template><script>window.hydrateTarget("${suspenseId}","${contentId}")</script>`;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Renders all components inside a suspense boundary
|
|
306
|
-
* @param {{
|
|
307
|
-
* id: string,
|
|
308
|
-
* content: string,
|
|
309
|
-
* components: Array<{name: string, attrs: object, fullMatch: string}>
|
|
310
|
-
* }} suspenseComponent
|
|
311
|
-
* @param {Map<string, { path: string }>} serverComponents
|
|
312
|
-
* @returns {Promise<string>}
|
|
313
|
-
*/
|
|
314
|
-
export async function renderSuspenseComponent(
|
|
315
|
-
suspenseComponent,
|
|
316
|
-
serverComponents
|
|
317
|
-
) {
|
|
318
|
-
const html = await processServerComponents(
|
|
319
|
-
suspenseComponent.content,
|
|
320
|
-
serverComponents
|
|
321
|
-
);
|
|
322
|
-
|
|
323
|
-
return html;
|
|
324
|
-
}
|
package/server/utils/template.js
DELETED
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
import { parseDocument, DomUtils } from "htmlparser2";
|
|
2
|
-
import { render } from "dom-serializer";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Compiled-function cache.
|
|
6
|
-
*
|
|
7
|
-
* `Function(...keys, body)` compiles a new function object on every call.
|
|
8
|
-
* With ~20 template expressions and 100 req/s that is ~2000 allocations/s
|
|
9
|
-
* that the GC has to reclaim. The compiled function only depends on the
|
|
10
|
-
* expression string and the names of the scope keys — not their values —
|
|
11
|
-
* so it can be reused across requests by calling it with different arguments.
|
|
12
|
-
*
|
|
13
|
-
* Key format: `"<expression>::<key1>,<key2>,..."` — must include both the
|
|
14
|
-
* expression and the key names because the same expression with a different
|
|
15
|
-
* scope shape produces a different function signature.
|
|
16
|
-
*/
|
|
17
|
-
const fnCache = new Map();
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Evaluates a template expression against the provided data scope.
|
|
21
|
-
*
|
|
22
|
-
* The compiled `Function` is cached by expression + scope key names so it is
|
|
23
|
-
* only created once per unique (expression, scope shape) pair.
|
|
24
|
-
*
|
|
25
|
-
* @param {string} expression
|
|
26
|
-
* @param {object} scope
|
|
27
|
-
* @returns {any}
|
|
28
|
-
*/
|
|
29
|
-
function getDataValue(expression, scope) {
|
|
30
|
-
const keys = Object.keys(scope);
|
|
31
|
-
const cacheKey = `${expression}::${keys.join(",")}`;
|
|
32
|
-
if (!fnCache.has(cacheKey)) {
|
|
33
|
-
fnCache.set(cacheKey, Function(...keys, `return (${expression})`));
|
|
34
|
-
}
|
|
35
|
-
try {
|
|
36
|
-
return fnCache.get(cacheKey)(...Object.values(scope));
|
|
37
|
-
} catch (e) {
|
|
38
|
-
return "";
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Checks if a DOM node is an empty text node
|
|
44
|
-
* @param {ChildNode} node
|
|
45
|
-
* @returns {boolean}
|
|
46
|
-
*/
|
|
47
|
-
function isEmptyTextNode(node) {
|
|
48
|
-
return node.type === "text" && /^\s*$/.test(node.data);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Parses HTML string and returns DOM nodes
|
|
53
|
-
* @param {string} html
|
|
54
|
-
* @returns {ChildNode[]}
|
|
55
|
-
*/
|
|
56
|
-
function parseHTMLToNodes(html) {
|
|
57
|
-
try {
|
|
58
|
-
const cleanHtml = html
|
|
59
|
-
.replace(/[\r\n\t]+/g, " ")
|
|
60
|
-
.replace(/ +/g, " ")
|
|
61
|
-
.trim();
|
|
62
|
-
const dom = parseDocument(cleanHtml, { xmlMode: true });
|
|
63
|
-
return DomUtils.getChildren(dom);
|
|
64
|
-
} catch (error) {
|
|
65
|
-
console.error('Error parsing HTML:', error);
|
|
66
|
-
return [];
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Processes an HTML file to extract script, template, metadata, client code, and component registry
|
|
73
|
-
* @param {ChildNode} node
|
|
74
|
-
* @param {Object} scope
|
|
75
|
-
* @param {boolean} previousRendered
|
|
76
|
-
* @returns {ChildNode | ChildNode[] | null}
|
|
77
|
-
*/
|
|
78
|
-
function processNode(node, scope, previousRendered = false) {
|
|
79
|
-
if (node.type === "text") {
|
|
80
|
-
// Replace {{expr}} with its value from scope (SSR interpolation).
|
|
81
|
-
// The lookbehind (?<!\\) skips escaped \{{expr}}, which are then
|
|
82
|
-
// unescaped to literal {{expr}} by the second replace.
|
|
83
|
-
node.data = node.data
|
|
84
|
-
.replace(/(?<!\\)\{\{(.+?)\}\}/g, (_, expr) => getDataValue(expr.trim(), scope))
|
|
85
|
-
.replace(/\\\{\{/g, "{{");
|
|
86
|
-
return node;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (node.type === "tag") {
|
|
90
|
-
const attrs = node.attribs || {};
|
|
91
|
-
|
|
92
|
-
for (const [attrName, attrValue] of Object.entries(attrs)) {
|
|
93
|
-
if (typeof attrValue === "string") {
|
|
94
|
-
attrs[attrName] = attrValue
|
|
95
|
-
.replace(/(?<!\\)\{\{(.+?)\}\}/g, (_, expr) => getDataValue(expr.trim(), scope))
|
|
96
|
-
.replace(/\\\{\{/g, "{{");
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if ("x-if" in attrs) {
|
|
101
|
-
const show = getDataValue(attrs["x-if"], scope);
|
|
102
|
-
delete attrs["x-if"];
|
|
103
|
-
if (!show) return null;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if ("x-else-if" in attrs) {
|
|
107
|
-
const show = getDataValue(attrs["x-else-if"], scope);
|
|
108
|
-
delete attrs["x-else-if"];
|
|
109
|
-
if (previousRendered || !show) return null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if ("x-else" in attrs) {
|
|
113
|
-
delete attrs["x-else"];
|
|
114
|
-
if (previousRendered) {
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if ("x-show" in attrs) {
|
|
120
|
-
const show = getDataValue(attrs["x-show"], scope);
|
|
121
|
-
delete attrs["x-show"];
|
|
122
|
-
if (!show) {
|
|
123
|
-
attrs.style = (attrs.style || "") + "display:none;";
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if ("x-for" in attrs) {
|
|
128
|
-
const exp = attrs["x-for"];
|
|
129
|
-
delete attrs["x-for"];
|
|
130
|
-
|
|
131
|
-
// format: item in items
|
|
132
|
-
const match = exp.match(/(.+?)\s+in\s+(.+)/);
|
|
133
|
-
if (!match) throw new Error("Invalid x-for format: " + exp);
|
|
134
|
-
|
|
135
|
-
const itemName = match[1].trim();
|
|
136
|
-
const listExpr = match[2].trim();
|
|
137
|
-
const list = getDataValue(listExpr, scope);
|
|
138
|
-
|
|
139
|
-
if (!Array.isArray(list)) return null;
|
|
140
|
-
|
|
141
|
-
const clones = [];
|
|
142
|
-
|
|
143
|
-
for (const item of list) {
|
|
144
|
-
const cloned = structuredClone(node);
|
|
145
|
-
const newScope = { ...scope, [itemName]: item };
|
|
146
|
-
clones.push(processNode(cloned, newScope));
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return clones;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
for (const [name, value] of Object.entries({ ...attrs })) {
|
|
153
|
-
if (name.startsWith(":")) {
|
|
154
|
-
const isSuspenseFallback =
|
|
155
|
-
name === ":fallback" && node.name === "Suspense";
|
|
156
|
-
const realName = name.slice(1);
|
|
157
|
-
if (!isSuspenseFallback) {
|
|
158
|
-
const val = getDataValue(value, scope);
|
|
159
|
-
attrs[realName] = (val !== null && val !== undefined && typeof val === "object")
|
|
160
|
-
? JSON.stringify(val)
|
|
161
|
-
: String(val ?? "");
|
|
162
|
-
} else {
|
|
163
|
-
attrs[realName] = value;
|
|
164
|
-
}
|
|
165
|
-
delete attrs[name];
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (name.startsWith("x-bind:")) {
|
|
169
|
-
const realName = name.slice(7);
|
|
170
|
-
const val = getDataValue(value, scope);
|
|
171
|
-
attrs[realName] = (val !== null && val !== undefined && typeof val === "object")
|
|
172
|
-
? JSON.stringify(val)
|
|
173
|
-
: String(val ?? "");
|
|
174
|
-
delete attrs[name];
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
for (const [name] of Object.entries({ ...attrs })) {
|
|
179
|
-
if (name.startsWith("@") || name.startsWith("x-on:")) {
|
|
180
|
-
delete attrs[name];
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (node.children) {
|
|
185
|
-
const result = [];
|
|
186
|
-
let isPreviousRendered = false;
|
|
187
|
-
for (const child of node.children) {
|
|
188
|
-
if (isEmptyTextNode(child)) {
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
191
|
-
const processed = processNode(child, scope, isPreviousRendered);
|
|
192
|
-
if (Array.isArray(processed)) {
|
|
193
|
-
result.push(...processed);
|
|
194
|
-
isPreviousRendered = processed.length > 0;
|
|
195
|
-
} else if (processed) {
|
|
196
|
-
result.push(processed);
|
|
197
|
-
isPreviousRendered = true;
|
|
198
|
-
} else {
|
|
199
|
-
isPreviousRendered = false;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
node.children = result;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return node;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
return node;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Renders HTML template content with provided data
|
|
213
|
-
* @param {string} templateContent
|
|
214
|
-
* @param {{
|
|
215
|
-
* [name: string]: string,
|
|
216
|
-
* clientScripts?: string[],
|
|
217
|
-
* metadata?: {
|
|
218
|
-
* title?: string,
|
|
219
|
-
* description?: string,
|
|
220
|
-
* }
|
|
221
|
-
* }} data
|
|
222
|
-
* @returns {string}
|
|
223
|
-
*
|
|
224
|
-
*/
|
|
225
|
-
/**
|
|
226
|
-
* Parsed-template cache (PERF-05).
|
|
227
|
-
*
|
|
228
|
-
* `parseHTMLToNodes` runs `parseDocument` (htmlparser2) on every call, which
|
|
229
|
-
* tokenises and builds a full DOM tree from the template string. The template
|
|
230
|
-
* string is constant between requests — only `data` changes — so the resulting
|
|
231
|
-
* tree can be cached and deep-cloned before each processing pass.
|
|
232
|
-
*
|
|
233
|
-
* `structuredClone` is used to deep-clone the cached nodes. htmlparser2 nodes
|
|
234
|
-
* are plain objects (no functions / Symbols), so structuredClone handles them
|
|
235
|
-
* correctly including the circular parent ↔ children references.
|
|
236
|
-
*
|
|
237
|
-
* Key: raw template string
|
|
238
|
-
* Value: array of parsed DOM nodes (never mutated — always clone before use)
|
|
239
|
-
*/
|
|
240
|
-
const parsedTemplateCache = new Map();
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Compiles a Vue-like HTML template string into a rendered HTML string.
|
|
244
|
-
*
|
|
245
|
-
* Parsing is performed only on the first call for a given template string.
|
|
246
|
-
* Subsequent calls clone the cached node tree and process the clone directly,
|
|
247
|
-
* avoiding repeated `parseDocument` invocations.
|
|
248
|
-
*
|
|
249
|
-
* @param {string} template
|
|
250
|
-
* @param {{
|
|
251
|
-
* [name: string]: string,
|
|
252
|
-
* clientScripts?: string[],
|
|
253
|
-
* metadata?: { title?: string, description?: string }
|
|
254
|
-
* }} data
|
|
255
|
-
* @returns {string}
|
|
256
|
-
*/
|
|
257
|
-
export function compileTemplateToHTML(template, data = {}) {
|
|
258
|
-
try {
|
|
259
|
-
if (!parsedTemplateCache.has(template)) {
|
|
260
|
-
parsedTemplateCache.set(template, parseHTMLToNodes(template));
|
|
261
|
-
}
|
|
262
|
-
// Clone before processing — processNode mutates the nodes in place
|
|
263
|
-
const nodes = structuredClone(parsedTemplateCache.get(template));
|
|
264
|
-
const processed = nodes
|
|
265
|
-
.map((n) => processNode(n, data))
|
|
266
|
-
.flat()
|
|
267
|
-
.filter(Boolean);
|
|
268
|
-
|
|
269
|
-
return render(processed, { encodeEntities: false });
|
|
270
|
-
} catch (error) {
|
|
271
|
-
console.error("Error compiling template:", error);
|
|
272
|
-
throw error;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
File without changes
|
|
File without changes
|