• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nodecraft / ya-bbcode / 19803483080

30 Nov 2025 07:03PM UTC coverage: 63.91% (-35.9%) from 99.775%
19803483080

push

github

web-flow
Merge pull request #382 from nodecraft/refactor/ts

296 of 544 branches covered (54.41%)

Branch coverage included in aggregate %.

214 of 254 new or added lines in 1 file covered. (84.25%)

214 of 254 relevant lines covered (84.25%)

93.93 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

63.91
/src/ya-bbcode.ts
1
// Type definitions
2
interface Config {
3
        newline?: boolean;
4
        paragraph?: boolean;
5
        cleanUnmatchable?: boolean;
6
        sanitizeHtml?: boolean;
7
        allowedSchemes?: string[];
8
}
9

10
// Parsed attributes from tags like [code lang=javascript skip-lint]
11
interface TagAttributes {
12
        [key: string]: string | boolean;
13
}
14

15
interface ReplaceTag {
16
        type: 'replace';
17
        open: ((attr: string, attrs: TagAttributes) => string) | string;
18
        close: ((attr: string, attrs: TagAttributes) => string) | string | null;
19
}
20

21
interface ContentTag {
22
        type: 'content';
23
        replace: ((attr: string, content: string, attrs: TagAttributes) => string) | string;
24
}
25

26
interface IgnoreTag {
27
        type: 'ignore';
28
}
29

30
type TagDefinition = ReplaceTag | ContentTag | IgnoreTag;
31

32
interface TagsMap {
33
        [key: string]: TagDefinition;
34
}
35

36
interface TagItem {
37
        index: number;
38
        module: string;
39
        raw: string;
40
        attr: string; // Backward compat: first value or empty
41
        attrs: TagAttributes; // Parsed attributes
42
        isClosing: boolean;
43
        matchTag: number | false | null;
44
        closing?: TagItem;
45
        children: TagItem[];
46
        parent: number | null;
47
        stackIndex?: number;
48
}
49

50
/**
51
 * Escapes HTML attribute values to prevent XSS
52
 * Encodes quotes, angle brackets, and other special characters
53
 */
