diff --git a/.playwright/tests/links.spec.ts b/.playwright/tests/links.spec.ts
index 60402572d..0300131ed 100644
--- a/.playwright/tests/links.spec.ts
+++ b/.playwright/tests/links.spec.ts
@@ -479,7 +479,7 @@ test.describe('test-links copy-paste', () => {
await setTestLinksEditorHtml(
page,
- '
custom://link
'
+ '/custom-link
'
);
await copyWholeContent(editor);
@@ -488,7 +488,7 @@ test.describe('test-links copy-paste', () => {
await expect
.poll(async () => getTestLinksSerializedHtml(page))
- .toContain('custom://link');
+ .toContain('/custom-link');
});
});
diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx
index 929d0dbea..245895ada 100644
--- a/apps/example-web/src/App.tsx
+++ b/apps/example-web/src/App.tsx
@@ -38,6 +38,10 @@ const DEFAULT_LINK_STATE: OnLinkDetected = {
const LINK_REGEX =
/^(?:enriched:\/\/\S+|(?:https?:\/\/)?(?:www\.)?swmansion\.com(?:\/\S*)?)$/i;
+const SANITIZATION_CONFIG = {
+ linkRegex: LINK_REGEX,
+};
+
function App() {
const ref = useRef(null);
const [currentHtml, setCurrentHtml] = useState('');
@@ -121,16 +125,16 @@ function App() {
const handleUserMentionSelected = (item: MentionItem) => {
ref.current?.setMention('@', `@${item.name}`, {
- id: item.id,
- type: 'user',
+ 'id': item.id,
+ 'data-type': 'user',
});
closeUserMentionPopup();
};
const handleChannelMentionSelected = (item: MentionItem) => {
ref.current?.setMention('#', `#${item.name}`, {
- id: item.id,
- type: 'channel',
+ 'id': item.id,
+ 'data-type': 'channel',
});
closeChannelMentionPopup();
};
@@ -278,6 +282,7 @@ function App() {
mentionIndicators={['@', '#']}
htmlStyle={WEB_DEFAULT_HTML_STYLE}
linkRegex={LINK_REGEX}
+ sanitizationConfig={SANITIZATION_CONFIG}
/>
{htmlValue}
diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c
index 232131a80..c41b9f5da 100644
--- a/cpp/parser/GumboNormalizer.c
+++ b/cpp/parser/GumboNormalizer.c
@@ -513,9 +513,10 @@ static void emit_attributes(GumboElement *el, const char *tag_name,
buffer_append_str(out, " checked");
}
} else if (strcmp(tag_name, "mention") == 0) {
- emit_one_attr(out, el, "id");
- emit_one_attr(out, el, "text");
- emit_one_attr(out, el, "indicator");
+ for (unsigned int i = 0; i < el->attributes.length; i++) {
+ GumboAttribute *attr = (GumboAttribute *)el->attributes.data[i];
+ emit_one_attr(out, el, attr->name);
+ }
} else {
/* preserve text-align */
emit_alignment(el, tag_name, out);
diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp
index a0ec32bc8..3101e1056 100644
--- a/cpp/tests/GumboParserTest.cpp
+++ b/cpp/tests/GumboParserTest.cpp
@@ -307,13 +307,20 @@ TEST(GumboParserTest, EnrichedTagRemappings) {
EXPECT_EQ(
GumboParser::normalizeHtml(
"@John Doe"),
- "@John "
+ "@John "
"Doe");
EXPECT_EQ(
GumboParser::normalizeHtml("@John Doe"),
- "@John "
+ "@John "
"Doe");
+ // Custom mention attributes are preserved
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "@John Doe"),
+ "@John Doe");
// Link
EXPECT_EQ(GumboParser::normalizeHtml(
diff --git a/docs/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md
index 67ed97a5d..2591d4d34 100644
--- a/docs/INPUT_API_REFERENCE.md
+++ b/docs/INPUT_API_REFERENCE.md
@@ -1041,7 +1041,7 @@ interface MentionStyleProperties {
### mention
-If only a single config is given, the style applies to all mention types. You can also set a different config for each mentionIndicator that has been defined, then the prop should be a record with indicators as a keys and configs as their values.
+If only a single config is given, the style applies to all mention types. You can also set a different config for each mentionIndicator that has been defined, then the prop should be a record with indicators as keys and configs as their values. Additionally, you can define a style using the `'default'` key, which will act as a base that the rest of your defined styles will fallback on.
- `color` defines the color of mention's text, takes [color](https://reactnative.dev/docs/colors) value and defaults to `blue`.
- `backgroundColor` is the mention's background color, takes [color](https://reactnative.dev/docs/colors) value and defaults to `yellow`.
diff --git a/docs/WEB.md b/docs/WEB.md
index 1c1f9763e..f8267a710 100644
--- a/docs/WEB.md
+++ b/docs/WEB.md
@@ -51,4 +51,39 @@ See [Web Keyboard Shortcuts](./INPUT_API_REFERENCE.md#web-keyboard-shortcuts) fo
## HTML sanitization
-You are responsible for sanitizing HTML on both input and output. The library does not guarantee safe or clean HTML output. This applies to any HTML you persist, render elsewhere, or accept from untrusted sources (XSS, paste attacks, etc.).
+On web, HTML is sanitized automatically with [DOMPurify](https://github.com/cure53/DOMPurify) on both input and output. This reduces XSS risk, but you should still treat untrusted HTML with caution and apply your own server-side sanitization.
+
+- **`EnrichedText`** sanitizes its `children` before rendering.
+- **`EnrichedTextInput`** sanitizes every HTML entry point — `defaultValue`, the `setValue` ref method, and pasted HTML — as well as its output from `getHTML` and the `onChangeHtml` callback.
+
+### Allowing custom link protocols
+
+By default, sanitization strips links with non-standard protocols (e.g. `custom://…`). Both `EnrichedText` and `EnrichedTextInput` accept a web-only `sanitizationConfig` prop whose `linkRegex` field lets you control which link URIs survive.
+
+`linkRegex` maps directly to DOMPurify's [`ALLOWED_URI_REGEXP`](https://github.com/cure53/DOMPurify#can-i-configure-dompurify), so it **replaces** the default allow-list rather than extending it — remember to keep the standard protocols you still want to permit:
+
+```tsx
+
+ {html}
+
+```
+
+When `sanitizationConfig` is omitted, DOMPurify's built-in default is used.
+
+> Note: `sanitizationConfig.linkRegex` only controls what sanitization keeps. It is independent of the top-level `linkRegex` prop, which controls autolink detection while typing. To both autolink and preserve a custom protocol, configure both.
+
+### Custom mention attributes
+
+To attach custom data to a mention, use the `data-` prefix (e.g. `data-user-id`) to make sure they survive sanitization. Attributes passed to the `setMention` ref method are properly sanitized.
+
+## Client-only rendering (no SSR)
+
+Both `EnrichedText` and `EnrichedTextInput` are **client-only** components. They rely on browser-only APIs (`DOMParser`, `DOMPurify`, `TipTap`) and are **not designed for server-side rendering (SSR)**.
+
+If your application uses SSR (Next.js, Remix, Gatsby, etc.), make sure these components only render on the client.
diff --git a/src/types.ts b/src/types.ts
index acda85b41..d4d0dfd8b 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -585,6 +585,25 @@ export interface OnChangeMentionEvent {
text: string;
}
+/**
+ * Web-only configuration for the HTML sanitization step.
+ *
+ * @platform web
+ */
+export interface SanitizationConfig {
+ /**
+ * Regular expression used to decide which link URIs survive sanitization.
+ * Maps directly to DOMPurify's `ALLOWED_URI_REGEXP`, so it fully replaces
+ * the default allow-list rather than extending it — include the standard
+ * protocols you still want to permit in addition to any custom ones.
+ *
+ * When omitted, DOMPurify's built-in default is used.
+ *
+ * @platform web
+ */
+ linkRegex?: RegExp;
+}
+
/**
* Props for the `` rich-text editor component.
*/
@@ -762,6 +781,13 @@ export interface EnrichedTextInputProps extends Omit {
*/
useHtmlNormalizer?: boolean;
+ /**
+ * Web-only configuration for the HTML sanitization step.
+ *
+ * @platform web
+ */
+ sanitizationConfig?: SanitizationConfig;
+
/**
* If true, fonts will scale to respect the system's accessibility text size.
* Enabled by default.
@@ -804,6 +830,13 @@ export interface EnrichedTextProps extends ViewProps {
*/
useHtmlNormalizer?: boolean;
+ /**
+ * Web-only configuration for the HTML sanitization step.
+ *
+ * @platform web
+ */
+ sanitizationConfig?: SanitizationConfig;
+
/**
* How to truncate text when it overflows `numberOfLines`.
* - `"head"` — truncates the beginning.
diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx
index 63141b0d7..8ec916cc5 100644
--- a/src/web/EnrichedText.tsx
+++ b/src/web/EnrichedText.tsx
@@ -20,6 +20,7 @@ import { useImageErrorFallback } from './useImageErrorFallback';
import { usePressInteractions } from './usePressInteractions';
import { adaptWebToNativeEvent } from './adaptWebToNativeEvent';
import { useStableRef } from './useStableRef';
+import { assertBrowserEnvironment } from './assertBrowserEnvironment';
export const EnrichedText = memo(
({
@@ -30,11 +31,14 @@ export const EnrichedText = memo(
selectionColor,
selectable = false,
useHtmlNormalizer = true,
+ sanitizationConfig,
onFocus,
onBlur,
onLinkPress,
onMentionPress,
}: EnrichedTextProps) => {
+ assertBrowserEnvironment('EnrichedText');
+
const containerRef = useRef(null);
useImperativeHandle(ref, () => ({
@@ -50,7 +54,10 @@ export const EnrichedText = memo(
},
}));
- const sanitizedHtml = useMemo(() => sanitizeHtml(children), [children]);
+ const sanitizedHtml = useMemo(
+ () => sanitizeHtml(children, sanitizationConfig),
+ [children, sanitizationConfig]
+ );
const finalHtml = useMemo(
() => prepareHtmlForWeb(sanitizedHtml, useHtmlNormalizer),
diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx
index 8ec53d0d7..87fa483b2 100644
--- a/src/web/EnrichedTextInput.tsx
+++ b/src/web/EnrichedTextInput.tsx
@@ -81,6 +81,11 @@ import { returnKeyTypeToEnterKeyHint } from './returnKeyTypeToEnterKeyHint';
import { ENRICHED_TEXT_INPUT_CLASSNAME } from './constants/classNames';
import { AutolinkPlugin } from './pmPlugins/AutolinkPlugin';
import { useStableRef } from './useStableRef';
+import {
+ checkMentionAttributes,
+ sanitizeMentionAttributes,
+} from './sanitization/htmlSanitizer';
+import { assertBrowserEnvironment } from './assertBrowserEnvironment';
function runFocused(
editor: Editor,
@@ -121,11 +126,18 @@ export const EnrichedTextInput = ({
linkRegex,
htmlStyle,
useHtmlNormalizer = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.useHtmlNormalizer,
+ sanitizationConfig,
textShortcuts = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.textShortcuts,
}: EnrichedTextInputProps) => {
+ assertBrowserEnvironment('EnrichedTextInput');
+
const tiptapContent =
defaultValue != null
- ? prepareHtmlForTiptap(defaultValue, useHtmlNormalizer)
+ ? prepareHtmlForTiptap(
+ defaultValue,
+ useHtmlNormalizer,
+ sanitizationConfig
+ )
: defaultValue;
const resolvedHtmlStyle = useMemo(
@@ -149,6 +161,7 @@ export const EnrichedTextInput = ({
const onSubmitEditingRef = useStableRef(onSubmitEditing);
const onKeyPressRef = useStableRef(onKeyPress);
const useHtmlNormalizerRef = useStableRef(useHtmlNormalizer);
+ const sanitizationConfigRef = useStableRef(sanitizationConfig);
const mentionCallbacksRef = useStableRef(mentionCallbacks);
const textShortcutsRef = useStableRef(textShortcuts);
@@ -274,7 +287,11 @@ export const EnrichedTextInput = ({
enterkeyhint: returnKeyTypeToEnterKeyHint(returnKeyType),
},
transformPastedHTML: (html) => {
- return prepareHtmlForTiptap(html, useHtmlNormalizerRef.current);
+ return prepareHtmlForTiptap(
+ html,
+ useHtmlNormalizerRef.current,
+ sanitizationConfigRef.current
+ );
},
},
},
@@ -309,7 +326,7 @@ export const EnrichedTextInput = ({
);
useMentionEvents(editor, getMentionCallbacks);
- useOnChangeHtml(editor, onChangeHtml);
+ useOnChangeHtml(editor, onChangeHtml, sanitizationConfig);
useOnChangeText(editor, onChangeText);
useOnChangeState(editor, resolvedHtmlStyle, onChangeState);
useOnLinkDetected(editor, linkEmitterRef);
@@ -321,7 +338,11 @@ export const EnrichedTextInput = ({
blur: () => editor.commands.blur(),
setValue: (value: string) =>
editor.commands.setContent(
- prepareHtmlForTiptap(value, useHtmlNormalizerRef.current)
+ prepareHtmlForTiptap(
+ value,
+ useHtmlNormalizerRef.current,
+ sanitizationConfigRef.current
+ )
),
setSelection: (start, end) => {
const doc = editor.state.doc;
@@ -332,7 +353,13 @@ export const EnrichedTextInput = ({
})
);
},
- getHTML: () => Promise.resolve(normalizeHtmlFromTiptap(editor.getHTML())),
+ getHTML: () =>
+ Promise.resolve(
+ normalizeHtmlFromTiptap(
+ editor.getHTML(),
+ sanitizationConfigRef.current
+ )
+ ),
toggleBold: () => runFocused(editor, (c) => c.toggleBold()),
toggleItalic: () => runFocused(editor, (c) => c.toggleItalic()),
toggleUnderline: () => runFocused(editor, (c) => c.toggleUnderline()),
@@ -362,7 +389,15 @@ export const EnrichedTextInput = ({
indicator: string,
text: string,
attributes?: Record
- ) => setMention(editor, indicator, text, attributes),
+ ) => {
+ checkMentionAttributes(attributes);
+ setMention(
+ editor,
+ indicator,
+ text,
+ sanitizeMentionAttributes(attributes)
+ );
+ },
setImage: (src: string, width: number, height: number) =>
runFocused(editor, (c) => c.setImage({ src, width, height })),
measure: () => {},
@@ -377,7 +412,7 @@ export const EnrichedTextInput = ({
}
},
}),
- [editor, mentionIndicatorsRef, useHtmlNormalizerRef]
+ [editor, mentionIndicatorsRef, useHtmlNormalizerRef, sanitizationConfigRef]
);
const editorStyle: CSSProperties = useMemo(
diff --git a/src/web/__tests__/assertBrowserEnvironment.dom.test.ts b/src/web/__tests__/assertBrowserEnvironment.dom.test.ts
new file mode 100644
index 000000000..5bb67f5d1
--- /dev/null
+++ b/src/web/__tests__/assertBrowserEnvironment.dom.test.ts
@@ -0,0 +1,8 @@
+import { assertBrowserEnvironment } from '../assertBrowserEnvironment';
+
+describe('assertBrowserEnvironment', () => {
+ // jsdom provides a full DOM, so the browser APIs are present by default.
+ test('does not throw when the DOM globals are available', () => {
+ expect(() => assertBrowserEnvironment('EnrichedText')).not.toThrow();
+ });
+});
diff --git a/src/web/__tests__/assertBrowserEnvironment.ssr.test.ts b/src/web/__tests__/assertBrowserEnvironment.ssr.test.ts
new file mode 100644
index 000000000..6035147be
--- /dev/null
+++ b/src/web/__tests__/assertBrowserEnvironment.ssr.test.ts
@@ -0,0 +1,13 @@
+/**
+ * @jest-environment node
+ */
+// Because of the docblock above, jsdom test environment does not exist here
+import { assertBrowserEnvironment } from '../assertBrowserEnvironment';
+
+describe('assertBrowserEnvironment', () => {
+ test('throws when DOM is missing', () => {
+ expect(() => assertBrowserEnvironment('EnrichedText')).toThrow(
+ /client-only/
+ );
+ });
+});
diff --git a/src/web/__tests__/htmlNormalizer.test.ts b/src/web/__tests__/htmlNormalizer.test.ts
index a129e72c5..d24202bbb 100644
--- a/src/web/__tests__/htmlNormalizer.test.ts
+++ b/src/web/__tests__/htmlNormalizer.test.ts
@@ -285,14 +285,19 @@ describe('htmlNormalizer', () => {
'',
],
- // Mentions (note: cpp reorders attrs to id, text, indicator)
+ // Mentions
[
"@John Doe",
- '@John Doe',
+ '@John Doe',
],
[
'@John Doe',
- '@John Doe',
+ '@John Doe',
+ ],
+ // Custom mention attributes are preserved
+ [
+ '@John Doe',
+ '@John Doe',
],
// Link
diff --git a/src/web/__tests__/sanitization.test.ts b/src/web/__tests__/sanitization.test.ts
new file mode 100644
index 000000000..2814432c8
--- /dev/null
+++ b/src/web/__tests__/sanitization.test.ts
@@ -0,0 +1,150 @@
+import {
+ sanitizeHtml,
+ sanitizeMentionAttributes,
+ checkMentionAttributes,
+} from '../sanitization/htmlSanitizer';
+
+describe('sanitizeMentionAttributes', () => {
+ it('returns an empty object when given no attributes', () => {
+ expect(sanitizeMentionAttributes()).toEqual({});
+ expect(sanitizeMentionAttributes({})).toEqual({});
+ });
+
+ it('keeps data-* and commonly-allowed attributes', () => {
+ expect(
+ sanitizeMentionAttributes({
+ 'data-user-id': '42',
+ 'data-team': 'core',
+ 'id': 'm1',
+ 'class': 'highlight',
+ })
+ ).toEqual({
+ 'data-user-id': '42',
+ 'data-team': 'core',
+ 'id': 'm1',
+ 'class': 'highlight',
+ });
+ });
+
+ it('strips event handlers and unsafe attributes', () => {
+ const result = sanitizeMentionAttributes({
+ 'onclick': 'alert(1)',
+ 'onmouseover': 'steal()',
+ // eslint-disable-next-line no-script-url
+ 'href': 'javascript:alert(1)',
+ 'data-user-id': '42',
+ });
+ expect(result).toEqual({ 'data-user-id': '42' });
+ });
+
+ it('does not return the reserved text/indicator attributes', () => {
+ const result = sanitizeMentionAttributes({
+ 'text': 'Joe',
+ 'indicator': '@',
+ 'data-user-id': '42',
+ });
+ expect(result).toEqual({ 'data-user-id': '42' });
+ });
+});
+
+describe('checkMentionAttributes', () => {
+ let warnSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ it('does not warn for data-*, text, indicator, or commonly-allowed attributes', () => {
+ checkMentionAttributes({
+ 'data-user-id': '42',
+ 'text': 'Joe',
+ 'indicator': '@',
+ 'id': 'm1',
+ 'class': 'x',
+ 'style': 'color: red',
+ });
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('warns for custom attributes without a recognized prefix', () => {
+ checkMentionAttributes({ foo: 'bar' });
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(warnSpy.mock.calls[0][0]).toContain('foo');
+ });
+
+ it('does nothing when given no attributes', () => {
+ checkMentionAttributes();
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+});
+
+describe('sanitizeHtmlMention', () => {
+ it('keeps tags with text/indicator/data-* attributes', () => {
+ const out = sanitizeHtml(
+ '@Joe'
+ );
+ expect(out).toContain('text="Joe"');
+ expect(out).toContain('indicator="@"');
+ expect(out).toContain('data-user-id="42"');
+ });
+
+ it('strips event handlers', () => {
+ expect(
+ sanitizeHtml('x')
+ ).not.toContain('onclick');
+ });
+});
+
+describe('sanitizeLinkAttributes', () => {
+ it('strips javascript: URLs from links', () => {
+ const out = sanitizeHtml('x');
+ // eslint-disable-next-line no-script-url
+ expect(out).not.toContain('javascript:');
+ });
+
+ it('strips unknown protocol URLs from links', () => {
+ const out = sanitizeHtml('x');
+ expect(out).not.toContain('custom');
+ });
+});
+
+describe('sanitizeHtml with a custom linkRegex', () => {
+ const linkRegex =
+ /^(?:(?:(?:f|ht)tps?|mailto|tel|custom):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i;
+
+ it('keeps links with a whitelisted custom protocol', () => {
+ const out = sanitizeHtml('x', {
+ linkRegex,
+ });
+ expect(out).toContain('href="custom://something"');
+ });
+
+ it('still keeps standard protocols when a custom regex is supplied', () => {
+ const out = sanitizeHtml('x', {
+ linkRegex,
+ });
+ expect(out).toContain('href="https://example.com"');
+ });
+
+ it('still strips protocols not covered by the custom regex', () => {
+ const out = sanitizeHtml('x', { linkRegex });
+ expect(out).not.toContain('other://');
+ });
+
+ it('strips custom protocols when no config is supplied (default behavior)', () => {
+ const out = sanitizeHtml('x');
+ expect(out).not.toContain('custom://');
+ });
+
+ it('does not weaken javascript: stripping when a custom regex is supplied', () => {
+ const out = sanitizeHtml('x', {
+ linkRegex,
+ });
+ // eslint-disable-next-line no-script-url
+ expect(out).not.toContain('javascript:');
+ });
+});
diff --git a/src/web/assertBrowserEnvironment.ts b/src/web/assertBrowserEnvironment.ts
new file mode 100644
index 000000000..3fa03721b
--- /dev/null
+++ b/src/web/assertBrowserEnvironment.ts
@@ -0,0 +1,21 @@
+/**
+ * `EnrichedText` and `EnrichedTextInput` rely on browser-only APIs (DOMParser,
+ * DOMPurify, TipTap) and therefore cannot render without a DOM — e.g. during
+ * server-side rendering (SSR). They are client-only components.
+ *
+ * This asserts a DOM is available and throws a clear error otherwise.
+ */
+export function assertBrowserEnvironment(componentName: string): void {
+ const hasDOM =
+ typeof window !== 'undefined' &&
+ typeof document !== 'undefined' &&
+ typeof DOMParser !== 'undefined' &&
+ typeof Node !== 'undefined';
+
+ if (!hasDOM) {
+ throw new Error(
+ `[react-native-enriched-html] ${componentName} is a client-only component and cannot be rendered without a DOM. ` +
+ `If you are running an SSR application, make sure the component is only rendered on the client.`
+ );
+ }
+}
diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts
index 4b2c0d34a..3f09357c1 100644
--- a/src/web/normalization/htmlNormalizer.ts
+++ b/src/web/normalization/htmlNormalizer.ts
@@ -286,11 +286,11 @@ function emitAttributes(el: Element, name: string): string {
el.getAttribute('data-leveltext') === ''; // MS Word checked box
return isChecked ? ' checked' : '';
case 'mention':
- return (
- emitOneAttr(el, 'id') +
- emitOneAttr(el, 'text') +
- emitOneAttr(el, 'indicator')
- );
+ let out = '';
+ for (const attr of Array.from(el.attributes)) {
+ out += emitOneAttr(el, attr.name);
+ }
+ return out;
default:
// preserve text-align
return emitAlignment(el, name);
@@ -700,8 +700,6 @@ function walkNode(node: Node, out: { buf: string }): void {
}
export function normalizeHtml(html: string): string {
- if (typeof DOMParser === 'undefined') return html;
-
const parser = new DOMParser();
const doc = parser.parseFromString(`${html}`, 'text/html');
const body = doc.body;
diff --git a/src/web/normalization/prepareHtmlForWeb.ts b/src/web/normalization/prepareHtmlForWeb.ts
index 99c315429..8da6d63e8 100644
--- a/src/web/normalization/prepareHtmlForWeb.ts
+++ b/src/web/normalization/prepareHtmlForWeb.ts
@@ -4,8 +4,6 @@ export function prepareHtmlForWeb(
html: string,
useHtmlNormalizer: boolean | undefined
): string {
- if (typeof DOMParser === 'undefined') return html;
-
if (useHtmlNormalizer) {
html = normalizeHtml(html);
}
diff --git a/src/web/normalization/tiptapHtmlNormalizer.ts b/src/web/normalization/tiptapHtmlNormalizer.ts
index 5831db4ac..976e1d316 100644
--- a/src/web/normalization/tiptapHtmlNormalizer.ts
+++ b/src/web/normalization/tiptapHtmlNormalizer.ts
@@ -1,3 +1,5 @@
+import { sanitizeHtml } from '../sanitization/htmlSanitizer';
+import type { SanitizationConfig } from '../../types';
import {
checkboxHtmlForTiptap,
checkboxHtmlFromTiptap,
@@ -6,8 +8,10 @@ import { normalizeHtml } from './htmlNormalizer';
export function prepareHtmlForTiptap(
html: string,
- useHtmlNormalizer: boolean | undefined
+ useHtmlNormalizer: boolean | undefined,
+ sanitizationConfig?: SanitizationConfig
): string {
+ html = sanitizeHtml(html, sanitizationConfig);
if (useHtmlNormalizer) {
html = normalizeHtml(html);
}
@@ -16,7 +20,11 @@ export function prepareHtmlForTiptap(
return html;
}
-export function normalizeHtmlFromTiptap(html: string): string {
+export function normalizeHtmlFromTiptap(
+ html: string,
+ sanitizationConfig?: SanitizationConfig
+): string {
+ html = sanitizeHtml(html, sanitizationConfig);
html = checkboxHtmlFromTiptap(html);
// Strip wrappers inside
elements.
diff --git a/src/web/sanitization/htmlSanitizer.ts b/src/web/sanitization/htmlSanitizer.ts
index 7a45adc19..a57a56aa0 100644
--- a/src/web/sanitization/htmlSanitizer.ts
+++ b/src/web/sanitization/htmlSanitizer.ts
@@ -1,8 +1,65 @@
import DOMPurify from 'dompurify';
+import type { SanitizationConfig } from '../../types';
-export function sanitizeHtml(html: string) {
+const MENTION_ATTRS = ['text', 'indicator'];
+
+// Attributes DOMPurify keeps by default and are commonly used, so we don't emit an unnecessary warning
+const COMMONLY_ALLOWED_ATTRS = ['id', 'class', 'style'];
+
+export function sanitizeHtml(html: string, config?: SanitizationConfig) {
return DOMPurify.sanitize(html, {
ADD_TAGS: ['mention', 'codeblock'],
- ADD_ATTR: ['text', 'indicator'],
+ ADD_ATTR: MENTION_ATTRS,
+ // if not supplied, fall back to DOMPurify's built-in default.
+ ...(config?.linkRegex ? { ALLOWED_URI_REGEXP: config.linkRegex } : {}),
+ });
+}
+
+export function sanitizeMentionAttributes(
+ attributes?: Record
+): Record {
+ if (!attributes) return {};
+
+ const el = document.createElement('mention');
+ for (const [name, value] of Object.entries(attributes)) {
+ try {
+ el.setAttribute(name, value);
+ } catch {
+ // Ignore invalid attribute names.
+ }
+ }
+
+ const cleaned = new DOMParser()
+ .parseFromString(sanitizeHtml(el.outerHTML), 'text/html')
+ .querySelector('mention');
+
+ const out: Record = {};
+ if (!cleaned) return out;
+
+ for (const attr of Array.from(cleaned.attributes)) {
+ if (MENTION_ATTRS.includes(attr.name.toLowerCase())) continue;
+ out[attr.name] = attr.value;
+ }
+ return out;
+}
+
+// Runtime warning: custom attributes without a "data-" prefix may be
+// removed by sanitization. This is a heuristic (it does not run DOMPurify).
+export function checkMentionAttributes(attributes?: Record) {
+ if (!attributes) return;
+
+ Object.keys(attributes).forEach((attrName) => {
+ const lower = attrName.toLowerCase();
+ if (
+ lower.startsWith('data-') ||
+ MENTION_ATTRS.includes(lower) ||
+ COMMONLY_ALLOWED_ATTRS.includes(lower)
+ ) {
+ return;
+ }
+ console.warn(
+ `[EnrichedMention] Attribute "${attrName}" on the tag may be removed during sanitization. ` +
+ `Consider using the "data-" prefix for custom data attributes.`
+ );
});
}
diff --git a/src/web/useOnChangeHtml.ts b/src/web/useOnChangeHtml.ts
index 90d159d97..a172d0655 100644
--- a/src/web/useOnChangeHtml.ts
+++ b/src/web/useOnChangeHtml.ts
@@ -1,14 +1,15 @@
import { type Editor } from '@tiptap/react';
-import type { OnChangeHtmlEvent } from '../types';
+import type { OnChangeHtmlEvent, SanitizationConfig } from '../types';
import type { NativeSyntheticEvent } from 'react-native';
import { useOnEditorChange } from './useOnEditorChange';
import { normalizeHtmlFromTiptap } from './normalization/tiptapHtmlNormalizer';
export const useOnChangeHtml = (
editor: Editor,
- onChangeHtml?: (e: NativeSyntheticEvent) => void
+ onChangeHtml?: (e: NativeSyntheticEvent) => void,
+ sanitizationConfig?: SanitizationConfig
) => {
useOnEditorChange(editor, onChangeHtml, (e) =>
- normalizeHtmlFromTiptap(e.getHTML())
+ normalizeHtmlFromTiptap(e.getHTML(), sanitizationConfig)
);
};