@antdv-next/x-markdown 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -105,19 +105,51 @@ import "@antdv-next/x-markdown/themes/light.css";
105
105
 
106
106
  ### Props
107
107
 
108
- | Property | Description | Type | Default |
109
- | ------------------------ | --------------------------------------- | --------------------------- | --------------- |
110
- | content | Markdown content to render | `string` | - |
111
- | components | Map HTML nodes to custom Vue components | `Record<string, Component>` | - |
112
- | streaming | Streaming behavior config | `StreamingOption` | - |
113
- | config | Marked parse config | `MarkedConfig` | `{ gfm: true }` |
114
- | className | Extra CSS class for root container | `string` | - |
115
- | style | Inline styles for root container | `Record<string, string>` | - |
116
- | paragraphTag | HTML tag for paragraphs | `string` | `'p'` |
117
- | openLinksInNewTab | Add `target="_blank"` to all links | `boolean` | `true` |
118
- | protectCustomTagNewlines | Preserve newlines inside custom tags | `boolean` | `true` |
119
- | escapeRawHtml | Escape raw HTML as plain text | `boolean` | `false` |
120
- | debug | Enable debug mode | `boolean` | `false` |
108
+ | Property | Description | Type | Default |
109
+ | ------------------------ | --------------------------------------------------- | ----------------------------------------- | --------------- |
110
+ | content | Markdown content to render | `string` | - |
111
+ | components | Map HTML nodes to custom Vue components | `Record<string, Component>` | - |
112
+ | componentsProps | Extra props passed to custom components by tag name | `Record<string, Record<string, unknown>>` | - |
113
+ | streaming | Streaming behavior config | `StreamingOption` | - |
114
+ | config | Marked parse config | `MarkedConfig` | `{ gfm: true }` |
115
+ | className | Extra CSS class for root container | `string` | - |
116
+ | style | Inline styles for root container | `Record<string, string>` | - |
117
+ | paragraphTag | HTML tag for paragraphs | `string` | `'p'` |
118
+ | openLinksInNewTab | Add `target="_blank"` to all links | `boolean` | `true` |
119
+ | protectCustomTagNewlines | Preserve newlines inside custom tags | `boolean` | `true` |
120
+ | escapeRawHtml | Escape raw HTML as plain text | `boolean` | `false` |
121
+ | debug | Enable debug mode | `boolean` | `false` |
122
+
123
+ ### Passing extra props to custom components
124
+
125
+ Custom components often need business data (theme, callbacks, etc.). Passing it via an inline wrapper function creates a new component on every render and forces the subtree to be re-created, losing internal state and hurting performance in streaming scenarios. Use `componentsProps` to pass extra props while keeping component references stable:
126
+
127
+ ```vue
128
+ <script setup>
129
+ import { computed, ref } from "vue";
130
+ import { XMarkdown } from "@antdv-next/x-markdown";
131
+
132
+ const theme = ref("dark");
133
+ const components = { "custom-chart": CustomChart };
134
+ // Keep the reference stable — an inline object literal invalidates the render
135
+ // cache and re-parses the whole tree on every render.
136
+ const componentsProps = computed(() => ({
137
+ "custom-chart": { theme: theme.value, onSelect },
138
+ }));
139
+ </script>
140
+
141
+ <template>
142
+ <XMarkdown :components="components" :componentsProps="componentsProps" />
143
+ </template>
144
+ ```
145
+
146
+ `componentsProps` is keyed by tag name. Its props are merged with the parsed HTML attributes and passed to the component:
147
+
148
+ - On conflict `componentsProps` wins — a `title` in `componentsProps` overrides `title="..."` from the HTML.
149
+ - `class` / `className` is the exception: both sides are concatenated, with the `componentsProps` class name first (merged into `class`).
150
+ - Internally computed props — `streamStatus`, `domNode`, `children` (plus `lang` and `block` for `code`) — cannot be overridden and are ignored if present in `componentsProps`.
151
+
152
+ When `componentsProps` changes, the component receives a normal props update without being remounted. Like `components`, it takes part in the render cache, so passing an inline object literal invalidates that cache on every render and re-parses the whole tree — keep the reference stable (e.g. with `computed`) as shown above.
121
153
 
