ya-bbcode 5.1.1 → 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.
@@ -1,118 +1,430 @@
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 replaceFirst(content, search, replacement) {
8
- const index = content.indexOf(search);
9
- if (index === -1) return content;
10
- return content.slice(0, index) + replacement + content.slice(index + search.length);
11
- }
12
- const sanitizedEntities = {
13
- "&amp;": "&",
14
- "&lt;": "<",
15
- "&gt;": ">",
16
- "&quot;": "\"",
17
- "&#39;": "'"
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 "&amp;";
23
+ case "<": return "&lt;";
24
+ case ">": return "&gt;";
25
+ case "\"": return "&quot;";
26
+ default: return "&#39;";
27
+ }
21
28
  }
22
29
  /**
23
- * Escapes HTML attribute values to prevent XSS
24
- * Encodes quotes, angle brackets, and other special characters
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 escapeHtmlAttr(value) {
27
- const attrEntities = {
28
- "&": "&amp;",
29
- "<": "&lt;",
30
- ">": "&gt;",
31
- "\"": "&quot;",
32
- "'": "&#39;"
33
- };
34
- return String(value).replaceAll(/["&'<>]/g, (char) => attrEntities[char] || char);
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("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&#39;", "'").replaceAll("&amp;", "&");
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
- return url.slice(start, end).replaceAll(/[\t\n\r]/g, "");
54
+ const trimmed = url.slice(start, end);
55
+ if (!tabOrNewline.test(trimmed)) return trimmed;
56
+ return trimmed.replaceAll(/[\t\n\r]/g, "");
42
57
  }
58
+ const defaultAllowedSchemes = [
59
+ "http",
60
+ "https",
61
+ "mailto",
62
+ "ftp",
63
+ "ftps"
64
+ ];
43
65
  /**
44
66
  * Validates and sanitizes URLs to prevent javascript: and data: URL attacks
45
67
  * Only allows safe URL schemes based on the allowedSchemes list
68
+ * Returns undefined for empty or disallowed URLs
46
69
  */
47
70
  function sanitizeUrl(url, allowedSchemes) {
48
71
  const trimmed = normalizeUrl(String(url));
49
- if (!trimmed) return "#";
50
- if (/^\.{0,2}\//.test(trimmed)) return trimmed;
72
+ if (!trimmed) return;
73
+ if (relativeUrl.test(trimmed)) return trimmed;
51
74
  if (trimmed.startsWith("#")) return trimmed;
52
- const schemeMatch = /^([a-z][\d+.a-z-]*):/.exec(trimmed.toLowerCase());
53
- if (schemeMatch) {
54
- const scheme = schemeMatch[1] ?? "";
55
- 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;
56
79
  }
57
80
  return trimmed;
58
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
+ }
59
91
  /**
60
- * Parse tag attributes from a tag string
92
+ * Parse a tag's attributes from the text between its brackets
61
93
  * Supports both formats:
62
- * - [tag=value] → { attr: 'value', attrs: {} }
63
- * - [tag key=value flag] → { attr: 'value', attrs: { key: 'value', flag: true } }
94
+ * - [tag=value] → attr: 'value', attrs: {}
95
+ * - [tag key=value flag] → attr: 'value', attrs: { key: 'value', flag: true }
64
96
  */
65
- function parseTagAttributes(tagContent) {
66
- const firstSpace = tagContent.indexOf(" ");
67
- const firstEquals = tagContent.indexOf("=");
68
- if (firstSpace === -1 || firstEquals !== -1 && firstEquals < firstSpace) return {
69
- attr: tagContent.split("=").slice(1).join("="),
70
- attrs: {}
71
- };
72
- const parts = tagContent.split(/\s+/);
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+/);
73
108
  const attrs = Object.create(null);
74
- let firstValue = "";
75
- const dangerousKeys = /* @__PURE__ */ new Set([
76
- "__proto__",
77
- "constructor",
78
- "prototype"
79
- ]);
80
109
  for (let i = 1; i < parts.length; i++) {
81
110
  const part = parts[i];
82
111
  if (!part) continue;
83
- if (part.includes("=")) {
84
- const eqIndex = part.indexOf("=");
85
- const key = part.slice(0, eqIndex);
86
- const value = part.slice(eqIndex + 1);
87
- if (dangerousKeys.has(key.toLowerCase())) continue;
88
- attrs[key] = value;
89
- if (!firstValue && value) firstValue = value;
90
- } else {
91
- if (dangerousKeys.has(part.toLowerCase())) continue;
92
- attrs[part] = true;
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;
93
415
  }
94
416
  }
95
- return {
96
- attr: firstValue,
97
- attrs
98
- };
99
417
  }
