svmarkdown 0.1.0 → 0.1.1
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/package.json +9 -3
- package/src/Markdown.svelte +56 -0
- package/src/SvmdChildren.svelte +14 -0
- package/src/index.ts +21 -0
- package/src/internal/RenderNode.svelte +116 -0
- package/src/internal/RenderNodes.svelte +16 -0
- package/src/parser.ts +626 -0
- package/src/types.ts +100 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svmarkdown",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.1",
|
|
5
5
|
"description": "Runtime markdown renderer for Svelte based on markdown-it and AST blocks.",
|
|
6
6
|
"author": "grtsinry43",
|
|
7
7
|
"license": "MIT",
|
|
@@ -14,14 +14,20 @@
|
|
|
14
14
|
"url": "https://github.com/grtsinry43/svmarkdown/issues"
|
|
15
15
|
},
|
|
16
16
|
"exports": {
|
|
17
|
-
".":
|
|
17
|
+
".": {
|
|
18
|
+
"svelte": "./src/index.ts",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
18
22
|
"./package.json": "./package.json"
|
|
19
23
|
},
|
|
20
24
|
"main": "./dist/index.js",
|
|
21
25
|
"module": "./dist/index.js",
|
|
22
26
|
"types": "./dist/index.d.ts",
|
|
27
|
+
"svelte": "./src/index.ts",
|
|
23
28
|
"files": [
|
|
24
|
-
"dist"
|
|
29
|
+
"dist",
|
|
30
|
+
"src"
|
|
25
31
|
],
|
|
26
32
|
"publishConfig": {
|
|
27
33
|
"access": "public"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { createParser, inferComponentBlocksFromMap } from './parser'
|
|
3
|
+
import RenderNodes from './internal/RenderNodes.svelte'
|
|
4
|
+
import type {
|
|
5
|
+
SvmdComponentMap,
|
|
6
|
+
SvmdComponentBlocks,
|
|
7
|
+
SvmdParseOptions,
|
|
8
|
+
SvmdRenderOptions,
|
|
9
|
+
SvmdRoot,
|
|
10
|
+
} from './types'
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
content: string
|
|
14
|
+
components?: SvmdComponentMap
|
|
15
|
+
parseOptions?: SvmdParseOptions
|
|
16
|
+
renderOptions?: SvmdRenderOptions
|
|
17
|
+
inferComponentBlocks?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
content,
|
|
22
|
+
components = {},
|
|
23
|
+
parseOptions = {},
|
|
24
|
+
renderOptions,
|
|
25
|
+
inferComponentBlocks = true,
|
|
26
|
+
}: Props = $props()
|
|
27
|
+
|
|
28
|
+
const effectiveParseOptions = $derived(resolveParseOptions(parseOptions, components, inferComponentBlocks))
|
|
29
|
+
const parser = $derived(createParser(effectiveParseOptions))
|
|
30
|
+
const ast: SvmdRoot = $derived(parser(content))
|
|
31
|
+
|
|
32
|
+
function resolveParseOptions(
|
|
33
|
+
options: SvmdParseOptions,
|
|
34
|
+
componentMap: SvmdComponentMap,
|
|
35
|
+
shouldInferComponentBlocks: boolean,
|
|
36
|
+
): SvmdParseOptions {
|
|
37
|
+
if (!shouldInferComponentBlocks) {
|
|
38
|
+
return options
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const inferredBlocks = inferComponentBlocksFromMap(componentMap)
|
|
42
|
+
const mergedBlocks: SvmdComponentBlocks = {
|
|
43
|
+
...inferredBlocks,
|
|
44
|
+
...(options.componentBlocks ?? {}),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
...options,
|
|
49
|
+
componentBlocks: mergedBlocks,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
{#if ast.children.length > 0}
|
|
55
|
+
<RenderNodes nodes={ast.children} {components} {renderOptions} />
|
|
56
|
+
{/if}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import RenderNodes from './internal/RenderNodes.svelte'
|
|
3
|
+
import type { SvmdComponentMap, SvmdNode, SvmdRenderOptions } from './types'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
nodes: SvmdNode[]
|
|
7
|
+
components?: SvmdComponentMap
|
|
8
|
+
renderOptions?: SvmdRenderOptions
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let { nodes, components = {}, renderOptions }: Props = $props()
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<RenderNodes {nodes} {components} {renderOptions} />
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { default as Markdown } from './Markdown.svelte'
|
|
2
|
+
export { default as SvmdChildren } from './SvmdChildren.svelte'
|
|
3
|
+
export { createParser, inferComponentBlocksFromMap, parseMarkdown } from './parser'
|
|
4
|
+
export type {
|
|
5
|
+
SvmdBreakNode,
|
|
6
|
+
SvmdCodeNode,
|
|
7
|
+
SvmdComponent,
|
|
8
|
+
SvmdComponentBlockConfig,
|
|
9
|
+
SvmdComponentBlocks,
|
|
10
|
+
SvmdComponentMap,
|
|
11
|
+
SvmdComponentNode,
|
|
12
|
+
SvmdElementNode,
|
|
13
|
+
SvmdHtmlNode,
|
|
14
|
+
SvmdMarkdownItPlugin,
|
|
15
|
+
SvmdNode,
|
|
16
|
+
SvmdParseOptions,
|
|
17
|
+
SvmdProps,
|
|
18
|
+
SvmdRenderOptions,
|
|
19
|
+
SvmdRoot,
|
|
20
|
+
SvmdTextNode,
|
|
21
|
+
} from './types'
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
SvmdCodeNode,
|
|
4
|
+
SvmdComponentMap,
|
|
5
|
+
SvmdElementNode,
|
|
6
|
+
SvmdNode,
|
|
7
|
+
SvmdRenderOptions,
|
|
8
|
+
} from '../types'
|
|
9
|
+
import RenderNodes from './RenderNodes.svelte'
|
|
10
|
+
|
|
11
|
+
const VOID_TAGS = new Set([
|
|
12
|
+
'area',
|
|
13
|
+
'base',
|
|
14
|
+
'br',
|
|
15
|
+
'col',
|
|
16
|
+
'embed',
|
|
17
|
+
'hr',
|
|
18
|
+
'img',
|
|
19
|
+
'input',
|
|
20
|
+
'link',
|
|
21
|
+
'meta',
|
|
22
|
+
'param',
|
|
23
|
+
'source',
|
|
24
|
+
'track',
|
|
25
|
+
'wbr',
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
interface Props {
|
|
29
|
+
node: SvmdNode
|
|
30
|
+
components?: SvmdComponentMap
|
|
31
|
+
renderOptions?: SvmdRenderOptions
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let { node, components = {}, renderOptions }: Props = $props()
|
|
35
|
+
|
|
36
|
+
function isVoidElement(elementNode: SvmdElementNode): boolean {
|
|
37
|
+
return VOID_TAGS.has(elementNode.name)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function codeClassName(codeNode: SvmdCodeNode): string | undefined {
|
|
41
|
+
if (!codeNode.lang) {
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return `language-${codeNode.lang}`
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
{#if node.kind === 'text'}
|
|
50
|
+
{node.value}
|
|
51
|
+
{:else if node.kind === 'break'}
|
|
52
|
+
{#if node.hard}
|
|
53
|
+
<br />
|
|
54
|
+
{:else}
|
|
55
|
+
{'\n'}
|
|
56
|
+
{/if}
|
|
57
|
+
{:else if node.kind === 'html'}
|
|
58
|
+
{#if renderOptions?.allowDangerousHtml}
|
|
59
|
+
{@html node.value}
|
|
60
|
+
{:else}
|
|
61
|
+
{node.value}
|
|
62
|
+
{/if}
|
|
63
|
+
{:else if node.kind === 'code'}
|
|
64
|
+
{@const CodeRenderer = components.code}
|
|
65
|
+
|
|
66
|
+
{#if CodeRenderer}
|
|
67
|
+
<CodeRenderer
|
|
68
|
+
node={node}
|
|
69
|
+
inline={node.inline}
|
|
70
|
+
text={node.text}
|
|
71
|
+
lang={node.lang}
|
|
72
|
+
info={node.info}
|
|
73
|
+
attrs={node.attrs}
|
|
74
|
+
/>
|
|
75
|
+
{:else if node.inline}
|
|
76
|
+
<code {...node.attrs}>{node.text}</code>
|
|
77
|
+
{:else}
|
|
78
|
+
<pre>
|
|
79
|
+
<code class={codeClassName(node)} {...node.attrs}>{node.text}</code>
|
|
80
|
+
</pre>
|
|
81
|
+
{/if}
|
|
82
|
+
{:else if node.kind === 'component'}
|
|
83
|
+
{@const ComponentRenderer = components[node.name]}
|
|
84
|
+
|
|
85
|
+
{#if ComponentRenderer}
|
|
86
|
+
<ComponentRenderer {...node.props} node={node} syntax={node.syntax} source={node.source}>
|
|
87
|
+
{#if node.children.length > 0}
|
|
88
|
+
<RenderNodes nodes={node.children} {components} {renderOptions} />
|
|
89
|
+
{/if}
|
|
90
|
+
</ComponentRenderer>
|
|
91
|
+
{:else}
|
|
92
|
+
{#if node.children.length > 0}
|
|
93
|
+
<RenderNodes nodes={node.children} {components} {renderOptions} />
|
|
94
|
+
{:else if node.source}
|
|
95
|
+
<pre><code>{node.source}</code></pre>
|
|
96
|
+
{/if}
|
|
97
|
+
{/if}
|
|
98
|
+
{:else}
|
|
99
|
+
{@const ElementRenderer = components[node.name]}
|
|
100
|
+
|
|
101
|
+
{#if ElementRenderer}
|
|
102
|
+
<ElementRenderer {...node.attrs} node={node}>
|
|
103
|
+
{#if node.children.length > 0}
|
|
104
|
+
<RenderNodes nodes={node.children} {components} {renderOptions} />
|
|
105
|
+
{/if}
|
|
106
|
+
</ElementRenderer>
|
|
107
|
+
{:else if isVoidElement(node)}
|
|
108
|
+
<svelte:element this={node.name} {...node.attrs} />
|
|
109
|
+
{:else}
|
|
110
|
+
<svelte:element this={node.name} {...node.attrs}>
|
|
111
|
+
{#if node.children.length > 0}
|
|
112
|
+
<RenderNodes nodes={node.children} {components} {renderOptions} />
|
|
113
|
+
{/if}
|
|
114
|
+
</svelte:element>
|
|
115
|
+
{/if}
|
|
116
|
+
{/if}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { SvmdComponentMap, SvmdNode, SvmdRenderOptions } from '../types'
|
|
3
|
+
import RenderNode from './RenderNode.svelte'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
nodes: SvmdNode[]
|
|
7
|
+
components?: SvmdComponentMap
|
|
8
|
+
renderOptions?: SvmdRenderOptions
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let { nodes, components = {}, renderOptions }: Props = $props()
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
{#each nodes as node (node.key)}
|
|
15
|
+
<RenderNode {node} {components} {renderOptions} />
|
|
16
|
+
{/each}
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import MarkdownIt from 'markdown-it'
|
|
2
|
+
import markdownItContainer from 'markdown-it-container'
|
|
3
|
+
import type Token from 'markdown-it/lib/token.mjs'
|
|
4
|
+
import type {
|
|
5
|
+
SvmdCodeNode,
|
|
6
|
+
SvmdComponentBlockConfig,
|
|
7
|
+
SvmdComponentBlocks,
|
|
8
|
+
SvmdComponentNode,
|
|
9
|
+
SvmdNode,
|
|
10
|
+
SvmdParseOptions,
|
|
11
|
+
SvmdProps,
|
|
12
|
+
SvmdRoot,
|
|
13
|
+
} from './types'
|
|
14
|
+
|
|
15
|
+
const FENCE_COMPONENT_PREFIX = 'component:'
|
|
16
|
+
|
|
17
|
+
interface NormalizedComponentBlock extends Required<Omit<SvmdComponentBlockConfig, 'parseProps'>> {
|
|
18
|
+
parseProps: (raw: string, context: { name: string; syntax: 'container' | 'fence' }) => SvmdProps
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ParseState {
|
|
22
|
+
keySeed: number
|
|
23
|
+
md: MarkdownIt
|
|
24
|
+
options: SvmdParseOptions
|
|
25
|
+
componentBlocks: Map<string, NormalizedComponentBlock>
|
|
26
|
+
parseFragment: (markdown: string) => SvmdNode[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type SvmdParser = (markdown: string) => SvmdRoot
|
|
30
|
+
|
|
31
|
+
export function createParser(options: SvmdParseOptions = {}): SvmdParser {
|
|
32
|
+
const componentBlocks = normalizeComponentBlocks(options.componentBlocks)
|
|
33
|
+
const md = createMarkdownIt(options, componentBlocks)
|
|
34
|
+
|
|
35
|
+
const parse = (markdown: string): SvmdRoot => {
|
|
36
|
+
const state: ParseState = {
|
|
37
|
+
keySeed: 0,
|
|
38
|
+
md,
|
|
39
|
+
options,
|
|
40
|
+
componentBlocks,
|
|
41
|
+
parseFragment: (fragment) => {
|
|
42
|
+
const fragmentTokens = md.parse(fragment, {})
|
|
43
|
+
return parseBlockTokens(fragmentTokens, state)
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const tokens = md.parse(markdown, {})
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
kind: 'root',
|
|
51
|
+
children: parseBlockTokens(tokens, state),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return parse
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseMarkdown(markdown: string, options: SvmdParseOptions = {}): SvmdRoot {
|
|
59
|
+
return createParser(options)(markdown)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function inferComponentBlocksFromMap(components: Record<string, unknown>): SvmdComponentBlocks {
|
|
63
|
+
const inferred: SvmdComponentBlocks = {}
|
|
64
|
+
|
|
65
|
+
for (const name of Object.keys(components)) {
|
|
66
|
+
if (!isLikelyCustomComponentName(name)) {
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
inferred[name] = {
|
|
71
|
+
container: true,
|
|
72
|
+
fence: true,
|
|
73
|
+
parseFenceBodyAsMarkdown: false,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return inferred
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isLikelyCustomComponentName(name: string): boolean {
|
|
81
|
+
return /^[A-Z]/.test(name)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createMarkdownIt(
|
|
85
|
+
options: SvmdParseOptions,
|
|
86
|
+
componentBlocks: Map<string, NormalizedComponentBlock>,
|
|
87
|
+
): MarkdownIt {
|
|
88
|
+
const markdownIt =
|
|
89
|
+
options.markdownIt ??
|
|
90
|
+
new MarkdownIt({
|
|
91
|
+
html: false,
|
|
92
|
+
linkify: true,
|
|
93
|
+
typographer: true,
|
|
94
|
+
...options.markdownItOptions,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
for (const [name, config] of componentBlocks) {
|
|
98
|
+
if (!config.container) {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
markdownIt.use(markdownItContainer, name)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const plugin of options.markdownItPlugins ?? []) {
|
|
106
|
+
if (Array.isArray(plugin)) {
|
|
107
|
+
const [pluginFn, ...params] = plugin
|
|
108
|
+
markdownIt.use(pluginFn, ...params)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
markdownIt.use(plugin)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return markdownIt
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeComponentBlocks(componentBlocks?: SvmdComponentBlocks): Map<string, NormalizedComponentBlock> {
|
|
119
|
+
const normalized = new Map<string, NormalizedComponentBlock>()
|
|
120
|
+
|
|
121
|
+
for (const [name, blockConfig] of Object.entries(componentBlocks ?? {})) {
|
|
122
|
+
if (!blockConfig) {
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (blockConfig === true) {
|
|
127
|
+
normalized.set(name, {
|
|
128
|
+
container: true,
|
|
129
|
+
fence: true,
|
|
130
|
+
parseFenceBodyAsMarkdown: false,
|
|
131
|
+
parseProps: defaultParseProps,
|
|
132
|
+
})
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
normalized.set(name, {
|
|
137
|
+
container: blockConfig.container ?? true,
|
|
138
|
+
fence: blockConfig.fence ?? true,
|
|
139
|
+
parseFenceBodyAsMarkdown: blockConfig.parseFenceBodyAsMarkdown ?? false,
|
|
140
|
+
parseProps: blockConfig.parseProps ?? defaultParseProps,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return normalized
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseBlockTokens(tokens: Token[], state: ParseState): SvmdNode[] {
|
|
148
|
+
const nodes: SvmdNode[] = []
|
|
149
|
+
|
|
150
|
+
let index = 0
|
|
151
|
+
while (index < tokens.length) {
|
|
152
|
+
const token = tokens[index]
|
|
153
|
+
|
|
154
|
+
if (token.hidden && token.nesting !== 0) {
|
|
155
|
+
index += 1
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (isContainerOpenToken(token, state.componentBlocks)) {
|
|
160
|
+
const componentName = getContainerName(token.type)
|
|
161
|
+
if (componentName) {
|
|
162
|
+
const closeType = `container_${componentName}_close`
|
|
163
|
+
const closeIndex = findCloseIndex(tokens, index, token.type, closeType)
|
|
164
|
+
|
|
165
|
+
if (closeIndex !== -1) {
|
|
166
|
+
const config = state.componentBlocks.get(componentName)
|
|
167
|
+
const propsRaw = extractContainerPropsRaw(componentName, token.info)
|
|
168
|
+
const props = (config?.parseProps ?? defaultParseProps)(propsRaw, {
|
|
169
|
+
name: componentName,
|
|
170
|
+
syntax: 'container',
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
nodes.push({
|
|
174
|
+
key: nextKey(state),
|
|
175
|
+
kind: 'component',
|
|
176
|
+
name: componentName,
|
|
177
|
+
syntax: 'container',
|
|
178
|
+
props,
|
|
179
|
+
children: parseBlockTokens(tokens.slice(index + 1, closeIndex), state),
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
index = closeIndex + 1
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (token.nesting === 1 && token.type.endsWith('_open')) {
|
|
189
|
+
const closeType = openTypeToCloseType(token.type)
|
|
190
|
+
const closeIndex = findCloseIndex(tokens, index, token.type, closeType)
|
|
191
|
+
|
|
192
|
+
if (closeIndex === -1) {
|
|
193
|
+
index += 1
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const children = parseBlockTokens(tokens.slice(index + 1, closeIndex), state)
|
|
198
|
+
|
|
199
|
+
if (token.hidden) {
|
|
200
|
+
nodes.push(...children)
|
|
201
|
+
} else {
|
|
202
|
+
nodes.push({
|
|
203
|
+
key: nextKey(state),
|
|
204
|
+
kind: 'element',
|
|
205
|
+
name: normalizeNodeName(token),
|
|
206
|
+
attrs: attrsToRecord(token.attrs),
|
|
207
|
+
children,
|
|
208
|
+
block: token.block,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
index = closeIndex + 1
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (token.nesting === 0) {
|
|
217
|
+
const parsed = parseBlockLeafToken(token, state)
|
|
218
|
+
if (parsed) {
|
|
219
|
+
nodes.push(...parsed)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
index += 1
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return nodes
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function parseBlockLeafToken(token: Token, state: ParseState): SvmdNode[] | null {
|
|
230
|
+
if (token.type === 'inline') {
|
|
231
|
+
return parseInlineTokens(token.children ?? [], state)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (token.type === 'fence') {
|
|
235
|
+
const maybeComponent = parseFenceComponent(token, state)
|
|
236
|
+
if (maybeComponent) {
|
|
237
|
+
return [maybeComponent]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const [lang] = token.info.trim().split(/\s+/, 1)
|
|
241
|
+
|
|
242
|
+
return [
|
|
243
|
+
{
|
|
244
|
+
key: nextKey(state),
|
|
245
|
+
kind: 'code',
|
|
246
|
+
inline: false,
|
|
247
|
+
text: token.content,
|
|
248
|
+
lang: lang || undefined,
|
|
249
|
+
info: token.info,
|
|
250
|
+
attrs: attrsToRecord(token.attrs),
|
|
251
|
+
} satisfies SvmdCodeNode,
|
|
252
|
+
]
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (token.type === 'code_block') {
|
|
256
|
+
return [
|
|
257
|
+
{
|
|
258
|
+
key: nextKey(state),
|
|
259
|
+
kind: 'code',
|
|
260
|
+
inline: false,
|
|
261
|
+
text: token.content,
|
|
262
|
+
attrs: attrsToRecord(token.attrs),
|
|
263
|
+
} satisfies SvmdCodeNode,
|
|
264
|
+
]
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (token.type === 'html_block') {
|
|
268
|
+
return [
|
|
269
|
+
{
|
|
270
|
+
key: nextKey(state),
|
|
271
|
+
kind: 'html',
|
|
272
|
+
value: token.content,
|
|
273
|
+
block: true,
|
|
274
|
+
},
|
|
275
|
+
]
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (token.type === 'hr') {
|
|
279
|
+
return [
|
|
280
|
+
{
|
|
281
|
+
key: nextKey(state),
|
|
282
|
+
kind: 'element',
|
|
283
|
+
name: 'hr',
|
|
284
|
+
attrs: attrsToRecord(token.attrs),
|
|
285
|
+
children: [],
|
|
286
|
+
block: true,
|
|
287
|
+
},
|
|
288
|
+
]
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (token.content) {
|
|
292
|
+
return [
|
|
293
|
+
{
|
|
294
|
+
key: nextKey(state),
|
|
295
|
+
kind: 'text',
|
|
296
|
+
value: token.content,
|
|
297
|
+
},
|
|
298
|
+
]
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return null
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function parseInlineTokens(tokens: Token[], state: ParseState): SvmdNode[] {
|
|
305
|
+
const nodes: SvmdNode[] = []
|
|
306
|
+
|
|
307
|
+
let index = 0
|
|
308
|
+
while (index < tokens.length) {
|
|
309
|
+
const token = tokens[index]
|
|
310
|
+
|
|
311
|
+
if (token.type === 'text') {
|
|
312
|
+
nodes.push({
|
|
313
|
+
key: nextKey(state),
|
|
314
|
+
kind: 'text',
|
|
315
|
+
value: token.content,
|
|
316
|
+
})
|
|
317
|
+
index += 1
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (token.type === 'softbreak' || token.type === 'hardbreak') {
|
|
322
|
+
nodes.push({
|
|
323
|
+
key: nextKey(state),
|
|
324
|
+
kind: 'break',
|
|
325
|
+
hard: token.type === 'hardbreak',
|
|
326
|
+
})
|
|
327
|
+
index += 1
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (token.type === 'html_inline') {
|
|
332
|
+
nodes.push({
|
|
333
|
+
key: nextKey(state),
|
|
334
|
+
kind: 'html',
|
|
335
|
+
value: token.content,
|
|
336
|
+
block: false,
|
|
337
|
+
})
|
|
338
|
+
index += 1
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (token.type === 'code_inline') {
|
|
343
|
+
nodes.push({
|
|
344
|
+
key: nextKey(state),
|
|
345
|
+
kind: 'code',
|
|
346
|
+
inline: true,
|
|
347
|
+
text: token.content,
|
|
348
|
+
attrs: attrsToRecord(token.attrs),
|
|
349
|
+
})
|
|
350
|
+
index += 1
|
|
351
|
+
continue
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (token.type === 'image') {
|
|
355
|
+
const attrs = attrsToRecord(token.attrs)
|
|
356
|
+
if (!attrs.alt && token.content) {
|
|
357
|
+
attrs.alt = token.content
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
nodes.push({
|
|
361
|
+
key: nextKey(state),
|
|
362
|
+
kind: 'element',
|
|
363
|
+
name: token.tag || 'img',
|
|
364
|
+
attrs,
|
|
365
|
+
children: [],
|
|
366
|
+
block: false,
|
|
367
|
+
})
|
|
368
|
+
index += 1
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (token.nesting === 1 && token.type.endsWith('_open')) {
|
|
373
|
+
const closeType = openTypeToCloseType(token.type)
|
|
374
|
+
const closeIndex = findCloseIndex(tokens, index, token.type, closeType)
|
|
375
|
+
|
|
376
|
+
if (closeIndex !== -1) {
|
|
377
|
+
const children = parseInlineTokens(tokens.slice(index + 1, closeIndex), state)
|
|
378
|
+
|
|
379
|
+
nodes.push({
|
|
380
|
+
key: nextKey(state),
|
|
381
|
+
kind: 'element',
|
|
382
|
+
name: normalizeNodeName(token),
|
|
383
|
+
attrs: attrsToRecord(token.attrs),
|
|
384
|
+
children,
|
|
385
|
+
block: false,
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
index = closeIndex + 1
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (token.content) {
|
|
394
|
+
nodes.push({
|
|
395
|
+
key: nextKey(state),
|
|
396
|
+
kind: 'text',
|
|
397
|
+
value: token.content,
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
index += 1
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return nodes
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function parseFenceComponent(token: Token, state: ParseState): SvmdComponentNode | null {
|
|
408
|
+
const info = token.info.trim()
|
|
409
|
+
if (!info) {
|
|
410
|
+
return null
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const prefix = state.options.fenceComponentPrefix ?? FENCE_COMPONENT_PREFIX
|
|
414
|
+
if (!info.startsWith(prefix)) {
|
|
415
|
+
return null
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const spec = info.slice(prefix.length).trim()
|
|
419
|
+
if (!spec) {
|
|
420
|
+
return null
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const { name, propsRaw } = parseComponentSpec(spec)
|
|
424
|
+
if (!name) {
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const config = state.componentBlocks.get(name)
|
|
429
|
+
if (config && !config.fence) {
|
|
430
|
+
return null
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const parseProps = config?.parseProps ?? defaultParseProps
|
|
434
|
+
const props = parseProps(propsRaw, { name, syntax: 'fence' })
|
|
435
|
+
|
|
436
|
+
const children = config?.parseFenceBodyAsMarkdown ? state.parseFragment(token.content) : []
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
key: nextKey(state),
|
|
440
|
+
kind: 'component',
|
|
441
|
+
name,
|
|
442
|
+
syntax: 'fence',
|
|
443
|
+
props,
|
|
444
|
+
children,
|
|
445
|
+
source: token.content,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function parseComponentSpec(spec: string): { name: string; propsRaw: string } {
|
|
450
|
+
const firstSpace = spec.search(/\s/)
|
|
451
|
+
if (firstSpace === -1) {
|
|
452
|
+
return { name: spec.trim(), propsRaw: '' }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
name: spec.slice(0, firstSpace).trim(),
|
|
457
|
+
propsRaw: spec.slice(firstSpace).trim(),
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function extractContainerPropsRaw(name: string, info: string): string {
|
|
462
|
+
const trimmed = info.trim()
|
|
463
|
+
if (!trimmed) {
|
|
464
|
+
return ''
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (!trimmed.startsWith(name)) {
|
|
468
|
+
return trimmed
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return trimmed.slice(name.length).trim()
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function attrsToRecord(attrs: Token['attrs']): Record<string, string> {
|
|
475
|
+
if (!attrs || attrs.length === 0) {
|
|
476
|
+
return {}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return Object.fromEntries(attrs)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function isContainerOpenToken(token: Token, componentBlocks: Map<string, NormalizedComponentBlock>): boolean {
|
|
483
|
+
if (!token.type.startsWith('container_') || !token.type.endsWith('_open')) {
|
|
484
|
+
return false
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const name = getContainerName(token.type)
|
|
488
|
+
if (!name) {
|
|
489
|
+
return false
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const config = componentBlocks.get(name)
|
|
493
|
+
return Boolean(config?.container)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function getContainerName(type: string): string | null {
|
|
497
|
+
if (!type.startsWith('container_') || !type.endsWith('_open')) {
|
|
498
|
+
return null
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return type.slice('container_'.length, -'_open'.length)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function openTypeToCloseType(openType: string): string {
|
|
505
|
+
if (!openType.endsWith('_open')) {
|
|
506
|
+
return openType
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return `${openType.slice(0, -'_open'.length)}_close`
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function findCloseIndex(tokens: Token[], startIndex: number, openType: string, closeType: string): number {
|
|
513
|
+
let depth = 0
|
|
514
|
+
|
|
515
|
+
for (let index = startIndex; index < tokens.length; index += 1) {
|
|
516
|
+
const token = tokens[index]
|
|
517
|
+
|
|
518
|
+
if (token.type === openType) {
|
|
519
|
+
depth += 1
|
|
520
|
+
continue
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (token.type === closeType) {
|
|
524
|
+
depth -= 1
|
|
525
|
+
if (depth === 0) {
|
|
526
|
+
return index
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return -1
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function normalizeNodeName(token: Token): string {
|
|
535
|
+
if (token.tag) {
|
|
536
|
+
return token.tag
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const type = token.type
|
|
540
|
+
if (type.endsWith('_open')) {
|
|
541
|
+
return type.slice(0, -'_open'.length)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (type.endsWith('_close')) {
|
|
545
|
+
return type.slice(0, -'_close'.length)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return type
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function defaultParseProps(raw: string): SvmdProps {
|
|
552
|
+
const source = raw.trim()
|
|
553
|
+
if (!source) {
|
|
554
|
+
return {}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const jsonProps = parseJsonProps(source)
|
|
558
|
+
if (jsonProps) {
|
|
559
|
+
return jsonProps
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const kvProps = parseKeyValueProps(source)
|
|
563
|
+
if (Object.keys(kvProps).length > 0) {
|
|
564
|
+
return kvProps
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return { value: coercePrimitive(source) }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function parseJsonProps(source: string): SvmdProps | null {
|
|
571
|
+
if (!(source.startsWith('{') && source.endsWith('}'))) {
|
|
572
|
+
return null
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
try {
|
|
576
|
+
const parsed = JSON.parse(source) as unknown
|
|
577
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
578
|
+
return parsed as SvmdProps
|
|
579
|
+
}
|
|
580
|
+
} catch {
|
|
581
|
+
return null
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return null
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function parseKeyValueProps(source: string): SvmdProps {
|
|
588
|
+
const props: SvmdProps = {}
|
|
589
|
+
const keyValuePattern = /([A-Za-z_][\w-]*)=(?:"([^"]*)"|'([^']*)'|([^\s]+))/g
|
|
590
|
+
|
|
591
|
+
for (const match of source.matchAll(keyValuePattern)) {
|
|
592
|
+
const [, key, dqValue, sqValue, rawValue] = match
|
|
593
|
+
const value = dqValue ?? sqValue ?? rawValue ?? ''
|
|
594
|
+
props[key] = coercePrimitive(value)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return props
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function coercePrimitive(value: string): SvmdProps[string] {
|
|
601
|
+
const trimmed = value.trim()
|
|
602
|
+
|
|
603
|
+
if (trimmed === 'true') {
|
|
604
|
+
return true
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (trimmed === 'false') {
|
|
608
|
+
return false
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (trimmed === 'null') {
|
|
612
|
+
return null
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
|
616
|
+
return Number(trimmed)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return trimmed
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function nextKey(state: ParseState): string {
|
|
623
|
+
const key = state.keySeed
|
|
624
|
+
state.keySeed += 1
|
|
625
|
+
return `n_${key}`
|
|
626
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type MarkdownIt from 'markdown-it'
|
|
2
|
+
import type {
|
|
3
|
+
Options as MarkdownItOptions,
|
|
4
|
+
PluginSimple as MarkdownItPluginSimple,
|
|
5
|
+
PluginWithOptions as MarkdownItPluginWithOptions,
|
|
6
|
+
PluginWithParams as MarkdownItPluginWithParams,
|
|
7
|
+
} from 'markdown-it'
|
|
8
|
+
import type { Component } from 'svelte'
|
|
9
|
+
|
|
10
|
+
export type SvmdPrimitive = string | number | boolean | null
|
|
11
|
+
export type SvmdPropValue = SvmdPrimitive | SvmdPropValue[] | { [key: string]: SvmdPropValue }
|
|
12
|
+
export type SvmdProps = Record<string, SvmdPropValue>
|
|
13
|
+
|
|
14
|
+
interface SvmdNodeBase {
|
|
15
|
+
key: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SvmdRoot {
|
|
19
|
+
kind: 'root'
|
|
20
|
+
children: SvmdNode[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SvmdTextNode extends SvmdNodeBase {
|
|
24
|
+
kind: 'text'
|
|
25
|
+
value: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SvmdBreakNode extends SvmdNodeBase {
|
|
29
|
+
kind: 'break'
|
|
30
|
+
hard: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SvmdHtmlNode extends SvmdNodeBase {
|
|
34
|
+
kind: 'html'
|
|
35
|
+
value: string
|
|
36
|
+
block: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SvmdElementNode extends SvmdNodeBase {
|
|
40
|
+
kind: 'element'
|
|
41
|
+
name: string
|
|
42
|
+
attrs: Record<string, string>
|
|
43
|
+
children: SvmdNode[]
|
|
44
|
+
block: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SvmdCodeNode extends SvmdNodeBase {
|
|
48
|
+
kind: 'code'
|
|
49
|
+
inline: boolean
|
|
50
|
+
text: string
|
|
51
|
+
lang?: string
|
|
52
|
+
info?: string
|
|
53
|
+
attrs: Record<string, string>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SvmdComponentNode extends SvmdNodeBase {
|
|
57
|
+
kind: 'component'
|
|
58
|
+
name: string
|
|
59
|
+
syntax: 'container' | 'fence'
|
|
60
|
+
props: SvmdProps
|
|
61
|
+
children: SvmdNode[]
|
|
62
|
+
source?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type SvmdNode =
|
|
66
|
+
| SvmdTextNode
|
|
67
|
+
| SvmdBreakNode
|
|
68
|
+
| SvmdHtmlNode
|
|
69
|
+
| SvmdElementNode
|
|
70
|
+
| SvmdCodeNode
|
|
71
|
+
| SvmdComponentNode
|
|
72
|
+
|
|
73
|
+
export type SvmdComponent = Component<any>
|
|
74
|
+
export type SvmdComponentMap = Record<string, SvmdComponent | undefined>
|
|
75
|
+
|
|
76
|
+
export type SvmdMarkdownItPlugin =
|
|
77
|
+
| MarkdownItPluginSimple
|
|
78
|
+
| MarkdownItPluginWithParams
|
|
79
|
+
| [MarkdownItPluginSimple | MarkdownItPluginWithOptions<any> | MarkdownItPluginWithParams, ...unknown[]]
|
|
80
|
+
|
|
81
|
+
export interface SvmdComponentBlockConfig {
|
|
82
|
+
container?: boolean
|
|
83
|
+
fence?: boolean
|
|
84
|
+
parseFenceBodyAsMarkdown?: boolean
|
|
85
|
+
parseProps?: (raw: string, context: { name: string; syntax: 'container' | 'fence' }) => SvmdProps
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type SvmdComponentBlocks = Record<string, boolean | SvmdComponentBlockConfig>
|
|
89
|
+
|
|
90
|
+
export interface SvmdParseOptions {
|
|
91
|
+
markdownIt?: MarkdownIt
|
|
92
|
+
markdownItOptions?: MarkdownItOptions
|
|
93
|
+
markdownItPlugins?: SvmdMarkdownItPlugin[]
|
|
94
|
+
componentBlocks?: SvmdComponentBlocks
|
|
95
|
+
fenceComponentPrefix?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SvmdRenderOptions {
|
|
99
|
+
allowDangerousHtml?: boolean
|
|
100
|
+
}
|