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

nodecraft / ya-bbcode / 19826989684

01 Dec 2025 02:57PM UTC coverage: 61.806% (-2.1%) from 63.91%
19826989684

Pull #383

github

web-flow
Merge 52642dd53 into b0afb625d
Pull Request #383: feat: add support for auto-closing tags like [*]

338 of 638 branches covered (52.98%)

Branch coverage included in aggregate %.

29 of 34 new or added lines in 1 file covered. (85.29%)

7 existing lines in 1 file now uncovered.

230 of 281 relevant lines covered (81.85%)

102.11 hits per line

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

61.81
/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
         * HTML to insert when the tag implicitly closes (e.g., before next sibling or parent close).
21
         * Useful for tags like [*] that don't have explicit closing tags in BBCode.
22
         */
23
        autoClose?: ((attr: string, attrs: TagAttributes) => string) | string;
24
}
25

26
interface ContentTag {
27
        type: 'content';
28
        replace: ((attr: string, content: string, attrs: TagAttributes) => string) | string;
29
}
30

31
interface IgnoreTag {
32
        type: 'ignore';
33
}
34

35
type TagDefinition = ReplaceTag | ContentTag | IgnoreTag;
36

37
interface TagsMap {
38
        [key: string]: TagDefinition;
39
}
40

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

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

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

78
        // Allow relative URLs (starting with /, ./, or ../)
79
        if (/^\.{0,2}\//.test(trimmed)) {
52!
80
                return trimmed;
8✔
81
        }
82

83
        // Allow fragment identifiers
84
        if (trimmed.startsWith('#')) {
44!
85
                return trimmed;
4✔
86
        }
87

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

98
        return trimmed;
24✔
99
}
100

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

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

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

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

130
        for (let i = 1; i < parts.length; i++) {
20✔
131
                const part = parts[i];
28✔
132
                if (!part) { continue; }
28!
133

134
                if (part.includes('=')) {
×
135
                        const eqIndex = part.indexOf('=');
20✔
136
                        const key = part.slice(0, eqIndex);
20✔
137
                        const value = part.slice(eqIndex + 1);
20✔
138

139
                        // Prevent prototype pollution
140
                        if (dangerousKeys.has(key.toLowerCase())) {
20!
141
                                continue;
10✔
142
                        }
143

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

158
        return { attr: firstValue, attrs };
20✔
159
}
160

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

268
        regex = {
138✔
269
                tags: /(\[[^\]^]+])/g,
270
                newline: /\r\n|\r|\n/g,
271
                placeholders: /\[TAG-[1-9]+]/g,
272
        };
273

274
        config: Config;
275

276
        contentModules = {
138✔
277
                replace: (tag: TagItem, module: ReplaceTag, content: string): string => {
UNCOV
278
                        let open = module.open;
×
UNCOV
279
                        let close = module.close;
×
280
                        if (typeof(open) === 'function') {
130!
UNCOV
281
                                open = open(tag.attr, tag.attrs);
×
282
                        }
283
                        if (typeof(close) === 'function') {
130!
UNCOV
284
                                close = close(tag.attr, tag.attrs);
×
285
                        }
286
                        // do the replace
287
                        if (open && !tag.isClosing) {
130!
288
                                //content = content
UNCOV
289
                                content = content.replace('[TAG-' + tag.index + ']', open);
×
290
                        }
291
                        if (close && tag.closing) {
130!
UNCOV
292
                                content = content.replace('[TAG-' + tag.closing.index + ']', close);
×
293
                        }
UNCOV
294
                        return content;
×
295
                },
296
                content: (tag: TagItem, module: ContentTag, content: string): string => {
297
                        if (!tag.closing) { return content; }
20✔
298
                        const openTag = '[TAG-' + tag.index + ']';
16✔
299
                        const closeTag = '[TAG-' + tag.closing.index + ']';
16✔
300
                        const start = content.indexOf(openTag);
130✔
301
                        const end = content.indexOf(closeTag);
130✔
302
                        let replace = module.replace;
16✔
303

304
                        const innerContent = content.slice(start + openTag.length, end);
130✔
305
                        if (typeof(replace) === 'function') {
130!
306
                                replace = replace(tag.attr, innerContent, tag.attrs);
14✔
307
                        }
308

309
                        const contentStart = content.slice(0, Math.max(0, start));
130✔
310
                        const contentEnd = content.slice(end + closeTag.length);
130✔
311

312
                        return contentStart + replace + contentEnd;
16✔
313
                },
314
                ignore: (tag: TagItem, _module: IgnoreTag, content: string): string => {
315
                        const openTag = '[TAG-' + tag.index + ']';
6✔
316
                        const start = content.indexOf(openTag);
130✔
317
                        let closeTag = '';
6✔
318
                        let end = content.length;
6✔
319
                        if (tag.closing) {
130!
320
                                closeTag = '[TAG-' + tag.closing.index + ']';
4✔
321
                                end = content.indexOf(closeTag);
4✔
322
                        }
323
                        let innerContent = content.slice(start + openTag.length, end);
130✔
324
                        innerContent = this.#ignoreLoop(tag.children, innerContent);
6✔
325
                        const contentStart = content.slice(0, Math.max(0, start));
130✔
326
                        const contentEnd = content.slice(end + closeTag.length);
130✔
327
                        return contentStart + innerContent + contentEnd;
6✔
328
                },
329
        };
