@webjsdev/core 0.7.63 → 0.7.65

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.
@@ -0,0 +1,419 @@
1
+ import { BINDING_PREFIXES } from '../binding-prefixes.js';
2
+ import { isVoidElement, isRawtextTag, isTextOnlyTag } from './html-scan.js';
3
+
4
+ /**
5
+ * The SSR template compiler (#1646): one tokenizer pass per template, run
6
+ * ONCE per `TemplateStringsArray` identity and cached, so a render interleaves
7
+ * the cached static chunks with its values instead of walking every character
8
+ * of every template on every render.
9
+ *
10
+ * The state machine is the one `renderTemplate` used to run per render, moved
11
+ * here unchanged in what it decides (where a hole sits: text, comment, raw
12
+ * text, an attribute value, a plain attribute, a prefixed binding) and extended
13
+ * with what the output rescans used to decide afterwards (where an element
14
+ * opens and closes, which content is text rather than markup), so the walker
15
+ * in `walk.js` can expand custom elements in the same pass with no second scan.
16
+ *
17
+ * Nothing here consults the component registry. An op records a tag NAME, and
18
+ * the walker resolves it with `lookup(tag)` at render time, because the
19
+ * registry can grow after a template compiled (`primeModuleUrl` inserts keys
20
+ * before classes register, and `bun --hot` re-registers), and a memo keyed on
21
+ * the registry rendered components as bare tags once already (#1645).
22
+ *
23
+ * Op shapes, all plain objects with a numeric `t`:
24
+ *
25
+ * TEXT { s } a static chunk, emitted verbatim
26
+ * OPEN { tag } a start tag begins at the NEXT chunk's `<`
27
+ * TAG_END { selfClose, close } the start tag's `>` or `/>` (`close` is its text)
28
+ * CLOSE { tag, text } a whole end tag (`text` is its source bytes)
29
+ * HOLE { k, ... } a value position, `k` says where it sits:
30
+ * CHILD text content, escaped or rendered
31
+ * RAW comment or raw text, emitted as a string
32
+ * ATTR { name, kind, tag, lead } a whole-value attribute hole;
33
+ * `kind` is the BINDING_PREFIXES class
34
+ * (`event` / `prop` / `bool`) or `plain`,
35
+ * `lead` the removed `name=` source text
36
+ * VALUE { name, tag } inside a quoted or unquoted value
37
+ * DROP an unsupported position, value ignored
38
+ *
39
+ * A text-only element (`title`, `textarea`, `iframe`, ...) emits no OPEN or
40
+ * CLOSE for anything inside it, and a comment, raw text or attribute value
41
+ * never does either, which is how a tag name written in one of those stays
42
+ * text (#1128, #1133, #1134). Script data honours the double-escaped state
43
+ * (#1134) so its real end is not necessarily the first `</script>`.
44
+ */
45
+
46
+ export const TEXT = 0;
47
+ export const OPEN = 1;
48
+ export const TAG_END = 2;
49
+ export const CLOSE = 3;
50
+ export const HOLE = 4;
51
+
52
+ export const CHILD = 0;
53
+ export const RAW = 1;
54
+ export const ATTR = 2;
55
+ export const VALUE = 3;
56
+ export const DROP = 4;
57
+
58
+ /** @typedef {TemplateStringsArray | string[]} Strings */
59
+ /** @typedef {{ t: number, s?: string, tag?: string, selfClose?: boolean, close?: string, text?: string, k?: number, name?: string, kind?: string, lead?: string }} Op */
60
+
61
+ /** @type {WeakMap<object, Op[]>} */
62
+ const cache = new WeakMap();
63
+
64
+ // Test seam: how many templates the tokenizer has walked, so a test can prove
65
+ // a second render of the same template did NOT re-tokenize it.
66
+ let compiled = 0;
67
+ export function _compileCount() { return compiled; }
68
+
69
+ /**
70
+ * Ops for a template, tokenized once per `strings` identity.
71
+ * @param {Strings} strings
72
+ * @returns {Op[]}
73
+ */
74
+ export function compile(strings) {
75
+ let ops = cache.get(strings);
76
+ if (ops === undefined) {
77
+ ops = tokenize(strings);
78
+ cache.set(strings, ops);
79
+ }
80
+ return ops;
81
+ }
82
+
83
+ /**
84
+ * Ops for a raw HTML string (an `unsafeHTML` value, a streamed boundary's
85
+ * children, a legacy `injectDSD` caller). Not cached: every string is new.
86
+ * @param {string} html
87
+ * @returns {Op[]}
88
+ */
89
+ export function compileRaw(html) {
90
+ return tokenize([html]);
91
+ }
92
+
93
+ const WS = /\s/;
94
+ const LETTER = /[a-zA-Z]/;
95
+ const BOUNDARY = /[\s/>]/;
96
+ // A processing-instruction opener (`<?`), matched by regex rather than by a
97
+ // character compare so the sigil guard (no `=== '<sigil>'` literal in a
98
+ // renderer) stays meaningful.
99
+ const QUESTION = /^\?$/;
100
+ // The states between a start tag's `<name` and its `>`.
101
+ const START_TAG_STATES = new Set(['tag-name', 'in-tag', 'attr-name', 'after-name', 'after-eq', 'attr-quoted', 'attr-unquoted']);
102
+
103
+ /**
104
+ * @param {Strings} strings
105
+ * @returns {Op[]}
106
+ */
107
+ function tokenize(strings) {
108
+ compiled += 1;
109
+ /** @type {Op[]} */
110
+ const ops = [];
111
+ let chunk = '';
112
+ const flush = () => { if (chunk) { ops.push({ t: TEXT, s: chunk }); chunk = ''; } };
113
+
114
+ let state = 'text';
115
+ let attrName = '';
116
+ let attrStart = 0; // index into `chunk` of the attribute's first character
117
+ let attrQuote = '';
118
+ let commentDashes = 0;
119
+ let currentTag = '';
120
+ /** @type {Op | null} */
121
+ let pendingOpen = null; // the OPEN op whose name is still being read
122
+ let openIndex = -1; // index in `ops` of the start tag being read
123
+ let closeStart = 0; // index into `chunk` of a close tag's `<`
124
+ let solidus = false; // a `/` in a tag state that may be a self-closing solidus
125
+ // Raw text (`script` / `style`): ends at `</script>` / `</style>`, with the
126
+ // #1134 escaped and double-escaped states for script data.
127
+ let rawTail = '';
128
+ let rawEscaped = false;
129
+ let rawDbl = false;
130
+ let rawScriptOpen = false; // `<script` just completed; the next char decides
131
+ // Text-only content (`title`, `textarea`, `iframe`, ...): ends at `</tag`.
132
+ let textOnlyTag = '';
133
+ let textOnlyTail = '';
134
+
135
+ const nameDone = () => {
136
+ if (pendingOpen) { pendingOpen.tag = currentTag; pendingOpen = null; }
137
+ };
138
+ /** Push the CLOSE op for the end tag whose bytes start at `closeStart`. */
139
+ const pushClose = (tag) => {
140
+ const text = chunk.slice(closeStart);
141
+ chunk = chunk.slice(0, closeStart);
142
+ flush();
143
+ ops.push({ t: CLOSE, tag, text });
144
+ state = 'text';
145
+ };
146
+ /**
147
+ * Content state after a start tag. `allowRawtext` is NOT a preference: only
148
+ * the `tag-name` and `in-tag` exits ever entered raw text in the renderer
149
+ * this replaces, while the three attribute exits (`attr-name`, `after-eq`,
150
+ * `attr-unquoted`) forced `text`, so `<script defer>` and
151
+ * `<style media=print>` escaped their bodies. That is an XSS-facing
152
+ * behaviour this compiler keeps exactly: through an attribute exit a
153
+ * raw-text element's holes stay escaped, and only the "is a tag in here an
154
+ * element" question gets the text-only answer. A self-closed text-only tag
155
+ * (`<svg><title/></svg>`) has no content in foreign content, so it opens
156
+ * nothing (#1128).
157
+ */
158
+ const finishStartTag = (allowRawtext) => {
159
+ const selfClose = solidus;
160
+ const close = selfClose ? '/>' : '>';
161
+ chunk = chunk.slice(0, chunk.length - close.length);
162
+ flush();
163
+ ops.push({ t: TAG_END, selfClose, close });
164
+ solidus = false;
165
+ attrName = '';
166
+ const tag = currentTag;
167
+ if (isRawtextTag(tag) && allowRawtext) {
168
+ state = 'rawtext';
169
+ rawTail = '';
170
+ rawEscaped = false;
171
+ rawDbl = false;
172
+ rawScriptOpen = false;
173
+ return;
174
+ }
175
+ if (!selfClose && isTextOnlyTag(tag)) {
176
+ state = 'textonly';
177
+ textOnlyTag = tag;
178
+ textOnlyTail = '';
179
+ return;
180
+ }
181
+ state = 'text';
182
+ };
183
+
184
+ for (let i = 0; i < strings.length; i++) {
185
+ const s = strings[i];
186
+ for (let j = 0; j < s.length; j++) {
187
+ const c = s[j];
188
+ switch (state) {
189
+ case 'text':
190
+ if (c === '<') { flush(); state = 'tag-open'; }
191
+ chunk += c;
192
+ break;
193
+ case 'tag-open':
194
+ if (c === '!') { chunk += c; state = 'bang-1'; }
195
+ else if (QUESTION.test(c)) { chunk += c; state = 'bogus'; }
196
+ else if (c === '/') { chunk += c; state = 'close-open'; }
197
+ else if (LETTER.test(c)) {
198
+ // A start tag. The OPEN op precedes the chunk holding `<name...`.
199
+ chunk = chunk.slice(0, -1);
200
+ flush();
201
+ pendingOpen = { t: OPEN, tag: '' };
202
+ openIndex = ops.length;
203
+ ops.push(pendingOpen);
204
+ chunk = '<' + c;
205
+ currentTag = c.toLowerCase();
206
+ state = 'tag-name';
207
+ } else { chunk += c; state = 'text'; }
208
+ break;
209
+ case 'close-open':
210
+ if (LETTER.test(c)) {
211
+ closeStart = chunk.length - 2;
212
+ chunk += c;
213
+ currentTag = c.toLowerCase();
214
+ state = 'close-name';
215
+ } else {
216
+ // `</` followed by anything else is a bogus comment to the next `>`.
217
+ chunk += c;
218
+ state = c === '>' ? 'text' : 'bogus';
219
+ }
220
+ break;
221
+ case 'close-name':
222
+ chunk += c;
223
+ if (c === '>') pushClose(currentTag);
224
+ else if (WS.test(c) || c === '/') state = 'close-tail';
225
+ else currentTag += c.toLowerCase();
226
+ break;
227
+ case 'close-tail':
228
+ chunk += c;
229
+ if (c === '>') pushClose(currentTag);
230
+ break;
231
+ case 'bang-1':
232
+ chunk += c;
233
+ state = c === '-' ? 'bang-dash' : 'bogus';
234
+ break;
235
+ case 'bang-dash':
236
+ chunk += c;
237
+ state = c === '-' ? 'comment-start' : 'bogus';
238
+ break;
239
+ case 'bogus':
240
+ // `<!doctype ...>`, `<![CDATA[...]]>`, `<?...>`, `</1...>`: to the next `>`.
241
+ chunk += c;
242
+ if (c === '>') state = 'text';
243
+ break;
244
+ case 'comment-start':
245
+ // The spec short forms: `<!-->` and `<!--->` close immediately.
246
+ chunk += c;
247
+ if (c === '>') state = 'text';
248
+ else if (c === '-') state = 'comment-start-dash';
249
+ else { state = 'comment'; commentDashes = 0; }
250
+ break;
251
+ case 'comment-start-dash':
252
+ chunk += c;
253
+ if (c === '>') state = 'text';
254
+ else if (c === '-') { state = 'comment'; commentDashes = 2; }
255
+ else { state = 'comment'; commentDashes = 0; }
256
+ break;
257
+ case 'comment':
258
+ chunk += c;
259
+ if (c === '-') commentDashes += 1;
260
+ else if (c === '>' && commentDashes >= 2) { state = 'text'; commentDashes = 0; }
261
+ else if (c === '!' && commentDashes >= 2) state = 'comment-bang';
262
+ else commentDashes = 0;
263
+ break;
264
+ case 'comment-bang':
265
+ // `--!>` closes like `-->`.
266
+ chunk += c;
267
+ if (c === '>') { state = 'text'; commentDashes = 0; }
268
+ else { state = 'comment'; commentDashes = c === '-' ? 1 : 0; }
269
+ break;
270
+ case 'tag-name':
271
+ chunk += c;
272
+ if (c === '>') { nameDone(); finishStartTag(true); }
273
+ else if (WS.test(c)) { nameDone(); state = 'in-tag'; }
274
+ else if (c === '/') { nameDone(); solidus = true; state = 'in-tag'; }
275
+ else currentTag += c.toLowerCase();
276
+ break;
277
+ case 'in-tag':
278
+ chunk += c;
279
+ if (c === '>') finishStartTag(true);
280
+ else if (c === '/') solidus = true;
281
+ else if (WS.test(c)) solidus = false;
282
+ else {
283
+ solidus = false;
284
+ state = 'attr-name';
285
+ attrName = c;
286
+ attrStart = chunk.length - 1;
287
+ }
288
+ break;
289
+ case 'attr-name':
290
+ if (c === '=') { state = 'after-eq'; chunk += c; }
291
+ else if (WS.test(c)) { state = 'after-name'; chunk += c; }
292
+ else if (c === '/') { state = 'in-tag'; attrName = ''; solidus = true; chunk += c; }
293
+ else if (c === '>') { chunk += c; finishStartTag(false); }
294
+ else { attrName += c; chunk += c; }
295
+ break;
296
+ case 'after-name':
297
+ // Whitespace after an attribute name (after-attribute-name state):
298
+ // an `=` still belongs to that attribute, so `href = ${x}` binds.
299
+ chunk += c;
300
+ if (c === '=') state = 'after-eq';
301
+ else if (WS.test(c)) { /* keep waiting */ }
302
+ // Whitespace already ended the attribute, the renderer this replaces
303
+ // was back in `in-tag` here, and that exit entered raw text, so
304
+ // `<script async >` keeps its holes raw.
305
+ else if (c === '>') finishStartTag(true);
306
+ else if (c === '/') { state = 'in-tag'; attrName = ''; solidus = true; }
307
+ else { state = 'attr-name'; attrName = c; attrStart = chunk.length - 1; }
308
+ break;
309
+ case 'after-eq':
310
+ // Whitespace after `=` is skipped (before-attribute-value state), so
311
+ // `title= "x"` still opens a quoted value. The renderer this replaces
312
+ // dropped to `in-tag` here and lost the attribute's hole.
313
+ if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; chunk += c; }
314
+ else if (WS.test(c)) { chunk += c; }
315
+ else if (c === '>') { chunk += c; finishStartTag(false); }
316
+ else { state = 'attr-unquoted'; chunk += c; }
317
+ break;
318
+ case 'attr-unquoted':
319
+ if (WS.test(c)) { state = 'in-tag'; attrName = ''; chunk += c; }
320
+ else if (c === '>') { chunk += c; finishStartTag(false); }
321
+ else chunk += c;
322
+ break;
323
+ case 'attr-quoted':
324
+ chunk += c;
325
+ if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
326
+ break;
327
+ case 'rawtext': {
328
+ chunk += c;
329
+ const lc = c.toLowerCase();
330
+ if (currentTag === 'script') {
331
+ // Script data (#1134). `<!--` enters the escaped state, `-->` and
332
+ // `--!>` leave it, a `<script` inside the escaped state enters the
333
+ // double-escaped one where a `</script>` is text, and the element
334
+ // ends at the first `</script>` outside the double-escaped state.
335
+ if (rawScriptOpen) { rawScriptOpen = false; if (BOUNDARY.test(c) && rawEscaped) rawDbl = true; }
336
+ rawTail = (rawTail + lc).slice(-9);
337
+ if (rawTail.endsWith('<!--')) { if (!rawEscaped) rawEscaped = true; }
338
+ else if (rawTail.endsWith('-->') || rawTail.endsWith('--!>')) { rawEscaped = false; rawDbl = false; }
339
+ else if (rawTail.endsWith('<script')) rawScriptOpen = true;
340
+ else if (rawTail.endsWith('</script>')) {
341
+ if (rawDbl) rawDbl = false;
342
+ else { closeStart = chunk.length - 9; pushClose('script'); rawTail = ''; currentTag = ''; }
343
+ }
344
+ } else {
345
+ rawTail = (rawTail + lc).slice(-8);
346
+ if (rawTail.endsWith('</style>')) { closeStart = chunk.length - 8; pushClose('style'); rawTail = ''; currentTag = ''; }
347
+ }
348
+ break;
349
+ }
350
+ case 'textonly':
351
+ // Text until `</tag` on a name boundary; `plaintext` never ends.
352
+ if (textOnlyTail === '</' + textOnlyTag && BOUNDARY.test(c)) {
353
+ closeStart = chunk.length - textOnlyTail.length;
354
+ chunk += c;
355
+ currentTag = textOnlyTag;
356
+ textOnlyTag = '';
357
+ textOnlyTail = '';
358
+ if (c === '>') pushClose(currentTag);
359
+ else state = 'close-tail';
360
+ break;
361
+ }
362
+ chunk += c;
363
+ if (textOnlyTag !== 'plaintext') textOnlyTail = (textOnlyTail + c.toLowerCase()).slice(-(textOnlyTag.length + 2));
364
+ break;
365
+ }
366
+ }
367
+
368
+ if (i < strings.length - 1) {
369
+ // A value hole sits here. Its op says where, and the walker decides what.
370
+ if (state === 'comment' || state === 'comment-start' || state === 'comment-start-dash' || state === 'comment-bang') {
371
+ flush();
372
+ ops.push({ t: HOLE, k: RAW });
373
+ commentDashes = 0;
374
+ state = 'comment';
375
+ } else if (state === 'rawtext') {
376
+ flush();
377
+ ops.push({ t: HOLE, k: RAW });
378
+ rawTail = '';
379
+ rawScriptOpen = false;
380
+ } else if (state === 'text' || state === 'textonly') {
381
+ flush();
382
+ ops.push({ t: HOLE, k: CHILD });
383
+ textOnlyTail = '';
384
+ } else if (state === 'after-eq') {
385
+ const prefix = attrName[0];
386
+ const kind = BINDING_PREFIXES[prefix];
387
+ const lead = chunk.slice(attrStart);
388
+ chunk = chunk.slice(0, attrStart);
389
+ flush();
390
+ ops.push({
391
+ t: HOLE, k: ATTR, kind: kind === undefined ? 'plain' : kind,
392
+ name: kind === undefined ? attrName : attrName.slice(1), tag: currentTag, lead,
393
+ });
394
+ state = 'in-tag';
395
+ attrName = '';
396
+ } else if (state === 'attr-quoted' || state === 'attr-unquoted') {
397
+ flush();
398
+ ops.push({ t: HOLE, k: VALUE, name: attrName, tag: currentTag });
399
+ } else {
400
+ // A hole in a tag name, between attributes, in a close tag or a markup
401
+ // declaration: nothing is emitted for it, as before.
402
+ flush();
403
+ ops.push({ t: HOLE, k: DROP });
404
+ closeStart = 0;
405
+ }
406
+ }
407
+ }
408
+ // An unterminated tag, comment or close tag: the bytes stay text, as a
409
+ // browser treats them. A start tag still open here has no TAG_END, so its
410
+ // OPEN is dropped too: the walker would otherwise begin taking the element
411
+ // (a registered tag moves the page built so far aside until the `>`) and
412
+ // never finish, losing everything before it (a truncated `unsafeHTML`).
413
+ if (pendingOpen) pendingOpen.tag = currentTag;
414
+ if (openIndex !== -1 && START_TAG_STATES.has(state)) ops.splice(openIndex, 1);
415
+ flush();
416
+ return ops;
417
+ }
418
+
419
+ export { isVoidElement };