From 493863111ea5c207bdcaded0fd7e950cc50d5ce4 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 1 Dec 2025 14:55:46 +0000 Subject: [PATCH 1/2] feat: add support for auto-closing tags like [*] feat: also add support for `hr` tag --- src/ya-bbcode.ts | 87 +++++++++++++++++++++++++++++------ tests/ya-bbcode.test.ts | 69 ++++++++++++++++++++++++++- tests/ya-bbcode.types.test.ts | 28 +++++++++++ 3 files changed, 169 insertions(+), 15 deletions(-) diff --git a/src/ya-bbcode.ts b/src/ya-bbcode.ts index 34bdd7d..2b28431 100644 --- a/src/ya-bbcode.ts +++ b/src/ya-bbcode.ts @@ -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 { @@ -215,6 +220,11 @@ class yabbcode { open: '', close: '', }, + 'hr': { + type: 'replace', + open: '
', + close: null, + }, 'strike': { type: 'replace', open: '', @@ -238,7 +248,8 @@ class yabbcode { '*': { type: 'replace', open: '
  • ', - close: null, + close: '
  • ', + autoClose: '', }, 'img': { type: 'content', @@ -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 @@ -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); } } diff --git a/tests/ya-bbcode.test.ts b/tests/ya-bbcode.test.ts index 202ec7d..6d09057 100644 --- a/tests/ya-bbcode.test.ts +++ b/tests/ya-bbcode.test.ts @@ -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]', @@ -122,6 +124,15 @@ describe('ya-bbcode', () => { const parser = new yabbc(); expect(parser.parse(bbcodes.code)).toBe('new yabbcode();'); }); + it('Tag: hr', () => { + const parser = new yabbc(); + // Steam-style [hr][/hr] + expect(parser.parse(bbcodes.hr)).toBe('
    '); + // Standalone [hr] without closing tag + expect(parser.parse(bbcodes.hr_standalone)).toBe('
    '); + // With surrounding content + expect(parser.parse('Above[hr][/hr]Below')).toBe('Above
    Below'); + }); it('Tag: strike', () => { const parser = new yabbc(); expect(parser.parse(bbcodes.strike)).toBe('getnodecraft.net'); @@ -132,11 +143,11 @@ describe('ya-bbcode', () => { }); it('Tag: list', () => { const parser = new yabbc(); - expect(parser.parse(bbcodes.list)).toBe(''); + expect(parser.parse(bbcodes.list)).toBe(''); }); it('Tag: olist', () => { const parser = new yabbc(); - expect(parser.parse(bbcodes.olist)).toBe('
    1. Pick your games
    2. Create your bot
    3. Get ingame!
    '); + expect(parser.parse(bbcodes.olist)).toBe('
    1. Pick your games
    2. Create your bot
    3. Get ingame!
    '); }); it('Tag: img', () => { const parser = new yabbc(); @@ -347,6 +358,60 @@ describe('ya-bbcode', () => { expect(parser.parse('[quote=John]Hello[/quote]')).toBe('
    Hello
    '); expect(parser.parse('[url=https://example.com]Link[/url]')).toBe('Link'); }); + it('Custom tag with autoClose', () => { + const parser = new yabbc(); + // Register a custom tag with autoClose for implicit closing + parser.registerTag('item', { + type: 'replace', + open: '
    ', + close: null, + autoClose: '
    ', + }); + parser.registerTag('container', { + type: 'replace', + open: '
    ', + close: '
    ', + }); + // autoClose should insert before next sibling and before parent close + expect(parser.parse('[container][item]one[item]two[/container]')).toBe('
    one
    two
    '); + }); + 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 tag + expect(result).toContain('
  • '); + expect(result).toContain('
  • '); + // Count opening and closing tags - should be equal + const openCount = (result.match(/
  • /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
  • tag + expect(result).toContain('
  • '); + expect(result).toContain('
  • '); + // Count opening and closing tags - should be equal + const openCount = (result.match(/
  • /g) || []).length; + const closeCount = (result.match(/<\/li>/g) || []).length; + expect(openCount).toBe(3); + expect(closeCount).toBe(3); + }); }); describe('Security Tests', () => { diff --git a/tests/ya-bbcode.types.test.ts b/tests/ya-bbcode.types.test.ts index 3ca67b7..29cd8a7 100644 --- a/tests/ya-bbcode.types.test.ts +++ b/tests/ya-bbcode.types.test.ts @@ -5,6 +5,7 @@ import yabbcode, { type ContentTag, type IgnoreTag, type ReplaceTag, + type TagAttributes, type TagDefinition, } from '../src/ya-bbcode'; @@ -54,6 +55,33 @@ describe('Type exports', () => { close: null, }; expectTypeOf(nullCloseTag).toEqualTypeOf(); + + // With autoClose (for implicit closing like list items) + const autoCloseTag: ReplaceTag = { + type: 'replace', + open: '
  • ', + close: null, + autoClose: '
  • ', + }; + expectTypeOf(autoCloseTag).toEqualTypeOf(); + + // With autoClose as function + const autoCloseFuncTag: ReplaceTag = { + type: 'replace', + open: '
  • ', + close: null, + autoClose: () => '
  • ', + }; + expectTypeOf(autoCloseFuncTag).toEqualTypeOf(); + + // With autoClose function using attrs parameter + const autoCloseWithAttrsTag: ReplaceTag = { + type: 'replace', + open: (_attr: string, attrs: TagAttributes) => `
  • `, + close: null, + autoClose: (_attr: string, _attrs: TagAttributes) => '
  • ', + }; + expectTypeOf(autoCloseWithAttrsTag).toEqualTypeOf(); }); it('should export ContentTag type correctly', () => { From 52642dd53955fc67b995174acd9bb0ddcca9b47c Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 1 Dec 2025 14:57:05 +0000 Subject: [PATCH 2/2] chore: bump to 5.1.0 --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56094c1..96d27a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` tags +- Added `[hr]` tag for horizontal rules (Steam BBCode compatibility) + ## 5.0.0 ### Major Changes diff --git a/package-lock.json b/package-lock.json index 65ac7e2..65ac841 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ya-bbcode", - "version": "5.0.0", + "version": "5.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ya-bbcode", - "version": "5.0.0", + "version": "5.1.0", "license": "MIT", "devDependencies": { "@bbob/html": "4.3.1", diff --git a/package.json b/package.json index b99dcd7..aeea9c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ya-bbcode", - "version": "5.0.0", + "version": "5.1.0", "description": "Yet another BBCode Parser", "keywords": [ "bbc",