From d19eee7aea039eb62046c37a5e360325e60e7fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 11 Sep 2026 15:46:16 +0200 Subject: [PATCH 1/5] feat(binding): add scoped For iteration across renderers Written by an AI agent; not yet human-reviewed. --- AGENTS.md | 11 ++ docs/content/4.plugins/1.built-in/binding.md | 92 ++++++++++++- .../8.examples/3.plugins/vue-vite-binding.md | 22 ++- examples/3.plugins/vue-vite-binding/README.md | 22 ++- .../3.plugins/vue-vite-binding/src/App.vue | 130 +++++++++++++++++- .../src/components/for.component.ts | 7 + .../src/components/markdown-node.component.ts | 19 ++- .../comark-angular/src/plugins/binding.ts | 2 + .../test/plugin-binding.test.ts | 16 ++- packages/comark-ansi/src/plugins/binding.ts | 27 +++- .../comark-ansi/test/plugin-binding.test.ts | 18 ++- packages/comark-html/src/plugins/binding.ts | 27 +++- .../comark-html/test/plugin-binding.test.ts | 16 ++- packages/comark-react/package.json | 2 + packages/comark-react/src/components/For.tsx | 6 + .../src/components/MarkdownDocument.tsx | 25 ++++ packages/comark-react/src/plugins/binding.ts | 2 + .../test/fixtures/for-browser.html | 12 ++ .../comark-react/test/fixtures/for-browser.ts | 25 ++++ .../comark-react/test/plugin-binding.test.tsx | 14 +- .../test/plugin-for.browser.test.ts | 41 ++++++ packages/comark-svelte/src/components/For.ts | 2 + .../src/components/MarkdownNode.svelte | 13 ++ packages/comark-svelte/src/plugins/binding.ts | 2 + .../test/plugin-binding.svelte.test.ts | 26 +++- .../comark-svelte/test/plugin-binding.test.ts | 16 ++- packages/comark-vue/package.json | 1 + packages/comark-vue/src/components/For.ts | 14 ++ .../src/components/MarkdownDocument.ts | 26 ++++ packages/comark-vue/src/plugins/binding.ts | 2 + .../comark-vue/test/fixtures/for-browser.html | 12 ++ .../comark-vue/test/fixtures/for-browser.ts | 28 ++++ .../comark-vue/test/plugin-binding.test.ts | 16 ++- .../test/plugin-for.browser.test.ts | 41 ++++++ .../src/internal/stringify/attributes.ts | 2 + packages/comark/src/plugins/binding.ts | 72 +++++++++- packages/comark/src/types.ts | 2 + packages/comark/test/plugins/binding.test.ts | 25 ++++ pnpm-lock.yaml | 9 ++ test/fixtures/for.ts | 45 ++++++ 40 files changed, 858 insertions(+), 32 deletions(-) create mode 100644 packages/comark-angular/src/components/for.component.ts create mode 100644 packages/comark-react/src/components/For.tsx create mode 100644 packages/comark-react/test/fixtures/for-browser.html create mode 100644 packages/comark-react/test/fixtures/for-browser.ts create mode 100644 packages/comark-react/test/plugin-for.browser.test.ts create mode 100644 packages/comark-svelte/src/components/For.ts create mode 100644 packages/comark-vue/src/components/For.ts create mode 100644 packages/comark-vue/test/fixtures/for-browser.html create mode 100644 packages/comark-vue/test/fixtures/for-browser.ts create mode 100644 packages/comark-vue/test/plugin-for.browser.test.ts create mode 100644 test/fixtures/for.ts diff --git a/AGENTS.md b/AGENTS.md index de7eb372..c533ff11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -881,3 +881,14 @@ After each change, ask: - [ ] Does AGENTS.md reflect the current architecture? - [ ] Are all public APIs documented in Package Exports Reference? - [ ] Are the docs pages accurate and up-to-date? + +### Binding structural components + +Renderer-specific `plugins/binding` entry points export `For` alongside `Binding` and `If`. +`For` repeats default AST children for an array, with `item` and optional `index` aliases, +optional item-property `key`, and an `#empty` slot. Core helpers `resolveForIterations` +and `selectForBranch` live in `packages/comark/src/plugins/binding.ts`. +`NodeRenderData.scope` preserves lexical loop aliases through descendant component props; +attribute resolution exposes those aliases under `props`. Vue/React keyed fragments and +Svelte keyed each blocks preserve iteration identity; Angular currently rebuilds descendants. +Cross-renderer iteration cases live in `test/fixtures/for.ts`. diff --git a/docs/content/4.plugins/1.built-in/binding.md b/docs/content/4.plugins/1.built-in/binding.md index 5498f7fd..91e1897f 100644 --- a/docs/content/4.plugins/1.built-in/binding.md +++ b/docs/content/4.plugins/1.built-in/binding.md @@ -1,6 +1,6 @@ --- title: Binding -description: "Interpolate data with `{{ path || default }}` and conditionally render content with `::if`." +description: "Interpolate data with `{{ path || default }}` and conditionally render content with `::if`, and repeat content with `::for`." navigation: icon: i-lucide-replace seo: @@ -18,7 +18,7 @@ links: variant: soft --- -The `comark/plugins/binding` module lets you interpolate values with `{{ path || default }}` and conditionally render content with `::if`. Values can come from frontmatter, the renderer's `data` prop, the tree's `meta`, or a parent component's `props`. +The `comark/plugins/binding` module lets you interpolate values with `{{ path || default }}` and conditionally render content with `::if`, and repeat content with `::for`. Values can come from frontmatter, the renderer's `data` prop, the tree's `meta`, or a parent component's `props`. The `binding()` parser plugin emits a `binding` component node whose `:value` attribute points at a dot-path. The [data binding](/syntax/components#data-binding) layer resolves that path against the ambient render context, so bindings work across HTML, ANSI, Vue, React, Svelte, Angular, and Nuxt, and round-trip back to their source form via `renderMarkdown`. @@ -252,6 +252,79 @@ I am NOT fine and NOT happy. Each `#else` belongs to its enclosing component. Only the selected branch renders, and `as` wraps whichever branch is selected. Without an else slot, a failed condition produces no output. This works with all renderer-specific `If` exports. +## Repeating content + +Register `For` alongside `Binding` to render Markdown once per array item: + +```typescript [render.ts] +import { renderHtml } from '@comark/html' +import binding, { Binding, For } from '@comark/html/plugins/binding' + +const markdown = ` +::for{:each="data.posts" item="post" key="id"} +### {{ props.post.title }} + +{{ props.post.description }} +#empty +No posts published yet. +:: +` + +const html = await renderHtml(markdown, { + plugins: [binding()], + components: { Binding, For }, + data: { + posts: [{ id: 'hello', title: 'Hello', description: 'Our first post.' }], + }, +}) +``` + +Import from your renderer's binding entry point (`html`, `ansi`, `vue`, `react`, `svelte`, `angular`, or `nuxt`). Register `For` directly in the component map. Like `If`, the block syntax uses the default component parser; `binding()` is only needed for `{{ … }}` interpolation. + +### Item and index aliases + +`item` names the current value in `props`; its default name is `item`. Add an `index` alias to expose the zero-based array position: + +```mdc +::for{:each="data.posts" item="post" index="position" key="id"} +{{ props.position }}: {{ props.post.title }} +:: +``` + +Arrays can contain objects or primitive values. Loop aliases remain available inside headings, attributed elements, and nested components. An inner loop can reference outer aliases; reusing an alias shadows it only within the inner loop. Aliases take precedence over component props of the same name and do not leak into following siblings. + +```mdc +::for{:each="data.posts" item="post" key="id"} +:::for{:each="props.post.tags" item="tag"} +{{ props.post.title }}: {{ props.tag }} +::: +:: +``` + +Register `If` too when combining loops with conditional content: + +```mdc +::for{:each="data.posts" item="post" key="id"} +:::if{:value="props.post.published"} +{{ props.post.title }} +#else +Draft: {{ props.post.title }} +::: +:: +``` + +### Empty lists + +`#empty` renders once when `each` is an empty array, `null`, or `undefined`. Without it, an empty list produces no output. Other non-array values are invalid. The default branch is not evaluated for an empty list, and the empty branch is not evaluated for a populated list. An explicit `#default` slot is also supported. + +### Stable keys + +`key="id"` reads each item's `id` property; nested paths such as `key="metadata.id"` work too. Values must be unique strings or finite numbers. Missing, invalid, or duplicate keys throw an error. Without `key`, iterations use their array positions. + +Vue, React, and Svelte use these keys to preserve an item's rendered components and uncontrolled input state when the array is reordered. Nuxt uses the Vue implementation. HTML and ANSI have no persistent DOM state. Angular currently rebuilds rendered descendants when its inputs change, so keys do not preserve component state there. + +`For` adds no wrapper element. Put wrapper elements inside its default slot when needed. + ## Markdown round-trip When you re-serialize the AST with `renderMarkdown`, you can pass the core `Binding` handler to preserve the original `{{ … }}` shorthand: @@ -382,6 +455,21 @@ import { } from 'comark/plugins/binding' ``` +### `For` + +Every renderer-specific binding entry point exports `For`. Register it with `components: { Binding, For }` when using interpolation inside loops. + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `each` | `unknown[]`, `null`, or `undefined` | `undefined` | Array to iterate; nullish values select `#empty` | +| `item` | `string` | `"item"` | Alias for the current item under `props` | +| `index` | `string` | — | Optional alias for the zero-based array position | +| `key` | `string` | — | Item property path used for stable identity | + +`item` and `index` must be distinct, non-empty names without dots or prototype keys. Slots: default content repeats per item; `#empty` renders once for an empty collection. See [Repeating content](#repeating-content) for Markdown examples and renderer-specific key behavior. + +The core entry point exports `ForProps`, `ForIteration`, `resolveForIterations(props, renderData)`, and `selectForBranch(children, empty)` for renderer adapters. Each iteration contains a key and a render context with lexical aliases in `scope`; attribute resolution makes those aliases available under `props`. + ## Use cases 1. **Personalized content**: greet users by name from frontmatter or runtime data: diff --git a/docs/content/8.examples/3.plugins/vue-vite-binding.md b/docs/content/8.examples/3.plugins/vue-vite-binding.md index 824f552e..4766a9ec 100644 --- a/docs/content/8.examples/3.plugins/vue-vite-binding.md +++ b/docs/content/8.examples/3.plugins/vue-vite-binding.md @@ -1,6 +1,6 @@ --- -title: Binding (frontmatter + data) -description: Example showing how to interpolate frontmatter and runtime data into Markdown using the Comark `binding` plugin in Vue and Vite. +title: Binding, conditions, and loops +description: Try live Markdown bindings, If branches, and For loops with editable posts in Vue and Vite. navigation: icon: i-lucide-replace --- @@ -23,12 +23,26 @@ This example demonstrates the Comark `binding` plugin in a Vue + Vite app: - **Parent props** — nested components can reference their enclosing component's resolved attributes via `props.`. - **Typed values** — bindings come through as real JS values (strings, numbers, objects) thanks to the shared data-binding layer. +## Try repeated content + +Run `pnpm dev:binding` from the repository root. In **Posts**, edit titles and descriptions, toggle **Published**, add or remove posts, or use **Reverse order**. **Clear posts** selects the `#empty` slot. **Reset** restores the initial posts. The highlighted **Source** view includes the `For` block and its nested `If`. + +```mdc +::for{:each="data.posts" item="post" index="position" key="id"} +### {{ props.post.title }} + +{{ props.post.description }} +#empty +No posts published yet. +:: +``` + ## Usage 1. Import the plugin and its matching Vue component: ```ts - import binding, { Binding } from '@comark/vue/plugins/binding' + import binding, { Binding, For, If } from '@comark/vue/plugins/binding' ``` 2. Wire them into ``: @@ -37,7 +51,7 @@ This example demonstrates the Comark `binding` plugin in a Vue + Vite app: ``` diff --git a/examples/3.plugins/vue-vite-binding/README.md b/examples/3.plugins/vue-vite-binding/README.md index c374fe09..aee9257c 100644 --- a/examples/3.plugins/vue-vite-binding/README.md +++ b/examples/3.plugins/vue-vite-binding/README.md @@ -1,6 +1,6 @@ --- -title: Binding and conditional content -description: Update Markdown bindings and If branches with live form controls using Vue and Vite. +title: Binding, conditions, and loops +description: Try live Markdown bindings, If branches, and For loops with editable posts in Vue and Vite. navigation: icon: i-lucide-replace category: Plugins @@ -33,12 +33,26 @@ Run `pnpm dev:binding` from the repository root. Change the role, move the age s The layout follows [Vercel's design guidance](https://vercel.com/design.md), with Comark branding. It loads the published Vercel CSS foundation and Geist fonts over the network. +## Try repeated content + +Run `pnpm dev:binding` from the repository root. In **Posts**, edit titles and descriptions, toggle **Published**, add or remove posts, or use **Reverse order**. **Clear posts** selects the `#empty` slot. **Reset** restores the initial posts. The highlighted **Source** view includes the `For` block and its nested `If`. + +```mdc +::for{:each="data.posts" item="post" index="position" key="id"} +### {{ props.post.title }} + +{{ props.post.description }} +#empty +No posts published yet. +:: +``` + ## Usage 1. Import the plugin and its Vue components: ```ts - import binding, { Binding, If } from '@comark/vue/plugins/binding' + import binding, { Binding, For, If } from '@comark/vue/plugins/binding' ``` 2. Wire them into ``: @@ -47,7 +61,7 @@ The layout follows [Vercel's design guidance](https://vercel.com/design.md), wit ``` diff --git a/examples/3.plugins/vue-vite-binding/src/App.vue b/examples/3.plugins/vue-vite-binding/src/App.vue index 1436f2dd..245c7f62 100644 --- a/examples/3.plugins/vue-vite-binding/src/App.vue +++ b/examples/3.plugins/vue-vite-binding/src/App.vue @@ -1,7 +1,7 @@ + + diff --git a/packages/comark-react/test/fixtures/for-browser.ts b/packages/comark-react/test/fixtures/for-browser.ts new file mode 100644 index 00000000..ec5850c7 --- /dev/null +++ b/packages/comark-react/test/fixtures/for-browser.ts @@ -0,0 +1,25 @@ +import { parseMarkdown } from 'comark' +import binding, { Binding, For } from '../../src/plugins/binding' +import { MarkdownDocument } from '../../src/components/MarkdownDocument' +import React from 'react' +import { createRoot } from 'react-dom/client' + +export async function mount(): Promise { + const value = await parseMarkdown( + '::for{:each="data.posts" item="post" key="id"}\n:input{:aria-label="props.post.title"}\n\n{{ props.post.title }}\n#empty\nNo posts\n::', + { plugins: [binding()] } + ) + const components = { Binding, For } + const container = document.createElement('div') + document.body.append(container) + const initial = [ + { id: 'a', title: 'Alpha' }, + { id: 'b', title: 'Beta' }, + ] + const root = createRoot(container) + const updatePosts = (posts: typeof initial): void => { + root.render(React.createElement(MarkdownDocument, { value, components, data: { posts } })) + } + updatePosts(initial) + Object.assign(window, { updatePosts }) +} diff --git a/packages/comark-react/test/plugin-binding.test.tsx b/packages/comark-react/test/plugin-binding.test.tsx index bf730875..58b12d36 100644 --- a/packages/comark-react/test/plugin-binding.test.tsx +++ b/packages/comark-react/test/plugin-binding.test.tsx @@ -1,8 +1,9 @@ +import { forCases } from '../../../test/fixtures/for' import { describe, expect, it } from 'vitest' import { renderToString } from 'react-dom/server' import { parseMarkdown } from 'comark' import { MarkdownDocument } from '../src/components/MarkdownDocument' -import binding, { Binding, If } from '../src/plugins/binding' +import binding, { Binding, For, If } from '../src/plugins/binding' import { nestedIfCases, nestedIfMarkdown } from '../../../test/fixtures/if' async function renderMarkdown(markdown: string, props: Record = {}) { @@ -10,7 +11,7 @@ async function renderMarkdown(markdown: string, props: Record = {}) const html = renderToString( ) @@ -69,3 +70,12 @@ describe('@comark/react plugins/binding — If component', () => { expect(await renderMarkdown(markdown, { data: { age: 17 } })).not.toContain('Adult') }) }) + +it.each(forCases)('For: $name', async ({ markdown, data, expected, absent }) => { + const output = (await renderMarkdown(markdown, { data })) + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim() + for (const text of expected) expect(output).toContain(text) + for (const text of absent) expect(output).not.toContain(text) +}) diff --git a/packages/comark-react/test/plugin-for.browser.test.ts b/packages/comark-react/test/plugin-for.browser.test.ts new file mode 100644 index 00000000..cb704b78 --- /dev/null +++ b/packages/comark-react/test/plugin-for.browser.test.ts @@ -0,0 +1,41 @@ +import { expect, it } from 'vitest' +import { createServer } from 'vite' +import { chromium } from 'playwright' +import { fileURLToPath } from 'node:url' + +it('preserves keyed inputs on reorder and item updates, then selects empty', async () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const server = await createServer({ configFile: false, root, server: { host: '127.0.0.1', port: 0 } }) + await server.listen() + const browser = await chromium.launch({ headless: true }) + try { + const page = await browser.newPage() + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + const address = server.httpServer!.address() as { port: number } + await page.goto(`http://127.0.0.1:${address.port}/test/fixtures/for-browser.html`) + await page.getByRole('textbox', { name: 'Alpha' }).fill('Keep this draft') + await page.evaluate(() => { + Object.assign(window, { originalInput: document.querySelector('input') }) + ;(window as any).updatePosts([ + { id: 'b', title: 'Beta' }, + { id: 'a', title: 'Alpha' }, + ]) + }) + await expect.poll(() => page.locator('input').last().getAttribute('aria-label')).toBe('Alpha') + expect(await page.evaluate(() => document.querySelectorAll('input')[1] === (window as any).originalInput)).toBe( + true + ) + expect(await page.getByRole('textbox', { name: 'Alpha' }).inputValue()).toBe('Keep this draft') + await page.evaluate(() => (window as any).updatePosts([{ id: 'a', title: 'Updated' }])) + await expect.poll(() => page.locator('input').first().getAttribute('aria-label')).toBe('Updated') + expect(await page.evaluate(() => document.querySelector('input') === (window as any).originalInput)).toBe(true) + await page.evaluate(() => (window as any).updatePosts([])) + await expect.poll(() => page.locator('body').innerText()).toContain('No posts') + expect(await page.locator('input').count()).toBe(0) + expect(errors).toEqual([]) + } finally { + await browser.close() + await server.close() + } +}, 30_000) diff --git a/packages/comark-svelte/src/components/For.ts b/packages/comark-svelte/src/components/For.ts new file mode 100644 index 00000000..5c2625ca --- /dev/null +++ b/packages/comark-svelte/src/components/For.ts @@ -0,0 +1,2 @@ +/** Structural marker interpreted by the Markdown renderer. */ +export const For = Object.assign(() => null, { __comarkFor: true }) diff --git a/packages/comark-svelte/src/components/MarkdownNode.svelte b/packages/comark-svelte/src/components/MarkdownNode.svelte index dc9db091..b0fbe88e 100644 --- a/packages/comark-svelte/src/components/MarkdownNode.svelte +++ b/packages/comark-svelte/src/components/MarkdownNode.svelte @@ -67,6 +67,7 @@ naturally appears inline after the deepest trailing text node. {#snippet renderChildren()} @@ -250,6 +253,16 @@ naturally appears inline after the deepest trailing text node. class={caretClass || undefined} style={CARET_STYLE}>{CARET_TEXT}{/if} +{:else if Component?.__comarkFor} + {#each forIterations as iteration (iteration.key)} + {#each forChildren as child, i (i)} + + {/each} + {:else} + {#each forChildren as child, i (i)} + + {/each} + {/each} {:else if Component && namedSlots.length > 0} = {}) { const tree = await parseMarkdown(markdown, { plugins: [binding()] }) return render(MarkdownDocument, { value: tree, - components: { binding: Binding, If }, + components: { binding: Binding, For, If }, ...props, }) } @@ -69,3 +69,25 @@ describe('@comark/svelte plugins/binding — If component', () => { expect(hidden.container.textContent).not.toContain('Adult') }) }) + +it('keeps keyed inputs and their values on reorder, updates items, and renders empty', async () => { + const posts = [ + { id: 'a', title: 'Alpha' }, + { id: 'b', title: 'Beta' }, + ] + const screen = await renderMarkdown( + '::for{:each="data.posts" item="post" key="id"}\n:input{:aria-label="props.post.title"}\n\n{{ props.post.title }}\n#empty\nNo posts\n::', + { data: { posts } } + ) + const alpha = screen.container.querySelector('input[aria-label="Alpha"]') as HTMLInputElement + alpha.value = 'Keep this draft' + await screen.rerender({ data: { posts: posts.toReversed() } }) + expect(screen.container.querySelectorAll('input')[1]).toBe(alpha) + expect(alpha.value).toBe('Keep this draft') + await screen.rerender({ data: { posts: [{ id: 'a', title: 'Updated' }] } }) + expect(screen.container.textContent).toContain('Updated') + expect(screen.container.querySelector('input')).toBe(alpha) + await screen.rerender({ data: { posts: [] } }) + expect(screen.container.textContent).toContain('No posts') + expect(screen.container.querySelector('input')).toBeNull() +}) diff --git a/packages/comark-svelte/test/plugin-binding.test.ts b/packages/comark-svelte/test/plugin-binding.test.ts index e6e2b655..deb84cf2 100644 --- a/packages/comark-svelte/test/plugin-binding.test.ts +++ b/packages/comark-svelte/test/plugin-binding.test.ts @@ -1,14 +1,26 @@ +import { forCases } from '../../../test/fixtures/for' import { describe, expect, it } from 'vitest' import { render } from 'svelte/server' import { parseMarkdown } from 'comark' import MarkdownDocument from '../src/components/MarkdownDocument.svelte' -import { If } from '../src/plugins/binding' +import binding, { Binding, For, If } from '../src/plugins/binding' import { nestedIfCases, nestedIfMarkdown } from '../../../test/fixtures/if' describe('@comark/svelte plugins/binding — If SSR', () => { it.each(nestedIfCases)('selects nested branches for $data', async ({ data, expected }) => { const value = await parseMarkdown(nestedIfMarkdown) - const { body } = render(MarkdownDocument, { props: { value, components: { If }, data } }) + const { body } = render(MarkdownDocument, { props: { value, components: { Binding, For, If }, data } }) expect(body.replace(/<[^>]*>/g, '').trim()).toBe(expected) }) }) + +it.each(forCases)('For: $name', async ({ markdown, data, expected, absent }) => { + const output = render(MarkdownDocument, { + props: { value: await parseMarkdown(markdown, { plugins: [binding()] }), components: { Binding, For, If }, data }, + }) + .body.replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim() + for (const text of expected) expect(output).toContain(text) + for (const text of absent) expect(output).not.toContain(text) +}) diff --git a/packages/comark-vue/package.json b/packages/comark-vue/package.json index 8185d827..ed11f41b 100644 --- a/packages/comark-vue/package.json +++ b/packages/comark-vue/package.json @@ -54,6 +54,7 @@ "devDependencies": { "@vue/compiler-core": "catalog:", "@vue/server-renderer": "catalog:", + "playwright": "catalog:", "vite": "catalog:", "vitest": "catalog:", "vue": "catalog:" diff --git a/packages/comark-vue/src/components/For.ts b/packages/comark-vue/src/components/For.ts new file mode 100644 index 00000000..747e0ca7 --- /dev/null +++ b/packages/comark-vue/src/components/For.ts @@ -0,0 +1,14 @@ +import { defineComponent, type PropType, type VNodeChild } from 'vue' + +/** Structural component whose children are evaluated only when rendered. */ +export const For = Object.assign( + defineComponent({ + name: 'For', + inheritAttrs: false, + props: { __render: { type: Function as PropType<() => VNodeChild>, required: true } }, + setup(props) { + return () => props.__render() + }, + }), + { __comarkFor: true } +) diff --git a/packages/comark-vue/src/components/MarkdownDocument.ts b/packages/comark-vue/src/components/MarkdownDocument.ts index 530870c3..0cb6f745 100644 --- a/packages/comark-vue/src/components/MarkdownDocument.ts +++ b/packages/comark-vue/src/components/MarkdownDocument.ts @@ -1,3 +1,4 @@ +import { resolveForIterations, selectForBranch } from 'comark/plugins/binding' import type { PropType, VNode } from 'vue' import type { ComponentManifest, @@ -8,6 +9,7 @@ import type { NodeRenderData, } from 'comark' import { + Fragment, computed, defineAsyncComponent, defineComponent, @@ -142,6 +144,30 @@ function renderNode( // Resolve `:prefix` bindings and let Vue-specific attribute mapping run // on top (e.g. `className` → `class`). const resolved = resolveAttributes(nodeProps, renderData, { parseJson: true }) + if ((customComponent as { __comarkFor?: boolean } | undefined)?.__comarkFor) { + return h(customComponent!, { + key, + __render: () => { + const iterations = resolveForIterations(resolved, renderData) + const branch = selectForBranch(children, iterations.length === 0) + const groups = iterations.length ? iterations : [{ key: 'empty', renderData }] + return h( + Fragment, + { key }, + groups.map((iteration) => + h( + Fragment, + { key: iterations.length ? `${typeof iteration.key}:${iteration.key}` : 'empty' }, + branch.map((child, index) => + renderNode(child, components, index, componentsManifest, node, iteration.renderData) + ) + ) + ) + ) + }, + }) + } + const props: Record = {} for (const k in resolved) { if (k === 'className') { diff --git a/packages/comark-vue/src/plugins/binding.ts b/packages/comark-vue/src/plugins/binding.ts index 11e484ba..0c79b284 100644 --- a/packages/comark-vue/src/plugins/binding.ts +++ b/packages/comark-vue/src/plugins/binding.ts @@ -3,3 +3,5 @@ export { default } from 'comark/plugins/binding' export { Binding } from '../components/Binding.ts' export { If } from '../components/If.ts' + +export { For } from '../components/For.ts' diff --git a/packages/comark-vue/test/fixtures/for-browser.html b/packages/comark-vue/test/fixtures/for-browser.html new file mode 100644 index 00000000..2959002a --- /dev/null +++ b/packages/comark-vue/test/fixtures/for-browser.html @@ -0,0 +1,12 @@ + + + + For browser test + + + + + diff --git a/packages/comark-vue/test/fixtures/for-browser.ts b/packages/comark-vue/test/fixtures/for-browser.ts new file mode 100644 index 00000000..6435efe3 --- /dev/null +++ b/packages/comark-vue/test/fixtures/for-browser.ts @@ -0,0 +1,28 @@ +import { parseMarkdown } from 'comark' +import binding, { Binding, For } from '../../src/plugins/binding' +import { MarkdownDocument } from '../../src/components/MarkdownDocument' +import { createApp, h, shallowRef, Suspense } from 'vue' + +export async function mount(): Promise { + const value = await parseMarkdown( + '::for{:each="data.posts" item="post" key="id"}\n:input{:aria-label="props.post.title"}\n\n{{ props.post.title }}\n#empty\nNo posts\n::', + { plugins: [binding()] } + ) + const components = { Binding, For } + const container = document.createElement('div') + document.body.append(container) + const initial = [ + { id: 'a', title: 'Alpha' }, + { id: 'b', title: 'Beta' }, + ] + const posts = shallowRef(initial) + createApp({ + render: () => + h(Suspense, null, { default: () => h(MarkdownDocument, { value, components, data: { posts: posts.value } }) }), + }).mount(container) + Object.assign(window, { + updatePosts: (next: typeof initial) => { + posts.value = next + }, + }) +} diff --git a/packages/comark-vue/test/plugin-binding.test.ts b/packages/comark-vue/test/plugin-binding.test.ts index df51fcc2..c6274070 100644 --- a/packages/comark-vue/test/plugin-binding.test.ts +++ b/packages/comark-vue/test/plugin-binding.test.ts @@ -1,15 +1,16 @@ +import { forCases } from '../../../test/fixtures/for' import { describe, expect, it } from 'vitest' import { createSSRApp, h } from 'vue' import { renderToString } from '@vue/server-renderer' import { parseMarkdown } from 'comark' import { MarkdownDocument } from '../src/components/MarkdownDocument' -import { If } from '../src/plugins/binding' +import binding, { Binding, For, If } from '../src/plugins/binding' import { nestedIfCases, nestedIfMarkdown } from '../../../test/fixtures/if' async function renderMarkdown(markdown: string, data: Record): Promise { - const document = await parseMarkdown(markdown) + const document = await parseMarkdown(markdown, { plugins: [binding()] }) const app = createSSRApp({ - render: () => h(MarkdownDocument, { value: document, components: { If }, data }), + render: () => h(MarkdownDocument, { value: document, components: { Binding, For, If }, data }), }) return renderToString(app) } @@ -36,3 +37,12 @@ describe('@comark/vue plugins/binding — If component', () => { expect(await renderMarkdown(markdown, { age: 17 })).not.toContain('Adult') }) }) + +it.each(forCases)('For: $name', async ({ markdown, data, expected, absent }) => { + const output = (await renderMarkdown(markdown, data)) + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim() + for (const text of expected) expect(output).toContain(text) + for (const text of absent) expect(output).not.toContain(text) +}) diff --git a/packages/comark-vue/test/plugin-for.browser.test.ts b/packages/comark-vue/test/plugin-for.browser.test.ts new file mode 100644 index 00000000..cb704b78 --- /dev/null +++ b/packages/comark-vue/test/plugin-for.browser.test.ts @@ -0,0 +1,41 @@ +import { expect, it } from 'vitest' +import { createServer } from 'vite' +import { chromium } from 'playwright' +import { fileURLToPath } from 'node:url' + +it('preserves keyed inputs on reorder and item updates, then selects empty', async () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const server = await createServer({ configFile: false, root, server: { host: '127.0.0.1', port: 0 } }) + await server.listen() + const browser = await chromium.launch({ headless: true }) + try { + const page = await browser.newPage() + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + const address = server.httpServer!.address() as { port: number } + await page.goto(`http://127.0.0.1:${address.port}/test/fixtures/for-browser.html`) + await page.getByRole('textbox', { name: 'Alpha' }).fill('Keep this draft') + await page.evaluate(() => { + Object.assign(window, { originalInput: document.querySelector('input') }) + ;(window as any).updatePosts([ + { id: 'b', title: 'Beta' }, + { id: 'a', title: 'Alpha' }, + ]) + }) + await expect.poll(() => page.locator('input').last().getAttribute('aria-label')).toBe('Alpha') + expect(await page.evaluate(() => document.querySelectorAll('input')[1] === (window as any).originalInput)).toBe( + true + ) + expect(await page.getByRole('textbox', { name: 'Alpha' }).inputValue()).toBe('Keep this draft') + await page.evaluate(() => (window as any).updatePosts([{ id: 'a', title: 'Updated' }])) + await expect.poll(() => page.locator('input').first().getAttribute('aria-label')).toBe('Updated') + expect(await page.evaluate(() => document.querySelector('input') === (window as any).originalInput)).toBe(true) + await page.evaluate(() => (window as any).updatePosts([])) + await expect.poll(() => page.locator('body').innerText()).toContain('No posts') + expect(await page.locator('input').count()).toBe(0) + expect(errors).toEqual([]) + } finally { + await browser.close() + await server.close() + } +}, 30_000) diff --git a/packages/comark/src/internal/stringify/attributes.ts b/packages/comark/src/internal/stringify/attributes.ts index 557cf70f..3177364b 100644 --- a/packages/comark/src/internal/stringify/attributes.ts +++ b/packages/comark/src/internal/stringify/attributes.ts @@ -45,6 +45,7 @@ export function resolveAttributes( renderData: NodeRenderData, options: ResolveAttributesOptions = {} ): Record { + if (renderData.scope) renderData = { ...renderData, props: { ...renderData.props, ...renderData.scope } } const result: Record = {} for (const key in attrs) { if (key === '$') continue @@ -110,6 +111,7 @@ export function resolveAttributes( * binding doesn't resolve. */ export function resolveAttribute(attrs: Record, renderData: NodeRenderData, key: string): unknown { + if (renderData.scope) renderData = { ...renderData, props: { ...renderData.props, ...renderData.scope } } const bindKey = `:${key}` if (bindKey in attrs) { const value = attrs[bindKey] diff --git a/packages/comark/src/plugins/binding.ts b/packages/comark/src/plugins/binding.ts index 95323ade..aea53dbc 100644 --- a/packages/comark/src/plugins/binding.ts +++ b/packages/comark/src/plugins/binding.ts @@ -1,6 +1,7 @@ import type { PluginWithOptions, MarkdownExit } from 'markdown-exit' +import { get } from '../utils/index.ts' import { defineComarkPlugin } from '../utils/helpers.ts' -import type { MarkdownItPlugin, NodeHandler, Node } from '../types' +import type { MarkdownItPlugin, NodeHandler, Node, NodeRenderData } from '../types' export interface MdcInlineBindingOptions { /** @@ -165,3 +166,72 @@ export const Binding: NodeHandler = (node) => { ? `{{ ${path} || ${defaultValue} }}` : `{{ ${path} }}` } + +export interface ForProps { + each?: unknown + item?: unknown + index?: unknown + key?: unknown +} + +export interface ForIteration { + key: string | number + renderData: NodeRenderData +} + +function forAlias(value: unknown, fallback?: string): string | undefined { + if (value === undefined) return fallback + if ( + typeof value !== 'string' || + !value || + value.includes('.') || + ['__proto__', 'prototype', 'constructor'].includes(value) + ) { + throw new Error('For aliases must be non-empty names without dots or prototype keys') + } + return value +} + +/** Resolve array iterations and lexical aliases without mutating runtime data. */ +export function resolveForIterations(props: ForProps, renderData: NodeRenderData): ForIteration[] { + const item = forAlias(props.item, 'item')! + const index = forAlias(props.index) + if (index === item) throw new Error('For item and index aliases must be different') + if (props.each == null) return [] + if (!Array.isArray(props.each)) throw new Error('For each must be an array') + if (props.key !== undefined && (typeof props.key !== 'string' || !props.key)) { + throw new Error('For key must be a non-empty item property path') + } + const keys = new Set() + return Array.from(props.each, (value: unknown, position: number): ForIteration => { + const key = props.key === undefined ? position : get(value, props.key as string) + if ((typeof key !== 'string' && typeof key !== 'number') || (typeof key === 'number' && !Number.isFinite(key))) { + throw new Error('For item keys must be strings or finite numbers') + } + if (keys.has(key)) throw new Error(`Duplicate For key: ${key}`) + keys.add(key) + const scope = { ...renderData.scope, [item]: value, ...(index ? { [index]: position } : {}) } + return { key, renderData: { ...renderData, scope } } + }) +} + +/** Select the default or empty slot without evaluating inactive content. */ +export function selectForBranch(children: Node[], empty: boolean): Node[] { + const regular: Node[] = [] + let defaultSlot: Node[] | undefined + let emptySlot: Node[] | undefined + for (const child of children) { + if (Array.isArray(child) && child[0] === 'template') { + const attrs = child[1] + const slotKey = Object.keys(attrs).find((key) => key.startsWith('#') || key.startsWith('v-slot:')) + const name = attrs.name ?? (slotKey?.startsWith('#') ? slotKey.slice(1) : slotKey?.slice(7)) + if (name) { + if (name === 'default') defaultSlot = child.slice(2) as Node[] + if (name === 'empty') emptySlot = child.slice(2) as Node[] + continue + } + } + regular.push(child) + } + return empty ? (emptySlot ?? []) : (defaultSlot ?? regular) +} diff --git a/packages/comark/src/types.ts b/packages/comark/src/types.ts index 50ceeae9..3d258b67 100644 --- a/packages/comark/src/types.ts +++ b/packages/comark/src/types.ts @@ -275,6 +275,8 @@ export interface NodeRenderData { * Props from parent node */ props: Record + /** Lexical bindings supplied by structural components, preserved through child props. */ + scope?: Record } // #endregion diff --git a/packages/comark/test/plugins/binding.test.ts b/packages/comark/test/plugins/binding.test.ts index 6e938994..dcc18400 100644 --- a/packages/comark/test/plugins/binding.test.ts +++ b/packages/comark/test/plugins/binding.test.ts @@ -1,3 +1,4 @@ +import { resolveForIterations } from '../../src/plugins/binding' import { describe, expect, it } from 'vitest' import { parseMarkdown } from '../../src/parse' import { renderMarkdown } from '../../src/render' @@ -182,3 +183,27 @@ Intro text. expect(html).not.toContain('

