# Playground

编辑源码、切换样例，观察同一份 Markdown 的完整加载与流式更新。

选择基础语法、后置引用、复杂 HTML 或自定义节点，直接编辑源码。流式回放从当前草稿开始；
停止保留前缀，继续从该位置追加。一次性加载与清空预览都保留草稿。
HTML 默认显示源码；复杂 HTML 样例显式选择允许的内联样式。

### playground/workspace.vue

```vue
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { QuireMarkdown } from "@quirekit/incremark/vue";
import { QuireButton, QuireCheckbox, QuireField, QuireSelect, QuireTextarea, QuireTypography } from "@quirekit/ui/vue";
import type { ExampleLocale } from "../shared/types";
import { statusOptions } from "../status/reading";
import ReadingLink from "../reading/link.vue";
import { usePlayback } from "./playback";
import { sampleItems, sampleSource } from "./samples";
import { playgroundCopy } from "./copy";

const props = withDefaults(defineProps<{ /** Tutorial language, separate from the Markdown. */ locale?: ExampleLocale }>(), { locale: "zh-CN" });
const text = computed(() => playgroundCopy(props.locale));
const sample = ref("basics"), speed = ref("normal"), policy = ref("source"), cache = ref(true);
const source = ref(sampleSource(sample.value, props.locale));
const playback = usePlayback(source, speed);
const { content, status, identity } = playback;
const failures = ref(0);
const components = { a: ReadingLink }, styled = { styles: "safe" } as const;
const html = computed(() => policy.value === "styles" ? styled : policy.value === "html");
const samples = computed(() => sampleItems(props.locale));
const speeds = computed(() => ["character", "normal", "fast"].map((value, index) => ({ value, label: text.value.speeds[index]! })));
const policies = computed(() => ["source", "html", "styles"].map((value, index) => ({ value, label: text.value.policies[index]! })));
let mounted = false, restoring = false;
/** Persist only the editable draft, never a truncated response or parser state. */
function save(): void {
  if (!mounted || restoring) return;
  try { sessionStorage.setItem(`markdown-playground:${props.locale}`, JSON.stringify({ version: 1, source: source.value, sample: sample.value, policy: policy.value, speed: speed.value, cache: cache.value })); } catch { /* Restricted storage still permits an in-memory demo. */ }
}
/** Restore a tab-local draft without leaking state across SSR requests. */
function restore(): void {
  restoring = true;
  sample.value = "basics"; policy.value = "source"; speed.value = "normal"; cache.value = true;
  source.value = sampleSource(sample.value, props.locale);
  try {
    const raw = sessionStorage.getItem(`markdown-playground:${props.locale}`);
    if (raw !== null) {
      // Migrate plain source saved by earlier previews without discarding a user's draft.
      let draft: unknown;
      try { draft = JSON.parse(raw); } catch { draft = raw; }
      if (typeof draft === "string") source.value = draft;
      else if (draft && typeof draft === "object" && "source" in draft && typeof draft.source === "string") {
        source.value = draft.source;
        if ("sample" in draft && typeof draft.sample === "string" && samples.value.some(item => item.value === draft.sample)) sample.value = draft.sample;
        if ("policy" in draft && ["source", "html", "styles"].includes(String(draft.policy))) policy.value = String(draft.policy);
        if ("speed" in draft && ["character", "normal", "fast"].includes(String(draft.speed))) speed.value = String(draft.speed);
        if ("cache" in draft && typeof draft.cache === "boolean") cache.value = draft.cache;
      }
    }
  } catch { /* Storage is optional and a malformed draft cannot prevent opening the demo. */ }
  restoring = false; playback.load();
}
/** Replace the document only after the user explicitly chooses a sample. */
function choose(): void { if (restoring) return; source.value = sampleSource(sample.value, props.locale); policy.value = sample.value === "html" ? "styles" : "source"; playback.load(); save(); }
watch(sample, choose, { flush: "sync" });
watch(source, () => { failures.value = 0; playback.load(); save(); }, { flush: "sync" });
watch([speed, policy, cache], save, { flush: "sync" });
watch(() => props.locale, restore);
onMounted(() => { restore(); mounted = true; window.addEventListener("pagehide", save); });
onBeforeUnmount(() => { save(); window.removeEventListener("pagehide", save); });
</script>
<template>
  <div class="markdown-playground">
    <div class="markdown-playground-controls">
      <QuireField :label="text.sample"><QuireSelect v-model="sample" :items="samples" :label="text.sample" width="100%" size="sm" /></QuireField>
      <QuireField :label="text.speed"><QuireSelect v-model="speed" :items="speeds" :label="text.speed" width="100%" size="sm" /></QuireField>
      <QuireField :label="text.html"><QuireSelect v-model="policy" :items="policies" :label="text.html" width="100%" size="sm" /></QuireField>
    </div>
    <div class="markdown-playground-actions">
      <QuireButton size="sm" @click="playback.start">{{ text.stream }}</QuireButton>
      <QuireButton size="sm" variant="outline" :disabled="status !== 'receiving'" @click="playback.stop">{{ text.stop }}</QuireButton>
      <QuireButton size="sm" variant="outline" :disabled="status !== 'stopped' || content.length >= source.length" @click="playback.resume">{{ text.resume }}</QuireButton>
      <QuireButton size="sm" variant="ghost" @click="playback.load">{{ text.load }}</QuireButton>
      <QuireButton size="sm" variant="ghost" @click="playback.reset">{{ text.reset }}</QuireButton>
      <QuireCheckbox v-model:checked="cache" :label="text.cache" size="sm" />
    </div>
    <div class="markdown-playground-panels">
      <QuireField :label="text.source" width="100%">
        <template #default="{ controlProps }"><QuireTextarea v-bind="controlProps" v-model="source" :rows="22" width="100%" resize="vertical" /></template>
      </QuireField>
      <section :aria-label="text.preview" class="markdown-playground-preview">
        <QuireTypography as="h2" variant="control">{{ text.preview }}</QuireTypography>
        <div data-playground-output class="markdown-playground-output"><QuireMarkdown :content="content" :identity="identity" :status="status" :options="statusOptions"
          :components="components" :html="html" :cache="cache === true" :locale="locale" @error="failures++" /></div>
      </section>
    </div>
    <QuireTypography as="span" variant="helper" data-playground-status>{{ text[status] }} · {{ text.received }} {{ content.length }} / {{ source.length }} UTF-16</QuireTypography>
    <QuireTypography v-if="failures" role="alert" variant="helper">{{ text.error }} · {{ failures }}</QuireTypography>
    <QuireTypography variant="helper" class="text-qk-text-tertiary">{{ text.hint }}</QuireTypography>
  </div>
</template>
<style scoped>
.markdown-playground { display: grid; gap: var(--qk-space-surface); padding: var(--qk-space-surface); min-width: 0; }
.markdown-playground-controls { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--qk-space-surface-compact); }
.markdown-playground-actions { display: flex; flex-wrap: wrap; gap: var(--qk-space-inline); align-items: center; }
.markdown-playground-panels { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--qk-space-surface); }
.markdown-playground-preview { min-width: 0; display: flex; flex-direction: column; gap: var(--qk-space-surface-compact); }
.markdown-playground-output { max-height: 600px; overflow: auto; padding: var(--qk-space-surface); border: 1px solid var(--qk-color-border-subtle); border-radius: var(--qk-radius-control); }
@media (max-width: 1100px) { .markdown-playground-panels { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 560px) { .markdown-playground-controls { grid-template-columns: minmax(0, 1fr); } }
</style>

```

