Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 5.1.0

- Added `autoClose` property for tags that implicitly close (fixes [#214](https://github.com/nodecraft/ya-bbcode/issues/214))
- List items (`[*]`) now automatically generate closing `</li>` tags
- Added `[hr]` tag for horizontal rules (Steam BBCode compatibility)

## 5.0.0

### Major Changes
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ya-bbcode",
"version": "5.0.0",
"version": "5.1.0",
"description": "Yet another BBCode Parser",
"keywords": [
"bbc",
Expand Down
87 changes: 74 additions & 13 deletions src/ya-bbcode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ interface ReplaceTag {
type: 'replace';
open: ((attr: string, attrs: TagAttributes) => string) | string;
close: ((attr: string, attrs: TagAttributes) => string) | string | null;
/**
* HTML to insert when the tag implicitly closes (e.g., before next sibling or parent close).
* Useful for tags like [*] that don't have explicit closing tags in BBCode.
*/
autoClose?: ((attr: string, attrs: TagAttributes) => string) | string;
}

interface ContentTag {
Expand Down Expand Up @@ -215,6 +220,11 @@ class yabbcode {
open: '<code>',
close: '</code>',
},
'hr': {
type: 'replace',
open: '<hr/>',
close: null,
},
'strike': {
type: 'replace',
open: '<span class="yabbcode-strike">',
Expand All @@ -238,7 +248,8 @@ class yabbcode {
'*': {
type: 'replace',
open: '<li>',
close: null,
close: '</li>',
autoClose: '</li>',
},
'img': {
type: 'content',
Expand Down Expand Up @@ -355,23 +366,29 @@ class yabbcode {
return content;
}

#contentLoop(tagsMap: TagItem[], content: string): string {
#contentLoop(tagsMap: TagItem[], content: string, parentClosingTagIndex?: number): string {
// Adaptive threshold: use optimized path for large documents (>50 tags)
// Only optimize when all tags are replace-type (most common case for large docs)
// Only optimize when all tags are replace-type and none have autoClose
if (tagsMap.length > 50) {
// Check if we have any content or ignore type tags
// Check if we have any content, ignore type, or autoClose tags
const hasSpecialTypes = tagsMap.some((tag) => {
const module = this.tags[tag.module];
return module && (module.type === 'content' || module.type === 'ignore');
if (!module) { return false; }
if (module.type === 'content' || module.type === 'ignore') { return true; }
if (module.type === 'replace' && (module as ReplaceTag).autoClose) { return true; }
return false;
});

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

for (const tag of tagsMap) {
for (let i = 0; i < tagsMap.length; i++) {
const tag = tagsMap[i];
if (!tag) { continue; }

let module = this.tags[tag.module];
if (!module) {
// ignore invalid BBCode
Expand All @@ -384,15 +401,59 @@ class yabbcode {
if (!this.contentModules[module.type]) {
throw new Error('Cannot parse content block. Invalid block type [' + module.type + '] provided for tag [' + tag.module + ']');
}

if (module.type === 'replace') {
content = this.contentModules.replace(tag, module, content);
const replaceModule = module as ReplaceTag;

// Step 1: Process opening tag
let open = replaceModule.open;
if (typeof(open) === 'function') {
open = open(tag.attr, tag.attrs);
}
if (open && !tag.isClosing) {
content = content.replace('[TAG-' + tag.index + ']', open);
}

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

// Step 3: Handle autoClose for tags without explicit closing
if (replaceModule.autoClose && !tag.closing) {
let autoClose = replaceModule.autoClose;
if (typeof(autoClose) === 'function') {
autoClose = autoClose(tag.attr, tag.attrs);
}

// Find insertion point: before next sibling or parent close
let insertBefore: string | undefined;
const nextTag = tagsMap[i + 1];
if (nextTag) {
insertBefore = '[TAG-' + nextTag.index + ']';
} else if (parentClosingTagIndex !== undefined) {
insertBefore = '[TAG-' + parentClosingTagIndex + ']';
}

if (insertBefore && autoClose) {
content = content.replace(insertBefore, autoClose + insertBefore);
}
}

// Step 4: Process closing tag (after children)
let close = replaceModule.close;
if (typeof(close) === 'function') {
close = close(tag.attr, tag.attrs);
}
if (close && tag.closing) {
content = content.replace('[TAG-' + tag.closing.index + ']', close);
}

} else if (module.type === 'content') {
content = this.contentModules.content(tag, module, content);
content = this.contentModules.content(tag, module as ContentTag, content);
} else if (module.type === 'ignore') {
content = this.contentModules.ignore(tag, module, content);
}
if (tag.children.length > 0 && module.type !== 'ignore') {
content = this.#contentLoop(tag.children, content);
content = this.contentModules.ignore(tag, module as IgnoreTag, content);
}
}

Expand Down
69 changes: 67 additions & 2 deletions tests/ya-bbcode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const bbcodes = {
h5: '[h5]Game Servers Done Right![/h5]',
h6: '[h6]Game Servers Done Right![/h6]',
code: '[code]new yabbcode();[/code]',
hr: '[hr][/hr]',
hr_standalone: '[hr]',
strike: '[strike]getnodecraft.net[/strike]',
spoiler: '[spoiler]The cake is a lie[/spoiler]',
list: '[list][*] Minecraft Servers[*] ARK Servers[*] PixARK Servers[*] Rust Servers[/list]',
Expand Down Expand Up @@ -122,6 +124,15 @@ describe('ya-bbcode', () => {
const parser = new yabbc();
expect(parser.parse(bbcodes.code)).toBe('<code>new yabbcode();</code>');
});
it('Tag: hr', () => {
const parser = new yabbc();
// Steam-style [hr][/hr]
expect(parser.parse(bbcodes.hr)).toBe('<hr/>');
// Standalone [hr] without closing tag
expect(parser.parse(bbcodes.hr_standalone)).toBe('<hr/>');
// With surrounding content
expect(parser.parse('Above[hr][/hr]Below')).toBe('Above<hr/>Below');
});
it('Tag: strike', () => {
const parser = new yabbc();
expect(parser.parse(bbcodes.strike)).toBe('<span class="yabbcode-strike">getnodecraft.net</span>');
Expand All @@ -132,11 +143,11 @@ describe('ya-bbcode', () => {
});
it('Tag: list', () => {
const parser = new yabbc();
expect(parser.parse(bbcodes.list)).toBe('<ul><li> Minecraft Servers<li> ARK Servers<li> PixARK Servers<li> Rust Servers</ul>');
expect(parser.parse(bbcodes.list)).toBe('<ul><li> Minecraft Servers</li><li> ARK Servers</li><li> PixARK Servers</li><li> Rust Servers</li></ul>');
});
it('Tag: olist', () => {
const parser = new yabbc();
expect(parser.parse(bbcodes.olist)).toBe('<ol><li> Pick your games<li> Create your bot<li> Get ingame!</ol>');
expect(parser.parse(bbcodes.olist)).toBe('<ol><li> Pick your games</li><li> Create your bot</li><li> Get ingame!</li></ol>');
});
it('Tag: img', () => {
const parser = new yabbc();
Expand Down Expand Up @@ -347,6 +358,60 @@ describe('ya-bbcode', () => {
expect(parser.parse('[quote=John]Hello[/quote]')).toBe('<blockquote author="John">Hello</blockquote>');
expect(parser.parse('[url=https://example.com]Link[/url]')).toBe('<a href="https://example.com">Link</a>');
});
it('Custom tag with autoClose', () => {
const parser = new yabbc();
// Register a custom tag with autoClose for implicit closing
parser.registerTag('item', {
type: 'replace',
open: '<div class="item">',
close: null,
autoClose: '</div>',
});
parser.registerTag('container', {
type: 'replace',
open: '<div class="container">',
close: '</div>',
});
// autoClose should insert </div> before next sibling and before parent close
expect(parser.parse('[container][item]one[item]two[/container]')).toBe('<div class="container"><div class="item">one</div><div class="item">two</div></div>');
});
it('List items without explicit closing tags (Steam BBCode format)', () => {
const parser = new yabbc();
// This tests the fix for issue #214 - auto-closing list items
const input = `[list]
[*]First item
[*]Second item
[*]Third item
[/list]`;
const result = parser.parse(input);
// Each list item should have a closing </li> tag
expect(result).toContain('<li>');
expect(result).toContain('</li>');
// Count opening and closing tags - should be equal
const openCount = (result.match(/<li>/g) || []).length;
const closeCount = (result.match(/<\/li>/g) || []).length;
expect(openCount).toBe(3);
expect(closeCount).toBe(3);
});

it('list items with explicit closing tags', () => {
const parser = new yabbc();
// This tests that explicit closing tags still work as expected
const input = `[list]
[*]First item[/*]
[*]Second item[/*]
[*]Third item[/*]
[/list]`;
const result = parser.parse(input);
// Each list item should have a closing </li> tag
expect(result).toContain('<li>');
expect(result).toContain('</li>');
// Count opening and closing tags - should be equal
const openCount = (result.match(/<li>/g) || []).length;
const closeCount = (result.match(/<\/li>/g) || []).length;
expect(openCount).toBe(3);
expect(closeCount).toBe(3);
});
});

describe('Security Tests', () => {
Expand Down
28 changes: 28 additions & 0 deletions tests/ya-bbcode.types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import yabbcode, {
type ContentTag,
type IgnoreTag,
type ReplaceTag,
type TagAttributes,
type TagDefinition,
} from '../src/ya-bbcode';

Expand Down Expand Up @@ -54,6 +55,33 @@ describe('Type exports', () => {
close: null,
};
expectTypeOf(nullCloseTag).toEqualTypeOf<ReplaceTag>();

// With autoClose (for implicit closing like list items)
const autoCloseTag: ReplaceTag = {
type: 'replace',
open: '<li>',
close: null,
autoClose: '</li>',
};
expectTypeOf(autoCloseTag).toEqualTypeOf<ReplaceTag>();

// With autoClose as function
const autoCloseFuncTag: ReplaceTag = {
type: 'replace',
open: '<li>',
close: null,
autoClose: () => '</li>',
};
expectTypeOf(autoCloseFuncTag).toEqualTypeOf<ReplaceTag>();

// With autoClose function using attrs parameter
const autoCloseWithAttrsTag: ReplaceTag = {
type: 'replace',
open: (_attr: string, attrs: TagAttributes) => `<li class="${attrs.class || ''}">`,
close: null,
autoClose: (_attr: string, _attrs: TagAttributes) => '</li>',
};
expectTypeOf(autoCloseWithAttrsTag).toEqualTypeOf<ReplaceTag>();
});

it('should export ContentTag type correctly', () => {
Expand Down