Skip to content

Commit

Permalink
2.0.0 - see changelog in extended commit message
Browse files Browse the repository at this point in the history
## 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`
  • Loading branch information
BellCubeDev committed May 19, 2024
1 parent 9f8f193 commit 8195076
Show file tree
Hide file tree
Showing 19 changed files with 358 additions and 147 deletions.
45 changes: 4 additions & 41 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ module.exports = {
ignoreParameters: false,
ignoreProperties: true,
}],
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
'@typescript-eslint/consistent-type-assertions': ['error', {
assertionStyle: 'as',
objectLiteralTypeAssertions: 'never',
Expand All @@ -115,48 +115,11 @@ module.exports = {
requireLast: false,
},
}],
'@typescript-eslint/strict-boolean-expressions': ['error', {
allowNullableBoolean: true,
allowNullableString: true,
}],
'@typescript-eslint/naming-convention': ['error',
{
selector: 'default',
format: null,
modifiers: ['requiresQuotes'],
},
{
selector: 'typeLike',
format: ['PascalCase'],
},
{
selector: 'parameter',
format: ['strictCamelCase', 'UPPER_CASE'],
leadingUnderscore: 'allowSingleOrDouble',
trailingUnderscore: 'allowDouble',
},
{
selector: 'memberLike',
modifiers: ['private'],
format: ['strictCamelCase'],
leadingUnderscore: 'require',
},
{
selector: [
'variableLike',
'method',
],
filter: {
regex: '^update:',
match: false,
},
format: ['strictCamelCase', 'UPPER_CASE'],
leadingUnderscore: 'allowDouble',
trailingUnderscore: 'allowDouble',
},
],
'@typescript-eslint/strict-boolean-expressions': 'off',
'@typescript-eslint/naming-convention': 'off', // I hate you sometimes, ESLint. I really do.
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
}],
'@typescript-eslint/no-unnecessary-type-arguments': 'off', // If I want to be explicit, I'm gonna be explicit!
},
}
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# BBCode Compiler - React

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

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

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

<!--
TODO: Touch on API capabilities a bit more, such as:
* Built-in utils
* DoNotRenderBBCodeError
* Rendering performance & why you should use this anyway (~5x slower than bbcode-compiler)
* Graceful error handling
-->

## Extending With Custom Tags

