@h-ai/ui 0.1.0-alpha.30 → 0.1.0-alpha.31

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.
@@ -33,9 +33,20 @@
33
33
  const dataAttributes = $derived(getDataAttributes(restProps))
34
34
  let textareaRef: HTMLTextAreaElement | undefined = $state()
35
35
 
36
+ const wrapperClass = $derived(
37
+ cn(
38
+ 'flex w-full rounded-lg border bg-base-100',
39
+ error
40
+ ? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
41
+ : 'border-base-content/15 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
42
+ 'transition-[border-color,box-shadow] duration-150',
43
+ disabled && 'opacity-50 cursor-not-allowed',
44
+ ),
45
+ )
46
+
36
47
  const textareaClass = $derived(
37
48
  cn(
38
- 'textarea w-full',
49
+ 'textarea w-full bg-transparent border-none outline-none',
39
50
  getInputSizeClass(size),
40
51
  error && 'textarea-error',
41
52
  className,
@@ -74,20 +85,22 @@
74
85
  </script>
75
86
 
76
87
  <div {...dataAttributes} class='fieldset w-full'>
77
- <textarea
78
- bind:this={textareaRef}
79
- {id}
80
- {name}
81
- {placeholder}
82
- {rows}
83
- {disabled}
84
- {readonly}
85
- {required}
86
- class={textareaClass}
87
- bind:value
88
- oninput={handleInput}
89
- oninvalid={handleInvalid}
90
- ></textarea>
88
+ <div class={wrapperClass}>
89
+ <textarea
90
+ bind:this={textareaRef}
91
+ {id}
92
+ {name}
93
+ {placeholder}
94
+ {rows}
95
+ {disabled}
96
+ {readonly}
97
+ {required}
98
+ class={textareaClass}
99
+ bind:value
100
+ oninput={handleInput}
101
+ oninvalid={handleInvalid}
102
+ ></textarea>
103
+ </div>
91
104
  {#if error}
92
105
  <span class='fieldset-label text-error'>{error}</span>
93
106
  {/if}
@@ -240,6 +240,8 @@
240
240
  sourceKind = 'markdown',
241
241
  // sourceKind=code 时的默认语言提示。
242
242
  codeLanguage,
243
+ // 正文字号,number 会按 px 处理。
244
+ fontSize,
243
245
  // 外层自定义类名。
244
246
  class: className = '',
245
247
  // 是否显示代码块复制按钮。
@@ -250,6 +252,8 @@
250
252
  showCodePreviewToggle = false,
251
253
  // 是否显式允许内置 HTML / JS / CSS 预览执行高风险内容。
252
254
  allowUnsafeCodePreview = false,
255
+ // 是否按安全白名单解析原始 HTML 标签。
256
+ allowHtmlTags = false,
253
257
  // 代码预览切换模式下展示在语言标签旁的提示文案。
254
258
  codePreviewHint = '',
255
259
  // 是否启用语法高亮。
@@ -412,6 +416,7 @@
412
416
  codeViewPreviewLabel: uiM('file_list_preview'),
413
417
  codePreviewHint: normalizedCodePreviewHint,
414
418
  breaks,
419
+ allowHtmlTags,
415
420
  }),
416
421
  )
417
422
  // html 是最终注入正文的内容。
@@ -466,6 +471,25 @@
466
471
  editable ? 'hai-markdown-editable' : '',
467
472
  ),
468
473
  )
474
+ const normalizedFontSize = $derived(resolveFontSize(fontSize))
475
+ const documentStyle = $derived(
476
+ normalizedFontSize
477
+ ? `--hai-markdown-font-size:${normalizedFontSize};`
478
+ : undefined,
479
+ )
480
+
481
+ function resolveFontSize(value: AiDocumentEditorProps['fontSize']): string | undefined {
482
+ if (typeof value === 'number' && Number.isFinite(value)) {
483
+ return `${value}px`
484
+ }
485
+
486
+ if (typeof value === 'string') {
487
+ const normalized = value.trim()
488
+ return normalized || undefined
489
+ }
490
+
491
+ return undefined
492
+ }
469
493
 