54
function escapeHtmlAttr(value: string): string {
55
        const attrEntities: { [key: string]: string; } = {
84✔
56
                '&': '&',
57
                '<': '&lt;',
58
                '>': '&gt;',
59
                '"': '&quot;',
60
                '\'': '&#39;',
61
        };
62
        return String(value).replaceAll(/["&'<>]/g, (char: string) => attrEntities[char] || char);
84!
63
}
64

65
/**
66
 * Validates and sanitizes URLs to prevent javascript: and data: URL attacks
67
 * Only allows safe URL schemes based on the allowedSchemes list
68
 */
69
function sanitizeUrl(url: string, allowedSchemes: string[]): string {
70
        const trimmed = String(url).trim();
54✔
71
        if (!trimmed) { return '#'; }
54✔
72

73
        // Allow relative URLs (starting with /, ./, or ../)
74
        if (/^\.{0,2}\//.test(trimmed)) {
52!
75
                return trimmed;
8✔
76
        }
77

78
        // Allow fragment identifiers
79
        if (trimmed.startsWith('#')) {
44!
80
                return trimmed;
4✔
81
        }
82

83
        // Check for scheme
84
        const schemeMatch = /^([a-z][\d+.a-z-]*):/.exec(trimmed.toLowerCase());
40✔
NEW
85
        if (schemeMatch) {
×
86
                const scheme = schemeMatch[1] ?? '';
40!
87
                if (!allowedSchemes.includes(scheme)) {
40!
88
                        // Dangerous scheme detected (javascript:, data:, vbscript:, etc.)
89
                        return '#';
16✔
90
                }
91
        }
92

93
        return trimmed;
24✔
94
}
95

96
/**
97
 * Parse tag attributes from a tag string
98
 * Supports both formats:
99
 * - [tag=value] → { attr: 'value', attrs: {} }
100
 * - [tag key=value flag] → { attr: 'value', attrs: { key: 'value', flag: true } }
101
 */
102
function parseTagAttributes(tagContent: string): { attr: string; attrs: TagAttributes; } {
103
        // Check if tag has spaces (space-separated) or = (simple attribute)
104
        const firstSpace = tagContent.indexOf(' ');
94✔
105
        const firstEquals = tagContent.indexOf('=');
94✔
106

107
        // Simple attribute format: [tag=value] or [tag] (no attributes)
108
        if (firstSpace === -1 || (firstEquals !== -1 && firstEquals < firstSpace)) {
94!
109
                const parts = tagContent.split('=');
94✔
110
                return {
74✔
111
                        attr: parts.slice(1).join('='), // Everything after first =
112
                        attrs: {},
113
                };
114
        }
115

116
        // Space-separated attributes format: [tag attr1 key=value attr2]
117
        const parts = tagContent.split(/\s+/);
20✔
118
        // Use Object.create(null) to prevent prototype pollution
119
        const attrs: TagAttributes = Object.create(null) as TagAttributes;
20✔
120
        let firstValue = '';
20✔
121

122
        // Dangerous keys that could lead to prototype pollution
123
        const dangerousKeys = new Set(['__proto__', 'constructor', 'prototype']);
20✔
124

125
        for (let i = 1; i < parts.length; i++) {
20✔
126
                const part = parts[i];
28✔
127
                if (!part) { continue; }
28!
128

NEW
129
                if (part.includes('=')) {
×
130
                        const eqIndex = part.indexOf('=');
20✔
131
                        const key = part.slice(0, eqIndex);
20✔
132
                        const value = part.slice(eqIndex + 1);
20✔
133

134
                        // Prevent prototype pollution
135
                        if (dangerousKeys.has(key.toLowerCase())) {
20!
136
                                continue;
10✔
137
                        }
138

139
                        attrs[key] = value;
10✔
140
                        // First key=value becomes the attr for backward compat
141
                        if (!firstValue && value) {
10!
142
                                firstValue = value;
8✔
143
                        }
144
                } else {
145
                        // Boolean flag - also check for dangerous keys
146
                        if (dangerousKeys.has(part.toLowerCase())) {
8!
NEW
147
                                continue;
×
148
                        }
149
                        attrs[part] = true;
8✔
150
                }
151
        }
152

153
        return { attr: firstValue, attrs };
20✔
154
}
155

156
class yabbcode {
157
        tags: TagsMap = {
130✔
158
                'url': {
159
                        type: 'replace',
160
                        open: attr => `<a href="${escapeHtmlAttr(sanitizeUrl(attr, this.config.allowedSchemes ?? ['http', 'https', 'mailto', 'ftp', 'ftps']))}">`,
54!
161
                        close: '</a>',
162
                },
163
                'quote': {
164
                        type: 'replace',
165
                        open: attr => `<blockquote author="${escapeHtmlAttr(attr)}">`,
14✔
166
                        close: '</blockquote>',
167
                },
168
                'b': {
169
                        type: 'replace',
170
                        open: '<strong>',
171
                        close: '</strong>',
172
                },
173
                'u': {
174
                        type: 'replace',
175
                        open: '<u>',
176
                        close: '</u>',
177
                },
178
                'i': {
179
                        type: 'replace',
180
                        open: '<i>',
181
                        close: '</i>',
182
                },
183
                'h1': {
184
                        type: 'replace',
185
                        open: '<h1>',
186
                        close: '</h1>',
187
                },
188
                'h2': {
189
                        type: 'replace',
190
                        open: '<h2>',
191
                        close: '</h2>',
192
                },
193
                'h3': {
194
                        type: 'replace',
195
                        open: '<h3>',
196
                        close: '</h3>',
197
                },
198
                'h4': {
199
                        type: 'replace',
200
                        open: '<h4>',
201
                        close: '</h4>',
202
                },
203
                'h5': {
204
                        type: 'replace',
205
                        open: '<h5>',
206
                        close: '</h5>',
207
                },
208
                'h6': {
209
                        type: 'replace',
210
                        open: '<h6>',
211
                        close: '</h6>',
212
                },
213
                'code': {
214
                        type: 'replace',
215
                        open: '<code>',
216
                        close: '</code>',
217
                },
218
                'strike': {
219
                        type: 'replace',
220
                        open: '<span class="yabbcode-strike">',
221
                        close: '</span>',
222
                },
223
                'spoiler': {
224
                        type: 'replace',
225
                        open: '<span class="yabbcode-spoiler">',
226
                        close: '</span>',
227
                },
228
                'list': {
229
                        type: 'replace',
230
                        open: '<ul>',
231
                        close: '</ul>',
232
                },
233
                'olist': {
234
                        type: 'replace',
235
                        open: '<ol>',
236
                        close: '</ol>',
237
                },
238
                '*': {
239
                        type: 'replace',
240
                        open: '<li>',
241
                        close: null,
242
                },
243
                'img': {
244
                        type: 'content',
245
                        replace: (attr, content) => {
246
                                if (!content) {
122!
247
                                        return '';
2✔
248
                                }
249
                                return `<img src="${escapeHtmlAttr(content)}" alt="${escapeHtmlAttr(attr)}"/>`;
8✔
250
                        },
251
                },
252
                'noparse': {
253
                        type: 'ignore',
254
                },
255
        };
256

257
        regex = {
130✔
258
                tags: /(\[[^\]^]+])/g,
259
                newline: /\r\n|\r|\n/g,
260
                placeholders: /\[TAG-[1-9]+]/g,
261
        };
262

263
        config: Config;
264

265
        contentModules = {
130✔
266
                replace: (tag: TagItem, module: ReplaceTag, content: string): string => {
267
                        let open = module.open;
226✔
268
                        let close = module.close;
226✔
269
                        if (typeof(open) === 'function') {
226!
270
                                open = open(tag.attr, tag.attrs);
90✔
271
                        }
272
                        if (typeof(close) === 'function') {
226!
273
                                close = close(tag.attr, tag.attrs);
4✔
274
                        }
275
                        // do the replace
276
                        if (open && !tag.isClosing) {
226!
277
                                //content = content
278
                                content = content.replace('[TAG-' + tag.index + ']', open);
226✔
279
                        }
280
                        if (close && tag.closing) {
226!
281
                                content = content.replace('[TAG-' + tag.closing.index + ']', close);
210✔
282
                        }
283
                        return content;
226✔
284
                },
285
                content: (tag: TagItem, module: ContentTag, content: string): string => {
286
                        if (!tag.closing) { return content; }
20✔
287
                        const openTag = '[TAG-' + tag.index + ']';
16✔
288
                        const closeTag = '[TAG-' + tag.closing.index + ']';
16✔
289
                        const start = content.indexOf(openTag);
122✔
290
                        const end = content.indexOf(closeTag);
122✔
291
                        let replace = module.replace;
16✔
292

293
                        const innerContent = content.slice(start + openTag.length, end);
122✔
294
                        if (typeof(replace) === 'function') {
122!
295
                                replace = replace(tag.attr, innerContent, tag.attrs);
14✔
296
                        }
297

298
                        const contentStart = content.slice(0, Math.max(0, start));
122✔
299
                        const contentEnd = content.slice(end + closeTag.length);
122✔
300

301
                        return contentStart + replace + contentEnd;
16✔
302
                },
303
                ignore: (tag: TagItem, _module: IgnoreTag, content: string): string => {
304
                        const openTag = '[TAG-' + tag.index + ']';
6✔
305
                        const start = content.indexOf(openTag);
122✔
306
                        let closeTag = '';
6✔
307
                        let end = content.length;
6✔
308
                        if (tag.closing) {
122!
309
                                closeTag = '[TAG-' + tag.closing.index + ']';
4✔
310
                                end = content.indexOf(closeTag);
4✔
311
                        }
312
                        let innerContent = content.slice(start + openTag.length, end);
122✔
313
                        innerContent = this.#ignoreLoop(tag.children, innerContent);
6✔
314
                        const contentStart = content.slice(0, Math.max(0, start));
122✔
315
                        const contentEnd = content.slice(end + closeTag.length);
122✔
316
                        return contentStart + innerContent + contentEnd;
6✔
317
                },
318
        };
319

320
        constructor(config: Config = {}) {
138✔
321
                this.config = {
130✔
322
                        newline: true,
323
                        paragraph: false,
324
                        cleanUnmatchable: true,
325
                        sanitizeHtml: true,
326
                        allowedSchemes: ['http', 'https', 'mailto', 'ftp', 'ftps'],
327
                };
328
                if (config.newline !== undefined) {
130✔
329
                        this.config.newline = config.newline;
10✔
330
                }
331
                if (config.paragraph !== undefined) {
130!
332
                        this.config.paragraph = config.paragraph;
2✔
333
                }
334
                if (config.cleanUnmatchable !== undefined) {
130!
335
                        this.config.cleanUnmatchable = config.cleanUnmatchable;
4✔
336
                }
337
                if (config.sanitizeHtml !== undefined) {
130!
338
                        this.config.sanitizeHtml = config.sanitizeHtml;
2✔
339
                }
340
                if (config.allowedSchemes !== undefined) {
130!
341
                        this.config.allowedSchemes = config.allowedSchemes;
4✔
342
                }
343
        }
344

345
        #ignoreLoop(tagsMap: TagItem[], content: string): string {
NEW
346
                for (const tag of tagsMap) {
×
347
                        content = content.replace('[TAG-' + tag.index + ']', tag.raw);
8✔
348
                        if (tag.closing) {
8!
349
                                content = content.replace('[TAG-' + tag.closing.index + ']', tag.closing.raw);
8✔
350
                        }
351
                        if (tag.children.length > 0) {
8!
352
                                content = this.#ignoreLoop(tag.children, content);
2✔
353
                        }
354
                }
355
                return content;
8✔
356
        }
357

358
        #contentLoop(tagsMap: TagItem[], content: string): string {
359
                // Adaptive threshold: use optimized path for large documents (>50 tags)
360
                // Only optimize when all tags are replace-type (most common case for large docs)
361
                if (tagsMap.length > 50) {
2!
362
                        // Check if we have any content or ignore type tags
NEW
363
                        const hasSpecialTypes = tagsMap.some((tag) => {
×
NEW
364
                                const module = this.tags[tag.module];
×
NEW
365
                                return module && (module.type === 'content' || module.type === 'ignore');
×
366
                        });
367

368
                        // If only replace-type tags, use optimized single-pass approach
NEW
369
                        if (!hasSpecialTypes) {
×
NEW
370
                                return this.#contentLoopOptimized(tagsMap, content);
×
371
                        }
372
                }
373

374
                for (const tag of tagsMap) {
2✔
375
                        let module = this.tags[tag.module];
256✔
376
                        if (!module) {
256!
377
                                // ignore invalid BBCode
378
                                module = {
4✔
379
                                        type: 'replace',
380
                                        open: tag.raw,
381
                                        close: tag.closing ? tag.closing.raw : '',
4!
382
                                };
383
                        }
384
                        if (!this.contentModules[module.type]) {
256!
385
                                throw new Error('Cannot parse content block. Invalid block type [' + module.type + '] provided for tag [' + tag.module + ']');
4✔
386
                        }
387
                        if (module.type === 'replace') {
252!
388
                                content = this.contentModules.replace(tag, module, content);
252✔
389
                        } else if (module.type === 'content') {
26!
390
                                content = this.contentModules.content(tag, module, content);
20✔
391
                        } else if (module.type === 'ignore') {
6!
392
                                content = this.contentModules.ignore(tag, module, content);
6✔
393
                        }
394
                        if (tag.children.length > 0 && module.type !== 'ignore') {
252!
395
                                content = this.#contentLoop(tag.children, content);
18✔
396
                        }
397
                }
398

399
                return content;
182✔
400
        }
