bbcode-compiler 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/generateHtml.d.ts +2 -0
- package/dist/generateHtml.d.ts.map +1 -0
- package/dist/generateHtml.js +13 -0
- package/dist/generateHtml.js.map +1 -0
- package/dist/generator/Generator.d.ts +8 -0
- package/dist/generator/Generator.d.ts.map +1 -0
- package/dist/generator/Generator.js +54 -0
- package/dist/generator/Generator.js.map +1 -0
- package/dist/generator/transforms/Transform.d.ts +10 -0
- package/dist/generator/transforms/Transform.d.ts.map +1 -0
- package/dist/generator/transforms/Transform.js +2 -0
- package/dist/generator/transforms/Transform.js.map +1 -0
- package/dist/generator/transforms/htmlTransforms.d.ts +3 -0
- package/dist/generator/transforms/htmlTransforms.d.ts.map +1 -0
- package/dist/generator/transforms/htmlTransforms.js +198 -0
- package/dist/generator/transforms/htmlTransforms.js.map +1 -0
- package/dist/generator/utils/getTagImmediateAttrVal.d.ts +14 -0
- package/dist/generator/utils/getTagImmediateAttrVal.d.ts.map +1 -0
- package/dist/generator/utils/getTagImmediateAttrVal.js +19 -0
- package/dist/generator/utils/getTagImmediateAttrVal.js.map +1 -0
- package/dist/generator/utils/getTagImmediateText.d.ts +12 -0
- package/dist/generator/utils/getTagImmediateText.d.ts.map +1 -0
- package/dist/generator/utils/getTagImmediateText.js +29 -0
- package/dist/generator/utils/getTagImmediateText.js.map +1 -0
- package/dist/generator/utils/getWidthHeightAttr.d.ts +31 -0
- package/dist/generator/utils/getWidthHeightAttr.d.ts.map +1 -0
- package/dist/generator/utils/getWidthHeightAttr.js +47 -0
- package/dist/generator/utils/getWidthHeightAttr.js.map +1 -0
- package/dist/generator/utils/isDangerousUrl.d.ts +2 -0
- package/dist/generator/utils/isDangerousUrl.d.ts.map +1 -0
- package/dist/generator/utils/isDangerousUrl.js +14 -0
- package/dist/generator/utils/isDangerousUrl.js.map +1 -0
- package/dist/generator/utils/isOrderedList.d.ts +19 -0
- package/dist/generator/utils/isOrderedList.d.ts.map +1 -0
- package/dist/generator/utils/isOrderedList.js +26 -0
- package/dist/generator/utils/isOrderedList.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/lexer/Lexer.d.ts +5 -0
- package/dist/lexer/Lexer.d.ts.map +1 -0
- package/dist/lexer/Lexer.js +81 -0
- package/dist/lexer/Lexer.js.map +1 -0
- package/dist/lexer/Token.d.ts +8 -0
- package/dist/lexer/Token.d.ts.map +1 -0
- package/dist/lexer/Token.js +54 -0
- package/dist/lexer/Token.js.map +1 -0
- package/dist/lexer/TokenType.d.ts +17 -0
- package/dist/lexer/TokenType.d.ts.map +1 -0
- package/dist/lexer/TokenType.js +41 -0
- package/dist/lexer/TokenType.js.map +1 -0
- package/dist/parser/AstNode.d.ts +105 -0
- package/dist/parser/AstNode.d.ts.map +1 -0
- package/dist/parser/AstNode.js +263 -0
- package/dist/parser/AstNode.js.map +1 -0
- package/dist/parser/Parser.d.ts +11 -0
- package/dist/parser/Parser.d.ts.map +1 -0
- package/dist/parser/Parser.js +265 -0
- package/dist/parser/Parser.js.map +1 -0
- package/dist/parser/nodeIsType.d.ts +13 -0
- package/dist/parser/nodeIsType.d.ts.map +1 -0
- package/dist/parser/nodeIsType.js +5 -0
- package/dist/parser/nodeIsType.js.map +1 -0
- package/package.json +68 -0
- package/src/generateHtml.ts +15 -0
- package/src/generator/Generator.ts +60 -0
- package/src/generator/transforms/Transform.ts +15 -0
- package/src/generator/transforms/htmlTransforms.ts +205 -0
- package/src/generator/utils/getTagImmediateAttrVal.ts +21 -0
- package/src/generator/utils/getTagImmediateText.ts +33 -0
- package/src/generator/utils/getWidthHeightAttr.ts +51 -0
- package/src/generator/utils/isDangerousUrl.ts +17 -0
- package/src/generator/utils/isOrderedList.ts +28 -0
- package/src/index.ts +18 -0
- package/src/lexer/Lexer.ts +89 -0
- package/src/lexer/Token.ts +64 -0
- package/src/lexer/TokenType.ts +65 -0
- package/src/parser/AstNode.ts +338 -0
- package/src/parser/Parser.ts +316 -0
- package/src/parser/nodeIsType.ts +15 -0
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bbcode-compiler",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Fast BBCode parser and HTML generator with TypeScript support",
|
|
5
|
+
"exports": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"README.md",
|
|
9
|
+
"dist/*",
|
|
10
|
+
"src/*"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"repository": "https://github.com/Trinovantes/bbcode-compiler",
|
|
15
|
+
"author": "Stephen Li <hello@stephenli.ca> (https://www.stephenli.ca)",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"private": false,
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": " NODE_ENV=development tsc --watch",
|
|
20
|
+
"build": "NODE_ENV=production tsc -p tsconfig.prod.json",
|
|
21
|
+
"prepublishOnly": "rm -rf ./dist && yarn build",
|
|
22
|
+
"lint": "tsc --noemit && eslint --ext '.ts' --ignore-path .gitignore .",
|
|
23
|
+
"test": "jest",
|
|
24
|
+
"benchmark": " node --loader ts-node/esm --experimental-specifier-resolution=node tests/benchmarks/benchmark.ts",
|
|
25
|
+
"profile": " node --loader ts-node/esm --experimental-specifier-resolution=node --prof --no-logfile-per-isolate tests/benchmarks/profile.ts && node --prof-process v8.log > v8.txt",
|
|
26
|
+
"playground": "node --loader ts-node/esm --experimental-specifier-resolution=node tests/playground/run.ts | tee tests/playground/index.html",
|
|
27
|
+
"serve": "python3 -m http.server --directory tests/playground 8080"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@babel/core": "^7.12.10",
|
|
31
|
+
"@babel/plugin-transform-runtime": "^7.14.5",
|
|
32
|
+
"@babel/preset-env": "^7.12.11",
|
|
33
|
+
"@babel/preset-typescript": "^7.12.7",
|
|
34
|
+
"@babel/register": "^7.13.14",
|
|
35
|
+
"@bbob/html": "^2.8.1",
|
|
36
|
+
"@bbob/preset-html5": "^2.8.1",
|
|
37
|
+
"@thoughtsunificator/bbcode-parser-template": "^1.0.9",
|
|
38
|
+
"@types/benchmark": "^2.1.1",
|
|
39
|
+
"@types/jest": "^28.1.3",
|
|
40
|
+
"@types/markdown-it": "^12.2.3",
|
|
41
|
+
"@types/node": "^14",
|
|
42
|
+
"@typescript-eslint/eslint-plugin": "^5.23.0",
|
|
43
|
+
"@typescript-eslint/parser": "^5.23.0",
|
|
44
|
+
"babel-plugin-root-import": "^6.6.0",
|
|
45
|
+
"babel-plugin-transform-define": "^2.0.0",
|
|
46
|
+
"bbcode": "^0.1.5",
|
|
47
|
+
"bbcode-parser": "^1.0.10",
|
|
48
|
+
"bbcodejs": "^0.0.4",
|
|
49
|
+
"benchmark": "^2.1.4",
|
|
50
|
+
"eslint": "^8.15.0",
|
|
51
|
+
"eslint-config-standard": "^17.0.0",
|
|
52
|
+
"eslint-import-resolver-typescript": "^3.1.1",
|
|
53
|
+
"eslint-plugin-import": "^2.22.0",
|
|
54
|
+
"eslint-plugin-n": "^15.2.0",
|
|
55
|
+
"eslint-plugin-node": "^11.1.0",
|
|
56
|
+
"eslint-plugin-promise": "^6.0.0",
|
|
57
|
+
"eslint-plugin-vue": "^9.1.1",
|
|
58
|
+
"jest": "^28.1.0",
|
|
59
|
+
"markdown-it": "^13.0.1",
|
|
60
|
+
"ts-bbcode-parser": "^1.0.4",
|
|
61
|
+
"ts-node": "^10.8.1",
|
|
62
|
+
"typescript": "^4.2.4",
|
|
63
|
+
"ya-bbcode": "^1.0.12"
|
|
64
|
+
},
|
|
65
|
+
"engines": {
|
|
66
|
+
"node": ">=14.16"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Generator } from './generator/Generator'
|
|
2
|
+
import { htmlTransforms } from './generator/transforms/htmlTransforms'
|
|
3
|
+
import { Lexer } from './lexer/Lexer'
|
|
4
|
+
import { Parser } from './parser/Parser'
|
|
5
|
+
|
|
6
|
+
export function generateHtml(input: string, transforms = htmlTransforms): string {
|
|
7
|
+
const lexer = new Lexer()
|
|
8
|
+
const tokens = lexer.tokenize(input)
|
|
9
|
+
|
|
10
|
+
const parser = new Parser(transforms)
|
|
11
|
+
const root = parser.parse(input, tokens)
|
|
12
|
+
|
|
13
|
+
const generator = new Generator(transforms)
|
|
14
|
+
return generator.generate(root)
|
|
15
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { AstNode, AstNodeType, RootNode } from '../parser/AstNode'
|
|
2
|
+
import { nodeIsType } from '../parser/nodeIsType'
|
|
3
|
+
import { htmlTransforms } from './transforms/htmlTransforms'
|
|
4
|
+
import type { Transform } from './transforms/Transform'
|
|
5
|
+
|
|
6
|
+
export class Generator {
|
|
7
|
+
transforms: ReadonlyMap<string, Transform>
|
|
8
|
+
|
|
9
|
+
constructor(transforms = htmlTransforms) {
|
|
10
|
+
this.transforms = new Map(transforms.map((transform) => [transform.name, transform]))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
generate(root: RootNode): string {
|
|
14
|
+
const stringify = (node: AstNode): string => {
|
|
15
|
+
let output = ''
|
|
16
|
+
|
|
17
|
+
if (nodeIsType(node, AstNodeType.TagNode)) {
|
|
18
|
+
const tagName = node.tagName
|
|
19
|
+
const transform = this.transforms.get(tagName)
|
|
20
|
+
if (!transform) {
|
|
21
|
+
throw new Error(`Unrecognized bbcode ${node.tagName}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const renderedStartTag = transform.start(node)
|
|
25
|
+
const renderedEndTag = transform.end?.(node) ?? ''
|
|
26
|
+
const isInvalidTag = renderedStartTag === false
|
|
27
|
+
|
|
28
|
+
if (isInvalidTag) {
|
|
29
|
+
output += node.ogStartTag
|
|
30
|
+
} else {
|
|
31
|
+
output += renderedStartTag
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!transform.skipChildren || isInvalidTag) {
|
|
35
|
+
for (const child of node.children) {
|
|
36
|
+
output += stringify(child)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (isInvalidTag) {
|
|
41
|
+
output += node.ogEndTag
|
|
42
|
+
} else {
|
|
43
|
+
output += renderedEndTag
|
|
44
|
+
}
|
|
45
|
+
} else if (nodeIsType(node, AstNodeType.TextNode)) {
|
|
46
|
+
output += node.str
|
|
47
|
+
} else if (nodeIsType(node, AstNodeType.LinebreakNode)) {
|
|
48
|
+
output += '\n'
|
|
49
|
+
} else {
|
|
50
|
+
for (const child of node.children) {
|
|
51
|
+
output += stringify(child)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return output
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return stringify(root)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TagNode } from '../../parser/AstNode'
|
|
2
|
+
|
|
3
|
+
export interface Transform {
|
|
4
|
+
name: string
|
|
5
|
+
|
|
6
|
+
skipChildren?: boolean // Do not recursively render children nodes (e.g. [img]url[/url] should not render url)
|
|
7
|
+
isStandalone?: boolean // Only has StartTag e.g. "[hr]"
|
|
8
|
+
isLinebreakTerminated?: boolean // Ends by linebreak e.g. "[*] list entry\n"
|
|
9
|
+
|
|
10
|
+
// Returns false when the StartTag failed validation and original bbcode will be displayed as plaintext
|
|
11
|
+
start: (tagNode: TagNode) => string | false
|
|
12
|
+
|
|
13
|
+
// Some tags do not need closing tag e.g. standalones like [hr] or wrappers like [img]
|
|
14
|
+
end?: (tagNode: TagNode) => string
|
|
15
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { getTagImmediateAttrVal } from '../utils/getTagImmediateAttrVal'
|
|
2
|
+
import { getTagImmediateText } from '../utils/getTagImmediateText'
|
|
3
|
+
import { getWidthHeightAttr } from '../utils/getWidthHeightAttr'
|
|
4
|
+
import { isDangerousUrl } from '../utils/isDangerousUrl'
|
|
5
|
+
import { isOrderedList } from '../utils/isOrderedList'
|
|
6
|
+
import type { Transform } from './Transform'
|
|
7
|
+
|
|
8
|
+
export const htmlTransforms: ReadonlyArray<Transform> = [
|
|
9
|
+
{
|
|
10
|
+
name: 'b',
|
|
11
|
+
start: () => {
|
|
12
|
+
return '<strong>'
|
|
13
|
+
},
|
|
14
|
+
end: () => {
|
|
15
|
+
return '</strong>'
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'i',
|
|
20
|
+
start: () => {
|
|
21
|
+
return '<em>'
|
|
22
|
+
},
|
|
23
|
+
end: () => {
|
|
24
|
+
return '</em>'
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'u',
|
|
29
|
+
start: () => {
|
|
30
|
+
return '<ins>'
|
|
31
|
+
},
|
|
32
|
+
end: () => {
|
|
33
|
+
return '</ins>'
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 's',
|
|
38
|
+
start: () => {
|
|
39
|
+
return '<del>'
|
|
40
|
+
},
|
|
41
|
+
end: () => {
|
|
42
|
+
return '</del>'
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'style',
|
|
47
|
+
start: (tagNode) => {
|
|
48
|
+
let style = ''
|
|
49
|
+
|
|
50
|
+
for (const child of tagNode.attributes) {
|
|
51
|
+
switch (child.key) {
|
|
52
|
+
case 'color': {
|
|
53
|
+
style += `color:${child.val};`
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
case 'size': {
|
|
57
|
+
if (/^\d+$/.test(child.val)) {
|
|
58
|
+
style += `font-size:${child.val}%;` // When no units provided (i.e. just a number), then assume %
|
|
59
|
+
} else {
|
|
60
|
+
style += `font-size:${child.val};`
|
|
61
|
+
}
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return `<span style="${style}">`
|
|
68
|
+
},
|
|
69
|
+
end: () => {
|
|
70
|
+
return '</span>'
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'color',
|
|
75
|
+
start: (tagNode) => {
|
|
76
|
+
const color = getTagImmediateAttrVal(tagNode)
|
|
77
|
+
return `<span style="color:${color};">`
|
|
78
|
+
},
|
|
79
|
+
end: () => {
|
|
80
|
+
return '</span>'
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'hr',
|
|
85
|
+
isStandalone: true,
|
|
86
|
+
start: () => {
|
|
87
|
+
return '<hr />'
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'list',
|
|
92
|
+
start: (tagNode) => {
|
|
93
|
+
return isOrderedList(tagNode)
|
|
94
|
+
? '<ol>'
|
|
95
|
+
: '<ul>'
|
|
96
|
+
},
|
|
97
|
+
end: (tagNode) => {
|
|
98
|
+
return isOrderedList(tagNode)
|
|
99
|
+
? '</ol>'
|
|
100
|
+
: '</ul>'
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: '*',
|
|
105
|
+
isLinebreakTerminated: true,
|
|
106
|
+
start: () => {
|
|
107
|
+
return '<li>'
|
|
108
|
+
},
|
|
109
|
+
end: () => {
|
|
110
|
+
return '</li>'
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'img',
|
|
115
|
+
skipChildren: true,
|
|
116
|
+
start: (tagNode) => {
|
|
117
|
+
const src = getTagImmediateText(tagNode)
|
|
118
|
+
if (!src) {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (isDangerousUrl(src)) {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { width, height } = getWidthHeightAttr(tagNode)
|
|
127
|
+
|
|
128
|
+
let str = `<img src="${src}"`
|
|
129
|
+
if (width) {
|
|
130
|
+
str += ` width="${width}"`
|
|
131
|
+
}
|
|
132
|
+
if (height) {
|
|
133
|
+
str += ` height="${height}"`
|
|
134
|
+
}
|
|
135
|
+
str += '>'
|
|
136
|
+
return str
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'url',
|
|
141
|
+
start: (tagNode) => {
|
|
142
|
+
const href = getTagImmediateAttrVal(tagNode) ?? getTagImmediateText(tagNode)
|
|
143
|
+
if (!href) {
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (isDangerousUrl(href)) {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return `<a href="${href}">`
|
|
152
|
+
},
|
|
153
|
+
end: () => {
|
|
154
|
+
return '</a>'
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: 'quote',
|
|
159
|
+
start: (tagNode) => {
|
|
160
|
+
const author = getTagImmediateAttrVal(tagNode)
|
|
161
|
+
return author
|
|
162
|
+
? `<blockquote><strong>${author}</strong>`
|
|
163
|
+
: '<blockquote>'
|
|
164
|
+
},
|
|
165
|
+
end: () => {
|
|
166
|
+
return '</blockquote>'
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: 'table',
|
|
171
|
+
start: () => {
|
|
172
|
+
return '<table>'
|
|
173
|
+
},
|
|
174
|
+
end: () => {
|
|
175
|
+
return '</table>'
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'tr',
|
|
180
|
+
start: () => {
|
|
181
|
+
return '<tr>'
|
|
182
|
+
},
|
|
183
|
+
end: () => {
|
|
184
|
+
return '</tr>'
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: 'td',
|
|
189
|
+
start: () => {
|
|
190
|
+
return '<td>'
|
|
191
|
+
},
|
|
192
|
+
end: () => {
|
|
193
|
+
return '</td>'
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: 'code',
|
|
198
|
+
start: () => {
|
|
199
|
+
return '<code>'
|
|
200
|
+
},
|
|
201
|
+
end: () => {
|
|
202
|
+
return '</code>'
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { TagNode } from '../../parser/AstNode'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gets the text of the immediate attribute of the current TagNode
|
|
5
|
+
*
|
|
6
|
+
* [url=https://en.wikipedia.org]English Wikipedia[/url]
|
|
7
|
+
*
|
|
8
|
+
* TagNode [url]
|
|
9
|
+
* AttrNode VAL="https://en.wikipedia.org" (returns this string)
|
|
10
|
+
* TextNode "https://en.wikipedia.org"
|
|
11
|
+
* RootNode
|
|
12
|
+
* TextNode "English Wikipedia"
|
|
13
|
+
*/
|
|
14
|
+
export function getTagImmediateAttrVal(tagNode: TagNode): string | undefined {
|
|
15
|
+
if (tagNode.attributes.length !== 1) {
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const attrNode = tagNode.attributes[0]
|
|
20
|
+
return attrNode.val
|
|
21
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AstNodeType, TagNode } from '../../parser/AstNode'
|
|
2
|
+
import { nodeIsType } from '../../parser/nodeIsType'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Gets the text of the immediate descendant of the current TagNode
|
|
6
|
+
*
|
|
7
|
+
* [url]https://en.wikipedia.org[/url]
|
|
8
|
+
*
|
|
9
|
+
* TagNode [url]
|
|
10
|
+
* RootNode
|
|
11
|
+
* TextNode "https://en.wikipedia.org" (returns this string)
|
|
12
|
+
*/
|
|
13
|
+
export function getTagImmediateText(tagNode: TagNode): string | undefined {
|
|
14
|
+
if (tagNode.children.length !== 1) {
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const child = tagNode.children[0]
|
|
19
|
+
if (!nodeIsType(child, AstNodeType.RootNode)) {
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (child.children.length !== 1) {
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const textNode = child.children[0]
|
|
28
|
+
if (!nodeIsType(textNode, AstNodeType.TextNode)) {
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return textNode.str
|
|
33
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { TagNode } from '../../parser/AstNode'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gets the width/height attributes of the TagNode if they exist
|
|
5
|
+
*
|
|
6
|
+
* [img 500x300]https://upload.wikimedia.org/wikipedia/commons/7/70/Example.png[/img]
|
|
7
|
+
*
|
|
8
|
+
* RootNode
|
|
9
|
+
* TagNode [img] (returns width:500, height:300)
|
|
10
|
+
* AttrNode VAL="500x300"
|
|
11
|
+
* TextNode " 500x300"
|
|
12
|
+
* RootNode
|
|
13
|
+
* TextNode "https://upload.wikimedia.org/wikipedia/commons/7/70/Example.png"
|
|
14
|
+
*
|
|
15
|
+
* [img width=500 height=300]https://upload.wikimedia.org/wikipedia/commons/7/70/Example.png[/img]
|
|
16
|
+
*
|
|
17
|
+
* RootNode
|
|
18
|
+
* TagNode [img] (returns width:500, height:300)
|
|
19
|
+
* AttrNode KEY="width" VAL="500"
|
|
20
|
+
* TextNode " width"
|
|
21
|
+
* TextNode "500"
|
|
22
|
+
* AttrNode KEY="height" VAL="300"
|
|
23
|
+
* TextNode " height"
|
|
24
|
+
* TextNode "300"
|
|
25
|
+
* RootNode
|
|
26
|
+
* TextNode "https://upload.wikimedia.org/wikipedia/commons/7/70/Example.png
|
|
27
|
+
*/
|
|
28
|
+
export function getWidthHeightAttr(tagNode: TagNode): { width?: string; height?: string } {
|
|
29
|
+
let width: string | undefined
|
|
30
|
+
let height: string | undefined
|
|
31
|
+
|
|
32
|
+
for (const child of tagNode.attributes) {
|
|
33
|
+
if (child.key === 'width') {
|
|
34
|
+
width = child.val
|
|
35
|
+
}
|
|
36
|
+
if (child.key === 'height') {
|
|
37
|
+
height = child.val
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const matches = /(\d+)x(\d+)/.exec(child.val)
|
|
41
|
+
if (matches) {
|
|
42
|
+
width = matches[1]
|
|
43
|
+
height = matches[2]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
width,
|
|
49
|
+
height,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const dangerousUriRe = /^(vbscript|javascript|file|data):/
|
|
2
|
+
const safeDataUriRe = /^data:image\/(gif|png|jpeg|webp);/
|
|
3
|
+
|
|
4
|
+
export function isDangerousUrl(url: string): boolean {
|
|
5
|
+
const normalizedUrl = url.trim().toLowerCase()
|
|
6
|
+
|
|
7
|
+
if (!dangerousUriRe.test(normalizedUrl)) {
|
|
8
|
+
return false
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Only a subset of data uris are considered safe
|
|
12
|
+
if (safeDataUriRe.test(normalizedUrl)) {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return true
|
|
17
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { TagNode } from '../../parser/AstNode'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Determines if the StartTag has an attribute of "1" to indicate that it's an ordered list
|
|
5
|
+
*
|
|
6
|
+
* [list=1]
|
|
7
|
+
*
|
|
8
|
+
* TagNode [list]
|
|
9
|
+
* AttrNode VAL="1"
|
|
10
|
+
* TextNode "1"
|
|
11
|
+
* RootNode
|
|
12
|
+
* TagNode [*]
|
|
13
|
+
* RootNode
|
|
14
|
+
* TextNode "Entry 1"
|
|
15
|
+
* TagNode [*]
|
|
16
|
+
* RootNode
|
|
17
|
+
* TextNode "Entry 2"
|
|
18
|
+
*/
|
|
19
|
+
export function isOrderedList(node: TagNode): boolean {
|
|
20
|
+
for (const child of node.attributes) {
|
|
21
|
+
const val = child.val
|
|
22
|
+
if (val === '1') {
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return false
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export * from './generateHtml'
|
|
2
|
+
|
|
3
|
+
export * from './lexer/Lexer'
|
|
4
|
+
export * from './lexer/Token'
|
|
5
|
+
export * from './lexer/TokenType'
|
|
6
|
+
|
|
7
|
+
export * from './parser/Parser'
|
|
8
|
+
export * from './parser/AstNode'
|
|
9
|
+
export * from './parser/nodeIsType'
|
|
10
|
+
|
|
11
|
+
export * from './generator/Generator'
|
|
12
|
+
export * from './generator/transforms/Transform'
|
|
13
|
+
export * from './generator/transforms/htmlTransforms'
|
|
14
|
+
export * from './generator/utils/getWidthHeightAttr'
|
|
15
|
+
export * from './generator/utils/getTagImmediateAttrVal'
|
|
16
|
+
export * from './generator/utils/getTagImmediateText'
|
|
17
|
+
export * from './generator/utils/isDangerousUrl'
|
|
18
|
+
export * from './generator/utils/isOrderedList'
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { symbolTable, TokenType } from './TokenType'
|
|
2
|
+
import type { Token } from './Token'
|
|
3
|
+
|
|
4
|
+
export class Lexer {
|
|
5
|
+
tokenize(input: Readonly<string>): Array<Token> {
|
|
6
|
+
const tokens = new Array<Token>()
|
|
7
|
+
|
|
8
|
+
const re = /\n|\[\/|\[(\w+|\*)|\]|=|&|<|>|'|"/g
|
|
9
|
+
let offset = 0
|
|
10
|
+
|
|
11
|
+
while (true) {
|
|
12
|
+
// Match until next symbol
|
|
13
|
+
const match = re.exec(input)
|
|
14
|
+
if (!match) {
|
|
15
|
+
break
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Everything between previous symbol and current symbol is treated as plaintext
|
|
19
|
+
//
|
|
20
|
+
// [...]plaintext[/...]
|
|
21
|
+
// | |
|
|
22
|
+
// offset match.index
|
|
23
|
+
// (new) offset
|
|
24
|
+
//
|
|
25
|
+
const length = match.index - offset
|
|
26
|
+
if (length > 0) {
|
|
27
|
+
tokens.push({
|
|
28
|
+
type: TokenType.STR,
|
|
29
|
+
offset,
|
|
30
|
+
length,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
offset = match.index
|
|
35
|
+
|
|
36
|
+
// Only add BACKSLASH token if it's preceded by L_BRACKET
|
|
37
|
+
// In the regex '[/' takes precedence over '['
|
|
38
|
+
if (match[0] === '[/') {
|
|
39
|
+
tokens.push({
|
|
40
|
+
type: TokenType.L_BRACKET,
|
|
41
|
+
offset,
|
|
42
|
+
length: 1,
|
|
43
|
+
})
|
|
44
|
+
offset += 1
|
|
45
|
+
|
|
46
|
+
tokens.push({
|
|
47
|
+
type: TokenType.BACKSLASH,
|
|
48
|
+
offset,
|
|
49
|
+
length: 1,
|
|
50
|
+
})
|
|
51
|
+
offset += 1
|
|
52
|
+
} else if (match[0].startsWith('[')) {
|
|
53
|
+
tokens.push({
|
|
54
|
+
type: TokenType.L_BRACKET,
|
|
55
|
+
offset,
|
|
56
|
+
length: 1,
|
|
57
|
+
})
|
|
58
|
+
offset += 1
|
|
59
|
+
|
|
60
|
+
const length = match[0].length - 1
|
|
61
|
+
tokens.push({
|
|
62
|
+
type: TokenType.STR,
|
|
63
|
+
offset,
|
|
64
|
+
length,
|
|
65
|
+
})
|
|
66
|
+
offset += length
|
|
67
|
+
} else {
|
|
68
|
+
tokens.push({
|
|
69
|
+
type: symbolTable[match[0]] ?? TokenType.STR,
|
|
70
|
+
offset,
|
|
71
|
+
length: 1,
|
|
72
|
+
})
|
|
73
|
+
offset += 1
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Add any leftover non-symbol text
|
|
78
|
+
const length = input.length - offset
|
|
79
|
+
if (length > 0) {
|
|
80
|
+
tokens.push({
|
|
81
|
+
type: TokenType.STR,
|
|
82
|
+
offset,
|
|
83
|
+
length,
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return tokens
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { TokenType } from './TokenType'
|
|
2
|
+
|
|
3
|
+
export interface Token {
|
|
4
|
+
type: TokenType
|
|
5
|
+
offset: number
|
|
6
|
+
length: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function stringifyTokens(ogText: string, tokens: ReadonlyArray<Token>): string {
|
|
10
|
+
let s = ''
|
|
11
|
+
|
|
12
|
+
for (const token of tokens) {
|
|
13
|
+
switch (token.type) {
|
|
14
|
+
case TokenType.STR: {
|
|
15
|
+
s += ogText.substring(token.offset, token.offset + token.length)
|
|
16
|
+
break
|
|
17
|
+
}
|
|
18
|
+
case TokenType.LINEBREAK: {
|
|
19
|
+
s += '\n'
|
|
20
|
+
break
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
case TokenType.L_BRACKET: {
|
|
24
|
+
s += '['
|
|
25
|
+
break
|
|
26
|
+
}
|
|
27
|
+
case TokenType.R_BRACKET: {
|
|
28
|
+
s += ']'
|
|
29
|
+
break
|
|
30
|
+
}
|
|
31
|
+
case TokenType.BACKSLASH: {
|
|
32
|
+
s += '/'
|
|
33
|
+
break
|
|
34
|
+
}
|
|
35
|
+
case TokenType.EQUALS: {
|
|
36
|
+
s += '='
|
|
37
|
+
break
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
case TokenType.XSS_AMP: {
|
|
41
|
+
s += '&'
|
|
42
|
+
break
|
|
43
|
+
}
|
|
44
|
+
case TokenType.XSS_LT: {
|
|
45
|
+
s += '<'
|
|
46
|
+
break
|
|
47
|
+
}
|
|
48
|
+
case TokenType.XSS_GT: {
|
|
49
|
+
s += '>'
|
|
50
|
+
break
|
|
51
|
+
}
|
|
52
|
+
case TokenType.XSS_D_QUOTE: {
|
|
53
|
+
s += '"'
|
|
54
|
+
break
|
|
55
|
+
}
|
|
56
|
+
case TokenType.XSS_S_QUOTE: {
|
|
57
|
+
s += '''
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return s
|
|
64
|
+
}
|