# Markdown

## 使用方法

`Markdown` 把 Markdown 源码渲染成正文。标题、段落、强调、链接、行内代码、代码块、引用、列表、任务列表、表格、分隔线和图片都可以用。表格、删除线和任务列表按 GFM 处理。尖括号里的网址和邮箱一直是链接。裸网址、`www.` 开头的主机名和裸邮箱，要等 `autolink` 设为 `gfm` 才变成链接。句末的标点留在链接外面。

从独立入口导入，结构样式会随组件加载。主题在应用入口引入一次：

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

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

<template>
  <Markdown content="## 标题\n\n一段 **说明**。" />
</template>
```

`content` 没传时，默认插槽里的文本会当作源码。插槽只读取文本，不接受已经渲染好的节点。

链接会去掉控制字符。`javascript:`、`vbscript:` 和 `data:text/html` 不会变成可点击的地址，只留下链接文字。图片另外拒绝所有 `data:` 地址。原始 HTML 不会执行，标签会按普通文字显示。

代码块交给已有的 `CodeBlock`，引用交给 `Blockquote`，标题交给 `Heading`，链接交给 `Link`。列表和表格用语义元素，因为现有的 `List` 和表格组件是界面控件，不是正文排版。

`components` 可以换掉某一类节点的渲染。自定义组件通过 props 拿到节点数据，有内容的节点还会收到默认插槽。

`components` 里放的是组件定义，不需要是响应式的。直接写成普通对象就行。如果放进了 `ref()` 或 `reactive()`，Vue 会把组件定义也变成响应式对象，开发模式下控制台会出现 `Vue received a Component that was made a reactive object` 的警告。渲染结果不受影响，只是多了不必要的开销。要避免它，用下面任意一种：

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

// 普通对象，不包响应式
const components = { paragraph: MyParagraph }

// 需要整体替换时，用 shallowRef（或 shallowReactive）
const swappable = shallowRef({ paragraph: MyParagraph })

// 或者给每个组件套 markRaw，外层再用 ref 也不会警告
const marked = ref({ paragraph: markRaw(MyParagraph) })
```

`plugins` 挂在解析之后的语法树上。`transform` 拿到根节点，返回下一棵树；没有改动时把原来的根返回去，流式渲染才能继续复用已经稳定的块。`render` 画出 `type` 为 `extension`、并且 `plugin` 等于这个名字的节点。`inlinePlugins` 只在普通文本里做匹配，`pattern` 要带 `g`。列表里靠前的先匹配，同一位置重叠时前面的赢。行内代码和围栏代码不会被碰到。`replace` 返回替换进去的节点，返回 `null` 就跳过这次匹配。扩展节点如果带了 `name`，就由同名插件的 `render` 来画；返回一个普通的 `link` 节点时，走原来的链接渲染。

`defineMarkdownPlugin` 把插件对象原样返回，用来收窄类型。`isMarkdownExtensionNode` 用来确认一个节点是不是某个插件的扩展节点。

传入 `sources` 后，正文里的 `[id]` 和 `【id】` 会画成引用芯片。`citationStyle` 默认 `label`，芯片上是来源标题。设成 `number` 后变成带序号的小徽章，同一个 id 共用一个序号。

标题会生成可聚焦的锚点，`href` 指向标题 id，并带有 `aria-label`。嵌套列表的记号按层循环：无序是圆点、圆圈、方块，有序是数字、小写字母、小写罗马数字。

正文里的字符实体会解码，`&amp;` 显示成 `&`，`&copy;` 显示成 ©。围栏代码里的 `&copy;` 保持原文。

围栏代码默认折行。一行超过容器宽度时，代码元素带 `data-wrapped="true"`。

围栏信息是 `diff` 或 `diff-ts` 这种时，默认的代码块会打开差异层。换掉 `components.code` 时，语言字符串仍原样传进去。

`markdownSourceLinesPlugin` 从 `@astryx-vue/core/Markdown/plugins` 引入，放进 `plugins` 后，标题、段落、列表项、引用、代码块外层、表格外层和分隔线会带上 `data-source-line` 与 `data-source-line-end`。不传这个插件时没有这些属性。

`@astryx-vue/core/Markdown/parser` 提供 `parseMarkdownAst`、`parseInlineAst`、`decodeMarkdownCharacterReferences`，以及旧的块列表 `parseMarkdown` 和行内 `parseInline`。组件自己用的 `parseMarkdown` 仍返回语法树根，不返回那份块列表。`math: true` 不会产生数学节点。`parseInlineAst` 和 `parseInline` 只读行内内容：`# h` 仍是文字，围栏读成一个行内代码。

`isStreaming` 打开流式模式。内容只在末尾追加时，已经闭合的块保持原来的节点，只重新解析最后一个还没写完的块。新出现的文字用 `--astryx-duration-fast-max` 淡入。系统开启「减少动态效果」时不播放淡入。

`isStreaming` 不做逐字平滑。字符级揭示用 `useStreamingText`，再把返回的字符串交给 `content`。组件不会把这段字符串再平滑一次。模拟模型输出时，把累积的原文交给 `content`，并在输出结束前保持 `isStreaming` 即可。

这一版不渲染数学公式。

### 结构