470
494
  $effect(() => {
471
495
  void html
@@ -936,6 +960,7 @@
936
960
  enableHighlight,
937
961
  showCopyButton: false,
938
962
  breaks,
963
+ allowHtmlTags,
939
964
  })
940
965
 
941
966
  host.innerHTML = `<div class="hai-md-preview-card"><div class="hai-md-preview-head">${previewTitle}</div>${previewDesc}<div class="hai-md-preview-rendered">${markdownHtml}</div></div>`
@@ -3138,6 +3163,7 @@
3138
3163
  <article
3139
3164
  bind:this={previewHost}
3140
3165
  class={readerDocumentClass}
3166
+ style={documentStyle}
3141
3167
  contenteditable={editable}
3142
3168
  role='document'
3143
3169
  onmousemove={handlePreviewMouseMove}
@@ -3993,6 +4019,7 @@
3993
4019
  }
3994
4020
 
3995
4021
  .hai-markdown-document {
4022
+ font-size: var(--hai-markdown-font-size, 1rem);
3996
4023
  width: 100%;
3997
4024
  min-width: 0;
3998
4025
  max-width: 52.5rem;
@@ -4045,29 +4072,29 @@
4045
4072
  }
4046
4073
 
4047
4074
  .hai-markdown :global(h1) {
4048
- font-size: 2.2rem;
4075
+ font-size: 2.2em;
4049
4076
  padding-bottom: 0.45em;
4050
4077
  border-bottom: 1px solid oklch(var(--bc) / 0.12);
4051
4078
  }
4052
4079
 
4053
4080
  .hai-markdown :global(h2) {
4054
- font-size: 1.48rem;
4081
+ font-size: 1.48em;
4055
4082
  }
4056
4083
 
4057
4084
  .hai-markdown :global(h3) {
4058
- font-size: 1.18rem;
4085
+ font-size: 1.18em;
4059
4086
  }
4060
4087
 
4061
4088
  .hai-markdown :global(h4) {
4062
- font-size: 1.02rem;
4089
+ font-size: 1.02em;
4063
4090
  }
4064
4091
 
4065
4092
  .hai-markdown :global(h5) {
4066
- font-size: 0.95rem;
4093
+ font-size: 0.95em;
4067
4094
  }
4068
4095
 
4069
4096
  .hai-markdown :global(h6) {
4070
- font-size: 0.88rem;
4097
+ font-size: 0.88em;
4071
4098
  color: oklch(var(--bc) / 0.7);
4072
4099
  }
4073
4100
 
@@ -4264,7 +4291,7 @@
4264
4291
  font-family:
4265
4292
  ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
4266
4293
  'Liberation Mono', monospace;
4267
- font-size: 0.875rem;
4294
+ font-size: 0.875em;
4268
4295
  line-height: 1.45;
4269
4296
  tab-size: 2;
4270
4297
 
@@ -24,19 +24,45 @@
24
24
 
25
25
  const {
26
26
  content = '',
27
+ fontSize,
27
28
  class: className = '',
28
29
  showCopyButton = true,
29
30
  enableHighlight = true,
30
31
  breaks = true,
32
+ allowHtmlTags = false,
31
33
  ...restProps
32
34
  }: MarkdownRendererProps & DataAttributes = $props()
33
35
 
34
36
  const dataAttributes = $derived(getDataAttributes(restProps))
37
+ const normalizedFontSize = $derived(resolveFontSize(fontSize))
38
+ const markdownStyle = $derived(
39
+ normalizedFontSize
40
+ ? `--hai-markdown-font-size:${normalizedFontSize};`
41
+ : undefined,
42
+ )
35
43
  /** 解析后的 HTML */
36
44
  const html = $derived(
37
- parseMarkdown(content, { enableHighlight, showCopyButton, breaks }),
45
+ parseMarkdown(content, {
46
+ enableHighlight,
47
+ showCopyButton,
48
+ breaks,
49
+ allowHtmlTags,
50
+ }),
38
51
  )
39
52
 
53
+ function resolveFontSize(value: MarkdownRendererProps['fontSize']): string | undefined {
54
+ if (typeof value === 'number' && Number.isFinite(value)) {
55
+ return `${value}px`
56
+ }
57
+
58
+ if (typeof value === 'string') {
59
+ const normalized = value.trim()
60
+ return normalized || undefined
61
+ }
62
+
63
+ return undefined
64
+ }
65
+
40
66
  /**
41
67
  * 事件代理:处理代码块复制按钮点击
42
68
  */
