@marianmeres/safe-html 0.2.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.
@@ -0,0 +1,742 @@
1
+ /**
2
+ * Context analysis (the scanner).
3
+ *
4
+ * Purpose: classify every slot of a template from its static strings alone, once per call
5
+ * site, and cache the result (including a failure) by template object.
6
+ *
7
+ * Invariants:
8
+ * - Models only the part of the HTML tokenizer, and of the tree construction that switches the
9
+ * tokenizer (raw text, RCDATA, SVG/MathML foreign content), needed to classify slots.
10
+ * Anything it does not model is an error, never a guess.
11
+ * - A slot never changes the scanner state: every value renders so that it can't.
12
+ * - A template ends where it started: in text, outside any tag, comment, raw text or foreign
13
+ * content. That makes a fragment safe to drop into another template's text slot.
14
+ * - Foreign content: a fragment is "neutral" when a second scan that starts inside SVG/MathML
15
+ * agrees with the HTML scan; only neutral fragments may go into SVG/MathML text slots.
16
+ */
17
+ import { HtmlTemplateError } from "./errors.js";
18
+ import { LEADING_C0_SPACE, ONLY_C0_SPACE } from "./url.js";
19
+ /** Creates a slot description (always the same shape, which keeps rendering monomorphic). */
20
+ export function slotInfo(context, tag = "", attr = "", scheme = "", foreign = false) {
21
+ return Object.freeze({ context, tag, attr, scheme, foreign });
22
+ }
23
+ // ---- character classes ------------------------------------------------------------------
24
+ const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\f" || c === "\r";
25
+ const isAlpha = (c) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
26
+ const isSchemeChar = (c) => isAlpha(c) || (c >= "0" && c <= "9") || c === "+" || c === "." || c === "-";
27
+ const WS_ONLY = /^[\t\n\f\r ]*$/;
28
+ /** Elements whose start tag switches the tokenizer (HTML mode only). */
29
+ const RAW_KIND = new Map([
30
+ ["script", "script"],
31
+ ["style", "style"],
32
+ ["title", "rcdata"],
33
+ ["textarea", "rcdata"],
34
+ ["iframe", "unsupported"],
35
+ ["noembed", "unsupported"],
36
+ ["noframes", "unsupported"],
37
+ ["noscript", "unsupported"],
38
+ ["xmp", "unsupported"],
39
+ ["plaintext", "plaintext"],
40
+ ]);
41
+ /** Start tags that end SVG/MathML foreign content (`font` only with some attributes; always here). */
42
+ const BREAKOUT = new Set(("b big blockquote body br center code dd div dl dt em embed font h1 h2 h3 h4 h5 h6 head " +
43
+ "hr i img li listing menu meta nobr ol p pre ruby s small span strong strike sub sup " +
44
+ "table tt u ul var").split(" "));
45
+ /** SVG HTML integration points: their content is parsed as HTML. */
46
+ const SVG_INTEGRATION = new Set(["foreignobject", "desc", "title"]);
47
+ /** MathML text integration points and `annotation-xml`. */
48
+ const MATH_INTEGRATION = new Set([
49
+ "mi",
50
+ "mo",
51
+ "mn",
52
+ "ms",
53
+ "mtext",
54
+ "annotation-xml",
55
+ ]);
56
+ /** SVG animation elements, which can set another attribute (e.g. `href`) to any value. */
57
+ const SVG_ANIMATION = new Set(["animate", "set"]);
58
+ const SVG_ANIMATION_ATTRS = new Set([
59
+ "attributename",
60
+ "to",
61
+ "from",
62
+ "by",
63
+ "values",
64
+ ]);
65
+ /** Attributes whose value is a URL (plus `data` on `<object>`). */
66
+ export const URL_ATTRIBUTES = new Set([
67
+ "href",
68
+ "src",
69
+ "action",
70
+ "formaction",
71
+ "poster",
72
+ "cite",
73
+ "background",
74
+ "longdesc",
75
+ "manifest",
76
+ "codebase",
77
+ "icon",
78
+ "xlink:href",
79
+ ]);
80
+ const isUrlAttr = (tag, attr) => URL_ATTRIBUTES.has(attr) || (attr === "data" && tag === "object");
81
+ // ---- tokenizer states -------------------------------------------------------------------
82
+ const TEXT = 0;
83
+ const TAG_OPEN = 1;
84
+ const END_TAG_OPEN = 2;
85
+ const TAG_NAME = 3;
86
+ const BEFORE_ATTR_NAME = 4;
87
+ const ATTR_NAME = 5;
88
+ const AFTER_ATTR_NAME = 6;
89
+ const BEFORE_ATTR_VALUE = 7;
90
+ const ATTR_VALUE_QUOTED = 8;
91
+ const ATTR_VALUE_UNQUOTED = 9;
92
+ const AFTER_ATTR_VALUE_QUOTED = 10;
93
+ const SELF_CLOSING = 11;
94
+ const MARKUP_DECL = 12;
95
+ const COMMENT = 13;
96
+ const BOGUS_COMMENT = 14;
97
+ const RAW = 15;
98
+ /** Internal failure; turned into an `HtmlTemplateError` with an excerpt. */
99
+ class ScanError {
100
+ reason;
101
+ slot;
102
+ chunk;
103
+ offset;
104
+ useRaw;
105
+ constructor(reason, slot, chunk, offset, useRaw = false) {
106
+ this.reason = reason;
107
+ this.slot = slot;
108
+ this.chunk = chunk;
109
+ this.offset = offset;
110
+ this.useRaw = useRaw;
111
+ }
112
+ }
113
+ const closerCache = new Map();
114
+ function closerRe(name) {
115
+ let re = closerCache.get(name);
116
+ if (!re) {
117
+ re = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
118
+ closerCache.set(name, re);
119
+ }
120
+ return re;
121
+ }
122
+ /** Does `text` end with a non-empty prefix of `seq` (case-insensitive)? E.g. `"<"`, `"</scr"`. */
123
+ function endsWithPartial(text, seq) {
124
+ const lt = text.lastIndexOf("<");
125
+ return lt !== -1 && seq.startsWith(text.slice(lt).toLowerCase());
126
+ }
127
+ /**
128
+ * The static text before the first slot of a URL attribute (normalized, non-empty):
129
+ * returns the lowercased scheme it writes, `""` if it settles the URL as relative, or `false`
130
+ * if it could still become the start of a scheme. Text from the first `&` on is ignored,
131
+ * because a character reference may decode to scheme characters.
132
+ */
133
+ function settleScheme(prefix) {
134
+ const amp = prefix.indexOf("&");
135
+ const p = amp === -1 ? prefix : prefix.slice(0, amp);
136
+ if (p === "")
137
+ return false;
138
+ if (!isAlpha(p[0]))
139
+ return "";
140
+ for (let i = 1; i < p.length; i++) {
141
+ if (p[i] === ":")
142
+ return p.slice(0, i).toLowerCase();
143
+ if (!isSchemeChar(p[i]))
144
+ return "";
145
+ }
146
+ return false;
147
+ }
148
+ class Scanner {
149
+ strings;
150
+ floor;
151
+ slots = [];
152
+ state = TEXT;
153
+ k = 0; // current chunk
154
+ // current tag and attribute
155
+ tag = "";
156
+ isEnd = false;
157
+ selfClosing = false;
158
+ attr = "";
159
+ quote = "";
160
+ valueStatic = "";
161
+ valueSlots = 0;
162
+ // raw text / RCDATA (HTML mode)
163
+ raw = "";
164
+ rawKind = "script";
165
+ rawStart = 0;
166
+ // foreign content: ns is the namespace of the outermost <svg>/<math> ("generic" for the
167
+ // virtual one a neutrality scan starts inside); stack holds the open svg/math elements
168
+ ns = "";
169
+ stack = [];
170
+ foreignRaw = ""; // inside <script>/<style> in foreign content
171
+ integration = ""; // inside an integration point
172
+ integrationHtml = false; // SVG integration point (content parsed as HTML)
173
+ constructor(strings, floor) {
174
+ this.strings = strings;
175
+ this.floor = floor;
176
+ if (floor)
177
+ this.ns = "generic";
178
+ }
179
+ run() {
180
+ const n = this.strings.length;
181
+ for (let k = 0; k < n; k++) {
182
+ this.k = k;
183
+ this.chunk(this.strings[k]);
184
+ if (k < n - 1)
185
+ this.slot(k);
186
+ }
187
+ this.end();
188
+ return this.slots;
189
+ }
190
+ fail(reason, offset) {
191
+ throw new ScanError(reason, undefined, this.k, offset);
192
+ }
193
+ chunk(s) {
194
+ this.rawStart = 0;
195
+ const len = s.length;
196
+ let i = 0;
197
+ while (i < len) {
198
+ const c = s[i];
199
+ switch (this.state) {
200
+ case TEXT: {
201
+ const j = s.indexOf("<", i);
202
+ if (j === -1)
203
+ i = len;
204
+ else {
205
+ this.state = TAG_OPEN;
206
+ i = j + 1;
207
+ }
208
+ break;
209
+ }
210
+ case TAG_OPEN:
211
+ if (c === "!")
212
+ i = this.markupDecl(s, i + 1);
213
+ else if (c === "/") {
214
+ this.state = END_TAG_OPEN;
215
+ i++;
216
+ }
217
+ else if (isAlpha(c)) {
218
+ this.startTag(c, false);
219
+ i++;
220
+ }
221
+ else if (c === "?") {
222
+ this.state = BOGUS_COMMENT;
223
+ i++;
224
+ }
225
+ else
226
+ this.state = TEXT; // a stray "<" is text
227
+ break;
228
+ case END_TAG_OPEN:
229
+ if (isAlpha(c)) {
230
+ this.startTag(c, true);
231
+ i++;
232
+ }
233
+ else if (c === ">") {
234
+ this.state = TEXT; // "</>" is ignored
235
+ i++;
236
+ }
237
+ else
238
+ this.state = BOGUS_COMMENT;
239
+ break;
240
+ case TAG_NAME:
241
+ if (isWs(c)) {
242
+ this.state = BEFORE_ATTR_NAME;
243
+ i++;
244
+ }
245
+ else if (c === "/") {
246
+ this.state = SELF_CLOSING;
247
+ i++;
248
+ }
249
+ else if (c === ">")
250
+ i = this.emitTag(i);
251
+ else {
252
+ this.tag += c.toLowerCase();
253
+ i++;
254
+ }
255
+ break;
256
+ case BEFORE_ATTR_NAME:
257
+ case AFTER_ATTR_NAME:
258
+ if (isWs(c))
259
+ i++;
260
+ else if (c === "/") {
261
+ this.state = SELF_CLOSING;
262
+ i++;
263
+ }
264
+ else if (c === ">")
265
+ i = this.emitTag(i);
266
+ else if (c === "=" && this.state === AFTER_ATTR_NAME) {
267
+ this.state = BEFORE_ATTR_VALUE;
268
+ i++;
269
+ }
270
+ else {
271
+ this.attr = c.toLowerCase();
272
+ this.state = ATTR_NAME;
273
+ i++;
274
+ }
275
+ break;
276
+ case ATTR_NAME:
277
+ if (isWs(c)) {
278
+ this.state = AFTER_ATTR_NAME;
279
+ i++;
280
+ }
281
+ else if (c === "/") {
282
+ this.state = SELF_CLOSING;
283
+ i++;
284
+ }
285
+ else if (c === "=") {
286
+ this.state = BEFORE_ATTR_VALUE;
287
+ i++;
288
+ }
289
+ else if (c === ">")
290
+ i = this.emitTag(i);
291
+ else {
292
+ this.attr += c.toLowerCase();
293
+ i++;
294
+ }
295
+ break;
296
+ case BEFORE_ATTR_VALUE:
297
+ if (isWs(c))
298
+ i++;
299
+ else if (c === '"' || c === "'") {
300
+ this.quote = c;
301
+ this.valueStatic = "";
302
+ this.valueSlots = 0;
303
+ this.state = ATTR_VALUE_QUOTED;
304
+ i++;
305
+ }
306
+ else if (c === ">")
307
+ i = this.emitTag(i);
308
+ else
309
+ this.state = ATTR_VALUE_UNQUOTED;
310
+ break;
311
+ case ATTR_VALUE_QUOTED: {
312
+ const j = s.indexOf(this.quote, i);
313
+ if (j === -1) {
314
+ this.valueStatic += s.slice(i);
315
+ i = len;
316
+ }
317
+ else {
318
+ this.valueStatic += s.slice(i, j);
319
+ this.state = AFTER_ATTR_VALUE_QUOTED;
320
+ i = j + 1;
321
+ }
322
+ break;
323
+ }
324
+ case ATTR_VALUE_UNQUOTED:
325
+ if (isWs(c)) {
326
+ this.state = BEFORE_ATTR_NAME;
327
+ i++;
328
+ }
329
+ else if (c === ">")
330
+ i = this.emitTag(i);
331
+ else
332
+ i++;
333
+ break;
334
+ case AFTER_ATTR_VALUE_QUOTED:
335
+ if (isWs(c)) {
336
+ this.state = BEFORE_ATTR_NAME;
337
+ i++;
338
+ }
339
+ else if (c === "/") {
340
+ this.state = SELF_CLOSING;
341
+ i++;
342
+ }
343
+ else if (c === ">")
344
+ i = this.emitTag(i);
345
+ else
346
+ this.state = BEFORE_ATTR_NAME;
347
+ break;
348
+ case SELF_CLOSING:
349
+ if (c === ">") {
350
+ this.selfClosing = true;
351
+ i = this.emitTag(i);
352
+ }
353
+ else
354
+ this.state = BEFORE_ATTR_NAME;
355
+ break;
356
+ case BOGUS_COMMENT: {
357
+ const j = s.indexOf(">", i);
358
+ if (j === -1)
359
+ i = len;
360
+ else {
361
+ this.state = TEXT;
362
+ i = j + 1;
363
+ }
364
+ break;
365
+ }
366
+ case RAW:
367
+ i = this.rawText(s, i);
368
+ break;
369
+ default: // COMMENT, MARKUP_DECL: unterminated in this chunk
370
+ i = len;
371
+ }
372
+ }
373
+ }
374
+ startTag(c, isEnd) {
375
+ this.tag = c.toLowerCase();
376
+ this.isEnd = isEnd;
377
+ this.selfClosing = false;
378
+ this.attr = "";
379
+ this.state = TAG_NAME;
380
+ }
381
+ /** After `<!`. Returns the next index. */
382
+ markupDecl(s, i) {
383
+ if (s.startsWith("--", i)) {
384
+ const j = i + 2;
385
+ this.state = TEXT;
386
+ if (s[j] === ">")
387
+ return j + 1; // <!-->
388
+ if (s.startsWith("->", j))
389
+ return j + 2; // <!--->
390
+ const a = s.indexOf("-->", j);
391
+ const b = s.indexOf("--!>", j);
392
+ if (b !== -1 && (a === -1 || b < a))
393
+ return b + 4;
394
+ if (a !== -1)
395
+ return a + 3;
396
+ this.state = COMMENT;
397
+ return s.length;
398
+ }
399
+ const head = s.slice(i, i + 7);
400
+ if (head.toUpperCase() === "[CDATA[") {
401
+ this.fail("CDATA sections are not supported", i - 2);
402
+ }
403
+ if (i + head.length === s.length &&
404
+ ("--".startsWith(head) || "[CDATA[".startsWith(head.toUpperCase()))) {
405
+ // the chunk ends before the kind of declaration is known
406
+ this.state = MARKUP_DECL;
407
+ return s.length;
408
+ }
409
+ this.state = BOGUS_COMMENT; // <!doctype …>, <!…>
410
+ return i;
411
+ }
412
+ /** Raw text of the current element (HTML mode). Returns the next index. */
413
+ rawText(s, i) {
414
+ if (this.rawKind === "plaintext")
415
+ return s.length;
416
+ const re = closerRe(this.raw);
417
+ re.lastIndex = i;
418
+ const m = re.exec(s);
419
+ const end = m ? m.index : -1;
420
+ if (this.rawKind === "script") {
421
+ const c = s.indexOf("<!--", i);
422
+ if (c !== -1 && (end === -1 || c < end)) {
423
+ this.fail('"<!--" inside <script> is not supported (script-data escape states are not modeled)', c);
424
+ }
425
+ }
426
+ if (end === -1)
427
+ return s.length;
428
+ // the end tag: continue in the tag machinery, which handles the terminator
429
+ this.startTag(this.raw[0], true);
430
+ this.tag = this.raw;
431
+ this.raw = "";
432
+ return end + 2 + this.tag.length;
433
+ }
434
+ /** Called with `i` at the `>` of a tag. Returns the next index. */
435
+ emitTag(i) {
436
+ const tag = this.tag;
437
+ this.state = TEXT;
438
+ if (this.isEnd) {
439
+ this.endTag(tag, i);
440
+ return i + 1;
441
+ }
442
+ if (this.integration) {
443
+ this.fail(`<${tag}> inside <${this.integration}> (an SVG/MathML integration point) is not supported`, i);
444
+ }
445
+ if (this.ns) {
446
+ if (BREAKOUT.has(tag)) {
447
+ this.fail(`<${tag}> inside <svg>/<math> ends the foreign content; close the <svg>/<math> first`, i);
448
+ }
449
+ if (this.selfClosing)
450
+ return i + 1; // honored in foreign content
451
+ if (tag === "svg" || tag === "math")
452
+ this.stack.push(tag);
453
+ else if (tag === "script" || tag === "style")
454
+ this.foreignRaw ||= tag;
455
+ else if ((this.ns === "svg" && SVG_INTEGRATION.has(tag)) ||
456
+ (this.ns === "math" && MATH_INTEGRATION.has(tag))) {
457
+ this.integration = tag;
458
+ this.integrationHtml = this.ns === "svg";
459
+ }
460
+ return i + 1;
461
+ }
462
+ if (tag === "svg" || tag === "math") {
463
+ if (!this.selfClosing) {
464
+ this.ns = tag;
465
+ this.stack = [tag];
466
+ }
467
+ return i + 1;
468
+ }
469
+ const kind = RAW_KIND.get(tag); // the self-closing flag is ignored here, as in HTML
470
+ if (kind) {
471
+ this.raw = tag;
472
+ this.rawKind = kind;
473
+ this.rawStart = i + 1;
474
+ this.state = RAW;
475
+ }
476
+ return i + 1;
477
+ }
478
+ endTag(tag, i) {
479
+ if (this.integration) {
480
+ if (tag === this.integration)
481
+ this.integration = "";
482
+ else
483
+ this.fail(`</${tag}> inside <${this.integration}> is not supported`, i);
484
+ return;
485
+ }
486
+ if (!this.ns) {
487
+ if (tag === "svg" || tag === "math") {
488
+ this.fail(`</${tag}> without a matching <${tag}> in the same template`, i);
489
+ }
490
+ return;
491
+ }
492
+ if (tag === "br" || tag === "p") {
493
+ this.fail(`</${tag}> inside <svg>/<math> ends the foreign content`, i);
494
+ }
495
+ if (tag === this.foreignRaw)
496
+ this.foreignRaw = "";
497
+ else if (tag === "svg" || tag === "math") {
498
+ const at = this.stack.lastIndexOf(tag);
499
+ if (at === -1) {
500
+ this.fail(`</${tag}> without a matching <${tag}> in the same template`, i);
501
+ }
502
+ this.stack.length = at;
503
+ this.foreignRaw = "";
504
+ if (!this.stack.length && !this.floor)
505
+ this.ns = "";
506
+ }
507
+ }
508
+ slot(k) {
509
+ const next = this.strings[k + 1];
510
+ const fail = (reason) => {
511
+ throw new ScanError(reason, k);
512
+ };
513
+ switch (this.state) {
514
+ case TEXT:
515
+ if (this.foreignRaw) {
516
+ fail(`slot inside <${this.foreignRaw}> within <svg>/<math> is not supported`);
517
+ }
518
+ this.slots.push(slotInfo("text", "", "", "", this.integration ? !this.integrationHtml : this.ns !== ""));
519
+ return;
520
+ case RAW: {
521
+ const kind = this.rawKind;
522
+ if (kind === "unsupported" || kind === "plaintext") {
523
+ fail(`slot inside <${this.raw}> is not supported`);
524
+ }
525
+ const before = this.strings[k].slice(this.rawStart);
526
+ if (endsWithPartial(before, "</" + this.raw) ||
527
+ (kind === "script" && endsWithPartial(before, "<!--"))) {
528
+ fail(`slot right after "<" inside <${this.raw}>: add a space between them`);
529
+ }
530
+ this.slots.push(slotInfo(kind, this.raw));
531
+ return;
532
+ }
533
+ case ATTR_VALUE_QUOTED:
534
+ return this.valueSlot(next, fail);
535
+ case BEFORE_ATTR_NAME:
536
+ case AFTER_ATTR_NAME:
537
+ case AFTER_ATTR_VALUE_QUOTED:
538
+ if (this.isEnd)
539
+ fail("slot inside an end tag");
540
+ if (SVG_ANIMATION.has(this.tag)) {
541
+ fail(`attribute-list slot on <${this.tag}> is not supported`);
542
+ }
543
+ if (next !== "" && !(isWs(next[0]) || next[0] === ">" || next[0] === "/")) {
544
+ fail('an attribute-list slot must be followed by whitespace, ">" or "/"');
545
+ }
546
+ if (/^[\t\n\f\r ]*=/.test(next)) {
547
+ fail('an attribute-list slot can\'t be followed by "="');
548
+ }
549
+ this.slots.push(slotInfo("attr-list", this.tag));
550
+ this.state = BEFORE_ATTR_NAME;
551
+ return;
552
+ case TAG_OPEN:
553
+ case END_TAG_OPEN:
554
+ case TAG_NAME:
555
+ fail("slot in a tag name: tag names must be static");
556
+ break;
557
+ case ATTR_NAME:
558
+ fail("slot in or right after an attribute name: add whitespace before it, or use attrs()");
559
+ break;
560
+ case BEFORE_ATTR_VALUE:
561
+ case ATTR_VALUE_UNQUOTED:
562
+ fail('unquoted attribute value: quote it, e.g. name="${…}"');
563
+ break;
564
+ case SELF_CLOSING:
565
+ fail('slot right after "/" in a tag');
566
+ break;
567
+ default:
568
+ fail("slot inside a comment or declaration");
569
+ }
570
+ }
571
+ valueSlot(next, fail) {
572
+ const { tag, attr } = this;
573
+ if (this.isEnd)
574
+ fail("slot inside an end tag");
575
+ if (attr.startsWith("on")) {
576
+ fail(`slot in event-handler attribute "${attr}": write the handler literally, or use scriptText() in a <script>`);
577
+ }
578
+ if (attr === "srcdoc") {
579
+ fail('slot in "srcdoc" (HTML inside an attribute) is not supported');
580
+ }
581
+ if (attr === "ping")
582
+ fail('slot in "ping" (a URL list) is not supported');
583
+ if (SVG_ANIMATION.has(tag) && SVG_ANIMATION_ATTRS.has(attr)) {
584
+ fail(`slot in "${attr}" on <${tag}> is not supported (SVG animation can set href)`);
585
+ }
586
+ const q = next.indexOf(this.quote);
587
+ const after = q === -1 ? null : next.slice(0, q); // null: another slot comes first
588
+ let info;
589
+ if (attr === "srcset" || attr === "imagesrcset") {
590
+ if (this.valueSlots > 0 || !WS_ONLY.test(this.valueStatic) ||
591
+ after === null ||
592
+ !WS_ONLY.test(after)) {
593
+ fail(`a "${attr}" slot must be the whole value: use srcset([...])`);
594
+ }
595
+ info = slotInfo("srcset", tag, attr);
596
+ }
597
+ else if (!isUrlAttr(tag, attr) || this.valueSlots > 0) {
598
+ // a later slot in a URL value: the first one already settled the scheme
599
+ info = slotInfo("attr-value", tag, attr);
600
+ }
601
+ else {
602
+ const prefix = this.valueStatic.replace(/[\t\n\r]/g, "").replace(LEADING_C0_SPACE, "");
603
+ if (prefix === "") {
604
+ const rest = (after ?? next).replace(/[\t\n\r]/g, "");
605
+ const ok = /^[/?#]/.test(rest) ||
606
+ (after !== null && ONLY_C0_SPACE.test(rest));
607
+ if (!ok) {
608
+ fail('a URL value that starts with a slot must be the slot alone, or continue with "/", "?" or "#": build the URL in code');
609
+ }
610
+ info = slotInfo("url", tag, attr);
611
+ }
612
+ else {
613
+ const scheme = settleScheme(prefix);
614
+ if (scheme === false) {
615
+ fail("ambiguous URL scheme: the static text before the slot could still become a scheme; build the URL in code");
616
+ }
617
+ info = slotInfo("attr-value", tag, attr, scheme);
618
+ }
619
+ }
620
+ this.slots.push(info);
621
+ this.valueSlots++;
622
+ }
623
+ end() {
624
+ const last = this.strings.length - 1;
625
+ const at = this.strings[last].length;
626
+ const fail = (reason) => {
627
+ throw new ScanError(reason, undefined, last, at);
628
+ };
629
+ switch (this.state) {
630
+ case TEXT:
631
+ break;
632
+ case RAW:
633
+ fail(`template ends inside <${this.raw}>: close it in the same template`);
634
+ break;
635
+ case COMMENT:
636
+ case BOGUS_COMMENT:
637
+ case MARKUP_DECL:
638
+ fail("template ends inside a comment or declaration");
639
+ break;
640
+ default:
641
+ fail("template ends inside a tag");
642
+ }
643
+ if (this.integration)
644
+ fail(`template ends inside <${this.integration}>`);
645
+ if (this.foreignRaw)
646
+ fail(`template ends inside <${this.foreignRaw}>`);
647
+ if (this.stack.length) {
648
+ fail(`template ends inside <${this.stack[0]}>: close it in the same template`);
649
+ }
650
+ }
651
+ }
652
+ // ---- cache ------------------------------------------------------------------------------
653
+ const cache = new WeakMap();
654
+ let analyses = 0;
655
+ /** Number of analyses run so far. For tests only (not exported from mod.ts). */
656
+ export function analysisCount() {
657
+ return analyses;
658
+ }
659
+ function compute(strings) {
660
+ for (let i = 0; i < strings.length; i++) {
661
+ if (typeof strings[i] !== "string") {
662
+ return new ScanError("invalid escape sequence in the template", undefined, i, 0, true);
663
+ }
664
+ }
665
+ try {
666
+ const slots = new Scanner(strings, false).run();
667
+ let neutral = false;
668
+ try {
669
+ const f = new Scanner(strings, true).run();
670
+ neutral = slots.every((h, i) => {
671
+ const g = f[i];
672
+ // rcdata accepts a subset of what text accepts, and renders it identically
673
+ if (h.context === "rcdata")
674
+ return g.context === "text";
675
+ return h.context === g.context && h.tag === g.tag && h.attr === g.attr &&
676
+ h.scheme === g.scheme;
677
+ });
678
+ }
679
+ catch (e) {
680
+ if (!(e instanceof ScanError))
681
+ throw e;
682
+ }
683
+ return { slots, neutral };
684
+ }
685
+ catch (e) {
686
+ if (e instanceof ScanError)
687
+ return e;
688
+ throw e;
689
+ }
690
+ }
691
+ /**
692
+ * The analysis of a template, from the cache or computed once. Throws `HtmlTemplateError` if
693
+ * `strings` is not a genuine template object, or if the template is refused.
694
+ */
695
+ export function analyze(strings) {
696
+ let a = cache.get(strings);
697
+ if (a === undefined) {
698
+ if (!Array.isArray(strings) || !Object.isFrozen(strings) ||
699
+ !Array.isArray(strings.raw) || !Object.isFrozen(strings.raw) ||
700
+ strings.raw.length !== strings.length || strings.length === 0) {
701
+ throw new HtmlTemplateError("html must be called as a tagged template: html`…`");
702
+ }
703
+ analyses++;
704
+ a = compute(strings);
705
+ cache.set(strings, a);
706
+ }
707
+ if (a instanceof ScanError) {
708
+ throw templateError(strings, a.reason, a.slot, a.chunk, a.offset, a.useRaw);
709
+ }
710
+ return a;
711
+ }
712
+ // ---- excerpts ---------------------------------------------------------------------------
713
+ const collapse = (s) => s.replace(/\s+/g, " ");
714
+ /**
715
+ * An `HtmlTemplateError` whose excerpt shows the static text with slots as `${…}` and the
716
+ * error position (a slot, or a chunk offset) marked `⟨here⟩`.
717
+ */
718
+ export function templateError(strings, reason, slot, chunk, offset, useRaw = false) {
719
+ const src = useRaw ? strings.raw : strings;
720
+ let text = "";
721
+ let mark = 0;
722
+ for (let k = 0; k < src.length; k++) {
723
+ const s = src[k] ?? "";
724
+ if (k === chunk && offset !== undefined) {
725
+ text += collapse(s.slice(0, offset));
726
+ mark = text.length;
727
+ text += "⟨here⟩" + collapse(s.slice(offset));
728
+ }
729
+ else
730
+ text += collapse(s);
731
+ if (k < src.length - 1) {
732
+ if (k === slot)
733
+ mark = text.length;
734
+ text += k === slot ? "${⟨here⟩}" : "${…}";
735
+ }
736
+ }
737
+ const from = Math.max(0, mark - 40);
738
+ const to = Math.min(text.length, mark + 50);
739
+ const excerpt = (from > 0 ? "…" : "") + text.slice(from, to) +
740
+ (to < text.length ? "…" : "");
741
+ return new HtmlTemplateError(reason, excerpt, slot);
742
+ }