@sullux/markdown-html 1.0.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 +113 -0
- package/index.js +10 -0
- package/lib/html-to-markdown/ast-builder.js +36 -0
- package/lib/html-to-markdown/block-builder.js +87 -0
- package/lib/html-to-markdown/block-table.js +50 -0
- package/lib/html-to-markdown/index.js +17 -0
- package/lib/html-to-markdown/inline-builder.js +66 -0
- package/lib/html-to-markdown/parse-html.js +59 -0
- package/lib/html-to-markdown/utils.js +93 -0
- package/lib/markdown-to-html/escape.js +11 -0
- package/lib/markdown-to-html/highlight/bash.js +45 -0
- package/lib/markdown-to-html/highlight/html.js +39 -0
- package/lib/markdown-to-html/highlight/index.js +33 -0
- package/lib/markdown-to-html/highlight/js.js +53 -0
- package/lib/markdown-to-html/highlight/json.js +41 -0
- package/lib/markdown-to-html/highlight/sql.js +54 -0
- package/lib/markdown-to-html/highlight/yaml.js +43 -0
- package/lib/markdown-to-html/index.js +11 -0
- package/lib/markdown-to-html/render-block.js +82 -0
- package/lib/markdown-to-html/render-inline.js +48 -0
- package/lib/markdown-to-html/slugify.js +21 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Charles Sullivan <charles@sullux.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# @sullux/markdown-html
|
|
2
|
+
|
|
3
|
+
A high-performance, bidirectional Markdown $\leftrightarrow$ HTML compiler supporting GitHub-Flavored Markdown (GFM), GitBook hints, pluggable code block renderers, built-in syntax highlighting, and custom image dimensions.
|
|
4
|
+
|
|
5
|
+
Part of the Sullux markdown suite, `@sullux/markdown-html` relies on `@sullux/markdown-compiler` for AST parsing and stringifying, ensuring zero dependencies and fully auditable code.
|
|
6
|
+
|
|
7
|
+
## Core Features
|
|
8
|
+
|
|
9
|
+
* **Bidirectional Transformations:**
|
|
10
|
+
* `markdownToHtml(markdown, options)`: Renders AST-driven semantic HTML.
|
|
11
|
+
* `htmlToMarkdown(html)`: Converts HTML documents/emails back to clean GFM Markdown.
|
|
12
|
+
* **Pluggable Code Block Renderers:** Register custom handlers for languages like `mermaid`, `dot`, or bespoke blocks via `options.codeRenderers`.
|
|
13
|
+
* **Built-in Zero-Dependency Syntax Highlighting:**
|
|
14
|
+
* Out-of-the-box tokenizers for `js`, `json`, `yaml`, `bash`, `html`, and `sql`.
|
|
15
|
+
* Customizable and overridable via `options.tokenizers`.
|
|
16
|
+
* **Image Dimension Styling:** Multi-syntax dimension support (Obsidian `|400x200`, Pandoc/GitLab `{width=50%}`, GitHub `{:width="400px"}`, VS Code `=300x200`) rendered as inline CSS (`style="width: 400px; height: 200px;"`).
|
|
17
|
+
* **Rich Component Support:**
|
|
18
|
+
* Auto-slugified heading IDs with deduplication (`<h2 id="section-one-1">`).
|
|
19
|
+
* Callouts (`> [!NOTE]`, `{% hint %}`).
|
|
20
|
+
* Task list checkboxes (`- [ ]`, `- [x]`).
|
|
21
|
+
* GFM tables with column alignment attributes (`<th align="center">`).
|
|
22
|
+
* YAML frontmatter stripping.
|
|
23
|
+
|
|
24
|
+
## Folder Topography
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
packages/markdown-html/
|
|
28
|
+
├── lib/
|
|
29
|
+
│ ├── markdown-to-html/ # Markdown -> AST -> HTML renderer (<100 lines each)
|
|
30
|
+
│ │ ├── index.js
|
|
31
|
+
│ │ ├── render-block.js
|
|
32
|
+
│ │ ├── render-inline.js
|
|
33
|
+
│ │ ├── slugify.js
|
|
34
|
+
│ │ ├── escape.js
|
|
35
|
+
│ │ └── highlight/ # Zero-dependency language tokenizers
|
|
36
|
+
│ │ ├── index.js
|
|
37
|
+
│ │ ├── js.js
|
|
38
|
+
│ │ ├── json.js
|
|
39
|
+
│ │ ├── yaml.js
|
|
40
|
+
│ │ ├── bash.js
|
|
41
|
+
│ │ ├── html.js
|
|
42
|
+
│ │ └── sql.js
|
|
43
|
+
│ └── html-to-markdown/ # HTML -> AST -> Markdown compiler (<100 lines each)
|
|
44
|
+
│ ├── index.js
|
|
45
|
+
│ ├── parse-html.js
|
|
46
|
+
│ ├── ast-builder.js
|
|
47
|
+
│ ├── block-builder.js
|
|
48
|
+
│ ├── block-table.js
|
|
49
|
+
│ ├── inline-builder.js
|
|
50
|
+
│ └── utils.js
|
|
51
|
+
├── index.js # Package entrypoint
|
|
52
|
+
└── package.json # Package manifest
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Programmatic Usage
|
|
56
|
+
|
|
57
|
+
### 1. Converting Markdown to HTML with Options
|
|
58
|
+
|
|
59
|
+
```javascript
|
|
60
|
+
const { markdownToHtml } = require('@sullux/markdown-html')
|
|
61
|
+
|
|
62
|
+
const md = `
|
|
63
|
+
# System Spec
|
|
64
|
+
|
|
65
|
+