@@ -65,9 +91,11 @@
65
91
  }
66
92
  </script>
67
93
 
68
- <div {...dataAttributes}
69
- class={cn('hai-markdown', className)}
70
- onclick={handleClick}
94
+ <div
95
+ {...dataAttributes}
96
+ class={cn('hai-markdown', className)}
97
+ style={markdownStyle}
98
+ onclick={handleClick}
71
99
  >
72
100
  <!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown HTML 渲染 -->
73
101
  {@html html}
@@ -81,6 +109,7 @@
81
109
  * ========================================================================= */
82
110
 
83
111
  .hai-markdown {
112
+ font-size: var(--hai-markdown-font-size, 1rem);
84
113
  line-height: 1.75;
85
114
  color: inherit;
86
115
  word-wrap: break-word;
@@ -171,6 +200,18 @@
171
200
  color: oklch(var(--bc) / 0.55);
172
201
  }
173
202
 
203
+ .hai-markdown :global(u) {
204
+ text-decoration-thickness: 0.08em;
205
+ text-underline-offset: 0.14em;
206
+ }
207
+
208
+ .hai-markdown :global(mark) {
209
+ padding: 0 0.12em;
210
+ border-radius: 0.25rem;
211
+ background: oklch(var(--wa, 0.9 0.14 90) / 0.34);
212
+ color: inherit;
213
+ }
214
+
174
215
  /* ─── 行内代码 ─── */
175
216
 
