Skip to content

Commit 8195076

Browse files
committed
2.0.0 - see changelog in extended commit message
## Performance Impact For full transparency, this update does have roughly a roughly 15% hit to performance on paper. In practice, this is likely to be completely negligible (especially given more real-world workloads than Stephen Li [Trinovantes]'s built-in benchmarking), but it is worth noting for full transparency. ## Breaking Changes * Adjust handling of component children, including fixing a bug where `skipChildren` was not being honored (meaning excess renders were being performed) * Adjust handling of render failing... * fixing a bug where components which would not be rendered would simply not appear (rather than displaying their raw text) * requiring that all render failures now either call `doNotRenderBBCodeComponent()` or throw `DoNotRenderBBCodeError` (see below) * Rename `Transform.component` to `Transform.Component` to better satisfy React naming conventions and linters which rely on them ### `doNotRenderBBCodeComponent()` & `DoNotRenderBBCodeError` These new exports are for use when a component should not be rendered (such as when an unsafe URL is detected). When encountered by the compiler, the component will not be rendered. The `doNotRenderBBCodeComponent()` function returns the TypeScript type `never` which TS understands to be the same as throwing an error or returning. You therefore are not strictly required to use `return` after calling `doNotRenderBBCodeComponent()` (though it helps with readability in IDEs with syntax highlighting). The `DoNotRenderBBCodeError` is a class which extends `Error` and is thrown when a component should not be rendered. While I can't think of much of a use case for this, it is provided for completeness under `bbcode-compiler-react/advanced`. ## Improvements * Add optional `doDangerCheck` parameter to `parseMaybeRelativeUrl()` (defaults to true to mirror 1.0.0 behavior) * Component keys are now the nodes stringified normally (rather than plain iterator indices) to make React happier * Errors thrown during tag rendering will now append to the stack the tag that caused the error and a TagNode object for debugging passed passed through Json.stringify() * This behavior will only apply if the symbol `BBCodeOriginalStackTrace` is not set to the old stack trace on the error object. This is exported from `bbcode-compiler-react/advanced` if you wish to use it in your own error handling. * Transform's Component method will now show the name `BBCode_${tagName}` (e.g. `BBCode_b` for [b]) if no custom function name is provided ## Bug Fixes * In rare cases, the `children` parameter could be a two-dimensional array. This array is now flattened. * Important note - you should NOT be relying on the `children` parameter in your components; you should prefer to gather data from the `tagNode` property. ## TypeScript Types * For transforms which have `skipChildren`, the `children` parameter will now always be `undefined` * Added JSDoc to: * `parseMaybeRelativeUrl()` * `isDangerousUrl()` * Revised JSDoc for: * `Transform.Component`
1 parent 9f8f193 commit 8195076

19 files changed

+358
-147
lines changed

.eslintrc.cjs

Lines changed: 4 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ module.exports = {
9797
ignoreParameters: false,
9898
ignoreProperties: true,
9999
}],
100-
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
100+
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
101101
'@typescript-eslint/consistent-type-assertions': ['error', {
102102
assertionStyle: 'as',
103103
objectLiteralTypeAssertions: 'never',
@@ -115,48 +115,11 @@ module.exports = {
115115
requireLast: false,
116116
},
117117
}],
118-
'@typescript-eslint/strict-boolean-expressions': ['error', {
119-
allowNullableBoolean: true,
120-
allowNullableString: true,
121-
}],
122-
'@typescript-eslint/naming-convention': ['error',
123-
{
124-
selector: 'default',
125-
format: null,
126-
modifiers: ['requiresQuotes'],
127-
},
128-
{
129-
selector: 'typeLike',
130-
format: ['PascalCase'],
131-
},
132-
{
133-
selector: 'parameter',
134-
format: ['strictCamelCase', 'UPPER_CASE'],
135-
leadingUnderscore: 'allowSingleOrDouble',
136-
trailingUnderscore: 'allowDouble',
137-
},
138-
{
139-
selector: 'memberLike',
140-
modifiers: ['private'],
141-
format: ['strictCamelCase'],
142-
leadingUnderscore: 'require',
143-
},
144-
{
145-
selector: [
146-
'variableLike',
147-
'method',
148-
],
149-
filter: {
150-
regex: '^update:',
151-
match: false,
152-
},
153-
format: ['strictCamelCase', 'UPPER_CASE'],
154-
leadingUnderscore: 'allowDouble',
155-
trailingUnderscore: 'allowDouble',
156-
},
157-
],
118+
'@typescript-eslint/strict-boolean-expressions': 'off',
119+
'@typescript-eslint/naming-convention': 'off', // I hate you sometimes, ESLint. I really do.
158120
'@typescript-eslint/no-unused-vars': ['error', {
159121
argsIgnorePattern: '^_',
160122
}],
123+
'@typescript-eslint/no-unnecessary-type-arguments': 'off', // If I want to be explicit, I'm gonna be explicit!
161124
},
162125
}

README.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# BBCode Compiler - React
22

3-
A fast BBCode parser and React generator with TypeScript support. Forked from [Trinovantes/bbcode-compiler](https://github.com/Trinovantes/bbcode-compiler).
3+
Parses BBCode and generates React components with strong TypeScript support. Forked from [Trinovantes/bbcode-compiler](https://github.com/Trinovantes/bbcode-compiler).
44

55
**Note:** This package is a [Pure ESM package](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c).
66

@@ -13,10 +13,18 @@ import { generateReact } from 'bbcode-compiler-react'
1313
const react = generateReact('[b]Hello World[/b]')
1414
```
1515

16+
<!--
17+
TODO: Touch on API capabilities a bit more, such as:
18+
* Built-in utils
19+
* DoNotRenderBBCodeError
20+
* Rendering performance & why you should use this anyway (~5x slower than bbcode-compiler)
21+
* Graceful error handling
22+
-->
23+
1624
## Extending With Custom Tags
1725

1826
```tsx
19-
import { generateReact, defaultTransforms, getWidthHeightAttr } from 'bbcode-compiler-react'
27+
import { generateReact, defaultTransforms, getWidthHeightAttr, doNotRenderBBCodeComponent } from 'bbcode-compiler-react'
2028

2129
const customTransforms: typeof defaultTransforms = [
2230
// Default tags included with this package
@@ -25,28 +33,28 @@ const customTransforms: typeof defaultTransforms = [
2533
// You can override a default tag by including it after the original in the transforms array
2634
{
2735
name: 'b',
28-
component({ tagNode, children }) {
36+
Component({ tagNode, children }) {
2937
return <b>
3038
{children}
3139
</b>
3240
}
3341
},
3442

3543
// Create new tag
36-
// You should read the TypeScript interface for TagNode in src/parser/AstNode.ts
44+
// If you're writing an advanced tag, you may want to read the TypeScript interface for TagNode in src/parser/AstNode.ts
3745
// You can also use the included helper functions like getTagImmediateText and getWidthHeightAttr
3846
{
3947
name: 'youtube',
4048
skipChildren: true, // Do not actually render the "https://www.youtube.com/watch?v=dQw4w9WgXcQ" text
41-
component({ tagNode, children }) {
49+
Component({ tagNode, children }) { // Because we're in a `skipChildren` tag, TypeScript knows that `children` will always be `undefined`
4250
const src = tagNode.getTagImmediateText()
4351
if (!src) {
44-
return false
52+
return doNotRenderBBCodeComponent() // This method returns the type `never` which is as good as returning or throwing for TypeScript
4553
}
4654

4755
const matches = /youtube.com\/watch\?v=(\w+)/.exec(src)
4856
if (!matches) {
49-
return false
57+
return doNotRenderBBCodeComponent()
5058
}
5159

5260
const videoId = matches[1]

changelog.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# 2.0.0
2+
## Performance Impact
3+
For full transparency, this update does have roughly a roughly 15% hit to performance on paper. In practice, this is likely to be completely negligible (especially given more real-world workloads than Stephen Li [Trinovantes]'s built-in benchmarking), but it is worth noting for full transparency.
4+
5+
## Breaking Changes
6+
* Adjust handling of component children, including fixing a bug where `skipChildren` was not being honored (meaning excess renders were being performed)
7+
* Adjust handling of render failing...
8+
* fixing a bug where components which would not be rendered would simply not appear (rather than displaying their raw text)
9+
* requiring that all render failures now either call `doNotRenderBBCodeComponent()` or throw `DoNotRenderBBCodeError` (see below)
10+
* Rename `Transform.component` to `Transform.Component` to better satisfy React naming conventions and linters which rely on them
11+
12+
### `doNotRenderBBCodeComponent()` & `DoNotRenderBBCodeError`
13+
These new exports are for use when a component should not be rendered (such as when an unsafe URL is detected). When encountered by the compiler, the component will not be rendered.
14+
15+
The `doNotRenderBBCodeComponent()` function returns the TypeScript type `never` which TS understands to be the same as throwing an error or returning. You therefore are not strictly required to use `return` after calling `doNotRenderBBCodeComponent()` (though it helps with readability in IDEs with syntax highlighting).
16+
17+
The `DoNotRenderBBCodeError` is a class which extends `Error` and is thrown when a component should not be rendered. While I can't think of much of a use case for this, it is provided for completeness under `bbcode-compiler-react/advanced`.
18+
19+
## Improvements
20+
* Add optional `doDangerCheck` parameter to `parseMaybeRelativeUrl()` (defaults to true to mirror 1.0.0 behavior)
21+
* Component keys are now the nodes stringified normally (rather than plain iterator indices) to make React happier
22+
* Errors thrown during tag rendering will now append to the stack the tag that caused the error and a TagNode object for debugging passed passed through Json.stringify()
23+
* This behavior will only apply if the symbol `BBCodeOriginalStackTrace` is not set to the old stack trace on the error object. This is exported from `bbcode-compiler-react/advanced` if you wish to use it in your own error handling.
24+
* Transform's Component method will now show the name `BBCode_${tagName}` (e.g. `BBCode_b` for [b]) if no custom function name is provided
25+
26+
## Bug Fixes
27+
* In rare cases, the `children` parameter could be a two-dimensional array. This array is now flattened.
28+
* Important note - you should NOT be relying on the `children` parameter in your components; you should prefer to gather data from the `tagNode` property.
29+
30+
## TypeScript Types
31+
* For transforms which have `skipChildren`, the `children` parameter will now always be `undefined`
32+
* Added JSDoc to:
33+
* `parseMaybeRelativeUrl()`
34+
* `isDangerousUrl()`
35+
* Revised JSDoc for:
36+
* `Transform.Component`
37+
38+
# 1.0.0
39+
Initial release

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "bbcode-compiler-react",
3-
"version": "1.0.0",
4-
"description": "Compiles BBCode into React components",
3+
"version": "2.0.0",
4+
"description": "Parses BBCode and generates React components with strong TypeScript support. Forked from Trinovantes/bbcode-compiler",
55
"exports": "./dist/index.js",
66
"types": "./dist/index.d.ts",
77
"files": [

src/advanced.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './index.js'
2+
export * from './generator/DoNotRenderBBCodeError.js'
3+
export * from './generator/BBCodeOriginalStackTrace.js'

src/generateReact.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,25 @@ import { defaultTransforms } from './generator/transforms/defaultTransforms.js'
33
import { Lexer } from './lexer/Lexer.js'
44
import { Parser } from './parser/Parser.js'
55

6+
/** Generate React elements from BBCode
7+
*
8+
* @param input
9+
* BBCode to parse
10+
*
11+
* @param transforms
12+
* A list of transforms. Defaults to the `defaultTransforms` exported from this package.
13+
*
14+
* @param errorIfNoTransform
15+
* If true, throws an error when a tag is not in the transforms list. This is the default behavior.
16+
*
17+
* If false, will only throw a warning and render the tag as plain text.
18+
*
19+
* @returns
20+
* a React.ReactElement parsed from the `input` BBCode
21+
*/
622
export function generateReact(
7-
/** BBCode to parse */
823
input: string,
9-
/** A list of transforms
10-
*
11-
* @see defaultTransforms
12-
*/
1324
transforms = defaultTransforms,
14-
/** If true, throws an error when a tag is not in the transforms list. This is the default behavior.
15-
*
16-
* If false, will only throw a warning and render the tag as plain text.
17-
*/
1825
errorIfNoTransform = true,
1926
): React.ReactElement {
2027
const lexer = new Lexer()
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const BBCodeOriginalStackTrace = Symbol('hasErrorInBBCodeBeenAddressed')
2+
3+
// add as a key to Error
4+
declare global {
5+
interface Error {
6+
[BBCodeOriginalStackTrace]?: string
7+
}
8+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class DoNotRenderBBCodeError extends Error {
2+
constructor() {
3+
super('[Internal bbcode-compiler-react Utility Error] Do not render this BBCode')
4+
this.name = 'DoNotRenderBBCodeError'
5+
}
6+
}

src/generator/Generator.tsx

Lines changed: 68 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import React from 'react';
2-
import { AstNode, AstNodeType, RootNode } from '../parser/AstNode.js'
1+
import React from 'react'
2+
import { type AstNode, AstNodeType, type RootNode, type TagNode } from '../parser/AstNode.js'
33
import { defaultTransforms } from './transforms/defaultTransforms.js'
44
import type { Transform } from './transforms/Transform.js'
5+
import { DoNotRenderBBCodeError } from './DoNotRenderBBCodeError.js'
6+
import { BBCodeOriginalStackTrace } from './BBCodeOriginalStackTrace.js'
57

68
export class Generator {
79
transforms: ReadonlyMap<string, Transform>
@@ -11,65 +13,102 @@ export class Generator {
1113
}
1214

1315
private joinAdjacentStrings(children: Array<React.ReactNode>): Array<React.ReactNode> {
14-
return children.reduce((acc: Array<React.ReactNode>, child) => {
15-
if (typeof child === 'string' && typeof acc[acc.length - 1]! === 'string') {
16-
acc[acc.length - 1]! += child
16+
return children.reduce<Array<React.ReactNode>>((acc: Array<React.ReactNode>, child) => {
17+
if (typeof child === 'string' && typeof acc[acc.length - 1] === 'string') {
18+
(acc[acc.length - 1] as string) += child
1719
} else {
1820
acc.push(child)
1921
}
2022
return acc
21-
}, [] as Array<React.ReactNode>)
23+
}, [])
2224
}
2325

24-
private generateForNode(this: Generator, node: AstNode, i: number): [number, React.ReactNode] {
26+
private createUnrenderedNodeWithRenderedChildren(tagNode: TagNode, children?: Array<React.ReactNode>): React.ReactNode {
27+
if (!children || children.length === 0) return [tagNode.ogStartTag, tagNode.ogEndTag]
28+
29+
if (typeof children[0] === 'string') {
30+
children[0] = tagNode.ogStartTag + children[0]
31+
} else {
32+
children.unshift(tagNode.ogStartTag)
33+
}
34+
35+
if (typeof children[children.length - 1] === 'string') {
36+
children[children.length - 1] = (children[children.length - 1] as string) + tagNode.ogEndTag
37+
} else {
38+
children.push(tagNode.ogEndTag)
39+
}
40+
41+
return children
42+
}
43+
44+
private generateForNode(this: Generator, node: AstNode, key: number): React.ReactNode {
2545
switch (node.nodeType) {
2646
case AstNodeType.LinebreakNode: {
27-
return [i + 1, <br key={i} />]
47+
return <br key={key} />
2848
} case AstNodeType.TextNode: {
29-
return [i, node.str]
49+
return node.str
3050
} case AstNodeType.TagNode: {
3151
const tagName = node.tagName
3252
const transform = this.transforms.get(tagName)
53+
const renderedChildren =
54+
transform && transform.skipChildren
55+
? undefined
56+
: this.joinAdjacentStrings(node.children.map((child, i) => {
57+
const renderedChild = this.generateForNode(child, i)
58+
return renderedChild
59+
})).flat()
3360
if (!transform) {
3461
if (this.errorIfNoTransform) {
3562
throw new Error(`Unrecognized bbcode ${node.tagName}`)
3663
} else {
3764
console.warn(`Unrecognized bbcode ${node.tagName}`)
3865
}
39-
return [i, node.ogEndTag]
66+
return this.createUnrenderedNodeWithRenderedChildren(node)
4067
}
4168

42-
const renderedChildren = node.children.map((child, j) => {
43-
const [newI, renderedChild] = this.generateForNode(child, i)
44-
i = newI
45-
return renderedChild
46-
})
69+
// because error boundaries don't work compile-time smh
70+
const WrappedComponentFunction = ({ tagNode, children }: {tagNode: TagNode; children: typeof renderedChildren}) => {
71+
try {
72+
return transform.Component({ tagNode, children } as unknown as never)
73+
} catch (e) {
74+
if (!(e instanceof DoNotRenderBBCodeError)) {
75+
if (e instanceof Error && e.stack && e[BBCodeOriginalStackTrace] === undefined) {
76+
e[BBCodeOriginalStackTrace] = e.stack
77+
e.stack += (`\n\nTag: [${node.tagName}]\n\n` + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
78+
`${this.createUnrenderedNodeWithRenderedChildren(node)}` +
79+
`\n\n${node.toString()}`).split('\n').map((line) => line.length ? ` ${line}` : '').join('\n')
80+
}
81+
throw e
82+
}
4783

48-
const { component: Component } = transform
49-
const renderedTag = <Component tagNode={node} key={i}>
50-
{this.joinAdjacentStrings(renderedChildren)}
51-
</Component>
52-
53-
if ((renderedTag as any) === false) {
54-
return [i, node.ogStartTag + renderedChildren + node.ogEndTag]
84+
return this.createUnrenderedNodeWithRenderedChildren(node, renderedChildren)
85+
}
5586
}
5687

57-
return [i + 1, renderedTag]
88+
// 😱 readable component names in error traces FTW
89+
Object.defineProperty(WrappedComponentFunction, 'name', {
90+
value: transform.Component.name !== '' && transform.Component.name !== 'Component'
91+
? transform.Component.name
92+
: `BBCode_${tagName}`,
93+
})
94+
95+
return <WrappedComponentFunction tagNode={node} key={key}>
96+
{renderedChildren}
97+
</WrappedComponentFunction>
5898
} default: {
59-
const renderedChildren = node.children.map((child, j) => {
60-
const [newI, renderedChild] = this.generateForNode(child, i)
61-
i = newI
99+
const renderedChildren = node.children.map((child, i) => {
100+
const renderedChild = this.generateForNode(child, i)
62101
return renderedChild
63-
});
102+
})
64103

65-
return [i + 1, this.joinAdjacentStrings(renderedChildren)]
104+
return this.joinAdjacentStrings(renderedChildren)
66105
}
67106
}
68107
}
69108

70109
public generate(root: RootNode): React.ReactElement {
71110
return <>
72-
{this.generateForNode(root, 0)[1]}
111+
{this.generateForNode(root, 0)}
73112
</>
74113
}
75114
}

0 commit comments

Comments
 (0)