@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.
@@ -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 'input',\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, '&amp;')\n .replace(/\"/g, '&quot;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\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 },\n allowedSchemes: ['http', 'https', 'mailto', 'tel'],\n allowedSchemesAppliedToAttributes: ['href'],\n allowProtocolRelative: false,\n nonTextTags: [...RICH_TEXT_DROP_WITH_CONTENT_TAGS],\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": "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;AAAA,EACA;AACF;AAEA,MAAM,oBAAoB;AAE1B,SAAS,gBAAgB,OAAuB;AAC9C,SAAO,MACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM;AACzB;AAOO,SAAS,qBAAqB,OAA0C;AAC7E,MAAI,OAAO,UAAU,YAAY,MAAM,WAAW,EAAG,QAAO;AAE5D,SAAO,aAAa,OAAO;AAAA,IACzB,aAAa,CAAC,GAAG,sBAAsB;AAAA,IACvC,mBAAmB;AAAA,MACjB,GAAG,CAAC,QAAQ,OAAO;AAAA,IACrB;AAAA,IACA,gBAAgB,CAAC,QAAQ,SAAS,UAAU,KAAK;AAAA,IACjD,mCAAmC,CAAC,MAAM;AAAA,IAC1C,uBAAuB;AAAA,IACvB,aAAa,CAAC,GAAG,gCAAgC;AAAA,EACnD,CAAC;AACH;AAEO,SAAS,qBAAqB,OAAiD;AACpF,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,MAAI,CAAC,QAAS,QAAO;AAErB,QAAM,OAAO,qBAAqB,YAAY,gBAAgB,OAAO,CAAC,SAAS;AAC/E,QAAM,QAAQ,mBAAmB,KAAK,IAAI;AAC1C,SAAO,QAAQ,UAAU;AAC3B;AAEO,SAAS,6BAA6B,MAAc,MAA2C;AACpG,MAAI,MAAM;AACR,WAAO,EAAE,SAAS,cAAc,OAAO,qBAAqB,IAAI,EAAE;AAAA,EACpE;AAEA,MAAI,CAAC,KAAM,QAAO;AAElB,MAAI,kBAAkB,KAAK,IAAI,GAAG;AAChC,WAAO,EAAE,SAAS,cAAc,OAAO,qBAAqB,IAAI,EAAE;AAAA,EACpE;AAEA,SAAO,EAAE,SAAS,cAAc,OAAO,KAAK;AAC9C;",
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, '&amp;')\n .replace(/\"/g, '&quot;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\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
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.1-develop.3246.1.dbef9d7392";
1
+ const APP_VERSION = "0.6.1-develop.3260.1.dc719cac84";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -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.3246.1.dbef9d7392'\nexport const appVersion = APP_VERSION\n"],
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.3246.1.dbef9d7392",
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.3246.1.dbef9d7392",
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 `OPENCODE_MODEL` is not set.
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 adapters that support a custom base URL
60
- * (e.g. OpenAI protocol adapter pointed at DeepInfra, Groq, or LiteLLM).
61
- * Adapters that don't use base URLs (Anthropic, Google) ignore this.
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 `OPENCODE_PROVIDER` env var resolves
80
- * to this value.
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
- * `OPENCODE_MODEL` is not set.
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>Hithere</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