- **文档**: 块级或行内 Markdown 内容的根容器。
- **标题**: 渲染出的语义标题。自定义标题渲染会换掉内置的这一部分。
- **段落**: 渲染出的段落。自定义段落渲染会换掉默认的这一部分。
- **列表**: 由 Markdown 项渲染出的有序、无序或任务列表。
- **代码块**: 围栏代码块。自定义代码渲染会换掉默认的这一部分。
- **引用**: 引用块。自定义引用渲染会换掉默认的这一部分。
- **表格**: 由 Markdown 的行和列渲染出的表格。里面的 Table 负责横向滚动。
- **分隔线**: 水平线。自定义 hr 渲染会换掉默认的这一部分。
- **图片**: 块级图片；地址不安全时改为回退。自定义图片渲染会换掉安全情况下的默认图片。

## 最佳实践

| 指引 | 实践 |
| --- | --- |
| 推荐 | 用它排模型回复、文档摘录和说明文字。 |
| 推荐 | 流式输出时只追加文本，结束时把 `isStreaming` 设回 `false`。 |
| 推荐 | 需要逐字揭示时，只调用一次 `useStreamingText`，把结果交给 `content`。`isStreaming` 负责分块解析和淡入，不会再平滑。 |
| 推荐 | 需要限制行宽时设置 `contentWidth`。不设置时正文跟容器一样宽。 |
| 避免 | 把用户提供的 HTML 塞进源码，指望它被执行。组件会把它当成文字。 |
| 避免 | 在 `isStreaming` 为 `true` 时整段替换已经显示的内容。缓存按「只追加」设计。 |

## 示例

示例和 [Astryx Markdown](https://astryx.atmeta.com/components/Markdown) 的 Examples 一一对应，顺序相同。总览卡片用英文短文，不带按钮或状态。

### Markdown — Cited Content

紧凑密度，标题从 h3 起。正文里的 [id] 变成引用来源芯片。

```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 — 序号引用

同一批来源改用序号徽章。同一个 id 再次出现时，序号不变。

```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 — 插件

块级插件把 Note 段画成提示，行内插件把 @名字 变成链接，autolink 打开裸网址。

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

紧凑密度，标题从 h3 起。包含代码块、列表和引用。

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

列对齐按 GFM 标记。表头是次要色，只有行分隔线。

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

标题、强调、列表、链接、代码块和任务列表。

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

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `content` | `string` | — | Markdown 源码。省略时使用默认插槽里的文本。 |
| `contentAlign` | `'start' \| 'center'` | `'start'` | 设置了 `contentWidth` 时，正文行宽的水平对齐。 |
| `contentWidth` | `number \| string` | — | 正文最大宽度。数字按像素。表格和代码块仍占满容器。 |
| `components` | `MarkdownComponents` | — | 替换某一类节点的渲染。可选键：`blockquote`、`code`、`heading`、`hr`、`image`、`inlineCode`、`link`、`list`、`paragraph`、`table`。 |
| `density` | `'default' \| 'compact'` | `'default'` | `compact` 收紧块与块之间的间距。 |
| `display` | `'block' \| 'inline'` | `'block'` | `inline` 把源码当成短语，渲染在 `span` 里。 |
| `headingLevelStart` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `1` | Markdown 里的 `#` 对应的标题级别。更深的标题一起后移，到 `h6` 为止。 |
| `isStreaming` | `boolean` | `false` | 只重新解析最后一个未完成的块，并为新文字播放淡入。不做逐字平滑。逐字揭示请先用 `useStreamingText`，再把结果传给 `content`。 |
| `onLinkClick` | `(href: string, event: MouseEvent) => void \| false` | — | 链接被点击时调用。返回 `false` 会取消跳转。 |
| `plugins` | `MarkdownPlugin[]` | — | 文档插件。可以替换语法树，并绘制自己的 `extension` 节点。 |
| `inlinePlugins` | `MarkdownInlinePlugin[]` | — | 在普通文本上做匹配。不进入行内代码和围栏代码。 |
| `autolink` | `'gfm'` | — | 设为 `gfm` 时，裸网址、`www.` 和裸邮箱变成链接。 |
| `citationStyle` | `'label' \| 'number'` | `'label'` | 引用来源芯片是标题，还是序号徽章。 |
| `sources` | `Record<string, MarkdownSource>` | — | 按 id 提供的引用来源。`[id]` 和 `【id】` 对上键时画成芯片。 |

## 插槽

| 名称 | 说明 |
| --- | --- |
| `default` | 没传 `content` 时，当作 Markdown 源码的文本。 |

## 暴露

| 名称 | 类型 | 说明 |
| --- | --- | --- |
| `element` | `HTMLElement \| null` | 根元素。 |

## 组合式函数

`useStreamingText(targetText, isStreaming?, options?)` 把一段不断变长的文本平滑成稳定的揭示。`options.speed` 可以是 `natural`、`fast` 或 `instant`。未在流式输出，或用户偏好减少动态效果时，直接返回全文。

字符级平滑只放在这一层。把返回值交给 `Markdown` 的 `content`，并用 `isStreaming` 打开分块解析和淡入。不要在 `Markdown` 外面再套一次 `useStreamingText`。

## 原生属性

块级渲染的根元素是 `<div role="document">`，行内渲染是 `<span>`。

以下内容传入根元素：

- HTML 属性。
- `aria-*` 属性。
- `data-*` 属性。
- `class`。
- `style`。
- 原生事件监听器。

传入 `role` 时使用传入的值。