100
418
  var yabbcode = class {
101
419
  tags = {
102
420
  "url": {
103
421
  type: "replace",
104
- open: (attr) => `<a href="${escapeHtmlAttr(sanitizeUrl(this.#unsanitize(attr), this.config.allowedSchemes ?? [
105
- "http",
106
- "https",
107
- "mailto",
108
- "ftp",
109
- "ftps"
110
- ]))}">`,
422
+ open: (attr) => `<a href="${escapeHtml(sanitizeUrl(this.#unsanitize(attr), this.config.allowedSchemes ?? defaultAllowedSchemes) ?? "#")}">`,
111
423
  close: "</a>"
112
424
  },
113
425
  "quote": {
114
426
  type: "replace",
115
- open: (attr) => `<blockquote author="${escapeHtmlAttr(this.#unsanitize(attr))}">`,
427
+ open: (attr) => `<blockquote author="${escapeHtml(this.#unsanitize(attr))}">`,
116
428
  close: "</blockquote>"
117
429
  },
118
430
  "b": {
@@ -199,70 +511,25 @@ var yabbcode = class {
199
511
  "img": {
200
512
  type: "content",
201
513
  replace: (attr, content) => {
202
- if (!content) return "";
203
- return `<img src="${escapeHtmlAttr(this.#unsanitize(content))}" alt="${escapeHtmlAttr(this.#unsanitize(attr))}"/>`;
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);
518
+ if (src === void 0) return "";
519
+ return `<img src="${escapeHtml(src)}" alt="${escapeHtml(this.#unsanitize(attr))}"/>`;
204
520
  }
205
521
  },
206
522
  "noparse": { type: "ignore" }
207
523
  };
208
- regex = {
209
- tags: /(\[[^\]^]+\])/g,
210
- newline: /\r\n|\r|\n/g,
211
- placeholders: /\0(\d+);/g
212
- };
524
+ regex = { placeholders: /\0(\d+);/g };
213
525
  config;
214
- contentModules = {
215
- replace: (tag, module, content) => {
216
- let open = module.open;
217
- let close = module.close;
218
- if (typeof open === "function") open = open(tag.attr, tag.attrs);
219
- if (typeof close === "function") close = close(tag.attr, tag.attrs);
220
- if (open && !tag.isClosing) content = replaceFirst(content, placeholder(tag.index), open);
221
- if (close && tag.closing) content = replaceFirst(content, placeholder(tag.closing.index), close);
222
- return content;
223
- },
224
- content: (tag, module, content) => {
225
- if (!tag.closing) return content;
226
- const openTag = placeholder(tag.index);
227
- const closeTag = placeholder(tag.closing.index);
228
- const start = content.indexOf(openTag);
229
- const end = content.indexOf(closeTag);
230
- let replace = module.replace;
231
- const innerContent = content.slice(start + openTag.length, end);
232
- if (typeof replace === "function") replace = replace(tag.attr, innerContent, tag.attrs);
233
- const contentStart = content.slice(0, Math.max(0, start));
234
- const contentEnd = content.slice(end + closeTag.length);
235
- return contentStart + replace + contentEnd;
236
- },
237
- ignore: (tag, _module, content) => {
238
- const openTag = placeholder(tag.index);
239
- const start = content.indexOf(openTag);
240
- let closeTag = "";
241
- let end = content.length;
242
- if (tag.closing) {
243
- closeTag = placeholder(tag.closing.index);
244
- end = content.indexOf(closeTag);
245
- }
246
- let innerContent = content.slice(start + openTag.length, end);
247
- innerContent = this.#ignoreLoop(tag.children, innerContent);
248
- const contentStart = content.slice(0, Math.max(0, start));
249
- const contentEnd = content.slice(end + closeTag.length);
250
- return contentStart + innerContent + contentEnd;
251
- }
252
- };
253
526
  constructor(config = {}) {
254
527
  this.config = {
255
528
  newline: true,
256
529
  paragraph: false,
257
530
  cleanUnmatchable: true,
258
531
  sanitizeHtml: true,
259
- allowedSchemes: [
260
- "http",
261
- "https",
262
- "mailto",
263
- "ftp",
264
- "ftps"
265
- ]
532
+ allowedSchemes: [...defaultAllowedSchemes]
266
533
  };
267
534
  if (config.newline !== void 0) this.config.newline = config.newline;
268
535
  if (config.paragraph !== void 0) this.config.paragraph = config.paragraph;
@@ -270,161 +537,13 @@ var yabbcode = class {
270
537
  if (config.sanitizeHtml !== void 0) this.config.sanitizeHtml = config.sanitizeHtml;
271
538
  if (config.allowedSchemes !== void 0) this.config.allowedSchemes = config.allowedSchemes;
272
539
  }
273
- #getTag(name) {
274
- if (!Object.hasOwn(this.tags, name)) return;
275
- return this.tags[name];
276
- }
277
- #convertNewlines(input) {
278
- if (!this.config.newline) return input;
279
- if (this.config.paragraph) return input.replace(this.regex.newline, "</p><p>");
280
- return input.replace(this.regex.newline, "<br/>");
281
- }
282
- #applyLayout(input) {
283
- input = this.#convertNewlines(input);
284
- if (this.config.paragraph) input = "<p>" + input + "</p>";
285
- return input;
286
- }
287
540
  #unsanitize(value) {
