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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "svmarkdown",
3
3
  "type": "module",
4
- "version": "0.1.0",
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
- ".": "./dist/index.js",
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
+ }