# Markdown

## Usage

`Markdown` renders a Markdown source as prose. It covers headings, paragraphs, emphasis, links, inline code, code blocks, quotes, lists, task lists, tables, thematic breaks, and images. Tables, strikethrough, and task lists follow GFM. Angle-bracket URLs and emails are always links. A bare URL, a `www.` host, or a bare email becomes a link only when `autolink` is `gfm`. Trailing sentence punctuation stays outside the link.

Import it from its own entry. Structural styles load with the component. Import the theme once in the application entry:

```ts
import '@astryx-vue/themes/neutral.css'
```

```vue
<script setup lang="ts">
import { Markdown } from '@astryx-vue/core/Markdown'
</script>

<template>
  <Markdown content="## Title\n\nA **note**." />
</template>
```

When `content` is omitted, text in the default slot is the source. The slot reads text only. It does not accept nodes that are already rendered.

Links have control characters removed. `javascript:`, `vbscript:`, and `data:text/html` do not become clickable targets; the link text stays. Images also reject every `data:` URL. Raw HTML is never executed. Tags are shown as text.

Code blocks go through the existing `CodeBlock`, quotes through `Blockquote`, headings through `Heading`, and links through `Link`. Lists and tables use semantic elements, because the existing `List` component and the table export are interface controls rather than prose.

`components` replaces the renderer for one kind of node. A custom component receives the node data as props and, when the node has content, that content as the default slot.

The values in `components` are component definitions and do not need to be reactive. A plain object works. If you put them inside `ref()` or `reactive()`, Vue makes the component definitions reactive too, and in development it logs `Vue received a Component that was made a reactive object`. Rendering is not affected; the cost is some unnecessary overhead. To avoid the warning, use any one of these:

```ts
import { markRaw, ref, shallowRef } from 'vue'

// A plain object, not wrapped in reactivity
const components = { paragraph: MyParagraph }

// Use shallowRef (or shallowReactive) when you need to swap the whole object
const swappable = shallowRef({ paragraph: MyParagraph })

// Or wrap each component with markRaw; an outer ref then does not warn
const marked = ref({ paragraph: markRaw(MyParagraph) })
```

`plugins` run on the parsed tree. `transform` receives the root and returns the next one. Return the same root when nothing changed, so a streaming render can keep settled blocks. `render` draws an `extension` node whose `plugin` equals that name. `inlinePlugins` match ordinary text nodes. `pattern` needs the `g` flag. Earlier plugins match first, and the first plugin wins when two matches overlap. Inline code and fenced code are left alone. `replace` returns the node that takes the match, or `null` to skip it. An extension node is drawn by the plugin `render` of the same `name`. A `link` node uses the normal link renderer.

`defineMarkdownPlugin` returns the plugin object unchanged, which narrows the type. `isMarkdownExtensionNode` checks that a node belongs to a plugin.

With `sources`, `[id]` and `【id】` render as citation chips. `citationStyle` defaults to `label`, which shows the source title. `number` shows a compact badge, and the same id keeps the same number.

A heading gets a focusable anchor whose `href` is the heading id, with an `aria-label`. Nested list markers cycle by depth: disc, circle, and square for bullets, and decimal, lower-alpha, and lower-roman for numbers.

Character references in prose are decoded, so `&amp;` shows as `&` and `&copy;` shows as ©. A fenced `&copy;` stays as written.

Fenced code wraps long lines. The code element carries `data-wrapped="true"`.

A fence info string of `diff` or `diff-ts` turns the diff layer on for the built-in code block. A custom `components.code` still receives the info string as written.

Import `markdownSourceLinesPlugin` from `@astryx-vue/core/Markdown/plugins` and pass it in `plugins`. Headings, paragraphs, list items, quotes, the code wrapper, the table wrapper, and rules then get `data-source-line` and `data-source-line-end`. Without that plugin, those attributes are absent.