288
541
  if (!this.config.sanitizeHtml) return value;
289
542
  return unescapeSanitized(value);
290
543
  }
291
- #ignoreLoop(tagsMap, content) {
292
- for (const tag of tagsMap) {
293
- content = replaceFirst(content, placeholder(tag.index), tag.raw);
294
- if (tag.closing) content = replaceFirst(content, placeholder(tag.closing.index), tag.closing.raw);
295
- if (tag.children.length > 0) content = this.#ignoreLoop(tag.children, content);
296
- }
297
- return content;
298
- }
299
- #contentLoop(tagsMap, content, parentClosingTagIndex) {
300
- if (tagsMap.length > 50) {
301
- const hasSpecialType = (tag) => {
302
- const module = this.#getTag(tag.module);
303
- if (module) {
304
- if (module.type === "content" || module.type === "ignore") return true;
305
- if (module.type === "replace" && module.autoClose) return true;
306
- }
307
- return tag.children.some((child) => hasSpecialType(child));
308
- };
309
- if (!tagsMap.some((tag) => hasSpecialType(tag))) return this.#contentLoopOptimized(tagsMap, content);
310
- }
311
- for (let i = 0; i < tagsMap.length; i++) {
312
- const tag = tagsMap[i];
313
- if (!tag) continue;
314
- let module = this.#getTag(tag.module);
315
- if (!module) module = {
316
- type: "replace",
317
- open: tag.raw,
318
- close: tag.closing ? tag.closing.raw : ""
319
- };
320
- if (!this.contentModules[module.type]) throw new Error("Cannot parse content block. Invalid block type [" + module.type + "] provided for tag [" + tag.module + "]");
321
- if (module.type === "replace") {
322
- const replaceModule = module;
323
- let open = replaceModule.open;
324
- if (typeof open === "function") open = open(tag.attr, tag.attrs);
325
- if (open && !tag.isClosing) content = replaceFirst(content, placeholder(tag.index), open);
326
- if (tag.children.length > 0) {
327
- const closingIndex = tag.closing?.index;
328
- content = this.#contentLoop(tag.children, content, closingIndex);
329
- }
330
- if (replaceModule.autoClose && !tag.closing) {
331
- let autoClose = replaceModule.autoClose;
332
- if (typeof autoClose === "function") autoClose = autoClose(tag.attr, tag.attrs);
333
- let insertBefore;
334
- for (let siblingIndex = i + 1; siblingIndex < tagsMap.length; siblingIndex++) {
335
- const sibling = tagsMap[siblingIndex];
336
- if (sibling?.module === tag.module) {
337
- insertBefore = placeholder(sibling.index);
338
- break;
339
- }
340
- }
341
- if (insertBefore === void 0 && parentClosingTagIndex !== void 0) insertBefore = placeholder(parentClosingTagIndex);
342
- if (insertBefore && autoClose) content = replaceFirst(content, insertBefore, autoClose + insertBefore);
343
- }
344
- let close = replaceModule.close;
345
- if (typeof close === "function") close = close(tag.attr, tag.attrs);
346
- if (close && tag.closing) content = replaceFirst(content, placeholder(tag.closing.index), close);
347
- } else if (module.type === "content") {
348
- if (tag.children.length > 0) content = this.#contentLoop(tag.children, content, tag.closing?.index);
349
- content = this.contentModules.content(tag, module, content);
350
- } else if (module.type === "ignore") content = this.contentModules.ignore(tag, module, content);
351
- }
352
- return content;
353
- }
354
- #contentLoopOptimized(tagsMap, content) {
355
- const replacements = /* @__PURE__ */ new Map();
356
- const processTag = (tag) => {
357
- let module = this.#getTag(tag.module);
358
- if (!module) module = {
359
- type: "replace",
360
- open: tag.raw,
361
- close: tag.closing ? tag.closing.raw : ""
362
- };
363
- if (module.type !== "replace") return;
364
- let open = module.open;
365
- let close = module.close;
366
- if (typeof open === "function") open = open(tag.attr, tag.attrs);
367
- if (typeof close === "function") close = close(tag.attr, tag.attrs);
368
- if (open && !tag.isClosing) replacements.set(placeholder(tag.index), open);
369
- if (close && tag.closing) replacements.set(placeholder(tag.closing.index), close);
370
- for (const child of tag.children) processTag(child);
371
- };
372
- for (const tag of tagsMap) processTag(tag);
373
- return content.replaceAll(this.regex.placeholders, (match) => replacements.get(match) ?? match);
374
- }
375
- #tagLoop(tagsMap, parent) {
376
- const stack = [];
377
- const processed = /* @__PURE__ */ new Set();
378
- for (let i = 0; i < tagsMap.length; i++) {
379
- const tag = tagsMap[i];
380
- if (!tag) continue;
381
- if (tag.isClosing) for (let stackIndex = stack.length - 1; stackIndex >= 0; stackIndex--) {
382
- const openTag = stack[stackIndex];
383
- if (!openTag) continue;
384
- if (openTag.module === tag.module && !processed.has(openTag.index)) {
385
- openTag.matchTag = tag.index;
386
- openTag.closing = tag;
387
- tag.matchTag = openTag.index;
388
- processed.add(openTag.index);
389
- const childStart = (openTag.stackIndex ?? 0) + 1;
390
- if (childStart < i) {
391
- openTag.children = [];
392
- for (let childIndex = childStart; childIndex < i; childIndex++) {
393
- const childTag = tagsMap[childIndex];
394
- if (!(childTag && !childTag.isClosing && childTag.parent === null)) continue;
395
- childTag.parent = openTag.index;
396
- openTag.children.push(childTag);
397
- }
398
- }
399
- stack.splice(stackIndex, 1);
400
- break;
401
- }
402
- }
403
- else {
404
- tag.stackIndex = i;
405
- stack.push(tag);
406
- }
407
- }
408
- for (const tag of stack) {
409
- tag.matchTag = false;
410
- const childStart = (tag.stackIndex ?? 0) + 1;
411
- if (!(childStart < tagsMap.length)) continue;
412
- tag.children = [];
413
- for (let childIndex = childStart; childIndex < tagsMap.length; childIndex++) {
414
- const childTag = tagsMap[childIndex];
415
- if (!(childTag && !childTag.isClosing && childTag.parent === null)) continue;
416
- childTag.parent = tag.index;
417
- tag.children.push(childTag);
418
- }
419
- }
420
- const result = [];
421
- for (const tag of tagsMap) {
422
- if (tag.isClosing) continue;
423
- if (!(parent === void 0 && tag.parent === null || parent !== void 0 && tag.parent === parent)) continue;
424
- if (tag.children.length > 0) tag.children = this.#tagLoop(tag.children, tag.index);
425
- result.push(tag);
426
- }
427
- 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);
428
547
  }
