diff --git a/AGENTS.md b/AGENTS.md index 595ef91e..7be82c61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ This is a **monorepo** containing the Comark Markdown parser, document model, pl │ ├── 3.cli/ # CLI examples (ANSI output, prompts, perf tracing) │ ├── 3.plugins/ # Plugin examples (math, mermaid, highlight, rangi, footnotes, ...) │ └── 4.ai/ # AI streaming examples (Nuxt + AI SDK) -├── docs/ # Documentation site (comark-docs layer) +├── docs/ # Documentation site (comark-docs layer; llms topic files in docs/server/routes/llms-*.txt.get.ts) ├── playground/ # Nuxt playground (`pnpm dev:playground`) ├── benchmarks/ # mitata benchmarks for parse/render/plugins ├── scripts/ # Build/sync/release scripts @@ -684,7 +684,7 @@ Example: // Output: { nodes: [ - ['h1', { id: 'hello' }, 'Hello ', ['strong', {}, 'World']] + ['h1', { id: 'hello-world' }, 'Hello ', ['strong', {}, 'World']] ], frontmatter: {}, meta: {} @@ -979,6 +979,8 @@ chore: update dependencies # No version bump - `3.rendering/` — Vue/React/Svelte/Angular/HTML/ANSI renderer changes - `4.plugins/` — Plugin changes - `5.reference/` — Public API and options changes + - `10.use-cases/` — Task guides (AI chat streaming, generative UI, CMS, CLI, email/RSS) + - `11.compare/` — Comparisons with other libraries (facts must be sourced; no unverified numbers) ### Documentation Checklist diff --git a/docs/app/app.config.ts b/docs/app/app.config.ts index cc618e30..c5e95762 100644 --- a/docs/app/app.config.ts +++ b/docs/app/app.config.ts @@ -13,9 +13,10 @@ export default defineAppConfig({ nav: [ { label: 'Documentation', - sections: ['getting-started', 'syntax', 'rendering', 'reference', 'compare', 'kb'], + sections: ['getting-started', 'syntax', 'rendering', 'reference', 'kb'], }, { label: 'Plugins', sections: ['plugins'], link: 'section' as const }, + { label: 'Use cases', sections: ['use-cases', 'compare'], link: 'section' as const }, { label: 'Examples', sections: ['examples'], link: 'section' as const }, { label: 'Playground', to: '/play' }, { label: 'Ecosystem', sections: ['ecosystem'], link: 'section' as const }, @@ -51,21 +52,25 @@ export default defineAppConfig({ docs: { ogImage: { mark: 'comark' as const, - tagline: 'Parse and render Markdown anywhere', + tagline: 'The Markdown parser and renderer for everything', }, llms: { description: - 'Parse and render Markdown anywhere with one JavaScript library for HTML, ANSI, Vue, React, Svelte and Angular, plus plugins and streaming.', + 'Comark is an open-source Markdown parser and renderer with components, attributes and plugins. It streams AI output to React, Vue, Svelte, Angular, HTML and the terminal.', }, schemaOrg: { description: - 'Parse and render Markdown anywhere with one JavaScript library for HTML, ANSI, Vue, React, Svelte and Angular, plus plugins and streaming.', + 'Comark is an open-source Markdown parser and renderer with components, attributes and plugins. It streams AI output to React, Vue, Svelte, Angular, HTML and the terminal.', applicationCategory: 'DeveloperApplication', operatingSystem: 'Any', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, license: 'https://github.com/comarkdown/comark/blob/main/LICENSE', - sameAs: ['https://github.com/comarkdown/comark', 'https://comark.dev'], + sameAs: ['https://github.com/comarkdown/comark', 'https://www.npmjs.com/package/comark'], programmingLanguage: 'TypeScript', + organization: { + url: 'https://comark.dev', + sameAs: ['https://github.com/comarkdown', 'https://www.npmjs.com/org/comark'], + }, }, }, diff --git a/docs/app/pages/compare.vue b/docs/app/pages/compare.vue deleted file mode 100644 index 009e2104..00000000 --- a/docs/app/pages/compare.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - diff --git a/docs/app/pages/play/index.vue b/docs/app/pages/play/index.vue index e7caf8f6..2b3e396d 100644 --- a/docs/app/pages/play/index.vue +++ b/docs/app/pages/play/index.vue @@ -2,9 +2,9 @@ definePageMeta({ footer: false, }) -const title = 'Playground - Comark' +const title = 'Markdown Playground' const description = - 'Try Comark live in the browser. Edit markdown with component syntax and see the rendered output in real-time.' + 'Try Comark live in the browser. Edit Markdown with component syntax and see the rendered output as you type.' useSeoMeta({ title, description, diff --git a/docs/app/pages/stream.vue b/docs/app/pages/stream.vue index a4af2f6d..ae15de17 100644 --- a/docs/app/pages/stream.vue +++ b/docs/app/pages/stream.vue @@ -8,7 +8,7 @@ import landingMD from '~~/content/index.md?raw' definePageMeta({ footer: false, }) -const title = 'Streaming Demo - Comark' +const title = 'Streaming Markdown Demo' const description = 'See Comark streaming in action. Watch markdown content parse and render in real-time as it arrives.' useSeoMeta({ diff --git a/docs/content/1.getting-started/0.introduction.md b/docs/content/1.getting-started/0.introduction.md index 2b793466..4b865187 100644 --- a/docs/content/1.getting-started/0.introduction.md +++ b/docs/content/1.getting-started/0.introduction.md @@ -1,6 +1,8 @@ --- -title: Introduction -description: Parse and render CommonMark and GFM anywhere with Comark, a JavaScript library for HTML, ANSI, Vue, React, Svelte, Angular, plugins, and streaming. +title: 'What Is Comark? Markdown Parser and Renderer' +description: 'Comark is an open-source Markdown parser and renderer with components, attributes and plugins. It streams AI output to React, Vue, Svelte, Angular, HTML and the terminal.' +navigation: + title: Introduction links: - label: Installation to: /getting-started/installation @@ -15,9 +17,7 @@ links: variant: soft --- -## What is Comark? - -**Comark** is a JavaScript library for parsing and rendering Markdown anywhere. Give it standard CommonMark or GitHub Flavored Markdown (GFM), then render the same source to HTML, ANSI, Vue, React, Svelte, or Angular. +**Comark** is an open-source Markdown parser and renderer with components, attributes and plugins. It streams AI output to React, Vue, Svelte, Angular, HTML and the terminal. Give it standard CommonMark or GitHub Flavored Markdown (GFM), and render the same source to any of these targets. Parsing and rendering are decoupled through a compact, serializable [`MarkdownDocument`](/getting-started/document-model). Parse on the server, in the browser, in a worker, during a build, or as content streams in. You can then inspect, transform, cache, serialize, or render the document without tying your content to one output target. @@ -214,6 +214,6 @@ For detailed, honest comparisons, see [Comark vs MDX](/compare/comark-vs-mdx), [ ::: :::accordion-item{label="Do I have to use components?"} - No. Comark is a superset of CommonMark and GFM, so plain Markdown parses unchanged. Components and [attributes](/syntax/attributes) are opt-in syntax. + No. Plain CommonMark and GFM parse as standard Markdown, with a few [documented differences](/syntax/markdown#differences-from-commonmark-and-gfm). Components and [attributes](/syntax/attributes) are opt-in syntax. ::: :: diff --git a/docs/content/10.use-cases/.navigation.yml b/docs/content/10.use-cases/.navigation.yml new file mode 100644 index 00000000..aea76ac3 --- /dev/null +++ b/docs/content/10.use-cases/.navigation.yml @@ -0,0 +1,2 @@ +title: Use cases +icon: i-lucide-lightbulb diff --git a/docs/content/10.use-cases/1.intelligent-ui.md b/docs/content/10.use-cases/1.intelligent-ui.md new file mode 100644 index 00000000..71fa0572 --- /dev/null +++ b/docs/content/10.use-cases/1.intelligent-ui.md @@ -0,0 +1,293 @@ +--- +title: 'Build Your Own Intelligent UI: Stream Components in LLM Answers' +description: 'Let a model answer with your own interactive components, rendered progressively as it streams, in Vue, React, Svelte, or Angular. The same pattern as ChatGPT Intelligent UI.' +navigation: + title: Intelligent UI + icon: i-lucide-layout-dashboard +seo: + title: 'Build Your Own Intelligent UI Like ChatGPT with Streamed Components' +links: + - label: Generative UI + icon: i-lucide-sparkles + to: /use-cases/generative-ui + color: neutral + variant: soft + - label: Component syntax + icon: i-lucide-component + to: /syntax/components + color: neutral + variant: soft +--- + +An intelligent UI answers with interactive components, such as a calculator, a timeline, or a chart, instead of text only. With Comark, the model writes component syntax in its Markdown answer, and your app renders your own components while the answer streams. + +## The pattern + +On October 7, 2026, OpenAI introduced [Intelligent UI in ChatGPT](https://openai.com/index/gpt-6-for-everyone/). ChatGPT can now answer with graphics, buttons, forms, charts, and small tools. OpenAI describes how it works: + +> We built a library of native, streamable components, along with a compiler that processes the interface as the model generates it. + +You can build the same pattern in your own app. Each part has a Comark equivalent: + +| Intelligent UI | Comark | +| --- | --- | +| A library of native components | Your Vue, React, Svelte, or Angular components, registered in the `components` prop | +| Streamable components | `streaming` and `autoClose`: a component renders before its closing `::` arrives | +| A compiler that processes the interface during generation | The parser turns `::component{props}` into data, and runs no code | +| The model picks the layout, or plain text | A system prompt that lists your components and when to use them | + +Comark isn't affiliated with OpenAI, and ChatGPT doesn't use Comark. This page shows how to build a similar experience with your own model and components. + +## What the model writes + +The model answers in Markdown, and places a component where it helps. Here, a dinner plan includes a calculator for quantities and a timeline: + +```mdc +## Pizza night for friends + +Here is a plan that scales with your guest count. + +::quantity-calculator{:guests="6"} +--- +items: + - name: Pizza dough balls + perGuest: 1.5 + unit: balls + - name: Mozzarella + perGuest: 80 + unit: g +--- +:: + +::timeline +- **4:00 pm** Take the dough out of the fridge. +- **6:30 pm** Heat the oven to its highest setting. +:: +``` + +The `:` prefix gives a typed value: `guests` is the number `6`. The YAML block passes `items` as an array of objects. The list inside `::timeline` is the default slot. See [component syntax](/syntax/components) for props, slots, and nesting. + +## Write streamable components + +A component renders on the first frame that contains its opening line, and gets new props on each frame. Your component receives partial props while the model writes them: + +1. While the model writes `{:guests="`, the `guests` prop is `undefined`. +2. While it writes the YAML block, `items` is missing. +3. Then `items` grows one entry at a time, and the last entry can miss fields, for example `{ name: 'Mozzarel' }`. + +Make every prop optional, and render what you have. Check model values before you use them, and keep the user's edits separate from the props, so a later frame doesn't overwrite them: + +::code-group + +```vue [QuantityCalculator.vue] + + + +``` + +```tsx [QuantityCalculator.tsx] +'use client' +import { useState } from 'react' + +interface Item { name?: string, perGuest?: number, unit?: string } + +// Model values are untrusted: keep only positive, finite numbers. +const positive = (value: unknown) => + typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined + +export function QuantityCalculator({ guests, items = [] }: { guests?: number, items?: Item[] }) { + // After the user edits the count, new `guests` props from the stream don't override it. + const [edited, setEdited] = useState() + const count = edited ?? positive(guests) ?? 4 + + return ( +
+ + +
+ ) +} +``` + +:: + +Register the components, and render each text part with `streaming` set from the AI SDK part state. Svelte and Angular take the same `components` map: + +::code-group + +```vue [Message.vue] + + + +``` + +```tsx [Message.tsx] +'use client' +import { MarkdownClient } from '@comark/react' +import { QuantityCalculator } from './QuantityCalculator' +import { Timeline } from './Timeline' + +const components = { 'quantity-calculator': QuantityCalculator, timeline: Timeline } + +export function Message({ text, isStreaming }: { text: string, isStreaming: boolean }) { + return +} +``` + +:: + +See [AI chat streaming](/use-cases/ai-chat-streaming) for the chat loop in each framework. + +## Help the model pick the right component + +The model can only use the components it knows about. A catalog in the system prompt tells it what each component does, which props it takes, and when it helps. Keep the catalog in code next to the components, so the prompt never drifts from what you registered: + +```typescript [server/catalog.ts] +interface CatalogEntry { + tag: string + description: string + useWhen: string + example: string +} + +export const catalog: CatalogEntry[] = [ + { + tag: 'quantity-calculator', + description: 'Scales ingredient or material quantities with an editable guest count.', + useWhen: 'The answer lists quantities that depend on a number of people.', + example: '::quantity-calculator{:guests="6"}\n---\nitems:\n - name: Flour\n perGuest: 100\n unit: g\n---\n::', + }, + { + tag: 'timeline', + description: 'Shows ordered steps with times. The default slot is a Markdown list.', + useWhen: 'The answer is a schedule or a sequence of timed steps.', + example: '::timeline\n- **9:00** Arrive.\n- **9:30** Start.\n::', + }, +] + +export function systemPrompt(): string { + const components = catalog + .map(entry => `### ${entry.tag}\n${entry.description}\nUse when: ${entry.useWhen}\nExample:\n${entry.example}`) + .join('\n\n') + + return `Answer in Markdown. You can place these Comark components in your answer: + +${components} + +Rules: +- Use a component only when it makes the answer easier to use than text. +- Answer in plain Markdown when text is enough. +- Don't use other components or raw HTML.` +} +``` + +Three practices help the model choose well: + +- **Describe the intent, not only the props.** The `useWhen` line gives the model a reason to pick the component. +- **Allow plain text.** Without the rule, a model tends to place components in every answer. +- **Give one example per component.** The model copies the syntax of the example, including the `:` prefix and the YAML block. + +To test the catalog, run a fixed set of questions against it, and check which components each answer uses. This docs site builds its page generator prompt from a component registry the same way: see [`docs/server/utils/prompt.ts`](https://github.com/comarkdown/comark/blob/main/docs/server/utils/prompt.ts). + +## Keep model output safe + +Model output is untrusted input. Component syntax runs no code, but the model can still write raw HTML or name a tag that you didn't register. Add the [security plugin](/plugins/built-in/security) and list your components in `allowedTags`, next to the Markdown tags that you render: + +```typescript +import security from '@comark/vue/plugins/security' + +const plugins = [ + security({ + allowedTags: [ + 'quantity-calculator', 'timeline', + 'p', 'h2', 'h3', 'ul', 'ol', 'li', 'strong', 'em', 'a', 'code', 'pre', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'th', 'td', + ], + }), +] +``` + +Pass `plugins` to the same `` component. Your components also receive values from the model, so check them before you use them, for example a negative `perGuest`. For more about props, unknown tags, and the `data` prop, see [generative UI](/use-cases/generative-ui#validate-the-output). + +## FAQ + +::accordion + :::accordion-item{label="Do I need GPT-6 to build this?"} + No. Any model that follows instructions can write component syntax. Test your catalog with the model that you use, and add examples where it makes mistakes. + ::: + + :::accordion-item{label="Can a component update while the model writes it?"} + Yes. The component mounts on the first frame and receives new props on each frame. Keep its local state, such as the guest count in an input, separate from the props. + ::: + + :::accordion-item{label="What if the model writes a component I didn't register?"} + Without the security plugin, it renders as a plain element with the same tag name. With `allowedTags`, the plugin removes it with its content. To keep the text instead, set the plugin's `tagFallback` option. + ::: + + :::accordion-item{label="How is this different from generative UI?"} + It's the same technique. [Generative UI](/use-cases/generative-ui) explains how component syntax becomes your components. This page adds a component library, streamable components, and a catalog that helps the model choose. + ::: +:: + +## Next steps + +- [Let an LLM render UI components inside Markdown](/use-cases/generative-ui) +- [Render streaming Markdown from an LLM](/use-cases/ai-chat-streaming) +- [Component syntax](/syntax/components) +- [Security plugin options](/plugins/built-in/security#options) diff --git a/docs/content/10.use-cases/10.email-and-rss.md b/docs/content/10.use-cases/10.email-and-rss.md new file mode 100644 index 00000000..1175e68c --- /dev/null +++ b/docs/content/10.use-cases/10.email-and-rss.md @@ -0,0 +1,150 @@ +--- +title: 'Convert Markdown to HTML for Emails and RSS Feeds' +description: 'Turn Markdown with components into HTML strings for emails and RSS feeds with @comark/html, including inline-styled components and CDATA output.' +navigation: + title: Emails and RSS + icon: i-lucide-mail +links: + - label: HTML rendering + icon: i-lucide-file-code + to: /rendering/html + color: neutral + variant: soft + - label: Component syntax + icon: i-lucide-component + to: /syntax/components + color: neutral + variant: soft +--- + +Use `renderHtml()` from `@comark/html` to turn Markdown into an HTML string, with no framework. Map component tags to functions that return HTML, for example a button with inline styles for an email. + +Emails and RSS readers receive static HTML. They don't run your Vue, React, or Svelte components. `@comark/html` renders each component with a function that you write, so you control the exact markup. + +## Render Markdown to HTML + +Install the package: + +```bash [Terminal] +npm install @comark/html +``` + +`renderHtml()` parses and renders in one call: + +```typescript [newsletter.ts] +import { renderHtml } from '@comark/html' + +const html = await renderHtml('# October update\n\nWe shipped **dark mode**.') +//

