Skip to content

Commit 660747e

Browse files
authored
fix: support Svelte named snippets (#206)
1 parent 1b3304e commit 660747e

7 files changed

Lines changed: 197 additions & 5 deletions

File tree

examples/1.frameworks/sveltekit/src/app.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ a {
207207
font-size: 20px;
208208
}
209209

210+
.lazy-card__footer {
211+
margin-top: 14px;
212+
border-top: 1px solid currentColor;
213+
padding-top: 12px;
214+
font-size: 14px;
215+
opacity: 0.78;
216+
}
217+
210218
.lazy-card p:last-child {
211219
margin-bottom: 0;
212220
}

examples/1.frameworks/sveltekit/src/lib/components/comark/LazyCard.svelte

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
title = 'Lazy card',
66
accent = 'cyan',
77
children,
8+
footer,
89
}: {
910
title?: string
1011
accent?: 'cyan' | 'emerald'
1112
children?: Snippet
13+
footer?: Snippet
1214
} = $props()
1315
</script>
1416

@@ -18,4 +20,9 @@
1820
<div>
1921
{@render children?.()}
2022
</div>
23+
{#if footer}
24+
<footer class="lazy-card__footer">
25+
{@render footer()}
26+
</footer>
27+
{/if}
2128
</section>

examples/1.frameworks/sveltekit/src/lib/content.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ This alert is returned from \`componentsManifest\` as a dynamic import and is aw
99
1010
::lazy-card{title="Lazy import rendered by SSR" accent="cyan"}
1111
This component is loaded only when the \`lazy-card\` tag appears in the rendered markdown.
12+
13+
#footer
14+
Named snippets are forwarded through the lazy component resolver.
1215
::
1316
`.trim()
1417

@@ -23,5 +26,8 @@ Use this pattern when you want stable, non-experimental SSR.
2326
2427
::lazy-card{title="Eager manifest entry" accent="emerald"}
2528
This card is resolved synchronously from an eager manifest, so it can render during SSR without Svelte's experimental async support.
29+
30+
#footer
31+
The same \`#footer\` slot works with \`<ComarkRenderer>\` and an eager manifest.
2632
::
2733
`.trim()

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

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ 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'
7172
import type { ComponentResolver } from '../types.js'
7273
import ComarkNode from './ComarkNode.svelte'
7374
import Resolve from './Resolve.svelte'
@@ -100,6 +101,62 @@ naturally appears inline after the deepest trailing text node.
100101
'link', 'meta', 'param', 'source', 'track', 'wbr',
101102
])
102103
104+
interface RenderChild {
105+
node: ComarkNodeType
106+
caretClass: string | null
107+
}
108+
109+
function getSlotName(node: ComarkNodeType): string | null {
110+
if (typeof node === 'string' || !Array.isArray(node) || node[0] !== 'template') {
111+
return null
112+
}
113+
114+
const props = (node.length >= 2 ? node[1] : {}) ?? {}
115+
if (typeof props.name === 'string' && props.name) {
116+
return props.name
117+
}
118+
119+
for (const key in props) {
120+
if (key.startsWith('#') && key.length > 1) {
121+
return key.slice(1)
122+
}
123+
}
124+
125+
return null
126+
}
127+
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+
148+
function toRenderChildren(
149+
sourceChildren: ComarkNodeType[],
150+
sourceIndex: number,
151+
totalChildren: number,
152+
nodeCaretClass: string | null,
153+
): RenderChild[] {
154+
return sourceChildren.map((child, index) => ({
155+
node: child,
156+
caretClass: sourceIndex === totalChildren - 1 && index === sourceChildren.length - 1 ? nodeCaretClass : null,
157+
}))
158+
}
159+
103160
let { isText, tag, isVoid, children, Component, componentPromise, mappedProps } = $derived.by(() => {
104161
let isText = false
105162
let tag: string | null = null
@@ -159,16 +216,50 @@ naturally appears inline after the deepest trailing text node.
159216
? { ...renderData, props: mappedProps }
160217
: renderData,
161218
)
219+
220+
let { defaultChildren, namedSlotProps } = $derived.by(() => {
221+
const defaultChildren: RenderChild[] = []
222+
const slotProps: Record<string, Snippet> = {}
223+
224+
for (let i = 0; i < children.length; i++) {
225+
const child = children[i]
226+
const slotName = getSlotName(child)
227+
if (slotName) {
228+
const slotChildren = (child as unknown as ComarkNodeType[]).slice(2) as ComarkNodeType[]
229+
if (slotName === 'default') {
230+
defaultChildren.push(...toRenderChildren(slotChildren, i, children.length, caretClass))
231+
}
232+
else {
233+
slotProps[slotName] = createChildrenSnippet(
234+
slotChildren,
235+
childrenRenderData,
236+
i === children.length - 1 ? caretClass : null,
237+
)
238+
}
239+
}
240+
else {
241+
defaultChildren.push({ node: child, caretClass: i === children.length - 1 ? caretClass : null })
242+
}
243+
}
244+
245+
return { defaultChildren, namedSlotProps: slotProps }
246+
})
247+
248+
let componentProps = $derived(
249+
Object.keys(namedSlotProps).length > 0
250+
? { ...mappedProps, ...namedSlotProps }
251+
: mappedProps,
252+
)
162253
</script>
163254

164255
{#snippet renderChildren()}
165-
{#each children as child, i (i)}
256+
{#each defaultChildren as child, i (i)}
166257
<ComarkNode
167-
node={child}
258+
node={child.node}
168259
{components}
169260
{componentsManifest}
170261
resolver={Resolver}
171-
caretClass={i === children.length - 1 ? caretClass : null}
262+
caretClass={child.caretClass}
172263
renderData={childrenRenderData}
173264
/>
174265
{/each}
@@ -180,11 +271,11 @@ naturally appears inline after the deepest trailing text node.
180271
style={CARET_STYLE}>{CARET_TEXT}</span
181272
>{/if}
182273
{:else if Component}
183-
<Component {...mappedProps}>
274+
<Component {...componentProps}>
184275
{@render renderChildren()}
185276
</Component>
186277
{:else if componentPromise}
187-
<Resolver promise={componentPromise} props={mappedProps}>
278+
<Resolver promise={componentPromise} props={componentProps}>
188279
{@render renderChildren()}
189280
</Resolver>
190281
{:else if isVoid}

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

Lines changed: 20 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 CardWithFooter from './test-components/CardWithFooter.svelte'
78
import ProseH1 from './test-components/ProseH1.svelte'
89

910
describe('ComarkNode', () => {
@@ -172,6 +173,25 @@ describe('custom components', () => {
172173
await expect.element(screen.getByRole('alert')).toHaveClass('alert-info')
173174
})
174175

176+
it('passes named slots as Svelte snippet props', async () => {
177+
const tree = await parse(`::card{title="My Card"}
178+
Default slot content.
179+
180+
#footer
181+
Footer slot content.
182+
::`)
183+
const screen = render(ComarkRenderer, {
184+
tree,
185+
components: { card: CardWithFooter },
186+
})
187+
const footer = screen.container.querySelector<HTMLElement>('footer')!
188+
expect(footer).not.toBeNull()
189+
await expect.element(screen.getByRole('heading', { name: 'My Card', level: 3 })).toBeInTheDocument()
190+
await expect.element(screen.getByText('Default slot content.')).toBeInTheDocument()
191+
await expect.element(footer).toHaveTextContent('Footer slot content.')
192+
expect(screen.container.querySelector('template[name="footer"]')).toBeNull()
193+
})
194+
175195
it('resolves custom components from componentsManifest', async () => {
176196
const tree = await parse('::alert{type="warning"}\nLazy content\n::')
177197
const screen = render(ComarkRenderer, {

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

Lines changed: 41 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 CardWithFooter from './test-components/CardWithFooter.svelte'
910
import ProseH1 from './test-components/ProseH1.svelte'
1011

1112
/** Strip Svelte SSR hydration comments from rendered HTML */
@@ -247,6 +248,23 @@ describe('custom components', () => {
247248
expect(output).toContain(' text')
248249
})
249250

251+
it('passes named slots as Svelte snippet props during SSR', async () => {
252+
const tree = await parse(`::card{title="My Card"}
253+
Default slot content.
254+
255+
#footer
256+
Footer slot content.
257+
::`)
258+
const { body } = render(ComarkRenderer, {
259+
props: { tree, components: { card: CardWithFooter } },
260+
})
261+
const output = html(body)
262+
expect(output).toContain('<h3>My Card</h3>')
263+
expect(output).toContain('<p>Default slot content.</p>')
264+
expect(output).toContain('<footer>Footer slot content.</footer>')
265+
expect(output).not.toContain('<template')
266+
})
267+
250268
it('resolves eager componentsManifest entries during SSR', async () => {
251269
const tree = await parse('::alert{type="warning"}\nLazy content\n::')
252270
const { body } = render(ComarkRenderer, {
@@ -281,6 +299,29 @@ describe('custom components', () => {
281299
expect(output).toContain('Lazy content')
282300
})
283301

302+
it('passes named slots through async componentsManifest entries during SSR', async () => {
303+
const { body } = await render(ComarkAsync, {
304+
props: {
305+
markdown: `::card{title="Async Card"}
306+
Default slot content.
307+
308+
#footer
309+
Footer slot content.
310+
::`,
311+
componentsManifest: (name: string) => {
312+
if (name === 'card') {
313+
return Promise.resolve({ default: CardWithFooter })
314+
}
315+
},
316+
},
317+
})
318+
const output = html(body)
319+
expect(output).toContain('<h3>Async Card</h3>')
320+
expect(output).toContain('<p>Default slot content.</p>')
321+
expect(output).toContain('<footer>Footer slot content.</footer>')
322+
expect(output).not.toContain('<template')
323+
})
324+
284325
it('keeps componentsManifest caches isolated by manifest function', async () => {
285326
await render(ComarkAsync, {
286327
props: {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script lang="ts">
2+
import type { Snippet } from 'svelte'
3+
4+
let {
5+
title = '',
6+
children,
7+
footer,
8+
}: {
9+
title?: string
10+
children?: Snippet
11+
footer?: Snippet
12+
} = $props()
13+
</script>
14+
15+
<div class="card">
16+
<h3>{title}</h3>
17+
{@render children?.()}
18+
<footer>{@render footer?.()}</footer>
19+
</div>

0 commit comments

Comments
 (0)