@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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/product.yml +1 -1
  3. package/src/dev.js +249 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflare/fx",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Code PenguinMod projects with text.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/product.yml CHANGED
@@ -1,7 +1,7 @@
1
1
  # Identity
2
2
  name: "Triflare Fx"
3
3
  command: "fx"
4
- version: "1.0.1"
4
+ version: "1.0.2"
5
5
  tagline: "Code PenguinMod projects with text."
6
6
  author: "Triflare"
7
7
  homepage: "https://github.com/triflare/fx"
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
- if (/<\/body\s*>/i.test(html))
88
- return html.replace(/<\/body\s*>/i, `${script}\n</body>`);
89
- return `${html}${html.endsWith("\n") ? "" : "\n"}${script}`;
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
  /**