176
217
  .hai-markdown :global(:not(pre) > code) {
@@ -245,7 +286,7 @@
245
286
 
246
287
  .hai-markdown :global(.hai-md-code-block code) {
247
288
  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
248
- font-size: 0.875rem;
289
+ font-size: 0.875em;
249
290
  line-height: 1.6;
250
291
  tab-size: 2;
251
292
  }
@@ -21,6 +21,8 @@ export interface MarkdownDocumentParseOptions {
21
21
  codePreviewHint?: string;
22
22
  /** Whether soft line breaks are converted to <br>. */
23
23
  breaks?: boolean;
24
+ /** Whether raw HTML tags are parsed through a safe allowlist. */
25
+ allowHtmlTags?: boolean;
24
26
  }
25
27
  export interface MarkdownRenderResult {
26
28
  /** Rendered HTML output. */
@@ -7,6 +7,7 @@ import { Marked } from 'marked';
7
7
  import { createEditorMarkdownExtensions, extractPlainTextFromTokens, } from './editor-markdown-extensions.js';
8
8
  import { highlightCode, isLanguageSupported } from './highlight.js';
9
9
  import { isMermaidLanguage } from './mermaid-render.js';
10
+ import { escapeHtml, isExternalLinkHref, sanitizeImageSrc, sanitizeLinkHref, sanitizeMarkdownHtml, } from './safe-html.js';
10
11
  /** Default parsing options for document mode. */
11
12
  const DEFAULT_OPTIONS = {
12
13
  enableHighlight: true,
@@ -17,19 +18,15 @@ const DEFAULT_OPTIONS = {
17
18
  codeViewPreviewLabel: 'Preview',
18
19
  codePreviewHint: '',
19
20
  breaks: true,
21
+ allowHtmlTags: false,
20
22
  };
21
- const SAFE_LINK_HREF_REGEX = /^(?:https?:\/\/|\/|#|mailto:)/i;
22
- const SAFE_IMAGE_SRC_REGEX = /^(?:https?:\/\/|\/|data:image\/)/i;
23
- /**
24
- * Escape HTML entities to prevent raw HTML injection.
25
- */
26
- function escapeHtml(text) {
27
- return text
28
- .replace(/&/g, '&amp;')
29
- .replace(/</g, '&lt;')
30
- .replace(/>/g, '&gt;')
31
- .replace(/"/g, '&quot;')
32
- .replace(/'/g, '&#39;');
23
+ function readHtmlTokenSource(token) {
24
+ if ('raw' in token && typeof token.raw === 'string') {
25
+ return token.raw;
26
+ }
27
+ return 'text' in token && typeof token.text === 'string'
28
+ ? token.text
29
+ : '';
33
30
  }
34
31
  /**
35
32
  * Create a URL-safe heading slug while keeping letters and digits.
@@ -166,16 +163,18 @@ function createRendererObject(options, state) {
166
163
  + `</div>`;
167
164
  },
168
165
  html(token) {
169
- return escapeHtml('text' in token ? token.text : '');
166
+ const source = readHtmlTokenSource(token);
167
+ return options.allowHtmlTags
168
+ ? sanitizeMarkdownHtml(source)
169
+ : escapeHtml(source);
170
170
  },
171
171
  link({ href, title, tokens }) {
172
172
  // text preserves inline markdown within the link label.
173
173
  const text = this.parser.parseInline(tokens);
174
174
  // safeHref strips potentially unsafe protocols.
175
- const safeHref = href && SAFE_LINK_HREF_REGEX.test(href) ? href : '';
175
+ const safeHref = sanitizeLinkHref(href);
176
176
  // isExternal controls whether target/_blank is added.
177
- const isExternal = safeHref
178
- && (safeHref.startsWith('http://') || safeHref.startsWith('https://'));
177
+ const isExternal = safeHref && isExternalLinkHref(safeHref);
179
178
  // attrs is the flattened HTML attribute string.
180
179
  const attrs = [
181
180
  `href="${escapeHtml(safeHref)}"`,
@@ -186,7 +185,7 @@ function createRendererObject(options, state) {
186
185
  },
187
186
  image({ href, title, text }) {
188
187
  // safeSrc prevents dangerous image protocols.
189
- const safeSrc = href && SAFE_IMAGE_SRC_REGEX.test(href) ? href : '';
188
+ const safeSrc = sanitizeImageSrc(href);
190
189
  // attrs is the flattened HTML attribute string.
191
190
  const attrs = [
192
191
  `src="${escapeHtml(safeSrc)}"`,
@@ -136,6 +136,8 @@ export interface AiDocumentEditorProps extends DataAttributes {
136
136
  sourceKind?: MarkdownSourceKind;
137
137
  /** Default language when sourceKind is `code`. */
138
138
  codeLanguage?: string;
139
+ /** Content font size. Numbers are interpreted as px. */
140
+ fontSize?: string | number;
139
141
  /** Custom class for the outer container. */
140
142
  class?: string;
141
143
  /** Whether to show the copy button on code blocks. */
@@ -144,6 +146,8 @@ export interface AiDocumentEditorProps extends DataAttributes {
144
146
  showRunButton?: boolean;
145
147
  /** Whether to show the code/preview toggle on code blocks. */
146
148
  showCodePreviewToggle?: boolean;
149
+ /** Whether to parse raw HTML tags through a safe allowlist. */
150
+ allowHtmlTags?: boolean;
147
151
  /**
148
152
  * 是否启用内置的高风险代码预览。
149
153
  *
@@ -15,6 +15,8 @@ export interface MarkdownParseOptions {
15
15
  showCopyButton?: boolean;
16
16
  /** 是否将换行符转换为 <br>(默认 true,适合 AI 输出) */
17
17
  breaks?: boolean;
18
+ /** 是否按安全白名单解析原始 HTML 标签(默认 false) */
19
+ allowHtmlTags?: boolean;
18
20
  }
19
21
  /**
20
22
  * 将 Markdown 文本解析为 HTML
@@ -8,21 +8,14 @@
8
8
  import { Marked } from 'marked';
9
9
  import { createEditorMarkdownExtensions } from './editor-markdown-extensions.js';
10
10
  import { highlightCode, isLanguageSupported } from './highlight.js';
11
- const SAFE_LINK_HREF_REGEX = /^(?:https?:\/\/|\/|#|mailto:)/i;
12
- const SAFE_IMAGE_SRC_REGEX = /^(?:https?:\/\/|\/|data:image\/)/i;
13
- /**
14
- * 对 HTML 特殊字符进行转义
15
- *
16
- * @param text - 原始文本
17
- * @returns 转义后的安全文本
18
- */
19
- function escapeHtml(text) {
20
- return text
21
- .replace(/&/g, '&amp;')
22
- .replace(/</g, '&lt;')
23
- .replace(/>/g, '&gt;')
24
- .replace(/"/g, '&quot;')
25
- .replace(/'/g, '&#39;');
11
+ import { escapeHtml, isExternalLinkHref, sanitizeImageSrc, sanitizeLinkHref, sanitizeMarkdownHtml, } from './safe-html.js';
12
+ function readHtmlTokenSource(token) {
13
+ if ('raw' in token && typeof token.raw === 'string') {
14
+ return token.raw;
15
+ }
16
+ return 'text' in token && typeof token.text === 'string'
17
+ ? token.text
18
+ : '';
26
19
  }
27
20
  function createTableCellAttrs(cell) {
28
21
  const attrs = [];
@@ -69,14 +62,17 @@ function createRendererObject(options) {
69
62
  },
70
63
  // 原始 HTML 块:转义以防止 XSS
71
64
  html(token) {
72
- return escapeHtml('text' in token ? token.text : '');
65
+ const source = readHtmlTokenSource(token);
66
+ return options.allowHtmlTags
67
+ ? sanitizeMarkdownHtml(source)
68
+ : escapeHtml(source);
73
69
  },
74
70
  // 链接:外部链接自动添加 target="_blank",阻止危险协议
75
71
  link({ href, title, tokens }) {
76
72
  const text = this.parser.parseInline(tokens);
77
73
  // 安全检查:仅允许安全协议
78
- const safeHref = href && SAFE_LINK_HREF_REGEX.test(href) ? href : '';
79
- const isExternal = safeHref && (safeHref.startsWith('http://') || safeHref.startsWith('https://'));
74
+ const safeHref = sanitizeLinkHref(href);
75
+ const isExternal = safeHref && isExternalLinkHref(safeHref);
80
76
  const attrs = [
81
77
  `href="${escapeHtml(safeHref)}"`,
82
78
  title ? `title="${escapeHtml(title)}"` : '',
@@ -87,7 +83,7 @@ function createRendererObject(options) {
87
83
  // 图片:添加 loading="lazy",验证 src 协议
88
84
  image({ href, title, text }) {
89
85
  // 安全检查:仅允许安全协议
90
- const safeSrc = href && SAFE_IMAGE_SRC_REGEX.test(href) ? href : '';
86
+ const safeSrc = sanitizeImageSrc(href);
91
87
  const attrs = [
92
88
  `src="${escapeHtml(safeSrc)}"`,
93
89
  `alt="${escapeHtml(text || '')}"`,
@@ -136,6 +132,7 @@ const DEFAULT_OPTIONS = {
136
132
  enableHighlight: true,
137
133
  showCopyButton: true,
138
134
  breaks: true,
135
+ allowHtmlTags: false,
139
136
  };
140
137
  /** 缓存 Marked 实例,避免重复创建 */
141
138
  let cachedInstance = null;
@@ -144,7 +141,7 @@ let cachedOptionsKey = '';
144
141
  * 获取配置签名,用于实例缓存比较
145
142
  */
146
143
  function getOptionsKey(options) {
147
- return `${options.enableHighlight}-${options.showCopyButton}-${options.breaks}`;
144
+ return `${options.enableHighlight}-${options.showCopyButton}-${options.breaks}-${options.allowHtmlTags}`;
148
145
  }
149
146
  /**
150
147
  * 将 Markdown 文本解析为 HTML
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @h-ai/ui — Safe HTML helpers for Markdown rendering.
3
+ *
4
+ * The Markdown pipeline keeps raw HTML escaped by default.
5
+ * When callers opt in, only a controlled allowlist of safe tags and
6
+ * attributes is reconstructed; unsupported or dangerous markup remains escaped.
7
+ */
8
+ export declare function escapeHtml(text: string): string;
9
+ export declare function sanitizeLinkHref(href: string | null | undefined): string;
10
+ export declare function isExternalLinkHref(href: string): boolean;
11
+ export declare function sanitizeImageSrc(src: string | null | undefined): string;
12
+ /**
13
+ * Sanitize raw HTML fragments embedded inside Markdown.
14
+ * Safe allowlisted tags are reconstructed, everything else remains escaped.
15
+ */
16
+ export declare function sanitizeMarkdownHtml(fragment: string): string;