@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.
@@ -0,0 +1,284 @@
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
+ const SAFE_LINK_HREF_REGEX = /^(?:https?:\/\/|\/|#|mailto:)/i;
9
+ const SAFE_IMAGE_SRC_REGEX = /^(?:https?:\/\/|\/|data:image\/)/i;
10
+ const SAFE_ALIGN_VALUE_REGEX = /^(?:left|center|right|justify)$/i;
11
+ const SAFE_TABLE_SPAN_REGEX = /^[1-9]\d{0,2}$/;
12
+ const HTML_ATTR_REGEX = /([^\s"'=<>`/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
13
+ const ALLOWED_HTML_TAGS = new Set([
14
+ 'a',
15
+ 'b',
16
+ 'blockquote',
17
+ 'br',
18
+ 'code',
19
+ 'del',
20
+ 'div',
21
+ 'em',
22
+ 'h1',
23
+ 'h2',
24
+ 'h3',
25
+ 'h4',
26
+ 'h5',
27
+ 'h6',
28
+ 'hr',
29
+ 'i',
30
+ 'img',
31
+ 'kbd',
32
+ 'li',
33
+ 'mark',
34
+ 'ol',
35
+ 'p',
36
+ 'pre',
37
+ 's',
38
+ 'span',
39
+ 'strong',
40
+ 'sub',
41
+ 'sup',
42
+ 'table',
43
+ 'tbody',
44
+ 'td',
45
+ 'th',
46
+ 'thead',
47
+ 'tr',
48
+ 'u',
49
+ 'ul',
50
+ ]);
51
+ const VOID_HTML_TAGS = new Set(['br', 'hr', 'img']);
52
+ const ALIGNABLE_HTML_TAGS = new Set([
53
+ 'blockquote',
54
+ 'div',
55
+ 'h1',
56
+ 'h2',
57
+ 'h3',
58
+ 'h4',
59
+ 'h5',
60
+ 'h6',
61
+ 'li',
62
+ 'p',
63
+ 'td',
64
+ 'th',
65
+ ]);
66
+ export function escapeHtml(text) {
67
+ return text
68
+ .replace(/&/g, '&amp;')
69
+ .replace(/</g, '&lt;')
70
+ .replace(/>/g, '&gt;')
71
+ .replace(/"/g, '&quot;')
72
+ .replace(/'/g, '&#39;');
73
+ }
74
+ export function sanitizeLinkHref(href) {
75
+ const normalized = href?.trim() ?? '';
76
+ return SAFE_LINK_HREF_REGEX.test(normalized) ? normalized : '';
77
+ }
78
+ export function isExternalLinkHref(href) {
79
+ return /^https?:\/\//i.test(href);
80
+ }
81
+ export function sanitizeImageSrc(src) {
82
+ const normalized = src?.trim() ?? '';
83
+ return SAFE_IMAGE_SRC_REGEX.test(normalized) ? normalized : '';
84
+ }
85
+ function sanitizeAlignValue(value) {
86
+ const normalized = value?.trim().toLowerCase();
87
+ return normalized && SAFE_ALIGN_VALUE_REGEX.test(normalized)
88
+ ? normalized
89
+ : undefined;
90
+ }
91
+ function parseHtmlAttributes(rawAttributes) {
92
+ const attributes = [];
93
+ for (const match of rawAttributes.matchAll(HTML_ATTR_REGEX)) {
94
+ const [, rawName, quotedDouble, quotedSingle, unquoted] = match;
95
+ const attrName = rawName?.trim().toLowerCase();
96
+ if (!attrName) {
97
+ continue;
98
+ }
99
+ const attrValue = quotedDouble ?? quotedSingle ?? unquoted ?? '';
100
+ attributes.push([attrName, attrValue]);
101
+ }
102
+ return attributes;
103
+ }
104
+ function isHtmlTagNameChar(char) {
105
+ return typeof char === 'string' && /^[\w:-]$/.test(char);
106
+ }
107
+ function skipWhitespace(source, startIndex) {
108
+ let cursor = startIndex;
109
+ while (cursor < source.length && /\s/.test(source[cursor] ?? '')) {
110
+ cursor += 1;
111
+ }
112
+ return cursor;
113
+ }
114
+ function findHtmlTagEnd(source, startIndex) {
115
+ let quote = null;
116
+ for (let cursor = startIndex; cursor < source.length; cursor += 1) {
117
+ const char = source[cursor];
118
+ if (quote) {
119
+ if (char === quote) {
120
+ quote = null;
121
+ }
122
+ continue;
123
+ }
124
+ if (char === '"' || char === '\'') {
125
+ quote = char;
126
+ continue;
127
+ }
128
+ if (char === '>') {
129
+ return cursor;
130
+ }
131
+ }
132
+ return -1;
133
+ }
134
+ function readNextHtmlTag(source, startIndex) {
135
+ let cursor = source.indexOf('<', startIndex);
136
+ while (cursor >= 0) {
137
+ const nextChar = source[cursor + 1];
138
+ if (!nextChar || (nextChar !== '/' && !/[a-z]/i.test(nextChar))) {
139
+ cursor = source.indexOf('<', cursor + 1);
140
+ continue;
141
+ }
142
+ const tagEnd = findHtmlTagEnd(source, cursor + 1);
143
+ if (tagEnd < 0) {
144
+ return null;
145
+ }
146
+ return {
147
+ rawTag: source.slice(cursor, tagEnd + 1),
148
+ start: cursor,
149
+ end: tagEnd + 1,
150
+ };
151
+ }
152
+ return null;
153
+ }
154
+ function readSanitizedAttributes(tagName, rawAttributes) {
155
+ const sanitized = {};
156
+ for (const [attrName, attrValue] of parseHtmlAttributes(rawAttributes)) {
157
+ if (tagName === 'a') {
158
+ if (attrName === 'href') {
159
+ sanitized.href = sanitizeLinkHref(attrValue);
160
+ }
161
+ if (attrName === 'title' && attrValue.trim()) {
162
+ sanitized.title = attrValue.trim();
163
+ }
164
+ continue;
165
+ }
166
+ if (tagName === 'img') {
167
+ if (attrName === 'src') {
168
+ sanitized.src = sanitizeImageSrc(attrValue);
169
+ }
170
+ if (attrName === 'alt') {
171
+ sanitized.alt = attrValue;
172
+ }
173
+ if (attrName === 'title' && attrValue.trim()) {
174
+ sanitized.title = attrValue.trim();
175
+ }
176
+ continue;
177
+ }
178
+ if (ALIGNABLE_HTML_TAGS.has(tagName) && attrName === 'align') {
179
+ sanitized.align = sanitizeAlignValue(attrValue);
180
+ continue;
181
+ }
182
+ if ((tagName === 'td' || tagName === 'th') && attrName === 'colspan') {
183
+ const normalized = attrValue.trim();
184
+ if (SAFE_TABLE_SPAN_REGEX.test(normalized)) {
185
+ sanitized.colspan = normalized;
186
+ }
187
+ continue;
188
+ }
189
+ if ((tagName === 'td' || tagName === 'th') && attrName === 'rowspan') {
190
+ const normalized = attrValue.trim();
191
+ if (SAFE_TABLE_SPAN_REGEX.test(normalized)) {
192
+ sanitized.rowspan = normalized;
193
+ }
194
+ }
195
+ }
196
+ return sanitized;
197
+ }
198
+ function sanitizeHtmlTag(rawTag) {
199
+ if (!rawTag.startsWith('<') || !rawTag.endsWith('>')) {
200
+ return escapeHtml(rawTag);
201
+ }
202
+ let cursor = 1;
203
+ cursor = skipWhitespace(rawTag, cursor);
204
+ let isClosingTag = false;
205
+ if (rawTag[cursor] === '/') {
206
+ isClosingTag = true;
207
+ cursor += 1;
208
+ cursor = skipWhitespace(rawTag, cursor);
209
+ }
210
+ const tagNameStart = cursor;
211
+ while (isHtmlTagNameChar(rawTag[cursor])) {
212
+ cursor += 1;
213
+ }
214
+ const tagName = rawTag.slice(tagNameStart, cursor).toLowerCase();
215
+ if (!tagName) {
216
+ return escapeHtml(rawTag);
217
+ }
218
+ let rawAttributes = rawTag.slice(cursor, -1);
219
+ let selfClosing = false;
220
+ const trimmedAttributes = rawAttributes.trimEnd();
221
+ if (trimmedAttributes.endsWith('/')) {
222
+ selfClosing = true;
223
+ rawAttributes = trimmedAttributes.slice(0, -1);
224
+ }
225
+ if (!ALLOWED_HTML_TAGS.has(tagName)) {
226
+ return escapeHtml(rawTag);
227
+ }
228
+ if (isClosingTag) {
229
+ return VOID_HTML_TAGS.has(tagName) ? '' : `</${tagName}>`;
230
+ }
231
+ const attributes = readSanitizedAttributes(tagName, rawAttributes);
232
+ const renderedAttributes = [];
233
+ if (attributes.href !== undefined) {
234
+ renderedAttributes.push(`href="${escapeHtml(attributes.href)}"`);
235
+ if (attributes.href && isExternalLinkHref(attributes.href)) {
236
+ renderedAttributes.push('target="_blank"');
237
+ renderedAttributes.push('rel="noopener noreferrer"');
238
+ }
239
+ }
240
+ if (attributes.title) {
241
+ renderedAttributes.push(`title="${escapeHtml(attributes.title)}"`);
242
+ }
243
+ if (attributes.src !== undefined) {
244
+ renderedAttributes.push(`src="${escapeHtml(attributes.src)}"`);
245
+ renderedAttributes.push('loading="lazy"');
246
+ renderedAttributes.push('class="hai-md-img"');
247
+ }
248
+ if (attributes.alt !== undefined) {
249
+ renderedAttributes.push(`alt="${escapeHtml(attributes.alt)}"`);
250
+ }
251
+ if (attributes.align) {
252
+ renderedAttributes.push(`style="text-align:${escapeHtml(attributes.align)}"`);
253
+ }
254
+ if (attributes.colspan) {
255
+ renderedAttributes.push(`colspan="${escapeHtml(attributes.colspan)}"`);
256
+ }
257
+ if (attributes.rowspan) {
258
+ renderedAttributes.push(`rowspan="${escapeHtml(attributes.rowspan)}"`);
259
+ }
260
+ const attributeText = renderedAttributes.length > 0
261
+ ? ` ${renderedAttributes.join(' ')}`
262
+ : '';
263
+ return VOID_HTML_TAGS.has(tagName) || selfClosing
264
+ ? `<${tagName}${attributeText} />`
265
+ : `<${tagName}${attributeText}>`;
266
+ }
267
+ /**
268
+ * Sanitize raw HTML fragments embedded inside Markdown.
269
+ * Safe allowlisted tags are reconstructed, everything else remains escaped.
270
+ */
271
+ export function sanitizeMarkdownHtml(fragment) {
272
+ if (!fragment) {
273
+ return '';
274
+ }
275
+ let sanitized = '';
276
+ let lastIndex = 0;
277
+ for (let match = readNextHtmlTag(fragment, lastIndex); match; match = readNextHtmlTag(fragment, lastIndex)) {
278
+ sanitized += escapeHtml(fragment.slice(lastIndex, match.start));
279
+ sanitized += sanitizeHtmlTag(match.rawTag);
280
+ lastIndex = match.end;
281
+ }
282
+ sanitized += escapeHtml(fragment.slice(lastIndex));
283
+ return sanitized;
284
+ }
@@ -544,6 +544,8 @@ export interface HashDisplayProps extends DataAttributes {
544
544
  export interface MarkdownRendererProps extends DataAttributes {
545
545
  /** Markdown 源文本 */
546
546
  content?: string;
547
+ /** 文本字号,支持 number(按 px)或任意 CSS 长度字符串 */
548
+ fontSize?: string | number;
547
549
  /** 自定义类名 */
548
550
  class?: string;
549
551
  /** 是否显示代码块复制按钮(默认 true) */
@@ -552,6 +554,8 @@ export interface MarkdownRendererProps extends DataAttributes {
552
554
  enableHighlight?: boolean;
553
555
  /** 是否将换行符转换为 <br>(默认 true,适合 AI 输出) */
554
556
  breaks?: boolean;
557
+ /** 是否按安全白名单解析原始 HTML 标签(默认 false) */
558
+ allowHtmlTags?: boolean;
555
559
  }
556
560
  /**
557
561
  * 签名展示属性
@@ -89,6 +89,11 @@ input::-webkit-credentials-auto-fill-button {
89
89
  color: oklch(0.55 0 0);
90
90
  }
91
91
 
92
+ /* ─── DaisyUI 组件微调:fieldset-legend 间距修正 ─── */
93
+ .fieldset-legend {
94
+ justify-content: flex-start !important;
95
+ }
96
+
92
97
  .modal-box {
93
98
  border-radius: 0.75rem;
94
99
  }
package/dist/types.d.ts CHANGED
@@ -286,7 +286,7 @@ export interface SelectOption<T = string> {
286
286
  export interface SelectProps<T = string> extends DataAttributes {
287
287
  /** 值 */
288
288
  value?: T;
289
- /** 选项(使用 options 或 children 二选一) */
289
+ /** 选项 */
290
290
  options?: SelectOption<T>[];
291
291
  /** 占位符 */
292
292
  placeholder?: string;
@@ -296,18 +296,18 @@ export interface SelectProps<T = string> extends DataAttributes {
296
296
  disabled?: boolean;
297
297
  /** 是否必填 */
298
298
  required?: boolean;
299
+ /** 是否可清空 */
300
+ clearable?: boolean;
301
+ /** 是否可筛选(启用后可在输入框内输入过滤选项) */
302
+ filterable?: boolean;
299
303
  /** 错误消息 */
300
304
  error?: string;
301
- /** 自定义验证消息(用于覆盖浏览器原生验证提示) */
302
- validationMessage?: string;
303
305
  /** 自定义类名 */
304
306
  class?: string;
305
307
  /** 元素 ID */
306
308
  id?: string;
307
309
  /** 变化事件 */
308
310
  onchange?: (value: T) => void;
309
- /** 子元素(原生 option 元素) */
310
- children?: Snippet;
311
311
  }
312
312
  /**
313
313
  * 复选框属性
package/dist/utils.js CHANGED
@@ -36,7 +36,7 @@ export function getDataAttributes(props) {
36
36
  */
37
37
  const VARIANT_MAPS = {
38
38
  btn: {
39
- default: 'btn-neutral',
39
+ default: '',
40
40
  primary: 'btn-primary',
41
41
  secondary: 'btn-secondary',
42
42
  success: 'btn-success',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@h-ai/ui",
3
3
  "type": "module",
4
- "version": "0.1.0-alpha.30",
4
+ "version": "0.1.0-alpha.31",
5
5
  "description": "Hai Framework UI component library for Svelte 5 Runes.",
6
6
  "license": "Apache-2.0",
7
7
  "svelte": "./dist/index.js",
@@ -51,7 +51,7 @@
51
51
  "mermaid": "^11.12.0",
52
52
  "shiki": "^4.0.2",
53
53
  "tailwind-merge": "^3.5.0",
54
- "@h-ai/core": "0.1.0-alpha.30"
54
+ "@h-ai/core": "0.1.0-alpha.31"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@sveltejs/package": "^2.5.7",