ya-bbcode 5.1.2 → 6.0.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.
- package/README.md +33 -0
- package/dist/ya-bbcode.cjs +412 -335
- package/dist/ya-bbcode.cjs.map +1 -1
- package/dist/ya-bbcode.d.cts +0 -20
- package/dist/ya-bbcode.d.mts +0 -20
- package/dist/ya-bbcode.mjs +412 -335
- package/dist/ya-bbcode.mjs.map +1 -1
- package/package.json +1 -1
package/dist/ya-bbcode.mjs
CHANGED
|
@@ -1,44 +1,59 @@
|
|
|
1
1
|
// src/ya-bbcode.ts
|
|
2
2
|
const PLACEHOLDER_START = "\0";
|
|
3
3
|
const PLACEHOLDER_END = ";";
|
|
4
|
+
const PARAGRAPH_BREAK = "</p><p>";
|
|
5
|
+
const newlines = /\r\n|\r|\n/g;
|
|
6
|
+
const placeholders = /\0(\d+);/g;
|
|
7
|
+
const whitespace = /\s/;
|
|
8
|
+
const escapable = /["&'<>]/g;
|
|
9
|
+
const tabOrNewline = /[\t\n\r]/;
|
|
10
|
+
const relativeUrl = /^\.{0,2}\//;
|
|
11
|
+
const schemeName = /^[a-z][\d+.a-z-]*$/;
|
|
12
|
+
const dangerousKeys = /* @__PURE__ */ new Set([
|
|
13
|
+
"__proto__",
|
|
14
|
+
"constructor",
|
|
15
|
+
"prototype"
|
|
16
|
+
]);
|
|
4
17
|
function placeholder(index) {
|
|
5
18
|
return PLACEHOLDER_START + index + PLACEHOLDER_END;
|
|
6
19
|
}
|
|
7
|
-
function
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
">": ">",
|
|
16
|
-
""": "\"",
|
|
17
|
-
"'": "'"
|
|
18
|
-
};
|
|
19
|
-
function unescapeSanitized(value) {
|
|
20
|
-
return value.replaceAll(/&(?:amp|lt|gt|quot|#39);/g, (entity) => sanitizedEntities[entity]);
|
|
20
|
+
function entityFor(character) {
|
|
21
|
+
switch (character) {
|
|
22
|
+
case "&": return "&";
|
|
23
|
+
case "<": return "<";
|
|
24
|
+
case ">": return ">";
|
|
25
|
+
case "\"": return """;
|
|
26
|
+
default: return "'";
|
|
27
|
+
}
|
|
21
28
|
}
|
|
22
29
|
/**
|
|
23
|
-
* Escapes HTML attribute values to prevent XSS
|
|
24
|
-
* Encodes quotes, angle brackets, and
|
|
30
|
+
* Escapes text for use in HTML content or attribute values to prevent XSS
|
|
31
|
+
* Encodes quotes, angle brackets, and ampersands
|
|
25
32
|
*/
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
function escapeHtml(value) {
|
|
34
|
+
escapable.lastIndex = 0;
|
|
35
|
+
if (!escapable.test(value)) return value;
|
|
36
|
+
let escaped = "";
|
|
37
|
+
let copied = 0;
|
|
38
|
+
do {
|
|
39
|
+
const index = escapable.lastIndex - 1;
|
|
40
|
+
escaped += value.slice(copied, index) + entityFor(value.charAt(index));
|
|
41
|
+
copied = index + 1;
|
|
42
|
+
} while (escapable.test(value));
|
|
43
|
+
return escaped + value.slice(copied);
|
|
44
|
+
}
|
|
45
|
+
function unescapeSanitized(value) {
|
|
46
|
+
if (!value.includes("&")) return value;
|
|
47
|
+
return value.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("&", "&");
|
|
35
48
|
}
|
|
36
49
|
function normalizeUrl(url) {
|
|
37
50
|
let start = 0;
|
|
38
51
|
let end = url.length;
|
|
39
52
|
while (start < end && url.charAt(start) <= " ") start++;
|
|
40
53
|
while (end > start && url.charAt(end - 1) <= " ") end--;
|
|
41
|
-
|
|
54
|
+
const trimmed = url.slice(start, end);
|
|
55
|
+
if (!tabOrNewline.test(trimmed)) return trimmed;
|
|
56
|
+
return trimmed.replaceAll(/[\t\n\r]/g, "");
|
|
42
57
|
}
|
|
43
58
|
const defaultAllowedSchemes = [
|
|
44
59
|
"http",
|
|
@@ -55,66 +70,361 @@ const defaultAllowedSchemes = [
|
|
|
55
70
|
function sanitizeUrl(url, allowedSchemes) {
|
|
56
71
|
const trimmed = normalizeUrl(String(url));
|
|
57
72
|
if (!trimmed) return;
|
|
58
|
-
if (
|
|
73
|
+
if (relativeUrl.test(trimmed)) return trimmed;
|
|
59
74
|
if (trimmed.startsWith("#")) return trimmed;
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
const scheme =
|
|
63
|
-
if (!allowedSchemes.includes(scheme)) return;
|
|
75
|
+
const colon = trimmed.indexOf(":");
|
|
76
|
+
if (colon > 0) {
|
|
77
|
+
const scheme = trimmed.slice(0, colon).toLowerCase();
|
|
78
|
+
if (!allowedSchemes.includes(scheme) && schemeName.test(scheme)) return;
|
|
64
79
|
}
|
|
65
80
|
return trimmed;
|
|
66
81
|
}
|
|
82
|
+
function tagName(content) {
|
|
83
|
+
const firstEquals = content.indexOf("=");
|
|
84
|
+
const firstSpace = content.indexOf(" ");
|
|
85
|
+
if (firstSpace === -1 || firstEquals !== -1 && firstEquals < firstSpace) {
|
|
86
|
+
if (firstEquals === -1) return content.toLowerCase();
|
|
87
|
+
return content.slice(0, firstEquals).toLowerCase();
|
|
88
|
+
}
|
|
89
|
+
return content.slice(0, content.search(whitespace)).toLowerCase();
|
|
90
|
+
}
|
|
67
91
|
/**
|
|
68
|
-
* Parse tag attributes from
|
|
92
|
+
* Parse a tag's attributes from the text between its brackets
|
|
69
93
|
* Supports both formats:
|
|
70
|
-
* - [tag=value] →
|
|
71
|
-
* - [tag key=value flag] →
|
|
94
|
+
* - [tag=value] → attr: 'value', attrs: {}
|
|
95
|
+
* - [tag key=value flag] → attr: 'value', attrs: { key: 'value', flag: true }
|
|
72
96
|
*/
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
97
|
+
function attributesOf(tag) {
|
|
98
|
+
if (tag.attrs !== void 0) return tag.attrs;
|
|
99
|
+
const content = tag.content;
|
|
100
|
+
const firstSpace = content.indexOf(" ");
|
|
101
|
+
const firstEquals = content.indexOf("=");
|
|
102
|
+
if (firstSpace === -1 || firstEquals !== -1 && firstEquals < firstSpace) {
|
|
103
|
+
if (firstEquals !== -1) tag.attr = content.slice(firstEquals + 1);
|
|
104
|
+
tag.attrs = {};
|
|
105
|
+
return tag.attrs;
|
|
106
|
+
}
|
|
107
|
+
const parts = content.split(/\s+/);
|
|
81
108
|
const attrs = Object.create(null);
|
|
82
|
-
let firstValue = "";
|
|
83
|
-
const dangerousKeys = /* @__PURE__ */ new Set([
|
|
84
|
-
"__proto__",
|
|
85
|
-
"constructor",
|
|
86
|
-
"prototype"
|
|
87
|
-
]);
|
|
88
109
|
for (let i = 1; i < parts.length; i++) {
|
|
89
110
|
const part = parts[i];
|
|
90
111
|
if (!part) continue;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
112
|
+
const eqIndex = part.indexOf("=");
|
|
113
|
+
if (eqIndex === -1) {
|
|
114
|
+
if (!dangerousKeys.has(part.toLowerCase())) attrs[part] = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const key = part.slice(0, eqIndex);
|
|
118
|
+
if (dangerousKeys.has(key.toLowerCase())) continue;
|
|
119
|
+
const value = part.slice(eqIndex + 1);
|
|
120
|
+
attrs[key] = value;
|
|
121
|
+
if (!tag.attr && value) tag.attr = value;
|
|
122
|
+
}
|
|
123
|
+
tag.attrs = attrs;
|
|
124
|
+
return attrs;
|
|
125
|
+
}
|
|
126
|
+
function breakLines(text, lineBreak) {
|
|
127
|
+
if (lineBreak === PARAGRAPH_BREAK) return text.replaceAll(newlines, "</p><p>");
|
|
128
|
+
return text.replaceAll(newlines, "<br/>");
|
|
129
|
+
}
|
|
130
|
+
const maxSplicedNewlines = 8;
|
|
131
|
+
function indexOrEnd(input, character, from) {
|
|
132
|
+
const index = input.indexOf(character, from);
|
|
133
|
+
if (index === -1) return Infinity;
|
|
134
|
+
return index;
|
|
135
|
+
}
|
|
136
|
+
function textBetween(doc, start, end) {
|
|
137
|
+
if (start === end) return "";
|
|
138
|
+
const { input, lineBreak } = doc;
|
|
139
|
+
if (lineBreak === void 0) return input.slice(start, end);
|
|
140
|
+
let text = "";
|
|
141
|
+
let from = start;
|
|
142
|
+
for (let spliced = 0;; spliced++) {
|
|
143
|
+
if (doc.nextLf < from) doc.nextLf = indexOrEnd(input, "\n", from);
|
|
144
|
+
if (doc.nextCr < from) doc.nextCr = indexOrEnd(input, "\r", from);
|
|
145
|
+
const newline = Math.min(doc.nextLf, doc.nextCr);
|
|
146
|
+
if (newline >= end) break;
|
|
147
|
+
if (spliced === maxSplicedNewlines) return text + breakLines(input.slice(from, end), lineBreak);
|
|
148
|
+
text += input.slice(from, newline) + lineBreak;
|
|
149
|
+
from = newline + 1;
|
|
150
|
+
if (doc.nextCr === newline && doc.nextLf === from) from++;
|
|
151
|
+
}
|
|
152
|
+
if (from === start) return input.slice(start, end);
|
|
153
|
+
return text + input.slice(from, end);
|
|
154
|
+
}
|
|
155
|
+
function tagText(doc, slot, escape = false) {
|
|
156
|
+
let text = doc.input.slice(doc.bounds[slot * 2], doc.bounds[slot * 2 + 1]);
|
|
157
|
+
if (escape) text = escapeHtml(text);
|
|
158
|
+
const { lineBreak } = doc;
|
|
159
|
+
if (lineBreak === void 0 || !(text.includes("\n") || text.includes("\r"))) return text;
|
|
160
|
+
return breakLines(text, lineBreak);
|
|
161
|
+
}
|
|
162
|
+
function adopt(parent, orphans) {
|
|
163
|
+
let first = orphans.length;
|
|
164
|
+
while (first > 0 && orphans[first - 1].slot > parent.slot) first--;
|
|
165
|
+
if (first === orphans.length) return;
|
|
166
|
+
parent.firstChild = orphans[first];
|
|
167
|
+
for (let i = first; i < orphans.length; i++) {
|
|
168
|
+
const child = orphans[i];
|
|
169
|
+
child.parent = parent;
|
|
170
|
+
child.nextSibling = orphans[i + 1];
|
|
171
|
+
}
|
|
172
|
+
orphans.length = first;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Looks up the kind for the text between a tag's brackets, creating it the first time a name is seen.
|
|
176
|
+
*/
|
|
177
|
+
function kindOf(kinds, tags, content) {
|
|
178
|
+
const name = tagName(content);
|
|
179
|
+
let kind = kinds.get(name);
|
|
180
|
+
if (kind === void 0) {
|
|
181
|
+
let definition;
|
|
182
|
+
if (Object.hasOwn(tags, name)) definition = tags[name];
|
|
183
|
+
kind = {
|
|
184
|
+
name,
|
|
185
|
+
definition,
|
|
186
|
+
lastOpen: void 0
|
|
187
|
+
};
|
|
188
|
+
kinds.set(name, kind);
|
|
189
|
+
}
|
|
190
|
+
return kind;
|
|
191
|
+
}
|
|
192
|
+
function spells(text, input, start, length) {
|
|
193
|
+
if (text.length !== length) return false;
|
|
194
|
+
for (let i = 0; i < length; i++) if (text.codePointAt(i) !== input.codePointAt(start + i)) return false;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
const recentSize = 64;
|
|
198
|
+
const recentMaxLength = 16;
|
|
199
|
+
/**
|
|
200
|
+
* Finds every tag in one pass, pairs opening and closing tags and links them into a tree.
|
|
201
|
+
* Returns the first top-level tag, or undefined when the input has no opening tag.
|
|
202
|
+
*/
|
|
203
|
+
function buildTree(doc, tags) {
|
|
204
|
+
const { input, parts, bounds } = doc;
|
|
205
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
206
|
+
const recent = [];
|
|
207
|
+
const orphans = [];
|
|
208
|
+
let unclosed = 0;
|
|
209
|
+
let ignoring;
|
|
210
|
+
let textStart = 0;
|
|
211
|
+
let from = 0;
|
|
212
|
+
let close = -1;
|
|
213
|
+
let caret = -1;
|
|
214
|
+
for (;;) {
|
|
215
|
+
const open = input.indexOf("[", from);
|
|
216
|
+
if (open === -1) break;
|
|
217
|
+
if (close <= open) {
|
|
218
|
+
close = input.indexOf("]", open + 1);
|
|
219
|
+
if (close === -1) break;
|
|
220
|
+
}
|
|
221
|
+
if (caret <= open) caret = indexOrEnd(input, "^", open + 1);
|
|
222
|
+
if (caret < close) {
|
|
223
|
+
from = caret + 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
from = close + 1;
|
|
227
|
+
if (close === open + 1) continue;
|
|
228
|
+
let nameStart = open + 1;
|
|
229
|
+
const isClosing = input.charAt(nameStart) === "/";
|
|
230
|
+
if (isClosing) nameStart++;
|
|
231
|
+
if (ignoring !== void 0 && !isClosing) continue;
|
|
232
|
+
const length = close - nameStart;
|
|
233
|
+
const hash = ((input.codePointAt(nameStart) ?? 0) + (input.codePointAt(close - 1) ?? 0) * 3 + length * 7) % recentSize;
|
|
234
|
+
let spelling = recent[hash];
|
|
235
|
+
if (spelling === void 0 || !spells(spelling.content, input, nameStart, length)) {
|
|
236
|
+
const content = input.slice(nameStart, close);
|
|
237
|
+
spelling = {
|
|
238
|
+
content,
|
|
239
|
+
kind: kindOf(kinds, tags, content)
|
|
240
|
+
};
|
|
241
|
+
if (length <= recentMaxLength) recent[hash] = spelling;
|
|
242
|
+
}
|
|
243
|
+
const { content, kind } = spelling;
|
|
244
|
+
if (ignoring !== void 0) {
|
|
245
|
+
if (kind !== ignoring) continue;
|
|
246
|
+
ignoring = void 0;
|
|
247
|
+
}
|
|
248
|
+
const slot = bounds.length / 2;
|
|
249
|
+
parts.push(textBetween(doc, textStart, open), void 0);
|
|
250
|
+
bounds.push(open, close + 1);
|
|
251
|
+
textStart = close + 1;
|
|
252
|
+
if (isClosing) {
|
|
253
|
+
const opener = kind.lastOpen;
|
|
254
|
+
if (opener !== void 0) {
|
|
255
|
+
kind.lastOpen = opener.previousOpen;
|
|
256
|
+
opener.closeSlot = slot;
|
|
257
|
+
unclosed--;
|
|
258
|
+
adopt(opener, orphans);
|
|
259
|
+
}
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const tag = {
|
|
263
|
+
slot,
|
|
264
|
+
closeSlot: -1,
|
|
265
|
+
kind,
|
|
266
|
+
content,
|
|
267
|
+
parent: void 0,
|
|
268
|
+
firstChild: void 0,
|
|
269
|
+
nextSibling: void 0,
|
|
270
|
+
previousOpen: kind.lastOpen,
|
|
271
|
+
attr: "",
|
|
272
|
+
attrs: void 0
|
|
273
|
+
};
|
|
274
|
+
kind.lastOpen = tag;
|
|
275
|
+
unclosed++;
|
|
276
|
+
orphans.push(tag);
|
|
277
|
+
if (kind.definition?.type === "ignore") ignoring = kind;
|
|
278
|
+
}
|
|
279
|
+
parts.push(textBetween(doc, textStart, input.length));
|
|
280
|
+
if (unclosed > 0) {
|
|
281
|
+
let firstUnclosed;
|
|
282
|
+
for (const kind of kinds.values()) for (let tag = kind.lastOpen; tag !== void 0; tag = tag.previousOpen) if (firstUnclosed === void 0 || tag.slot < firstUnclosed.slot) firstUnclosed = tag;
|
|
283
|
+
if (firstUnclosed !== void 0) adopt(firstUnclosed, orphans);
|
|
284
|
+
}
|
|
285
|
+
for (let i = 0; i < orphans.length; i++) orphans[i].nextSibling = orphans[i + 1];
|
|
286
|
+
return orphans[0];
|
|
287
|
+
}
|
|
288
|
+
function resolve(doc, slot, value) {
|
|
289
|
+
if (doc.dead?.has(slot)) return;
|
|
290
|
+
doc.parts[slot * 2 + 1] = value;
|
|
291
|
+
}
|
|
292
|
+
function tagHtml(tag, html) {
|
|
293
|
+
if (typeof html !== "function") return html;
|
|
294
|
+
const attrs = attributesOf(tag);
|
|
295
|
+
return html(tag.attr, attrs);
|
|
296
|
+
}
|
|
297
|
+
function enter(doc, tag) {
|
|
298
|
+
const definition = tag.kind.definition;
|
|
299
|
+
if (!definition) {
|
|
300
|
+
resolve(doc, tag.slot, tagText(doc, tag.slot));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const type = definition.type;
|
|
304
|
+
if (type === "replace") {
|
|
305
|
+
const open = tagHtml(tag, definition.open);
|
|
306
|
+
if (open) resolve(doc, tag.slot, open);
|
|
307
|
+
} else if (type === "ignore") {
|
|
308
|
+
if (doc.dead?.has(tag.slot) || doc.dead?.has(tag.closeSlot)) return;
|
|
309
|
+
doc.parts[tag.slot * 2 + 1] = "";
|
|
310
|
+
if (tag.closeSlot !== -1) doc.parts[tag.closeSlot * 2 + 1] = "";
|
|
311
|
+
} else if (type !== "content") throw new Error("Cannot parse content block. Invalid block type [" + type + "] provided for tag [" + tag.kind.name + "]");
|
|
312
|
+
}
|
|
313
|
+
function autoClose(doc, tag, definition) {
|
|
314
|
+
const html = tagHtml(tag, definition.autoClose);
|
|
315
|
+
let before;
|
|
316
|
+
for (let sibling = tag.nextSibling; sibling !== void 0; sibling = sibling.nextSibling) if (sibling.kind === tag.kind) {
|
|
317
|
+
before = sibling.slot;
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
if (before === void 0 && tag.parent !== void 0 && tag.parent.closeSlot !== -1) before = tag.parent.closeSlot;
|
|
321
|
+
if (before === void 0 || !html || doc.dead?.has(before)) return;
|
|
322
|
+
doc.parts[before * 2] += html;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Writes a content tag's output back when it was given placeholders for slots that were still unresolved.
|
|
326
|
+
* Each placeholder it kept goes back to its own slot with the output before it as that slot's text, so the
|
|
327
|
+
* slot can still be resolved later exactly as if the content tag had never wrapped it.
|
|
328
|
+
*/
|
|
329
|
+
function reseat(doc, tag, html, pending, skips) {
|
|
330
|
+
const { parts } = doc;
|
|
331
|
+
doc.dead ??= /* @__PURE__ */ new Set();
|
|
332
|
+
const positions = /* @__PURE__ */ new Map();
|
|
333
|
+
for (const [index, slot] of pending.entries()) positions.set(slot, index);
|
|
334
|
+
let anchor = tag.slot;
|
|
335
|
+
let next = 0;
|
|
336
|
+
let written = 0;
|
|
337
|
+
for (const match of html.matchAll(placeholders)) {
|
|
338
|
+
const slot = Number(match[1]);
|
|
339
|
+
const found = positions.get(slot);
|
|
340
|
+
if (found === void 0 || found < next) continue;
|
|
341
|
+
for (; next < found; next++) doc.dead.add(pending[next]);
|
|
342
|
+
next = found + 1;
|
|
343
|
+
parts[slot * 2] = html.slice(written, match.index);
|
|
344
|
+
parts[slot * 2 + 1] = void 0;
|
|
345
|
+
skips.set(anchor, slot - 1);
|
|
346
|
+
anchor = slot;
|
|
347
|
+
written = match.index + match[0].length;
|
|
348
|
+
}
|
|
349
|
+
for (; next < pending.length; next++) doc.dead.add(pending[next]);
|
|
350
|
+
parts[tag.slot * 2 + 1] = "";
|
|
351
|
+
parts[tag.closeSlot * 2 + 1] = html.slice(written);
|
|
352
|
+
skips.set(anchor, tag.closeSlot - 1);
|
|
353
|
+
}
|
|
354
|
+
function replaceContent(doc, tag, definition) {
|
|
355
|
+
const { parts } = doc;
|
|
356
|
+
const { slot: openSlot, closeSlot } = tag;
|
|
357
|
+
if (doc.dead?.has(openSlot) || doc.dead?.has(closeSlot)) return;
|
|
358
|
+
let inner = "";
|
|
359
|
+
let pending;
|
|
360
|
+
for (let slot = (doc.skips?.get(openSlot) ?? openSlot) + 1; slot <= closeSlot; slot++) {
|
|
361
|
+
inner += parts[slot * 2];
|
|
362
|
+
parts[slot * 2] = "";
|
|
363
|
+
if (slot === closeSlot) break;
|
|
364
|
+
const value = parts[slot * 2 + 1];
|
|
365
|
+
if (value === void 0) {
|
|
366
|
+
inner += placeholder(slot);
|
|
367
|
+
pending ??= [];
|
|
368
|
+
pending.push(slot);
|
|
369
|
+
} else inner += value;
|
|
370
|
+
parts[slot * 2 + 1] = "";
|
|
371
|
+
slot = doc.skips?.get(slot) ?? slot;
|
|
372
|
+
}
|
|
373
|
+
let replace = definition.replace;
|
|
374
|
+
if (typeof replace === "function") {
|
|
375
|
+
const attrs = attributesOf(tag);
|
|
376
|
+
replace = replace(tag.attr, inner, attrs);
|
|
377
|
+
}
|
|
378
|
+
doc.skips ??= /* @__PURE__ */ new Map();
|
|
379
|
+
if (pending !== void 0) {
|
|
380
|
+
reseat(doc, tag, String(replace), pending, doc.skips);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
parts[openSlot * 2 + 1] = String(replace);
|
|
384
|
+
parts[closeSlot * 2 + 1] = "";
|
|
385
|
+
doc.skips.set(openSlot, doc.skips.get(closeSlot) ?? closeSlot);
|
|
386
|
+
}
|
|
387
|
+
function leave(doc, tag) {
|
|
388
|
+
const definition = tag.kind.definition;
|
|
389
|
+
if (!definition) {
|
|
390
|
+
if (tag.closeSlot !== -1) resolve(doc, tag.closeSlot, tagText(doc, tag.closeSlot));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (definition.type === "replace") {
|
|
394
|
+
if (definition.autoClose && tag.closeSlot === -1) autoClose(doc, tag, definition);
|
|
395
|
+
const close = tagHtml(tag, definition.close);
|
|
396
|
+
if (close && tag.closeSlot !== -1) resolve(doc, tag.closeSlot, close);
|
|
397
|
+
} else if (definition.type === "content" && tag.closeSlot !== -1) replaceContent(doc, tag, definition);
|
|
398
|
+
}
|
|
399
|
+
function renderTree(doc, root) {
|
|
400
|
+
let tag = root;
|
|
401
|
+
while (tag !== void 0) {
|
|
402
|
+
enter(doc, tag);
|
|
403
|
+
if (tag.firstChild !== void 0) {
|
|
404
|
+
tag = tag.firstChild;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
for (;;) {
|
|
408
|
+
leave(doc, tag);
|
|
409
|
+
if (tag.nextSibling !== void 0) {
|
|
410
|
+
tag = tag.nextSibling;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
tag = tag.parent;
|
|
414
|
+
if (tag === void 0) return;
|
|
101
415
|
}
|
|
102
416
|
}
|
|
103
|
-
return {
|
|
104
|
-
attr: firstValue,
|
|
105
|
-
attrs
|
|
106
|
-
};
|
|
107
417
|
}
|
|
108
418
|
var yabbcode = class {
|
|
109
419
|
tags = {
|
|
110
420
|
"url": {
|
|
111
421
|
type: "replace",
|
|
112
|
-
open: (attr) => `<a href="${
|
|
422
|
+
open: (attr) => `<a href="${escapeHtml(sanitizeUrl(this.#unsanitize(attr), this.config.allowedSchemes ?? defaultAllowedSchemes) ?? "#")}">`,
|
|
113
423
|
close: "</a>"
|
|
114
424
|
},
|
|
115
425
|
"quote": {
|
|
116
426
|
type: "replace",
|
|
117
|
-
open: (attr) => `<blockquote author="${
|
|
427
|
+
open: (attr) => `<blockquote author="${escapeHtml(this.#unsanitize(attr))}">`,
|
|
118
428
|
close: "</blockquote>"
|
|
119
429
|
},
|
|
120
430
|
"b": {
|
|
@@ -201,57 +511,18 @@ var yabbcode = class {
|
|
|
201
511
|
"img": {
|
|
202
512
|
type: "content",
|
|
203
513
|
replace: (attr, content) => {
|
|
204
|
-
|
|
514
|
+
let url = content;
|
|
515
|
+
if (url.includes(PLACEHOLDER_START)) url = url.replaceAll(this.regex.placeholders, "");
|
|
516
|
+
url = this.#unsanitize(url);
|
|
517
|
+
const src = sanitizeUrl(url, this.config.allowedSchemes ?? defaultAllowedSchemes);
|
|
205
518
|
if (src === void 0) return "";
|
|
206
|
-
return `<img src="${
|
|
519
|
+
return `<img src="${escapeHtml(src)}" alt="${escapeHtml(this.#unsanitize(attr))}"/>`;
|
|
207
520
|
}
|
|
208
521
|
},
|
|
209
522
|
"noparse": { type: "ignore" }
|
|
210
523
|
};
|
|
211
|
-
regex = {
|
|
212
|
-
tags: /(\[[^\]^]+\])/g,
|
|
213
|
-
newline: /\r\n|\r|\n/g,
|
|
214
|
-
placeholders: /\0(\d+);/g
|
|
215
|
-
};
|
|
524
|
+
regex = { placeholders: /\0(\d+);/g };
|
|
216
525
|
config;
|
|
217
|
-
contentModules = {
|
|
218
|
-
replace: (tag, module, content) => {
|
|
219
|
-
let open = module.open;
|
|
220
|
-
let close = module.close;
|
|
221
|
-
if (typeof open === "function") open = open(tag.attr, tag.attrs);
|
|
222
|
-
if (typeof close === "function") close = close(tag.attr, tag.attrs);
|
|
223
|
-
if (open && !tag.isClosing) content = replaceFirst(content, placeholder(tag.index), open);
|
|
224
|
-
if (close && tag.closing) content = replaceFirst(content, placeholder(tag.closing.index), close);
|
|
225
|
-
return content;
|
|
226
|
-
},
|
|
227
|
-
content: (tag, module, content) => {
|
|
228
|
-
if (!tag.closing) return content;
|
|
229
|
-
const openTag = placeholder(tag.index);
|
|
230
|
-
const closeTag = placeholder(tag.closing.index);
|
|
231
|
-
const start = content.indexOf(openTag);
|
|
232
|
-
const end = content.indexOf(closeTag);
|
|
233
|
-
let replace = module.replace;
|
|
234
|
-
const innerContent = content.slice(start + openTag.length, end);
|
|
235
|
-
if (typeof replace === "function") replace = replace(tag.attr, innerContent, tag.attrs);
|
|
236
|
-
const contentStart = content.slice(0, Math.max(0, start));
|
|
237
|
-
const contentEnd = content.slice(end + closeTag.length);
|
|
238
|
-
return contentStart + replace + contentEnd;
|
|
239
|
-
},
|
|
240
|
-
ignore: (tag, _module, content) => {
|
|
241
|
-
const openTag = placeholder(tag.index);
|
|
242
|
-
const start = content.indexOf(openTag);
|
|
243
|
-
let closeTag = "";
|
|
244
|
-
let end = content.length;
|
|
245
|
-
if (tag.closing) {
|
|
246
|
-
closeTag = placeholder(tag.closing.index);
|
|
247
|
-
end = content.indexOf(closeTag);
|
|
248
|
-
}
|
|
249
|
-
const innerContent = content.slice(start + openTag.length, end);
|
|
250
|
-
const contentStart = content.slice(0, Math.max(0, start));
|
|
251
|
-
const contentEnd = content.slice(end + closeTag.length);
|
|
252
|
-
return contentStart + innerContent + contentEnd;
|
|
253
|
-
}
|
|
254
|
-
};
|
|
255
526
|
constructor(config = {}) {
|
|
256
527
|
this.config = {
|
|
257
528
|
newline: true,
|
|
@@ -266,162 +537,13 @@ var yabbcode = class {
|
|
|
266
537
|
if (config.sanitizeHtml !== void 0) this.config.sanitizeHtml = config.sanitizeHtml;
|
|
267
538
|
if (config.allowedSchemes !== void 0) this.config.allowedSchemes = config.allowedSchemes;
|
|
268
539
|
}
|
|
269
|
-
#getTag(name) {
|
|
270
|
-
if (!Object.hasOwn(this.tags, name)) return;
|
|
271
|
-
return this.tags[name];
|
|
272
|
-
}
|
|
273
|
-
#convertNewlines(input) {
|
|
274
|
-
if (!this.config.newline) return input;
|
|
275
|
-
if (this.config.paragraph) return input.replace(this.regex.newline, "</p><p>");
|
|
276
|
-
return input.replace(this.regex.newline, "<br/>");
|
|
277
|
-
}
|
|
278
|
-
#applyLayout(input) {
|
|
279
|
-
input = this.#convertNewlines(input);
|
|
280
|
-
if (this.config.paragraph) input = "<p>" + input + "</p>";
|
|
281
|
-
return input;
|
|
282
|
-
}
|
|
283
540
|
#unsanitize(value) {
|
|
284
541
|
if (!this.config.sanitizeHtml) return value;
|
|
285
542
|
return unescapeSanitized(value);
|
|
286
543
|
}
|
|
287
|
-
#
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
for (const tag of tagsMap) if (ignoreModule === void 0) {
|
|
291
|
-
if (!tag.isClosing && this.#getTag(tag.module)?.type === "ignore") ignoreModule = tag.module;
|
|
292
|
-
} else if (tag.isClosing && tag.module === ignoreModule) ignoreModule = void 0;
|
|
293
|
-
else ignored.add(tag.index);
|
|
294
|
-
return ignored;
|
|
295
|
-
}
|
|
296
|
-
#contentLoop(tagsMap, content, parentClosingTagIndex) {
|
|
297
|
-
if (tagsMap.length > 50) {
|
|
298
|
-
const hasSpecialType = (tag) => {
|
|
299
|
-
const module = this.#getTag(tag.module);
|
|
300
|
-
if (module) {
|
|
301
|
-
if (module.type === "content" || module.type === "ignore") return true;
|
|
302
|
-
if (module.type === "replace" && module.autoClose) return true;
|
|
303
|
-
}
|
|
304
|
-
return tag.children.some((child) => hasSpecialType(child));
|
|
305
|
-
};
|
|
306
|
-
if (!tagsMap.some((tag) => hasSpecialType(tag))) return this.#contentLoopOptimized(tagsMap, content);
|
|
307
|
-
}
|
|
308
|
-
for (let i = 0; i < tagsMap.length; i++) {
|
|
309
|
-
const tag = tagsMap[i];
|
|
310
|
-
if (!tag) continue;
|
|
311
|
-
let module = this.#getTag(tag.module);
|
|
312
|
-
if (!module) module = {
|
|
313
|
-
type: "replace",
|
|
314
|
-
open: tag.raw,
|
|
315
|
-
close: tag.closing ? tag.closing.raw : ""
|
|
316
|
-
};
|
|
317
|
-
if (!this.contentModules[module.type]) throw new Error("Cannot parse content block. Invalid block type [" + module.type + "] provided for tag [" + tag.module + "]");
|
|
318
|
-
if (module.type === "replace") {
|
|
319
|
-
const replaceModule = module;
|
|
320
|
-
let open = replaceModule.open;
|
|
321
|
-
if (typeof open === "function") open = open(tag.attr, tag.attrs);
|
|
322
|
-
if (open && !tag.isClosing) content = replaceFirst(content, placeholder(tag.index), open);
|
|
323
|
-
if (tag.children.length > 0) {
|
|
324
|
-
const closingIndex = tag.closing?.index;
|
|
325
|
-
content = this.#contentLoop(tag.children, content, closingIndex);
|
|
326
|
-
}
|
|
327
|
-
if (replaceModule.autoClose && !tag.closing) {
|
|
328
|
-
let autoClose = replaceModule.autoClose;
|
|
329
|
-
if (typeof autoClose === "function") autoClose = autoClose(tag.attr, tag.attrs);
|
|
330
|
-
let insertBefore;
|
|
331
|
-
for (let siblingIndex = i + 1; siblingIndex < tagsMap.length; siblingIndex++) {
|
|
332
|
-
const sibling = tagsMap[siblingIndex];
|
|
333
|
-
if (sibling?.module === tag.module) {
|
|
334
|
-
insertBefore = placeholder(sibling.index);
|
|
335
|
-
break;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
if (insertBefore === void 0 && parentClosingTagIndex !== void 0) insertBefore = placeholder(parentClosingTagIndex);
|
|
339
|
-
if (insertBefore && autoClose) content = replaceFirst(content, insertBefore, autoClose + insertBefore);
|
|
340
|
-
}
|
|
341
|
-
let close = replaceModule.close;
|
|
342
|
-
if (typeof close === "function") close = close(tag.attr, tag.attrs);
|
|
343
|
-
if (close && tag.closing) content = replaceFirst(content, placeholder(tag.closing.index), close);
|
|
344
|
-
} else if (module.type === "content") {
|
|
345
|
-
if (tag.children.length > 0) content = this.#contentLoop(tag.children, content, tag.closing?.index);
|
|
346
|
-
content = this.contentModules.content(tag, module, content);
|
|
347
|
-
} else if (module.type === "ignore") content = this.contentModules.ignore(tag, module, content);
|
|
348
|
-
}
|
|
349
|
-
return content;
|
|
350
|
-
}
|
|
351
|
-
#contentLoopOptimized(tagsMap, content) {
|
|
352
|
-
const replacements = /* @__PURE__ */ new Map();
|
|
353
|
-
const processTag = (tag) => {
|
|
354
|
-
let module = this.#getTag(tag.module);
|
|
355
|
-
if (!module) module = {
|
|
356
|
-
type: "replace",
|
|
357
|
-
open: tag.raw,
|
|
358
|
-
close: tag.closing ? tag.closing.raw : ""
|
|
359
|
-
};
|
|
360
|
-
if (module.type !== "replace") return;
|
|
361
|
-
let open = module.open;
|
|
362
|
-
let close = module.close;
|
|
363
|
-
if (typeof open === "function") open = open(tag.attr, tag.attrs);
|
|
364
|
-
if (typeof close === "function") close = close(tag.attr, tag.attrs);
|
|
365
|
-
if (open && !tag.isClosing) replacements.set(placeholder(tag.index), open);
|
|
366
|
-
if (close && tag.closing) replacements.set(placeholder(tag.closing.index), close);
|
|
367
|
-
for (const child of tag.children) processTag(child);
|
|
368
|
-
};
|
|
369
|
-
for (const tag of tagsMap) processTag(tag);
|
|
370
|
-
return content.replaceAll(this.regex.placeholders, (match) => replacements.get(match) ?? match);
|
|
371
|
-
}
|
|
372
|
-
#tagLoop(tagsMap, parent) {
|
|
373
|
-
const stack = [];
|
|
374
|
-
const processed = /* @__PURE__ */ new Set();
|
|
375
|
-
for (let i = 0; i < tagsMap.length; i++) {
|
|
376
|
-
const tag = tagsMap[i];
|
|
377
|
-
if (!tag) continue;
|
|
378
|
-
if (tag.isClosing) for (let stackIndex = stack.length - 1; stackIndex >= 0; stackIndex--) {
|
|
379
|
-
const openTag = stack[stackIndex];
|
|
380
|
-
if (!openTag) continue;
|
|
381
|
-
if (openTag.module === tag.module && !processed.has(openTag.index)) {
|
|
382
|
-
openTag.matchTag = tag.index;
|
|
383
|
-
openTag.closing = tag;
|
|
384
|
-
tag.matchTag = openTag.index;
|
|
385
|
-
processed.add(openTag.index);
|
|
386
|
-
const childStart = (openTag.stackIndex ?? 0) + 1;
|
|
387
|
-
if (childStart < i) {
|
|
388
|
-
openTag.children = [];
|
|
389
|
-
for (let childIndex = childStart; childIndex < i; childIndex++) {
|
|
390
|
-
const childTag = tagsMap[childIndex];
|
|
391
|
-
if (!(childTag && !childTag.isClosing && childTag.parent === null)) continue;
|
|
392
|
-
childTag.parent = openTag.index;
|
|
393
|
-
openTag.children.push(childTag);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
stack.splice(stackIndex, 1);
|
|
397
|
-
break;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
else {
|
|
401
|
-
tag.stackIndex = i;
|
|
402
|
-
stack.push(tag);
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
for (const tag of stack) {
|
|
406
|
-
tag.matchTag = false;
|
|
407
|
-
const childStart = (tag.stackIndex ?? 0) + 1;
|
|
408
|
-
if (!(childStart < tagsMap.length)) continue;
|
|
409
|
-
tag.children = [];
|
|
410
|
-
for (let childIndex = childStart; childIndex < tagsMap.length; childIndex++) {
|
|
411
|
-
const childTag = tagsMap[childIndex];
|
|
412
|
-
if (!(childTag && !childTag.isClosing && childTag.parent === null)) continue;
|
|
413
|
-
childTag.parent = tag.index;
|
|
414
|
-
tag.children.push(childTag);
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
const result = [];
|
|
418
|
-
for (const tag of tagsMap) {
|
|
419
|
-
if (tag.isClosing) continue;
|
|
420
|
-
if (!(parent === void 0 && tag.parent === null || parent !== void 0 && tag.parent === parent)) continue;
|
|
421
|
-
if (tag.children.length > 0) tag.children = this.#tagLoop(tag.children, tag.index);
|
|
422
|
-
result.push(tag);
|
|
423
|
-
}
|
|
424
|
-
return result;
|
|
544
|
+
#leftover(doc, slot) {
|
|
545
|
+
if (this.config.cleanUnmatchable || slot * 2 >= doc.bounds.length) return "";
|
|
546
|
+
return tagText(doc, slot, !this.config.sanitizeHtml);
|
|
425
547
|
}
|
|
426
548
|
clearTags() {
|
|
427
549
|
this.tags = {};
|
|
@@ -429,88 +551,43 @@ var yabbcode = class {
|
|
|
429
551
|
}
|
|
430
552
|
registerTag(tag, options) {
|
|
431
553
|
const tagName = String(tag).toLowerCase();
|
|
432
|
-
if (
|
|
433
|
-
"__proto__",
|
|
434
|
-
"constructor",
|
|
435
|
-
"prototype"
|
|
436
|
-
].includes(tagName)) throw new Error(`Cannot register tag with dangerous name: ${tagName}`);
|
|
554
|
+
if (dangerousKeys.has(tagName)) throw new Error(`Cannot register tag with dangerous name: ${tagName}`);
|
|
437
555
|
this.tags[tagName] = options;
|
|
438
556
|
return this;
|
|
439
557
|
}
|
|
440
558
|
parse(bbcInput) {
|
|
441
559
|
if (typeof bbcInput === "boolean" || typeof bbcInput !== "string" && Number.isNaN(Number(bbcInput))) return "";
|
|
442
|
-
let input = String(bbcInput)
|
|
443
|
-
if (
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
"'": "'"
|
|
450
|
-
};
|
|
451
|
-
input = input.replaceAll(/["&'<>]/g, (char) => htmlEntities[char]);
|
|
560
|
+
let input = String(bbcInput);
|
|
561
|
+
if (input.includes(PLACEHOLDER_START)) input = input.replaceAll(PLACEHOLDER_START, "");
|
|
562
|
+
if (this.config.sanitizeHtml) input = escapeHtml(input);
|
|
563
|
+
let lineBreak;
|
|
564
|
+
if (this.config.newline) {
|
|
565
|
+
lineBreak = "<br/>";
|
|
566
|
+
if (this.config.paragraph) lineBreak = PARAGRAPH_BREAK;
|
|
452
567
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
attrs: {}
|
|
469
|
-
};
|
|
470
|
-
} else if (spaceIndex === -1 || equalsIndex !== -1 && equalsIndex < spaceIndex) {
|
|
471
|
-
moduleName = (contentToParse.split("=", 1)[0] ?? "").toLowerCase();
|
|
472
|
-
parsedAttrs = parseTagAttributes(contentToParse);
|
|
473
|
-
} else {
|
|
474
|
-
moduleName = (contentToParse.split(/\s+/, 1)[0] ?? "").toLowerCase();
|
|
475
|
-
parsedAttrs = parseTagAttributes(contentToParse);
|
|
476
|
-
}
|
|
477
|
-
const item = {
|
|
478
|
-
index: i,
|
|
479
|
-
module: moduleName,
|
|
480
|
-
isClosing,
|
|
481
|
-
raw: this.#convertNewlines(tag),
|
|
482
|
-
attr: parsedAttrs.attr,
|
|
483
|
-
attrs: parsedAttrs.attrs,
|
|
484
|
-
children: [],
|
|
485
|
-
parent: null,
|
|
486
|
-
matchTag: null
|
|
487
|
-
};
|
|
488
|
-
tagsMap.push(item);
|
|
568
|
+
const doc = {
|
|
569
|
+
input,
|
|
570
|
+
lineBreak,
|
|
571
|
+
parts: [],
|
|
572
|
+
bounds: [],
|
|
573
|
+
nextLf: -1,
|
|
574
|
+
nextCr: -1,
|
|
575
|
+
skips: void 0,
|
|
576
|
+
dead: void 0
|
|
577
|
+
};
|
|
578
|
+
const { parts } = doc;
|
|
579
|
+
const root = buildTree(doc, this.tags);
|
|
580
|
+
if (this.config.paragraph) {
|
|
581
|
+
parts[0] = "<p>" + parts[0];
|
|
582
|
+
parts[parts.length - 1] += "</p>";
|
|
489
583
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
let searchFrom = 0;
|
|
494
|
-
let copiedUpTo = 0;
|
|
495
|
-
for (const [i, tag] of tags.entries()) {
|
|
496
|
-
const pos = input.indexOf(tag, searchFrom);
|
|
497
|
-
if (pos === -1) continue;
|
|
498
|
-
searchFrom = pos + tag.length;
|
|
499
|
-
if (ignored.has(i)) continue;
|
|
500
|
-
parts.push(input.slice(copiedUpTo, pos), placeholder(i));
|
|
501
|
-
copiedUpTo = searchFrom;
|
|
584
|
+
renderTree(doc, root);
|
|
585
|
+
if (!this.config.cleanUnmatchable) {
|
|
586
|
+
for (let index = 1; index < parts.length; index += 2) if (parts[index] === void 0) parts[index] = this.#leftover(doc, (index - 1) / 2);
|
|
502
587
|
}
|
|
503
|
-
parts.
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
tagsMap = this.#tagLoop(tagsMap);
|
|
507
|
-
input = this.#contentLoop(tagsMap, input);
|
|
508
|
-
if (this.config.cleanUnmatchable) return input.replaceAll(this.regex.placeholders, "");
|
|
509
|
-
return input.replaceAll(this.regex.placeholders, (_match, index) => {
|
|
510
|
-
let raw = tags[Number(index)] ?? "";
|
|
511
|
-
if (!this.config.sanitizeHtml) raw = escapeHtmlAttr(raw);
|
|
512
|
-
return this.#convertNewlines(raw);
|
|
513
|
-
});
|
|
588
|
+
const output = parts.join("");
|
|
589
|
+
if (!output.includes(PLACEHOLDER_START)) return output;
|
|
590
|
+
return output.replaceAll(placeholders, (_match, index) => this.#leftover(doc, Number(index)));
|
|
514
591
|
}
|
|
515
592
|
};
|
|
516
593
|
|