Skip to content

Commit e8bcdbf

Browse files
committed
refactor: typescript
perf: major improvements
1 parent e725798 commit e8bcdbf

25 files changed

+3346
-3882
lines changed

.eslintignore

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1-
node_modules/**
2-
build/**
3-
coverage/**
1+
# Build output
2+
dist/
3+
coverage/
4+
node_modules/
5+
6+
# Generated files
7+
*.d.ts
8+
*.tsbuildinfo

.github/workflows/benchmark.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: Benchmark
2+
on:
3+
push:
4+
branches:
5+
- main
6+
pull_request:
7+
workflow_dispatch:
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
name: Benchmark
13+
steps:
14+
- uses: actions/checkout@v6
15+
- name: Setup node
16+
uses: actions/setup-node@v5
17+
with:
18+
node-version: 24
19+
cache: "npm"
20+
- run: npm ci
21+
- run: npm run build
22+
- run: npm run bench

.github/workflows/codeql-analysis.yml

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
push:
55
pull_request:
66
schedule:
7-
- cron: '0 5 * * 3'
7+
- cron: "0 5 * * 3"
88

99
jobs:
1010
CodeQL-Build:
@@ -17,26 +17,9 @@ jobs:
1717

1818
# Initializes the CodeQL tools for scanning.
1919
- name: Initialize CodeQL
20-
uses: github/codeql-action/init@v3
20+
uses: github/codeql-action/init@v4
2121
# Override language selection by uncommenting this and choosing your languages
2222
with:
23-
languages: javascript
24-
25-
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
26-
# If this step fails, then you should remove it and run the build manually (see below).
27-
- name: Autobuild
28-
uses: github/codeql-action/autobuild@v3
29-
30-
# ℹ️ Command-line programs to run using the OS shell.
31-
# 📚 https://git.io/JvXDl
32-
33-
# ✏️ If the Autobuild fails above, remove it and uncomment the following
34-
# three lines and modify them (or add more) to build your code if your
35-
# project uses a compiled language
36-
37-
#- run: |
38-
# make bootstrap
39-
# make release
40-
23+
languages: javascript-typescript
4124
- name: Perform CodeQL Analysis
42-
uses: github/codeql-action/analyze@v3
25+
uses: github/codeql-action/analyze@v4

.github/workflows/test.yml

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,18 @@ jobs:
66
runs-on: ubuntu-latest
77
strategy:
88
matrix:
9-
node: [18, 20]
9+
node: [22, 24]
1010
name: Node ${{ matrix.node }} Test
1111
steps:
12-
- uses: actions/checkout@v4
12+
- uses: actions/checkout@v6
1313
- name: Setup node
14-
uses: actions/setup-node@v4
14+
uses: actions/setup-node@v5
1515
with:
1616
node-version: ${{ matrix.node }}
17-
- name: Restore NPM cache
18-
uses: actions/cache@v4
19-
continue-on-error: true
20-
with:
21-
path: ~/.npm
22-
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
23-
restore-keys: |
24-
${{ runner.os }}-node-
17+
cache: "npm"
2518
- run: npm ci
26-
- run: npm run lint
19+
- run: npm run build
20+
- run: npm run test
2721
- run: npm run coverage
2822
- name: Coveralls
2923
uses: coverallsapp/github-action@v2

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,7 @@ typings/
5757
# dotenv environment variables file
5858
.env
5959

60+
# Build output
61+
dist/
62+
*.tsbuildinfo
63+

.husky/commit-msg

Lines changed: 0 additions & 1 deletion
This file was deleted.

.husky/pre-commit

Lines changed: 0 additions & 1 deletion
This file was deleted.

CHANGELOG.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,53 @@
11
# Changelog
22

3+
## 5.0.0
4+
5+
### Major Changes
6+
7+
- **Space-separated Tag Attributes**: Support for multiple attributes in BBCode tags
8+
- Simple attribute syntax: `[tag=value]` (existing format, fully supported)
9+
- Space-separated attributes: `[code lang=javascript highlight]`
10+
- Boolean flags: `[code skip-lint]`
11+
- Key-value pairs: `[code lang=javascript]`
12+
- Mixed attributes: `[code lang=javascript skip-lint highlight]`
13+
14+
### Security Improvements
15+
16+
- **URL Scheme Allowlist**: New `allowedSchemes` config option to customize which URL schemes are allowed
17+
- Default: `['http', 'https', 'mailto', 'ftp', 'ftps']`
18+
- Dangerous schemes like `javascript:`, `data:`, and `vbscript:` are blocked by default
19+
- Set to `[]` to block all absolute URLs while allowing relative URLs
20+
- Example: `new yabbcode({ allowedSchemes: ['https', 'mailto'] })` to allow only HTTPS and mailto links
21+
- **XSS Protection**: HTML attribute values are properly escaped to prevent attribute injection attacks
22+
- **Prototype Pollution Protection**: Dangerous attribute names (`__proto__`, `constructor`, `prototype`) are filtered out
23+
24+
### Performance Improvements
25+
26+
- **Stack-based Tag Matching**: Replaced O(n²) algorithm with O(n) stack-based approach
27+
- Dramatically faster parsing for deeply nested structures
28+
- More memory efficient for large documents
29+
30+
- **Adaptive Content Processing**: Smart threshold-based optimization
31+
- Documents with >50 tags automatically use optimized single-pass replacement
32+
- Batch processing for complex documents
33+
34+
- **Optimized HTML Sanitization**: Batch HTML entity replacement using regex for better performance
35+
36+
### Breaking Changes
37+
38+
- For most use-cases, there should be no noticeable differences in functionality, just with improved performance and security. However:
39+
- Due to the TypeScript migration and build system changes, there are some minor changes to types to be more accurate and strict
40+
- Tag attribute and content callbacks now receive an additional `attrs` parameter: `(attr: string, attrs: TagAttributes) => string`
41+
- Dangerous URL schemes are now blocked by default in `[url]` tags (previously would pass through)
42+
43+
### Bug Fixes
44+
45+
- Fixed proper type discrimination for tag definitions
46+
- Improved handling of optional properties
47+
348
## 4.0.0
449

550
- By default, any HTML input is now escaped to prevent possible security issues from untrusted input. If you need to disable this for any reason (for the behaviour of previous versions), construct `ya-bbcode` like this:
651
```javascript
752
const parser = new yabbcode({sanitizeHtml: false});
8-
```
53+
```

README.md

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,8 @@ parser.registerTag('url', {
3232
parser.clearTags();
3333
```
3434

35-
### Why another BBCode Parser?
36-
- Supports nested BBCode
37-
- Has no dependencies
38-
- All BBCode is replaced in a nested format, meaning that parent nodes are parsed before children.
39-
- Allows custom tags to be replaced or added.
40-
41-
#### Roadmap
42-
- Performance improvements
43-
- Clean code up for improved readability
44-
- Improve docs
35+
### Why ya-bbcode?
36+
- **Supports nested BBCode**: Properly handles complex nested structures
37+
- **Zero dependencies**: Lightweight with no external dependencies
38+
- **Correct parsing**: Parent nodes are parsed before children in nested format
39+
- **Customizable**: Easy to add or override tags

benchmark/run.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { parse } from '@bbob/parser';
2+
import { Bench } from 'tinybench';
3+
import xbbcode from 'xbbcode-parser';
4+
5+
import stub from './stub.ts';
6+
import { testCases } from './test-cases.ts';
7+
import yabbcode from '../dist/ya-bbcode.mjs';
8+
9+
function formatNumber(num: number): string {
10+
return num.toLocaleString('en-US', { maximumFractionDigits: 0 });
11+
}
12+
13+
interface BenchResult {
14+
name: string;
15+
opsPerSec: number;
16+
mean: number;
17+
margin: number;
18+
}
19+
20+
async function runBenchmark(name: string, input: string, chTagOnly = false): Promise<BenchResult[]> {
21+
console.log(`\n━━━ ${name} ━━━`);
22+
console.log(`Input length: ${input.length.toLocaleString()} characters\n`);
23+
24+
const bench = new Bench({ time: 1000 });
25+
26+
// ya-bbcode setup
27+
if (chTagOnly) {
28+
// For stub benchmark - only parse [ch] tag like BBob benchmark
29+
// https://github.com/JiLiZART/BBob/blob/5904ef46ed3d1c9b1827f89c5282848945d98338/benchmark/index.js
30+
bench.add('ya-bbcode', () => {
31+
const yaParser = new yabbcode();
32+
yaParser.registerTag('ch', {
33+
type: 'replace',
34+
open: '<div>',
35+
close: '</div>',
36+
});
37+
yaParser.parse(input);
38+
});
39+
} else {
40+
// For other benchmarks - use default tags
41+
bench.add('ya-bbcode', () => {
42+
const yaParser = new yabbcode();
43+
yaParser.parse(input);
44+
});
45+
}
46+
47+
if (chTagOnly) {
48+
// xbbcode-parser with only [ch] tag
49+
bench.add('xbbcode-parser', () => {
50+
xbbcode.addTags({
51+
ch: {
52+
openTag: () => '<div>',
53+
closeTag: () => '</div>',
54+
restrictChildrenTo: [],
55+
},
56+
});
57+
return xbbcode.process({
58+
text: input,
59+
removeMisalignedTags: false,
60+
addInLineBreaks: false,
61+
});
62+
});
63+
64+
// @bbob/parser with only [ch] tag
65+
bench.add('@bbob/parser', () => {
66+
parse(input, {
67+
onlyAllowTags: ['ch'],
68+
});
69+
});
70+
} else {
71+
// Default parsers
72+
bench.add('xbbcode-parser', () => {
73+
xbbcode.process({
74+
text: input,
75+
removeMisalignedTags: false,
76+
addInLineBreaks: false,
77+
});
78+
});
79+
80+
bench.add('@bbob/parser', () => {
81+
parse(input);
82+
});
83+
}
84+
85+
await bench.run();
86+
87+
// Sort results by ops/sec (descending)
88+
const results: BenchResult[] = bench.tasks
89+
.map((task) => {
90+
return {
91+
name: task.name,
92+
opsPerSec: task.result?.hz || 0,
93+
mean: task.result?.mean || 0,
94+
margin: task.result?.rme || 0,
95+
};
96+
})
97+
.sort((resultA, resultB) => resultB.opsPerSec - resultA.opsPerSec);
98+
99+
// Display results
100+
const maxNameLength = Math.max(...results.map(result => result.name.length));
101+
102+
for (const [index, result] of results.entries()) {
103+
const indicator = index === 0 ? '🏆' : (index === results.length - 1 ? '🐌' : '📊');
104+
const name = result.name.padEnd(maxNameLength);
105+
const ops = formatNumber(result.opsPerSec).padStart(10);
106+
const fastestOps = results[0]?.opsPerSec ?? 1;
107+
const relative = index === 0
108+
? '(fastest)'
109+
: `(${(fastestOps / result.opsPerSec).toFixed(2)}x slower)`;
110+
111+
console.log(`${indicator} ${name} ${ops} ops/sec ±${result.margin.toFixed(2)}% ${relative}`);
112+
}
113+
114+
return results;
115+
}
116+
117+
async function main() {
118+
console.log('\n╔═══════════════════════════════════════════════════════════════╗');
119+
console.log('║ BBCode Parser Performance Comparison ║');
120+
console.log('╚═══════════════════════════════════════════════════════════════╝');
121+
122+
const allResults: Record<string, BenchResult[]> = {};
123+
124+
// Run all benchmarks
125+
allResults['Short (Simple Bold)'] = await runBenchmark('Short (Simple Bold)', testCases.short);
126+
allResults['Medium (Nested Quote)'] = await runBenchmark('Medium (Nested Quote)', testCases.medium);
127+
allResults['Long (Forum Post)'] = await runBenchmark('Long (Forum Post)', testCases.long);
128+
allResults['Complex (Deep Nesting)'] = await runBenchmark('Complex (Deep Nesting)', testCases.complex);
129+
allResults['Deep Nesting (5 levels)'] = await runBenchmark('Deep Nesting (5 levels)', testCases.deepNesting);
130+
allResults['URL Heavy (10 links)'] = await runBenchmark('URL Heavy (10 links)', testCases.urlHeavy);
131+
allResults['List Heavy (50 items)'] = await runBenchmark('List Heavy (50 items)', testCases.listHeavy);
132+
allResults['Large Document (BBob stub)'] = await runBenchmark('Large Document (BBob stub)', stub, true);
133+
134+
// Summary
135+
console.log('\n╔═══════════════════════════════════════════════════════════════╗');
136+
console.log('║ Summary ║');
137+
console.log('╚═══════════════════════════════════════════════════════════════╝\n');
138+
139+
const wins: Record<string, number> = { 'ya-bbcode': 0, 'xbbcode-parser': 0, '@bbob/parser': 0 };
140+
for (const results of Object.values(allResults)) {
141+
const winner = results[0];
142+
if (winner) {
143+
const currentWins = wins[winner.name] ?? 0;
144+
wins[winner.name] = currentWins + 1;
145+
}
146+
}
147+
148+
console.log('Wins (fastest in category):');
149+
for (const [parser, count] of Object.entries(wins)
150+
.sort((entryA, entryB) => entryB[1] - entryA[1])) {
151+
const percentage = ((count / Object.keys(allResults).length) * 100).toFixed(0);
152+
console.log(` ${parser}: ${count}/${Object.keys(allResults).length} (${percentage}%)`);
153+
}
154+
155+
console.log('\n✓ Benchmark complete!\n');
156+
}
157+
158+
main().catch(console.error);

0 commit comments

Comments
 (0)