330

331
        constructor(config: Config = {}) {
146✔
332
                this.config = {
138✔
333
                        newline: true,
334
                        paragraph: false,
335
                        cleanUnmatchable: true,
336
                        sanitizeHtml: true,
337
                        allowedSchemes: ['http', 'https', 'mailto', 'ftp', 'ftps'],
338
                };
339
                if (config.newline !== undefined) {
138✔
340
                        this.config.newline = config.newline;
10✔
341
                }
342
                if (config.paragraph !== undefined) {
138!
343
                        this.config.paragraph = config.paragraph;
2✔
344
                }
345
                if (config.cleanUnmatchable !== undefined) {
138!
346
                        this.config.cleanUnmatchable = config.cleanUnmatchable;
4✔
347
                }
348
                if (config.sanitizeHtml !== undefined) {
138!
349
                        this.config.sanitizeHtml = config.sanitizeHtml;
2✔
350
                }
351
                if (config.allowedSchemes !== undefined) {
138!
352
                        this.config.allowedSchemes = config.allowedSchemes;
4✔
353
                }
354
        }
355

356
        #ignoreLoop(tagsMap: TagItem[], content: string): string {
357
                for (const tag of tagsMap) {
×
358
                        content = content.replace('[TAG-' + tag.index + ']', tag.raw);
8✔
359
                        if (tag.closing) {
8!
360
                                content = content.replace('[TAG-' + tag.closing.index + ']', tag.closing.raw);
8✔
361
                        }
362
                        if (tag.children.length > 0) {
8!
363
                                content = this.#ignoreLoop(tag.children, content);
2✔
364
                        }
365
                }
366
                return content;
8✔
367
        }
368

369
        #contentLoop(tagsMap: TagItem[], content: string, parentClosingTagIndex?: number): string {
370
                // Adaptive threshold: use optimized path for large documents (>50 tags)
371
                // Only optimize when all tags are replace-type and none have autoClose
372
                if (tagsMap.length > 50) {
2!
373
                        // Check if we have any content, ignore type, or autoClose tags
374
                        const hasSpecialTypes = tagsMap.some((tag) => {
×
375
                                const module = this.tags[tag.module];
×
NEW
376
                                if (!module) { return false; }
×
NEW
377
                                if (module.type === 'content' || module.type === 'ignore') { return true; }
×
NEW
378
                                if (module.type === 'replace' && (module as ReplaceTag).autoClose) { return true; }
×
NEW
379
                                return false;
×
380
                        });
381

382
                        // If only replace-type tags without autoClose, use optimized single-pass approach
383
                        if (!hasSpecialTypes) {
×
384
                                return this.#contentLoopOptimized(tagsMap, content);
×
385
                        }
386
                }
387