### shared/types.ts

```ts
/** Example host language; independent of the documentation router. */
export type ExampleLocale = "zh-CN" | "en";

```

### status/reading.ts

```ts
import { h } from "vue";
import { withMarkdownReading } from "@quirekit/incremark/vue";
import type { MarkdownOptions } from "@quirekit/incremark";
import { Status } from "./status";
import type { StatusAttributes } from "./types";

/** One validated source codec shared by the custom-node guide and playground. */
export const statusOptions: MarkdownOptions = { extensions: [withMarkdownReading<StatusAttributes>(Status, {
  // The codec/render only read local attrs and locale; no external dependency is hidden.
  /** @returns No dependencies outside this node and the reader configuration. */
  cacheKey: () => null,
  /** @param context - Validated source fields and host language. @returns Semantic trusted output. */
  render: ({ attrs, locale }) => h("mark", { "data-status-token": attrs.value },
    locale === "zh-CN" ? attrs.value === "done" ? "已完成" : "待办" : attrs.value === "done" ? "Done" : "To do"),
})] };

```

### status/status.ts

```ts
import { Node, defineSource } from "@quirekit/msfe-core";
import type { StatusAttributes, StatusValue } from "./types";

/** @param value - Source or requested attribute. @returns Whether it has an exact source spelling. */
function validStatus(value: unknown): value is StatusValue { return value === "todo" || value === "done"; }

/** Grammar, fields and commands are independent of the consumer's component framework. */
export const Status = Node.create({ name: "status",
  /** @returns The one admitted, persistable field. */
  addAttributes() { return { value: { validate: validStatus } }; },
  /** @returns Bounded parsing and exact field updates, without a second document. */
  addSource() {
    return defineSource<StatusAttributes>({ nodes: ["StatusToken"], syntax: { name: "StatusToken", open: "[[", close: "]]" }, placement: "replace",
      /** @param document - Canonical Markdown. @param node - Confirmed token. @returns Attributes or a source fallback. */
      read(document, node) {
        const value = document.slice(node.from + 2, node.to - 2);
        return validStatus(value) ? { value } : null;
      },
      /** @param _document - Canonical source. @param node - Current valid range. @param patch - Requested fields. @returns A precise patch or refusal. */
      patch(_document, node, patch) {
        return validStatus(patch.value) ? { from: node.from + 2, to: node.to - 2, insert: patch.value } : null;
      },
    });
  },
  /** @returns Typed commands sharing the editor's transaction and history. */
  addCommands() { return { insertStatus: (value: StatusValue) => ({ tr }) => validStatus(value) && tr.insertText(`[[${value}]]`) }; },
});

```

