# Playground

Edit source and compare static loading with streaming updates of the same Markdown.

Choose basic syntax, late references, complex HTML or custom nodes, then edit the source.
Streaming starts from the current draft. Stop retains the prefix; Resume continues it. Load all and Clear preview preserve the draft.
HTML is escaped by default; the complex HTML sample explicitly enables permitted inline styles.

### 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"],
  };
}

```

Speed is measured in UTF-16 units per chunk and milliseconds between chunks. This simulates delivery cadence, not throughput.
The cache toggle controls Vue block projection reuse, not incremental parsing. See [streaming](/en/guide/streaming) or [measurement methods](/en/guide/performance).
