From 73fcf23130303e34dd93e44261b4e745bd59bfc8 Mon Sep 17 00:00:00 2001 From: miguelrk Date: Wed, 16 Sep 2026 00:48:47 +0200 Subject: [PATCH] feat(model): add two-way data binding via `::prop` in `comark` core #437 Added support for two-way data binding using the `ComarkModel` protocol, allowing native form elements and custom components to synchronize state with the model. Updated documentation to include examples for Vue, React, Svelte, and Angular, demonstrating both uncontrolled and controlled modes. Enhanced the model plugin to facilitate seamless integration with existing components and frameworks. --- AGENTS.md | 19 +- docs/content/2.syntax/2.components.md | 34 + docs/content/3.rendering/2.html.md | 51 ++ docs/content/3.rendering/3.vue.md | 90 +++ docs/content/3.rendering/5.react.md | 90 +++ docs/content/3.rendering/6.svelte.md | 51 ++ docs/content/3.rendering/7.angular.md | 53 ++ docs/content/4.plugins/1.built-in/model.md | 264 +++++++ .../angular/src/app/pages/syntax.component.ts | 20 +- examples/2.vite/ansi/src/main.ts | 7 + examples/2.vite/html/src/main.ts | 33 +- examples/2.vite/html/src/preview.css | 9 + examples/2.vite/react/src/pages/Syntax.tsx | 22 +- .../2.vite/svelte/src/pages/Syntax.svelte | 24 +- examples/2.vite/vue/src/App.vue | 14 +- examples/2.vite/vue/src/pages/syntax.vue | 21 +- .../src/components/form.component.ts | 40 + .../components/markdown-document.component.ts | 33 + .../src/components/markdown-node.component.ts | 63 +- .../src/components/markdown.component.ts | 9 + packages/comark-angular/src/plugins/form.ts | 3 + .../comark-angular/test/model-binding.test.ts | 64 ++ packages/comark-ansi/src/render.ts | 23 + .../comark-ansi/test/model-binding.test.ts | 46 ++ packages/comark-html/package.json | 4 +- packages/comark-html/src/plugins/binding.ts | 8 +- packages/comark-html/src/runtime.ts | 84 ++ .../comark-html/test/plugin-binding.test.ts | 7 +- packages/comark-html/test/runtime.test.ts | 110 +++ packages/comark-react/src/components/Form.tsx | 37 + .../comark-react/src/components/Markdown.tsx | 20 + .../src/components/MarkdownClient.tsx | 4 + .../src/components/MarkdownDocument.tsx | 117 ++- packages/comark-react/src/plugins/form.ts | 3 + packages/comark-react/test/form.test.tsx | 43 + .../comark-react/test/model-binding.test.tsx | 76 ++ .../src/components/ComarkComponent.svelte | 5 + .../comark-svelte/src/components/Form.svelte | 33 + .../src/components/Markdown.svelte | 9 + .../src/components/MarkdownDocument.svelte | 39 +- .../src/components/MarkdownNode.svelte | 34 +- packages/comark-svelte/src/plugins/form.ts | 3 + .../comark-svelte/test/model-binding.test.ts | 62 ++ packages/comark-vue/src/components/Form.ts | 53 ++ .../comark-vue/src/components/Markdown.ts | 34 + .../src/components/MarkdownDocument.ts | 86 +- packages/comark-vue/src/plugins/form.ts | 3 + packages/comark-vue/test/form.test.ts | 45 ++ .../comark-vue/test/model-binding.test.ts | 73 ++ .../comark/SPEC/COMARK/data-binding-form.md | 62 ++ .../SPEC/COMARK/data-binding-two-way-block.md | 41 + .../COMARK/data-binding-two-way-inline.md | 42 + packages/comark/package.json | 1 + packages/comark/src/context.ts | 3 +- .../comark/src/internal/parse/syntax/props.ts | 7 +- .../src/internal/stringify/attributes.ts | 217 ++++- .../comark/src/internal/stringify/state.ts | 9 +- packages/comark/src/model.ts | 169 ++++ packages/comark/src/plugins/form.ts | 57 ++ packages/comark/src/types.ts | 23 +- packages/comark/src/utils/index.ts | 40 +- packages/comark/test/context.test.ts | 23 + packages/comark/test/form.test.ts | 65 ++ packages/comark/test/model.test.ts | 177 +++++ packages/comark/test/plugins/security.test.ts | 28 + .../comark/test/resolve-attributes.test.ts | 118 ++- packages/comark/test/set-guard.test.ts | 62 ++ .../test/two-way-binding-parser.test.ts | 56 ++ pnpm-lock.yaml | 743 ++++++++---------- test/bundle.test.ts | 14 +- 70 files changed, 3520 insertions(+), 512 deletions(-) create mode 100644 docs/content/4.plugins/1.built-in/model.md create mode 100644 packages/comark-angular/src/components/form.component.ts create mode 100644 packages/comark-angular/src/plugins/form.ts create mode 100644 packages/comark-angular/test/model-binding.test.ts create mode 100644 packages/comark-ansi/test/model-binding.test.ts create mode 100644 packages/comark-html/src/runtime.ts create mode 100644 packages/comark-html/test/runtime.test.ts create mode 100644 packages/comark-react/src/components/Form.tsx create mode 100644 packages/comark-react/src/plugins/form.ts create mode 100644 packages/comark-react/test/form.test.tsx create mode 100644 packages/comark-react/test/model-binding.test.tsx create mode 100644 packages/comark-svelte/src/components/Form.svelte create mode 100644 packages/comark-svelte/src/plugins/form.ts create mode 100644 packages/comark-svelte/test/model-binding.test.ts create mode 100644 packages/comark-vue/src/components/Form.ts create mode 100644 packages/comark-vue/src/plugins/form.ts create mode 100644 packages/comark-vue/test/form.test.ts create mode 100644 packages/comark-vue/test/model-binding.test.ts create mode 100644 packages/comark/SPEC/COMARK/data-binding-form.md create mode 100644 packages/comark/SPEC/COMARK/data-binding-two-way-block.md create mode 100644 packages/comark/SPEC/COMARK/data-binding-two-way-inline.md create mode 100644 packages/comark/src/model.ts create mode 100644 packages/comark/src/plugins/form.ts create mode 100644 packages/comark/test/form.test.ts create mode 100644 packages/comark/test/model.test.ts create mode 100644 packages/comark/test/set-guard.test.ts create mode 100644 packages/comark/test/two-way-binding-parser.test.ts diff --git a/AGENTS.md b/AGENTS.md index de7eb372..d9b8bc37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ packages/comark/ │ │ ├── index.ts # Re-exports (comark/ast entry point) │ │ ├── types.ts # MarkdownDocument, Node, ElementNode, TextNode │ │ └── utils.ts # textContent(), visit() document utilities +│ ├── model.ts # ComarkModel protocol + createModelStore() (comark/model entry point) │ ├── plugins/ # Built-in and optional plugins │ │ ├── alert.ts # Alert/callout blocks │ │ ├── frontmatter.ts # YAML frontmatter extraction (default via registerDefaultPlugins) @@ -62,6 +63,7 @@ packages/comark/ │ │ ├── components.ts # Block/inline components + spans (`::name`, `:name`, `[text]`) │ │ ├── attributes.ts # Inline attributes (`{props}` after tokens) │ │ ├── binding.ts # Inline interpolation + shared conditional rendering rules +│ │ ├── form.ts # ::form aggregate helpers (collectFormBindingPaths, buildFormAggregate) │ │ ├── emoji.ts # Emoji shortcodes │ │ ├── shiki.ts # Shiki with bundled default theme + language loaders (peer: shiki) │ │ ├── shiki/core.ts # Shiki without default theme/language imports @@ -71,12 +73,12 @@ packages/comark/ │ │ ├── rangi/language-comark.ts # Standalone Comark grammar for rangi │ │ ├── math.ts # LaTeX math via KaTeX (peer: katex) │ │ ├── mermaid.ts # Mermaid diagrams (peer: beautiful-mermaid) -│ │ ├── security.ts # XSS/security sanitization +│ │ ├── security.ts # XSS/security sanitization (handles ::prop keys correctly) │ │ ├── summary.ts # Summary extraction │ │ ├── task-list.ts # GFM task lists │ │ └── toc.ts # Table of contents │ ├── utils/ # Shared utilities (comark/utils entry point) -│ │ ├── index.ts # textContent(), visit(), visitAsync(), escapeHtml(), string/object utils +│ │ ├── index.ts # textContent(), visit(), visitAsync(), escapeHtml(), get(), set(), string/object utils │ │ ├── helpers.ts # defineComarkPlugin(), dedupePlugins() │ │ └── caret.ts # Caret utilities for streaming │ └── internal/ # Internal implementation (not exported) @@ -406,7 +408,11 @@ import { renderMarkdown } from 'comark/render' // AST types and utilities import type { MarkdownDocument, Node, ElementNode, TextNode } from 'comark' -import { textContent, visit, escapeHtml } from 'comark/utils' +import { textContent, visit, escapeHtml, set } from 'comark/utils' + +// Two-way data binding model +import type { ComarkModel } from 'comark/model' +import { createModelStore } from 'comark/model' // Core plugins — use when calling parseMarkdown() directly (framework-agnostic) import shiki from 'comark/plugins/shiki' @@ -426,6 +432,7 @@ import attributes from 'comark/plugins/attributes' // default via registerDefa import html from 'comark/plugins/html' // default via registerDefaultPlugins import binding, { Binding, resolveIfWrapper, selectIfBranch, shouldRenderIf } from 'comark/plugins/binding' import type { IfComparisonOperator, IfProps, IfWrapperTag } from 'comark/plugins/binding' +import { collectFormBindingPaths, buildFormAggregate } from 'comark/plugins/form' // markdown-it / markdown-exit adapters (e.g. VitePress) import { markdownItComponents } from 'comark/plugins/components' @@ -439,12 +446,14 @@ import { markdownItAttributes } from 'comark/plugins/attributes' // HTML rendering — parse + render to HTML string import { createHtmlRenderer, renderHtml, renderHtmlFromDocument } from '@comark/html' +import { initComarkRuntime } from '@comark/html/runtime' // progressive-enhancement runtime (browser) import shiki from '@comark/html/plugins/shiki' import math, { Math } from '@comark/html/plugins/math' import mermaid, { Mermaid } from '@comark/html/plugins/mermaid' import binding, { Binding, If } from '@comark/html/plugins/binding' // ANSI terminal rendering — parse + render to styled terminal string +// NOTE: ::prop two-way bindings render read-only in ANSI (a dev warning is emitted once). import { createAnsiRenderer, createAnsiPrinter, printAnsi, renderAnsi, renderAnsiFromDocument } from '@comark/ansi' import shiki from '@comark/ansi/plugins/shiki' import math from '@comark/ansi/plugins/math' @@ -455,12 +464,14 @@ import { Markdown, MarkdownDocument, defineMarkdownComponent } from '@comark/vue import math, { Math } from '@comark/vue/plugins/math' import mermaid, { Mermaid } from '@comark/vue/plugins/mermaid' import binding, { Binding, If } from '@comark/vue/plugins/binding' +import { Form } from '@comark/vue/plugins/form' // ::form aggregate component // React — renderer + plugin wrappers (plugin fn + React component) import { Markdown, MarkdownDocument, defineMarkdownComponent } from '@comark/react' import math, { Math } from '@comark/react/plugins/math' import mermaid, { Mermaid } from '@comark/react/plugins/mermaid' import binding, { Binding, If } from '@comark/react/plugins/binding' +import { Form } from '@comark/react/plugins/form' // ::form aggregate component // Svelte — renderer + plugin wrappers (plugin fn + Svelte component) import { Markdown, MarkdownDocument } from '@comark/svelte' @@ -468,12 +479,14 @@ import { MarkdownAsync } from '@comark/svelte/async' // requires experimental.as import math, { Math } from '@comark/svelte/plugins/math' import mermaid, { Mermaid } from '@comark/svelte/plugins/mermaid' import binding, { Binding, If } from '@comark/svelte/plugins/binding' +import { Form } from '@comark/svelte/plugins/form' // ::form aggregate component // Angular — renderer + plugin wrappers (plugin fn + Angular component) import { Markdown, MarkdownDocument, defineMarkdownComponent, defineMarkdownDocumentComponent } from '@comark/angular' import math, { Math } from '@comark/angular/plugins/math' import mermaid, { Mermaid } from '@comark/angular/plugins/mermaid' import binding, { Binding, If } from '@comark/angular/plugins/binding' +import { Form } from '@comark/angular/plugins/form' // ::form aggregate component ``` ## Coding Principles diff --git a/docs/content/2.syntax/2.components.md b/docs/content/2.syntax/2.components.md index d5e9d41a..224a5b2a 100644 --- a/docs/content/2.syntax/2.components.md +++ b/docs/content/2.syntax/2.components.md @@ -238,6 +238,40 @@ response: Props prefixed with `:` are JSON-parsed at render time. When the value isn't valid JSON, the renderer looks it up as a dot-path against an ambient **render context**, letting authors reference runtime data, frontmatter, meta, or the enclosing component's props without hardcoding them. +### Two-way binding {#two-way-binding} + +Props prefixed with `::` create a **two-way binding**. The renderer reads the current value from a `ComarkModel` and wires an update handler back — so user interactions (typing, checking a box, picking an option) write the new value into the model automatically. + +```mdc + +:input{::value="data.name" type="text"} +:input{::checked="data.active" type="checkbox"} +:select{::value="data.color"} + + +::card{::title="data.heading"} +content +:: +``` + +The path resolves against the model's writable namespaces (default: `data.*`). Paths outside the writable set or containing unsafe URL schemes are silently ignored. + +Pass a `model` to the renderer to activate two-way binding: + +```vue [Vue] +{{ content }} +``` + +```tsx [React] +{content} +``` + +See [Model plugin](/plugins/built-in/model) for the full API, security notes, and HTML progressive enhancement. + +::callout{color="info" icon="i-lucide-info"} +`::prop` is an inline-only attribute — it cannot appear in a YAML props block. Inline attributes (`{::value="path"}`) are the correct form. +:: + ### Scope The render context exposes four namespaces: diff --git a/docs/content/3.rendering/2.html.md b/docs/content/3.rendering/2.html.md index 54e90ab7..e0ada594 100644 --- a/docs/content/3.rendering/2.html.md +++ b/docs/content/3.rendering/2.html.md @@ -277,6 +277,57 @@ const renderHtml = createHtmlRenderer({ components }) --- +## Two-way model binding + +`@comark/html` supports the `::prop="path"` syntax for progressive-enhancement two-way binding via the optional `@comark/html/runtime` script. No framework is required. + +### How it works + +1. **SSR / static output** — `renderHtml()` renders `::prop` bindings as `data-comark-model-{prop}="path"` markers plus the resolved read value. The page is fully functional without JavaScript. +2. **Client enhancement** — importing `@comark/html/runtime` and calling `initComarkRuntime()` adds event delegation. User input is captured and propagated to the internal `createModelStore`, which patches `[data-comark-bind]` text nodes in real time. + +### Example + +```markdown [content.md] +Enter your name: :input{::value="data.name" type="text"} + +Hello, {{ data.name }}! +``` + +```typescript [render.ts] +import { renderHtml } from '@comark/html' + +const html = await renderHtml(source, { data: { name: 'World' } }) +``` + +```html [page.html] + + + +
+ + + +``` + +### `initComarkRuntime(root?, model?)` + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `root` | `Element \| null` | `document.body` | Root element to scope event delegation to | +| `model` | `ComarkModel` | internal store | External model for controlled mode | + +Returns a teardown function that removes event listeners. + +### ANSI renderer + +The `@comark/ansi` renderer does not support two-way binding. `::prop` attributes resolve read-only (the current value is used) and a single warning is emitted per process. + +--- + ## Use cases ### Static site generation diff --git a/docs/content/3.rendering/3.vue.md b/docs/content/3.rendering/3.vue.md index c8426cd9..53925e80 100644 --- a/docs/content/3.rendering/3.vue.md +++ b/docs/content/3.rendering/3.vue.md @@ -118,6 +118,8 @@ Passing a document to `` skips parsing at runtime, but the **parser is | `summary` | `boolean` | `false` | Only render content before `` | | [`caret`](#streaming-caret) | `boolean \| { class: string }` | `false` | Append caret to last text node | | [`data`](#code-markdown-props-code-data) | `Record` | `{}` | Runtime values referenced from markdown via `:prop="data.path"` | +| [`model`](#two-way-model-binding) | `ComarkModel` | `undefined` | Two-way binding model for `::prop="path"` attributes. Omit to use an internal uncontrolled store. | +| `onModelChange` | `(path, value, snapshot) => void` | `undefined` | Observer called after every accepted write. Use for controlled-mode sync. | #### `options` @@ -452,6 +454,8 @@ const document = await res.json() | [`streaming`](#streaming) | `boolean` | `false` | Enable streaming mode | | [`caret`](#streaming-caret) | `boolean \| { class: string }` | `false` | Append a blinking caret to the last text node | | [`data`](#code-markdown-props-code-data) | `Record` | `{}` | Runtime values referenced from markdown via `:prop="data.path"` | +| [`model`](#two-way-model-binding) | `ComarkModel` | `undefined` | Two-way binding model for `::prop="path"` attributes. Omit to use an internal uncontrolled store. | +| `onModelChange` | `(path, value, snapshot) => void` | `undefined` | Observer called after every accepted write. Use for controlled-mode sync. | ### `defineMarkdownDocumentComponent` @@ -815,4 +819,90 @@ defineProps<{ ``` +## Two-way model binding + +Use `::prop="data.path"` in your markdown to wire native form elements or pass live values to custom components. No event handlers needed in the markdown itself. + +### Uncontrolled (default) + +When no `model` prop is provided, `` and `` create an internal `createModelStore()` seeded from `data`. Writes stay local to the component. + +```vue [App.vue] + + + +``` + +### Controlled + +Pass your own `ComarkModel` to share state between components or observe changes externally. + +```vue [App.vue] + + + +``` + +### Native form elements + +`::value`, `::checked`, and `::files` on native ``, `