388
                for (let i = 0; i < tagsMap.length; i++) {
204✔
389
                        const tag = tagsMap[i];
284✔
390
                        if (!tag) { continue; }
284!
391

392
                        let module = this.tags[tag.module];
284✔
393
                        if (!module) {
284!
394
                                // ignore invalid BBCode
395
                                module = {
4✔
396
                                        type: 'replace',
397
                                        open: tag.raw,
398
                                        close: tag.closing ? tag.closing.raw : '',
4!
399
                                };
400
                        }
401
                        if (!this.contentModules[module.type]) {
284!
402
                                throw new Error('Cannot parse content block. Invalid block type [' + module.type + '] provided for tag [' + tag.module + ']');
4✔
403
                        }
404

405
                        if (module.type === 'replace') {
280!
406
                                const replaceModule = module as ReplaceTag;
254✔
407

408
                                // Step 1: Process opening tag
409
                                let open = replaceModule.open;
254✔
410
                                if (typeof(open) === 'function') {
280✔
411
                                        open = open(tag.attr, tag.attrs);
90✔
412
                                }
413
                                if (open && !tag.isClosing) {
280!
414
                                        content = content.replace('[TAG-' + tag.index + ']', open);
254✔
415
                                }
416

417
                                // Step 2: Process children (before closing, so parent's close placeholder is still available)
418
                                if (tag.children.length > 0) {
280✔
419
                                        const closingIndex = tag.closing?.index;
24✔
420
                                        content = this.#contentLoop(tag.children, content, closingIndex);
24✔
421
                                }
422

423
                                // Step 3: Handle autoClose for tags without explicit closing
424
                                if (replaceModule.autoClose && !tag.closing) {
280✔
425
                                        let autoClose = replaceModule.autoClose;
24✔
426
                                        if (typeof(autoClose) === 'function') {
278!
NEW
427
                                                autoClose = autoClose(tag.attr, tag.attrs);
×
428
                                        }
429

430
                                        // Find insertion point: before next sibling or parent close
431
                                        let insertBefore: string | undefined;
432
                                        const nextTag = tagsMap[i + 1];
24✔
433
                                        if (nextTag) {
278✔
434
                                                insertBefore = '[TAG-' + nextTag.index + ']';
16✔
435
                                        } else if (parentClosingTagIndex !== undefined) {
278!
436
                                                insertBefore = '[TAG-' + parentClosingTagIndex + ']';
8✔
437
                                        }
438

439
                                        if (insertBefore && autoClose) {
278!
440
                                                content = content.replace(insertBefore, autoClose + insertBefore);
24✔
441
                                        }
442
                                }
443

444
                                // Step 4: Process closing tag (after children)
445
                                let close = replaceModule.close;
254✔
446
                                if (typeof(close) === 'function') {
280✔
447
                                        close = close(tag.attr, tag.attrs);
4✔
448
                                }
449
                                if (close && tag.closing) {
280✔
450
                                        content = content.replace('[TAG-' + tag.closing.index + ']', close);
222✔
451
                                }
452

453
                        } else if (module.type === 'content') {
26!
454
                                content = this.contentModules.content(tag, module as ContentTag, content);
20✔
455
                        } else if (module.type === 'ignore') {
6!
456
                                content = this.contentModules.ignore(tag, module as IgnoreTag, content);
6✔
457
                        }
458
                }
459

460
                return content;
200✔
461
        }
462

463
        #contentLoopOptimized(tagsMap: TagItem[], content: string): string {
464
                // Optimized single-pass replacement for replace-type tags only
465
                // Build replacement map for all placeholders
466
                const replacements = new Map<string, string>();
×
467

468
                const processTag = (tag: TagItem) => {
×
469
                        let module = this.tags[tag.module];
×
470
                        if (!module) {
×
471
                                module = {
×
472
                                        type: 'replace',
473
                                        open: tag.raw,
474
                                        close: tag.closing ? tag.closing.raw : '',
×
475
                                } as ReplaceTag;
476
                        }
477

478
                        // Only handle replace type (this function is only called when all tags are replace type)
479
                        if (module.type !== 'replace') { return; }
×
480

481
                        let open = module.open;
×
482
                        let close = module.close;
×
483
                        if (typeof(open) === 'function') {
×
484
                                open = open(tag.attr, tag.attrs);
×
485
                        }
486
                        if (typeof(close) === 'function') {
×
487
                                close = close(tag.attr, tag.attrs);
×
488
                        }
489
                        if (open && !tag.isClosing) {
×
490
                                replacements.set('[TAG-' + tag.index + ']', open);
×
491
                        }
492
                        if (close && tag.closing) {
×
493
                                replacements.set('[TAG-' + tag.closing.index + ']', close);
×
494
                        }
495

496
                        // Process children recursively
497
                        if (tag.children.length > 0) {
×
498
                                for (const child of tag.children) {
×
499
                                        processTag(child);
×
500
                                }
501
                        }
502
                };
503

504
                // Build replacement map for all tags
505
                for (const tag of tagsMap) {
×
506
                        processTag(tag);
×
507
                }
508

509
                // Single-pass replacement using regex
510
                const placeholderRegex = /\[TAG-(\d+)]/g;
×
511
                return content.replaceAll(placeholderRegex, (match: string) => replacements.get(match) || '');
×
512
        }
513

514
        #tagLoop(tagsMap: TagItem[], parent?: number): TagItem[] {
515
                // Use stack-based algorithm - O(n) instead of O(n²)
516
                const stack = [];
212✔
517
                const processed = new Set();
212✔
518

