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.
@@ -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 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
  }
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 (/^\.{0,2}\//.test(trimmed)) return trimmed;
73
+ if (relativeUrl.test(trimmed)) return trimmed;
59
74
  if (trimmed.startsWith("#")) return trimmed;
60
- const schemeMatch = /^([a-z][\d+.a-z-]*):/.exec(trimmed.toLowerCase());
61
- if (schemeMatch) {
62
- const scheme = schemeMatch[1] ?? "";
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 a tag string
92
+ * Parse a tag's attributes from the text between its brackets
69
93
  * Supports both formats:
70
- * - [tag=value] → { attr: 'value', attrs: {} }
71
- * - [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 }
72
96
  */
73
- function parseTagAttributes(tagContent) {
74
- const firstSpace = tagContent.indexOf(" ");
75
- const firstEquals = tagContent.indexOf("=");
76
- if (firstSpace === -1 || firstEquals !== -1 && firstEquals < firstSpace) return {
77
- attr: tagContent.split("=").slice(1).join("="),
78
- attrs: {}
79
- };
80
- 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+/);
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
- if (part.includes("=")) {
92
- const eqIndex = part.indexOf("=");
93
- const key = part.slice(0, eqIndex);
94
- const value = part.slice(eqIndex + 1);
95
- if (dangerousKeys.has(key.toLowerCase())) continue;
96
- attrs[key] = value;
97
- if (!firstValue && value) firstValue = value;
98
- } else {
99
- if (dangerousKeys.has(part.toLowerCase())) continue;
100
- 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;
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="${escapeHtmlAttr(sanitizeUrl(this.#unsanitize(attr), this.config.allowedSchemes ?? defaultAllowedSchemes) ?? "#")}">`,
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="${escapeHtmlAttr(this.#unsanitize(attr))}">`,
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
- const src = sanitizeUrl(this.#unsanitize(content.replaceAll(this.regex.placeholders, "")), this.config.allowedSchemes ?? defaultAllowedSchemes);
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="${escapeHtmlAttr(src)}" alt="${escapeHtmlAttr(this.#unsanitize(attr))}"/>`;
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
- #findIgnoredTags(tagsMap) {
288
- const ignored = /* @__PURE__ */ new Set();
289
- let ignoreModule;
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).replaceAll(PLACEHOLDER_START, "");
443
- if (this.config.sanitizeHtml) {
444
- const htmlEntities = {
445
- "&": "&amp;",
446
- "<": "&lt;",
447
- ">": "&gt;",
448
- "\"": "&quot;",
449
- "'": "&#39;"
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
- let tagsMap = [];
454
- const tags = String(input).match(this.regex.tags);
455
- if (!tags || tags.length === 0) return this.#applyLayout(input);
456
- for (const [i, tag] of tags.entries()) {
457
- const tagContent = tag.slice(1, -1);
458
- const isClosing = tagContent.startsWith("/");
459
- const contentToParse = isClosing ? tagContent.slice(1) : tagContent;
460
- const spaceIndex = contentToParse.indexOf(" ");
461
- const equalsIndex = contentToParse.indexOf("=");
462
- let moduleName;
463
- let parsedAttrs;
464
- if (spaceIndex === -1 && equalsIndex === -1) {
465
- moduleName = contentToParse.toLowerCase();
466
- parsedAttrs = {
467
- attr: "",
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
- const ignored = this.#findIgnoredTags(tagsMap);
491
- tagsMap = tagsMap.filter((tag) => !ignored.has(tag.index));
492
- const parts = [];
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.push(input.slice(copiedUpTo));
504
- input = parts.join("");
505
- input = this.#applyLayout(input);
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