|
|
66
|
+
|
|
67
|
+
\`\`\`js
|
|
68
|
+
const name = "Sullux";
|
|
69
|
+
\`\`\`
|
|
70
|
+
|
|
71
|
+
\`\`\`mermaid
|
|
72
|
+
graph TD;
|
|
73
|
+
A-->B;
|
|
74
|
+
\`\`\`
|
|
75
|
+
`
|
|
76
|
+
|
|
77
|
+
const html = markdownToHtml(md, {
|
|
78
|
+
codeRenderers: {
|
|
79
|
+
mermaid: (node) => `<div class="mermaid">${node.value}</div>\n`,
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
console.log(html)
|
|
84
|
+
/* Output includes:
|
|
85
|
+
<h1 id="system-spec">System Spec</h1>
|
|
86
|
+
<p><img src="schema.png" alt="Diagram" style="width: 400px; height: 200px;" /></p>
|
|
87
|
+
<pre><code class="language-js"><span class="hl-kw">const</span> <span class="hl-id">name</span> <span class="hl-punc">=</span> <span class="hl-str">"Sullux"</span><span class="hl-punc">;</span></code></pre>
|
|
88
|
+
<div class="mermaid">graph TD;
|
|
89
|
+
A-->B;</div>
|
|
90
|
+
*/
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 2. Converting HTML back to Markdown
|
|
94
|
+
|
|
95
|
+
```javascript
|
|
96
|
+
const { htmlToMarkdown } = require('@sullux/markdown-html')
|
|
97
|
+
|
|
98
|
+
const htmlInput = '<h1>Title</h1><p>This is <strong>bold</strong> text with <a href="https://sullux.com">a link</a>.</p>'
|
|
99
|
+
const markdown = htmlToMarkdown(htmlInput)
|
|
100
|
+
|
|
101
|
+
console.log(markdown)
|
|
102
|
+
/* Output:
|
|
103
|
+
# Title
|
|
104
|
+
|
|
105
|
+
This is **bold** text with [a link](https://sullux.com).
|
|
106
|
+
*/
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Running Unit Tests
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
yarn test
|
|
113
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const { processInlineNode } = require('./inline-builder')
|
|
2
|
+
const { processBlockNode } = require('./block-builder')
|
|
3
|
+
|
|
4
|
+
const DEFAULT_STYLE = {
|
|
5
|
+
fontWeight: 'normal',
|
|
6
|
+
fontStyle: 'normal',
|
|
7
|
+
isCode: false,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const toAst = (htmlRoot) => {
|
|
11
|
+
const blocks = []
|
|
12
|
+
let currentInline = []
|
|
13
|
+
|
|
14
|
+
const traverse = (node, style, inlineTarget) => {
|
|
15
|
+
const target = inlineTarget || currentInline
|
|
16
|
+
|
|
17
|
+
if (processInlineNode(node, style, target, traverse)) return
|
|
18
|
+
if (processBlockNode(node, style, blocks, currentInline, traverse, toAst)) return
|
|
19
|
+
|
|
20
|
+
if (node.children) {
|
|
21
|
+
for (const child of node.children) traverse(child, style, target)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const child of htmlRoot.children) traverse(child, DEFAULT_STYLE, currentInline)
|
|
26
|
+
|
|
27
|
+
if (currentInline.length > 0) {
|
|
28
|
+
if (currentInline[0].type === 'text' && currentInline[0].value === ' ') currentInline.shift()
|
|
29
|
+
if (currentInline.length > 0 && currentInline[currentInline.length - 1].type === 'text' && currentInline[currentInline.length - 1].value === ' ') currentInline.pop()
|
|
30
|
+
if (currentInline.length > 0) blocks.push({ type: 'paragraph', children: [...currentInline] })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { frontmatter: {}, blocks }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { toAst }
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const { parseStyleAttr } = require('./utils')
|
|
2
|
+
const { processTableNode } = require('./block-table')
|
|
3
|
+
|
|
4
|
+
const processBlockNode = (node, style, blocks, currentInline, traverse, toAst) => {
|
|
5
|
+
if (node.type !== 'element') return false
|
|
6
|
+
|
|
7
|
+
const tagName = node.tagName
|
|
8
|
+
const attrs = node.attributes || {}
|
|
9
|
+
const inlineStyles = parseStyleAttr(attrs.style)
|
|
10
|
+
|
|
11
|
+
const flushInline = () => {
|
|
12
|
+
if (currentInline.length > 0) {
|
|
13
|
+
blocks.push({ type: 'paragraph', children: [...currentInline] })
|
|
14
|
+
currentInline.length = 0
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (tagName === 'hr') {
|
|
19
|
+
flushInline()
|
|
20
|
+
blocks.push({ type: 'hr' })
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const headerMatch = tagName.match(/^h([1-6])$/)
|
|
25
|
+
if (headerMatch) {
|
|
26
|
+
flushInline()
|
|
27
|
+
const level = parseInt(headerMatch[1])
|
|
28
|
+
const headerInline = []
|
|
29
|
+
for (const child of node.children) traverse(child, { ...style, fontWeight: 'normal' }, headerInline)
|
|
30
|
+
if (headerInline.length > 0) blocks.push({ type: 'header', level, children: headerInline })
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const fontSizeVal = inlineStyles['font-size']
|
|
35
|
+
if (fontSizeVal && (tagName === 'div' || tagName === 'p' || tagName === 'span')) {
|
|
36
|
+
const sizePx = parseInt(fontSizeVal)
|
|
37
|
+
if (sizePx >= 20) {
|
|
38
|
+
flushInline()
|
|
39
|
+
const level = sizePx >= 28 ? 1 : (sizePx >= 24 ? 2 : 3)
|
|
40
|
+
const headerInline = []
|
|
41
|
+
for (const child of node.children) traverse(child, { ...style, fontWeight: 'normal' }, headerInline)
|
|
42
|
+
if (headerInline.length > 0) blocks.push({ type: 'header', level, children: headerInline })
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (tagName === 'blockquote') {
|
|
48
|
+
flushInline()
|
|
49
|
+
const innerDoc = toAst(node)
|
|
50
|
+
if (innerDoc.blocks.length > 0) blocks.push({ type: 'blockquote', children: innerDoc.blocks })
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (tagName === 'ul' || tagName === 'ol') {
|
|
55
|
+
flushInline()
|
|
56
|
+
const items = []
|
|
57
|
+
for (const child of node.children) {
|
|
58
|
+
if (child.tagName === 'li') {
|
|
59
|
+
const itemInline = []
|
|
60
|
+
for (const liChild of child.children) traverse(liChild, style, itemInline)
|
|
61
|
+
if (itemInline.length > 0) items.push(itemInline)
|
|
62
|
+
} else {
|
|
63
|
+
traverse(child, style, currentInline)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (items.length > 0) blocks.push({ type: tagName === 'ul' ? 'bulletList' : 'orderedList', items })
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (tagName === 'table') {
|
|
71
|
+
flushInline()
|
|
72
|
+
processTableNode(node, style, blocks, traverse)
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const isBlockContainer = new Set(['div', 'p', 'section', 'article', 'main', 'header', 'footer', 'address', 'pre']).has(tagName)
|
|
77
|
+
if (isBlockContainer) {
|
|
78
|
+
flushInline()
|
|
79
|
+
for (const child of node.children) traverse(child, style, currentInline)
|
|
80
|
+
flushInline()
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { processBlockNode }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const { isCellEmpty, optimizeTable } = require('./utils')
|
|
2
|
+
|
|
3
|
+
const processTableNode = (node, style, blocks, traverse) => {
|
|
4
|
+
const tableAlignments = []
|
|
5
|
+
const tableRows = []
|
|
6
|
+
|
|
7
|
+
const findRows = (tableNode) => {
|
|
8
|
+
for (const child of tableNode.children) {
|
|
9
|
+
if (child.tagName === 'tr') {
|
|
10
|
+
const cells = []
|
|
11
|
+
for (const cellNode of child.children) {
|
|
12
|
+
if (cellNode.tagName === 'th' || cellNode.tagName === 'td') {
|
|
13
|
+
if (tableRows.length === 0) {
|
|
14
|
+
const alignAttr = cellNode.attributes.align || ''
|
|
15
|
+
const styleMatch = cellNode.attributes.style?.match(/text-align:\s*([a-z]+)/)
|
|
16
|
+
let align = alignAttr.toLowerCase() || styleMatch?.[1] || 'default'
|
|
17
|
+
if (align !== 'left' && align !== 'right' && align !== 'center') align = 'default'
|
|
18
|
+
tableAlignments.push(align)
|
|
19
|
+
}
|
|
20
|
+
const cellInline = []
|
|
21
|
+
for (const cellChild of cellNode.children) traverse(cellChild, style, cellInline)
|
|
22
|
+
cells.push(cellInline)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (cells.length > 0) tableRows.push(cells)
|
|
26
|
+
} else if (child.tagName === 'tbody' || child.tagName === 'thead' || child.tagName === 'tfoot') {
|
|
27
|
+
findRows(child)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
findRows(node)
|
|
33
|
+
|
|
34
|
+
if (tableRows.length > 0) {
|
|
35
|
+
const optimized = optimizeTable(tableRows, tableAlignments)
|
|
36
|
+
if (optimized) {
|
|
37
|
+
if (optimized.rows[0].length <= 1 || optimized.rows.length <= 1) {
|
|
38
|
+
for (const row of optimized.rows) {
|
|
39
|
+
for (const cell of row) {
|
|
40
|
+
if (!isCellEmpty(cell)) blocks.push({ type: 'paragraph', children: cell })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
blocks.push({ type: 'table', alignments: optimized.alignments, rows: optimized.rows })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { processTableNode }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const { stringify } = require('@sullux/markdown-compiler')
|
|
2
|
+
const { parseHtml } = require('./parse-html')
|
|
3
|
+
const { toAst } = require('./ast-builder')
|
|
4
|
+
|
|
5
|
+
const htmlToMarkdown = (htmlString) => {
|
|
6
|
+
if (!htmlString) return ''
|
|
7
|
+
const htmlTree = parseHtml(htmlString)
|
|
8
|
+
const markdownAst = toAst(htmlTree)
|
|
9
|
+
return stringify(markdownAst)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
parseHtml,
|
|
14
|
+
toAst,
|
|
15
|
+
htmlToMarkdown,
|
|
16
|
+
toMarkdown: htmlToMarkdown,
|
|
17
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const { parseStyleAttr, decodeHtmlEntities } = require('./utils')
|
|
2
|
+
|
|
3
|
+
const INLINE_TAGS = new Set([
|
|
4
|
+
'b', 'strong', 'i', 'em', 'code', 'tt', 'span', 'a', 'img', 'br', 'sub', 'sup', 'del', 's', 'strike', 'mark', 'u'
|
|
5
|
+
])
|
|
6
|
+
|
|
7
|
+
const processInlineNode = (node, style, currentInline, traverse) => {
|
|
8
|
+
if (node.type === 'text') {
|
|
9
|
+
let textVal = decodeHtmlEntities(node.value)
|
|
10
|
+
if (!style.isCode) textVal = textVal.replace(/\s+/g, ' ')
|
|
11
|
+
if (textVal === ' ' || textVal === '') {
|
|
12
|
+
if (textVal === ' ' && currentInline.length > 0) {
|
|
13
|
+
const lastToken = currentInline[currentInline.length - 1]
|
|
14
|
+
if (lastToken.type !== 'text' || !lastToken.value.endsWith(' ')) {
|
|
15
|
+
currentInline.push({ type: 'text', value: ' ' })
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let token = { type: 'text', value: textVal }
|
|
22
|
+
if (style.fontStyle === 'italic') token = { type: 'italic', children: [token] }
|
|
23
|
+
if (style.fontWeight === 'bold') token = { type: 'bold', children: [token] }
|
|
24
|
+
if (style.isCode) token = { type: 'code', value: textVal }
|
|
25
|
+
|
|
26
|
+
currentInline.push(token)
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (node.type === 'element') {
|
|
31
|
+
const tagName = node.tagName
|
|
32
|
+
const attrs = node.attributes || {}
|
|
33
|
+
const inlineStyles = parseStyleAttr(attrs.style)
|
|
34
|
+
const nextStyle = { ...style }
|
|
35
|
+
|
|
36
|
+
if (tagName === 'b' || tagName === 'strong') nextStyle.fontWeight = 'bold'
|
|
37
|
+
if (tagName === 'i' || tagName === 'em') nextStyle.fontStyle = 'italic'
|
|
38
|
+
if (tagName === 'code' || tagName === 'tt') nextStyle.isCode = true
|
|
39
|
+
if (inlineStyles['font-weight'] === 'bold' || parseInt(inlineStyles['font-weight']) >= 700) nextStyle.fontWeight = 'bold'
|
|
40
|
+
if (inlineStyles['font-style'] === 'italic') nextStyle.fontStyle = 'italic'
|
|
41
|
+
|
|
42
|
+
if (tagName === 'br') {
|
|
43
|
+
currentInline.push({ type: 'br' })
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
if (tagName === 'img') {
|
|
47
|
+
if (attrs.src) currentInline.push({ type: 'image', url: attrs.src, alt: attrs.alt || '' })
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
if (tagName === 'a') {
|
|
51
|
+
const childTokens = []
|
|
52
|
+
for (const child of node.children) traverse(child, nextStyle, childTokens)
|
|
53
|
+
if (childTokens.length > 0) currentInline.push({ type: 'link', url: attrs.href || '', children: childTokens })
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (INLINE_TAGS.has(tagName)) {
|
|
58
|
+
for (const child of node.children) traverse(child, nextStyle, currentInline)
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { processInlineNode }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { VOID_TAGS, parseAttributes } = require('./utils')
|
|
2
|
+
|
|
3
|
+
const parseHtml = (rawHtml) => {
|
|
4
|
+
let html = (rawHtml || '')
|
|
5
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
6
|
+
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '')
|
|
7
|
+
|
|
8
|
+
const root = { type: 'element', tagName: 'root', attributes: {}, children: [] }
|
|
9
|
+
const stack = [root]
|
|
10
|
+
let index = 0
|
|
11
|
+
|
|
12
|
+
while (index < html.length) {
|
|
13
|
+
const nextTag = html.indexOf('<', index)
|
|
14
|
+
|
|
15
|
+
if (nextTag === -1 || nextTag > index) {
|
|
16
|
+
const textVal = nextTag === -1 ? html.slice(index) : html.slice(index, nextTag)
|
|
17
|
+
if (textVal) {
|
|
18
|
+
stack[stack.length - 1].children.push({ type: 'text', value: textVal })
|
|
19
|
+
}
|
|
20
|
+
if (nextTag === -1) break
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const closeTag = html.indexOf('>', nextTag)
|
|
24
|
+
if (closeTag === -1) break
|
|
25
|
+
|
|
26
|
+
const tagContent = html.slice(nextTag + 1, closeTag).trim()
|
|
27
|
+
index = closeTag + 1
|
|
28
|
+
|
|
29
|
+
if (tagContent.startsWith('/')) {
|
|
30
|
+
const tagName = tagContent.slice(1).trim().toLowerCase()
|
|
31
|
+
const matchIdx = stack.map(node => node.tagName).lastIndexOf(tagName)
|
|
32
|
+
if (matchIdx > 0) {
|
|
33
|
+
stack.length = matchIdx
|
|
34
|
+
}
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isSelfClosing = tagContent.endsWith('/')
|
|
39
|
+
const cleanedContent = isSelfClosing ? tagContent.slice(0, -1).trim() : tagContent
|
|
40
|
+
|
|
41
|
+
const nameMatch = cleanedContent.match(/^([a-zA-Z0-9_\-]+)/)
|
|
42
|
+
if (!nameMatch) continue
|
|
43
|
+
const tagName = nameMatch[1].toLowerCase()
|
|
44
|
+
|
|
45
|
+
const attrString = cleanedContent.slice(tagName.length).trim()
|
|
46
|
+
const attributes = parseAttributes(attrString)
|
|
47
|
+
|
|
48
|
+
const element = { type: 'element', tagName, attributes, children: [] }
|
|
49
|
+
stack[stack.length - 1].children.push(element)
|
|
50
|
+
|
|
51
|
+
if (!isSelfClosing && !VOID_TAGS.has(tagName)) {
|
|
52
|
+
stack.push(element)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return root
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { parseHtml }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const VOID_TAGS = new Set([
|
|
2
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'
|
|
3
|
+
])
|
|
4
|
+
|
|
5
|
+
const parseAttributes = (attrStr) => {
|
|
6
|
+
const attrs = {}
|
|
7
|
+
if (!attrStr) return attrs
|
|
8
|
+
const matches = attrStr.matchAll(/([a-zA-Z0-9_\-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)
|
|
9
|
+
for (const match of matches) {
|
|
10
|
+
const key = match[1].toLowerCase()
|
|
11
|
+
const val = match[2] !== undefined ? match[2] : (match[3] !== undefined ? match[3] : (match[4] !== undefined ? match[4] : ''))
|
|
12
|
+
attrs[key] = val
|
|
13
|
+
}
|
|
14
|
+
return attrs
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const parseStyleAttr = (styleStr) => {
|
|
18
|
+
if (!styleStr) return {}
|
|
19
|
+
const styles = {}
|
|
20
|
+
styleStr.split(';').forEach(decl => {
|
|
21
|
+
const parts = decl.split(':')
|
|
22
|
+
if (parts.length === 2) {
|
|
23
|
+
styles[parts[0].trim().toLowerCase()] = parts[1].trim().toLowerCase()
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
return styles
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const decodeHtmlEntities = (str) => {
|
|
30
|
+
if (!str) return ''
|
|
31
|
+
const entities = {
|
|
32
|
+
nbsp: ' ', zwnj: '', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'",
|
|
33
|
+
ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', ndash: '–', mdash: '—',
|
|
34
|
+
}
|
|
35
|
+
return str
|
|
36
|
+
.replace(/&([a-z0-9]+);/gi, (match, name) => {
|
|
37
|
+
const lower = name.toLowerCase()
|
|
38
|
+
return entities[lower] !== undefined ? entities[lower] : match
|
|
39
|
+
})
|
|
40
|
+
.replace(/&#([0-9]+);/g, (match, dec) => String.fromCharCode(parseInt(dec, 10)))
|
|
41
|
+
.replace(/&#x([0-9a-f]+);/gi, (match, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isCellEmpty = (cell) => {
|
|
45
|
+
if (!cell || cell.length === 0) return true
|
|
46
|
+
return cell.every(node => {
|
|
47
|
+
if (node.type === 'text') return !node.value.trim()
|
|
48
|
+
if (node.type === 'br') return true
|
|
49
|
+
return false
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const optimizeTable = (rows, alignments) => {
|
|
54
|
+
const nonDescantRows = rows.filter(row => row.some(cell => !isCellEmpty(cell)))
|
|
55
|
+
if (nonDescantRows.length === 0) return null
|
|
56
|
+
|
|
57
|
+
const numCols = Math.max(...nonDescantRows.map(r => r.length))
|
|
58
|
+
const emptyCols = new Set()
|
|
59
|
+
for (let c = 0; c < numCols; c++) {
|
|
60
|
+
let colIsEmpty = true
|
|
61
|
+
for (const row of nonDescantRows) {
|
|
62
|
+
if (row[c] && !isCellEmpty(row[c])) {
|
|
63
|
+
colIsEmpty = false
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (colIsEmpty) emptyCols.add(c)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const cleanRows = nonDescantRows.map(row => {
|
|
71
|
+
const newRow = []
|
|
72
|
+
for (let c = 0; c < numCols; c++) {
|
|
73
|
+
if (!emptyCols.has(c)) newRow.push(row[c] || [])
|
|
74
|
+
}
|
|
75
|
+
return newRow
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const cleanAlignments = []
|
|
79
|
+
for (let c = 0; c < numCols; c++) {
|
|
80
|
+
if (!emptyCols.has(c)) cleanAlignments.push(alignments[c] || 'default')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { rows: cleanRows, alignments: cleanAlignments }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
VOID_TAGS,
|
|
88
|
+
parseAttributes,
|
|
89
|
+
parseStyleAttr,
|
|
90
|
+
decodeHtmlEntities,
|
|
91
|
+
isCellEmpty,
|
|
92
|
+
optimizeTable,
|
|
93
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const { escapeHtml } = require('../escape')
|
|
2
|
+
|
|
3
|
+
const BASH_KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'in', 'do', 'done', 'case', 'esac', 'function', 'return', 'exit'])
|
|
4
|
+
|
|
5
|
+
const highlightBash = (code) => {
|
|
6
|
+
const bashRegex = /(#.*$)|("(?:\\.|[^\\])*?"|'(?:\\.|[^\\])*?')|(\$[a-zA-Z0-9_]+|\$\{[^}]+\})|\b([a-zA-Z0-9_\-]+)\b/gm
|
|
7
|
+
|
|
8
|
+
let result = ''
|
|
9
|
+
let lastIndex = 0
|
|
10
|
+
let match
|
|
11
|
+
|
|
12
|
+
while ((match = bashRegex.exec(code)) !== null) {
|
|
13
|
+
if (match.index > lastIndex) {
|
|
14
|
+
result += escapeHtml(code.slice(lastIndex, match.index))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const [raw, cmt, strVal, varVal, wordVal] = match
|
|
18
|
+
|
|
19
|
+
if (cmt) {
|
|
20
|
+
result += `<span class="hl-cmt">${escapeHtml(cmt)}</span>`
|
|
21
|
+
} else if (strVal) {
|
|
22
|
+
result += `<span class="hl-str">${escapeHtml(strVal)}</span>`
|
|
23
|
+
} else if (varVal) {
|
|
24
|
+
result += `<span class="hl-var">${escapeHtml(varVal)}</span>`
|
|
25
|
+
} else if (wordVal) {
|
|
26
|
+
if (BASH_KEYWORDS.has(wordVal)) {
|
|
27
|
+
result += `<span class="hl-kw">${escapeHtml(wordVal)}</span>`
|
|
28
|
+
} else {
|
|
29
|
+
result += escapeHtml(wordVal)
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
result += escapeHtml(raw)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
lastIndex = bashRegex.lastIndex
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (lastIndex < code.length) {
|
|
39
|
+
result += escapeHtml(code.slice(lastIndex))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return result
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { highlightBash }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const { escapeHtml } = require('../escape')
|
|
2
|
+
|
|
3
|
+
const highlightHtml = (code) => {
|
|
4
|
+
const htmlRegex = /(<!--[\s\S]*?-->)|(<\/?[a-zA-Z0-9\-]+)|([a-zA-Z0-9\-]+)=("[^"]*"|'[^']*')|(\/?>)/g
|
|
5
|
+
|
|
6
|
+
let result = ''
|
|
7
|
+
let lastIndex = 0
|
|
8
|
+
let match
|
|
9
|
+
|
|
10
|
+
while ((match = htmlRegex.exec(code)) !== null) {
|
|
11
|
+
if (match.index > lastIndex) {
|
|
12
|
+
result += escapeHtml(code.slice(lastIndex, match.index))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const [raw, cmt, tagStart, attrName, attrVal, tagEnd] = match
|
|
16
|
+
|
|
17
|
+
if (cmt) {
|
|
18
|
+
result += `<span class="hl-cmt">${escapeHtml(cmt)}</span>`
|
|
19
|
+
} else if (tagStart) {
|
|
20
|
+
result += `<span class="hl-tag">${escapeHtml(tagStart)}</span>`
|
|
21
|
+
} else if (attrName && attrVal) {
|
|
22
|
+
result += `<span class="hl-attr">${escapeHtml(attrName)}</span>=<span class="hl-str">${escapeHtml(attrVal)}</span>`
|
|
23
|
+
} else if (tagEnd) {
|
|
24
|
+
result += `<span class="hl-tag">${escapeHtml(tagEnd)}</span>`
|
|
25
|
+
} else {
|
|
26
|
+
result += escapeHtml(raw)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
lastIndex = htmlRegex.lastIndex
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (lastIndex < code.length) {
|
|
33
|
+
result += escapeHtml(code.slice(lastIndex))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return result
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { highlightHtml }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const { highlightJs } = require('./js')
|
|
2
|
+
const { highlightJson } = require('./json')
|
|
3
|
+
const { highlightYaml } = require('./yaml')
|
|
4
|
+
const { highlightBash } = require('./bash')
|
|
5
|
+
const { highlightHtml } = require('./html')
|
|
6
|
+
const { highlightSql } = require('./sql')
|
|
7
|
+
const { escapeHtml } = require('../escape')
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TOKENIZERS = {
|
|
10
|
+
js: highlightJs,
|
|
11
|
+
javascript: highlightJs,
|
|
12
|
+
json: highlightJson,
|
|
13
|
+
yaml: highlightYaml,
|
|
14
|
+
yml: highlightYaml,
|
|
15
|
+
bash: highlightBash,
|
|
16
|
+
sh: highlightBash,
|
|
17
|
+
zsh: highlightBash,
|
|
18
|
+
html: highlightHtml,
|
|
19
|
+
xml: highlightHtml,
|
|
20
|
+
sql: highlightSql,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const highlightCode = (code, lang, customTokenizers) => {
|
|
24
|
+
const tokenizers = { ...DEFAULT_TOKENIZERS, ...customTokenizers }
|
|
25
|
+
const normalizedLang = (lang || '').toLowerCase()
|
|
26
|
+
const tokenizer = tokenizers[normalizedLang]
|
|
27
|
+
if (tokenizer) {
|
|
28
|
+
return tokenizer(code)
|
|
29
|
+
}
|
|
30
|
+
return escapeHtml(code)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { DEFAULT_TOKENIZERS, highlightCode }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const { escapeHtml } = require('../escape')
|
|
2
|
+
|
|
3
|
+
const JS_KEYWORDS = new Set([
|
|
4
|
+
'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'do',
|
|
5
|
+
'switch', 'case', 'break', 'continue', 'default', 'import', 'export', 'from',
|
|
6
|
+
'as', 'class', 'extends', 'super', 'this', 'new', 'try', 'catch', 'finally',
|
|
7
|
+
'throw', 'async', 'await', 'yield', 'typeof', 'instanceof', 'in', 'of', 'null',
|
|
8
|
+
'undefined', 'true', 'false', 'void', 'delete'
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
const highlightJs = (code) => {
|
|
12
|
+
const tokenRegex = /(\/\/.*$|\/\*[\s\S]*?\*\/)|("(?:\\[\s\S]|[^"\\])*?"|'(?:\\[\s\S]|[^'\\])*?'|`(?:\\[\s\S]|[^`\\])*?`)|(\b[0-9]+(?:\.[0-9]+)?\b)|(\b[a-zA-Z_$][a-zA-Z0-9_$]*\b)|([{}()\[\];,.:+=\-*\/%&|^!~?<>]+)/gm
|
|
13
|
+
|
|
14
|
+
let result = ''
|
|
15
|
+
let lastIndex = 0
|
|
16
|
+
let match
|
|
17
|
+
|
|
18
|
+
while ((match = tokenRegex.exec(code)) !== null) {
|
|
19
|
+
if (match.index > lastIndex) {
|
|
20
|
+
result += escapeHtml(code.slice(lastIndex, match.index))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const [raw, comment, stringLiteral, numberLiteral, identifier, punctuation] = match
|
|
24
|
+
|
|
25
|
+
if (comment) {
|
|
26
|
+
result += `<span class="hl-cmt">${escapeHtml(comment)}</span>`
|
|
27
|
+
} else if (stringLiteral) {
|
|
28
|
+
result += `<span class="hl-str">${escapeHtml(stringLiteral)}</span>`
|
|
29
|
+
} else if (numberLiteral) {
|
|
30
|
+
result += `<span class="hl-num">${escapeHtml(numberLiteral)}</span>`
|
|
31
|
+
} else if (identifier) {
|
|
32
|
+
if (JS_KEYWORDS.has(identifier)) {
|
|
33
|
+
result += `<span class="hl-kw">${escapeHtml(identifier)}</span>`
|
|
34
|
+
} else {
|
|
35
|
+
result += `<span class="hl-id">${escapeHtml(identifier)}</span>`
|
|
36
|
+
}
|
|
37
|
+
} else if (punctuation) {
|
|
38
|
+
result += `<span class="hl-punc">${escapeHtml(punctuation)}</span>`
|
|
39
|
+
} else {
|
|
40
|
+
result += escapeHtml(raw)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
lastIndex = tokenRegex.lastIndex
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (lastIndex < code.length) {
|
|
47
|
+
result += escapeHtml(code.slice(lastIndex))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { highlightJs }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const { escapeHtml } = require('../escape')
|
|
2
|
+
|
|
3
|
+
const highlightJson = (code) => {
|
|
4
|
+
const jsonRegex = /("(?:\\.|[^\\])*?")\s*(:)|("(?:\\.|[^\\])*?")|\b(true|false|null)\b|\b(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b|([{}()\[\],])/g
|
|
5
|
+
|
|
6
|
+
let result = ''
|
|
7
|
+
let lastIndex = 0
|
|
8
|
+
let match
|
|
9
|
+
|
|
10
|
+
while ((match = jsonRegex.exec(code)) !== null) {
|
|
11
|
+
if (match.index > lastIndex) {
|
|
12
|
+
result += escapeHtml(code.slice(lastIndex, match.index))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const [raw, keyStr, colon, valStr, boolNull, num, punc] = match
|
|
16
|
+
|
|
17
|
+
if (keyStr && colon) {
|
|
18
|
+
result += `<span class="hl-key">${escapeHtml(keyStr)}</span>` + `<span class="hl-punc">${escapeHtml(colon)}</span>`
|
|
19
|
+
} else if (valStr) {
|
|
20
|
+
result += `<span class="hl-str">${escapeHtml(valStr)}</span>`
|
|
21
|
+
} else if (boolNull) {
|
|
22
|
+
result += `<span class="hl-kw">${escapeHtml(boolNull)}</span>`
|
|
23
|
+
} else if (num) {
|
|
24
|
+
result += `<span class="hl-num">${escapeHtml(num)}</span>`
|
|
25
|
+
} else if (punc) {
|
|
26
|
+
result += `<span class="hl-punc">${escapeHtml(punc)}</span>`
|
|
27
|
+
} else {
|
|
28
|
+
result += escapeHtml(raw)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
lastIndex = jsonRegex.lastIndex
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (lastIndex < code.length) {
|
|
35
|
+
result += escapeHtml(code.slice(lastIndex))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return result
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { highlightJson }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const { escapeHtml } = require('../escape')
|
|
2
|
+
|
|
3
|
+
const SQL_KEYWORDS = new Set([
|
|
4
|
+
'SELECT', 'FROM', 'WHERE', 'INSERT', 'INTO', 'UPDATE', 'DELETE', 'CREATE', 'TABLE',
|
|
5
|
+
'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER',
|
|
6
|
+
'ON', 'GROUP', 'BY', 'ORDER', 'HAVING', 'LIMIT', 'OFFSET', 'UNION', 'ALL', 'AS', 'AND',
|
|
7
|
+
'OR', 'NOT', 'IN', 'IS', 'NULL', 'LIKE', 'EXISTS', 'DEFAULT', 'VARCHAR', 'INTEGER', 'INT',
|
|
8
|
+
'TEXT', 'BOOLEAN', 'TIMESTAMP', 'NOT', 'NULL', 'CAST'
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
const highlightSql = (code) => {
|
|
12
|
+
const sqlRegex = /(--.*$|\/\*[\s\S]*?\*\/)|("?:?[a-zA-Z0-9_\-]+"?)|('(?:\\.|[^\'])*?')|\b([0-9]+(?:\.[0-9]+)?)\b|([(),.;=+*\/%<>!]+)/gm
|
|
13
|
+
|
|
14
|
+
let result = ''
|
|
15
|
+
let lastIndex = 0
|
|
16
|
+
let match
|
|
17
|
+
|
|
18
|
+
while ((match = sqlRegex.exec(code)) !== null) {
|
|
19
|
+
if (match.index > lastIndex) {
|
|
20
|
+
result += escapeHtml(code.slice(lastIndex, match.index))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const [raw, cmt, wordVal, strVal, numVal, puncVal] = match
|
|
24
|
+
|
|
25
|
+
if (cmt) {
|
|
26
|
+
result += `<span class="hl-cmt">${escapeHtml(cmt)}</span>`
|
|
27
|
+
} else if (strVal) {
|
|
28
|
+
result += `<span class="hl-str">${escapeHtml(strVal)}</span>`
|
|
29
|
+
} else if (wordVal) {
|
|
30
|
+
const upper = wordVal.toUpperCase()
|
|
31
|
+
if (SQL_KEYWORDS.has(upper)) {
|
|
32
|
+
result += `<span class="hl-kw">${escapeHtml(wordVal)}</span>`
|
|
33
|
+
} else {
|
|
34
|
+
result += escapeHtml(wordVal)
|
|
35
|
+
}
|
|
36
|
+
} else if (numVal) {
|
|
37
|
+
result += `<span class="hl-num">${escapeHtml(numVal)}</span>`
|
|
38
|
+
} else if (puncVal) {
|
|
39
|
+
result += `<span class="hl-punc">${escapeHtml(puncVal)}</span>`
|
|
40
|
+
} else {
|
|
41
|
+
result += escapeHtml(raw)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
lastIndex = sqlRegex.lastIndex
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (lastIndex < code.length) {
|
|
48
|
+
result += escapeHtml(code.slice(lastIndex))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { highlightSql }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const { escapeHtml } = require('../escape')
|
|
2
|
+
|
|
3
|
+
const highlightYaml = (code) => {
|
|
4
|
+
const yamlRegex = /(#.*$)|^([ \t]*[a-zA-Z0-9_\-]+)(:)|("(?:\\.|[^\\])*?"|'(?:\\.|[^\\])*?')|\b(true|false|null)\b|\b([0-9]+(?:\.[0-9]+)?)\b|([:\-\[\]{}])/gm
|
|
5
|
+
|
|
6
|
+
let result = ''
|
|
7
|
+
let lastIndex = 0
|
|
8
|
+
let match
|
|
9
|
+
|
|
10
|
+
while ((match = yamlRegex.exec(code)) !== null) {
|
|
11
|
+
if (match.index > lastIndex) {
|
|
12
|
+
result += escapeHtml(code.slice(lastIndex, match.index))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const [raw, cmt, keyName, colon, strVal, kwVal, numVal, puncVal] = match
|
|
16
|
+
|
|
17
|
+
if (cmt) {
|
|
18
|
+
result += `<span class="hl-cmt">${escapeHtml(cmt)}</span>`
|
|
19
|
+
} else if (keyName) {
|
|
20
|
+
result += `<span class="hl-key">${escapeHtml(keyName)}</span>` + (colon ? `<span class="hl-punc">${escapeHtml(colon)}</span>` : '')
|
|
21
|
+
} else if (strVal) {
|
|
22
|
+
result += `<span class="hl-str">${escapeHtml(strVal)}</span>`
|
|
23
|
+
} else if (kwVal) {
|
|
24
|
+
result += `<span class="hl-kw">${escapeHtml(kwVal)}</span>`
|
|
25
|
+
} else if (numVal) {
|
|
26
|
+
result += `<span class="hl-num">${escapeHtml(numVal)}</span>`
|
|
27
|
+
} else if (puncVal) {
|
|
28
|
+
result += `<span class="hl-punc">${escapeHtml(puncVal)}</span>`
|
|
29
|
+
} else {
|
|
30
|
+
result += escapeHtml(raw)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
lastIndex = yamlRegex.lastIndex
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (lastIndex < code.length) {
|
|
37
|
+
result += escapeHtml(code.slice(lastIndex))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return result
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { highlightYaml }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const { parse } = require('@sullux/markdown-compiler')
|
|
2
|
+
const { renderBlock } = require('./render-block')
|
|
3
|
+
|
|
4
|
+
const markdownToHtml = (markdown, options = {}) => {
|
|
5
|
+
if (!markdown) return ''
|
|
6
|
+
const ast = parse(markdown)
|
|
7
|
+
const ctx = { ...options, usedSlugs: new Set() }
|
|
8
|
+
return ast.blocks.map((block) => renderBlock(block, ctx)).join('')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = { markdownToHtml }
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const { escapeHtml } = require('./escape')
|
|
2
|
+
const { slugify } = require('./slugify')
|
|
3
|
+
const { renderInline } = require('./render-inline')
|
|
4
|
+
const { highlightCode } = require('./highlight')
|
|
5
|
+
|
|
6
|
+
const renderBlock = (node, options = {}) => {
|
|
7
|
+
if (!node) return ''
|
|
8
|
+
|
|
9
|
+
switch (node.type) {
|
|
10
|
+
case 'header': {
|
|
11
|
+
const text = renderInline(node.children)
|
|
12
|
+
const rawText = node.children ? node.children.map((c) => (c.type === 'text' ? c.value : '')).join('') : ''
|
|
13
|
+
const slug = (options.slugify || slugify)(rawText, options.usedSlugs)
|
|
14
|
+
return `<h${node.level} id="${slug}">${text}</h${node.level}>\n`
|
|
15
|
+
}
|
|
16
|
+
case 'paragraph': {
|
|
17
|
+
return `<p>${renderInline(node.children)}</p>\n`
|
|
18
|
+
}
|
|
19
|
+
case 'codeBlock': {
|
|
20
|
+
const lang = node.language || ''
|
|
21
|
+
if (options.codeRenderers && lang && options.codeRenderers[lang]) {
|
|
22
|
+
return options.codeRenderers[lang](node, options)
|
|
23
|
+
}
|
|
24
|
+
const highlighted = highlightCode(node.value, lang, options.tokenizers)
|
|
25
|
+
const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : ''
|
|
26
|
+
return `<pre><code${langClass}>${highlighted}</code></pre>\n`
|
|
27
|
+
}
|
|
28
|
+
case 'blockquote': {
|
|
29
|
+
const innerHtml = node.children ? node.children.map((child) => renderBlock(child, options)).join('') : ''
|
|
30
|
+
return `<blockquote>\n${innerHtml}</blockquote>\n`
|
|
31
|
+
}
|
|
32
|
+
case 'callout': {
|
|
33
|
+
const style = node.style || 'note'
|
|
34
|
+
const title = style.charAt(0).toUpperCase() + style.slice(1)
|
|
35
|
+
const innerHtml = node.children ? node.children.map((child) => renderBlock(child, options)).join('') : ''
|
|
36
|
+
return `<div class="callout callout-${style}">\n<div class="callout-title">${title}</div>\n${innerHtml}</div>\n`
|
|
37
|
+
}
|
|
38
|
+
case 'bulletList': {
|
|
39
|
+
const itemsHtml = node.items ? node.items.map((item) => `<li>${renderInline(item)}</li>\n`).join('') : ''
|
|
40
|
+
return `<ul>\n${itemsHtml}</ul>\n`
|
|
41
|
+
}
|
|
42
|
+
case 'orderedList': {
|
|
43
|
+
const itemsHtml = node.items ? node.items.map((item) => `<li>${renderInline(item)}</li>\n`).join('') : ''
|
|
44
|
+
return `<ol>\n${itemsHtml}</ol>\n`
|
|
45
|
+
}
|
|
46
|
+
case 'table': {
|
|
47
|
+
const alignments = node.alignments || []
|
|
48
|
+
const rows = node.rows || []
|
|
49
|
+
if (rows.length === 0) return ''
|
|
50
|
+
|
|
51
|
+
const headerRow = rows[0]
|
|
52
|
+
const bodyRows = rows.slice(1)
|
|
53
|
+
|
|
54
|
+
const renderRow = (row, isHeader) => {
|
|
55
|
+
const cellTag = isHeader ? 'th' : 'td'
|
|
56
|
+
const cells = row
|
|
57
|
+
.map((cell, colIdx) => {
|
|
58
|
+
const align = alignments[colIdx] || 'default'
|
|
59
|
+
const alignAttr = align !== 'default' ? ` align="${align}"` : ''
|
|
60
|
+
return `<${cellTag}${alignAttr}>${renderInline(cell)}</${cellTag}>`
|
|
61
|
+
})
|
|
62
|
+
.join('')
|
|
63
|
+
return `<tr>${cells}</tr>\n`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let tableHtml = '<table>\n<thead>\n' + renderRow(headerRow, true) + '</thead>\n'
|
|
67
|
+
if (bodyRows.length > 0) {
|
|
68
|
+
tableHtml += '<tbody>\n' + bodyRows.map((r) => renderRow(r, false)).join('') + '</tbody>\n'
|
|
69
|
+
}
|
|
70
|
+
tableHtml += '</table>\n'
|
|
71
|
+
return tableHtml
|
|
72
|
+
}
|
|
73
|
+
case 'hr': {
|
|
74
|
+
return '<hr />\n'
|
|
75
|
+
}
|
|
76
|
+
default: {
|
|
77
|
+
return ''
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { renderBlock }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const { escapeHtml } = require('./escape')
|
|
2
|
+
|
|
3
|
+
const renderInline = (tokens) => {
|
|
4
|
+
if (!tokens) return ''
|
|
5
|
+
if (!Array.isArray(tokens)) return escapeHtml(tokens)
|
|
6
|
+
|
|
7
|
+
return tokens
|
|
8
|
+
.map((token) => {
|
|
9
|
+
if (!token) return ''
|
|
10
|
+
if (typeof token === 'string') return escapeHtml(token)
|
|
11
|
+
|
|
12
|
+
switch (token.type) {
|
|
13
|
+
case 'text':
|
|
14
|
+
return escapeHtml(token.value)
|
|
15
|
+
case 'bold':
|
|
16
|
+
return `<strong>${renderInline(token.children)}</strong>`
|
|
17
|
+
case 'italic':
|
|
18
|
+
return `<em>${renderInline(token.children)}</em>`
|
|
19
|
+
case 'strikethrough':
|
|
20
|
+
return `<del>${renderInline(token.children)}</del>`
|
|
21
|
+
case 'code':
|
|
22
|
+
return `<code>${escapeHtml(token.value)}</code>`
|
|
23
|
+
case 'link':
|
|
24
|
+
return `<a href="${escapeHtml(token.url)}">${renderInline(token.children)}</a>`
|
|
25
|
+
case 'wikilink':
|
|
26
|
+
return `<a href="${escapeHtml(token.target)}">${escapeHtml(token.display)}</a>`
|
|
27
|
+
case 'image': {
|
|
28
|
+
let styleAttr = ''
|
|
29
|
+
if (token.width || token.height) {
|
|
30
|
+
const styles = []
|
|
31
|
+
if (token.width) styles.push(`width: ${token.width}`)
|
|
32
|
+
if (token.height) styles.push(`height: ${token.height}`)
|
|
33
|
+
styleAttr = ` style="${styles.join('; ')};"`
|
|
34
|
+
}
|
|
35
|
+
return `<img src="${escapeHtml(token.url)}" alt="${escapeHtml(token.alt)}"${styleAttr} />`
|
|
36
|
+
}
|
|
37
|
+
case 'checkbox':
|
|
38
|
+
return `<input type="checkbox"${token.checked ? ' checked' : ''} disabled /> `
|
|
39
|
+
case 'br':
|
|
40
|
+
return '<br />'
|
|
41
|
+
default:
|
|
42
|
+
return escapeHtml(token.value || '')
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
.join('')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { renderInline }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const slugify = (str, usedSlugs = new Set()) => {
|
|
2
|
+
let base = String(str || '')
|
|
3
|
+
.toLowerCase()
|
|
4
|
+
.replace(/<[^>]+>/g, '')
|
|
5
|
+
.replace(/[^\w\s-]/g, '')
|
|
6
|
+
.trim()
|
|
7
|
+
.replace(/\s+/g, '-')
|
|
8
|
+
|
|
9
|
+
if (!base) base = 'section'
|
|
10
|
+
|
|
11
|
+
let slug = base
|
|
12
|
+
let counter = 1
|
|
13
|
+
while (usedSlugs.has(slug)) {
|
|
14
|
+
slug = `${base}-${counter}`
|
|
15
|
+
counter++
|
|
16
|
+
}
|
|
17
|
+
usedSlugs.add(slug)
|
|
18
|
+
return slug
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { slugify }
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sullux/markdown-html",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A high-performance, bidirectional Markdown <-> HTML compiler supporting GFM, GitBook hints, syntax highlighting, and custom image dimensions.",
|
|
5
|
+
"main": "./index.js",
|
|
6
|
+
"author": "Charles Sullivan <charles@sullux.com>",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/Sullux/markdown.git",
|
|
11
|
+
"directory": "packages/markdown-html"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Sullux/markdown/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/Sullux/markdown/tree/main/packages/markdown-html#readme",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"markdown",
|
|
19
|
+
"html",
|
|
20
|
+
"compiler",
|
|
21
|
+
"converter",
|
|
22
|
+
"gfm",
|
|
23
|
+
"syntax-highlighting",
|
|
24
|
+
"bidirectional"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18.0.0"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@sullux/markdown-compiler": "^1.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|