@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 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
+ ![Diagram|400x200](schema.png)
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">&quot;Sullux&quot;</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,10 @@
1
+ const { markdownToHtml } = require('./lib/markdown-to-html')
2
+ const { htmlToMarkdown, toMarkdown, parseHtml, toAst } = require('./lib/html-to-markdown')
3
+
4
+ module.exports = {
5
+ markdownToHtml,
6
+ htmlToMarkdown,
7
+ toMarkdown,
8
+ parseHtml,
9
+ toAst,
10
+ }
@@ -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,11 @@
1
+ const escapeHtml = (str) => {
2
+ if (!str) return ''
3
+ return String(str)
4
+ .replace(/&/g, '&amp;')
5
+ .replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;')
7
+ .replace(/"/g, '&quot;')
8
+ .replace(/'/g, '&#39;')
9
+ }
10
+
11
+ module.exports = { escapeHtml }
@@ -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
+ }