519
                for (let i = 0; i < tagsMap.length; i++) {
212✔
520
                        const tag = tagsMap[i];
604✔
521
                        if (!tag) { continue; }
604!
522

523
                        if (tag.isClosing) {
4✔
524
                                // Find matching opening tag on stack (search backwards for proper nesting)
525
                                for (let stackIndex = stack.length - 1; stackIndex >= 0; stackIndex--) {
260✔
526
                                        const openTag = stack[stackIndex];
288✔
527
                                        if (!openTag) { continue; }
288!
528
                                        if (openTag.module === tag.module && !processed.has(openTag.index)) {
2!
529
                                                // Match found
530
                                                openTag.matchTag = tag.index;
258✔
531
                                                openTag.closing = tag;
258✔
532
                                                tag.matchTag = openTag.index;
258✔
533
                                                processed.add(openTag.index);
258✔
534

535
                                                // Set children for the opening tag
536
                                                const childStart = (openTag.stackIndex ?? 0) + 1;
258!
537
                                                if (childStart < i) {
2!
538
                                                        openTag.children = [];
30✔
539
                                                        for (let childIndex = childStart; childIndex < i; childIndex++) {
30✔
540
                                                                const childTag = tagsMap[childIndex];
86✔
541
                                                                if (childTag && !childTag.isClosing && childTag.parent === null) {
×
542
                                                                        childTag.parent = openTag.index;
48✔
543
                                                                        openTag.children.push(childTag);
48✔
544
                                                                }
545
                                                        }
546
                                                }
547

548
                                                // Remove matched opening tag from stack
549
                                                stack.splice(stackIndex, 1);
258✔
550
                                                break;
258✔
551
                                        }
552
                                }
553
                        } else {
554
                                // Opening tag - add to stack
555
                                tag.stackIndex = i;
344✔
556
                                stack.push(tag);
344✔
557
                        }
558
                }
559

560
                // Handle unmatched opening tags - assign all remaining tags as children
561
                for (const tag of stack) {
2✔
562
                        tag.matchTag = false;
86✔
563
                        const childStart = (tag.stackIndex ?? 0) + 1;
86!
564
                        if (childStart < tagsMap.length) {
×
565
                                tag.children = [];
52✔
566
                                for (let childIndex = childStart; childIndex < tagsMap.length; childIndex++) {
52✔
567
                                        const childTag = tagsMap[childIndex];
94✔
568
                                        if (childTag && !childTag.isClosing && childTag.parent === null) {
×
569
                                                childTag.parent = tag.index;
4✔
570
                                                tag.children.push(childTag);
4✔
571
                                        }
572
                                }
573
                        }
574
                }
575

576
                // Filter and process recursively
577
                const result = [];
212✔
578
                for (const tag of tagsMap) {
2✔
579
                        if (!tag.isClosing) {
4✔
580
                                const shouldInclude = (parent === undefined && tag.parent === null) ||
2✔
581
                                        (parent !== undefined && tag.parent === parent);
582
                                if (shouldInclude) {
2!
583
                                        if (tag.children.length > 0) {
292!
584
                                                tag.children = this.#tagLoop(tag.children, tag.index);
32✔
585
                                        }
586
                                        result.push(tag);
292✔
587
                                }
588
                        }
589
                }
590

591
                return result;
212✔
592
        }
593

594
        clearTags(): this {
595
                this.tags = {};
4✔
596
                return this;
4✔
597
        }
598

599
        registerTag(tag: string, options: TagDefinition): this {
600
                const tagName = String(tag).toLowerCase();
42✔
601

602
                // Prevent prototype pollution
603
                const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
2✔
604
                if (dangerousKeys.includes(tagName)) {
42!
605
                        throw new Error(`Cannot register tag with dangerous name: ${tagName}`);
6✔
606
                }
607

608
                this.tags[tagName] = options;
36✔
609
                return this;
36✔
610
        }
611