`@astryx-vue/core/Markdown/parser` exports `parseMarkdownAst`, `parseInlineAst`, `decodeMarkdownCharacterReferences`, plus the legacy block list `parseMarkdown` and inline `parseInline`. The `parseMarkdown` used by the component still returns the syntax-tree root, not that block list. `math: true` does not emit math nodes. `parseInlineAst` and `parseInline` read inline content only: `# h` stays text, and a fence reads as one code span.

`isStreaming` turns on streaming. While content is only appended, a block that has already closed keeps its rendered node, and only the last unfinished block is parsed again. New text fades in with `--astryx-duration-fast-max`. When the system prefers reduced motion, the fade is skipped.

`isStreaming` does not reveal the string character by character. Use `useStreamingText` for that, then pass its string as `content`. Markdown does not smooth that string again. To simulate a model, pass the accumulated source as `content` and leave `isStreaming` on until the reply is finished.

This version does not render math.

### Anatomy

- **Document**: Root container for block or inline Markdown content.
- **Heading**: Rendered semantic heading block; a custom heading renderer replaces the built-in part.
- **Paragraph**: Rendered paragraph block; a custom paragraph renderer replaces the default part.
- **List**: Ordered, unordered, or task-list block rendered from Markdown items.
- **Code block**: Fenced code block; a custom code renderer replaces the default part.
- **Blockquote**: Quoted block; a custom blockquote renderer replaces the default part.
- **Table**: Table block rendered from Markdown rows and columns; its Table child owns horizontal scrolling.
- **Divider**: Horizontal rule block; a custom hr renderer replaces the default part.
- **Image**: Block image or unsafe-URL fallback; a custom image renderer replaces a safe default image.

## Best practices

| Guidance | Practices |
| --- | --- |
| Do | Use it for model replies, documentation excerpts, and explanatory prose. |
| Do | While streaming, only append text, and set `isStreaming` back to `false` when the reply is done. |
| Do | For a character reveal, call `useStreamingText` once and pass the result as `content`. `isStreaming` handles block parsing and the fade, and does not smooth again. |
| Do | Set `contentWidth` when the line length should be capped. Without it, prose uses the container width. |
| Don't | Put user-supplied HTML in the source and expect it to run. The component shows it as text. |
| Don't | Replace the whole source while `isStreaming` is `true`. The cache assumes the text only grows at the end. |

## Examples

