Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ packages/comark-svelte/
│ │ ├── Comark.svelte # High-level markdown → render ($state + $effect)
│ │ ├── ComarkRenderer.svelte # Low-level AST → render component
│ │ ├── ComarkNode.svelte # Recursive AST node renderer
│ │ ├── ComarkComponent.svelte # Custom component renderer with named snippets
│ │ └── Resolve.svelte # Stable promise resolver for lazy components
│ ├── async/
│ │ ├── index.ts # Async export (@comark/svelte/async)
Expand Down
78 changes: 78 additions & 0 deletions packages/comark-svelte/src/components/ComarkComponent.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<script lang="ts">
import type { ComarkNode as ComarkNodeType, ComponentManifest, NodeRenderData } from 'comark'
import type { Snippet } from 'svelte'
import type { ComponentResolver } from '../types.js'
import ComarkNode from './ComarkNode.svelte'
import Resolve from './Resolve.svelte'
import ComarkComponent from './ComarkComponent.svelte'

interface NamedSlot {
name: string
children: ComarkNodeType[]
caretClass: string | null
}

const EMPTY_RENDER_DATA: NodeRenderData = { frontmatter: {}, meta: {}, data: {}, props: {} }

let {
Component = null,
componentPromise = null,
props = {},
namedSlots = [],
slotIndex = 0,
components = {},
componentsManifest,
resolver: Resolver = Resolve,
renderData = EMPTY_RENDER_DATA,
children,
}: {
Component?: any
componentPromise?: Promise<any> | null
props?: Record<string, any>
namedSlots?: NamedSlot[]
slotIndex?: number
components?: Record<string, any>
componentsManifest?: ComponentManifest
resolver?: ComponentResolver
renderData?: NodeRenderData
children?: Snippet
} = $props()
</script>