401

402
        #contentLoopOptimized(tagsMap: TagItem[], content: string): string {
403
                // Optimized single-pass replacement for replace-type tags only
404
                // Build replacement map for all placeholders
NEW
405
                const replacements = new Map<string, string>();
×
406

NEW
407
                const processTag = (tag: TagItem) => {
×
NEW
408
                        let module = this.tags[tag.module];
×
NEW
409
                        if (!module) {
×
NEW
410
                                module = {
×
411
                                        type: 'replace',
412
                                        open: tag.raw,
413
                                        close: tag.closing ? tag.closing.raw : '',
×
414
                                } as ReplaceTag;
415
                        }
416

417
                        // Only handle replace type (this function is only called when all tags are replace type)
NEW
418
                        if (module.type !== 'replace') { return; }
×
419

NEW
420
                        let open = module.open;
×
NEW
421
                        let close = module.close;
×
NEW
422
                        if (typeof(open) === 'function') {
×
NEW
423
                                open = open(tag.attr, tag.attrs);
×
424
                        }
NEW
425
                        if (typeof(close) === 'function') {
×
NEW
426
                                close = close(tag.attr, tag.attrs);
×
427
                        }
NEW
428
                        if (open && !tag.isClosing) {
×
NEW
429
                                replacements.set('[TAG-' + tag.index + ']', open);
×
430
                        }
NEW
431
                        if (close && tag.closing) {
×
NEW
432
                                replacements.set('[TAG-' + tag.closing.index + ']', close);
×
433
                        }
434

435
                        // Process children recursively
NEW
436
                        if (tag.children.length > 0) {
×
NEW
437
                                for (const child of tag.children) {
×
NEW
438
                                        processTag(child);
×
439
                                }
440
                        }
441
                };