October update

+//

We shipped dark mode.

+``` + +When you render many messages or feed items, create the renderer once with `createHtmlRenderer()`. The returned function reuses the same parser and plugins. + +## Add an email button component + +Write the component as an async function. It receives the node as `[tag, attrs, ...children]` and a `render()` helper for the children. Escape attribute values with `escapeHtml()`: + +```typescript [email.ts] +import { createHtmlRenderer } from '@comark/html' +import { escapeHtml } from '@comark/html/utils' + +export const renderEmail = createHtmlRenderer({ + components: { + button: async ([, attrs, ...children], { render }) => + `${await render(children)}`, + }, +}) +``` + +The email content uses the component like any other Comark component: + +```mdc [welcome.md] +Thanks for signing up. + +:button[Confirm your email]{href="https://example.com/confirm"} +``` + +`@comark/html` returns only the content HTML. Put it inside your email layout. Email clients differ in their CSS support. We recommend that you inline the styles of your layout and of the Markdown elements with a separate CSS inliner tool. + +## Pass per-recipient data + +Pass values in the `data` option. Every component function receives them in its context: + +```typescript [order-email.ts] +import { renderHtml } from '@comark/html' +import { escapeHtml } from '@comark/html/utils' + +const html = await renderHtml('::greeting\nYour order has shipped.\n::', { + data: { name: 'Ada' }, + components: { + greeting: async ([, , ...children], { render, data }) => + `

Hi ${escapeHtml(String(data?.name))},

${await render(children)}`, + }, +}) +``` + +## Build an RSS feed + +Put the title and the HTML of each item in CDATA sections, and escape the link URL, so the feed stays valid XML. A CDATA section can't contain `]]>`, so split that sequence: + +```typescript [rss.ts] +import { createHtmlRenderer } from '@comark/html' + +const renderHtml = createHtmlRenderer() + +// Keep `]]>` in the value from closing the CDATA section early. +const cdata = (value: string) => `', ']]]]>')}]]>` +const escapeXml = (value: string) => + value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') + +export async function renderItem(post: { title: string, url: string, markdown: string }) { + const html = await renderHtml(post.markdown) + return ` + ${cdata(post.title)} + ${escapeXml(post.url)} + ${cdata(html)} +` +} +``` + +## Make links absolute + +Emails and feed readers can't resolve relative URLs such as `/docs`. Override the native `a` tag and resolve each `href` against your site: + +```typescript [email.ts] +import { createHtmlRenderer } from '@comark/html' +import { escapeHtml } from '@comark/html/utils' + +export const renderEmail = createHtmlRenderer({ + components: { + a: async ([, attrs, ...children], { render }) => + `${await render(children)}`, + }, +}) +``` + +Use the same approach for `img` and its `src` attribute. If the Markdown comes from users, also add the [security plugin](/plugins/built-in/security) with a `blockedTags` list. + +## FAQ + +::accordion + :::accordion-item{label="Does @comark/html make the HTML work in every email client?"} + No. It renders the Markdown and your component functions to HTML. Email client support depends on the markup and the CSS that you write. + ::: + + :::accordion-item{label="Can I use the same Markdown on my website?"} + Yes. Render the same source with a framework renderer on the site, and with `@comark/html` for email. Register a component for each target. + ::: + + :::accordion-item{label="Can I render a document that I already parsed?"} + Yes. `renderHtmlFromDocument()` renders a stored `MarkdownDocument` without a parse step. + ::: +:: + +## Next steps + +- [HTML rendering reference](/rendering/html) +- [Render Markdown from a CMS or database](/use-cases/cms-runtime-content) +- [Binding plugin](/plugins/built-in/binding) +- [Render Markdown in the terminal](/use-cases/cli-and-agents) diff --git a/docs/content/10.use-cases/2.ai-chat-streaming.md b/docs/content/10.use-cases/2.ai-chat-streaming.md new file mode 100644 index 00000000..a55e4e95 --- /dev/null +++ b/docs/content/10.use-cases/2.ai-chat-streaming.md @@ -0,0 +1,160 @@ +--- +title: 'Render Streaming Markdown from an LLM' +description: 'Render LLM output as Markdown while it streams, with no raw asterisks or open code fences, in Vue, React, Svelte, and Angular.' +navigation: + title: AI chat streaming + icon: i-lucide-message-square-text +links: + - label: Auto-close API + icon: i-lucide-radio + to: /reference/auto-close + color: neutral + variant: soft + - label: Streaming demo + icon: i-lucide-play + to: /stream + color: neutral + variant: soft +--- + +Pass the accumulated text to a Comark renderer component, and set its `streaming` prop while tokens arrive. Comark closes unfinished syntax on every parse, so each frame renders as valid Markdown. + +## The problem + +An LLM sends Markdown in small chunks. Most chunks end in the middle of a construct, for example `**bold` or a code fence with no closing line. A standard Markdown renderer shows these half-written parts as raw characters. When the closing marker arrives, the block changes shape, and the layout jumps. Comark handles this with two features: + +- **`autoClose`** is on by default in every renderer. It closes unterminated bold, italic, inline code, links, tables, and components before each parse. An open code fence renders as a code block that grows until the fence closes. An incomplete link gets the placeholder URL `comark:incomplete-link`. +- **`streaming`** turns on incremental parsing. Comark reuses the output of completed blocks, drops a half-typed opener at the end, and keeps the previous output if a parse fails. + +Set `streaming` back to `false` when the stream ends. Comark then runs a final full parse. The `caret` prop adds a blinking cursor to the last text node while `streaming` is `true`. + +## Render a streaming message + +Each renderer takes the same three inputs: the text, a `streaming` flag, and an optional `caret`: + +::code-group + +```vue [Vue] + + + +``` + +```tsx [React] +'use client' +import { MarkdownClient } from '@comark/react' + +export function Message({ text, isStreaming }: { text: string, isStreaming: boolean }) { + return +} +``` + +```svelte [Svelte] + + + +``` + +```typescript [Angular] +import { Component, Input } from '@angular/core' +import { Markdown } from '@comark/angular' + +@Component({ + selector: 'app-message', + standalone: true, + imports: [Markdown], + template: ``, +}) +export class MessageComponent { + @Input() text = '' + @Input() isStreaming = false +} +``` + +:: + +In Vue, `` is async, so wrap it in ``. In React, `` is an async component. It hands off to `MarkdownClient` when `streaming` is `true`. In a client component, such as a chat that uses `useChat`, use `MarkdownClient` directly. + +## Set `streaming` from the AI SDK + +The [AI SDK](https://ai-sdk.dev/docs) splits each message into `parts`. A text part has `state === 'streaming'` while it receives tokens. Pass that value to the `streaming` prop, so each part stops streaming on its own: + +```tsx [app/chat.tsx] +'use client' +import { useState } from 'react' +import { useChat } from '@ai-sdk/react' +import { MarkdownClient } from '@comark/react' + +export default function Chat() { + const { messages, sendMessage } = useChat() + const [input, setInput] = useState('') + return ( +
+ {messages.map(message => ( +
+ {message.parts.map((part, index) => + part.type === 'text' + ? + : null + )} +
+ ))} +
{ event.preventDefault(); sendMessage({ text: input }); setInput('') }}> + setInput(event.target.value)} /> +
+
+ ) +} +``` + +For complete chat setups, see the [Vue and Nuxt](/use-cases/vue-ai-chat), [Svelte](/use-cases/svelte-ai-chat), and [Angular](/use-cases/angular-ai-chat) guides. + +## Sanitize model output + +Treat model output as untrusted input. The default `html` plugin parses raw HTML into nodes. Without more configuration, a raw ` + + +``` + +:: + +User messages render as plain text in this example. Only the assistant output goes through the Markdown parser. + +## Use Nuxt UI chat components + +The [full example](/examples/ai/nuxt-ai-sdk) uses Nuxt UI chat components, such as `UChatMessages` and `UChatPrompt`. It reads the part state with the `isPartStreaming()` helper: + +```vue [app/pages/index.vue] + + + +``` + +When `@nuxt/ui` is installed, Comark also renders Markdown elements with the Nuxt UI prose components. See [Nuxt UI integration](/rendering/nuxt#nuxt-ui-integration). + +## Add components and plugins + +Pass components and plugins to ``, for example to highlight code in answers. To keep untrusted output safe, add the [security plugin](/plugins/built-in/security): + +```vue [app/pages/index.vue] + + + +``` + +## FAQ + +::accordion + :::accordion-item{label="Why do I need Suspense?"} + The Vue `` component is async, and it must be inside a `` boundary. The example puts one boundary around each assistant part. + ::: + + :::accordion-item{label="Can I render the answer on the server?"} + Chat messages arrive in the browser, so client rendering is enough. For server-rendered content, parse on the server and render with [``](/rendering/vue#code-markdowndocument). + ::: + + :::accordion-item{label="Can the model write Comark components?"} + Yes. Register the components in the `components` prop and tell the model which ones exist. See [generative UI](/use-cases/generative-ui). + ::: +:: + +## Next steps + +- [Run the full Nuxt AI SDK example](/examples/ai/nuxt-ai-sdk) +- [Render streaming Markdown from an LLM](/use-cases/ai-chat-streaming) +- [Let an LLM render UI components](/use-cases/generative-ui) +- [Vue streaming reference](/rendering/vue#streaming) diff --git a/docs/content/10.use-cases/4.svelte-ai-chat.md b/docs/content/10.use-cases/4.svelte-ai-chat.md new file mode 100644 index 00000000..c109cf32 --- /dev/null +++ b/docs/content/10.use-cases/4.svelte-ai-chat.md @@ -0,0 +1,159 @@ +--- +title: 'Stream Markdown in Svelte and SvelteKit with the AI SDK' +description: 'Build a Svelte 5 chat that streams AI SDK responses and renders each message as Markdown with Comark while it arrives.' +navigation: + title: Svelte chat + icon: i-simple-icons-svelte +links: + - label: Svelte rendering + icon: i-simple-icons-svelte + to: /rendering/svelte + color: neutral + variant: soft + - label: SvelteKit example + icon: i-lucide-square-play + to: /examples/frameworks/sveltekit + color: neutral + variant: soft +--- + +Use the `Chat` class from `@ai-sdk/svelte` to receive messages, and render each text part with `` from `@comark/svelte`. Comark closes unfinished syntax on every update, and runs a full parse when `streaming` becomes `false`. + +This guide uses Svelte 5 and SvelteKit. It assumes that you have a SvelteKit app and an AI provider key. + +## Build the chat + +::steps{level="3"} + +### Install the packages + +```bash [Terminal] +npm install @comark/svelte ai @ai-sdk/svelte +``` + +### Create the server route + +`Chat` sends requests to `/api/chat` by default. Create a SvelteKit endpoint at that path: + +```typescript [src/routes/api/chat/+server.ts] +import { convertToModelMessages, createGateway, streamText, type UIMessage } from 'ai' +import { AI_GATEWAY_API_KEY } from '$env/static/private' + +const gateway = createGateway({ apiKey: AI_GATEWAY_API_KEY }) + +export async function POST({ request }) { + const { messages }: { messages: UIMessage[] } = await request.json() + + const result = streamText({ + model: gateway('anthropic/claude-sonnet-4.6'), + messages: await convertToModelMessages(messages), + }) + + return result.toUIMessageStreamResponse() +} +``` + +Vite doesn't copy environment variables to `process.env`, so the route reads the key from `$env/static/private`. The server API can change between AI SDK versions. Check the [AI SDK Svelte guide](https://ai-sdk.dev/docs/getting-started/svelte) for your version. + +### Render the messages + +Pass `part.state === 'streaming'` to the `streaming` prop. Each part then runs its final full parse as soon as it is complete: + +```svelte [src/routes/+page.svelte] + + +{#each chat.messages as message (message.id)} + {#each message.parts as part, index (index)} + {#if part.type === 'text'} + {#if message.role === 'user'} +

{part.text}

+ {:else} + + {/if} + {/if} + {/each} +{/each} + +
+ +
+``` + +:: + +Don't destructure `chat`. A destructured `messages` value is a copy and doesn't update. + +## Server-side rendering + +The Svelte `` component uses `$effect`, so it doesn't render during SSR. This is fine for chat, because the messages arrive in the browser. + +For content that must be in the server HTML, parse it in a `load` function and render it with ``: + +```typescript [src/routes/docs/+page.server.ts] +import { parseMarkdown } from 'comark' + +export const load = async () => { + return { tree: await parseMarkdown('# Release notes\n\nThis page renders on the server.') } +} +``` + +```svelte [src/routes/docs/+page.svelte] + + + +``` + +The [SvelteKit example](/examples/frameworks/sveltekit) shows both routes, including lazy components with ``. + +## Sanitize model output + +Raw HTML in model output is parsed into nodes by default. Add the [security plugin](/plugins/built-in/security) and pass it in the `plugins` prop: + +```svelte + + + +``` + +## FAQ + +::accordion + :::accordion-item{label="Does @comark/svelte support Svelte 4?"} + No. `@comark/svelte` provides Svelte 5 components that use runes such as `$state` and `$effect`. + ::: + + :::accordion-item{label="Why is my Markdown missing from the server HTML?"} + `` renders only in the browser. Parse on the server and use ``, or use the experimental [``](/rendering/svelte#code-markdownasync-code-experimental). + ::: + + :::accordion-item{label="How do I map a component to a Svelte component?"} + Pass a `components` map, for example `components={{ alert: Alert }}`. See [Svelte components](/rendering/svelte#code-markdown-props-code-components). + ::: +:: + +## Next steps + +- [Render streaming Markdown from an LLM](/use-cases/ai-chat-streaming) +- [Let an LLM render UI components](/use-cases/generative-ui) +- [Svelte streaming reference](/rendering/svelte#streaming) +- [SvelteKit example](/examples/frameworks/sveltekit) diff --git a/docs/content/10.use-cases/5.angular-ai-chat.md b/docs/content/10.use-cases/5.angular-ai-chat.md new file mode 100644 index 00000000..845ba3e3 --- /dev/null +++ b/docs/content/10.use-cases/5.angular-ai-chat.md @@ -0,0 +1,153 @@ +--- +title: 'Stream Markdown in Angular with the AI SDK' +description: 'Build an Angular 17+ chat that streams AI SDK responses and renders each message as Markdown with Comark while it arrives.' +navigation: + title: Angular chat + icon: i-simple-icons-angular +links: + - label: Angular rendering + icon: i-simple-icons-angular + to: /rendering/angular + color: neutral + variant: soft + - label: Vite Angular example + icon: i-lucide-square-play + to: /examples/vite/angular + color: neutral + variant: soft +--- + +Use the `Chat` class from `@ai-sdk/angular` to receive messages, and render each text part with ``. Comark closes unfinished syntax while the part streams, and runs a full parse when it is complete. + +`@comark/angular` supports Angular 17 or later. See the [requirements](/rendering/angular#requirements) for the exact range. The `Chat` class from `@ai-sdk/angular` stores its state in Angular signals, so the template updates as tokens arrive. + +## Build the chat + +::steps{level="3"} + +### Install the packages + +```bash [Terminal] +npm install @comark/angular ai @ai-sdk/angular +``` + +### Create the server route + +`Chat` sends requests to `/api/chat` by default. This Express route streams the model response in the format that `Chat` expects: + +```typescript [server.ts] +import express from 'express' +import { convertToModelMessages, streamText } from 'ai' + +const app = express() +app.use(express.json()) + +app.post('/api/chat', async (req, res) => { + const result = streamText({ + model: 'anthropic/claude-sonnet-4.6', + messages: await convertToModelMessages(req.body.messages), + }) + + result.pipeUIMessageStreamToResponse(res) +}) + +app.listen(3000) +``` + +Configure the Angular dev server to proxy `/api` to this server. The server API can change between AI SDK versions. Check the [AI SDK docs](https://ai-sdk.dev/docs) for your version. + +### Render the messages + +Read `chat.messages` in the template. Pass `part.state === 'streaming'` to the `streaming` input: + +```typescript [chat.component.ts] +import { Component } from '@angular/core' +import { Chat } from '@ai-sdk/angular' +import { Markdown } from '@comark/angular' + +@Component({ + selector: 'app-chat', + standalone: true, + imports: [Markdown], + template: ` + @for (message of chat.messages; track message.id) { + @for (part of message.parts; track $index) { + @switch (part.type) { + @case ('text') { + @if (message.role === 'user') { +

{{ part.text }}

+ } @else { + + } + } + } + } + } + +
+ +
+ `, +}) +export class ChatComponent { + chat = new Chat({}) + + send(event: Event, prompt: HTMLInputElement) { + event.preventDefault() + this.chat.sendMessage({ text: prompt.value }) + prompt.value = '' + } +} +``` + +:: + +## Map components and add plugins + +Use the `components` input to map Comark tags to Angular components. Use the `plugins` input to add parser plugins, for example the [security plugin](/plugins/built-in/security) for untrusted output. This excerpt adds two properties to the component above: + +```typescript [chat.component.ts] +import security from '@comark/angular/plugins/security' +import { AlertComponent } from './alert.component' + +export class ChatComponent { + chat = new Chat({}) + components = { alert: AlertComponent } + plugins = [security({ blockedTags: ['script', 'iframe', 'embed', 'form', 'base', 'meta', 'link', 'style', 'object'] })] +} +``` + +```html + +``` + +Comark passes component props as `@Input()` values. The default slot maps to ``. See [component bindings](/rendering/angular#component-bindings). + +## FAQ + +::accordion + :::accordion-item{label="Does it work without the Angular CLI?"} + Yes. `@comark/angular` doesn't depend on a bundler. It works with the Angular CLI, Vite with AnalogJS, webpack, and esbuild. + ::: + + :::accordion-item{label="How do I style the caret?"} + Pass `[caret]="{ class: 'my-caret' }"` and style that class. See [caret](/rendering/angular#caret). + ::: + + :::accordion-item{label="How do I render content that is already parsed?"} + Use `` with a document from `parseMarkdown()`. It renders the document without a parse step. See [``](/rendering/angular#code-comark-markdown-document). + ::: +:: + +## Next steps + +- [Render streaming Markdown from an LLM](/use-cases/ai-chat-streaming) +- [Let an LLM render UI components](/use-cases/generative-ui) +- [Angular streaming reference](/rendering/angular#streaming) +- [Vite Angular example](/examples/vite/angular) diff --git a/docs/content/10.use-cases/6.generative-ui.md b/docs/content/10.use-cases/6.generative-ui.md new file mode 100644 index 00000000..64aaac3e --- /dev/null +++ b/docs/content/10.use-cases/6.generative-ui.md @@ -0,0 +1,154 @@ +--- +title: 'Let an LLM Render UI Components Inside Markdown Safely' +description: 'Let a model write Comark component syntax such as ::flight-card and render real Vue, React, Svelte, or Angular components, with no code execution.' +navigation: + title: Generative UI + icon: i-lucide-sparkles +links: + - label: Component syntax + icon: i-lucide-component + to: /syntax/components + color: neutral + variant: soft + - label: Security plugin + icon: i-lucide-shield-check + to: /plugins/built-in/security + color: neutral + variant: soft +--- + +The model writes component syntax, for example `::flight-card{from="CDG" to="JFK" price="342"}`, and your app maps the tag to a real component. Component syntax is data: it runs no code, and only the components that you register render as components. Raw HTML in the output is different: the default `html` plugin keeps a ` + + +``` + +```vue [Chat.vue] + + + +``` + +:: + +React, Svelte, and Angular use the same `components` map. See [components](/syntax/components) for props, slots, and nesting. + +Attribute values are strings. Ask the model for a `:` prefix to get a typed value: `:price="342"` passes the number `342`. + +## Tell the model which components exist + +A model can't guess your components. List each component, its props, and one example in the system prompt: + +```typescript [server/api/chat.post.ts] +const system = `Answer in Markdown. You can use these Comark components: + +- ::flight-card — one flight offer. Props: from and to (IATA airport codes), :price (number, USD). + Example: ::flight-card{from="CDG" to="JFK" :price="342"} + :: + +Don't use other components or raw HTML.` +``` + +For the full syntax, give the model the Comark syntax guide for agents at [comark.dev/.well-known/skills/comark/references/markdown-syntax.md](https://comark.dev/.well-known/skills/comark/references/markdown-syntax.md). + +## Validate the output + +Component syntax can name any tag, for example `::script` or `::iframe`. Because unregistered tags render as plain elements, block dangerous tag names with the [security plugin](/plugins/built-in/security): + +```typescript +import security from '@comark/vue/plugins/security' + +const plugins = [ + security({ + blockedTags: ['script', 'iframe', 'embed', 'form', 'base', 'meta', 'link', 'style', 'object'], + allowedProtocols: ['https', 'mailto'], + }), +] +``` + +The plugin removes blocked tags from raw HTML and from component syntax. It also strips `on*` attributes and unsafe URLs such as `javascript:`. To restrict links to your own domains, add `allowedLinkPrefixes`. Your components also receive props from the model, so validate those values in the component. + +A `:`-prefixed prop can also read a path from the render context, for example `:label="data.user.email"`. Don't put secrets in the `data` prop when the content comes from a model. + +## Stream components + +`autoClose` closes an open component while the model writes it. A frame that ends with `::flight-card{from="CDG"` still parses as a `flight-card` node. Your component renders at once, and gets new props on each frame. Set `streaming` from the part state, as described in [AI chat streaming](/use-cases/ai-chat-streaming). + +## Use JSON specs instead + +Some models produce structured JSON more reliably than Markdown syntax. The [json-render plugin](/plugins/built-in/json-render) reads a `json-render` or `yaml-render` code block and replaces it with element nodes: + +````mdc +```json-render +{ "type": "FlightCard", "props": { "from": "CDG", "to": "JFK", "price": 342 } } +``` +```` + +Each element `type` becomes the tag name, so map `FlightCard` in the `components` prop. JSON values keep their types. Pass `jsonRender()` in `plugins`, from `@comark/vue/plugins/json-render` or the path for your framework. + +## FAQ + +::accordion + :::accordion-item{label="Can the model run JavaScript through a component?"} + No. Component syntax produces data, and Comark passes it to the components that you register. Block `script` and other risky tags, because unregistered tags render as plain elements. + ::: + + :::accordion-item{label="What if the model uses a component I didn't register?"} + It renders as a plain element with the same tag name and its children. To keep only the tags you expect, set the `allowedTags` option of the security plugin. List your components and the Markdown tags that you allow. + ::: + + :::accordion-item{label="When do I use json-render instead of component syntax?"} + Use a `json-render` block when your model writes structured JSON more reliably than component syntax. Component syntax mixes text and UI in one answer. Both produce the same kind of nodes. + ::: +:: + +## Next steps + +- [Render streaming Markdown from an LLM](/use-cases/ai-chat-streaming) +- [Component syntax](/syntax/components) +- [Security plugin options](/plugins/built-in/security#options) +- [json-render example](/examples/plugins/vue-vite-json-render) diff --git a/docs/content/10.use-cases/7.docs-and-blogs.md b/docs/content/10.use-cases/7.docs-and-blogs.md new file mode 100644 index 00000000..87272967 --- /dev/null +++ b/docs/content/10.use-cases/7.docs-and-blogs.md @@ -0,0 +1,156 @@ +--- +title: 'Build a Markdown Docs Site or Blog in Nuxt, Next.js, Astro, or SvelteKit' +description: 'Parse Markdown files with frontmatter, a table of contents, and highlighted code, then render them with your own components in Nuxt, Next.js, Astro, or SvelteKit.' +navigation: + title: Docs and blogs + icon: i-lucide-book-open +links: + - label: Examples + icon: i-lucide-square-play + to: /examples/frameworks/nuxt + color: neutral + variant: soft + - label: Parse API + icon: i-lucide-file-code + to: /reference/parse + color: neutral + variant: soft +--- + +Parse each Markdown file on the server or at build time with `parseMarkdown()`, then render the result with ``. The browser receives the rendered page, and it doesn't need the parser. + +A Markdown file for a post holds YAML frontmatter, Markdown, and components: + +```mdc [content/posts/hello-world.md] +--- +title: Hello World +pubDate: 2026-10-07 +--- + +## Getting started + +::alert{type="info"} +Components render with your own design system. +:: +``` + +## Parse with frontmatter, a table of contents, and code highlighting + +One parse returns the content, the frontmatter, and plugin metadata. The [shiki plugin](/plugins/built-in/shiki) highlights code blocks. The [toc plugin](/plugins/built-in/toc) builds a table of contents from the `h2` to `h6` headings: + +```typescript [lib/posts.ts] +import { readFile } from 'node:fs/promises' +import { createMarkdownParser } from 'comark' +import shiki from 'comark/plugins/shiki' +import toc from 'comark/plugins/toc' + +const parse = createMarkdownParser({ plugins: [shiki(), toc({ depth: 3 })] }) + +export async function getPost(slug: string) { + const tree = await parse(await readFile(`content/posts/${slug}.md`, 'utf-8')) + + tree.frontmatter.title // 'Hello World' + tree.meta.toc.links // [{ id: 'getting-started', text: 'Getting started', depth: 2 }] + return tree +} +``` + +`createMarkdownParser()` sets up the parser and its plugins once. Reuse the returned function for every file. + +## Render in your framework + +Each framework calls `getPost()` from the previous section on the server. It then renders the document with ``. + +### Nuxt + +Move `getPost()` to `server/utils/posts.ts`, so server routes can use it. With the `@comark/nuxt` module, `` is auto-imported: + +```typescript [server/api/posts/[slug].get.ts] +export default defineEventHandler(event => getPost(getRouterParam(event, 'slug')!)) +``` + +```vue [app/pages/blog/[slug].vue] + + + +``` + +### Next.js + +A React Server Component parses and renders the post. No client JavaScript ships for the content: + +```tsx [app/blog/[slug]/page.tsx] +import { MarkdownDocument } from '@comark/react' +import { getPost } from '@/lib/posts' +import Alert from '@/components/Alert' + +export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + return +} +``` + +### Astro + +Parse the body of a content collection entry, and render it with the React renderer. Astro renders it to HTML on the server: + +```astro [src/pages/posts/[...id].astro] +--- +import { parseMarkdown } from 'comark' +import { MarkdownDocument } from '@comark/react' +import Alert from '../../components/Alert' + +const document = await parseMarkdown(Astro.props.post.body!) +--- + + +``` + +### SvelteKit + +Parse in a server `load` function. The Svelte `` component doesn't render during SSR, so use ``: + +```svelte [src/routes/blog/[slug]/+page.svelte] + + + +``` + +Each framework has a complete example: [Nuxt](/examples/frameworks/nuxt), [Next.js](/examples/frameworks/nextjs), [Astro](/examples/frameworks/astro), and [SvelteKit](/examples/frameworks/sveltekit). For VitePress, the [VitePress example](/examples/frameworks/vitepress) adds Comark component syntax to its markdown-it setup. + +## Override Markdown elements + +Map a native tag in `components` to change how it renders, for example `pre` for code blocks with a copy button. In Nuxt, place components such as `ProseH2.vue` in `~/components/prose`. See [overriding HTML elements](/rendering/nuxt#overriding-html-elements) and [custom code blocks](/kb/custom-code-block). + +## FAQ + +::accordion + :::accordion-item{label="Do I need a content collection or a build plugin?"} + No. `parseMarkdown()` takes a string, so the Markdown can come from files, a collection, or an API. The Astro example uses a content collection to load the files. + ::: + + :::accordion-item{label="Where is the table of contents?"} + With the `toc` plugin, it is in `tree.meta.toc`. Render `tree.meta.toc.links` as a list of anchor links to the heading IDs. + ::: + + :::accordion-item{label="How does this compare to MDX?"} + MDX compiles content to JSX at build time. Comark parses at runtime or build time into a JSON document. See [Comark vs MDX](/compare/comark-vs-mdx). + ::: +:: + +## Next steps + +- [Frontmatter syntax](/syntax/frontmatter) +- [Component syntax](/syntax/components) +- [Shiki plugin options](/plugins/built-in/shiki) +- [Render Markdown from a CMS or database](/use-cases/cms-runtime-content) diff --git a/docs/content/10.use-cases/8.cms-runtime-content.md b/docs/content/10.use-cases/8.cms-runtime-content.md new file mode 100644 index 00000000..41cb0248 --- /dev/null +++ b/docs/content/10.use-cases/8.cms-runtime-content.md @@ -0,0 +1,137 @@ +--- +title: 'Render Markdown with Components from a CMS or Database' +description: 'Parse Markdown with components at runtime, store the JSON document, and render it in Vue, React, Svelte, Angular, or HTML with no build step.' +navigation: + title: CMS and runtime content + icon: i-lucide-database +links: + - label: Document model + icon: i-lucide-braces + to: /getting-started/document-model + color: neutral + variant: soft + - label: Render API + icon: i-lucide-file-code + to: /reference/render + color: neutral + variant: soft +--- + +Parse the Markdown on the server when an editor saves it, and store the resulting `MarkdownDocument` as JSON. Any client then renders that JSON with ``, with no parser in the browser and no build step. + +Content from a CMS or a database changes after you deploy. A compiler such as MDX turns content into code at build time, so new content needs a new build. Comark parses at runtime into plain data. See [Comark vs MDX](/compare/comark-vs-mdx) for a full comparison. + +## Parse and store the document + +A `MarkdownDocument` is a JSON-safe object: `{ nodes, frontmatter, meta }`. Parse once when the content changes, then store or cache the result. Sanitize before you store, so unsafe content never reaches the database: + +```typescript [server/save-page.ts] +import { createMarkdownParser } from 'comark' +import security from 'comark/plugins/security' + +const parse = createMarkdownParser({ + plugins: [security({ blockedTags: ['script', 'iframe', 'embed', 'form', 'base', 'meta', 'link', 'style', 'object'] })], +}) + +export async function savePage(slug: string, markdown: string) { + const document = await parse(markdown) + // `db` stands for your database client + await db.pages.upsert({ slug, markdown, document: JSON.stringify(document) }) +} +``` + +This example keeps the source Markdown for the editor and the document for rendering. Your API then returns the stored document. + +## Render the document on any client + +`` renders a parsed document without a parse step. Map component tags to your own components with the `components` prop: + +::code-group + +```vue [Vue] + + + +``` + +```tsx [React] +import { MarkdownDocument } from '@comark/react' +import Alert from './components/Alert' + +export default async function PricingPage() { + const document = await fetch('https://cms.example.com/api/pages/pricing').then(res => res.json()) + return +} +``` + +```typescript [HTML] +import { renderHtmlFromDocument } from '@comark/html' + +const document = await fetch('https://cms.example.com/api/pages/pricing').then(res => res.json()) +const html = await renderHtmlFromDocument(document) +``` + +:: + +Svelte and Angular use the same pattern with `` and ``. The document format doesn't depend on a framework, so one stored document serves every client. + +## Pass runtime data + +Content often refers to values that change per request, for example the user name or a price. Pass them in the `data` prop, and reference them with a `:` prop in the Markdown: + +```mdc +Welcome back, :badge{:label="data.user.name"}! +``` + +```vue + +``` + +To insert a value directly into text, add the [binding plugin](/plugins/built-in/binding). It adds the `{{ data.user.name || guest }}` syntax. See [data binding](/syntax/components#data-binding) for the full scope. + +## Turn the document back into Markdown + +`renderMarkdown()` converts a document into a Markdown string, with frontmatter and component syntax. Use it when you edit the document in code, or when you keep only the document: + +```typescript [server/edit-page.ts] +import { renderMarkdown } from 'comark/render' + +// `row` is the stored page from your database +const document = JSON.parse(row.document) +document.frontmatter.title = 'New pricing' + +const markdown = await renderMarkdown(document) +await savePage(row.slug, markdown) +``` + +The output can differ from the original formatting. For example, a component with more than three attributes moves them to a YAML block. See the [`maxInlineAttributes`](/reference/render#options-maxinlineattributes) option. + +## FAQ + +::accordion + :::accordion-item{label="Does the client need the parser?"} + No. `` only renders. The `` component imports the parser, so use it only when you parse in the browser. + ::: + + :::accordion-item{label="Can authors use components the app doesn't have?"} + An unregistered tag renders as a plain element with the same name. To limit the tags in stored content, use the `allowedTags` or `blockedTags` options of the [security plugin](/plugins/built-in/security). + ::: + + :::accordion-item{label="How do I update a live page without a reload?"} + The renderers can subscribe to a context and apply patches by key. See [live documents](/rendering/vue#live-documents). + ::: +:: + +## Next steps + +- [Document model](/getting-started/document-model) +- [Render API reference](/reference/render) +- [Migrate from MDX](/kb/migration-from-mdx) +- [Build a docs site or blog](/use-cases/docs-and-blogs) diff --git a/docs/content/10.use-cases/9.cli-and-agents.md b/docs/content/10.use-cases/9.cli-and-agents.md new file mode 100644 index 00000000..dfc21e3c --- /dev/null +++ b/docs/content/10.use-cases/9.cli-and-agents.md @@ -0,0 +1,134 @@ +--- +title: 'Render Markdown in the Terminal for Node CLIs and Coding Agents' +description: 'Print Markdown as styled ANSI output in Node.js CLIs and coding agents, with highlighted code, tables, and alerts, using @comark/ansi.' +navigation: + title: CLIs and agents + icon: i-lucide-terminal +links: + - label: ANSI rendering + icon: i-lucide-terminal + to: /rendering/ansi + color: neutral + variant: soft + - label: Shiki plugin + icon: i-lucide-code + to: /plugins/built-in/shiki + color: neutral + variant: soft +--- + +Use `@comark/ansi` to turn Markdown into a string with ANSI styles. `printAnsi()` writes it to the terminal, and `renderAnsi()` returns it so you can write it yourself. + +Coding agents and CLIs often print Markdown: release notes, help text, or model answers. Raw Markdown in a terminal shows `**` and `#` characters. `@comark/ansi` styles headings, emphasis, lists, links, tables, GitHub alerts, and code blocks. + +## Print a Markdown file + +Install the package, and `shiki` for code highlighting: + +```bash [Terminal] +npm install @comark/ansi shiki +``` + +This script reads a file and prints it with highlighted code and math: + +```typescript [render.ts] +import { readFile } from 'node:fs/promises' +import { printAnsi } from '@comark/ansi' +import shiki from '@comark/ansi/plugins/shiki' +import math, { Math } from '@comark/ansi/plugins/math' + +const md = await readFile('source.md', 'utf-8') + +await printAnsi(md, { + plugins: [shiki(), math()], + components: { Math }, +}) +``` + +Run it with `node render.ts`. The [ANSI example](https://github.com/comarkdown/comark/tree/main/examples/3.cli/ansi) contains a sample file with every supported element. + +## Control width, colors, and output + +`printAnsi()` writes to `stdout` by default. These options change the output: + +- **`width`** sets the width of horizontal rules and code block headers. The default is `80`. +- **`colors`** turns ANSI escape codes on or off. It is `false` when the `NO_COLOR` environment variable is set. +- **`writer`** receives the output string, for example to write to `stderr`. + +```typescript [cli.ts] +import { createAnsiPrinter } from '@comark/ansi' +import shiki from '@comark/ansi/plugins/shiki' + +const print = createAnsiPrinter({ + plugins: [shiki()], + width: process.stdout.columns ?? 80, + writer: output => process.stderr.write(output), +}) + +await print('> [!WARNING]\n> This command deletes the build cache.') +await print('| Step | Status |\n| --- | --- |\n| Build | Done |') +``` + +`createAnsiPrinter()` and `createAnsiRenderer()` set up the parser once. Use them when you print more than one document. + +## Render custom components + +Map a component tag to a function that returns a string. The function receives the node and a `render()` helper for its children: + +```typescript [cli.ts] +import { renderAnsi } from '@comark/ansi' + +const output = await renderAnsi('::badge{type="success"}\nBuild passed\n::', { + components: { + badge: async ([, attrs, ...children], { render }) => + `[${String(attrs.type).toUpperCase()}] ${await render(children)}`, + }, +}) +// [SUCCESS] Build passed +``` + +## Show streaming model output + +`@comark/ansi` renders complete strings, and it has no `streaming` option. Because `autoClose` closes unfinished syntax, you can render the partial text at any time. + +The following code is a pattern, not a Comark API. It re-renders the accumulated text after each chunk, and redraws the screen: + +```typescript [agent.ts] +import { streamText } from 'ai' +import { createAnsiRenderer } from '@comark/ansi' + +const render = createAnsiRenderer({ width: process.stdout.columns ?? 80 }) +const result = streamText({ model: 'anthropic/claude-sonnet-4.6', prompt: 'Explain git rebase.' }) + +let text = '' +for await (const chunk of result.textStream) { + text += chunk + // Clear the screen, move the cursor to the top, then print the new frame + process.stdout.write('\x1b[2J\x1b[H' + await render(text)) +} +``` + +A full redraw replaces the scrollback on each frame. For long answers, print the final output once when the stream ends. + +## FAQ + +::accordion + :::accordion-item{label="How do I turn off colors in CI?"} + Set the `NO_COLOR` environment variable, or pass `colors: false`. The output then contains no escape codes. + ::: + + :::accordion-item{label="Can I render a document that I already parsed?"} + Yes. Pass it to `renderAnsiFromDocument()`. It renders without a parse step. + ::: + + :::accordion-item{label="Can I change how a heading or link looks?"} + Yes. Pass a function for the native tag, for example `h1` or `a`, in `components`. See [overriding terminal output](/rendering/ansi#overriding-terminal-output). + ::: +:: + +## Next steps + +- [ANSI rendering reference](/rendering/ansi) +- [Shiki plugin](/plugins/built-in/shiki) +- [Render streaming Markdown from an LLM](/use-cases/ai-chat-streaming) +- [Markdown to HTML for emails and RSS](/use-cases/email-and-rss) diff --git a/docs/content/10.use-cases/index.md b/docs/content/10.use-cases/index.md new file mode 100644 index 00000000..5a259829 --- /dev/null +++ b/docs/content/10.use-cases/index.md @@ -0,0 +1,59 @@ +--- +title: Comark use cases +description: 'Guides for common Comark tasks: AI chat streaming, generative UI, docs sites and blogs, CMS content, terminal output, emails, and RSS feeds.' +navigation: false +--- + +Each guide answers one question with working code. Pick the task that matches your project. + +## AI and streaming + +::card-group{cols="2"} + :::card{icon="i-lucide-layout-dashboard" title="Intelligent UI" to="/use-cases/intelligent-ui"} + Answer with interactive components that render while the model streams, and help it pick the right one. + ::: + + :::card{icon="i-lucide-message-square-text" title="Render streaming Markdown from an LLM" to="/use-cases/ai-chat-streaming"} + Render model output while it streams, with no raw syntax or layout jumps. + ::: + + :::card{icon="i-simple-icons-nuxt" title="Vue and Nuxt chat with the AI SDK" to="/use-cases/vue-ai-chat"} + Stream AI SDK messages into Comark's Vue `` component. + ::: + + :::card{icon="i-simple-icons-svelte" title="Svelte chat with the AI SDK" to="/use-cases/svelte-ai-chat"} + Stream AI SDK messages in Svelte 5 and SvelteKit. + ::: + + :::card{icon="i-simple-icons-angular" title="Angular chat with the AI SDK" to="/use-cases/angular-ai-chat"} + Stream AI SDK messages in Angular 17 or later. + ::: + + :::card{icon="i-lucide-sparkles" title="Generative UI" to="/use-cases/generative-ui"} + Let a model render your UI components inside Markdown, with no code execution. + ::: +:: + +## Content + +::card-group{cols="2"} + :::card{icon="i-lucide-book-open" title="Docs sites and blogs" to="/use-cases/docs-and-blogs"} + Parse Markdown files with frontmatter, a table of contents, and highlighted code. + ::: + + :::card{icon="i-lucide-database" title="CMS and runtime content" to="/use-cases/cms-runtime-content"} + Store parsed documents as JSON and render them on any client. + ::: +:: + +## Other outputs + +::card-group{cols="2"} + :::card{icon="i-lucide-terminal" title="CLIs and coding agents" to="/use-cases/cli-and-agents"} + Print styled Markdown in the terminal with `@comark/ansi`. + ::: + + :::card{icon="i-lucide-mail" title="Emails and RSS feeds" to="/use-cases/email-and-rss"} + Render Markdown to HTML strings with `@comark/html`. + ::: +:: diff --git a/docs/content/6.compare/.navigation.yml b/docs/content/11.compare/.navigation.yml similarity index 100% rename from docs/content/6.compare/.navigation.yml rename to docs/content/11.compare/.navigation.yml diff --git a/docs/content/11.compare/0.markdown-libraries-for-ai-chat.md b/docs/content/11.compare/0.markdown-libraries-for-ai-chat.md new file mode 100644 index 00000000..8d09a038 --- /dev/null +++ b/docs/content/11.compare/0.markdown-libraries-for-ai-chat.md @@ -0,0 +1,140 @@ +--- +title: Choosing a Markdown Library for AI Chat in 2026 +description: Compare Streamdown, Markstream, Comark, @humanspeak/svelte-markdown, markdown-to-jsx, and plain parsers for rendering streamed LLM output. See how each handles incomplete Markdown, components, security, and frameworks. +navigation: + title: 'Markdown for AI chat' +seo: + title: 'Choosing a Markdown Library for AI Chat in 2026: Streaming Renderers Compared' +links: + - label: AI chat streaming + icon: i-lucide-message-square + to: /use-cases/ai-chat-streaming + color: neutral + variant: soft + - label: Streaming playground + icon: i-lucide-radio + to: /stream + color: neutral + variant: soft +--- + +**TL;DR**: For a React-only chat where the model writes standard Markdown, start with Streamdown. If the model must place components, or you ship Vue, Svelte, Angular, or a terminal UI, use Comark. For Vue 2 or very long documents that need virtualization, look at Markstream. For a Svelte-only app built on marked extensions, use @humanspeak/svelte-markdown. + +This guide is part of Comark's documentation. Each claim about another library links to that library's repository or documentation, so you can check it. + +## What an AI chat renderer needs + +A model sends its answer a few characters at a time. A renderer built for complete documents re-parses the whole text on each update. Between updates, it shows broken output. Five questions separate the options: + +- **Incomplete syntax**: what does the reader see while `**bold`, a code fence, a link, or a table is still open? The renderer can complete the syntax, hide it, or show it raw. +- **Re-render cost**: does each new token re-parse and re-render the whole message, or only the last block? This matters for long answers. +- **Components in the output**: can the model place a chart, a product card, or a tool result? Can that component take props and nested content? +- **Security of model output**: model output is untrusted input. Check what happens to raw ` + +Click Me'} {renderers} /> +``` + +Comark uses a [component syntax](/syntax/components) with typed props and named slots. Each `#slot` becomes a Svelte 5 snippet prop: + +```mdc [content.md] +::card{title="Order summary" :items="3"} +Your order ships tomorrow. + +#footer +Questions? Reply to this message. +:: +``` + +```svelte [Card.svelte] + + +
+

{title} ({items} items)

+ {@render children?.()} +
{@render footer?.()}
+
+``` + +Register it with ``. For lazy loading, pass a `componentsManifest` function instead. See [component bindings](/rendering/svelte#component-bindings). + +## Streaming + +`@humanspeak/svelte-markdown` has a detailed streaming engine. You bind the component and call `writeChunk()`, or append to `source`. It re-lexes only the open block at the end of the text and coalesces updates once per animation frame. Offset chunks can arrive out of order, and `streamId` resets the buffer between messages. Its documentation doesn't describe completing unterminated inline syntax; half-open tags wait in a buffer. Streaming turns off when you use an async extension such as Mermaid. + +Comark completes the syntax with [`autoClose`](/reference/auto-close). `**bold` renders as bold, and an open `::card` renders as a card while its content streams. You pass the growing text to `value`: + +```svelte [Message.svelte] + + + +``` + +While `streaming` is `true`, `` reuses completed blocks. Set it to `false` when the stream ends to run a final full parse. With the AI SDK, use `part.state === 'streaming'` for the prop. See [streaming in Svelte](/rendering/svelte#streaming). + +## SvelteKit SSR + +Comark's `` uses `$effect`, so it doesn't render during SSR. For SvelteKit pages, parse in `load()` and render the JSON-safe document with ``: + +```svelte [src/routes/docs/[slug]/+page.svelte] + + + +``` + +`` ships no parser to the browser. To render lazy-loaded components in server HTML, use `` from `@comark/svelte/async`. It requires Svelte's `experimental.async` option. + +## What @humanspeak/svelte-markdown does well + +- **Svelte 5-native API**: snippet overrides let you change a renderer inline, without a separate component file. +- **Streaming engineering**: open-block re-lexing, per-frame coalescing, and out-of-order offset chunks are well documented. +- **Secure defaults**: a URL allowlist, `on*` and `srcdoc` stripping, and escaped `script` and `style` work without setup. Its documentation lists gaps, for example `iframe`, `form`, and `embed` still render. Comark keeps raw ` + + +``` + +Because the output is an HTML string, Vue can't mount components inside it. It inherits markdown-it's safe defaults: raw HTML is off, and `validateLink` rejects `javascript:` and similar URLs. If you set `html: true`, markdown-it recommends an external sanitizer. + +**@crazydos/vue-markdown** renders VNodes from a unified pipeline. You replace an element with a scoped slot, and you render its `children` with ``: + +```vue [@crazydos/vue-markdown] + +``` + +**Comark** parses to a document and renders each node as a VNode. The `components` prop maps a tag to a Vue component: + +```vue [Comark] + + + +``` + +Comark looks up `ProseH2`, then `H2`, then `h2`, then globally registered components. In Nuxt, the `@comark/nuxt` module registers the components in `~/components/prose` for you. + +## Components in the content + +`@crazydos/vue-markdown` supports a `` tag in the Markdown, filled from the parent with `