### status/types.ts

```ts
/** Source-persisted value for the custom status syntax. */
export type StatusValue = "todo" | "done";

/** Attributes decoded from the canonical Markdown token. */
export interface StatusAttributes {
  /** Current task status. */
  readonly value: StatusValue;
}

declare module "@quirekit/msfe-core" {
  /** Editor commands contributed by the same source extension. */
  interface Commands<ReturnType> {
    /** Commands installed by this consumer extension. */
    status: {
      /** @param value - Persisted status. @returns Command outcome. */
      insertStatus: (value: StatusValue) => ReturnType;
    };
  }
}

```

### reading/link.vue

```vue
<script setup lang="ts">
import type { MarkdownNodeInfo } from "@quirekit/incremark/vue";
defineProps<{
  /** Validated link destination, omitted when HTML policy rejects it. */ href?: string;
  /** Source metadata is explicitly consumed rather than forwarded to the DOM. */ node?: MarkdownNodeInfo;
}>();
</script>
<template><a :href="href" class="qk-prose-node" data-custom-link><slot /> ↗</a></template>

```

### playground/playback.ts

```ts
import { computed, onBeforeUnmount, ref, type Ref } from "vue";
import type { MarkdownStatus } from "@quirekit/incremark";

/**
 * Owns the example producer independently of the reader's parsing scheduler.
 * @param source - Editable, complete draft; never overwritten by a partial response.
 * @param speed - Transport preset selected by the user.
 * @returns Received source, transport metadata and explicit lifecycle actions.
 */
export function usePlayback(source: Ref<string>, speed: Ref<string>) {
  const content = ref(source.value), status = ref<MarkdownStatus>("complete"), generation = ref(0);
  const identity = computed(() => `playground-${generation.value}`);
  let timer: ReturnType<typeof setTimeout> | undefined;
  let accepted = source.value;
  /** Release the one scheduled delivery. No parsing state is frozen. */
  function cancel(): void { if (timer !== undefined) clearTimeout(timer); timer = undefined; }
  /** Publish the next exact prefix, including partial syntax and split UTF-16 pairs. */
  function deliver(): void {
    const size = speed.value === "character" ? 1 : speed.value === "fast" ? 80 : 20;
    content.value = accepted.slice(0, content.value.length + size);
    if (content.value.length < accepted.length) timer = setTimeout(deliver, speed.value === "character" ? 12 : 30);
    else { timer = undefined; status.value = "complete"; }
  }
  /** Begin a new response from the current draft. */
  function start(): void { cancel(); accepted = source.value; generation.value++; content.value = ""; status.value = "receiving"; deliver(); }
  /** Retain the exact received prefix and stop future producer arrivals. */
  function stop(): void { cancel(); status.value = "stopped"; }
  /** Resume the captured response; editing the draft starts a separate identity. */
  function resume(): void { if (status.value !== "stopped" || content.value.length >= accepted.length) return; status.value = "receiving"; deliver(); }
  /** Accept a complete replacement and invalidate any old producer. */
  function load(): void { cancel(); accepted = source.value; generation.value++; content.value = accepted; status.value = "complete"; }
  /** Empty the preview while retaining the editable draft. */
  function reset(): void { cancel(); generation.value++; content.value = ""; status.value = "stopped"; accepted = source.value; }
  onBeforeUnmount(cancel);
  return { content, status, identity, start, stop, resume, load, reset };
}

```