{ + const context = { props: {}, data: {}, frontmatter: {}, meta: {} } + it('uses stable item keys and does not mutate the parent scope', () => { + const parent = { ...context, scope: { outer: 'kept' } } + const each = [{ id: 'a' }, { id: 'b' }] + const first = resolveForIterations({ each, item: 'post', index: 'i', key: 'id' }, parent) + const reordered = resolveForIterations({ each: each.toReversed(), item: 'post', key: 'id' }, parent) + expect(first.map((item) => item.key)).toEqual(['a', 'b']) + expect(reordered.map((item) => item.key)).toEqual(['b', 'a']) + expect(first[0].renderData.scope).toEqual({ outer: 'kept', post: each[0], i: 0 }) + expect(parent.scope).toEqual({ outer: 'kept' }) + }) + it.each([ + { each: 'text' }, + { each: {} }, + { each: [1], item: '__proto__' }, + { each: [1], item: 'x', index: 'x' }, + { each: [{}], key: 'id' }, + { each: [{ id: 'a' }, { id: 'a' }], key: 'id' }, + ])('rejects invalid iteration props %j', (props) => { + expect(() => resolveForIterations(props, context)).toThrow() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4101fbee..2932b7f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1404,12 +1404,18 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.17) + playwright: + specifier: 'catalog:' + version: 1.61.1 react: specifier: ^19.2.7 version: 19.2.7 react-dom: specifier: 'catalog:' version: 19.2.7(react@19.2.7) + vite: + specifier: 'catalog:' + version: 8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) vitest: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) @@ -1478,6 +1484,9 @@ importers: '@vue/server-renderer': specifier: 'catalog:' version: 3.5.41 + playwright: + specifier: 'catalog:' + version: 1.61.1 vite: specifier: 'catalog:' version: 8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) diff --git a/test/fixtures/for.ts b/test/fixtures/for.ts new file mode 100644 index 00000000..f223eb4f --- /dev/null +++ b/test/fixtures/for.ts @@ -0,0 +1,45 @@ +export const forCases = [ + { + name: 'does not evaluate loops in inactive branches', + markdown: + '::if{:value="data.show"}\n:::for{:each="data.invalid"}\nHidden\n:::\n#else\nVisible\n::\n\n::for{:each="data.items"}\n{{ props.item }}\n#empty\n:::for{:each="data.invalid"}\nHidden\n:::\n::', + data: { show: false, invalid: 'not an array', items: ['Present'] }, + expected: ['Visible', 'Present'], + absent: ['Hidden'], + }, + { + name: 'items through headings and attributed wrappers', + markdown: + '::for{:each="data.posts" item="post" index="i" key="id"}\n### {{ props.post.title }}\n\n[{{ props.i }}: {{ props.post.description }}]{class="description"}\n#empty\nNo posts published yet.\n::', + data: { + posts: [ + { id: 1, title: 'First', description: 'Hello' }, + { id: 2, title: 'Second', description: 'World' }, + ], + }, + expected: ['First', '0: Hello', 'Second', '1: World'], + absent: ['No posts published yet.'], + }, + ...[[], null, undefined].map((posts) => ({ + name: `empty collection ${String(posts)}`, + markdown: '::for{:each="data.posts" item="post"}\n{{ props.post.title }}\n#empty\nNo posts published yet.\n::', + data: { posts }, + expected: ['No posts published yet.'], + absent: [' Date: Fri, 11 Sep 2026 15:52:20 +0200 Subject: [PATCH 2/5] test: update bundle sizes for For renderers Written by an AI agent; not yet human-reviewed. --- test/bundle.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index a6534737..6e9774d6 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -60,14 +60,14 @@ describe('package bundle size', { timeout: 60_000 }, () => { expect(report).toMatchInlineSnapshot(` { - "@comark/angular": "56.2k (72 files)", - "@comark/ansi": "37.3k (98 files)", - "@comark/html": "16.5k (58 files)", + "@comark/angular": "58.0k (74 files)", + "@comark/ansi": "38.2k (98 files)", + "@comark/html": "17.3k (58 files)", "@comark/nuxt": "11.8k (58 files)", - "@comark/react": "37.7k (76 files)", - "@comark/svelte": "44.9k (84 files)", - "@comark/vue": "56.0k (80 files)", - "comark": "368k (158 files)", + "@comark/react": "39.1k (78 files)", + "@comark/svelte": "45.9k (86 files)", + "@comark/vue": "58.8k (82 files)", + "comark": "371k (158 files)", } `) }) From 7af9b004c52a9878bb4d53b00eb23f5a3c6324f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 11 Sep 2026 16:16:08 +0200 Subject: [PATCH 3/5] chore: update example --- .../3.plugins/vue-vite-binding/src/App.vue | 41 ++++++------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/examples/3.plugins/vue-vite-binding/src/App.vue b/examples/3.plugins/vue-vite-binding/src/App.vue index 245c7f62..17fd9269 100644 --- a/examples/3.plugins/vue-vite-binding/src/App.vue +++ b/examples/3.plugins/vue-vite-binding/src/App.vue @@ -55,24 +55,6 @@ release: Hello **{{ data.user.name || friend }}** (role: {{ data.user.role }}), welcome back! -## Posts - -::for{:each="data.posts" item="post" index="position" key="id"} -### {{ props.post.title }} - -{{ props.post.description }} - -Post index: {{ props.position }} - -:::if{:value="props.post.published"} -Published -#else -Draft -::: -#empty -No posts published yet. -:: - ## Role comparison ::if{:value="data.user.role" eq="admin"} @@ -107,21 +89,22 @@ I am NOT fine and NOT happy 😩 ::: :: -## Platform stats - -| Metric | Value | -| ------ | -------------------------------- | -| Users | {{ data.stats.users }} | -| Uptime | {{ data.stats.uptime }} | -| Plan | {{ data.plan \\|\\| community }} | +## Posts -## Components see their own props +::for{:each="data.posts" item="post"} +### {{ props.post.title }} -::card{title="Component props"} -Inside the card the binding below pulls the card's title via \`props\`: +{{ props.post.description }} -{{ props.title }} +:::if{:value="props.post.published"} +[Published]{style="background: lightseagreen; padding: 2px 4px; font-size: 0.8em; border-radius: 2px;"} +#else +[Draft]{style="background: lightsalmon; padding: 2px 4px; font-size: 0.8em; border-radius: 2px;"} +::: +#empty +No posts published yet. :: + ` const sourceMarkdown = `~~~~mdc From 558110797378d74451380095e4212a0efd36996a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 11 Sep 2026 16:20:49 +0200 Subject: [PATCH 4/5] fix(examples): stretch the binding playground divider Written by an AI agent; not yet human-reviewed. --- examples/3.plugins/vue-vite-binding/src/App.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/3.plugins/vue-vite-binding/src/App.vue b/examples/3.plugins/vue-vite-binding/src/App.vue index 17fd9269..efb9bbf2 100644 --- a/examples/3.plugins/vue-vite-binding/src/App.vue +++ b/examples/3.plugins/vue-vite-binding/src/App.vue @@ -471,7 +471,7 @@ ${markdown} grid-template-columns: minmax(260px, 300px) minmax(0, 1fr); border: 1px solid var(--vbg-border-default); border-radius: var(--vbg-radius); - align-items: start; + align-items: stretch; } .vbg-custom-inputs { padding: var(--vbg-space-6); From 163a148ffc17234787b98fc00886074b4a8469f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 11 Sep 2026 16:48:21 +0200 Subject: [PATCH 5/5] refactor(binding): keep structural logic in optional components Written by an AI agent on behalf of @atinux; not yet human-reviewed. --- AGENTS.md | 9 +++- docs/content/4.plugins/1.built-in/binding.md | 10 +++- packages/comark-angular/package.json | 1 + .../src/components/for.component.ts | 3 +- .../src/components/if.component.ts | 15 +++++- .../src/components/markdown-node.component.ts | 42 ++++------------ .../test/binding-bundle.test.ts | 46 ++++++++++++++++++ .../test/plugin-binding.test.ts | 8 ++++ packages/comark-react/src/components/For.tsx | 3 +- .../src/components/MarkdownDocument.tsx | 29 +++++------ .../comark-react/test/binding-bundle.test.ts | 46 ++++++++++++++++++ packages/comark-svelte/package.json | 1 + packages/comark-svelte/src/components/For.ts | 4 +- .../src/components/MarkdownNode.svelte | 28 ++++++----- .../comark-svelte/test/binding-bundle.test.ts | 48 +++++++++++++++++++ packages/comark-vue/src/components/For.ts | 3 +- .../src/components/MarkdownDocument.ts | 32 ++++++------- .../comark-vue/test/binding-bundle.test.ts | 46 ++++++++++++++++++ packages/comark/src/plugins/binding.ts | 14 +++++- packages/comark/src/types.ts | 18 +++++++ pnpm-lock.yaml | 6 +++ test/bundle.test.ts | 8 ++-- 22 files changed, 328 insertions(+), 92 deletions(-) create mode 100644 packages/comark-angular/test/binding-bundle.test.ts create mode 100644 packages/comark-react/test/binding-bundle.test.ts create mode 100644 packages/comark-svelte/test/binding-bundle.test.ts create mode 100644 packages/comark-vue/test/binding-bundle.test.ts diff --git a/AGENTS.md b/AGENTS.md index c533ff11..3e6ddf43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -886,9 +886,14 @@ After each change, ask: Renderer-specific `plugins/binding` entry points export `For` alongside `Binding` and `If`. `For` repeats default AST children for an array, with `item` and optional `index` aliases, -optional item-property `key`, and an `#empty` slot. Core helpers `resolveForIterations` -and `selectForBranch` live in `packages/comark/src/plugins/binding.ts`. +optional item-property `key`, and an `#empty` slot. Core helpers `resolveForIterations`, +`selectForBranch`, and `renderFor` live in `packages/comark/src/plugins/binding.ts`. `NodeRenderData.scope` preserves lexical loop aliases through descendant component props; attribute resolution exposes those aliases under `props`. Vue/React keyed fragments and Svelte keyed each blocks preserve iteration identity; Angular currently rebuilds descendants. Cross-renderer iteration cases live in `test/fixtures/for.ts`. + +Structural framework components use a generic `__comarkRender` hook returning +`NodeRenderGroup[]` (key, AST children, render context, optional wrapper). Keep binding +imports in optional adapters; base renderers consume this hook without importing binding +helpers. `test/binding-bundle.test.ts` in each framework package verifies the exclusion. diff --git a/docs/content/4.plugins/1.built-in/binding.md b/docs/content/4.plugins/1.built-in/binding.md index 91e1897f..8d5e4017 100644 --- a/docs/content/4.plugins/1.built-in/binding.md +++ b/docs/content/4.plugins/1.built-in/binding.md @@ -279,6 +279,8 @@ const html = await renderHtml(markdown, { }) ``` +Iteration and comparison logic lives in the optional binding entry points. The base framework renderers retain a generic scoped-child hook, but do not import the `If` or `For` implementation. + Import from your renderer's binding entry point (`html`, `ansi`, `vue`, `react`, `svelte`, `angular`, or `nuxt`). Register `For` directly in the component map. Like `If`, the block syntax uses the default component parser; `binding()` is only needed for `{{ … }}` interpolation. ### Item and index aliases @@ -468,7 +470,13 @@ Every renderer-specific binding entry point exports `For`. Register it with `com `item` and `index` must be distinct, non-empty names without dots or prototype keys. Slots: default content repeats per item; `#empty` renders once for an empty collection. See [Repeating content](#repeating-content) for Markdown examples and renderer-specific key behavior. -The core entry point exports `ForProps`, `ForIteration`, `resolveForIterations(props, renderData)`, and `selectForBranch(children, empty)` for renderer adapters. Each iteration contains a key and a render context with lexical aliases in `scope`; attribute resolution makes those aliases available under `props`. +The core binding entry point exports `ForProps`, `ForIteration`, `resolveForIterations(props, renderData)`, `selectForBranch(children, empty)`, and `renderFor(context)` for renderer adapters. Each iteration contains a key and a render context with lexical aliases in `scope`; attribute resolution makes those aliases available under `props`. + +### Structural renderer hooks + +Framework components can supply a `__comarkRender` hook that returns keyed groups of AST children. The renderer renders each group in its supplied context, optionally inside a wrapper element. The binding adapters attach their behavior to this hook so applications that do not import them exclude that behavior from their bundles. + +The hook uses the `NodeRenderContext`, `NodeRenderGroup`, and `NodeRenderHook` types exported by `comark`. Its context contains resolved `props`, unrendered `children`, and `renderData`; each returned group contains a `key`, `children`, `renderData`, and optional `wrapper`. Vue and React adapters receive an internal `__render` callback, which they invoke when rendering so inactive branches stay unevaluated. ## Use cases diff --git a/packages/comark-angular/package.json b/packages/comark-angular/package.json index 994c9221..6196ae52 100644 --- a/packages/comark-angular/package.json +++ b/packages/comark-angular/package.json @@ -55,6 +55,7 @@ "@angular/platform-browser": "^22.0.6", "@angular/platform-server": "catalog:", "typescript": "catalog:", + "vite": "catalog:", "vitest": "catalog:" }, "peerDependencies": { diff --git a/packages/comark-angular/src/components/for.component.ts b/packages/comark-angular/src/components/for.component.ts index 3c43e797..218f4f6d 100644 --- a/packages/comark-angular/src/components/for.component.ts +++ b/packages/comark-angular/src/components/for.component.ts @@ -1,7 +1,8 @@ +import { renderFor } from 'comark/plugins/binding' import { Component, ChangeDetectionStrategy } from '@angular/core' /** Structural marker interpreted by the Markdown renderer. */ @Component({ selector: 'comark-for', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: '' }) export class For { - static readonly __comarkFor = true + static readonly __comarkRender = renderFor } diff --git a/packages/comark-angular/src/components/if.component.ts b/packages/comark-angular/src/components/if.component.ts index 2fb63bb4..9d31d46d 100644 --- a/packages/comark-angular/src/components/if.component.ts +++ b/packages/comark-angular/src/components/if.component.ts @@ -1,3 +1,5 @@ +import type { NodeRenderContext, NodeRenderGroup } from 'comark' +import { resolveIfWrapper, selectIfBranch, shouldRenderIf } from 'comark/plugins/binding' import { Component, ChangeDetectionStrategy } from '@angular/core' /** Marker component for Angular's structural `::if` renderer. */ @@ -8,5 +10,16 @@ import { Component, ChangeDetectionStrategy } from '@angular/core' template: '', }) export class If { - static readonly ɵcomarkIf = true + static __comarkRender({ props, children, renderData }: NodeRenderContext): NodeRenderGroup[] { + const branch = selectIfBranch(children, shouldRenderIf(props)) + if (!branch) return [] + return [ + { + key: 'branch', + children: branch, + renderData: Object.keys(props).length ? { ...renderData, props } : renderData, + wrapper: resolveIfWrapper(props.as), + }, + ] + } } diff --git a/packages/comark-angular/src/components/markdown-node.component.ts b/packages/comark-angular/src/components/markdown-node.component.ts index 1c73e3a4..649a1d58 100644 --- a/packages/comark-angular/src/components/markdown-node.component.ts +++ b/packages/comark-angular/src/components/markdown-node.component.ts @@ -15,20 +15,11 @@ import { reflectComponentType, inject, } from '@angular/core' -import type { ElementNode, Node as MarkdownAstNode, NodeRenderData } from 'comark' -import { - resolveForIterations, - selectForBranch, - resolveIfWrapper, - selectIfBranch, - shouldRenderIf, - type IfProps, -} from 'comark/plugins/binding' +import type { ElementNode, Node as MarkdownAstNode, NodeRenderData, NodeRenderHook } from 'comark' import { pascalCase, resolveAttributes } from 'comark/utils' interface StructuralComponent extends Type { - ɵcomarkIf?: boolean - __comarkFor?: boolean + __comarkRender?: NodeRenderHook } /** @@ -168,14 +159,15 @@ export class MarkdownNode implements OnChanges { const hasOwnAttrs = Object.keys(resolved).length > 0 const childrenRenderData: NodeRenderData = hasOwnAttrs ? { ...this.renderData, props: resolved } : this.renderData - if ((customComponent as StructuralComponent | undefined)?.__comarkFor) { - const iterations = resolveForIterations(resolved, this.renderData) - const branch = selectForBranch(children, iterations.length === 0) - for (const iteration of iterations.length ? iterations : [{ renderData: this.renderData }]) { - this.renderChildren(hostEl, branch, iteration.renderData) + const renderHook = (customComponent as StructuralComponent | undefined)?.__comarkRender + if (renderHook) { + for (const group of renderHook({ props: resolved, children, renderData: this.renderData })) { + if (group.wrapper) { + this.renderNativeEl(hostEl, group.wrapper, {}, group.children, group.renderData) + } else { + this.renderChildren(hostEl, group.children, group.renderData) + } } - } else if ((customComponent as StructuralComponent | undefined)?.ɵcomarkIf) { - this.renderIf(resolved, children, childrenRenderData) } else if (customComponent) { this.renderCustomComponent(customComponent, resolved, children, childrenRenderData) } else { @@ -233,20 +225,6 @@ export class MarkdownNode implements OnChanges { this.renderNativeEl(this.elementRef.nativeElement as HTMLElement, tag, attrs, children, childrenRenderData) } - /** Evaluate an `::if` before rendering any of its descendants. */ - private renderIf(props: IfProps, children: MarkdownAstNode[], childrenRenderData: NodeRenderData): void { - const branch = selectIfBranch(children, shouldRenderIf(props)) - if (!branch) return - - const hostEl = this.elementRef.nativeElement as HTMLElement - const wrapper = resolveIfWrapper(props.as) - if (wrapper) { - this.renderNativeEl(hostEl, wrapper, {}, branch, childrenRenderData) - } else { - this.renderChildren(hostEl, branch, childrenRenderData) - } - } - private renderCustomComponent( componentType: Type, attrs: Record, diff --git a/packages/comark-angular/test/binding-bundle.test.ts b/packages/comark-angular/test/binding-bundle.test.ts new file mode 100644 index 00000000..b0716c22 --- /dev/null +++ b/packages/comark-angular/test/binding-bundle.test.ts @@ -0,0 +1,46 @@ +import { expect, it } from 'vitest' +import { build } from 'vite' +import { fileURLToPath } from 'node:url' + +it.each(['none', 'inline', 'if', 'for'] as const)( + 'only includes the requested binding component (%s)', + async (mode) => { + const requestedExports = { none: '', inline: 'default as binding, Binding', if: 'If', for: 'For' }[mode] + const renderer = fileURLToPath(new URL('../dist/components/markdown-document.component.js', import.meta.url)) + const binding = fileURLToPath(new URL('../dist/plugins/binding.js', import.meta.url)) + const result = await build({ + configFile: false, + logLevel: 'silent', + plugins: [ + { + name: 'binding-bundle-probe', + resolveId(id) { + return id.endsWith('virtual:probe') ? '\0virtual:probe' : null + }, + load(id) { + if (id !== '\0virtual:probe') return null + return ( + `export { MarkdownDocument } from ${JSON.stringify(renderer)};` + + (requestedExports ? `export { ${requestedExports} } from ${JSON.stringify(binding)};` : '') + ) + }, + }, + ], + build: { + write: false, + minify: false, + lib: { entry: 'virtual:probe', formats: ['es'] }, + rollupOptions: { external: ['@angular/core', '@angular/common'] }, + }, + }) + const outputs = Array.isArray(result) ? result : [result] + const code = outputs + .flatMap((output) => ('output' in output ? output.output : [])) + .map((chunk) => (chunk.type === 'chunk' ? chunk.code : '')) + .join('\n') + expect(code.includes('For each must be an array')).toBe(mode === 'for') + expect(code.includes('Duplicate For key:')).toBe(mode === 'for') + expect(code.includes('Unsupported If wrapper tag:')).toBe(mode === 'if') + }, + 30_000 +) diff --git a/packages/comark-angular/test/plugin-binding.test.ts b/packages/comark-angular/test/plugin-binding.test.ts index 452b4afc..00327372 100644 --- a/packages/comark-angular/test/plugin-binding.test.ts +++ b/packages/comark-angular/test/plugin-binding.test.ts @@ -94,3 +94,11 @@ it.each(forCases)('For: $name', async ({ markdown, data, expected, absent }) => for (const text of expected) expect(output).toContain(text) for (const text of absent) expect(output).not.toContain(text) }) + +it('keeps inherited props in an If without attributes', async () => { + const html = await renderMarkdown( + '::div{title="Inherited"}\n:::if\nHidden\n#else\nValue: {{ props.title }}\n:::\n::', + {} + ) + expect(html.replace(/<[^>]*>/g, '')).toContain('Value: Inherited') +}) diff --git a/packages/comark-react/src/components/For.tsx b/packages/comark-react/src/components/For.tsx index 95374b84..a21c2ce5 100644 --- a/packages/comark-react/src/components/For.tsx +++ b/packages/comark-react/src/components/For.tsx @@ -1,6 +1,7 @@ +import { renderFor } from 'comark/plugins/binding' import type { ReactNode } from 'react' /** Structural component whose children are evaluated only when rendered. */ export const For = Object.assign(({ __render }: { __render: () => ReactNode }): ReactNode => __render(), { - __comarkFor: true, + __comarkRender: renderFor, }) diff --git a/packages/comark-react/src/components/MarkdownDocument.tsx b/packages/comark-react/src/components/MarkdownDocument.tsx index 7dd04a26..2bcf66f0 100644 --- a/packages/comark-react/src/components/MarkdownDocument.tsx +++ b/packages/comark-react/src/components/MarkdownDocument.tsx @@ -1,10 +1,10 @@ -import { resolveForIterations, selectForBranch } from 'comark/plugins/binding' import type { ElementNode, Node, MarkdownDocument as MarkdownDocumentType, ComponentManifest, NodeRenderData, + NodeRenderHook, } from 'comark' import React, { lazy, Suspense, useMemo } from 'react' import { pascalCase, camelCase, resolveAttributes } from 'comark/utils' @@ -142,27 +142,24 @@ function renderNode( // remapping (`class` → `className`, string `style` → object, `tabindex` // → `tabIndex`). const resolved = resolveAttributes(nodeProps, renderData, { parseJson: true }) - if (customComponent?.__comarkFor) { - return React.createElement(customComponent, { + const renderHook = (customComponent as { __comarkRender?: NodeRenderHook } | undefined)?.__comarkRender + if (renderHook) { + return React.createElement(customComponent!, { key, - __render: () => { - const iterations = resolveForIterations(resolved, renderData) - const branch = selectForBranch(children, iterations.length === 0) - const groups = iterations.length ? iterations : [{ key: 'empty', renderData }] - return React.createElement( + __render: () => + React.createElement( React.Fragment, - { key }, - groups.map((iteration) => + null, + renderHook({ props: resolved, children, renderData }).map((group) => React.createElement( - React.Fragment, - { key: iterations.length ? `${typeof iteration.key}:${iteration.key}` : 'empty' }, - branch.map((child, index) => - renderNode(child, components, index, componentsManifest, node, iteration.renderData) + group.wrapper || React.Fragment, + { key: group.key }, + group.children.map((child, index) => + renderNode(child, components, index, componentsManifest, node, group.renderData) ) ) ) - ) - }, + ), }) } diff --git a/packages/comark-react/test/binding-bundle.test.ts b/packages/comark-react/test/binding-bundle.test.ts new file mode 100644 index 00000000..1dd0486b --- /dev/null +++ b/packages/comark-react/test/binding-bundle.test.ts @@ -0,0 +1,46 @@ +import { expect, it } from 'vitest' +import { build } from 'vite' +import { fileURLToPath } from 'node:url' + +it.each(['none', 'inline', 'if', 'for'] as const)( + 'only includes the requested binding component (%s)', + async (mode) => { + const requestedExports = { none: '', inline: 'default as binding, Binding', if: 'If', for: 'For' }[mode] + const renderer = fileURLToPath(new URL('../src/components/MarkdownDocument.tsx', import.meta.url)) + const binding = fileURLToPath(new URL('../src/plugins/binding.ts', import.meta.url)) + const result = await build({ + configFile: false, + logLevel: 'silent', + plugins: [ + { + name: 'binding-bundle-probe', + resolveId(id) { + return id.endsWith('virtual:probe') ? '\0virtual:probe' : null + }, + load(id) { + if (id !== '\0virtual:probe') return null + return ( + `export { MarkdownDocument } from ${JSON.stringify(renderer)};` + + (requestedExports ? `export { ${requestedExports} } from ${JSON.stringify(binding)};` : '') + ) + }, + }, + ], + build: { + write: false, + minify: false, + lib: { entry: 'virtual:probe', formats: ['es'] }, + rollupOptions: { external: ['react'] }, + }, + }) + const outputs = Array.isArray(result) ? result : [result] + const code = outputs + .flatMap((output) => ('output' in output ? output.output : [])) + .map((chunk) => (chunk.type === 'chunk' ? chunk.code : '')) + .join('\n') + expect(code.includes('For each must be an array')).toBe(mode === 'for') + expect(code.includes('Duplicate For key:')).toBe(mode === 'for') + expect(code.includes('Unsupported If wrapper tag:')).toBe(mode === 'if') + }, + 30_000 +) diff --git a/packages/comark-svelte/package.json b/packages/comark-svelte/package.json index bab3894d..8090cbd1 100644 --- a/packages/comark-svelte/package.json +++ b/packages/comark-svelte/package.json @@ -74,6 +74,7 @@ "release-it": "catalog:", "svelte": "^5.56.4", "svelte-check": "catalog:", + "vite": "catalog:", "vitest": "catalog:", "vitest-browser-svelte": "catalog:" }, diff --git a/packages/comark-svelte/src/components/For.ts b/packages/comark-svelte/src/components/For.ts index 5c2625ca..6a634ab5 100644 --- a/packages/comark-svelte/src/components/For.ts +++ b/packages/comark-svelte/src/components/For.ts @@ -1,2 +1,4 @@ +import { renderFor } from 'comark/plugins/binding' + /** Structural marker interpreted by the Markdown renderer. */ -export const For = Object.assign(() => null, { __comarkFor: true }) +export const For = Object.assign(() => null, { __comarkRender: renderFor }) diff --git a/packages/comark-svelte/src/components/MarkdownNode.svelte b/packages/comark-svelte/src/components/MarkdownNode.svelte index b0fbe88e..17abe3d1 100644 --- a/packages/comark-svelte/src/components/MarkdownNode.svelte +++ b/packages/comark-svelte/src/components/MarkdownNode.svelte @@ -67,8 +67,7 @@ naturally appears inline after the deepest trailing text node. {#snippet renderChildren()} @@ -253,15 +252,18 @@ naturally appears inline after the deepest trailing text node. class={caretClass || undefined} style={CARET_STYLE}>{CARET_TEXT}{/if} -{:else if Component?.__comarkFor} - {#each forIterations as iteration (iteration.key)} - {#each forChildren as child, i (i)} - - {/each} - {:else} - {#each forChildren as child, i (i)} - - {/each} +{:else if renderHook} + {#each renderGroups as group (group.key)} + {#snippet groupChildren()} + {#each group.children as child, i (i)} + + {/each} + {/snippet} + {#if group.wrapper} + {@render groupChildren()} + {:else} + {@render groupChildren()} + {/if} {/each} {:else if Component && namedSlots.length > 0} { + const requestedExports = { none: '', inline: 'default as binding, Binding', if: 'If', for: 'For' }[mode] + const renderer = fileURLToPath(new URL('../src/components/MarkdownDocument.svelte', import.meta.url)) + const binding = fileURLToPath(new URL('../src/plugins/binding.ts', import.meta.url)) + const result = await build({ + configFile: false, + logLevel: 'silent', + plugins: [ + svelte(), + { + name: 'binding-bundle-probe', + resolveId(id) { + return id.endsWith('virtual:probe') ? '\0virtual:probe' : null + }, + load(id) { + if (id !== '\0virtual:probe') return null + return ( + `export { default as MarkdownDocument } from ${JSON.stringify(renderer)};` + + (requestedExports ? `export { ${requestedExports} } from ${JSON.stringify(binding)};` : '') + ) + }, + }, + ], + build: { + write: false, + minify: false, + lib: { entry: 'virtual:probe', formats: ['es'] }, + rollupOptions: { external: (id) => id === 'svelte' || id.startsWith('svelte/') }, + }, + }) + const outputs = Array.isArray(result) ? result : [result] + const code = outputs + .flatMap((output) => ('output' in output ? output.output : [])) + .map((chunk) => (chunk.type === 'chunk' ? chunk.code : '')) + .join('\n') + expect(code.includes('For each must be an array')).toBe(mode === 'for') + expect(code.includes('Duplicate For key:')).toBe(mode === 'for') + expect(code.includes('Unsupported If wrapper tag:')).toBe(mode === 'if') + }, + 30_000 +) diff --git a/packages/comark-vue/src/components/For.ts b/packages/comark-vue/src/components/For.ts index 747e0ca7..7ad04e6e 100644 --- a/packages/comark-vue/src/components/For.ts +++ b/packages/comark-vue/src/components/For.ts @@ -1,3 +1,4 @@ +import { renderFor } from 'comark/plugins/binding' import { defineComponent, type PropType, type VNodeChild } from 'vue' /** Structural component whose children are evaluated only when rendered. */ @@ -10,5 +11,5 @@ export const For = Object.assign( return () => props.__render() }, }), - { __comarkFor: true } + { __comarkRender: renderFor } ) diff --git a/packages/comark-vue/src/components/MarkdownDocument.ts b/packages/comark-vue/src/components/MarkdownDocument.ts index 0cb6f745..953026ab 100644 --- a/packages/comark-vue/src/components/MarkdownDocument.ts +++ b/packages/comark-vue/src/components/MarkdownDocument.ts @@ -1,4 +1,3 @@ -import { resolveForIterations, selectForBranch } from 'comark/plugins/binding' import type { PropType, VNode } from 'vue' import type { ComponentManifest, @@ -7,6 +6,7 @@ import type { Node, MarkdownDocument as MarkdownDocumentType, NodeRenderData, + NodeRenderHook, } from 'comark' import { Fragment, @@ -144,27 +144,23 @@ function renderNode( // Resolve `:prefix` bindings and let Vue-specific attribute mapping run // on top (e.g. `className` → `class`). const resolved = resolveAttributes(nodeProps, renderData, { parseJson: true }) - if ((customComponent as { __comarkFor?: boolean } | undefined)?.__comarkFor) { + const renderHook = (customComponent as { __comarkRender?: NodeRenderHook } | undefined)?.__comarkRender + if (renderHook) { return h(customComponent!, { key, - __render: () => { - const iterations = resolveForIterations(resolved, renderData) - const branch = selectForBranch(children, iterations.length === 0) - const groups = iterations.length ? iterations : [{ key: 'empty', renderData }] - return h( + __render: () => + h( Fragment, - { key }, - groups.map((iteration) => - h( - Fragment, - { key: iterations.length ? `${typeof iteration.key}:${iteration.key}` : 'empty' }, - branch.map((child, index) => - renderNode(child, components, index, componentsManifest, node, iteration.renderData) - ) + null, + renderHook({ props: resolved, children, renderData }).map((group) => { + const children = group.children.map((child, index) => + renderNode(child, components, index, componentsManifest, node, group.renderData) ) - ) - ) - }, + return group.wrapper + ? h(group.wrapper, { key: group.key }, children) + : h(Fragment, { key: group.key }, children) + }) + ), }) } diff --git a/packages/comark-vue/test/binding-bundle.test.ts b/packages/comark-vue/test/binding-bundle.test.ts new file mode 100644 index 00000000..5355e3c0 --- /dev/null +++ b/packages/comark-vue/test/binding-bundle.test.ts @@ -0,0 +1,46 @@ +import { expect, it } from 'vitest' +import { build } from 'vite' +import { fileURLToPath } from 'node:url' + +it.each(['none', 'inline', 'if', 'for'] as const)( + 'only includes the requested binding component (%s)', + async (mode) => { + const requestedExports = { none: '', inline: 'default as binding, Binding', if: 'If', for: 'For' }[mode] + const renderer = fileURLToPath(new URL('../src/components/MarkdownDocument.ts', import.meta.url)) + const binding = fileURLToPath(new URL('../src/plugins/binding.ts', import.meta.url)) + const result = await build({ + configFile: false, + logLevel: 'silent', + plugins: [ + { + name: 'binding-bundle-probe', + resolveId(id) { + return id.endsWith('virtual:probe') ? '\0virtual:probe' : null + }, + load(id) { + if (id !== '\0virtual:probe') return null + return ( + `export { MarkdownDocument } from ${JSON.stringify(renderer)};` + + (requestedExports ? `export { ${requestedExports} } from ${JSON.stringify(binding)};` : '') + ) + }, + }, + ], + build: { + write: false, + minify: false, + lib: { entry: 'virtual:probe', formats: ['es'] }, + rollupOptions: { external: ['vue'] }, + }, + }) + const outputs = Array.isArray(result) ? result : [result] + const code = outputs + .flatMap((output) => ('output' in output ? output.output : [])) + .map((chunk) => (chunk.type === 'chunk' ? chunk.code : '')) + .join('\n') + expect(code.includes('For each must be an array')).toBe(mode === 'for') + expect(code.includes('Duplicate For key:')).toBe(mode === 'for') + expect(code.includes('Unsupported If wrapper tag:')).toBe(mode === 'if') + }, + 30_000 +) diff --git a/packages/comark/src/plugins/binding.ts b/packages/comark/src/plugins/binding.ts index aea53dbc..e5e080df 100644 --- a/packages/comark/src/plugins/binding.ts +++ b/packages/comark/src/plugins/binding.ts @@ -1,7 +1,7 @@ import type { PluginWithOptions, MarkdownExit } from 'markdown-exit' import { get } from '../utils/index.ts' import { defineComarkPlugin } from '../utils/helpers.ts' -import type { MarkdownItPlugin, NodeHandler, Node, NodeRenderData } from '../types' +import type { MarkdownItPlugin, NodeHandler, Node, NodeRenderData, NodeRenderContext, NodeRenderGroup } from '../types' export interface MdcInlineBindingOptions { /** @@ -235,3 +235,15 @@ export function selectForBranch(children: Node[], empty: boolean): Node[] { } return empty ? (emptySlot ?? []) : (defaultSlot ?? regular) } + +/** Build the scoped, keyed groups consumed by structural renderer hooks. */ +export function renderFor({ props, children, renderData }: NodeRenderContext): NodeRenderGroup[] { + const iterations = resolveForIterations(props, renderData) + const branch = selectForBranch(children, iterations.length === 0) + if (!iterations.length) return [{ key: 'empty', children: branch, renderData }] + return iterations.map((iteration) => ({ + key: `${typeof iteration.key}:${iteration.key}`, + children: branch, + renderData: iteration.renderData, + })) +} diff --git a/packages/comark/src/types.ts b/packages/comark/src/types.ts index 3d258b67..bd893f0a 100644 --- a/packages/comark/src/types.ts +++ b/packages/comark/src/types.ts @@ -278,6 +278,24 @@ export interface NodeRenderData { /** Lexical bindings supplied by structural components, preserved through child props. */ scope?: Record } +/** Input supplied to an optional component's structural rendering hook. */ +export interface NodeRenderContext { + props: Record + children: Node[] + renderData: NodeRenderData +} + +/** A keyed group of AST nodes to render within a supplied context. */ +export interface NodeRenderGroup { + key: string | number + children: Node[] + renderData: NodeRenderData + wrapper?: string +} + +/** Optional component capability for selecting or repeating children before rendering. */ +export type NodeRenderHook = (context: NodeRenderContext) => NodeRenderGroup[] + // #endregion export type MarkdownExitPlugin = (md: MarkdownExit) => void diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2932b7f6..b01e0528 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1316,6 +1316,9 @@ importers: typescript: specifier: 'catalog:' version: 6.0.3 + vite: + specifier: 'catalog:' + version: 8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) vitest: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) @@ -1456,6 +1459,9 @@ importers: svelte-check: specifier: 'catalog:' version: 4.7.2(picomatch@4.0.5)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3) + vite: + specifier: 'catalog:' + version: 8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) vitest: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 6e9774d6..6c199690 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -64,10 +64,10 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/ansi": "38.2k (98 files)", "@comark/html": "17.3k (58 files)", "@comark/nuxt": "11.8k (58 files)", - "@comark/react": "39.1k (78 files)", - "@comark/svelte": "45.9k (86 files)", - "@comark/vue": "58.8k (82 files)", - "comark": "371k (158 files)", + "@comark/react": "38.8k (78 files)", + "@comark/svelte": "46.0k (86 files)", + "@comark/vue": "58.7k (82 files)", + "comark": "372k (158 files)", } `) })