Skip to content

Commit 8ec994d

Browse files
authored
fix(svelte): avoid runtime snippet casts for slots (#209)
1 parent fcab786 commit 8ec994d

6 files changed

Lines changed: 192 additions & 37 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ packages/comark-svelte/
239239
│ │ ├── Comark.svelte # High-level markdown → render ($state + $effect)
240240
│ │ ├── ComarkRenderer.svelte # Low-level AST → render component
241241
│ │ ├── ComarkNode.svelte # Recursive AST node renderer
242+
│ │ ├── ComarkComponent.svelte # Custom component renderer with named snippets
242243
│ │ └── Resolve.svelte # Stable promise resolver for lazy components
243244
│ ├── async/
244245
│ │ ├── index.ts # Async export (@comark/svelte/async)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<script lang="ts">
2+
import type { ComarkNode as ComarkNodeType, ComponentManifest, NodeRenderData } from 'comark'
3+
import type { Snippet } from 'svelte'
4+
import type { ComponentResolver } from '../types.js'
5+
import ComarkNode from './ComarkNode.svelte'
6+
import Resolve from './Resolve.svelte'
7+
import ComarkComponent from './ComarkComponent.svelte'
8+
9+
interface NamedSlot {
10+
name: string
11+
children: ComarkNodeType[]
12+
caretClass: string | null
13+
}
14+
15+
const EMPTY_RENDER_DATA: NodeRenderData = { frontmatter: {}, meta: {}, data: {}, props: {} }
16+
17+
let {
18+
Component = null,
19+
componentPromise = null,
20+
props = {},
21+
namedSlots = [],
22+
slotIndex = 0,
23+
components = {},
24+
componentsManifest,
25+
resolver: Resolver = Resolve,
26+
renderData = EMPTY_RENDER_DATA,
27+
children,
28+
}: {
29+
Component?: any
30+
componentPromise?: Promise<any> | null
31+
props?: Record<string, any>
32+
namedSlots?: NamedSlot[]
33+
slotIndex?: number
34+
components?: Record<string, any>
35+
componentsManifest?: ComponentManifest
36+
resolver?: ComponentResolver
37+
renderData?: NodeRenderData
38+
children?: Snippet
39+
} = $props()
40+
</script>
41+
42+
{#if slotIndex < namedSlots.length}
43+
{@const slot = namedSlots[slotIndex]}
44+
{#snippet namedSlot()}
45+
{#each slot.children as child, i (i)}
46+
<ComarkNode
47+
node={child}
48+
{components}
49+
{componentsManifest}
50+
resolver={Resolver}
51+
caretClass={i === slot.children.length - 1 ? slot.caretClass : null}
52+
{renderData}
53+
/>
54+
{/each}
55+
{/snippet}
56+
57+
<ComarkComponent
58+
{Component}
59+
{componentPromise}
60+
props={{ ...props, [slot.name]: namedSlot }}
61+
{namedSlots}
62+
slotIndex={slotIndex + 1}
63+
{components}
64+
{componentsManifest}
65+
resolver={Resolver}
66+
{renderData}
67+
>
68+
{@render children?.()}
69+
</ComarkComponent>
70+
{:else if Component}
71+
<Component {...props}>
72+
{@render children?.()}
73+
</Component>
74+
{:else if componentPromise}
75+
<Resolver promise={componentPromise} {props}>
76+
{@render children?.()}
77+
</Resolver>
78+
{/if}

packages/comark-svelte/src/components/ComarkNode.svelte

Lines changed: 41 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ naturally appears inline after the deepest trailing text node.
6868

6969
<script lang="ts">
7070
import type { ComarkNode as ComarkNodeType, ComponentManifest, NodeRenderData } from 'comark'
71-
import type { Snippet } from 'svelte'
7271
import type { ComponentResolver } from '../types.js'
7372
import ComarkNode from './ComarkNode.svelte'
73+
import ComarkComponent from './ComarkComponent.svelte'
7474
import Resolve from './Resolve.svelte'
7575
import { resolveAttributes } from 'comark/utils'
7676
@@ -106,6 +106,12 @@ naturally appears inline after the deepest trailing text node.
106106
caretClass: string | null
107107
}
108108
109+
interface NamedSlot {
110+
name: string
111+
children: ComarkNodeType[]
112+
caretClass: string | null
113+
}
114+
109115
function getSlotName(node: ComarkNodeType): string | null {
110116
if (typeof node === 'string' || !Array.isArray(node) || node[0] !== 'template') {
111117
return null
@@ -125,26 +131,6 @@ naturally appears inline after the deepest trailing text node.
125131
return null
126132
}
127133
128-
function createChildrenSnippet(
129-
snippetChildren: ComarkNodeType[],
130-
snippetRenderData: NodeRenderData,
131-
snippetCaretClass: string | null,
132-
): Snippet {
133-
return ((anchor: unknown) => {
134-
const renderNode = ComarkNode as unknown as (anchor: unknown, props: Record<string, unknown>) => void
135-
for (let i = 0; i < snippetChildren.length; i++) {
136-
renderNode(anchor, {
137-
node: snippetChildren[i],
138-
components,
139-
componentsManifest,
140-
resolver: Resolver,
141-
caretClass: i === snippetChildren.length - 1 ? snippetCaretClass : null,
142-
renderData: snippetRenderData,
143-
})
144-
}
145-
}) as unknown as Snippet
146-
}
147-
148134
function toRenderChildren(
149135
sourceChildren: ComarkNodeType[],
150136
sourceIndex: number,
@@ -217,9 +203,9 @@ naturally appears inline after the deepest trailing text node.
217203
: renderData,
218204
)
219205
220-
let { defaultChildren, namedSlotProps } = $derived.by(() => {
206+
let { defaultChildren, namedSlots } = $derived.by(() => {
221207
const defaultChildren: RenderChild[] = []
222-
const slotProps: Record<string, Snippet> = {}
208+
const slots: NamedSlot[] = []
223209
224210
for (let i = 0; i < children.length; i++) {
225211
const child = children[i]
@@ -230,26 +216,20 @@ naturally appears inline after the deepest trailing text node.
230216
defaultChildren.push(...toRenderChildren(slotChildren, i, children.length, caretClass))
231217
}
232218
else {
233-
slotProps[slotName] = createChildrenSnippet(
234-
slotChildren,
235-
childrenRenderData,
236-
i === children.length - 1 ? caretClass : null,
237-
)
219+
slots.push({
220+
name: slotName,
221+
children: slotChildren,
222+
caretClass: i === children.length - 1 ? caretClass : null,
223+
})
238224
}
239225
}
240226
else {
241227
defaultChildren.push({ node: child, caretClass: i === children.length - 1 ? caretClass : null })
242228
}
243229
}
244230
245-
return { defaultChildren, namedSlotProps: slotProps }
231+
return { defaultChildren, namedSlots: slots }
246232
})
247-
248-
let componentProps = $derived(
249-
Object.keys(namedSlotProps).length > 0
250-
? { ...mappedProps, ...namedSlotProps }
251-
: mappedProps,
252-
)
253233
</script>
254234

255235
{#snippet renderChildren()}
@@ -270,12 +250,36 @@ naturally appears inline after the deepest trailing text node.
270250
class={caretClass || undefined}
271251
style={CARET_STYLE}>{CARET_TEXT}</span
272252
>{/if}
253+
{:else if Component && namedSlots.length > 0}
254+
<ComarkComponent
255+
{Component}
256+
props={mappedProps}
257+
{namedSlots}
258+
{components}
259+
{componentsManifest}
260+
resolver={Resolver}
261+
renderData={childrenRenderData}
262+
>
263+
{@render renderChildren()}
264+
</ComarkComponent>
273265
{:else if Component}
274-
<Component {...componentProps}>
266+
<Component {...mappedProps}>
275267
{@render renderChildren()}
276268
</Component>
269+
{:else if componentPromise && namedSlots.length > 0}
270+
<ComarkComponent
271+
{componentPromise}
272+
props={mappedProps}
273+
{namedSlots}
274+
{components}
275+
{componentsManifest}
276+
resolver={Resolver}
277+
renderData={childrenRenderData}
278+
>
279+
{@render renderChildren()}
280+
</ComarkComponent>
277281
{:else if componentPromise}
278-
<Resolver promise={componentPromise} props={componentProps}>
282+
<Resolver promise={componentPromise} props={mappedProps}>
279283
{@render renderChildren()}
280284
</Resolver>
281285
{:else if isVoid}

packages/comark-svelte/test/ComarkNode.svelte.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { parse } from 'comark'
44
import ComarkRenderer from '../src/components/ComarkRenderer.svelte'
55
import ComarkNode from '../src/components/ComarkNode.svelte'
66
import Alert from './test-components/Alert.svelte'
7+
import CardWithHeaderFooter from './test-components/CardWithHeaderFooter.svelte'
78
import CardWithFooter from './test-components/CardWithFooter.svelte'
89
import ProseH1 from './test-components/ProseH1.svelte'
910

@@ -192,6 +193,34 @@ Footer slot content.
192193
expect(screen.container.querySelector('template[name="footer"]')).toBeNull()
193194
})
194195

196+
it('passes multiple named slots as Svelte snippet props', async () => {
197+
const tree = await parse(`::card{title="My Card"}
198+
Default slot content.
199+
200+
#header
201+
Header slot content.
202+
203+
#footer
204+
Footer slot content.
205+
::`)
206+
const screen = render(ComarkRenderer, {
207+
tree,
208+
components: { card: CardWithHeaderFooter },
209+
})
210+
const header = screen.container.querySelector<HTMLElement>('header')!
211+
const main = screen.container.querySelector<HTMLElement>('main')!
212+
const footer = screen.container.querySelector<HTMLElement>('footer')!
213+
214+
expect(header).not.toBeNull()
215+
expect(main).not.toBeNull()
216+
expect(footer).not.toBeNull()
217+
await expect.element(header).toHaveTextContent('Header slot content.')
218+
await expect.element(main).toHaveTextContent('Default slot content.')
219+
await expect.element(footer).toHaveTextContent('Footer slot content.')
220+
expect(screen.container.querySelector('template[name="header"]')).toBeNull()
221+
expect(screen.container.querySelector('template[name="footer"]')).toBeNull()
222+
})
223+
195224
it('resolves custom components from componentsManifest', async () => {
196225
const tree = await parse('::alert{type="warning"}\nLazy content\n::')
197226
const screen = render(ComarkRenderer, {

packages/comark-svelte/test/ComarkNode.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import ComarkNode from '../src/components/ComarkNode.svelte'
66
import ComarkAsync from '../src/async/ComarkAsync.svelte'
77
import Alert from './test-components/Alert.svelte'
88
import Card from './test-components/Card.svelte'
9+
import CardWithHeaderFooter from './test-components/CardWithHeaderFooter.svelte'
910
import CardWithFooter from './test-components/CardWithFooter.svelte'
1011
import ProseH1 from './test-components/ProseH1.svelte'
1112

@@ -265,6 +266,26 @@ Footer slot content.
265266
expect(output).not.toContain('<template')
266267
})
267268

269+
it('passes multiple named slots as Svelte snippet props during SSR', async () => {
270+
const tree = await parse(`::card{title="My Card"}
271+
Default slot content.
272+
273+
#header
274+
Header slot content.
275+
276+
#footer
277+
Footer slot content.
278+
::`)
279+
const { body } = render(ComarkRenderer, {
280+
props: { tree, components: { card: CardWithHeaderFooter } },
281+
})
282+
const output = html(body)
283+
expect(output).toContain('<header>Header slot content.</header>')
284+
expect(output).toContain('<main><p>Default slot content.</p></main>')
285+
expect(output).toContain('<footer>Footer slot content.</footer>')
286+
expect(output).not.toContain('<template')
287+
})
288+
268289
it('resolves eager componentsManifest entries during SSR', async () => {
269290
const tree = await parse('::alert{type="warning"}\nLazy content\n::')
270291
const { body } = render(ComarkRenderer, {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script lang="ts">
2+
import type { Snippet } from 'svelte'
3+
4+
let {
5+
title = '',
6+
children,
7+
header,
8+
footer,
9+
}: {
10+
title?: string
11+
children?: Snippet
12+
header?: Snippet
13+
footer?: Snippet
14+
} = $props()
15+
</script>
16+
17+
<section class="card">
18+
<h3>{title}</h3>
19+
<header>{@render header?.()}</header>
20+
<main>{@render children?.()}</main>
21+
<footer>{@render footer?.()}</footer>
22+
</section>

0 commit comments

Comments
 (0)