The examples match [Astryx Markdown](https://astryx.atmeta.com/components/Markdown), in the same order. The catalog card is a short English sample, with no button and no status line.

### Markdown — Cited Content

Compact density, headings starting at h3. An [id] in the prose becomes a source chip.

```vue
<script setup lang="ts">
import { Markdown } from '@astryx-vue/core/Markdown'

const sources = {
  abc1: {
    title: 'Tokyo - Wikipedia',
    url: 'https://en.wikipedia.org/wiki/Tokyo',
    icon: 'https://en.wikipedia.org/favicon.ico',
  },
  def2: {
    title: 'Japan Statistics Bureau - Population',
    url: 'https://www.stat.go.jp/english/',
  },
  ghi3: {
    title: 'World Population Review',
    url: 'https://worldpopulationreview.com/world-cities/tokyo-population',
  },
  jkl4: {
    title: 'Reuters — Tokyo GDP',
    url: 'https://www.reuters.com/markets/',
    icon: 'https://www.reuters.com/favicon.ico',
  },
  mno5: {
    title: 'UN Urbanization Prospects',
    url: 'https://population.un.org/wup/',
  },
}

const content = [
  '## Tokyo Overview',
  '',
  'Tokyo is the capital of Japan with a population of over 14 million[abc1].',
  'It\'s the most populous metropolitan area in the world[def2][ghi3].',
  '',
  '### Key Facts',
  '',
  '- Population: 13.96 million (city proper)[abc1]',
  '- Metro area: 37.4 million[def2]',
  '- GDP: $1.93 trillion[jkl4]',
].join('\n')
</script>

<template>
  <Markdown :content="content" density="compact" :heading-level-start="3" :sources="sources" />
</template>
```

### Markdown — Numbered citations

The same sources as numbered badges. A repeated id keeps its number.

```vue
<script setup lang="ts">
import { Markdown } from '@astryx-vue/core/Markdown'

const sources = {
  abc1: {
    title: 'Tokyo - Wikipedia',
    url: 'https://en.wikipedia.org/wiki/Tokyo',
  },
  def2: {
    title: 'Japan Statistics Bureau - Population',
    url: 'https://www.stat.go.jp/english/',
  },
}

const content = [
  'Tokyo is the capital of Japan[abc1].',
  'The metro area is larger still[def2][abc1].',
].join('\n\n')
</script>

<template>
  <Markdown
    :content="content"
    citation-style="number"
    density="compact"
    :sources="sources"
  />
</template>
```

### Markdown — Plugins

A block plugin turns a Note paragraph into a callout, an inline plugin turns @name into a link, and autolink links the bare URL.

```vue
<script setup lang="ts">
import type { MarkdownInlinePlugin, MarkdownPlugin } from '@astryx-vue/core/Markdown'
import { Markdown } from '@astryx-vue/core/Markdown'
import { defineComponent, h } from 'vue'

const NoteCallout = defineComponent({
  name: 'NoteCallout',
  props: {
    value: { type: String, default: '' },
  },
  setup(props) {
    return () => h('p', { class: 'markdown-plugin-note' }, props.value)
  },
})

const plugins: MarkdownPlugin[] = [{
  name: 'note',
  transform(root) {
    const children = (root.children ?? []).map((node) => {
      const text = node.children?.[0]
      if (node.type !== 'paragraph' || text?.type !== 'text' || !text.value?.startsWith('Note: '))
        return node
      return {
        type: 'extension',
        plugin: 'note',
        name: 'callout',
        display: 'block' as const,
        value: text.value.slice('Note: '.length),
      }
    })
    return { ...root, children }
  },
  render(node) {
    return h(NoteCallout, { value: node.value ?? '' })
  },
}]

const inlinePlugins: MarkdownInlinePlugin[] = [{
  name: 'mention',
  pattern: /@([A-Z]+)/gi,
  replace(match) {
    const name = match[1] ?? ''
    return {
      type: 'link',
      url: `https://example.com/people/${name}`,
      children: [{ type: 'text', value: `@${name}` }],
    }
  },
}]

const content = [
  'Note: a bare URL keeps its trailing period outside the link.',
  '',
  'Ask @Ada, then read https://example.com/notes.',
].join('\n')
</script>

<template>
  <Markdown
    :content="content"
    autolink="gfm"
    :inline-plugins="inlinePlugins"
    :plugins="plugins"
  />
</template>

