@triflare/fx 1.0.1 → 1.0.2
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/package.json +1 -1
- package/product.yml +1 -1
- package/src/dev.js +249 -3
package/package.json
CHANGED
package/product.yml
CHANGED
package/src/dev.js
CHANGED
|
@@ -55,6 +55,239 @@ function escapeHtml(text) {
|
|
|
55
55
|
.replaceAll('"', """);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Elements whose content the HTML tokenizer treats as text rather than markup.
|
|
60
|
+
* `noscript` is included because this page always runs scripts, which is
|
|
61
|
+
* exactly when the parser stops tokenizing its content.
|
|
62
|
+
*/
|
|
63
|
+
const RAW_TEXT_TAGS = new Set([
|
|
64
|
+
"script",
|
|
65
|
+
"style",
|
|
66
|
+
"title",
|
|
67
|
+
"textarea",
|
|
68
|
+
"iframe",
|
|
69
|
+
"noembed",
|
|
70
|
+
"noframes",
|
|
71
|
+
"xmp",
|
|
72
|
+
"noscript",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Elements whose contents are parsed into a separate fragment: a `</body>`
|
|
77
|
+
* inside one is ignored by the document tokenizer, so it cannot be the
|
|
78
|
+
* document's body end either.
|
|
79
|
+
*/
|
|
80
|
+
const FRAGMENT_TAGS = new Set(["template"]);
|
|
81
|
+
|
|
82
|
+
const TAG_NAME_AT = /<([a-zA-Z][^\s/>]*)/y;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Index just past the start tag beginning at `start`, skipping quoted attribute
|
|
86
|
+
* values so a `>` inside `srcdoc="…"` cannot end the tag early. Returns -1 when
|
|
87
|
+
* the tag never closes.
|
|
88
|
+
* @param {string} html
|
|
89
|
+
* @param {number} start
|
|
90
|
+
* @returns {number}
|
|
91
|
+
*/
|
|
92
|
+
function startTagEnd(html, start) {
|
|
93
|
+
let quote = "";
|
|
94
|
+
for (let i = start + 1; i < html.length; i += 1) {
|
|
95
|
+
const char = html[i];
|
|
96
|
+
if (quote) {
|
|
97
|
+
if (char === quote) quote = "";
|
|
98
|
+
} else if (char === '"' || char === "'") {
|
|
99
|
+
quote = char;
|
|
100
|
+
} else if (char === ">") {
|
|
101
|
+
return i + 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return -1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Index just past the first `</name>` at or after `from`, or -1.
|
|
109
|
+
* @param {string} html
|
|
110
|
+
* @param {string} name
|
|
111
|
+
* @param {number} from
|
|
112
|
+
* @returns {number}
|
|
113
|
+
*/
|
|
114
|
+
function endTagEnd(html, name, from) {
|
|
115
|
+
const pattern = new RegExp(`</${name}\\s*>`, "gi");
|
|
116
|
+
pattern.lastIndex = from;
|
|
117
|
+
const match = pattern.exec(html);
|
|
118
|
+
return match ? match.index + match[0].length : -1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Index just past the `</name>` that closes the element whose start tag ends at
|
|
123
|
+
* `from`, honouring nested `name` elements and skipping comments and raw-text
|
|
124
|
+
* content. Returns -1 when the element never closes.
|
|
125
|
+
* @param {string} html
|
|
126
|
+
* @param {string} name
|
|
127
|
+
* @param {number} from
|
|
128
|
+
* @returns {number}
|
|
129
|
+
*/
|
|
130
|
+
function matchingClose(html, name, from) {
|
|
131
|
+
let depth = 1;
|
|
132
|
+
let i = from;
|
|
133
|
+
|
|
134
|
+
while (i < html.length) {
|
|
135
|
+
const tag = html.indexOf("<", i);
|
|
136
|
+
if (tag === -1) return -1;
|
|
137
|
+
|
|
138
|
+
if (html.startsWith("<!--", tag)) {
|
|
139
|
+
const commentEnd = html.indexOf("-->", tag + 4);
|
|
140
|
+
if (commentEnd === -1) return -1;
|
|
141
|
+
i = commentEnd + 3;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const end = startTagEnd(html, tag);
|
|
146
|
+
if (end === -1) return -1;
|
|
147
|
+
|
|
148
|
+
if (html[tag + 1] === "/") {
|
|
149
|
+
const nameAt = tag + 2;
|
|
150
|
+
if (
|
|
151
|
+
html.slice(nameAt, nameAt + name.length).toLowerCase() === name &&
|
|
152
|
+
" \t\n\f\r/>".includes(html[nameAt + name.length] ?? ">")
|
|
153
|
+
) {
|
|
154
|
+
depth -= 1;
|
|
155
|
+
if (depth === 0) return end;
|
|
156
|
+
}
|
|
157
|
+
i = end;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
TAG_NAME_AT.lastIndex = tag;
|
|
162
|
+
const match = TAG_NAME_AT.exec(html);
|
|
163
|
+
const inner = match ? match[1].toLowerCase() : "";
|
|
164
|
+
|
|
165
|
+
if (inner === name) {
|
|
166
|
+
depth += 1;
|
|
167
|
+
i = end;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// `<plaintext>` never closes, so nothing after it can close the fragment.
|
|
172
|
+
if (inner === "plaintext") return -1;
|
|
173
|
+
|
|
174
|
+
// Raw-text content is text, so a `</template>`-looking string in a script
|
|
175
|
+
// inside the fragment cannot close it early.
|
|
176
|
+
if (RAW_TEXT_TAGS.has(inner)) {
|
|
177
|
+
const raw = endTagEnd(html, inner, end);
|
|
178
|
+
if (raw === -1) return -1;
|
|
179
|
+
i = raw;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
i = end;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return -1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Find the ranges the HTML tokenizer treats as text rather than markup, so tag
|
|
191
|
+
* searches cannot match text that merely looks like markup (a `</body>` inside
|
|
192
|
+
* a script string, a commented-out tag, an attribute value, ...).
|
|
193
|
+
* @param {string} html
|
|
194
|
+
* @returns {{ranges: Array<[number, number]>, textFrom: number}} `ranges` are
|
|
195
|
+
* sorted, non-overlapping `[start, end)` spans; `textFrom` is the offset
|
|
196
|
+
* where a `<plaintext>` or unterminated tag/text element starts, so nothing
|
|
197
|
+
* from there to end of input is markup. -1 when the whole input is markup.
|
|
198
|
+
*/
|
|
199
|
+
function nonMarkupRanges(html) {
|
|
200
|
+
/** @type {Array<[number, number]>} */
|
|
201
|
+
const ranges = [];
|
|
202
|
+
let textFrom = -1;
|
|
203
|
+
let i = 0;
|
|
204
|
+
|
|
205
|
+
while (i < html.length) {
|
|
206
|
+
const tag = html.indexOf("<", i);
|
|
207
|
+
if (tag === -1) break;
|
|
208
|
+
|
|
209
|
+
if (html.startsWith("<!--", tag)) {
|
|
210
|
+
const close = html.indexOf("-->", tag + 4);
|
|
211
|
+
const end = close === -1 ? html.length : close + 3;
|
|
212
|
+
ranges.push([tag, end]);
|
|
213
|
+
i = end;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
TAG_NAME_AT.lastIndex = tag;
|
|
218
|
+
const match = TAG_NAME_AT.exec(html);
|
|
219
|
+
const name = match ? match[1].toLowerCase() : "";
|
|
220
|
+
const next = html[tag + 1] ?? "";
|
|
221
|
+
|
|
222
|
+
// A bare `<` (`1 < 2`) opens no tag, so carry on just after it: scanning
|
|
223
|
+
// from here could otherwise let a stray quote in text open an attribute
|
|
224
|
+
// quote and swallow real markup that follows.
|
|
225
|
+
if (!match && next !== "/" && next !== "!" && next !== "?") {
|
|
226
|
+
i = tag + 1;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const end = startTagEnd(html, tag);
|
|
231
|
+
|
|
232
|
+
// An unterminated tag, and `<plaintext>`, make the rest of the input text.
|
|
233
|
+
if (end === -1 || name === "plaintext") {
|
|
234
|
+
ranges.push([tag, html.length]);
|
|
235
|
+
textFrom = tag;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// End tags and declarations have no attributes that could hide a tag.
|
|
240
|
+
if (name === "") {
|
|
241
|
+
i = end;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!RAW_TEXT_TAGS.has(name) && !FRAGMENT_TAGS.has(name)) {
|
|
246
|
+
// A start tag's quoted attributes are text too, so a `</body>` inside
|
|
247
|
+
// `title="…"` cannot capture the injection.
|
|
248
|
+
ranges.push([tag, end]);
|
|
249
|
+
i = end;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Fragment contents (templates) are opaque to the document tokenizer, so a
|
|
254
|
+
// `</body>` inside them — nested templates included — is not the body end.
|
|
255
|
+
const close = FRAGMENT_TAGS.has(name)
|
|
256
|
+
? matchingClose(html, name, end)
|
|
257
|
+
: endTagEnd(html, name, end);
|
|
258
|
+
|
|
259
|
+
// An element without its end tag also runs to end of input.
|
|
260
|
+
if (close === -1) {
|
|
261
|
+
ranges.push([tag, html.length]);
|
|
262
|
+
textFrom = tag;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
ranges.push([tag, close]);
|
|
267
|
+
i = close;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { ranges, textFrom };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Offset of the last `</body>` outside the given text ranges, or -1.
|
|
275
|
+
* @param {string} html
|
|
276
|
+
* @param {Array<[number, number]>} ranges
|
|
277
|
+
* @returns {number}
|
|
278
|
+
*/
|
|
279
|
+
function lastBodyEnd(html, ranges) {
|
|
280
|
+
let bodyEnd = -1;
|
|
281
|
+
let next = 0;
|
|
282
|
+
for (const match of html.matchAll(/<\/body\s*>/gi)) {
|
|
283
|
+
while (next < ranges.length && ranges[next][1] <= match.index) next += 1;
|
|
284
|
+
const range = ranges[next];
|
|
285
|
+
if (range && range[0] <= match.index) continue; // text, not a tag
|
|
286
|
+
bodyEnd = match.index;
|
|
287
|
+
}
|
|
288
|
+
return bodyEnd;
|
|
289
|
+
}
|
|
290
|
+
|
|
58
291
|
/**
|
|
59
292
|
* @param {string} html
|
|
60
293
|
* @returns {string}
|
|
@@ -84,9 +317,22 @@ export function injectDevClient(html) {
|
|
|
84
317
|
});
|
|
85
318
|
})();
|
|
86
319
|
</script>`;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
320
|
+
// Insert before the *last* real closing body tag: packaged pages inline a
|
|
321
|
+
// bundle that can itself contain the literal text `</body>` inside a string,
|
|
322
|
+
// and injecting there would corrupt the script instead of ending the
|
|
323
|
+
// document. Comments, text-element content and attribute values are masked
|
|
324
|
+
// first so a quoted or commented-out `</body>` cannot capture the injection.
|
|
325
|
+
const { ranges, textFrom } = nonMarkupRanges(html);
|
|
326
|
+
const bodyEnd = lastBodyEnd(html, ranges);
|
|
327
|
+
|
|
328
|
+
// Without a usable body end tag, go before the point where the document
|
|
329
|
+
// turns into text (keeping the client a script rather than literal text),
|
|
330
|
+
// or append when the whole input was markup.
|
|
331
|
+
const at =
|
|
332
|
+
bodyEnd === -1 ? (textFrom === -1 ? html.length : textFrom) : bodyEnd;
|
|
333
|
+
const before = html.slice(0, at);
|
|
334
|
+
const separator = before === "" || before.endsWith("\n") ? "" : "\n";
|
|
335
|
+
return `${before}${separator}${script}${html.slice(at)}`;
|
|
90
336
|
}
|
|
91
337
|
|
|
92
338
|
/**
|