@h-ai/ui 0.1.0-alpha.30 → 0.1.0-alpha.32
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 +38 -3
- package/dist/components/compounds/Combobox.svelte +59 -20
- package/dist/components/compounds/MessageContainer.svelte +37 -64
- package/dist/components/compounds/Modal.svelte +24 -40
- package/dist/components/compounds/Pagination.svelte +3 -5
- package/dist/components/primitives/Select.svelte +293 -46
- package/dist/components/primitives/Textarea.svelte +28 -15
- package/dist/components/scenes/ai/AiDocumentEditor.svelte +34 -7
- package/dist/components/scenes/ai/MarkdownRenderer.svelte +46 -5
- package/dist/components/scenes/ai/document-parse.d.ts +2 -0
- package/dist/components/scenes/ai/document-parse.js +16 -17
- package/dist/components/scenes/ai/document-types.d.ts +4 -0
- package/dist/components/scenes/ai/markdown-parse.d.ts +2 -0
- package/dist/components/scenes/ai/markdown-parse.js +17 -20
- package/dist/components/scenes/ai/safe-html.d.ts +16 -0
- package/dist/components/scenes/ai/safe-html.js +284 -0
- package/dist/components/scenes/app/FeedbackModal.svelte +3 -2
- package/dist/components/scenes/app/FeedbackModal.svelte.d.ts +1 -0
- package/dist/components/scenes/app/SettingsModal.svelte +4 -2
- package/dist/components/scenes/app/SettingsModal.svelte.d.ts +1 -0
- package/dist/components/scenes/crud/CrudDeleteConfirm.svelte +3 -0
- package/dist/components/scenes/crud/CrudDeleteConfirm.svelte.d.ts +1 -0
- package/dist/components/scenes/crud/CrudDetailDrawer.svelte +4 -1
- package/dist/components/scenes/crud/CrudDetailDrawer.svelte.d.ts +1 -0
- package/dist/components/scenes/crud/CrudDetailPanel.svelte +4 -1
- package/dist/components/scenes/crud/CrudDetailPanel.svelte.d.ts +1 -0
- package/dist/components/scenes/crud/CrudEditPanel.svelte +9 -7
- package/dist/components/scenes/crud/CrudEditPanel.svelte.d.ts +1 -0
- package/dist/components/scenes/crud/CrudFilterBar.svelte +15 -13
- package/dist/components/scenes/crud/CrudFilterBar.svelte.d.ts +1 -0
- package/dist/components/scenes/types.d.ts +4 -0
- package/dist/styles/global.css +5 -0
- package/dist/types.d.ts +5 -5
- package/dist/utils.d.ts +13 -0
- package/dist/utils.js +25 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.replace(/&/g, '&')
|
|
29
|
-
.replace(/</g, '<')
|
|
30
|
-
.replace(/>/g, '>')
|
|
31
|
-
.replace(/"/g, '"')
|
|
32
|
-
.replace(/'/g, ''');
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
*
|
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
function escapeHtml(text) {
|
|
20
|
-
return text
|
|
21
|
-
.replace(/&/g, '&')
|
|
22
|
-
.replace(/</g, '<')
|
|
23
|
-
.replace(/>/g, '>')
|
|
24
|
-
.replace(/"/g, '"')
|
|
25
|
-
.replace(/'/g, ''');
|
|
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
|
-
|
|
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 =
|
|
79
|
-
const isExternal = safeHref && (safeHref
|
|
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 =
|
|
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;
|
|
@@ -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, '&')
|
|
69
|
+
.replace(/</g, '<')
|
|
70
|
+
.replace(/>/g, '>')
|
|
71
|
+
.replace(/"/g, '"')
|
|
72
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|
|
@@ -55,9 +55,10 @@
|
|
|
55
55
|
open?: boolean
|
|
56
56
|
labels?: FeedbackLabels
|
|
57
57
|
onsubmit?: (data: FeedbackData) => Promise<void>
|
|
58
|
+
class?: string
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
let { open = $bindable(false), labels = {}, onsubmit, ...restProps }: Props & DataAttributes = $props()
|
|
61
|
+
let { open = $bindable(false), labels = {}, onsubmit, class: className = '', ...restProps }: Props & DataAttributes = $props()
|
|
61
62
|
|
|
62
63
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
63
64
|
// 文案优先使用传入的 labels,缺省回退到内置消息 uiM(...)
|
|
@@ -109,7 +110,7 @@
|
|
|
109
110
|
}
|
|
110
111
|
</script>
|
|
111
112
|
|
|
112
|
-
<Modal {...dataAttributes} bind:open title={labels.title ?? uiM('feedback_title')}>
|
|
113
|
+
<Modal {...dataAttributes} class={className} bind:open title={labels.title ?? uiM('feedback_title')}>
|
|
113
114
|
<p class='text-base-content/70 mb-4'>{labels.description ?? uiM('feedback_description')}</p>
|
|
114
115
|
|
|
115
116
|
{#if error}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<script lang='ts'>
|
|
13
13
|
import type { DataAttributes } from '../../../types.js'
|
|
14
14
|
import { uiM } from '../../../messages.js'
|
|
15
|
-
import { getDataAttributes } from '../../../utils.js'
|
|
15
|
+
import { cn, getDataAttributes } from '../../../utils.js'
|
|
16
16
|
import BareButton from '../../primitives/BareButton.svelte'
|
|
17
17
|
import IconButton from '../../primitives/IconButton.svelte'
|
|
18
18
|
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
onlanguagechange?: (lang: string) => void
|
|
26
26
|
onthemechange?: (theme: string) => void
|
|
27
27
|
onclose?: () => void
|
|
28
|
+
class?: string
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
let {
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
onlanguagechange,
|
|
46
47
|
onthemechange,
|
|
47
48
|
onclose,
|
|
49
|
+
class: className = '',
|
|
48
50
|
...restProps
|
|
49
51
|
}: Props & DataAttributes = $props()
|
|
50
52
|
|
|
@@ -76,7 +78,7 @@
|
|
|
76
78
|
onclick={handleBackdropClick}
|
|
77
79
|
>
|
|
78
80
|
<div
|
|
79
|
-
class='relative w-full max-w-lg mx-4 transform overflow-hidden rounded-2xl bg-base-100 shadow-xl transition-all'
|
|
81
|
+
class={cn('relative w-full max-w-lg mx-4 transform overflow-hidden rounded-2xl bg-base-100 shadow-xl transition-all', className)}
|
|
80
82
|
role='dialog'
|
|
81
83
|
aria-modal='true'
|
|
82
84
|
>
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
message = '',
|
|
19
19
|
onconfirm,
|
|
20
20
|
oncancel,
|
|
21
|
+
class: className = '',
|
|
21
22
|
...restProps
|
|
22
23
|
}: {
|
|
23
24
|
open?: boolean
|
|
@@ -26,12 +27,14 @@
|
|
|
26
27
|
message?: string
|
|
27
28
|
onconfirm?: () => Promise<void> | void
|
|
28
29
|
oncancel?: () => void
|
|
30
|
+
class?: string
|
|
29
31
|
} & DataAttributes = $props()
|
|
30
32
|
|
|
31
33
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
32
34
|
</script>
|
|
33
35
|
|
|
34
36
|
<Confirm {...dataAttributes}
|
|
37
|
+
class={className}
|
|
35
38
|
bind:open
|
|
36
39
|
title={title || uiM('crud_delete_title')}
|
|
37
40
|
message={message || uiM('crud_delete_message')}
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
onedit,
|
|
42
42
|
onclose,
|
|
43
43
|
detailExtra,
|
|
44
|
+
class: className = '',
|
|
44
45
|
...restProps
|
|
45
46
|
}: {
|
|
46
47
|
open?: boolean
|
|
@@ -57,6 +58,7 @@
|
|
|
57
58
|
onedit?: () => void
|
|
58
59
|
onclose?: () => void
|
|
59
60
|
detailExtra?: Snippet<[Record<string, unknown>]>
|
|
61
|
+
class?: string
|
|
60
62
|
} & DataAttributes = $props()
|
|
61
63
|
|
|
62
64
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
@@ -171,6 +173,7 @@
|
|
|
171
173
|
{#if variant === 'modal'}
|
|
172
174
|
<Modal
|
|
173
175
|
bind:open
|
|
176
|
+
class={className}
|
|
174
177
|
{title}
|
|
175
178
|
size={modalSize}
|
|
176
179
|
width={modalWidth}
|
|
@@ -184,7 +187,7 @@
|
|
|
184
187
|
{/snippet}
|
|
185
188
|
</Modal>
|
|
186
189
|
{:else}
|
|
187
|
-
<Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose}>
|
|
190
|
+
<Drawer bind:open {title} class={className} position='right' {size} width={drawerWidth} onclose={handleClose}>
|
|
188
191
|
{#if item}
|
|
189
192
|
<div class='pb-20'>
|
|
190
193
|
{@render detailBody()}
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
onedit,
|
|
43
43
|
onclose,
|
|
44
44
|
detailExtra,
|
|
45
|
+
class: className = '',
|
|
45
46
|
...restProps
|
|
46
47
|
}: {
|
|
47
48
|
open?: boolean
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
onedit?: () => void
|
|
60
61
|
onclose?: () => void
|
|
61
62
|
detailExtra?: Snippet<[Record<string, unknown>]>
|
|
63
|
+
class?: string
|
|
62
64
|
} & DataAttributes = $props()
|
|
63
65
|
|
|
64
66
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
@@ -179,6 +181,7 @@
|
|
|
179
181
|
{#if variant === 'modal'}
|
|
180
182
|
<Modal
|
|
181
183
|
bind:open
|
|
184
|
+
class={className}
|
|
182
185
|
{title}
|
|
183
186
|
size={modalSize}
|
|
184
187
|
width={modalWidth}
|
|
@@ -192,7 +195,7 @@
|
|
|
192
195
|
{/snippet}
|
|
193
196
|
</Modal>
|
|
194
197
|
{:else}
|
|
195
|
-
<Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose}>
|
|
198
|
+
<Drawer bind:open {title} class={className} position='right' {size} width={drawerWidth} onclose={handleClose}>
|
|
196
199
|
{#if item}
|
|
197
200
|
<div class={drawerBodyClass}>
|
|
198
201
|
{@render detailBody()}
|