{#if slotIndex < namedSlots.length}
{@const slot = namedSlots[slotIndex]}
{#snippet namedSlot()}
{#each slot.children as child, i (i)}
<ComarkNode
node={child}
{components}
{componentsManifest}
resolver={Resolver}
caretClass={i === slot.children.length - 1 ? slot.caretClass : null}
{renderData}
/>
{/each}
{/snippet}

<ComarkComponent
{Component}
{componentPromise}
props={{ ...props, [slot.name]: namedSlot }}
{namedSlots}
slotIndex={slotIndex + 1}
{components}
{componentsManifest}
resolver={Resolver}
{renderData}
>
{@render children?.()}
</ComarkComponent>
{:else if Component}
<Component {...props}>
{@render children?.()}
</Component>
{:else if componentPromise}
<Resolver promise={componentPromise} {props}>
{@render children?.()}
</Resolver>
{/if}
78 changes: 41 additions & 37 deletions packages/comark-svelte/src/components/ComarkNode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ naturally appears inline after the deepest trailing text node.

<script lang="ts">
import type { ComarkNode as ComarkNodeType, ComponentManifest, NodeRenderData } from 'comark'
import type { Snippet } from 'svelte'
import type { ComponentResolver } from '../types.js'
import ComarkNode from './ComarkNode.svelte'
import ComarkComponent from './ComarkComponent.svelte'
import Resolve from './Resolve.svelte'
import { resolveAttributes } from 'comark/utils'

Expand Down Expand Up @@ -106,6 +106,12 @@ naturally appears inline after the deepest trailing text node.
caretClass: string | null
}

interface NamedSlot {
name: string
children: ComarkNodeType[]
caretClass: string | null
}

function getSlotName(node: ComarkNodeType): string | null {
if (typeof node === 'string' || !Array.isArray(node) || node[0] !== 'template') {
return null
Expand All @@ -125,26 +131,6 @@ naturally appears inline after the deepest trailing text node.
return null
}

function createChildrenSnippet(
snippetChildren: ComarkNodeType[],
snippetRenderData: NodeRenderData,
snippetCaretClass: string | null,
): Snippet {
return ((anchor: unknown) => {
const renderNode = ComarkNode as unknown as (anchor: unknown, props: Record<string, unknown>) => void
for (let i = 0; i < snippetChildren.length; i++) {
renderNode(anchor, {
node: snippetChildren[i],
components,
componentsManifest,
resolver: Resolver,
caretClass: i === snippetChildren.length - 1 ? snippetCaretClass : null,
renderData: snippetRenderData,
})
}
}) as unknown as Snippet
}

function toRenderChildren(
sourceChildren: ComarkNodeType[],
sourceIndex: number,
Expand Down Expand Up @@ -217,9 +203,9 @@ naturally appears inline after the deepest trailing text node.
: renderData,
)

let { defaultChildren, namedSlotProps } = $derived.by(() => {
let { defaultChildren, namedSlots } = $derived.by(() => {
const defaultChildren: RenderChild[] = []
const slotProps: Record<string, Snippet> = {}
const slots: NamedSlot[] = []

for (let i = 0; i < children.length; i++) {
const child = children[i]
Expand All @@ -230,26 +216,20 @@ naturally appears inline after the deepest trailing text node.
defaultChildren.push(...toRenderChildren(slotChildren, i, children.length, caretClass))
}
else {
slotProps[slotName] = createChildrenSnippet(
slotChildren,
childrenRenderData,
i === children.length - 1 ? caretClass : null,
)
slots.push({
name: slotName,
children: slotChildren,
caretClass: i === children.length - 1 ? caretClass : null,
})
}
}
else {
defaultChildren.push({ node: child, caretClass: i === children.length - 1 ? caretClass : null })
}
}

return { defaultChildren, namedSlotProps: slotProps }
return { defaultChildren, namedSlots: slots }
})

let componentProps = $derived(
Object.keys(namedSlotProps).length > 0
? { ...mappedProps, ...namedSlotProps }
: mappedProps,
)
</script>

{#snippet renderChildren()}
Expand All @@ -270,12 +250,36 @@ naturally appears inline after the deepest trailing text node.
class={caretClass || undefined}
style={CARET_STYLE}>{CARET_TEXT}</span
>{/if}
{:else if Component && namedSlots.length > 0}
<ComarkComponent
{Component}
props={mappedProps}
{namedSlots}
{components}
{componentsManifest}
resolver={Resolver}
renderData={childrenRenderData}
>
{@render renderChildren()}
</ComarkComponent>
{:else if Component}
<Component {...componentProps}>
<Component {...mappedProps}>
{@render renderChildren()}
</Component>
{:else if componentPromise && namedSlots.length > 0}
<ComarkComponent
{componentPromise}
props={mappedProps}
{namedSlots}
{components}
{componentsManifest}
resolver={Resolver}
renderData={childrenRenderData}
>
{@render renderChildren()}
</ComarkComponent>
{:else if componentPromise}
<Resolver promise={componentPromise} props={componentProps}>
<Resolver promise={componentPromise} props={mappedProps}>
{@render renderChildren()}
</Resolver>
{:else if isVoid}
Expand Down
29 changes: 29 additions & 0 deletions packages/comark-svelte/test/ComarkNode.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { parse } from 'comark'
import ComarkRenderer from '../src/components/ComarkRenderer.svelte'
import ComarkNode from '../src/components/ComarkNode.svelte'
import Alert from './test-components/Alert.svelte'
import CardWithHeaderFooter from './test-components/CardWithHeaderFooter.svelte'
import CardWithFooter from './test-components/CardWithFooter.svelte'
import ProseH1 from './test-components/ProseH1.svelte'

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

it('passes multiple named slots as Svelte snippet props', async () => {
const tree = await parse(`::card{title="My Card"}
Default slot content.

#header
Header slot content.

#footer
Footer slot content.
::`)
const screen = render(ComarkRenderer, {
tree,
components: { card: CardWithHeaderFooter },
})
const header = screen.container.querySelector<HTMLElement>('header')!
const main = screen.container.querySelector<HTMLElement>('main')!
const footer = screen.container.querySelector<HTMLElement>('footer')!

expect(header).not.toBeNull()
expect(main).not.toBeNull()
expect(footer).not.toBeNull()
await expect.element(header).toHaveTextContent('Header slot content.')
await expect.element(main).toHaveTextContent('Default slot content.')
await expect.element(footer).toHaveTextContent('Footer slot content.')
expect(screen.container.querySelector('template[name="header"]')).toBeNull()
expect(screen.container.querySelector('template[name="footer"]')).toBeNull()
})

it('resolves custom components from componentsManifest', async () => {
const tree = await parse('::alert{type="warning"}\nLazy content\n::')
const screen = render(ComarkRenderer, {
Expand Down
21 changes: 21 additions & 0 deletions packages/comark-svelte/test/ComarkNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ComarkNode from '../src/components/ComarkNode.svelte'
import ComarkAsync from '../src/async/ComarkAsync.svelte'
import Alert from './test-components/Alert.svelte'
import Card from './test-components/Card.svelte'
import CardWithHeaderFooter from './test-components/CardWithHeaderFooter.svelte'
import CardWithFooter from './test-components/CardWithFooter.svelte'
import ProseH1 from './test-components/ProseH1.svelte'

Expand Down Expand Up @@ -265,6 +266,26 @@ Footer slot content.
expect(output).not.toContain('<template')
})

it('passes multiple named slots as Svelte snippet props during SSR', async () => {
const tree = await parse(`::card{title="My Card"}
Default slot content.

#header
Header slot content.

#footer
Footer slot content.
::`)
const { body } = render(ComarkRenderer, {
props: { tree, components: { card: CardWithHeaderFooter } },
})
const output = html(body)
expect(output).toContain('<header>Header slot content.</header>')
expect(output).toContain('<main><p>Default slot content.</p></main>')
expect(output).toContain('<footer>Footer slot content.</footer>')
expect(output).not.toContain('<template')
})

it('resolves eager componentsManifest entries during SSR', async () => {
const tree = await parse('::alert{type="warning"}\nLazy content\n::')
const { body } = render(ComarkRenderer, {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script lang="ts">
import type { Snippet } from 'svelte'

let {
title = '',
children,
header,
footer,
}: {
title?: string
children?: Snippet
header?: Snippet
footer?: Snippet
} = $props()
</script>

<section class="card">
<h3>{title}</h3>
<header>{@render header?.()}</header>
<main>{@render children?.()}</main>
<footer>{@render footer?.()}</footer>
</section>
Loading