429
548
  clearTags() {
430
549
  this.tags = {};
@@ -432,99 +551,43 @@ var yabbcode = class {
432
551
  }
433
552
  registerTag(tag, options) {
434
553
  const tagName = String(tag).toLowerCase();
435
- if ([
436
- "__proto__",
437
- "constructor",
438
- "prototype"
439
- ].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}`);
440
555
  this.tags[tagName] = options;
441
556
  return this;
442
557
  }
443
558
  parse(bbcInput) {
444
559
  if (typeof bbcInput === "boolean" || typeof bbcInput !== "string" && Number.isNaN(Number(bbcInput))) return "";
445
- let input = String(bbcInput).replaceAll(PLACEHOLDER_START, "");
446
- if (this.config.sanitizeHtml) {
447
- const htmlEntities = {
448
- "&": "&amp;",
449
- "<": "&lt;",
450
- ">": "&gt;",
451
- "\"": "&quot;",
452
- "'": "&#39;"
453
- };
454
- 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;
455
567
  }
456
- let tagsMap = [];
457
- const tags = String(input).match(this.regex.tags);
458
- if (!tags || tags.length === 0) return this.#applyLayout(input);
459
- for (const [i, tag] of tags.entries()) {
460
- const tagContent = tag.slice(1, -1);
461
- const isClosing = tagContent.startsWith("/");
462
- const contentToParse = isClosing ? tagContent.slice(1) : tagContent;
463
- const spaceIndex = contentToParse.indexOf(" ");
464
- const equalsIndex = contentToParse.indexOf("=");
465
- let moduleName;
466
- let parsedAttrs;
467
- if (spaceIndex === -1 && equalsIndex === -1) {
468
- moduleName = contentToParse.toLowerCase();
469
- parsedAttrs = {
470
- attr: "",
471
- attrs: {}
472
- };
473
- } else if (spaceIndex === -1 || equalsIndex !== -1 && equalsIndex < spaceIndex) {
474
- moduleName = (contentToParse.split("=", 1)[0] ?? "").toLowerCase();
475
- parsedAttrs = parseTagAttributes(contentToParse);
476
- } else {
477
- moduleName = (contentToParse.split(/\s+/, 1)[0] ?? "").toLowerCase();
478
- parsedAttrs = parseTagAttributes(contentToParse);
479
- }
480
- const item = {
481
- index: i,
482
- module: moduleName,
483
- isClosing,
484
- raw: this.#convertNewlines(tag),
485
- attr: parsedAttrs.attr,
486
- attrs: parsedAttrs.attrs,
487
- children: [],
488
- parent: null,
489
- matchTag: null
490
- };
491
- 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>";
492
583
  }
493
- if (tags.length <= 3) for (const [i, tag] of tags.entries()) input = replaceFirst(input, tag, placeholder(i));
494
- else {
495
- const tagPositions = [];
496
- let lastIndex = 0;
497
- for (const [i, tag] of tags.entries()) {
498
- const pos = input.indexOf(tag, lastIndex);
499
- if (pos === -1) continue;
500
- tagPositions.push({
501
- pos,
502
- tag,
503
- placeholder: placeholder(i)
504
- });
505
- lastIndex = pos + tag.length;
506
- }
507
- if (tagPositions.length > 0) {
508
- const parts = [];
509
- let lastPos = 0;
510
- for (const { pos, tag, placeholder } of tagPositions) {
511
- parts.push(input.slice(lastPos, pos));
512
- parts.push(placeholder);
513
- lastPos = pos + tag.length;
514
- }
515
- parts.push(input.slice(lastPos));
516
- input = parts.join("");
517
- }
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);
518
587
  }
519
- input = this.#applyLayout(input);
520
- tagsMap = this.#tagLoop(tagsMap);
521
- input = this.#contentLoop(tagsMap, input);
522
- if (this.config.cleanUnmatchable) return input.replaceAll(this.regex.placeholders, "");
523
- return input.replaceAll(this.regex.placeholders, (_match, index) => {
524
- let raw = tags[Number(index)] ?? "";
525
- if (!this.config.sanitizeHtml) raw = escapeHtmlAttr(raw);
526
- return this.#convertNewlines(raw);
527
- });
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)));
528
591
  }
529
592
  };
530
593