@h-ai/ui 0.1.0-alpha.19 → 0.1.0-alpha.21

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
@@ -1,6 +1,6 @@
1
1
  # @h-ai/ui
2
2
 
3
- > 基于 Svelte 5 Runes 的多端 UI 组件库,采用 DaisyUI v5 + Tailwind CSS v4 样式 + Bits UI v2 headless 交互,内置 i18n(zh-CN / en-US),支持 32+ 主题。
3
+ > 基于 Svelte 5 Runes 的多端 UI 组件库,采用 DaisyUI v5 + Tailwind CSS v4 样式 + Bits UI v2 headless 交互,内置 i18n(zh-CN / en-US),内置 15 个精选 DaisyUI 主题。
4
4
 
5
5
  ## 安装
6
6
 
@@ -84,6 +84,8 @@ export default defineConfig({
84
84
  <Button variant="primary">提交</Button>
85
85
  ```
86
86
 
87
+ > 除 `toast`、类型导入与 `Range`(会和 DOM `Range` 构造器冲突)外,其余公开 Svelte 组件都可通过 `@h-ai/ui/auto-import` 自动注入。
88
+
87
89
  ## 组件架构
88
90
 
89
91
  组件按三层划分(primitives → compounds → scenes):
@@ -93,7 +95,9 @@ components/
93
95
  ├── primitives/ # 原子组件(不可再分的基础 UI 单元)
94
96
  ├── compounds/ # 组合组件(由原子组件 + Bits UI headless 组合而成)
95
97
  └── scenes/ # 场景组件(面向具体业务场景的完整 UI 流程)
98
+ ├── ai/ # AI 文档 / 表格 / Markdown 预览
96
99
  ├── app/ # 应用级(设置/反馈/主题/语言切换)
100
+ ├── crud/ # CRUD 页面 / 抽屉 / 过滤 / 删除确认
97
101
  ├── iam/ # 身份认证
98
102
  ├── storage/ # 存储管理
99
103
  └── crypto/ # 加密展示
@@ -101,12 +105,12 @@ components/
101
105
 
102
106
  ## 组件清单
103
107
 
104
- ### 原子组件 Primitives(21 个)
108
+ ### 原子组件 Primitives(20 个)
105
109
 
106
110
  | 组件 | 描述 | 主要属性 |
107
111
  | ---------------- | ------------ | ------------------------------------------------------------------ |
108
112
  | `Button` | 按钮 | `variant`, `size`, `loading`, `disabled`, `outline`, `circle` |
109
- | `IconButton` | 图标按钮 | `icon: string \| Snippet`, `variant`, `size`, `tooltip`, `loading` |
113
+ | `IconButton` | 图标按钮 | `icon: trusted SVG string \| Snippet`, `variant`, `size`, `tooltip`, `loading` |
110
114
  | `BareButton` | 无样式按钮 | `class`, `ariaLabel`, `role`, `tabindex` |
111
115
  | `Input` | 输入框 | `type`, `value`, `size`, `error`, `validationMessage` |
112
116
  | `BareInput` | 无样式输入框 | `type`, `class`, `accept`, `multiple` |
@@ -126,7 +130,7 @@ components/
126
130
  | `Spinner` | 加载动画 | `size`, `variant` |
127
131
  | `Progress` | 进度条 | `value`, `max`, `variant`, `striped`, `animated` |
128
132
 
129
- ### 组合组件 Compounds(25 个)
133
+ ### 组合组件 Compounds(33 个)
130
134
 
131
135
  #### 表单
132
136
 
@@ -171,6 +175,18 @@ components/
171
175
 
172
176
  > 日期值使用 `@internationalized/date` 的 `DateValue` / `CalendarDate` 类型。
173
177
 
178
+ #### 移动端 / 增强交互
179
+
180
+ | 组件 | 描述 | 主要属性 |
181
+ | ---------------- | ------------ | --------------------------------------------------- |
182
+ | `ActionSheet` | 底部操作面板 | `open`, `title`, `items`, `cancelText`, `onselect` |
183
+ | `AppBar` | 顶部导航栏 | `title`, `backHref`, `onback`, `fixed`, `safeArea` |
184
+ | `BottomNav` | 底部导航栏 | `items`, `active`, `centered`, `maxWidth` |
185
+ | `InfiniteScroll` | 无限滚动 | `loading`, `finished`, `threshold`, `onload` |
186
+ | `PullRefresh` | 下拉刷新 | `refreshing`, `threshold`, `onrefresh` |
187
+ | `SafeArea` | 安全区域容器 | `top`, `bottom`, `left`, `right` |
188
+ | `SwipeCell` | 滑动操作单元 | `leftActions`, `rightActions`, `threshold`, `onaction` |
189
+
174
190
  #### 导航
175
191
 
176
192
  | 组件 | 描述 | 主要属性 |
@@ -196,7 +212,7 @@ components/
196
212
  | ------------ | -------- | ------------------------------------------- |
197
213
  | `PageHeader` | 页面头部 | `title`, `description`,支持 `actions` 插槽 |
198
214
 
199
- ### 场景组件 Scenes(20 个)
215
+ ### 场景组件 Scenes(30 个)
200
216
 
201
217
  #### App 应用级(6 个)
202
218
 
@@ -209,7 +225,7 @@ components/
209
225
  | `ThemeSelector` | 完整主题选择面板 | 无需 Props |
210
226
  | `ThemeToggle` | 明/暗主题切换 | 无需 Props |
211
227
 
212
- #### IAM 身份认证(7 个)
228
+ #### IAM 身份认证(8 个)
213
229
 
214
230
  | 组件 | 描述 | 主要属性 |
215
231
  | -------------------- | ---------- | ----------------------------------------------------- |
@@ -219,6 +235,7 @@ components/
219
235
  | `ResetPasswordForm` | 重置密码 | `loading`, `errors`, `showCode`, `onsubmit` |
220
236
  | `ChangePasswordForm` | 修改密码 | `loading`, `errors`, `requireOldPassword`, `onsubmit` |
221
237
  | `PasswordInput` | 密码输入框 | `value`, `showToggle`, `showStrength`, `minLength` |
238
+ | `PermGuard` | 权限守卫 | `permissions`, `mode`, `fallback` |
222
239
  | `UserProfile` | 用户资料 | `user`, `editable`, `fields`, `onsubmit` |
223
240
 
224
241
  #### Storage 存储(4 个)
@@ -240,6 +257,27 @@ components/
240
257
 
241
258
  默认算法与 `@h-ai/crypto` 对齐:加密输入使用 `SM4`,哈希展示使用 `SM3`,签名展示使用 `SM2`。
242
259
 
260
+ #### AI 文档与表格(4 个)
261
+
262
+ | 组件 | 描述 | 主要属性 / 能力 |
263
+ | ------------------------ | ---------------- | ---------------- |
264
+ | `MarkdownRenderer` | Markdown 渲染器 | `content`, `showCopyButton`, `enableHighlight` |
265
+ | `AiDocumentDownloadMenu` | 文档下载菜单 | `actions`, `ondownload` |
266
+ | `AiDocumentEditor` | AI 文档编辑器 | `content`, `showRunButton`, `showCodePreviewToggle`, `allowUnsafeCodePreview` |
267
+ | `AiTableEditor` | AI 表格编辑器 | `columns`, `rows`, `editable`, `ondownload` |
268
+
269
+ > `AiDocumentEditor` 默认只允许 Markdown 内置预览。HTML / JS / CSS 等高风险预览需要显式设置 `allowUnsafeCodePreview`,或通过 `oncoderun` 返回受控的预览结果。
270
+
271
+ #### CRUD 场景(5 个)
272
+
273
+ | 组件 | 描述 | 主要属性 / 能力 |
274
+ | -------------------- | ------------ | ---------------- |
275
+ | `CrudPage` | CRUD 主页面 | `definition`, `navAdapter`, `loading` |
276
+ | `CrudFilterBar` | 过滤工具栏 | `filters`, `query`, `onchange` |
277
+ | `CrudDetailDrawer` | 详情抽屉 | `open`, `item`, `fields` |
278
+ | `CrudEditDrawer` | 编辑抽屉 | `open`, `schema`, `onsubmit` |
279
+ | `CrudDeleteConfirm` | 删除确认框 | `open`, `item`, `onconfirm` |
280
+
243
281
  ## 使用示例
244
282
 
245
283
  ### Toast 通知
@@ -427,25 +465,25 @@ npm install -D @iconify/tailwind4 @iconify-json/tabler
427
465
 
428
466
  ### 主题切换
429
467
 
430
- 使用内置的主题工具函数管理 32 个 DaisyUI 主题:
468
+ 使用内置的主题工具函数管理 15 个精选 DaisyUI 主题:
431
469
 
432
470
  ```ts
433
471
  import {
434
472
  applyTheme, // 应用主题(自动持久化到 localStorage)
435
473
  getCurrentTheme, // 获取当前主题
436
- getThemeInitScript, // 防闪烁脚本(放在 app.html <head> 中)
474
+ getThemeInitScript, // 返回可注入到 HTML shell 的防闪烁脚本文本
437
475
  isDarkTheme, // 检查是否暗色主题
438
476
  THEME_GROUPS, // 按亮色/暗色分组
439
- THEMES, // ThemeInfo[] — 全部 32 个主题元数据
477
+ THEMES, // ThemeInfo[] — 15 个精选主题元数据
440
478
  } from '@h-ai/ui'
441
479
  ```
442
480
 
443
- 在 `app.html` 中添加防闪烁脚本:
481
+ 在 SvelteKit 的 `app.html` 中,请直接写入下面这段脚本内容(与 `getThemeInitScript()` 返回值一致):
444
482
 
445
483
  ```html
446
484
  <head>
447
485
  <script>
448
- {@html getThemeInitScript()}
486
+ (function(){var t='light';try{var s=localStorage.getItem('theme');if(s)t=s}catch{}document.documentElement.setAttribute('data-theme',t)})()
449
487
  </script>
450
488
  </head>
451
489
  ```
package/auto-import.d.ts CHANGED
@@ -27,6 +27,7 @@ declare global {
27
27
  const Input: typeof import('@h-ai/ui')['Input']
28
28
  const Progress: typeof import('@h-ai/ui')['Progress']
29
29
  const Radio: typeof import('@h-ai/ui')['Radio']
30
+ // `Range` 与 DOM Range 构造器冲突,保持显式 import。
30
31
  const Rating: typeof import('@h-ai/ui')['Rating']
31
32
  const Select: typeof import('@h-ai/ui')['Select']
32
33
  const Spinner: typeof import('@h-ai/ui')['Spinner']
@@ -39,7 +40,10 @@ declare global {
39
40
 
40
41
  // compounds
41
42
  const Accordion: typeof import('@h-ai/ui')['Accordion']
43
+ const ActionSheet: typeof import('@h-ai/ui')['ActionSheet']
42
44
  const Alert: typeof import('@h-ai/ui')['Alert']
45
+ const AppBar: typeof import('@h-ai/ui')['AppBar']
46
+ const BottomNav: typeof import('@h-ai/ui')['BottomNav']
43
47
  const Breadcrumb: typeof import('@h-ai/ui')['Breadcrumb']
44
48
  const Calendar: typeof import('@h-ai/ui')['Calendar']
45
49
  const Card: typeof import('@h-ai/ui')['Card']
@@ -52,13 +56,17 @@ declare global {
52
56
  const Empty: typeof import('@h-ai/ui')['Empty']
53
57
  const Form: typeof import('@h-ai/ui')['Form']
54
58
  const FormField: typeof import('@h-ai/ui')['FormField']
59
+ const InfiniteScroll: typeof import('@h-ai/ui')['InfiniteScroll']
55
60
  const Modal: typeof import('@h-ai/ui')['Modal']
56
61
  const PageHeader: typeof import('@h-ai/ui')['PageHeader']
57
62
  const Pagination: typeof import('@h-ai/ui')['Pagination']
58
63
  const Popover: typeof import('@h-ai/ui')['Popover']
64
+ const PullRefresh: typeof import('@h-ai/ui')['PullRefresh']
59
65
  const Result: typeof import('@h-ai/ui')['Result']
66
+ const SafeArea: typeof import('@h-ai/ui')['SafeArea']
60
67
  const Skeleton: typeof import('@h-ai/ui')['Skeleton']
61
68
  const Steps: typeof import('@h-ai/ui')['Steps']
69
+ const SwipeCell: typeof import('@h-ai/ui')['SwipeCell']
62
70
  const Tabs: typeof import('@h-ai/ui')['Tabs']
63
71
  const TagInput: typeof import('@h-ai/ui')['TagInput']
64
72
  const Timeline: typeof import('@h-ai/ui')['Timeline']
@@ -78,6 +86,7 @@ declare global {
78
86
  const ForgotPasswordForm: typeof import('@h-ai/ui')['ForgotPasswordForm']
79
87
  const LoginForm: typeof import('@h-ai/ui')['LoginForm']
80
88
  const PasswordInput: typeof import('@h-ai/ui')['PasswordInput']
89
+ const PermGuard: typeof import('@h-ai/ui')['PermGuard']
81
90
  const RegisterForm: typeof import('@h-ai/ui')['RegisterForm']
82
91
  const ResetPasswordForm: typeof import('@h-ai/ui')['ResetPasswordForm']
83
92
  const UserProfile: typeof import('@h-ai/ui')['UserProfile']
@@ -92,6 +101,19 @@ declare global {
92
101
  const EncryptedInput: typeof import('@h-ai/ui')['EncryptedInput']
93
102
  const HashDisplay: typeof import('@h-ai/ui')['HashDisplay']
94
103
  const SignatureDisplay: typeof import('@h-ai/ui')['SignatureDisplay']
104
+
105
+ // scenes - ai
106
+ const AiDocumentDownloadMenu: typeof import('@h-ai/ui')['AiDocumentDownloadMenu']
107
+ const AiDocumentEditor: typeof import('@h-ai/ui')['AiDocumentEditor']
108
+ const AiTableEditor: typeof import('@h-ai/ui')['AiTableEditor']
109
+ const MarkdownRenderer: typeof import('@h-ai/ui')['MarkdownRenderer']
110
+
111
+ // scenes - crud
112
+ const CrudDeleteConfirm: typeof import('@h-ai/ui')['CrudDeleteConfirm']
113
+ const CrudDetailDrawer: typeof import('@h-ai/ui')['CrudDetailDrawer']
114
+ const CrudEditDrawer: typeof import('@h-ai/ui')['CrudEditDrawer']
115
+ const CrudFilterBar: typeof import('@h-ai/ui')['CrudFilterBar']
116
+ const CrudPage: typeof import('@h-ai/ui')['CrudPage']
95
117
  }
96
118
 
97
119
  export {}
package/auto-import.js CHANGED
@@ -19,7 +19,6 @@ const UI_COMPONENTS = new Set([
19
19
  'Input',
20
20
  'Progress',
21
21
  'Radio',
22
- 'Range',
23
22
  'Rating',
24
23
  'Select',
25
24
  'Spinner',
@@ -29,6 +28,7 @@ const UI_COMPONENTS = new Set([
29
28
  'ToggleCheckbox',
30
29
  'ToggleInput',
31
30
  'ToggleRadio',
31
+ // `Range` 与 DOM Range 构造器重名,保持显式 import 更稳妥。
32
32
  // compounds
33
33
  'Accordion',
34
34
  'ActionSheet',
@@ -89,8 +89,16 @@ const UI_COMPONENTS = new Set([
89
89
  'HashDisplay',
90
90
  'SignatureDisplay',
91
91
  // scenes - ai
92
+ 'AiDocumentDownloadMenu',
92
93
  'AiDocumentEditor',
94
+ 'AiTableEditor',
93
95
  'MarkdownRenderer',
96
+ // scenes - crud
97
+ 'CrudDeleteConfirm',
98
+ 'CrudDetailDrawer',
99
+ 'CrudEditDrawer',
100
+ 'CrudFilterBar',
101
+ 'CrudPage',
94
102
  ])
95
103
 
96
104
  const TAG_REGEX = /<([A-Z][A-Za-z0-9]*)\b/g
@@ -11,6 +11,9 @@
11
11
  import type { IconButtonProps } from '../../types.js'
12
12
  import { cn, getSizeClass, getVariantClass } from '../../utils.js'
13
13
 
14
+ const SAFE_INLINE_SVG_REGEX = /^<svg[\s\S]*<\/svg>$/i
15
+ const UNSAFE_INLINE_SVG_REGEX = /<\s*(?:script|foreignObject|iframe|object|embed|link|meta|img)\b|\son[a-z]+\s*=|(?:href|xlink:href)\s*=\s*['"]\s*(?:javascript:|vbscript:|data:text\/html)/i
16
+
14
17
  const {
15
18
  icon,
16
19
  label = '',
@@ -28,6 +31,16 @@
28
31
  /** 计算最终的 aria-label */
29
32
  const computedAriaLabel = $derived(ariaLabel || label || tooltip)
30
33
 
34
+ /**
35
+ * 字符串图标仅允许受信任的内联 SVG。
36
+ *
37
+ * 这里不尝试支持任意 HTML,避免把图标位变成通用 HTML 注入入口;
38
+ * 若需要复杂内容,请改用 Snippet / children。
39
+ */
40
+ const sanitizedIcon = $derived(
41
+ typeof icon === 'string' ? sanitizeInlineSvg(icon) : '',
42
+ )
43
+
31
44
  const buttonClass = $derived(
32
45
  cn(
33
46
  'btn btn-circle',
@@ -56,6 +69,19 @@
56
69
  }
57
70
  onclick?.(e)
58
71
  }
72
+
73
+ function sanitizeInlineSvg(markup: string): string {
74
+ const trimmed = markup.trim()
75
+ if (!trimmed) {
76
+ return ''
77
+ }
78
+
79
+ if (!SAFE_INLINE_SVG_REGEX.test(trimmed) || UNSAFE_INLINE_SVG_REGEX.test(trimmed)) {
80
+ return ''
81
+ }
82
+
83
+ return trimmed
84
+ }
59
85
  </script>
60
86
 
61
87
  {#if tooltip}
@@ -73,10 +99,10 @@
73
99
  {@render children()}
74
100
  {:else if typeof icon === 'function'}
75
101
  {@render icon()}
76
- {:else if icon}
102
+ {:else if sanitizedIcon}
77
103
  <span class={iconSize}>
78
- <!-- eslint-disable-next-line svelte/no-at-html-tags -- 受控 SVG 图标渲染 -->
79
- {@html icon}
104
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- 字符串图标只渲染经过白名单校验的内联 SVG -->
105
+ {@html sanitizedIcon}
80
106
  </span>
81
107
  {/if}
82
108
  </button>
@@ -95,10 +121,10 @@
95
121
  {@render children()}
96
122
  {:else if typeof icon === 'function'}
97
123
  {@render icon()}
98
- {:else if icon}
124
+ {:else if sanitizedIcon}
99
125
  <span class={iconSize}>
100
- <!-- eslint-disable-next-line svelte/no-at-html-tags -- 受控 SVG 图标渲染 -->
101
- {@html icon}
126
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- 字符串图标只渲染经过白名单校验的内联 SVG -->
127
+ {@html sanitizedIcon}
102
128
  </span>
103
129
  {/if}
104
130
  </button>
@@ -25,9 +25,14 @@
25
25
  MarkdownTextAlignKind,
26
26
  } from './document-types.js'
27
27
  import { tick } from 'svelte'
28
+ import { writeTextToClipboard } from '../../../internal/browser-safety.js'
28
29
  import { uiM } from '../../../messages.js'
29
30
  import { cn } from '../../../utils.js'
30
31
  import AiDocumentDownloadMenu from './AiDocumentDownloadMenu.svelte'
32
+ import {
33
+ createBuiltInCodePreview,
34
+ resolvePreviewSandbox,
35
+ } from './code-preview.js'
31
36
  import { resolveDocumentMarkdownContent } from './document-download.js'
32
37
  import { renderMarkdownDocument } from './document-parse.js'
33
38
  import { parseMarkdown } from './markdown-parse.js'
@@ -241,6 +246,8 @@
241
246
  showRunButton = false,
242
247
  // 是否在代码块头部显示“代码/预览”切换。
243
248
  showCodePreviewToggle = false,
249
+ // 是否显式允许内置 HTML / JS / CSS 预览执行高风险内容。
250
+ allowUnsafeCodePreview = false,
244
251
  // 代码预览切换模式下展示在语言标签旁的提示文案。
245
252
  codePreviewHint = '',
246
253
  // 是否启用语法高亮。
@@ -628,17 +635,9 @@
628
635
  ]
629
636
  }
630
637
 
631
- const HTML_DOCUMENT_PATTERN = /<!doctype html>|<html[\s>]|<body[\s>]|<div[\s>]|<main[\s>]/i
632
-
633
638
  async function copyRawContent(): Promise<boolean> {
634
- try {
635
- await navigator.clipboard.writeText(content)
636
- return true
637
- }
638
- catch {
639
- // clipboard API 可能被安全策略禁用;保持静默避免打断阅读。
640
- return false
641
- }
639
+ // 剪贴板能力在受限 WebView / 非安全上下文中可能缺失;这里统一静默降级。
640
+ return writeTextToClipboard(content)
642
641
  }
643
642
 
644
643
  function triggerDocumentCopiedFeedback(): void {
@@ -682,137 +681,15 @@
682
681
  return
683
682
  }
684
683
 
685
- try {
686
- await navigator.clipboard.writeText(codeEl.textContent ?? '')
684
+ if (await writeTextToClipboard(codeEl.textContent ?? '')) {
687
685
  updateCopyButtonState(button)
688
686
  }
689
- catch {
690
- // clipboard API 可能被安全策略禁用;不抛错以免阻断其他交互。
691
- }
692
687
  }
693
688
 
694
689
  function lookupCodeBlock(blockId: string): MarkdownCodeBlockItem | undefined {
695
690
  return renderResult.codeBlocks.find(item => item.id === blockId)
696
691
  }
697
692
 
698
- function looksLikeHtml(code: string): boolean {
699
- return HTML_DOCUMENT_PATTERN.test(code)
700
- }
701
-
702
- function buildJavaScriptPreview(code: string): string {
703
- const safeCode = code.replace(/<\/script/gi, '<\\/script')
704
-
705
- return `<!doctype html>
706
- <html>
707
- <head>
708
- <meta charset="utf-8" />
709
- <meta name="viewport" content="width=device-width, initial-scale=1" />
710
- <style>
711
- body {
712
- margin: 0;
713
- padding: 16px;
714
- font: 14px/1.6 system-ui, sans-serif;
715
- color: #1f2937;
716
- background: #f8fafc;
717
- }
718
-
719
- #app {
720
- min-height: 48px;
721
- }
722
- </style>
723
- </head>
724
- <body>
725
- <div id="app"></div>
726
- <script>
727
- ${safeCode}
728
- <\/script>
729
- </body>
730
- </html>`
731
- }
732
-
733
- function buildCssPreview(code: string): string {
734
- return `<!doctype html>
735
- <html>
736
- <head>
737
- <meta charset="utf-8" />
738
- <meta name="viewport" content="width=device-width, initial-scale=1" />
739
- <style>${code}</style>
740
- <style>
741
- body {
742
- margin: 0;
743
- padding: 20px;
744
- font: 14px/1.6 system-ui, sans-serif;
745
- background: #f8fafc;
746
- }
747
-
748
- .preview-card {
749
- border-radius: 16px;
750
- padding: 24px;
751
- border: 1px dashed #cbd5e1;
752
- background: white;
753
- }
754
- </style>
755
- </head>
756
- <body>
757
- <div class="preview-card">
758
- <h3>CSS Preview</h3>
759
- <p>This surface is provided so the stylesheet can render immediately.</p>
760
- <button type="button">Action</button>
761
- </div>
762
- </body>
763
- </html>`
764
- }
765
-
766
- /**
767
- * 没有外部运行器时,组件只为可直接在浏览器里安全预览的语言提供兜底能力。
768
- * 其余语言返回 undefined,让上层明确知道需要后端或沙箱参与执行。
769
- */
770
- function createBuiltInCodePreview(
771
- request: MarkdownCodeRunRequest,
772
- ): MarkdownCodeRunResult | undefined {
773
- const language = request.language?.trim().toLocaleLowerCase()
774
-
775
- if (
776
- language === 'html'
777
- || language === 'htm'
778
- || language === 'xml'
779
- || language === 'svg'
780
- || (!language && looksLikeHtml(request.code))
781
- ) {
782
- return {
783
- kind: 'html',
784
- title: uiM('markdown_run_preview'),
785
- content: request.code,
786
- }
787
- }
788
-
789
- if (language === 'javascript' || language === 'js' || language === 'mjs') {
790
- return {
791
- kind: 'html',
792
- title: uiM('markdown_run_preview'),
793
- content: buildJavaScriptPreview(request.code),
794
- }
795
- }
796
-
797
- if (language === 'css') {
798
- return {
799
- kind: 'html',
800
- title: uiM('markdown_run_preview'),
801
- content: buildCssPreview(request.code),
802
- }
803
- }
804
-
805
- if (language === 'markdown' || language === 'md') {
806
- return {
807
- kind: 'markdown',
808
- title: uiM('markdown_run_preview'),
809
- content: request.code,
810
- }
811
- }
812
-
813
- return undefined
814
- }
815
-
816
693
  function formatPreviewLanguageLabel(language: string | undefined): string | null {
817
694
  const normalized = language?.trim().toLocaleLowerCase()
818
695
  if (!normalized) {
@@ -867,8 +744,12 @@ ${safeCode}
867
744
  }
868
745
 
869
746
  try {
870
- const preview = await (oncoderun?.(request)
871
- ?? createBuiltInCodePreview(request))
747
+ const preview = oncoderun
748
+ ? await oncoderun(request)
749
+ : createBuiltInCodePreview(request, {
750
+ allowUnsafeCodePreview,
751
+ previewTitle: uiM('markdown_run_preview'),
752
+ })
872
753
 
873
754
  if (!preview) {
874
755
  codePreviews = {
@@ -1029,7 +910,10 @@ ${safeCode}
1029
910
  : ''
1030
911
 
1031
912
  if (result.kind === 'html') {
1032
- host.innerHTML = `<div class="hai-md-preview-card"><div class="hai-md-preview-head">${previewTitle}</div>${previewDesc}<iframe class="hai-md-preview-frame" sandbox="allow-scripts" srcdoc="${escapePreviewAttribute(result.content)}" title="${previewTitle}"></iframe></div>`
913
+ const previewSandbox = escapePreviewAttribute(
914
+ resolvePreviewSandbox(result.allowScripts),
915
+ )
916
+ host.innerHTML = `<div class="hai-md-preview-card"><div class="hai-md-preview-head">${previewTitle}</div>${previewDesc}<iframe class="hai-md-preview-frame" sandbox="${previewSandbox}" srcdoc="${escapePreviewAttribute(result.content)}" title="${previewTitle}"></iframe></div>`
1033
917
  return
1034
918
  }
1035
919
 
@@ -17,6 +17,7 @@
17
17
  -->
18
18
  <script lang='ts'>
19
19
  import type { MarkdownRendererProps } from '../types.js'
20
+ import { writeTextToClipboard } from '../../../internal/browser-safety.js'
20
21
  import { cn } from '../../../utils.js'
21
22
  import { parseMarkdown } from './markdown-parse.js'
22
23
 
@@ -36,7 +37,7 @@
36
37
  /**
37
38
  * 事件代理:处理代码块复制按钮点击
38
39
  */
39
- function handleClick(e: MouseEvent) {
40
+ async function handleClick(e: MouseEvent) {
40
41
  const target = e.target as HTMLElement
41
42
  const btn = target.closest('[data-copy-code]') as HTMLButtonElement | null
42
43
  if (!btn)
@@ -48,15 +49,16 @@
48
49
  return
49
50
 
50
51
  const text = codeEl.textContent ?? ''
51
- navigator.clipboard.writeText(text).then(() => {
52
- btn.classList.add('hai-md-copied')
53
- btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`
54
-
55
- setTimeout(() => {
56
- btn.classList.remove('hai-md-copied')
57
- btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
58
- }, 2000)
59
- }).catch(() => { /* clipboard API 可能被安全策略禁用 */ })
52
+ if (!await writeTextToClipboard(text))
53
+ return
54
+
55
+ btn.classList.add('hai-md-copied')
56
+ btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`
57
+
58
+ window.setTimeout(() => {
59
+ btn.classList.remove('hai-md-copied')
60
+ btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
61
+ }, 2000)
60
62
  }
61
63
  </script>
62
64
 
@@ -0,0 +1,18 @@
1
+ import type { MarkdownCodeRunRequest, MarkdownCodeRunResult } from './document-types.js';
2
+ export interface BuiltInCodePreviewOptions {
3
+ /**
4
+ * 是否允许执行内置的不安全代码预览。
5
+ *
6
+ * 默认仅允许 Markdown 预览;HTML / JS / CSS 预览需要显式 opt-in,
7
+ * 否则组件会提示调用方提供自定义 oncoderun 或自行开启风险开关。
8
+ */
9
+ allowUnsafeCodePreview?: boolean;
10
+ previewTitle: string;
11
+ }
12
+ /**
13
+ * 根据运行结果构造 iframe sandbox 值。
14
+ *
15
+ * 默认不允许脚本执行;只有显式声明 `allowScripts` 时才放开 `allow-scripts`。
16
+ */
17
+ export declare function resolvePreviewSandbox(allowScripts: boolean | undefined): string;
18
+ export declare function createBuiltInCodePreview(request: MarkdownCodeRunRequest, options: BuiltInCodePreviewOptions): MarkdownCodeRunResult | undefined;