612
        parse(bbcInput: string | number | boolean): string {
613
                if (
202✔
614
                        typeof(bbcInput) === 'boolean'
414✔
615
                        || (typeof(bbcInput) !== 'string' && Number.isNaN(Number(bbcInput)))
616
                ) { return ''; }
10✔
617
                let input = String(bbcInput);
194✔
618
                if (this.config.sanitizeHtml) {
2!
619
                        // Batch HTML entity replacement for better performance
620
                        const htmlEntities = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' };
192✔
621
                        input = input.replaceAll(/["&'<>]/g, (char: string) => htmlEntities[char as keyof typeof htmlEntities]);
192✔
622
                }
623

624
                // reset
625
                let tagsMap: TagItem[] = [];
194✔
626
                // split input into tags by index
627
                const tags = String(input).match(this.regex.tags);
194✔
628

629
                if (this.config.newline) {
194!
630
                        if (this.config.paragraph) {
194!
631
                                input = input.replace(this.regex.newline, '</p><p>');
2✔
632
                        } else {
633
                                input = input.replace(this.regex.newline, '<br/>');
190✔
634
                        }
635
                }
636
                if (this.config.paragraph) {
194!
637
                        input = '<p>' + input + '</p>';
192✔
638
                }
639

640
                // handle when no tags are present
641
                if (!tags || tags.length === 0) {
194!
642
                        return input;
14✔
643
                }
644
                // Build tag map and replace with placeholders
645
                for (const [i, tag] of tags.entries()) {
2✔
646
                        const tagContent = tag.slice(1, -1); // Remove [ ]
552✔
647
                        const isClosing = tagContent.startsWith('/');
552✔
648
                        const contentToParse = isClosing ? tagContent.slice(1) : tagContent;
552✔
649

650
                        // Parse module name and attributes
651
                        const spaceIndex = contentToParse.indexOf(' ');
552✔
652
                        const equalsIndex = contentToParse.indexOf('=');
552✔
653
                        let moduleName: string;
654
                        let parsedAttrs: { attr: string; attrs: TagAttributes; };
655

656
                        // Extract module name (before space or =)
657
                        if (spaceIndex === -1 && equalsIndex === -1) {
4!
658
                                // No attributes: [tag]
659
                                moduleName = contentToParse.toLowerCase();
458✔
660
                                parsedAttrs = { attr: '', attrs: {} };
458✔
661
                        } else if (spaceIndex === -1 || (equalsIndex !== -1 && equalsIndex < spaceIndex)) {
×
662
                                // Old format: [tag=value]
663
                                moduleName = (contentToParse.split('=')[0] ?? '').toLowerCase();
74!
664
                                parsedAttrs = parseTagAttributes(contentToParse);
74✔
665
                        } else {
666
                                // New format: [tag attr1 key=value]
667
                                moduleName = (contentToParse.split(/\s+/)[0] ?? '').toLowerCase();
20!
668
                                parsedAttrs = parseTagAttributes(contentToParse);
20✔
669
                        }
670

671
                        const item: TagItem = {
552✔
672
                                index: i,
673
                                module: moduleName,
674
                                isClosing,
675
                                raw: tag,
676
                                attr: parsedAttrs.attr,
677
                                attrs: parsedAttrs.attrs,
678
                                children: [],
679
                                parent: null,
680
                                matchTag: null,
681
                        };
682

683
                        tagsMap.push(item);
552✔
684
                }
685

686
                // Replace tags with placeholders - optimized for different input sizes
687
                if (tags.length <= 3) {
2!
688
                        // Fast path for few tags - direct replacement is faster
689
                        for (const [i, tag] of tags.entries()) {
180✔
690
                                input = input.replace(tag, '[TAG-' + i + ']');
312✔
691
                        }
692
                } else {
693
                        // Batch replacement for many tags
694
                        const tagPositions = [];
28✔
695
                        let lastIndex = 0;
28✔
696
                        for (const [i, tag] of tags.entries()) {
×
697
                                const pos = input.indexOf(tag, lastIndex);
240✔
698
                                if (pos !== -1) {
×
699
                                        tagPositions.push({ pos, tag: tag, placeholder: '[TAG-' + i + ']' });
240✔
700
                                        lastIndex = pos + tag.length;
240✔
701
                                }
702
                        }
703

704
                        if (tagPositions.length > 0) {
×
705
                                const parts = [];
28✔
706
                                let lastPos = 0;
28✔
707
                                for (const { pos, tag, placeholder } of tagPositions) {
×
708
                                        parts.push(input.slice(lastPos, pos));
240✔
709
                                        parts.push(placeholder);
240✔
710
                                        lastPos = pos + tag.length;
240✔
711
                                }
712
                                parts.push(input.slice(lastPos));
28✔
713
                                input = parts.join('');
28✔
714
                        }
715
                }
716
                // loop through each tag to create nested elements
717
                tagsMap = this.#tagLoop(tagsMap);
180✔
718
                // put back all non-found matches?
719
                input = this.#contentLoop(tagsMap, input);
180✔
720
                if (this.config.cleanUnmatchable) {
180!
721
                        input = input.replace(this.regex.placeholders, '');
174✔
722
                }
723
                return input;
176✔
724
        }
725
}
726

727
// Export types for users
728
export type { Config, TagAttributes, ReplaceTag, ContentTag, IgnoreTag, TagDefinition };
729

730
// Default export for both CJS and ESM
731
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