### playground/samples.ts

```ts
import type { ExampleLocale } from "../shared/types";
import { readingSample } from "../reading/sample";
import { complexHtmlSample } from "../reading/html-source";

/** @param locale - Interface language. @returns Select options for the complete sample collection. */
export function sampleItems(locale: ExampleLocale) {
  const labels = locale === "zh-CN" ? ["Markdown 基础", "后置引用与脚注", "复杂 HTML", "自定义节点"]
    : ["Markdown basics", "Late references and notes", "Complex HTML", "Custom nodes"];
  return ["basics", "references", "html", "custom"].map((value, index) => ({ value, label: labels[index]! }));
}
/** @param id - Sample identity. @param locale - Document language. @returns Authoritative Markdown for a new draft. */
export function sampleSource(id: string, locale: ExampleLocale): string {
  if (id === "html") return complexHtmlSample(locale);
  if (id === "references") return readingSample(locale);
  if (id === "custom") return locale === "zh-CN"
    ? "# 自定义阅读节点\n\n设计 [[done]]，开发 [[todo]]。无效值 [[unknown]] 保留原文。\n\n[扩展指南][guide]\n\n`[[done]]` 在代码里保持原文。\n\n[guide]: /guide/extensions"
    : "# Custom reading nodes\n\nDesign [[done]], development [[todo]]. Invalid [[unknown]] stays as source.\n\n[Extension guide][guide]\n\n`[[done]]` stays literal in code.\n\n[guide]: /en/guide/extensions";
  return locale === "zh-CN" ? `# 从一个字符开始

同一份 **Markdown**，可以一次显示，也可以逐段到达。

> 改写左侧源码，或点击「流式回放」观察未闭合语法。

- [x] 标题、列表与链接
- [x] 表格、代码与数学
- [ ] 接入你的 AI 响应

| 能力 | 用法 |
| --- | --- |
| 流式 | 完整已接收前缀 |
| 自定义 | 组件与语法扩展 |

\`\`\`ts
const message = { id: 'answer-1', text: '# Hello' }
\`\`\`

行内数学：$E = mc^2$。🙂
` : `# Start with one character

The same **Markdown** can arrive at once or a chunk at a time.

> Edit the source or choose “Stream” to watch incomplete syntax.

- [x] Headings, lists and links
- [x] Tables, code and math
- [ ] Connect your AI response

| Feature | Input |
| --- | --- |
| Streaming | Full received prefix |
| Customization | Components and syntax extensions |

\`\`\`ts
const message = { id: 'answer-1', text: '# Hello' }
\`\`\`

Inline math: $E = mc^2$. 🙂
`;
}

