@oomfware/jsx 0.1.1
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/LICENSE +14 -0
- package/README.md +164 -0
- package/dist/index.d.mts +77 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +408 -0
- package/dist/index.mjs.map +1 -0
- package/dist/jsx-runtime-BQHdv_66.d.mts +1311 -0
- package/dist/jsx-runtime-BQHdv_66.d.mts.map +1 -0
- package/dist/jsx-runtime.d.mts +2 -0
- package/dist/jsx-runtime.mjs +34 -0
- package/dist/jsx-runtime.mjs.map +1 -0
- package/package.json +46 -0
- package/src/index.ts +9 -0
- package/src/jsx-runtime.ts +33 -0
- package/src/lib/context.ts +98 -0
- package/src/lib/intrinsic-elements.ts +1592 -0
- package/src/lib/render.ts +504 -0
- package/src/lib/stream.test.tsx +625 -0
- package/src/lib/suspense.ts +57 -0
- package/src/lib/types.ts +37 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* streaming JSX renderer
|
|
3
|
+
*
|
|
4
|
+
* architecture:
|
|
5
|
+
* - segment tree: we build a tree of segments (static text, composites, suspense
|
|
6
|
+
* boundaries) then serialize to HTML
|
|
7
|
+
* - suspense: components can throw promises via use(), caught at Suspense boundaries
|
|
8
|
+
* which render fallback immediately and stream resolved content later
|
|
9
|
+
* - head hoisting: <title>, <meta>, <link>, <style> found outside <head> are
|
|
10
|
+
* collected and injected into <head> during finalization
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Fragment } from '../jsx-runtime.ts';
|
|
14
|
+
|
|
15
|
+
import { currentFrame, popContextFrame, pushContextFrame } from './context.ts';
|
|
16
|
+
import { Suspense, type SuspenseProps } from './suspense.ts';
|
|
17
|
+
import type { Component, JSXElement, JSXNode } from './types.ts';
|
|
18
|
+
|
|
19
|
+
const HEAD_ELEMENTS = new Set(['title', 'meta', 'link', 'style']);
|
|
20
|
+
const SELF_CLOSING_TAGS = new Set([
|
|
21
|
+
'area',
|
|
22
|
+
'base',
|
|
23
|
+
'br',
|
|
24
|
+
'col',
|
|
25
|
+
'embed',
|
|
26
|
+
'hr',
|
|
27
|
+
'img',
|
|
28
|
+
'input',
|
|
29
|
+
'link',
|
|
30
|
+
'meta',
|
|
31
|
+
'param',
|
|
32
|
+
'source',
|
|
33
|
+
'track',
|
|
34
|
+
'wbr',
|
|
35
|
+
]);
|
|
36
|
+
/** props that shouldn't be rendered as HTML attributes */
|
|
37
|
+
const FRAMEWORK_PROPS = new Set(['children', 'dangerouslySetInnerHTML']);
|
|
38
|
+
|
|
39
|
+
// #region Segment types
|
|
40
|
+
|
|
41
|
+
type Segment =
|
|
42
|
+
| {
|
|
43
|
+
readonly kind: 'static';
|
|
44
|
+
readonly html: string;
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
readonly kind: 'composite';
|
|
48
|
+
readonly parts: readonly Segment[];
|
|
49
|
+
}
|
|
50
|
+
| {
|
|
51
|
+
readonly kind: 'suspense';
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly fallback: Segment;
|
|
54
|
+
pending?: Promise<void>;
|
|
55
|
+
content: Segment | null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function staticSeg(html: string): Segment {
|
|
59
|
+
return { kind: 'static', html };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function compositeSeg(parts: Segment[]): Segment {
|
|
63
|
+
return { kind: 'composite', parts };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const EMPTY_SEGMENT = staticSeg('');
|
|
67
|
+
|
|
68
|
+
// #endregion
|
|
69
|
+
|
|
70
|
+
export interface RenderOptions {
|
|
71
|
+
onError?: (error: unknown) => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface RenderContext {
|
|
75
|
+
headElements: string[];
|
|
76
|
+
idsByPath: Map<string, number>;
|
|
77
|
+
insideHead: boolean;
|
|
78
|
+
insideSvg: boolean;
|
|
79
|
+
onError: (error: unknown) => void;
|
|
80
|
+
pendingSuspense: Array<{ id: string; promise: Promise<Segment> }>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* renders JSX to a readable stream
|
|
85
|
+
* @param node JSX node to render
|
|
86
|
+
* @param options render options
|
|
87
|
+
* @returns readable stream of UTF-8 encoded HTML
|
|
88
|
+
*/
|
|
89
|
+
export function renderToStream(node: JSXNode, options?: RenderOptions): ReadableStream<Uint8Array> {
|
|
90
|
+
const encoder = new TextEncoder();
|
|
91
|
+
const onError = options?.onError ?? ((error) => console.error(error));
|
|
92
|
+
const context: RenderContext = {
|
|
93
|
+
headElements: [],
|
|
94
|
+
idsByPath: new Map(),
|
|
95
|
+
insideHead: false,
|
|
96
|
+
insideSvg: false,
|
|
97
|
+
onError,
|
|
98
|
+
pendingSuspense: [],
|
|
99
|
+
};
|
|
100
|
+
return new ReadableStream({
|
|
101
|
+
async start(controller) {
|
|
102
|
+
try {
|
|
103
|
+
const root = buildSegment(node, context, '');
|
|
104
|
+
await resolveBlocking(root);
|
|
105
|
+
const html = serializeSegment(root);
|
|
106
|
+
const finalHtml = finalizeHtml(html, context);
|
|
107
|
+
controller.enqueue(encoder.encode(finalHtml));
|
|
108
|
+
// stream pending suspense boundaries as they resolve
|
|
109
|
+
if (context.pendingSuspense.length > 0) {
|
|
110
|
+
await streamPendingSuspense(context, controller, encoder);
|
|
111
|
+
}
|
|
112
|
+
controller.close();
|
|
113
|
+
} catch (error) {
|
|
114
|
+
onError(error);
|
|
115
|
+
controller.error(error);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* renders JSX to a string (non-streaming)
|
|
123
|
+
* @param node JSX node to render
|
|
124
|
+
* @param options render options
|
|
125
|
+
* @returns promise resolving to HTML string
|
|
126
|
+
*/
|
|
127
|
+
export async function renderToString(node: JSXNode, options?: RenderOptions): Promise<string> {
|
|
128
|
+
const stream = renderToStream(node, options);
|
|
129
|
+
const reader = stream.getReader();
|
|
130
|
+
const decoder = new TextDecoder();
|
|
131
|
+
let html = '';
|
|
132
|
+
while (true) {
|
|
133
|
+
const { done, value } = await reader.read();
|
|
134
|
+
if (done) break;
|
|
135
|
+
html += decoder.decode(value);
|
|
136
|
+
}
|
|
137
|
+
return html;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* renders JSX to a streaming Response
|
|
142
|
+
* @param node JSX node to render
|
|
143
|
+
* @param init optional ResponseInit (status, headers, etc.)
|
|
144
|
+
* @returns Response with streaming HTML body
|
|
145
|
+
*/
|
|
146
|
+
export function render(node: JSXNode, init?: ResponseInit): Response {
|
|
147
|
+
const stream = renderToStream(node);
|
|
148
|
+
|
|
149
|
+
// @ts-expect-error: not sure why.
|
|
150
|
+
const headers = new Headers(init?.headers);
|
|
151
|
+
if (!headers.has('Content-Type')) {
|
|
152
|
+
headers.set('Content-Type', 'text/html; charset=utf-8');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return new Response(stream, { ...init, headers });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// #region Segment building
|
|
159
|
+
|
|
160
|
+
function isJSXElement(node: unknown): node is JSXElement {
|
|
161
|
+
return typeof node === 'object' && node !== null && 'type' in node && 'props' in node;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isHeadElement(tag: string): boolean {
|
|
165
|
+
return HEAD_ELEMENTS.has(tag);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function buildSegment(node: JSXNode, context: RenderContext, path: string): Segment {
|
|
169
|
+
// primitives
|
|
170
|
+
if (typeof node === 'string' || typeof node === 'number' || typeof node === 'bigint') {
|
|
171
|
+
return staticSeg(escapeHtml(node, false));
|
|
172
|
+
}
|
|
173
|
+
if (node === null || node === undefined || typeof node === 'boolean') {
|
|
174
|
+
return EMPTY_SEGMENT;
|
|
175
|
+
}
|
|
176
|
+
// iterables (arrays, generators, etc.)
|
|
177
|
+
if (typeof node === 'object' && Symbol.iterator in node) {
|
|
178
|
+
const parts: Segment[] = [];
|
|
179
|
+
for (const child of node) {
|
|
180
|
+
parts.push(buildSegment(child, context, path));
|
|
181
|
+
}
|
|
182
|
+
return compositeSeg(parts);
|
|
183
|
+
}
|
|
184
|
+
// JSX elements
|
|
185
|
+
if (isJSXElement(node)) {
|
|
186
|
+
const { type, props } = node;
|
|
187
|
+
// Fragment
|
|
188
|
+
if (type === Fragment) {
|
|
189
|
+
const children = (props as { children?: JSXNode }).children;
|
|
190
|
+
return children != null ? buildSegment(children, context, path) : EMPTY_SEGMENT;
|
|
191
|
+
}
|
|
192
|
+
// intrinsic elements (HTML tags)
|
|
193
|
+
if (typeof type === 'string') {
|
|
194
|
+
const tag = type;
|
|
195
|
+
|
|
196
|
+
if (tag === 'head') {
|
|
197
|
+
return buildHeadElementSegment(tag, props as Record<string, unknown>, context, path);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!context.insideHead && isHeadElement(tag)) {
|
|
201
|
+
// hoist to <head>
|
|
202
|
+
const elementSeg = buildElementSegment(tag, props as Record<string, unknown>, context, path);
|
|
203
|
+
context.headElements.push(serializeSegment(elementSeg));
|
|
204
|
+
return EMPTY_SEGMENT;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return buildElementSegment(tag, props as Record<string, unknown>, context, path);
|
|
208
|
+
}
|
|
209
|
+
// function components
|
|
210
|
+
if (typeof type === 'function') {
|
|
211
|
+
// Suspense boundary
|
|
212
|
+
if (type === Suspense) {
|
|
213
|
+
return buildSuspenseSegment(props as unknown as SuspenseProps, context, path);
|
|
214
|
+
}
|
|
215
|
+
return buildComponentSegment(type, props as Record<string, unknown>, context, path);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return EMPTY_SEGMENT;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// #endregion
|
|
222
|
+
|
|
223
|
+
// #region Element building
|
|
224
|
+
|
|
225
|
+
function buildElementSegment(
|
|
226
|
+
tag: string,
|
|
227
|
+
props: Record<string, unknown>,
|
|
228
|
+
context: RenderContext,
|
|
229
|
+
path: string,
|
|
230
|
+
): Segment {
|
|
231
|
+
const currentIsSvg = context.insideSvg || tag === 'svg';
|
|
232
|
+
const attrs = renderAttributes(props);
|
|
233
|
+
// self-closing tags
|
|
234
|
+
if (SELF_CLOSING_TAGS.has(tag)) {
|
|
235
|
+
return staticSeg(`<${tag}${attrs}>`);
|
|
236
|
+
}
|
|
237
|
+
// dangerouslySetInnerHTML
|
|
238
|
+
const innerHTML = props.dangerouslySetInnerHTML as { __html: string } | undefined;
|
|
239
|
+
if (innerHTML) {
|
|
240
|
+
return staticSeg(`<${tag}${attrs}>${innerHTML.__html}</${tag}>`);
|
|
241
|
+
}
|
|
242
|
+
// normal element with children
|
|
243
|
+
const open = staticSeg(`<${tag}${attrs}>`);
|
|
244
|
+
const previousInsideSvg = context.insideSvg;
|
|
245
|
+
context.insideSvg = tag === 'foreignObject' ? false : currentIsSvg;
|
|
246
|
+
const children =
|
|
247
|
+
props.children != null ? buildSegment(props.children as JSXNode, context, path) : EMPTY_SEGMENT;
|
|
248
|
+
context.insideSvg = previousInsideSvg;
|
|
249
|
+
const close = staticSeg(`</${tag}>`);
|
|
250
|
+
return compositeSeg([open, children, close]);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function buildHeadElementSegment(
|
|
254
|
+
tag: string,
|
|
255
|
+
props: Record<string, unknown>,
|
|
256
|
+
context: RenderContext,
|
|
257
|
+
path: string,
|
|
258
|
+
): Segment {
|
|
259
|
+
const attrs = renderAttributes(props);
|
|
260
|
+
const previousInsideHead = context.insideHead;
|
|
261
|
+
context.insideHead = true;
|
|
262
|
+
const open = staticSeg(`<${tag}${attrs}>`);
|
|
263
|
+
const children =
|
|
264
|
+
props.children != null ? buildSegment(props.children as JSXNode, context, path) : EMPTY_SEGMENT;
|
|
265
|
+
context.insideHead = previousInsideHead;
|
|
266
|
+
const close = staticSeg(`</${tag}>`);
|
|
267
|
+
return compositeSeg([open, children, close]);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function renderAttributes(props: Record<string, unknown>): string {
|
|
271
|
+
let attrs = '';
|
|
272
|
+
for (const key in props) {
|
|
273
|
+
if (FRAMEWORK_PROPS.has(key)) continue;
|
|
274
|
+
const value = props[key];
|
|
275
|
+
if (value === undefined || value === null || value === false) continue;
|
|
276
|
+
if (typeof value === 'function') continue;
|
|
277
|
+
// style object -> string
|
|
278
|
+
if (key === 'style' && typeof value === 'object') {
|
|
279
|
+
const styleStr = serializeStyle(value as Record<string, string | number>);
|
|
280
|
+
if (styleStr) {
|
|
281
|
+
attrs += ` style="${escapeHtml(styleStr, true)}"`;
|
|
282
|
+
}
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (value === true) {
|
|
286
|
+
attrs += ` ${key}`;
|
|
287
|
+
} else {
|
|
288
|
+
attrs += ` ${key}="${escapeHtml(value, true)}"`;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return attrs;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function serializeStyle(style: Record<string, string | number>): string {
|
|
295
|
+
const parts: string[] = [];
|
|
296
|
+
for (const [key, value] of Object.entries(style)) {
|
|
297
|
+
if (value == null) continue;
|
|
298
|
+
parts.push(`${key}:${value}`);
|
|
299
|
+
}
|
|
300
|
+
return parts.join(';');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// #endregion
|
|
304
|
+
|
|
305
|
+
// #region Component building
|
|
306
|
+
|
|
307
|
+
function buildComponentSegment(
|
|
308
|
+
type: Component,
|
|
309
|
+
props: Record<string, unknown>,
|
|
310
|
+
ctx: RenderContext,
|
|
311
|
+
path: string,
|
|
312
|
+
): Segment {
|
|
313
|
+
// call component
|
|
314
|
+
const result = type(props);
|
|
315
|
+
// if component called provide(), push frame before rendering children
|
|
316
|
+
const hadFrame = currentFrame !== null;
|
|
317
|
+
pushContextFrame();
|
|
318
|
+
try {
|
|
319
|
+
return buildSegment(result, ctx, path);
|
|
320
|
+
} finally {
|
|
321
|
+
popContextFrame(hadFrame);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function buildSuspenseSegment(props: SuspenseProps, ctx: RenderContext, path: string): Segment {
|
|
326
|
+
// generate unique id for this suspense boundary
|
|
327
|
+
const nextIndex = (ctx.idsByPath.get(path) ?? 0) + 1;
|
|
328
|
+
ctx.idsByPath.set(path, nextIndex);
|
|
329
|
+
const id = path ? `${path}-${nextIndex}` : `${nextIndex}`;
|
|
330
|
+
const suspenseId = `s${id}`;
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
// try to render children synchronously
|
|
334
|
+
const content = buildSegment(props.children, ctx, suspenseId);
|
|
335
|
+
// no suspension - return content directly (no boundary needed)
|
|
336
|
+
return content;
|
|
337
|
+
} catch (thrown) {
|
|
338
|
+
// check if it's a promise (suspension)
|
|
339
|
+
if (thrown instanceof Promise) {
|
|
340
|
+
// render fallback
|
|
341
|
+
const fallback = buildSegment(props.fallback, ctx, suspenseId);
|
|
342
|
+
|
|
343
|
+
// create suspense segment
|
|
344
|
+
const seg: Segment = {
|
|
345
|
+
kind: 'suspense',
|
|
346
|
+
id: suspenseId,
|
|
347
|
+
fallback,
|
|
348
|
+
content: null,
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// set up promise to re-render children when resolved
|
|
352
|
+
const pending = thrown.then(() => {
|
|
353
|
+
// re-render children after promise resolves
|
|
354
|
+
seg.content = buildSegment(props.children, ctx, suspenseId);
|
|
355
|
+
});
|
|
356
|
+
seg.pending = pending;
|
|
357
|
+
|
|
358
|
+
// track for streaming
|
|
359
|
+
ctx.pendingSuspense.push({
|
|
360
|
+
id: suspenseId,
|
|
361
|
+
promise: pending.then(() => seg.content!),
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
return seg;
|
|
365
|
+
}
|
|
366
|
+
// not a promise - re-throw
|
|
367
|
+
throw thrown;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// #endregion
|
|
372
|
+
|
|
373
|
+
// #region Serialization
|
|
374
|
+
|
|
375
|
+
/** resolve all blocking suspense boundaries */
|
|
376
|
+
async function resolveBlocking(segment: Segment): Promise<void> {
|
|
377
|
+
if (segment.kind === 'suspense') {
|
|
378
|
+
if (segment.pending) {
|
|
379
|
+
await segment.pending;
|
|
380
|
+
segment.pending = undefined;
|
|
381
|
+
}
|
|
382
|
+
if (segment.content) {
|
|
383
|
+
await resolveBlocking(segment.content);
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (segment.kind === 'composite') {
|
|
388
|
+
for (const part of segment.parts) {
|
|
389
|
+
await resolveBlocking(part);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** serialize segment tree to HTML string */
|
|
395
|
+
function serializeSegment(seg: Segment): string {
|
|
396
|
+
if (seg.kind === 'static') {
|
|
397
|
+
return seg.html;
|
|
398
|
+
}
|
|
399
|
+
if (seg.kind === 'composite') {
|
|
400
|
+
return seg.parts.map(serializeSegment).join('');
|
|
401
|
+
}
|
|
402
|
+
// suspense - always render fallback; resolved content streams in template
|
|
403
|
+
return `<!--$s:${seg.id}-->${serializeSegment(seg.fallback)}<!--/$s:${seg.id}-->`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// #endregion
|
|
407
|
+
|
|
408
|
+
// #region Streaming
|
|
409
|
+
|
|
410
|
+
/** suspense runtime function name */
|
|
411
|
+
const SR = '$sr';
|
|
412
|
+
/** suspense runtime - injected once, swaps template content with fallback */
|
|
413
|
+
const SUSPENSE_RUNTIME = `<script>${SR}=(t,i,s,e)=>{i="$s:"+t.dataset.suspense;s=document.createTreeWalker(document,128);while(e=s.nextNode())if(e.data===i){while(e.nextSibling?.data!=="/"+i)e.nextSibling.remove();e.nextSibling.replaceWith(...t.content.childNodes);e.remove();break}t.remove()}</script>`;
|
|
414
|
+
|
|
415
|
+
async function streamPendingSuspense(
|
|
416
|
+
context: RenderContext,
|
|
417
|
+
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
418
|
+
encoder: TextEncoder,
|
|
419
|
+
): Promise<void> {
|
|
420
|
+
let runtimeInjected = false;
|
|
421
|
+
const processed = new Set<string>();
|
|
422
|
+
while (true) {
|
|
423
|
+
const batch = context.pendingSuspense.filter(({ id }) => !processed.has(id));
|
|
424
|
+
if (batch.length === 0) break;
|
|
425
|
+
await Promise.all(
|
|
426
|
+
batch.map(async ({ id, promise }) => {
|
|
427
|
+
processed.add(id);
|
|
428
|
+
try {
|
|
429
|
+
const resolvedSegment = await promise;
|
|
430
|
+
await resolveBlocking(resolvedSegment);
|
|
431
|
+
const html = serializeSegment(resolvedSegment);
|
|
432
|
+
// inject runtime once before first resolution
|
|
433
|
+
const runtime = runtimeInjected ? '' : SUSPENSE_RUNTIME;
|
|
434
|
+
runtimeInjected = true;
|
|
435
|
+
// stream template + call to swap function
|
|
436
|
+
controller.enqueue(
|
|
437
|
+
encoder.encode(
|
|
438
|
+
`${runtime}<template data-suspense="${id}">${html}</template>` +
|
|
439
|
+
`<script>${SR}(document.currentScript.previousElementSibling)</script>`,
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
context.onError(error);
|
|
444
|
+
}
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// #endregion
|
|
451
|
+
|
|
452
|
+
// #region Utilities
|
|
453
|
+
|
|
454
|
+
const ATTR_REGEX = /[&"]/g;
|
|
455
|
+
const CONTENT_REGEX = /[&<]/g;
|
|
456
|
+
|
|
457
|
+
function escapeHtml(value: unknown, isAttr: boolean): string {
|
|
458
|
+
const str = String(value ?? '');
|
|
459
|
+
const pattern = isAttr ? ATTR_REGEX : CONTENT_REGEX;
|
|
460
|
+
pattern.lastIndex = 0;
|
|
461
|
+
|
|
462
|
+
let escaped = '';
|
|
463
|
+
let last = 0;
|
|
464
|
+
|
|
465
|
+
while (pattern.test(str)) {
|
|
466
|
+
const i = pattern.lastIndex - 1;
|
|
467
|
+
const ch = str[i];
|
|
468
|
+
escaped += str.substring(last, i) + (ch === '&' ? '&' : ch === '"' ? '"' : '<');
|
|
469
|
+
last = i + 1;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return escaped + str.substring(last);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function finalizeHtml(html: string, context: RenderContext): string {
|
|
476
|
+
const hasHtmlRoot = html.trimStart().toLowerCase().startsWith('<html');
|
|
477
|
+
// inject hoisted head elements
|
|
478
|
+
if (context.headElements.length > 0) {
|
|
479
|
+
const headContent = context.headElements.join('');
|
|
480
|
+
if (hasHtmlRoot) {
|
|
481
|
+
const headCloseIndex = html.indexOf('</head>');
|
|
482
|
+
if (headCloseIndex !== -1) {
|
|
483
|
+
// inject before existing </head>
|
|
484
|
+
html = html.slice(0, headCloseIndex) + headContent + html.slice(headCloseIndex);
|
|
485
|
+
} else {
|
|
486
|
+
// no existing head, inject after <html>
|
|
487
|
+
const htmlOpenMatch = html.match(/<html[^>]*>/);
|
|
488
|
+
if (htmlOpenMatch && htmlOpenMatch.index !== undefined) {
|
|
489
|
+
const insertIndex = htmlOpenMatch.index + htmlOpenMatch[0].length;
|
|
490
|
+
html = html.slice(0, insertIndex) + `<head>${headContent}</head>` + html.slice(insertIndex);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
// no HTML root, prepend head
|
|
495
|
+
html = `<head>${headContent}</head>${html}`;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (hasHtmlRoot) {
|
|
499
|
+
html = '<!doctype html>' + html;
|
|
500
|
+
}
|
|
501
|
+
return html;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// #endregion
|