442

443
                // Build replacement map for all tags
NEW
444
                for (const tag of tagsMap) {
×
NEW
445
                        processTag(tag);
×
446
                }
447

448
                // Single-pass replacement using regex
NEW
449
                const placeholderRegex = /\[TAG-(\d+)]/g;
×
NEW
450
                return content.replaceAll(placeholderRegex, (match: string) => replacements.get(match) || '');
×
451
        }
452

453
        #tagLoop(tagsMap: TagItem[], parent?: number): TagItem[] {
454
                // Use stack-based algorithm - O(n) instead of O(n²)
455
                const stack = [];
194✔
456
                const processed = new Set();
194✔
457

458
                for (let i = 0; i < tagsMap.length; i++) {
194✔
459
                        const tag = tagsMap[i];
544✔
460
                        if (!tag) { continue; }
544!
461

462
                        if (tag.isClosing) {
4✔
463
                                // Find matching opening tag on stack (search backwards for proper nesting)
464
                                for (let stackIndex = stack.length - 1; stackIndex >= 0; stackIndex--) {
244✔
465
                                        const openTag = stack[stackIndex];
262✔
466
                                        if (!openTag) { continue; }
262!
467
                                        if (openTag.module === tag.module && !processed.has(openTag.index)) {
2!
468
                                                // Match found
469
                                                openTag.matchTag = tag.index;
242✔
470
                                                openTag.closing = tag;
242✔
471
                                                tag.matchTag = openTag.index;
242✔
472
                                                processed.add(openTag.index);
242✔
473

474
                                                // Set children for the opening tag
475
                                                const childStart = (openTag.stackIndex ?? 0) + 1;
242!
476
                                                if (childStart < i) {
2!
477
                                                        openTag.children = [];
24✔
478
                                                        for (let childIndex = childStart; childIndex < i; childIndex++) {
24✔
479
                                                                const childTag = tagsMap[childIndex];
64✔
NEW
480
                                                                if (childTag && !childTag.isClosing && childTag.parent === null) {
×
481
                                                                        childTag.parent = openTag.index;
32✔
482
                                                                        openTag.children.push(childTag);
32✔
483
                                                                }
484
                                                        }
485
                                                }
486

487
                                                // Remove matched opening tag from stack
488
                                                stack.splice(stackIndex, 1);
242✔
489
                                                break;
242✔
490
                                        }
491
                                }
492
                        } else {
493
                                // Opening tag - add to stack
494
                                tag.stackIndex = i;
300✔
495
                                stack.push(tag);
300✔
496
                        }
497
                }