```

### reading/sample.ts

````ts
import type { ExampleLocale } from "../shared/types";

/** @param locale - Tutorial language. @returns One source with late definitions, repeated footnotes and an incrementally displayed code block. */
export function readingSample(locale: ExampleLocale): string {
  const chinese = locale === "zh-CN";
  return [chinese ? "## 流式 Markdown" : "## Streaming Markdown",
    chinese ? "先收到正文和[文档][api]引用，定义会在末尾到达。这里重复引用同一脚注[^note]、[^note]。"
      : "The [documentation][api] reference arrives before its definition. This footnote is used twice[^note], [^note].",
    ...Array.from({ length: 6 }, (_, index) => chinese
      ? `第 ${index + 1} 段保留 **强调**、中文与 emoji 🙂。已收到的正文继续可读，后置定义到达时前面的链接会更新。`
      : `Paragraph ${index + 1} keeps **emphasis** and emoji 🙂. Existing text stays readable while late definitions update earlier links.`),
    "```ts\nconst message = 'hello'\nconsole.log(message)\n```",
    chinese ? "[^note]: 脚注正文也使用[文档][api]链接。" : "[^note]: The footnote also links to the [documentation][api].",
    "[api]: https://example.com/docs", "[api]: https://example.com/ignored",
  ].join("\n\n");
}

````

### reading/html-source.ts

```ts
import type { ExampleLocale } from "../shared/types";

/** @param locale - Example language. @returns One canonical source shared by the tutorial and the local streaming comparison. */
export function complexHtmlSample(locale: ExampleLocale = "zh-CN"): string {
  const text = locale === "en" ? {
    title: "Complex HTML, streamed", intro: "Nested HTML keeps its structure and explicitly permitted inline styles.",
    card: "HTML element example", body: "This paragraph uses a colored background, a border and custom spacing.",
    cards: "Nested layout", first: "Accepted sections", second: "Pending sections", ready: "Ready", pending: "Receiving",
    table: "A table inside a disclosure", expand: "Open the delivery report", name: "Feature", result: "Result", detail: "Details",
    feature: "Nested cards", notes: "Notes and references", stable: "Source preserved", linked: "Definitions arrive later",
    more: "Open nested details", nested: "Lists, code, links and tables can be nested inside the same HTML island.",
    source: "Source stays authoritative", events: "Native disclosure needs no event handler", anchor: "Back to the card",
    tail: "This Markdown link is resolved at the end", report: "Report", footnote: "The definition arrives after the HTML.",
  } : {
    title: "复杂 HTML · 流式渲染", intro: "嵌套 HTML 保留结构，并显式启用允许的内联样式。",
    card: "HTML 元素示例", body: "这是一个使用 HTML 样式自定义的段落，包含背景、边框与间距。",
    cards: "嵌套布局", first: "已接收片段", second: "等待片段", ready: "已就绪", pending: "接收中",
    table: "折叠面板中的表格", expand: "展开交付报告", name: "能力", result: "结果", detail: "说明",
    feature: "嵌套卡片", notes: "脚注与引用", stable: "原文保全", linked: "定义后到仍更新",
    more: "展开嵌套说明", nested: "列表、代码、链接和表格可以嵌套在同一个 HTML 片段里。",
    source: "Markdown 原文始终是权威", events: "原生折叠交互不需要事件脚本", anchor: "返回上方提示框",
    tail: "这条 Markdown 链接在文末获得定义", report: "交付报告", footnote: "这条定义在 HTML 内容之后到达。",
  };
  // Deliberately keep each HTML island free of blank lines: Markdown, not the HTML
  // renderer, owns block boundaries. Attribute/tag boundaries are split by transport.
  return `# ${text.title}

${text.intro}

<div id="html-card" style="background: #f0f9ff; padding: 1rem; border-radius: 6px; border-left: 4px solid #3b82f6; margin: 1em 0">
  <p style="margin: 0; color: #1e40af; font-size: 20px"><strong>${text.card}</strong></p>
  <p style="margin: 0.5em 0 0 0; color: #1e40af">${text.body}</p>
</div>

## ${text.cards}

<div style="display: flex; flex-wrap: wrap; gap: 12px">
  <div style="flex: 1 1 240px; min-width: 0; max-width: 100%; padding: 16px; border: 1px solid #bfdbfe; border-radius: 12px; background: #eff6ff">
    <p style="margin: 0; color: #1e40af">${text.first}</p>
    <div style="display: flex; align-items: center; justify-content: space-between; gap: 8px">
      <strong style="font-size: 32px; color: #1e3a8a">128</strong>
      <span style="background: #dcfce7; color: #166534; padding: 4px 8px; border-radius: 6px; font-size: 12px">${text.ready}</span>
    </div>
  </div>
  <div style="flex: 1 1 240px; min-width: 0; max-width: 100%; padding: 16px; border: 1px solid #e2e8f0; border-radius: 12px; background: #f8fafc">
    <p style="margin: 0; color: #475569">${text.second}</p>
    <div style="display: flex; align-items: center; justify-content: space-between; gap: 8px">
      <strong style="font-size: 32px; color: #334155">3</strong>
      <span style="background: #fef3c7; color: #92400e; padding: 4px 8px; border-radius: 6px; font-size: 12px">${text.pending}</span>
    </div>
  </div>
</div>

## ${text.table}

<details open style="border: 1px solid #cbd5e1; border-radius: 12px; padding: 16px">
  <summary style="cursor: pointer; font-weight: 600; color: #334155">${text.expand}</summary>
  <table style="width: 100%; border-collapse: collapse; margin: 12px 0">
    <thead><tr style="background: #f1f5f9"><th>${text.name}</th><th>${text.result}</th><th>${text.detail}</th></tr></thead>
    <tbody>
      <tr><td><strong>${text.feature}</strong></td><td style="color: #166534">${text.ready}</td><td>${text.stable}</td></tr>
      <tr><td><strong>${text.notes}</strong></td><td style="color: #166534">${text.ready}</td><td>${text.linked}</td></tr>
    </tbody>
  </table>
  <details style="background: #f8fafc; border-radius: 8px; padding: 12px">
    <summary style="cursor: pointer; color: #475569">${text.more}</summary>
    <p>${text.nested}</p>
    <ul><li>${text.source}</li><li>${text.events}</li><li><code>&lt;div style="padding: 1rem"&gt;</code></li></ul>
    <a href="#html-card">${text.anchor}</a>
  </details>
</details>

${text.tail}：[${text.report}][report]。[^html-note]

[report]: https://example.com/report

[^html-note]: ${text.footnote}
`;
}

