@open-mercato/shared 0.6.1-develop.3246.1.dbef9d7392 → 0.6.1-develop.3260.1.dc719cac84
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/dist/lib/html/sanitizeRichText.js +60 -4
- package/dist/lib/html/sanitizeRichText.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/ai/llm-provider.ts +8 -7
- package/src/lib/html/__tests__/sanitizeRichText.test.ts +30 -2
- package/src/lib/html/sanitizeRichText.ts +53 -2
|
@@ -13,7 +13,11 @@ const RICH_TEXT_ALLOWED_TAGS = [
|
|
|
13
13
|
"h4",
|
|
14
14
|
"h5",
|
|
15
15
|
"h6",
|
|
16
|
+
"hr",
|
|
16
17
|
"i",
|
|
18
|
+
"img",
|
|
19
|
+
"input",
|
|
20
|
+
"label",
|
|
17
21
|
"li",
|
|
18
22
|
"ol",
|
|
19
23
|
"p",
|
|
@@ -21,6 +25,12 @@ const RICH_TEXT_ALLOWED_TAGS = [
|
|
|
21
25
|
"s",
|
|
22
26
|
"span",
|
|
23
27
|
"strong",
|
|
28
|
+
"table",
|
|
29
|
+
"tbody",
|
|
30
|
+
"td",
|
|
31
|
+
"th",
|
|
32
|
+
"thead",
|
|
33
|
+
"tr",
|
|
24
34
|
"u",
|
|
25
35
|
"ul"
|
|
26
36
|
];
|
|
@@ -29,7 +39,6 @@ const RICH_TEXT_DROP_WITH_CONTENT_TAGS = [
|
|
|
29
39
|
"embed",
|
|
30
40
|
"form",
|
|
31
41
|
"iframe",
|
|
32
|
-
"input",
|
|
33
42
|
"link",
|
|
34
43
|
"math",
|
|
35
44
|
"meta",
|
|
@@ -50,12 +59,59 @@ function sanitizeRichTextHtml(value) {
|
|
|
50
59
|
return sanitizeHtml(value, {
|
|
51
60
|
allowedTags: [...RICH_TEXT_ALLOWED_TAGS],
|
|
52
61
|
allowedAttributes: {
|
|
53
|
-
a: ["href", "title"]
|
|
62
|
+
a: ["href", "title"],
|
|
63
|
+
img: ["src", "alt", "width", "height"],
|
|
64
|
+
table: ["style"],
|
|
65
|
+
tr: [],
|
|
66
|
+
td: ["colspan", "rowspan", "style"],
|
|
67
|
+
th: ["colspan", "rowspan", "style"],
|
|
68
|
+
input: ["type", "checked", "disabled"],
|
|
69
|
+
label: [],
|
|
70
|
+
span: ["style"],
|
|
71
|
+
p: ["style"],
|
|
72
|
+
div: ["style"],
|
|
73
|
+
ul: ["data-task-list", "style"],
|
|
74
|
+
ol: ["style"],
|
|
75
|
+
li: ["style"],
|
|
76
|
+
h1: ["style"],
|
|
77
|
+
h2: ["style"],
|
|
78
|
+
h3: ["style"],
|
|
79
|
+
h4: ["style"],
|
|
80
|
+
h5: ["style"],
|
|
81
|
+
h6: ["style"]
|
|
54
82
|
},
|
|
55
83
|
allowedSchemes: ["http", "https", "mailto", "tel"],
|
|
56
|
-
allowedSchemesAppliedToAttributes: ["href"],
|
|
84
|
+
allowedSchemesAppliedToAttributes: ["href", "src"],
|
|
57
85
|
allowProtocolRelative: false,
|
|
58
|
-
nonTextTags: [...RICH_TEXT_DROP_WITH_CONTENT_TAGS]
|
|
86
|
+
nonTextTags: [...RICH_TEXT_DROP_WITH_CONTENT_TAGS],
|
|
87
|
+
allowedStyles: {
|
|
88
|
+
"*": {
|
|
89
|
+
"color": [/^#(?:[0-9a-f]{3}){1,2}$/i, /^rgb\([\d,\s]+\)$/i, /^rgba\([\d.,\s]+\)$/i],
|
|
90
|
+
"background-color": [/^#(?:[0-9a-f]{3}){1,2}$/i, /^rgb\([\d,\s]+\)$/i, /^rgba\([\d.,\s]+\)$/i],
|
|
91
|
+
"font-size": [/^\d+(?:\.\d+)?(?:px|em|rem|%)$/],
|
|
92
|
+
"font-weight": [/^(?:normal|bold|\d{3})$/],
|
|
93
|
+
"font-style": [/^(?:normal|italic|oblique)$/],
|
|
94
|
+
"text-align": [/^(?:left|right|center|justify)$/],
|
|
95
|
+
"text-decoration": [/^(?:none|underline|line-through)$/],
|
|
96
|
+
"width": [/^\d+(?:\.\d+)?(?:px|em|rem|%)$/],
|
|
97
|
+
"height": [/^\d+(?:\.\d+)?(?:px|em|rem|%)$/],
|
|
98
|
+
"border": [/^[\w\-:#.,\s()]+$/],
|
|
99
|
+
"border-collapse": [/^(?:collapse|separate)$/],
|
|
100
|
+
"padding": [/^[\d.\spxemr%]+$/],
|
|
101
|
+
"list-style": [/^(?:none|disc|circle|square|decimal)$/],
|
|
102
|
+
"padding-left": [/^\d+(?:\.\d+)?(?:px|em|rem|%)?$/]
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
transformTags: {
|
|
106
|
+
// Restrict <input> to checkbox toggles only — strip any other `type` so
|
|
107
|
+
// text/file/etc. inputs don't slip through the rich text channel.
|
|
108
|
+
"input": (tagName, attribs) => {
|
|
109
|
+
if (attribs.type === "checkbox") {
|
|
110
|
+
return { tagName: "input", attribs };
|
|
111
|
+
}
|
|
112
|
+
return { tagName: "", attribs: {}, text: "" };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
59
115
|
});
|
|
60
116
|
}
|
|
61
117
|
function sanitizeRichTextHref(value) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/html/sanitizeRichText.ts"],
|
|
4
|
-
"sourcesContent": ["import sanitizeHtml from 'sanitize-html'\n\nexport const RICH_TEXT_ALLOWED_TAGS = [\n 'a',\n 'b',\n 'blockquote',\n 'br',\n 'code',\n 'div',\n 'em',\n 'h1',\n 'h2',\n 'h3',\n 'h4',\n 'h5',\n 'h6',\n 'i',\n 'li',\n 'ol',\n 'p',\n 'pre',\n 's',\n 'span',\n 'strong',\n 'u',\n 'ul',\n] as const\n\nconst RICH_TEXT_DROP_WITH_CONTENT_TAGS = [\n 'button',\n 'embed',\n 'form',\n 'iframe',\n '
|
|
5
|
-
"mappings": "AAAA,OAAO,kBAAkB;AAElB,MAAM,yBAAyB;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,MAAM,mCAAmC;AAAA,EACvC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;
|
|
4
|
+
"sourcesContent": ["import sanitizeHtml from 'sanitize-html'\n\nexport const RICH_TEXT_ALLOWED_TAGS = [\n 'a',\n 'b',\n 'blockquote',\n 'br',\n 'code',\n 'div',\n 'em',\n 'h1',\n 'h2',\n 'h3',\n 'h4',\n 'h5',\n 'h6',\n 'hr',\n 'i',\n 'img',\n 'input',\n 'label',\n 'li',\n 'ol',\n 'p',\n 'pre',\n 's',\n 'span',\n 'strong',\n 'table',\n 'tbody',\n 'td',\n 'th',\n 'thead',\n 'tr',\n 'u',\n 'ul',\n] as const\n\nconst RICH_TEXT_DROP_WITH_CONTENT_TAGS = [\n 'button',\n 'embed',\n 'form',\n 'iframe',\n 'link',\n 'math',\n 'meta',\n 'object',\n 'option',\n 'script',\n 'select',\n 'style',\n 'svg',\n 'textarea',\n] as const\n\nconst HTML_LIKE_PATTERN = /<\\/?[a-z][\\s\\S]*>/i\n\nfunction escapeAttribute(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/\"/g, '"')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n}\n\nexport type RichTextPasteContent = {\n command: 'insertHTML' | 'insertText'\n value: string\n}\n\nexport function sanitizeRichTextHtml(value: string | null | undefined): string {\n if (typeof value !== 'string' || value.length === 0) return ''\n\n return sanitizeHtml(value, {\n allowedTags: [...RICH_TEXT_ALLOWED_TAGS],\n allowedAttributes: {\n a: ['href', 'title'],\n img: ['src', 'alt', 'width', 'height'],\n table: ['style'],\n tr: [],\n td: ['colspan', 'rowspan', 'style'],\n th: ['colspan', 'rowspan', 'style'],\n input: ['type', 'checked', 'disabled'],\n label: [],\n span: ['style'],\n p: ['style'],\n div: ['style'],\n ul: ['data-task-list', 'style'],\n ol: ['style'],\n li: ['style'],\n h1: ['style'], h2: ['style'], h3: ['style'], h4: ['style'], h5: ['style'], h6: ['style'],\n },\n allowedSchemes: ['http', 'https', 'mailto', 'tel'],\n allowedSchemesAppliedToAttributes: ['href', 'src'],\n allowProtocolRelative: false,\n nonTextTags: [...RICH_TEXT_DROP_WITH_CONTENT_TAGS],\n allowedStyles: {\n '*': {\n 'color': [/^#(?:[0-9a-f]{3}){1,2}$/i, /^rgb\\([\\d,\\s]+\\)$/i, /^rgba\\([\\d.,\\s]+\\)$/i],\n 'background-color': [/^#(?:[0-9a-f]{3}){1,2}$/i, /^rgb\\([\\d,\\s]+\\)$/i, /^rgba\\([\\d.,\\s]+\\)$/i],\n 'font-size': [/^\\d+(?:\\.\\d+)?(?:px|em|rem|%)$/],\n 'font-weight': [/^(?:normal|bold|\\d{3})$/],\n 'font-style': [/^(?:normal|italic|oblique)$/],\n 'text-align': [/^(?:left|right|center|justify)$/],\n 'text-decoration': [/^(?:none|underline|line-through)$/],\n 'width': [/^\\d+(?:\\.\\d+)?(?:px|em|rem|%)$/],\n 'height': [/^\\d+(?:\\.\\d+)?(?:px|em|rem|%)$/],\n 'border': [/^[\\w\\-:#.,\\s()]+$/],\n 'border-collapse': [/^(?:collapse|separate)$/],\n 'padding': [/^[\\d.\\spxemr%]+$/],\n 'list-style': [/^(?:none|disc|circle|square|decimal)$/],\n 'padding-left': [/^\\d+(?:\\.\\d+)?(?:px|em|rem|%)?$/],\n },\n },\n transformTags: {\n // Restrict <input> to checkbox toggles only \u2014 strip any other `type` so\n // text/file/etc. inputs don't slip through the rich text channel.\n 'input': (tagName, attribs) => {\n if (attribs.type === 'checkbox') {\n return { tagName: 'input', attribs }\n }\n return { tagName: '', attribs: {}, text: '' }\n },\n },\n })\n}\n\nexport function sanitizeRichTextHref(value: string | null | undefined): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n if (!trimmed) return null\n\n const html = sanitizeRichTextHtml(`<a href=\"${escapeAttribute(trimmed)}\">x</a>`)\n const match = /\\shref=\"([^\"]+)\"/.exec(html)\n return match ? trimmed : null\n}\n\nexport function sanitizeRichTextPasteContent(html: string, text: string): RichTextPasteContent | null {\n if (html) {\n return { command: 'insertHTML', value: sanitizeRichTextHtml(html) }\n }\n\n if (!text) return null\n\n if (HTML_LIKE_PATTERN.test(text)) {\n return { command: 'insertHTML', value: sanitizeRichTextHtml(text) }\n }\n\n return { command: 'insertText', value: text }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.1-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.1-develop.3260.1.dc719cac84'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.1-develop.
|
|
3
|
+
"version": "0.6.1-develop.3260.1.dc719cac84",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"@mikro-orm/core": "^7.0.15",
|
|
93
93
|
"@mikro-orm/decorators": "^7.0.15",
|
|
94
94
|
"@mikro-orm/postgresql": "^7.0.15",
|
|
95
|
-
"@open-mercato/cache": "0.6.1-develop.
|
|
95
|
+
"@open-mercato/cache": "0.6.1-develop.3260.1.dc719cac84",
|
|
96
96
|
"dotenv": "^17.4.2",
|
|
97
97
|
"rate-limiter-flexible": "^11.1.0",
|
|
98
98
|
"reflect-metadata": "^0.2.2",
|
|
@@ -21,7 +21,7 @@ export type EnvLookup = Record<string, string | undefined>
|
|
|
21
21
|
* Metadata describing a single model available in an LLM provider.
|
|
22
22
|
*
|
|
23
23
|
* Used by the AI Assistant UI to populate model dropdowns and by the
|
|
24
|
-
* routing layer to pick defaults when `
|
|
24
|
+
* routing layer to pick defaults when `OM_AI_MODEL` is not set.
|
|
25
25
|
*/
|
|
26
26
|
export interface LlmModelInfo {
|
|
27
27
|
/**
|
|
@@ -56,9 +56,10 @@ export interface LlmCreateModelOptions {
|
|
|
56
56
|
/** Resolved API key for the provider. */
|
|
57
57
|
apiKey: string
|
|
58
58
|
/**
|
|
59
|
-
* Optional override for
|
|
60
|
-
*
|
|
61
|
-
*
|
|
59
|
+
* Optional override for the upstream base URL. Every OpenAI-compatible
|
|
60
|
+
* adapter MUST honor baseURL; Anthropic now also honors it (Messages-
|
|
61
|
+
* protocol relays only — Cloudflare AI Gateway in Anthropic mode, Helicone
|
|
62
|
+
* proxy); Google honors it when the SDK supports it (@ai-sdk/google ≥3.0).
|
|
62
63
|
*/
|
|
63
64
|
baseURL?: string
|
|
64
65
|
}
|
|
@@ -76,8 +77,8 @@ export interface LlmProvider {
|
|
|
76
77
|
/**
|
|
77
78
|
* Stable identifier used in configuration, env vars, and the registry.
|
|
78
79
|
* MUST be lowercase snake_case or kebab-case (e.g. `anthropic`, `deepinfra`,
|
|
79
|
-
* `openai`, `internal-litellm`). The `
|
|
80
|
-
*
|
|
80
|
+
* `openai`, `internal-litellm`). The `OM_AI_PROVIDER` env var resolves to
|
|
81
|
+
* this value; the legacy `OPENCODE_PROVIDER` env var is a BC fallback.
|
|
81
82
|
*/
|
|
82
83
|
readonly id: string
|
|
83
84
|
/** Human-readable display name for UI dropdowns. */
|
|
@@ -90,7 +91,7 @@ export interface LlmProvider {
|
|
|
90
91
|
/**
|
|
91
92
|
* Default model id returned by {@link LlmProvider.defaultModels}[0]
|
|
92
93
|
* when the caller does not specify one. Used by the routing layer when
|
|
93
|
-
* `
|
|
94
|
+
* `OM_AI_MODEL` is not set.
|
|
94
95
|
*/
|
|
95
96
|
readonly defaultModel: string
|
|
96
97
|
/** Curated list of models shown in the UI dropdown for this provider. */
|
|
@@ -12,9 +12,12 @@ describe('sanitizeRichText', () => {
|
|
|
12
12
|
})
|
|
13
13
|
|
|
14
14
|
it('removes executable content and unsafe attributes', () => {
|
|
15
|
+
// `<img>` is on the allowlist (toolbar `insertImage`), so the tag stays
|
|
16
|
+
// but the `onerror` handler is stripped and the `color:red` keyword
|
|
17
|
+
// doesn't match the hex/rgb allowed-style regex so it falls away too.
|
|
15
18
|
expect(
|
|
16
19
|
sanitizeRichTextHtml('<p onclick="alert(1)" style="color:red">Hi<script>alert(1)</script><img src=x onerror=alert(2)>there</p>'),
|
|
17
|
-
).toBe('<p>
|
|
20
|
+
).toBe('<p>Hi<img src="x" />there</p>')
|
|
18
21
|
})
|
|
19
22
|
|
|
20
23
|
it('removes unsafe href values from links', () => {
|
|
@@ -44,10 +47,35 @@ describe('sanitizeRichText', () => {
|
|
|
44
47
|
),
|
|
45
48
|
).toEqual({
|
|
46
49
|
command: 'insertHTML',
|
|
47
|
-
value: '<p>Safe text</p><a>Bad link</a>',
|
|
50
|
+
value: '<p>Safe text</p><img src="x" /><a>Bad link</a>',
|
|
48
51
|
})
|
|
49
52
|
})
|
|
50
53
|
|
|
54
|
+
it('keeps RichEditor toolbar output: hr / image / table / inline-code / code-block / checklist', () => {
|
|
55
|
+
expect(sanitizeRichTextHtml('<hr />')).toBe('<hr />')
|
|
56
|
+
expect(sanitizeRichTextHtml('<img src="https://example.com/x.png" alt="" />')).toBe('<img src="https://example.com/x.png" alt="" />')
|
|
57
|
+
expect(sanitizeRichTextHtml('<table><tbody><tr><td>a</td></tr></tbody></table>')).toBe('<table><tbody><tr><td>a</td></tr></tbody></table>')
|
|
58
|
+
expect(sanitizeRichTextHtml('<p>before <code>inline</code> after</p>')).toBe('<p>before <code>inline</code> after</p>')
|
|
59
|
+
expect(sanitizeRichTextHtml('<pre>code block</pre>')).toBe('<pre>code block</pre>')
|
|
60
|
+
expect(sanitizeRichTextHtml('<ul data-task-list="true"><li><label><input type="checkbox" /> task</label></li></ul>')).toBe(
|
|
61
|
+
'<ul data-task-list="true"><li><label><input type="checkbox" /> task</label></li></ul>',
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('keeps color + font-size span styles emitted by the toolbar color picker / font size dropdown', () => {
|
|
66
|
+
expect(sanitizeRichTextHtml('<span style="color:#6366f1">blue</span>')).toBe('<span style="color:#6366f1">blue</span>')
|
|
67
|
+
expect(sanitizeRichTextHtml('<span style="font-size:16px">big</span>')).toBe('<span style="font-size:16px">big</span>')
|
|
68
|
+
// Disallowed style values (CSS keyword colors, url(), etc.) still get dropped.
|
|
69
|
+
expect(sanitizeRichTextHtml('<span style="color:red">red</span>')).toBe('<span>red</span>')
|
|
70
|
+
expect(sanitizeRichTextHtml('<span style="background-image:url(http://evil)">x</span>')).toBe('<span>x</span>')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('only allows <input type="checkbox"> — other input types are dropped', () => {
|
|
74
|
+
expect(sanitizeRichTextHtml('<input type="checkbox" checked />')).toBe('<input type="checkbox" checked />')
|
|
75
|
+
expect(sanitizeRichTextHtml('<input type="text" value="x" />')).toBe('')
|
|
76
|
+
expect(sanitizeRichTextHtml('<input type="file" />')).toBe('')
|
|
77
|
+
})
|
|
78
|
+
|
|
51
79
|
it('keeps non-html plain text paste as text', () => {
|
|
52
80
|
expect(sanitizeRichTextPasteContent('', 'Plain < not a tag')).toEqual({
|
|
53
81
|
command: 'insertText',
|
|
@@ -14,7 +14,11 @@ export const RICH_TEXT_ALLOWED_TAGS = [
|
|
|
14
14
|
'h4',
|
|
15
15
|
'h5',
|
|
16
16
|
'h6',
|
|
17
|
+
'hr',
|
|
17
18
|
'i',
|
|
19
|
+
'img',
|
|
20
|
+
'input',
|
|
21
|
+
'label',
|
|
18
22
|
'li',
|
|
19
23
|
'ol',
|
|
20
24
|
'p',
|
|
@@ -22,6 +26,12 @@ export const RICH_TEXT_ALLOWED_TAGS = [
|
|
|
22
26
|
's',
|
|
23
27
|
'span',
|
|
24
28
|
'strong',
|
|
29
|
+
'table',
|
|
30
|
+
'tbody',
|
|
31
|
+
'td',
|
|
32
|
+
'th',
|
|
33
|
+
'thead',
|
|
34
|
+
'tr',
|
|
25
35
|
'u',
|
|
26
36
|
'ul',
|
|
27
37
|
] as const
|
|
@@ -31,7 +41,6 @@ const RICH_TEXT_DROP_WITH_CONTENT_TAGS = [
|
|
|
31
41
|
'embed',
|
|
32
42
|
'form',
|
|
33
43
|
'iframe',
|
|
34
|
-
'input',
|
|
35
44
|
'link',
|
|
36
45
|
'math',
|
|
37
46
|
'meta',
|
|
@@ -66,11 +75,53 @@ export function sanitizeRichTextHtml(value: string | null | undefined): string {
|
|
|
66
75
|
allowedTags: [...RICH_TEXT_ALLOWED_TAGS],
|
|
67
76
|
allowedAttributes: {
|
|
68
77
|
a: ['href', 'title'],
|
|
78
|
+
img: ['src', 'alt', 'width', 'height'],
|
|
79
|
+
table: ['style'],
|
|
80
|
+
tr: [],
|
|
81
|
+
td: ['colspan', 'rowspan', 'style'],
|
|
82
|
+
th: ['colspan', 'rowspan', 'style'],
|
|
83
|
+
input: ['type', 'checked', 'disabled'],
|
|
84
|
+
label: [],
|
|
85
|
+
span: ['style'],
|
|
86
|
+
p: ['style'],
|
|
87
|
+
div: ['style'],
|
|
88
|
+
ul: ['data-task-list', 'style'],
|
|
89
|
+
ol: ['style'],
|
|
90
|
+
li: ['style'],
|
|
91
|
+
h1: ['style'], h2: ['style'], h3: ['style'], h4: ['style'], h5: ['style'], h6: ['style'],
|
|
69
92
|
},
|
|
70
93
|
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
|
71
|
-
allowedSchemesAppliedToAttributes: ['href'],
|
|
94
|
+
allowedSchemesAppliedToAttributes: ['href', 'src'],
|
|
72
95
|
allowProtocolRelative: false,
|
|
73
96
|
nonTextTags: [...RICH_TEXT_DROP_WITH_CONTENT_TAGS],
|
|
97
|
+
allowedStyles: {
|
|
98
|
+
'*': {
|
|
99
|
+
'color': [/^#(?:[0-9a-f]{3}){1,2}$/i, /^rgb\([\d,\s]+\)$/i, /^rgba\([\d.,\s]+\)$/i],
|
|
100
|
+
'background-color': [/^#(?:[0-9a-f]{3}){1,2}$/i, /^rgb\([\d,\s]+\)$/i, /^rgba\([\d.,\s]+\)$/i],
|
|
101
|
+
'font-size': [/^\d+(?:\.\d+)?(?:px|em|rem|%)$/],
|
|
102
|
+
'font-weight': [/^(?:normal|bold|\d{3})$/],
|
|
103
|
+
'font-style': [/^(?:normal|italic|oblique)$/],
|
|
104
|
+
'text-align': [/^(?:left|right|center|justify)$/],
|
|
105
|
+
'text-decoration': [/^(?:none|underline|line-through)$/],
|
|
106
|
+
'width': [/^\d+(?:\.\d+)?(?:px|em|rem|%)$/],
|
|
107
|
+
'height': [/^\d+(?:\.\d+)?(?:px|em|rem|%)$/],
|
|
108
|
+
'border': [/^[\w\-:#.,\s()]+$/],
|
|
109
|
+
'border-collapse': [/^(?:collapse|separate)$/],
|
|
110
|
+
'padding': [/^[\d.\spxemr%]+$/],
|
|
111
|
+
'list-style': [/^(?:none|disc|circle|square|decimal)$/],
|
|
112
|
+
'padding-left': [/^\d+(?:\.\d+)?(?:px|em|rem|%)?$/],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
transformTags: {
|
|
116
|
+
// Restrict <input> to checkbox toggles only — strip any other `type` so
|
|
117
|
+
// text/file/etc. inputs don't slip through the rich text channel.
|
|
118
|
+
'input': (tagName, attribs) => {
|
|
119
|
+
if (attribs.type === 'checkbox') {
|
|
120
|
+
return { tagName: 'input', attribs }
|
|
121
|
+
}
|
|
122
|
+
return { tagName: '', attribs: {}, text: '' }
|
|
123
|
+
},
|
|
124
|
+
},
|
|
74
125
|
})
|
|
75
126
|
}
|
|
76
127
|
|