498

499
                // Handle unmatched opening tags - assign all remaining tags as children
500
                for (const tag of stack) {
2✔
501
                        tag.matchTag = false;
58✔
502
                        const childStart = (tag.stackIndex ?? 0) + 1;
58!
NEW
503
                        if (childStart < tagsMap.length) {
×
504
                                tag.children = [];
32✔
505
                                for (let childIndex = childStart; childIndex < tagsMap.length; childIndex++) {
32✔
506
                                        const childTag = tagsMap[childIndex];
62✔
NEW
507
                                        if (childTag && !childTag.isClosing && childTag.parent === null) {
×
508
                                                childTag.parent = tag.index;
4✔
509
                                                tag.children.push(childTag);
4✔
510
                                        }
511
                                }
512
                        }
513
                }
514

515
                // Filter and process recursively
516
                const result = [];
194✔
517
                for (const tag of tagsMap) {
2✔
518
                        if (!tag.isClosing) {
4✔
519
                                const shouldInclude = (parent === undefined && tag.parent === null) ||
2✔
520
                                        (parent !== undefined && tag.parent === parent);
521
                                if (shouldInclude) {
2!
522
                                        if (tag.children.length > 0) {
264!
523
                                                tag.children = this.#tagLoop(tag.children, tag.index);
26✔
524
                                        }
525
                                        result.push(tag);
264✔
526
                                }
527
                        }
528
                }
