diff --git a/AGENTS.md b/AGENTS.md index de7eb372..3e6ddf43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -881,3 +881,19 @@ 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`, +`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 5498f7fd..8d5e4017 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,81 @@ 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.' }], + }, +}) +``` + +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 + +`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 +457,27 @@ 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 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 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..efb9bbf2 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/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 new file mode 100644 index 00000000..6a634ab5 --- /dev/null +++ b/packages/comark-svelte/src/components/For.ts @@ -0,0 +1,4 @@ +import { renderFor } from 'comark/plugins/binding' + +/** Structural marker interpreted by the Markdown renderer. */ +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 dc9db091..17abe3d1 100644 --- a/packages/comark-svelte/src/components/MarkdownNode.svelte +++ b/packages/comark-svelte/src/components/MarkdownNode.svelte @@ -67,7 +67,7 @@ naturally appears inline after the deepest trailing text node. {#snippet renderChildren()} @@ -250,6 +252,19 @@ naturally appears inline after the deepest trailing text node. class={caretClass || undefined} style={CARET_STYLE}>{CARET_TEXT}{/if} +{: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-svelte/test/plugin-binding.svelte.test.ts b/packages/comark-svelte/test/plugin-binding.svelte.test.ts index f7ebfd3c..36af6e13 100644 --- a/packages/comark-svelte/test/plugin-binding.svelte.test.ts +++ b/packages/comark-svelte/test/plugin-binding.svelte.test.ts @@ -2,14 +2,14 @@ import { describe, expect, it } from 'vitest' import { render } from 'vitest-browser-svelte' import { parseMarkdown } from 'comark' import MarkdownDocument from '../src/components/MarkdownDocument.svelte' -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 = {}) { 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..7ad04e6e --- /dev/null +++ b/packages/comark-vue/src/components/For.ts @@ -0,0 +1,15 @@ +import { renderFor } from 'comark/plugins/binding' +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() + }, + }), + { __comarkRender: renderFor } +) diff --git a/packages/comark-vue/src/components/MarkdownDocument.ts b/packages/comark-vue/src/components/MarkdownDocument.ts index 530870c3..953026ab 100644 --- a/packages/comark-vue/src/components/MarkdownDocument.ts +++ b/packages/comark-vue/src/components/MarkdownDocument.ts @@ -6,8 +6,10 @@ import type { Node, MarkdownDocument as MarkdownDocumentType, NodeRenderData, + NodeRenderHook, } from 'comark' import { + Fragment, computed, defineAsyncComponent, defineComponent, @@ -142,6 +144,26 @@ 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 }) + const renderHook = (customComponent as { __comarkRender?: NodeRenderHook } | undefined)?.__comarkRender + if (renderHook) { + return h(customComponent!, { + key, + __render: () => + h( + Fragment, + 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) + }) + ), + }) + } + 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/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-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..e5e080df 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, NodeRenderContext, NodeRenderGroup } from '../types' export interface MdcInlineBindingOptions { /** @@ -165,3 +166,84 @@ 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) +} + +/** 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 50ceeae9..bd893f0a 100644 --- a/packages/comark/src/types.ts +++ b/packages/comark/src/types.ts @@ -275,7 +275,27 @@ export interface NodeRenderData { * Props from parent node */ props: Record + /** 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/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..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)) @@ -1404,12 +1407,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)) @@ -1450,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)) @@ -1478,6 +1490,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/bundle.test.ts b/test/bundle.test.ts index a6534737..6c199690 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": "38.8k (78 files)", + "@comark/svelte": "46.0k (86 files)", + "@comark/vue": "58.7k (82 files)", + "comark": "372k (158 files)", } `) }) 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: ['