```tsx
import { generateReact, defaultTransforms, getWidthHeightAttr } from 'bbcode-compiler-react'
import { generateReact, defaultTransforms, getWidthHeightAttr, doNotRenderBBCodeComponent } from 'bbcode-compiler-react'

const customTransforms: typeof defaultTransforms = [
// Default tags included with this package
Expand All @@ -25,28 +33,28 @@ const customTransforms: typeof defaultTransforms = [
// You can override a default tag by including it after the original in the transforms array
{
name: 'b',
component({ tagNode, children }) {
Component({ tagNode, children }) {
return <b>
{children}
</b>
}
},

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

const matches = /youtube.com\/watch\?v=(\w+)/.exec(src)
if (!matches) {
return false
return doNotRenderBBCodeComponent()
}

const videoId = matches[1]
Expand Down
39 changes: 39 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 2.0.0
## 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.0.0
Initial release
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "bbcode-compiler-react",
"version": "1.0.0",
"description": "Compiles BBCode into React components",
"version": "2.0.0",
"description": "Parses BBCode and generates React components with strong TypeScript support. Forked from Trinovantes/bbcode-compiler",
"exports": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
Expand Down
3 changes: 3 additions & 0 deletions src/advanced.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './index.js'
export * from './generator/DoNotRenderBBCodeError.js'
export * from './generator/BBCodeOriginalStackTrace.js'
25 changes: 16 additions & 9 deletions src/generateReact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,25 @@ import { defaultTransforms } from './generator/transforms/defaultTransforms.js'
import { Lexer } from './lexer/Lexer.js'
import { Parser } from './parser/Parser.js'

/** Generate React elements from BBCode
*
* @param input
* BBCode to parse
*
* @param transforms
* A list of transforms. Defaults to the `defaultTransforms` exported from this package.
*
* @param errorIfNoTransform
* If true, throws an error when a tag is not in the transforms list. This is the default behavior.
*
* If false, will only throw a warning and render the tag as plain text.
*
* @returns
* a React.ReactElement parsed from the `input` BBCode
*/
export function generateReact(
/** BBCode to parse */
input: string,
/** A list of transforms
*
* @see defaultTransforms
*/
transforms = defaultTransforms,
/** If true, throws an error when a tag is not in the transforms list. This is the default behavior.
*
* If false, will only throw a warning and render the tag as plain text.
*/
errorIfNoTransform = true,
): React.ReactElement {
const lexer = new Lexer()
Expand Down
8 changes: 8 additions & 0 deletions src/generator/BBCodeOriginalStackTrace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const BBCodeOriginalStackTrace = Symbol('hasErrorInBBCodeBeenAddressed')

// add as a key to Error
declare global {
interface Error {
[BBCodeOriginalStackTrace]?: string
}
}
6 changes: 6 additions & 0 deletions src/generator/DoNotRenderBBCodeError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class DoNotRenderBBCodeError extends Error {
constructor() {
super('[Internal bbcode-compiler-react Utility Error] Do not render this BBCode')
this.name = 'DoNotRenderBBCodeError'
}
}
97 changes: 68 additions & 29 deletions src/generator/Generator.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React from 'react';
import { AstNode, AstNodeType, RootNode } from '../parser/AstNode.js'
import React from 'react'
import { type AstNode, AstNodeType, type RootNode, type TagNode } from '../parser/AstNode.js'
import { defaultTransforms } from './transforms/defaultTransforms.js'
import type { Transform } from './transforms/Transform.js'
import { DoNotRenderBBCodeError } from './DoNotRenderBBCodeError.js'
import { BBCodeOriginalStackTrace } from './BBCodeOriginalStackTrace.js'

export class Generator {
transforms: ReadonlyMap<string, Transform>
Expand All @@ -11,65 +13,102 @@ export class Generator {
}

private joinAdjacentStrings(children: Array<React.ReactNode>): Array<React.ReactNode> {
return children.reduce((acc: Array<React.ReactNode>, child) => {
if (typeof child === 'string' && typeof acc[acc.length - 1]! === 'string') {
acc[acc.length - 1]! += child
return children.reduce<Array<React.ReactNode>>((acc: Array<React.ReactNode>, child) => {
if (typeof child === 'string' && typeof acc[acc.length - 1] === 'string') {
(acc[acc.length - 1] as string) += child
} else {
acc.push(child)
}
return acc
}, [] as Array<React.ReactNode>)
}, [])
}

private generateForNode(this: Generator, node: AstNode, i: number): [number, React.ReactNode] {
private createUnrenderedNodeWithRenderedChildren(tagNode: TagNode, children?: Array<React.ReactNode>): React.ReactNode {
if (!children || children.length === 0) return [tagNode.ogStartTag, tagNode.ogEndTag]

if (typeof children[0] === 'string') {
children[0] = tagNode.ogStartTag + children[0]
} else {
children.unshift(tagNode.ogStartTag)
}

if (typeof children[children.length - 1] === 'string') {
children[children.length - 1] = (children[children.length - 1] as string) + tagNode.ogEndTag
} else {
children.push(tagNode.ogEndTag)
}

return children
}

private generateForNode(this: Generator, node: AstNode, key: number): React.ReactNode {
switch (node.nodeType) {
case AstNodeType.LinebreakNode: {
return [i + 1, <br key={i} />]
return <br key={key} />
} case AstNodeType.TextNode: {
return [i, node.str]
return node.str
} case AstNodeType.TagNode: {
const tagName = node.tagName
const transform = this.transforms.get(tagName)
const renderedChildren =
transform && transform.skipChildren
? undefined
: this.joinAdjacentStrings(node.children.map((child, i) => {
const renderedChild = this.generateForNode(child, i)
return renderedChild
})).flat()
if (!transform) {
if (this.errorIfNoTransform) {
throw new Error(`Unrecognized bbcode ${node.tagName}`)
} else {
console.warn(`Unrecognized bbcode ${node.tagName}`)
}
return [i, node.ogEndTag]
return this.createUnrenderedNodeWithRenderedChildren(node)
}

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

const { component: Component } = transform
const renderedTag = <Component tagNode={node} key={i}>
{this.joinAdjacentStrings(renderedChildren)}
</Component>

if ((renderedTag as any) === false) {
return [i, node.ogStartTag + renderedChildren + node.ogEndTag]
return this.createUnrenderedNodeWithRenderedChildren(node, renderedChildren)
}
}

return [i + 1, renderedTag]
// 😱 readable component names in error traces FTW
Object.defineProperty(WrappedComponentFunction, 'name', {
value: transform.Component.name !== '' && transform.Component.name !== 'Component'
? transform.Component.name
: `BBCode_${tagName}`,
})

return <WrappedComponentFunction tagNode={node} key={key}>
{renderedChildren}
</WrappedComponentFunction>
} default: {
const renderedChildren = node.children.map((child, j) => {
const [newI, renderedChild] = this.generateForNode(child, i)
i = newI
const renderedChildren = node.children.map((child, i) => {
const renderedChild = this.generateForNode(child, i)
return renderedChild
});
})

return [i + 1, this.joinAdjacentStrings(renderedChildren)]
return this.joinAdjacentStrings(renderedChildren)
}
}
}

public generate(root: RootNode): React.ReactElement {
return <>
{this.generateForNode(root, 0)[1]}
{this.generateForNode(root, 0)}
</>
}
}
Loading

0 comments on commit 8195076

Please sign in to comment.