529

530
                return result;
194✔
531
        }
532

533
        clearTags(): this {
534
                this.tags = {};
4✔
535
                return this;
4✔
536
        }
537

538
        registerTag(tag: string, options: TagDefinition): this {
539
                const tagName = String(tag).toLowerCase();
38✔
540

541
                // Prevent prototype pollution
542
                const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
2✔
543
                if (dangerousKeys.includes(tagName)) {
38!
544
                        throw new Error(`Cannot register tag with dangerous name: ${tagName}`);
6✔
545
                }
546

547
                this.tags[tagName] = options;
32✔
548
                return this;
32✔
549
        }
550

551
        parse(bbcInput: string | number | boolean): string {
552
                if (
190✔
553
                        typeof(bbcInput) === 'boolean'
390✔
554
                        || (typeof(bbcInput) !== 'string' && Number.isNaN(Number(bbcInput)))
555
                ) { return ''; }
10✔
556
                let input = String(bbcInput);
182✔
557
                if (this.config.sanitizeHtml) {
2!
558
                        // Batch HTML entity replacement for better performance
559
                        const htmlEntities = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' };
180✔
560
                        input = input.replaceAll(/["&'<>]/g, (char: string) => htmlEntities[char as keyof typeof htmlEntities]);
180✔
561
                }
562

563
                // reset
564
                let tagsMap: TagItem[] = [];
182✔
565
                // split input into tags by index
566
                const tags = String(input).match(this.regex.tags);
182✔
567

568
                if (this.config.newline) {
182!
569
                        if (this.config.paragraph) {
182!
570
                                input = input.replace(this.regex.newline, '</p><p>');
2✔
571
                        } else {
572
                                input = input.replace(this.regex.newline, '<br/>');
178✔
573
                        }
574
                }
575
                if (this.config.paragraph) {
182!
576
                        input = '<p>' + input + '</p>';
180✔
577
                }
578

579
                // handle when no tags are present
580
                if (!tags || tags.length === 0) {
182!
581
                        return input;
14✔
582
                }
583
                // Build tag map and replace with placeholders
584
                for (const [i, tag] of tags.entries()) {
2✔
585
                        const tagContent = tag.slice(1, -1); // Remove [ ]
508✔
586
                        const isClosing = tagContent.startsWith('/');
508✔
587
                        const contentToParse = isClosing ? tagContent.slice(1) : tagContent;
508✔
588

589
                        // Parse module name and attributes
590
                        const spaceIndex = contentToParse.indexOf(' ');
508✔
591
                        const equalsIndex = contentToParse.indexOf('=');
508✔
592
                        let moduleName: string;
593
                        let parsedAttrs: { attr: string; attrs: TagAttributes; };
594

595
                        // Extract module name (before space or =)
596
                        if (spaceIndex === -1 && equalsIndex === -1) {
4!
597
                                // No attributes: [tag]
598
                                moduleName = contentToParse.toLowerCase();
414✔
599
                                parsedAttrs = { attr: '', attrs: {} };
414✔
NEW
600
                        } else if (spaceIndex === -1 || (equalsIndex !== -1 && equalsIndex < spaceIndex)) {
×
601
                                // Old format: [tag=value]
602
                                moduleName = (contentToParse.split('=')[0] ?? '').toLowerCase();
74!
603
                                parsedAttrs = parseTagAttributes(contentToParse);
74✔
604
                        } else {
605
                                // New format: [tag attr1 key=value]
606
                                moduleName = (contentToParse.split(/\s+/)[0] ?? '').toLowerCase();
20!
607
                                parsedAttrs = parseTagAttributes(contentToParse);
20✔
608
                        }
609

610
                        const item: TagItem = {
508✔
611
                                index: i,
612
                                module: moduleName,
613
                                isClosing,
614
                                raw: tag,
615
                                attr: parsedAttrs.attr,
616
                                attrs: parsedAttrs.attrs,
617
                                children: [],
618
                                parent: null,
619
                                matchTag: null,
620
                        };
621

622
                        tagsMap.push(item);
508✔
623
                }
624

625
                // Replace tags with placeholders - optimized for different input sizes
626
                if (tags.length <= 3) {
2!
627
                        // Fast path for few tags - direct replacement is faster
628
                        for (const [i, tag] of tags.entries()) {
168✔
629
                                input = input.replace(tag, '[TAG-' + i + ']');
302✔
630
                        }
631
                } else {
632
                        // Batch replacement for many tags
633
                        const tagPositions = [];
22✔
634
                        let lastIndex = 0;
22✔
NEW
635
                        for (const [i, tag] of tags.entries()) {
×
636
                                const pos = input.indexOf(tag, lastIndex);
206✔
NEW
637
                                if (pos !== -1) {
×
638
                                        tagPositions.push({ pos, tag: tag, placeholder: '[TAG-' + i + ']' });
206✔
639
                                        lastIndex = pos + tag.length;
206✔
640
                                }
641
                        }
642

NEW
643
                        if (tagPositions.length > 0) {
×
644
                                const parts = [];
22✔
645
                                let lastPos = 0;
22✔
NEW
646
                                for (const { pos, tag, placeholder } of tagPositions) {
×
647
                                        parts.push(input.slice(lastPos, pos));
206✔
648
                                        parts.push(placeholder);
206✔
649
                                        lastPos = pos + tag.length;
206✔
650
                                }
651
                                parts.push(input.slice(lastPos));
22✔
652
                                input = parts.join('');
22✔
653
                        }
654
                }
655
                // loop through each tag to create nested elements
656
                tagsMap = this.#tagLoop(tagsMap);
168✔
657
                // put back all non-found matches?
658
                input = this.#contentLoop(tagsMap, input);
168✔
659
                if (this.config.cleanUnmatchable) {
168!
660
                        input = input.replace(this.regex.placeholders, '');
162✔
661
                }
662
                return input;
164✔
663
        }
664
}
665

666
// Export types for users
667
export type { Config, TagAttributes, ReplaceTag, ContentTag, IgnoreTag, TagDefinition };
668

669
// Default export for both CJS and ESM
670
export default yabbcode;
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc