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 ``, `