@webjsdev/core 0.7.44 → 0.7.46
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/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +22 -21
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -6
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -0
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/asset-url.d.ts +3 -0
- package/src/asset-url.js +99 -0
- package/src/component.js +68 -2
- package/src/form-action.js +163 -0
- package/src/render-client.js +30 -2
- package/src/render-server.js +449 -11
- package/src/router-client.js +183 -24
package/src/render-server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { html, isTemplate } from './html.js';
|
|
2
2
|
import { BINDING_PREFIXES } from './binding-prefixes.js';
|
|
3
3
|
import { escapeText, escapeAttr } from './escape.js';
|
|
4
|
+
import { assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp } from './form-action.js';
|
|
4
5
|
import { lookup, lookupModuleUrl, allTags } from './registry.js';
|
|
5
6
|
import { stylesToString, isCSS } from './css.js';
|
|
6
7
|
import { isRepeat } from './repeat.js';
|
|
@@ -289,6 +290,17 @@ async function renderTemplate(tr, ctx) {
|
|
|
289
290
|
continue;
|
|
290
291
|
}
|
|
291
292
|
if (!currentTag.includes('-')) {
|
|
293
|
+
// A native element's `.prop` is dropped at SSR, so this path never
|
|
294
|
+
// leaked here. It still refuses a function where the property is a
|
|
295
|
+
// REFLECTED IDL attribute (`.action` on a form, `.formAction` on a
|
|
296
|
+
// button or input), so the rule does not depend on which renderer
|
|
297
|
+
// sees it first: the client sets that property for real and the
|
|
298
|
+
// reflection writes the source into the DOM. A page that renders
|
|
299
|
+
// clean on the server and throws on hydration is a worse failure
|
|
300
|
+
// than one that refuses at the earliest point. Elsewhere the
|
|
301
|
+
// property is a plain expando that reflects nothing, so refusing
|
|
302
|
+
// it would be a false positive.
|
|
303
|
+
assertNotFunctionReflectedActionProp(val, name, currentTag);
|
|
292
304
|
state = 'in-tag';
|
|
293
305
|
attrName = '';
|
|
294
306
|
continue;
|
|
@@ -318,16 +330,26 @@ async function renderTemplate(tr, ctx) {
|
|
|
318
330
|
state = 'in-tag';
|
|
319
331
|
attrName = '';
|
|
320
332
|
} else if (kind === 'bool') {
|
|
333
|
+
// Never leaked (a boolean binding stringifies nothing), but
|
|
334
|
+
// `?action=${fn}` is meaningless in every case and refusing it keeps
|
|
335
|
+
// the rule true for every sigil rather than only the quoted ones.
|
|
336
|
+
assertNotFunctionActionAttr(val, name, currentTag);
|
|
321
337
|
out = out.slice(0, attrStart);
|
|
322
338
|
if (val) out += `${name}=""`;
|
|
323
339
|
state = 'in-tag';
|
|
324
340
|
attrName = '';
|
|
325
341
|
} else {
|
|
342
|
+
// #1154: never stringify a function into action=/formaction= (it
|
|
343
|
+
// would serialize a server action's source into the served HTML).
|
|
344
|
+
assertNotFunctionActionAttr(val, attrName, currentTag);
|
|
326
345
|
out += `"${escapeAttr(String(val ?? ''))}"`;
|
|
327
346
|
state = 'in-tag';
|
|
328
347
|
attrName = '';
|
|
329
348
|
}
|
|
330
349
|
} else if (state === 'attr-quoted' || state === 'attr-unquoted') {
|
|
350
|
+
// Same guard for a hole inside a quoted/unquoted value, the
|
|
351
|
+
// `action="${fn}"` and mixed `action="/x/${fn}"` shapes (#1154).
|
|
352
|
+
assertNotFunctionActionAttr(val, attrName, currentTag);
|
|
331
353
|
out += escapeAttr(String(val ?? ''));
|
|
332
354
|
}
|
|
333
355
|
}
|
|
@@ -411,6 +433,294 @@ function defaultSSRErrorTemplate(tag, err, dev) {
|
|
|
411
433
|
</div>`;
|
|
412
434
|
}
|
|
413
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Index just past the end of the comment starting at `start`, or -1 if it is
|
|
438
|
+
* unterminated. Shared so every scanner that has to decide where a comment
|
|
439
|
+
* stops agrees, including on the spec short forms.
|
|
440
|
+
*
|
|
441
|
+
* @param {string} html
|
|
442
|
+
* @param {number} start index of the `<` of `<!--`
|
|
443
|
+
* @returns {number}
|
|
444
|
+
*/
|
|
445
|
+
function endOfComment(html, start) {
|
|
446
|
+
let p = start + 4;
|
|
447
|
+
// `<!-->` and `<!--->` are comments whose data is empty (spec short forms).
|
|
448
|
+
if (html[p] === '>') return p + 1;
|
|
449
|
+
if (html.startsWith('->', p)) return p + 2;
|
|
450
|
+
while (p < html.length) {
|
|
451
|
+
// `--!>` is the spec's "abrupt closing" form and closes just like `-->`.
|
|
452
|
+
if (html.startsWith('-->', p)) return p + 3;
|
|
453
|
+
if (html.startsWith('--!>', p)) return p + 4;
|
|
454
|
+
p += 1;
|
|
455
|
+
}
|
|
456
|
+
return -1;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Index of the `</script` that really closes a `<script>` whose content starts
|
|
461
|
+
* at `from`, or -1 when unterminated (#1134).
|
|
462
|
+
*
|
|
463
|
+
* Script data is not plain raw text: once the content contains `<!--` followed
|
|
464
|
+
* by `<script`, the tokenizer is in the script-data-double-escaped state, where
|
|
465
|
+
* a `</script>` is TEXT (it only steps back to the escaped state) and the
|
|
466
|
+
* element ends at the NEXT `</script>`. The legacy comment-wrapped inline
|
|
467
|
+
* script that document.writes a script tag is the pattern that produces this.
|
|
468
|
+
* Stopping at the first `</script>` there re-opened the original #1128 bug in
|
|
469
|
+
* the one element the scanner most explicitly claims to handle.
|
|
470
|
+
*
|
|
471
|
+
* @param {string} html
|
|
472
|
+
* @param {number} from index just past the opening tag's `>`
|
|
473
|
+
* @returns {number}
|
|
474
|
+
*/
|
|
475
|
+
function endOfScriptContent(html, from) {
|
|
476
|
+
const re = /<!--|-->|<\/script(?=[\s/>])|<script(?=[\s/>])/gi;
|
|
477
|
+
re.lastIndex = from;
|
|
478
|
+
let escaped = false;
|
|
479
|
+
let dbl = false;
|
|
480
|
+
let m;
|
|
481
|
+
while ((m = re.exec(html)) !== null) {
|
|
482
|
+
const t = m[0];
|
|
483
|
+
if (t === '<!--') {
|
|
484
|
+
// The token's own trailing `--` puts the tokenizer in a dash-dash state
|
|
485
|
+
// REGARDLESS of what state it was in (`<!` are inert bytes in escaped and
|
|
486
|
+
// double-escaped states too), and every dash-dash state exits straight
|
|
487
|
+
// back to plain script data on `>`. So `<!-->`, `<!--->`, and any dash
|
|
488
|
+
// run followed by `>` clear BOTH flags: entering fresh it cancels the
|
|
489
|
+
// escape before it starts, and inside an escaped or double-escaped body
|
|
490
|
+
// it is the exit a browser honours, after which the element ends at the
|
|
491
|
+
// next `</script>`.
|
|
492
|
+
let q = m.index + 4;
|
|
493
|
+
while (html[q] === '-') q += 1;
|
|
494
|
+
if (html[q] === '>') { escaped = false; dbl = false; re.lastIndex = q + 1; }
|
|
495
|
+
else if (!escaped) escaped = true;
|
|
496
|
+
}
|
|
497
|
+
else if (t === '-->') { escaped = false; dbl = false; }
|
|
498
|
+
else if (t[1] === '/') {
|
|
499
|
+
if (dbl) dbl = false;
|
|
500
|
+
else return m.index;
|
|
501
|
+
} else if (escaped) dbl = true;
|
|
502
|
+
}
|
|
503
|
+
return -1;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Byte ranges of `html` where a tag-shaped match is NOT an element (#1128).
|
|
508
|
+
*
|
|
509
|
+
* The element scanners below match tags with a flat regex over already-
|
|
510
|
+
* assembled markup, which has no notion of an HTML context. So a registered tag
|
|
511
|
+
* name written inside a comment used to be constructed and rendered as a real
|
|
512
|
+
* element, and the replacement consumed the rest of the comment INCLUDING its
|
|
513
|
+
* closing `-->`, leaving an unterminated comment that swallowed every following
|
|
514
|
+
* byte. Whether it happened depended on whether the name in the comment was a
|
|
515
|
+
* registered component, which is what made it look random.
|
|
516
|
+
*
|
|
517
|
+
* This is a single left-to-right pass rather than a search for `<!--`, because
|
|
518
|
+
* the naive version introduces failures worse than the bug: an `<!--` inside an
|
|
519
|
+
* attribute value (`title="use <!-- here"`) or inside RCDATA would open a region
|
|
520
|
+
* that never closes, and every component after it would silently stop rendering.
|
|
521
|
+
* Deciding that requires knowing the context, which means tokenizing, so the
|
|
522
|
+
* pass tracks the same states the HTML parser does for these purposes:
|
|
523
|
+
*
|
|
524
|
+
* - **Comments**, including the spec's short forms. `<!-->` and `<!--->` close
|
|
525
|
+
* immediately, `--!>` closes as well as `-->`, and an unterminated comment
|
|
526
|
+
* runs to EOF, exactly as a browser would treat the same bytes.
|
|
527
|
+
* - **Markup declarations and bogus comments** (`<!doctype …>`, `<![CDATA[…]]>`),
|
|
528
|
+
* which end at the next `>`.
|
|
529
|
+
* - **Tags**, consumed with their quoted attribute values, so `<` and `<!--`
|
|
530
|
+
* inside an attribute are inert rather than context-changing.
|
|
531
|
+
* - **Text-only elements**, whose content the HTML tokenizer never reads as
|
|
532
|
+
* markup: raw text (`script`, `style`, `iframe`, `xmp`, `noembed`,
|
|
533
|
+
* `noframes`, `plaintext`) and RCDATA (`textarea`, `title`). Their content is
|
|
534
|
+
* returned as a skip range too, because a component tag inside a `<style>`
|
|
535
|
+
* comment or an `<iframe>` fallback hit the identical markup-destroying path,
|
|
536
|
+
* so excluding them would leave half the bug live.
|
|
537
|
+
*
|
|
538
|
+
* Two deliberate exclusions. `<template>` content IS parsed and legitimately
|
|
539
|
+
* carries components (Declarative Shadow DOM and the streamed swap templates
|
|
540
|
+
* both depend on that). `<noscript>` content is parsed as markup when
|
|
541
|
+
* scripting is disabled, which for a progressive-enhancement framework is the
|
|
542
|
+
* case that matters, so components inside it must keep rendering.
|
|
543
|
+
*
|
|
544
|
+
* @param {string} html
|
|
545
|
+
* @returns {[number, number][]} ascending, non-overlapping `[start, end)` pairs
|
|
546
|
+
*/
|
|
547
|
+
function inertRanges(html) {
|
|
548
|
+
/** @type {[number, number][]} */
|
|
549
|
+
const ranges = [];
|
|
550
|
+
const n = html.length;
|
|
551
|
+
let i = 0;
|
|
552
|
+
while (i < n) {
|
|
553
|
+
const lt = html.indexOf('<', i);
|
|
554
|
+
if (lt === -1) break;
|
|
555
|
+
if (html.startsWith('<!--', lt)) {
|
|
556
|
+
const end = endOfComment(html, lt);
|
|
557
|
+
const stop = end === -1 ? n : end;
|
|
558
|
+
ranges.push([lt, stop]);
|
|
559
|
+
i = stop;
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (html.startsWith('<!', lt) || html.startsWith('<?', lt)) {
|
|
563
|
+
// Doctype / bogus comment / processing instruction: ends at the next `>`.
|
|
564
|
+
const close = html.indexOf('>', lt);
|
|
565
|
+
const end = close === -1 ? n : close + 1;
|
|
566
|
+
ranges.push([lt, end]);
|
|
567
|
+
i = end;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const name = /^<\/?([a-zA-Z][^\s/>]*)/.exec(html.slice(lt, lt + 64));
|
|
571
|
+
if (!name) {
|
|
572
|
+
// `</` followed by anything that is not an ASCII letter is the third
|
|
573
|
+
// bogus-comment form (`</1`, `</<`, `</ `), which the spec also runs to
|
|
574
|
+
// the next `>`. Without this branch the bytes after it are scanned as
|
|
575
|
+
// markup and a tag inside gets instantiated, which is the original bug.
|
|
576
|
+
if (html.startsWith('</', lt)) {
|
|
577
|
+
const close = html.indexOf('>', lt);
|
|
578
|
+
const end = close === -1 ? n : close + 1;
|
|
579
|
+
ranges.push([lt, end]);
|
|
580
|
+
i = end;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
i = lt + 1;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
// Consume the tag, honouring quoted attribute values so a `<` or `<!--`
|
|
587
|
+
// inside one cannot be mistaken for markup.
|
|
588
|
+
//
|
|
589
|
+
// A quote only OPENS a value when it directly follows `=`. That condition
|
|
590
|
+
// is load-bearing rather than pedantic: `escapeAttr` does not escape `'`,
|
|
591
|
+
// so an interpolated apostrophe in a single-quoted attribute emits three
|
|
592
|
+
// unbalanced quotes (`title='don't'`). Treating every quote as a delimiter
|
|
593
|
+
// left the scanner stuck inside a value to EOF, which returned a truncated
|
|
594
|
+
// range list and silently re-enabled this whole bug for the rest of the
|
|
595
|
+
// page. A browser recovers at the `>`, and so does this: after the value
|
|
596
|
+
// closes, the stray `'` is just an attribute-name character.
|
|
597
|
+
let p = lt + 1;
|
|
598
|
+
let quote = '';
|
|
599
|
+
// `expectValue` is set by `=` and cleared by the first non-whitespace
|
|
600
|
+
// character after it. Only THAT character can open a quoted value, which is
|
|
601
|
+
// what the spec does: before-attribute-value reconsumes anything else in
|
|
602
|
+
// attribute-value-unquoted state. Keying off "the previous character was
|
|
603
|
+
// `=`" instead re-opens the hole on `<a title==">`, where the `"` is an
|
|
604
|
+
// ordinary value character; an odd quote count then ran the scan to EOF and
|
|
605
|
+
// returned one giant inert range, silently disabling this whole fix for the
|
|
606
|
+
// rest of the page.
|
|
607
|
+
let expectValue = false;
|
|
608
|
+
// Unquoted values need their own state for two reasons the spec spells out
|
|
609
|
+
// and a simpler scan gets wrong: `>` ends the tag from here (so `attr=>` is
|
|
610
|
+
// a missing value, not a value of `>`), and `/` is an ordinary value
|
|
611
|
+
// character, so an unquoted URL ending in `/` is NOT a self-closing solidus.
|
|
612
|
+
let inUnquoted = false;
|
|
613
|
+
let selfClosing = false;
|
|
614
|
+
while (p < n) {
|
|
615
|
+
const c = html[p];
|
|
616
|
+
const isSpace = c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
|
|
617
|
+
if (quote) {
|
|
618
|
+
if (c === quote) quote = '';
|
|
619
|
+
} else if (c === '>') {
|
|
620
|
+
// Checked before the value branches: a `>` arriving where a value was
|
|
621
|
+
// expected terminates the tag (`<a href=>`). Consuming it as a value
|
|
622
|
+
// character ran the scan on to the NEXT `>`, which swallowed the real
|
|
623
|
+
// tag end and re-armed the original bug for what followed.
|
|
624
|
+
selfClosing = !inUnquoted && html[p - 1] === '/';
|
|
625
|
+
p += 1;
|
|
626
|
+
break;
|
|
627
|
+
} else if (expectValue) {
|
|
628
|
+
// The first non-whitespace character after `=` decides the value form.
|
|
629
|
+
if (!isSpace) {
|
|
630
|
+
if (c === '"' || c === "'") quote = c;
|
|
631
|
+
else inUnquoted = true;
|
|
632
|
+
expectValue = false;
|
|
633
|
+
}
|
|
634
|
+
} else if (inUnquoted) {
|
|
635
|
+
if (isSpace) inUnquoted = false;
|
|
636
|
+
} else if (c === '=') {
|
|
637
|
+
expectValue = true;
|
|
638
|
+
}
|
|
639
|
+
p += 1;
|
|
640
|
+
}
|
|
641
|
+
// The tag's interior is not markup either. A component tag written inside
|
|
642
|
+
// an attribute value (`title="renders a <my-card> element"`) was otherwise
|
|
643
|
+
// instantiated in place, destroying the rest of the document exactly like
|
|
644
|
+
// the comment case. Start at lt+1 so the tag's OWN opening `<` still
|
|
645
|
+
// matches; only what is nested inside it is inert.
|
|
646
|
+
if (p > lt + 1) ranges.push([lt + 1, p]);
|
|
647
|
+
i = p;
|
|
648
|
+
const tag = name[1].toLowerCase();
|
|
649
|
+
const isClose = html[lt + 1] === '/';
|
|
650
|
+
// A self-closing start tag has no content to skip. In HTML the `/` is
|
|
651
|
+
// ignored, but in SVG and MathML foreign content it genuinely closes the
|
|
652
|
+
// element, and `<svg><title/></svg>` otherwise finds no `</title`, runs the
|
|
653
|
+
// range to EOF, and makes every component in the rest of the document
|
|
654
|
+
// inert. Honouring `/>` costs only the malformed-HTML case (`<style/>`,
|
|
655
|
+
// already broken authoring) and fails in the direction where components
|
|
656
|
+
// keep rendering rather than silently vanishing.
|
|
657
|
+
if (!isClose && !selfClosing && isTextOnlyTag(tag)) {
|
|
658
|
+
// Everything up to the matching close tag is text, not markup.
|
|
659
|
+
let contentEnd;
|
|
660
|
+
if (tag === 'plaintext') {
|
|
661
|
+
// `<plaintext>` has no end tag at all: the rest of the document is text.
|
|
662
|
+
contentEnd = n;
|
|
663
|
+
} else if (tag === 'script') {
|
|
664
|
+
// Script data has the double-escaped state (#1134), so its real end is
|
|
665
|
+
// not necessarily the first `</script`.
|
|
666
|
+
const end = endOfScriptContent(html, p);
|
|
667
|
+
contentEnd = end === -1 ? n : end;
|
|
668
|
+
} else {
|
|
669
|
+
const close = new RegExp(`</${tag}(?=[\\s/>])`, 'i').exec(html.slice(p));
|
|
670
|
+
contentEnd = close ? p + close.index : n;
|
|
671
|
+
}
|
|
672
|
+
if (contentEnd > p) ranges.push([p, contentEnd]);
|
|
673
|
+
i = contentEnd;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return ranges;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Random-access membership test over ascending, non-overlapping ranges, for
|
|
681
|
+
* callers whose queries are NOT monotonic (findClosingTagInString resets its
|
|
682
|
+
* regex cursors backward while pairing opens with closes). O(ranges) per call;
|
|
683
|
+
* monotonic callers use `inertAt` below instead.
|
|
684
|
+
*
|
|
685
|
+
* @param {[number, number][]} ranges
|
|
686
|
+
* @param {number} index
|
|
687
|
+
* @returns {boolean}
|
|
688
|
+
*/
|
|
689
|
+
function inRanges(ranges, index) {
|
|
690
|
+
for (const [start, end] of ranges) {
|
|
691
|
+
if (start > index) return false;
|
|
692
|
+
if (index < end) return true;
|
|
693
|
+
}
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* A left-to-right membership test over ascending, non-overlapping ranges.
|
|
699
|
+
*
|
|
700
|
+
* Returns a function that answers "is this index inert?" and REMEMBERS how far
|
|
701
|
+
* it has walked, so a caller scanning matches in increasing order pays O(ranges)
|
|
702
|
+
* across the whole scan instead of O(ranges) per match. Restarting each time is
|
|
703
|
+
* an O(tags x components) term, which is measurable on a large page: holding a
|
|
704
|
+
* document at 40k tags and raising the component count adds hundreds of
|
|
705
|
+
* milliseconds that the cursor removes.
|
|
706
|
+
*
|
|
707
|
+
* The cursor only ever moves forward, so callers MUST query in non-decreasing
|
|
708
|
+
* index order. All three call sites do (`matchAll`, and the two loops that
|
|
709
|
+
* consume their input left to right). A caller that needs random access should
|
|
710
|
+
* scan `ranges` directly rather than reusing this.
|
|
711
|
+
*
|
|
712
|
+
* @param {[number, number][]} ranges
|
|
713
|
+
* @returns {(index: number) => boolean}
|
|
714
|
+
*/
|
|
715
|
+
function inertAt(ranges) {
|
|
716
|
+
let cursor = 0;
|
|
717
|
+
return (index) => {
|
|
718
|
+
while (cursor < ranges.length && ranges[cursor][1] <= index) cursor += 1;
|
|
719
|
+
if (cursor >= ranges.length) return false;
|
|
720
|
+
return index >= ranges[cursor][0];
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
414
724
|
/**
|
|
415
725
|
* Scan an HTML string for registered custom elements and inject
|
|
416
726
|
* Declarative Shadow DOM (`<template shadowrootmode="open">`).
|
|
@@ -447,10 +757,15 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
|
|
|
447
757
|
);
|
|
448
758
|
/** @type {{start:number, end:number, text:string}[]} */
|
|
449
759
|
const edits = [];
|
|
760
|
+
// A tag name inside a comment, a script, a style, RCDATA, or another tag's
|
|
761
|
+
// attribute value is text, not an element (#1128).
|
|
762
|
+
const inert = inertRanges(html);
|
|
763
|
+
const isInert = inertAt(inert);
|
|
450
764
|
for (const m of html.matchAll(pattern)) {
|
|
451
765
|
const [match, tag, attrs, selfClose] = m;
|
|
452
766
|
const Cls = lookup(tag);
|
|
453
767
|
if (!Cls) continue;
|
|
768
|
+
if (isInert(m.index)) continue;
|
|
454
769
|
// Track which custom elements actually appeared: used by SSR to emit
|
|
455
770
|
// `<link rel="modulepreload">` hints for their module URLs.
|
|
456
771
|
if (ctx && ctx.usedComponents) ctx.usedComponents.add(tag);
|
|
@@ -496,7 +811,7 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
|
|
|
496
811
|
let closeEnd = m.index + match.length;
|
|
497
812
|
if (!selfClose) {
|
|
498
813
|
const innerStart = m.index + match.length;
|
|
499
|
-
const closeIdx = findClosingTagInString(html, innerStart, tag);
|
|
814
|
+
const closeIdx = findClosingTagInString(html, innerStart, tag, inert);
|
|
500
815
|
if (closeIdx !== -1) {
|
|
501
816
|
authoredInner = html.slice(innerStart, closeIdx);
|
|
502
817
|
const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
|
|
@@ -644,7 +959,7 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
|
|
|
644
959
|
let closeEnd = m.index + match.length;
|
|
645
960
|
if (!selfClose) {
|
|
646
961
|
const innerStart = m.index + match.length;
|
|
647
|
-
const closeIdx = findClosingTagInString(html, innerStart, tag);
|
|
962
|
+
const closeIdx = findClosingTagInString(html, innerStart, tag, inert);
|
|
648
963
|
if (closeIdx !== -1) {
|
|
649
964
|
const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
|
|
650
965
|
const cm = closeRe.exec(html.slice(closeIdx));
|
|
@@ -753,6 +1068,17 @@ function isVoidElement(tag) {
|
|
|
753
1068
|
async function processSuspenseElements(html, ctx, ancestors = [], dev) {
|
|
754
1069
|
if (html.indexOf('<webjs-suspense') === -1) return html;
|
|
755
1070
|
const OPEN = /<webjs-suspense((?:"[^"]*"|'[^']*'|[^>])*?)>/i;
|
|
1071
|
+
// A commented-out boundary is text, not an element (#1128). This scanner is
|
|
1072
|
+
// the reason the comment fix cannot live in injectDSD alone: it runs FIRST
|
|
1073
|
+
// and hands the boundary's children to a fresh injectDSD as a standalone
|
|
1074
|
+
// string, which has no idea those bytes came from inside a comment. Under
|
|
1075
|
+
// streaming it is worse than a stray render, because the children's data
|
|
1076
|
+
// fetches run and the swap script targets an id that only exists inside a
|
|
1077
|
+
// comment, so it can never resolve. Ranges are computed against the FULL
|
|
1078
|
+
// input once, and `consumed` maps the shrinking `rest` back onto it.
|
|
1079
|
+
const inert = inertRanges(html);
|
|
1080
|
+
const isInert = inertAt(inert);
|
|
1081
|
+
let consumed = 0;
|
|
756
1082
|
let result = '';
|
|
757
1083
|
let rest = html;
|
|
758
1084
|
// Bounded loop: each iteration consumes at least the opening tag.
|
|
@@ -762,6 +1088,14 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
|
|
|
762
1088
|
result += rest;
|
|
763
1089
|
break;
|
|
764
1090
|
}
|
|
1091
|
+
if (isInert(consumed + m.index)) {
|
|
1092
|
+
// Emit through the end of this match and keep scanning after it.
|
|
1093
|
+
const skipTo = m.index + m[0].length;
|
|
1094
|
+
result += rest.slice(0, skipTo);
|
|
1095
|
+
rest = rest.slice(skipTo);
|
|
1096
|
+
consumed += skipTo;
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
765
1099
|
const openStart = m.index;
|
|
766
1100
|
const openEnd = m.index + m[0].length;
|
|
767
1101
|
result += rest.slice(0, openStart);
|
|
@@ -769,7 +1103,20 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
|
|
|
769
1103
|
const fbMatch = /data-webjs-fallback="([^"]*)"/i.exec(attrs);
|
|
770
1104
|
const fallbackHtml = fbMatch ? unescapeAttr(fbMatch[1]) : '';
|
|
771
1105
|
|
|
772
|
-
|
|
1106
|
+
// Pass the FULL-input ranges shifted into `rest` coordinates rather than
|
|
1107
|
+
// letting the helper re-tokenize the suffix. `rest` can begin mid-comment
|
|
1108
|
+
// or mid-raw-text after the skip path above, and a tokenizer restarted
|
|
1109
|
+
// there is in the wrong state: a text-only opener named later in that same
|
|
1110
|
+
// comment would read as a real element and mark everything to EOF inert,
|
|
1111
|
+
// so the boundary's real close tag was skipped and the trailing markup
|
|
1112
|
+
// folded into the boundary. The shifted view keeps the full-string truth,
|
|
1113
|
+
// including a first range that starts before `rest` does.
|
|
1114
|
+
const shifted = [];
|
|
1115
|
+
for (const [s, e] of inert) {
|
|
1116
|
+
if (e <= consumed) continue;
|
|
1117
|
+
shifted.push([Math.max(0, s - consumed), e - consumed]);
|
|
1118
|
+
}
|
|
1119
|
+
const closeIdx = findClosingTagInString(rest, openEnd, 'webjs-suspense', shifted);
|
|
773
1120
|
let inner;
|
|
774
1121
|
let afterClose;
|
|
775
1122
|
if (closeIdx === -1) {
|
|
@@ -792,6 +1139,7 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
|
|
|
792
1139
|
const innerProcessed = await injectDSD(inner, ctx, ancestors, dev);
|
|
793
1140
|
result += `<webjs-suspense>${innerProcessed}</webjs-suspense>`;
|
|
794
1141
|
}
|
|
1142
|
+
consumed += rest.length - afterClose.length;
|
|
795
1143
|
rest = afterClose;
|
|
796
1144
|
}
|
|
797
1145
|
return result;
|
|
@@ -807,18 +1155,34 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
|
|
|
807
1155
|
* @param {string} tagName
|
|
808
1156
|
* @returns {number}
|
|
809
1157
|
*/
|
|
810
|
-
function findClosingTagInString(html, fromIndex, tagName) {
|
|
1158
|
+
function findClosingTagInString(html, fromIndex, tagName, inert) {
|
|
811
1159
|
const esc = escapeRegex(tagName);
|
|
812
1160
|
// Match same-name opening tags. Followed by a name-boundary character
|
|
813
1161
|
// so we don't accept <table> as opening <tab>.
|
|
814
1162
|
const openRe = new RegExp(`<${esc}(?:[\\s>/])`, 'gi');
|
|
815
1163
|
const closeRe = new RegExp(`</${esc}\\s*>`, 'gi');
|
|
1164
|
+
// A tag inside a comment, raw text, RCDATA, or an attribute value is text
|
|
1165
|
+
// and must count for NEITHER side of the depth ledger (#1133). Counting a
|
|
1166
|
+
// commented `<my-card>` as a nested open meant depth never returned to zero,
|
|
1167
|
+
// and matching a commented `</my-card>` as the close truncated the authored
|
|
1168
|
+
// children at the comment, so the projected content ended with an
|
|
1169
|
+
// unterminated `<!--` that a browser read as commenting out the real close
|
|
1170
|
+
// tags. Callers that already computed the ranges for this exact string pass
|
|
1171
|
+
// them; a caller that did not gets them computed here.
|
|
1172
|
+
const ranges = inert === undefined ? inertRanges(html) : inert;
|
|
1173
|
+
const next = (re) => {
|
|
1174
|
+
let m;
|
|
1175
|
+
while ((m = re.exec(html)) !== null) {
|
|
1176
|
+
if (ranges.length === 0 || !inRanges(ranges, m.index)) return m;
|
|
1177
|
+
}
|
|
1178
|
+
return null;
|
|
1179
|
+
};
|
|
816
1180
|
openRe.lastIndex = fromIndex;
|
|
817
1181
|
closeRe.lastIndex = fromIndex;
|
|
818
1182
|
let depth = 1;
|
|
819
1183
|
while (depth > 0) {
|
|
820
|
-
const o = openRe
|
|
821
|
-
const c = closeRe
|
|
1184
|
+
const o = next(openRe);
|
|
1185
|
+
const c = next(closeRe);
|
|
822
1186
|
if (!c) return -1;
|
|
823
1187
|
if (o && o.index < c.index) {
|
|
824
1188
|
depth++;
|
|
@@ -875,14 +1239,20 @@ function partitionAuthoredBySlot(html) {
|
|
|
875
1239
|
if (lt > cursor) defaultBuf += html.slice(cursor, lt);
|
|
876
1240
|
const rest = html.slice(lt);
|
|
877
1241
|
if (rest.startsWith('<!--')) {
|
|
878
|
-
|
|
879
|
-
|
|
1242
|
+
// Find the comment's end the same way inertRanges does, rather than with
|
|
1243
|
+
// a bare `indexOf('-->')`. The two helpers both decide where a comment
|
|
1244
|
+
// stops, so a bare search makes them DISAGREE on the spec short forms
|
|
1245
|
+
// (`--!>`, `<!-->`, `<!--->`): this one would run past the real end and
|
|
1246
|
+
// swallow the slotted children that follow, silently routing a
|
|
1247
|
+
// `slot="head"` child into the default slot.
|
|
1248
|
+
const commentEnd = endOfComment(html, lt);
|
|
1249
|
+
if (commentEnd === -1) {
|
|
880
1250
|
defaultBuf += rest;
|
|
881
1251
|
cursor = html.length;
|
|
882
1252
|
break;
|
|
883
1253
|
}
|
|
884
|
-
defaultBuf += html.slice(lt,
|
|
885
|
-
cursor =
|
|
1254
|
+
defaultBuf += html.slice(lt, commentEnd);
|
|
1255
|
+
cursor = commentEnd;
|
|
886
1256
|
continue;
|
|
887
1257
|
}
|
|
888
1258
|
if (rest.startsWith('<!') || rest.startsWith('</')) {
|
|
@@ -968,8 +1338,16 @@ function substituteSlotsInRender(rendered, partitioned, ownerTag) {
|
|
|
968
1338
|
let result = '';
|
|
969
1339
|
let cursor = 0;
|
|
970
1340
|
const slotRe = /<slot((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/gi;
|
|
1341
|
+
// A `<slot>` written inside a comment is documentation, not a slot (#1128).
|
|
1342
|
+
// Substituting one is worse here than in the element walk: a commented slot
|
|
1343
|
+
// has no `</slot>`, so the fallback scan below swallows the rest of the
|
|
1344
|
+
// template, the component's REAL slot is never substituted, and the authored
|
|
1345
|
+
// children are dropped from the page entirely.
|
|
1346
|
+
const inert = inertRanges(rendered);
|
|
1347
|
+
const isInert = inertAt(inert);
|
|
971
1348
|
let m;
|
|
972
1349
|
while ((m = slotRe.exec(rendered)) !== null) {
|
|
1350
|
+
if (isInert(m.index)) continue;
|
|
973
1351
|
result += rendered.slice(cursor, m.index);
|
|
974
1352
|
const [fullOpen, attrsRaw, selfCloseSlash] = m;
|
|
975
1353
|
const isSelfClose = !!selfCloseSlash;
|
|
@@ -984,7 +1362,7 @@ function substituteSlotsInRender(rendered, partitioned, ownerTag) {
|
|
|
984
1362
|
totalEnd = m.index + fullOpen.length;
|
|
985
1363
|
} else {
|
|
986
1364
|
const innerStart = m.index + fullOpen.length;
|
|
987
|
-
const closeIdx = findClosingTagInString(rendered, innerStart, 'slot');
|
|
1365
|
+
const closeIdx = findClosingTagInString(rendered, innerStart, 'slot', inert);
|
|
988
1366
|
if (closeIdx === -1) {
|
|
989
1367
|
fallback = rendered.slice(innerStart);
|
|
990
1368
|
totalEnd = rendered.length;
|
|
@@ -1028,6 +1406,41 @@ function isRawtextTag(tag) {
|
|
|
1028
1406
|
return tag === 'script' || tag === 'style';
|
|
1029
1407
|
}
|
|
1030
1408
|
|
|
1409
|
+
/**
|
|
1410
|
+
* RCDATA elements: their content is text (character references aside), so a
|
|
1411
|
+
* tag-shaped string inside one is not markup. Kept next to `isRawtextTag` so
|
|
1412
|
+
* the two lists stay together rather than drifting apart.
|
|
1413
|
+
* @param {string} tag
|
|
1414
|
+
* @returns {boolean}
|
|
1415
|
+
*/
|
|
1416
|
+
function isRcdataTag(tag) {
|
|
1417
|
+
return tag === 'textarea' || tag === 'title';
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Elements whose content the HTML tokenizer never reads as markup, for the
|
|
1422
|
+
* purposes of `inertRanges` only (#1128).
|
|
1423
|
+
*
|
|
1424
|
+
* Deliberately NOT `isRawtextTag`, even though it overlaps: that predicate is
|
|
1425
|
+
* shared with the template tokenizer, where widening it would change how holes
|
|
1426
|
+
* inside those elements are escaped. This one answers a narrower question,
|
|
1427
|
+
* "can a tag-shaped string in here be a real element", and the answer is no for
|
|
1428
|
+
* every raw-text and RCDATA element, not just the two the template path cares
|
|
1429
|
+
* about. `<iframe>` with fallback markup is the realistic trigger.
|
|
1430
|
+
*
|
|
1431
|
+
* `<noscript>` is excluded on purpose: its content IS parsed as markup when
|
|
1432
|
+
* scripting is disabled, which for a progressive-enhancement framework is the
|
|
1433
|
+
* case that matters, so components inside it must keep rendering.
|
|
1434
|
+
*
|
|
1435
|
+
* @param {string} tag
|
|
1436
|
+
* @returns {boolean}
|
|
1437
|
+
*/
|
|
1438
|
+
function isTextOnlyTag(tag) {
|
|
1439
|
+
return isRawtextTag(tag) || isRcdataTag(tag)
|
|
1440
|
+
|| tag === 'iframe' || tag === 'xmp' || tag === 'noembed'
|
|
1441
|
+
|| tag === 'noframes' || tag === 'plaintext';
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1031
1444
|
/**
|
|
1032
1445
|
* Minimal attribute string parser.
|
|
1033
1446
|
* @param {string} attrStr
|
|
@@ -1484,20 +1897,45 @@ async function streamTemplate(tr, ctx, controller) {
|
|
|
1484
1897
|
const name = attrName.slice(1);
|
|
1485
1898
|
const kind = BINDING_PREFIXES[prefix];
|
|
1486
1899
|
if (kind === 'event' || kind === 'prop') {
|
|
1900
|
+
// Guard `prop` ONLY, matching the buffered machine. An `@action`
|
|
1901
|
+
// event binding is dropped here and never stringified, and a
|
|
1902
|
+
// function is the LEGITIMATE value for one (`<my-el @action=${fn}>`
|
|
1903
|
+
// listens for an `action` event), so refusing it would be a false
|
|
1904
|
+
// positive. `.action` differs where the property REFLECTS: on a form
|
|
1905
|
+
// (and `.formAction` on a button or input) the client assignment
|
|
1906
|
+
// writes the source into the DOM, so refusing at SSR keeps a page
|
|
1907
|
+
// from rendering clean on the server and throwing on hydration.
|
|
1908
|
+
// Elsewhere, including a custom element, the property reflects
|
|
1909
|
+
// nothing and a function stays legal.
|
|
1910
|
+
if (kind === 'prop' && !currentTag.includes('-')) {
|
|
1911
|
+
assertNotFunctionReflectedActionProp(val, name, currentTag);
|
|
1912
|
+
}
|
|
1487
1913
|
buf = buf.slice(0, attrStart);
|
|
1488
1914
|
state = 'in-tag';
|
|
1489
1915
|
attrName = '';
|
|
1490
1916
|
} else if (kind === 'bool') {
|
|
1917
|
+
// A boolean binding stringifies nothing, so this never leaked, but
|
|
1918
|
+
// `?action=${fn}` is meaningless in every case (a truthy function
|
|
1919
|
+
// emits a bare `action=""`), and refusing it keeps the rule the docs
|
|
1920
|
+
// state true for every sigil rather than true only when quoted.
|
|
1921
|
+
assertNotFunctionActionAttr(val, name, currentTag);
|
|
1491
1922
|
buf = buf.slice(0, attrStart);
|
|
1492
1923
|
if (val) buf += `${name}=""`;
|
|
1493
1924
|
state = 'in-tag';
|
|
1494
1925
|
attrName = '';
|
|
1495
1926
|
} else {
|
|
1927
|
+
// The SAME guard as the buffered renderer above. This is a second,
|
|
1928
|
+
// independent state machine, so it inherits nothing from that one;
|
|
1929
|
+
// a change to the rule has to land in both. Reached only via
|
|
1930
|
+
// `renderToStream(v, { ssr: false })`, which no page render uses, so
|
|
1931
|
+
// this covers the public API surface rather than a page leak.
|
|
1932
|
+
assertNotFunctionActionAttr(val, attrName, currentTag);
|
|
1496
1933
|
buf += `"${escapeAttr(String(val ?? ''))}"`;
|
|
1497
1934
|
state = 'in-tag';
|
|
1498
1935
|
attrName = '';
|
|
1499
1936
|
}
|
|
1500
1937
|
} else if (state === 'attr-quoted' || state === 'attr-unquoted') {
|
|
1938
|
+
assertNotFunctionActionAttr(val, attrName, currentTag);
|
|
1501
1939
|
buf += escapeAttr(String(val ?? ''));
|
|
1502
1940
|
}
|
|
1503
1941
|
}
|