<style scoped>
.markdown-plugin-note {
  margin: 0;
  padding-inline-start: 12px;
  border-inline-start: 2px solid var(--astryx-color-border, #ebebeb);
  color: var(--astryx-color-text-secondary, #525252);
}
</style>
```

### Markdown — Compact AI Response

Compact density, headings starting at h3. Includes a code block, a list, and a quote.

````vue
<script setup lang="ts">
import { Markdown } from '@astryx-vue/core/Markdown'

const content = [
  '## Design Tokens',
  '',
  'Tokens are the **shared language** between design and code.',
  '',
  '```typescript',
  'const tokens = {',
  '  primary: \'#0066FF\',',
  '  spacing: \'8px\',',
  '};',
  '```',
  '',
  '- **Composable** — small pieces',
  '- **Accessible** — built-in a11y',
  '',
  '> Opinionated enough for consistency, *flexible* enough for edge cases.',
].join('\n')
</script>

<template>
  <Markdown :content="content" density="compact" :heading-level-start="3" />
</template>
````

### Markdown — Data Table

Column alignment follows the GFM markers. The header is secondary text, with row rules only.

```vue
<script setup lang="ts">
import { Markdown } from '@astryx-vue/core/Markdown'

const content = [
  '## Comparison Table',
  '',
  '| Feature | React | Vue | Svelte |',
  '|:--------|:-----:|:---:|-------:|',
  '| Virtual DOM | Yes | Yes | No |',
  '| Bundle Size | ~40KB | ~30KB | ~2KB |',
  '| TypeScript | Native | Plugin | Native |',
  '| Learning Curve | Medium | Easy | Easy |',
].join('\n')
</script>

<template>
  <Markdown :content="content" />
</template>
```

### Markdown — Rich Content

Headings, emphasis, a list, a link, a code block, and a task list.

````vue
<script setup lang="ts">
import { Markdown } from '@astryx-vue/core/Markdown'

const content = [
  '# Markdown Demo',
  '',
  'Renders **markdown** with *Astryx* styling.',
  '',
  '## Features',
  '',
  'Fish &amp; chips &copy; 2026',
  '',
  '- **Bold**, *italic*, `code`',
  '  1. Nested number',
  '     - Nested square',
  '- [Links](https://example.com)',
  '',
  '```typescript',
  'interface User {name: string;}',
  '```',
  '',
  '- [x] Parser',
  '- [ ] Stories',
].join('\n')
</script>

<template>
  <Markdown :content="content" />
</template>
````

## Props

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `content` | `string` | — | Markdown source. When omitted, text in the default slot is used. |
| `contentAlign` | `'start' \| 'center'` | `'start'` | Horizontal alignment of the prose measure when `contentWidth` is set. |
| `contentWidth` | `number \| string` | — | Maximum width of prose. A number is pixels. Tables and code blocks still fill the container. |
| `components` | `MarkdownComponents` | — | Replaces the renderer for a node kind. Keys: `blockquote`, `code`, `heading`, `hr`, `image`, `inlineCode`, `link`, `list`, `paragraph`, `table`. |
| `density` | `'default' \| 'compact'` | `'default'` | `compact` tightens the space between blocks. |
| `display` | `'block' \| 'inline'` | `'block'` | `inline` renders the source as phrasing content inside a `span`. |
| `headingLevelStart` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `1` | The heading level a markdown `#` maps to. Deeper headings shift with it and stop at `h6`. |
| `isStreaming` | `boolean` | `false` | Re-parses only the last unfinished block and fades in new text. It does not reveal characters. For that, pass the result of `useStreamingText` as `content`. |
| `onLinkClick` | `(href: string, event: MouseEvent) => void \| false` | — | Called when a link is activated. Return `false` to cancel navigation. |
| `plugins` | `MarkdownPlugin[]` | — | Document plugins. They can replace the tree and draw their own `extension` nodes. |
| `inlinePlugins` | `MarkdownInlinePlugin[]` | — | Match ordinary text. Inline code and fenced code are skipped. |
| `autolink` | `'gfm'` | — | When set to `gfm`, bare URLs, `www.` hosts, and bare emails become links. |
| `citationStyle` | `'label' \| 'number'` | `'label'` | Whether a citation chip shows the source title or a numbered badge. |
| `sources` | `Record<string, MarkdownSource>` | — | Citation sources by id. A matching `[id]` or `【id】` renders as a chip. |

## Slots

| Name | Description |
| --- | --- |
| `default` | Text used as the Markdown source when `content` is omitted. |

## Expose

| Name | Type | Description |
| --- | --- | --- |
| `element` | `HTMLElement \| null` | The root element. |

## Composable

`useStreamingText(targetText, isStreaming?, options?)` turns a growing string into a steady reveal. `options.speed` is `natural`, `fast`, or `instant`. When streaming is off, or the user prefers reduced motion, the full source is returned.

Character smoothing stays in this composable. Pass its result to `Markdown` as `content`, and set `isStreaming` for block parsing and the fade. Do not wrap that result in `useStreamingText` again.

## Native attributes

A block root is a `<div role="document">`. An inline root is a `<span>`.

The following are passed to the root:

- HTML attributes.
- `aria-*` attributes.
- `data-*` attributes.
- `class`.
- `style`.
- Native event listeners.

A provided `role` is used as given.