122
154
  ### StreamingOption
123
155
 
@@ -1,6 +1,6 @@
1
1
  import { Fragment, computed, createElementBlock, defineComponent, normalizeStyle, openBlock, ref, renderList, toDisplayString, watch } from "vue";
2
2
  //#region src/XMarkdown/components/AnimationText.vue?vue&type=script&setup=true&lang.ts
3
- var AnimationText_vue_vue_type_script_setup_true_lang_default = /* @__PURE__ */ defineComponent({
3
+ var AnimationText_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComponent({
4
4
  __name: "AnimationText",
5
5
  props: {
6
6
  text: {},
@@ -10,7 +10,7 @@ var _hoisted_4 = {
10
10
  };
11
11
  var _hoisted_5 = ["d"];
12
12
  var maxSnapshots = 60;
13
- var DebugPanel_vue_vue_type_script_setup_true_lang_default = /* @__PURE__ */ defineComponent({
13
+ var DebugPanel_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComponent({
14
14
  __name: "DebugPanel",
15
15
  props: { className: {} },
16
16
  setup(__props) {
@@ -1,7 +1,7 @@
1
1
  import { createElementBlock, defineComponent, openBlock, toDisplayString } from "vue";
2
2
  //#region src/XMarkdown/components/TailIndicator.vue?vue&type=script&setup=true&lang.ts
3
3
  var _hoisted_1 = { class: "xmd-tail" };
4
- var TailIndicator_vue_vue_type_script_setup_true_lang_default = /* @__PURE__ */ defineComponent({
4
+ var TailIndicator_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComponent({
5
5
  __name: "TailIndicator",
6
6
  props: { content: { default: "▋" } },
7
7
  setup(__props) {
@@ -82,11 +82,21 @@ var recognize = (cache, tokenType) => {
82
82
  }
83
83
  };
84
84
  var recognizeHandlers = Object.values(tokenRecognizerMap).filter((recognizer) => Boolean(recognizer));
85
+ var getInitialFenceState = () => ({
86
+ inFenced: false,
87
+ fenceChar: "",
88
+ fenceLen: 0,
89
+ lineFenceChar: "",
90
+ lineFenceLen: 0,
91
+ lineFenceRunEnded: false,
92
+ lineTailBlank: true
93
+ });
85
94
  var getInitialCache = () => ({
86
95
  pending: "",
87
96
  token: StreamCacheTokenType.Text,
88
97
  processedLength: 0,
89
- completeMarkdown: ""
98
+ completeMarkdown: "",
99
+ fence: getInitialFenceState()
90
100
  });
91
101
  var commitCache = (cache) => {
92
102
  if (cache.pending) {
@@ -95,33 +105,42 @@ var commitCache = (cache) => {
95
105
  }
96
106
  cache.token = StreamCacheTokenType.Text;
97
107
  };
98
- var isInCodeBlock = (text, isFinalChunk = false) => {
99
- const lines = text.split("\n");
100
- let inFenced = false;
101
- let fenceChar = "";
102
- let fenceLen = 0;
103
- for (let i = 0; i < lines.length; i++) {
104
- const rawLine = lines[i];
105
- const match = (rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine).match(/^(`{3,}|~{3,})(.*)$/);
106
- if (!match) continue;
107
- const fence = match[1];
108
- const after = match[2];
109
- const char = fence[0];
110
- const len = fence.length;
111
- if (!inFenced) {
112
- inFenced = true;
113
- fenceChar = char;
114
- fenceLen = len;
115
- continue;
116
- }
117
- if (char === fenceChar && len >= fenceLen && /^\s*$/.test(after) && (isFinalChunk || i < lines.length - 1)) {
118
- inFenced = false;
119
- fenceChar = "";
120
- fenceLen = 0;
108
+ /**
109
+ * Incremental fenced-code-block state over the processed text, updated in O(1)
110
+ * per character. Recomputing over the full accumulated text on every character
111
+ * is O(N²) and freezes the page on long single-line content such as base64
112
+ * image data URIs.
113
+ */
114
+ var feedFenceState = (fence, char) => {
115
+ if (char === "\n") {
116
+ if (fence.lineFenceLen >= 3) {
117
+ if (!fence.inFenced) {
118
+ fence.inFenced = true;
119
+ fence.fenceChar = fence.lineFenceChar;
120
+ fence.fenceLen = fence.lineFenceLen;
121
+ } else if (fence.lineFenceChar === fence.fenceChar && fence.lineFenceLen >= fence.fenceLen && fence.lineTailBlank) {
122
+ fence.inFenced = false;
123
+ fence.fenceChar = "";
124
+ fence.fenceLen = 0;
125
+ }
121
126
  }
127
+ fence.lineFenceChar = "";
128
+ fence.lineFenceLen = 0;
129
+ fence.lineFenceRunEnded = false;
130
+ fence.lineTailBlank = true;
131
+ return;
132
+ }
133
+ if (!fence.lineFenceRunEnded) if (fence.lineFenceLen === 0 && (char === "`" || char === "~")) {
134
+ fence.lineFenceChar = char;
135
+ fence.lineFenceLen = 1;
136
+ } else if (fence.lineFenceLen > 0 && char === fence.lineFenceChar) fence.lineFenceLen += 1;
137
+ else {
138
+ fence.lineFenceRunEnded = true;
139
+ fence.lineTailBlank = fence.lineTailBlank && /\s/.test(char);
122
140
  }
123
- return inFenced;
141
+ else fence.lineTailBlank = fence.lineTailBlank && /\s/.test(char);
124
142
  };
143
+ var isInCodeBlock = (fence) => fence.inFenced || fence.lineFenceLen >= 3;
125
144
  var sanitizeForURIComponent = (input) => {
126
145
  let result = "";
127
146
  for (let i = 0; i < input.length; i++) {
@@ -144,7 +163,7 @@ var safeEncodeURIComponent = (str) => {
144
163
  }
145
164
  };
146
165
  function useStreaming(content, streaming, components) {
147
- const streamCache = ref(getInitialCache());
166
+ let streamCache = getInitialCache();
148
167
  const processedContent = ref("");
149
168
  function handleIncompleteMarkdown(cache, opts) {
150
169
  const { token, pending } = cache;
@@ -158,12 +177,12 @@ function useStreaming(content, streaming, components) {
158
177
  function processStreaming(text, opts) {
159
178
  if (!text) {
160
179
  processedContent.value = "";
161
- streamCache.value = getInitialCache();
180
+ streamCache = getInitialCache();
162
181
  return;
163
182
  }
164
- const expectedPrefix = streamCache.value.completeMarkdown + streamCache.value.pending;
165
- if (!text.startsWith(expectedPrefix)) streamCache.value = getInitialCache();
166
- const cache = streamCache.value;
183
+ const expectedPrefix = streamCache.completeMarkdown + streamCache.pending;
184
+ if (!text.startsWith(expectedPrefix)) streamCache = getInitialCache();
185
+ const cache = streamCache;
167
186
  const chunk = text.slice(cache.processedLength);
168
187
  if (!chunk) {
169
188
  const incompletePlaceholder = handleIncompleteMarkdown(cache, opts);
@@ -173,7 +192,8 @@ function useStreaming(content, streaming, components) {
173
192
  cache.processedLength += chunk.length;
174
193
  for (const char of chunk) {
175
194
  cache.pending += char;
176
- if (isInCodeBlock(cache.completeMarkdown + cache.pending)) {
195
+ feedFenceState(cache.fence, char);
196
+ if (isInCodeBlock(cache.fence)) {
177
197
  commitCache(cache);
178
198
  continue;
179
199
  }
@@ -189,18 +209,19 @@ function useStreaming(content, streaming, components) {
189
209
  processedContent.value = cache.completeMarkdown + (incompletePlaceholder || "");
190
210
  }
191
211
  function reset() {
192
- streamCache.value = getInitialCache();
212
+ streamCache = getInitialCache();
193
213
  processedContent.value = "";
194
214
  }
195
215
  watch([content, () => streaming.value?.hasNextChunk], ([newContent, hasNextChunk]) => {
196
216
  const opts = streaming.value;
197
217
  if (!Boolean(hasNextChunk)) {
198
218
  processedContent.value = newContent;
199
- streamCache.value = {
219
+ streamCache = {
200
220
  pending: "",
201
221
  token: StreamCacheTokenType.Text,
202
222
  processedLength: newContent.length,
203
- completeMarkdown: newContent
223
+ completeMarkdown: newContent,
224
+ fence: getInitialFenceState()
204
225
  };
205
226
  return;
206
227
  }
@@ -55,6 +55,7 @@ var VueRenderer = class {
55
55
  constructor(options = {}) {
56
56
  this.options = {
57
57
  components: options.components ?? {},
58
+ componentsProps: options.componentsProps ?? {},
58
59
  enableAnimation: options.enableAnimation ?? true,
59
60
  animationConfig: {
60
61
  fadeDuration: options.animationConfig?.fadeDuration ?? DEFAULT_ANIMATION_DURATION,
@@ -71,7 +72,7 @@ var VueRenderer = class {
71
72
  return h(Fragment, nodes);
72
73
  }
73
74
  sanitize(html) {
74
- const allowedTags = [...new Set([
75
+ const allowedTags = [.../* @__PURE__ */ new Set([
75
76
  ...Object.keys(this.options.components),
76
77
  "p",
77
78
  "br",
@@ -183,12 +184,17 @@ var VueRenderer = class {
183
184
  }
184
185
  extractComponentProps(element, tagName, unclosedTags, cidRef) {
185
186
  const props = {};
186
- cidRef.tagIndexes[tagName] = (cidRef.tagIndexes[tagName] ?? 0) + 1;
187
- const instanceId = getTagInstanceId(tagName, cidRef.tagIndexes[tagName]);
188
- props.streamStatus = unclosedTags.has(instanceId) ? "loading" : "done";
189
187
  Array.from(element.attributes).forEach((attr) => {
190
188
  props[attr.name] = attr.value;
191
189
  });
190
+ const htmlClass = typeof props.class === "string" ? props.class : "";
191
+ const extraProps = this.options.componentsProps[tagName] ?? {};
192
+ Object.assign(props, extraProps);
193
+ cidRef.tagIndexes[tagName] = (cidRef.tagIndexes[tagName] ?? 0) + 1;
194
+ const instanceId = getTagInstanceId(tagName, cidRef.tagIndexes[tagName]);
195
+ props.streamStatus = unclosedTags.has(instanceId) ? "loading" : "done";
196
+ props.domNode = element;
197
+ delete props.children;
192
198
  if (tagName === "code") {
193
199
  props.block = element.getAttribute("data-block") === "true";
194
200
  props.streamStatus = element.getAttribute("data-state") === "loading" ? "loading" : "done";
@@ -197,7 +203,12 @@ var VueRenderer = class {
197
203
  const langFromClass = className.match(/(?:^|\s)language-([^\s]+)/)?.[1] ?? className.match(/(?:^|\s)lang-([^\s]+)/)?.[1] ?? "";
198
204
  const lang = langFromData || langFromClass;
199
205
  if (lang) props.lang = lang;
206
+ else delete props.lang;
200
207
  }
208
+ const extraClass = extraProps.className ?? extraProps.classname ?? extraProps.class;
209
+ if (typeof extraClass === "string") props.class = [extraClass, htmlClass].filter(Boolean).join(" ").trim();
210
+ delete props.className;
211
+ delete props.classname;
201
212
  return props;
202
213
  }
203
214
  wrapWithAnimation(text) {
@@ -212,6 +223,10 @@ var VueRenderer = class {
212
223
  ...this.options.components,
213
224
  ...options.components
214
225
  };
226
+ if (options.componentsProps) this.options.componentsProps = {
227
+ ...this.options.componentsProps,
228
+ ...options.componentsProps
229
+ };
215
230
  if (options.enableAnimation !== void 0) this.options.enableAnimation = options.enableAnimation;
216
231
  if (options.animationConfig) this.options.animationConfig = {
217
232
  ...this.options.animationConfig,
@@ -1,7 +1,7 @@
1
1
  //#region src/XMarkdown/core/detectUnclosedComponentTags.ts
2
2
  var WHITESPACE_REGEX = /\s/;
3
3
  var TAG_NAME_CHAR_REGEX = /[a-zA-Z0-9-]/;
4
- var VOID_ELEMENTS = new Set([
4
+ var VOID_ELEMENTS = /* @__PURE__ */ new Set([
5
5
  "area",
6
6
  "base",
7
7
  "br",
@@ -6,11 +6,12 @@ import { VueRenderer } from "./core/VueRenderer.js";
6
6
  import { useTail } from "./composables/useTail.js";
7
7
  import { computed, createBlock, createCommentVNode, createElementBlock, createVNode, defineComponent, h, normalizeClass, normalizeStyle, openBlock, ref, shallowRef, unref, watch } from "vue";
8
8
  //#region src/XMarkdown/index.vue?vue&type=script&setup=true&lang.ts
9
- var index_vue_vue_type_script_setup_true_lang_default = /* @__PURE__ */ defineComponent({
9
+ var index_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComponent({
10
10
  __name: "index",
11
11
  props: {
12
12
  content: { default: "" },
13
13
  components: { default: () => ({}) },
14
+ componentsProps: {},
14
15
  streaming: { default: void 0 },
15
16
  config: { default: () => ({ gfm: true }) },
16
17
  debug: {
@@ -75,6 +76,7 @@ var index_vue_vue_type_script_setup_true_lang_default = /* @__PURE__ */ defineCo
75
76
  }));
76
77
  const renderer = shallowRef(new VueRenderer({
77
78
  components: mergedComponents.value,
79
+ componentsProps: props.componentsProps,
78
80
  enableAnimation: props.streaming?.enableAnimation ?? true,
79
81
  animationConfig: props.streaming?.animationConfig
80
82
  }));
@@ -116,6 +118,10 @@ var index_vue_vue_type_script_setup_true_lang_default = /* @__PURE__ */ defineCo
116
118
  renderer.value.setOptions({ components: newComponents });
117
119
  bumpOptionsVersion();
118
120
  }, { deep: true });
121
+ watch(() => props.componentsProps, (newComponentsProps) => {
122
+ renderer.value.setOptions({ componentsProps: newComponentsProps });
123
+ bumpOptionsVersion();
124
+ }, { deep: true });
119
125
  watch(() => props.streaming, (newStreaming) => {
120
126
  renderer.value.setOptions({
121
127
  enableAnimation: newStreaming?.enableAnimation ?? true,
@@ -10,11 +10,24 @@ export declare enum StreamCacheTokenType {
10
10
  Table = "table",
11
11
  InlineCode = "inline-code"
12
12
  }
13
+ export interface FenceState {
14
+ /** Inside an open fence, considering completed lines only */
15
+ inFenced: boolean;
16
+ fenceChar: string;
17
+ fenceLen: number;
18
+ /** Leading `` ` ``/`~` run of the current (possibly incomplete) line */
19
+ lineFenceChar: string;
20
+ lineFenceLen: number;
21
+ lineFenceRunEnded: boolean;
22
+ /** Whether every char after the leading run is whitespace (closing fences allow only whitespace) */
23
+ lineTailBlank: boolean;
24
+ }
13
25
  export interface StreamCache {
14
26
  pending: string;
15
27
  token: StreamCacheTokenType;
16
28
  processedLength: number;
17
29
  completeMarkdown: string;
30
+ fence: FenceState;
18
31
  }
19
32
  export interface StreamingOption {
20
33
  hasNextChunk: boolean;
@@ -32,6 +45,11 @@ export interface StreamingOption {
32
45
  export interface XMarkdownProps {
33
46
  content?: string;
34
47
  components?: Record<string, Component>;
48
+ /**
49
+ * 按标签名向 `components` 中的自定义组件传递额外的 props,使组件引用保持稳定,避免内联函数导致的重复挂载
50
+ * Extra props passed to custom components in `components` by tag name, keeping component references stable and avoiding remounts caused by inline functions
51
+ */
52
+ componentsProps?: Record<string, Record<string, unknown>>;
35
53
  streaming?: StreamingOption;
36
54
  config?: MarkedConfig;
37
55
  debug?: boolean;
@@ -78,6 +96,7 @@ export interface ParserOptions {
78
96
  }
79
97
  export interface RendererOptions {
80
98
  components?: Record<string, Component>;
99
+ componentsProps?: Record<string, Record<string, unknown>>;
81
100
  enableAnimation?: boolean;
82
101
  animationConfig?: {
83
102
  fadeDuration?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antdv-next/x-markdown",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "license": "MIT",
5
5
  "files": [
6
6
  "dist",
@@ -1,11 +1,17 @@
1
1
  import katex from "katex";
2
2
  import "katex/dist/katex.min.css";
3
3
  //#region src/plugins/Latex/index.ts
4
- var inlineRuleNonStandard = /^(?:\${1,2}([^$]{1,10000}?)\${1,2}|\\\(([\s\S]{1,10000}?)\\\)|\\\[((?:\\.|[^\\]){1,10000}?)\\\])/;
4
+ var inlineDollarRule = /^(\${1,2})((?:\\.|[^\\$]){1,10000}?)\1/;
5
+ var inlineRuleNonStandard = /^(?:\\\(([\s\S]{1,10000}?)\\\)|\\\[((?:\\.|[^\\]){1,10000}?)\\\])/;
5
6
  var blockRule = /^(\${1,2})\n([\s\S]{1,10000}?)\n\1(?:\s*(?:\n|$))|^\\\[((?:\\.|[^\\]){1,10000}?)\\\]/;
6
7
  function replaceAlign(text) {
7
8
  return text ? text.replace(/\{align\*\}/g, "{aligned}") : text;
8
9
  }
10
+ function isValidInlineDollarMatch(match, src) {
11
+ const [, delimiter, text] = match;
12
+ if (delimiter === "$$") return true;
13
+ return !/^\s|\s$/.test(text) && !/^\d/.test(src.slice(match[0].length));
14
+ }
9
15
  function createRenderer(options, newlineAfter) {
10
16
  return (token) => katex.renderToString(token.text, {
11
17
  ...options,
@@ -25,10 +31,13 @@ function inlineKatex(renderer, replaceAlignStart) {
25
31
  return indices.length > 0 ? Math.min(...indices) : void 0;
26
32
  },
27
33
  tokenizer(src) {
28
- const match = src.match(inlineRuleNonStandard);
34
+ const dollarMatch = src.match(inlineDollarRule);
35
+ if (dollarMatch && !isValidInlineDollarMatch(dollarMatch, src)) return;
36
+ const nonStandardMatch = src.match(inlineRuleNonStandard);
37
+ const match = dollarMatch || nonStandardMatch;
29
38
  if (!match) return;
30
- const rawText = (match[1] || match[2] || match[3] || "").trim();
31
- const text = replaceAlignStart ? replaceAlign(rawText) : rawText;
39
+ const rawText = dollarMatch?.[2] || nonStandardMatch?.[1] || nonStandardMatch?.[2] || "";
40
+ const text = replaceAlignStart ? replaceAlign(rawText.trim()) : rawText.trim();
32
41
  return {
33
42
  type: "inlineKatex",
34
43
  raw: match[0],