```

### playground/copy.ts

```ts
import type { ExampleLocale } from "../shared/types";

/** @param locale - Active tutorial language. @returns User-facing playground labels. */
export function playgroundCopy(locale: ExampleLocale) {
  return locale === "zh-CN" ? {
    sample: "样例", speed: "追加速度", html: "HTML 策略", source: "Markdown 源码", preview: "渲染预览",
    stream: "流式回放", stop: "停止", resume: "继续", load: "一次性加载", reset: "清空预览", cache: "块缓存",
    receiving: "接收中", stopped: "已停止", complete: "已完成", error: "渲染错误", received: "已接收",
    hint: "草稿仅保存在当前浏览器标签页。编辑源码会停止旧回放并立即预览。",
    speeds: ["逐字 · 1 / 12ms", "标准 · 20 / 30ms", "快速 · 80 / 30ms"],
    policies: ["显示 HTML 源码", "净化 HTML", "HTML + 允许的样式"],
  } : {
    sample: "Sample", speed: "Chunk speed", html: "HTML policy", source: "Markdown source", preview: "Rendered preview",
    stream: "Stream", stop: "Stop", resume: "Resume", load: "Load all", reset: "Clear preview", cache: "Block cache",
    receiving: "Receiving", stopped: "Stopped", complete: "Complete", error: "Rendering error", received: "Received",
    hint: "Drafts stay in this browser tab. Editing stops the old stream and previews the new source immediately.",
    speeds: ["Character · 1 / 12ms", "Normal · 20 / 30ms", "Fast · 80 / 30ms"],
    policies: ["Show HTML source", "Sanitized HTML", "HTML + permitted styles"],
  };
}

```

速度单位为每次追加的 UTF-16 单元数与间隔；这模拟接收节奏，不是吞吐量基准。
缓存开关只控制 Vue 块投影复用，不关闭增量解析。查看[流式接入](/guide/streaming)或[性能方法](/guide/performance)。
