Merge issue-46 (Refs #44 #46)

# Conflicts:
#	craft/src/editor/CanvasOverlays.tsx
#	craft/src/styles/editor.css
This commit is contained in:
shadowdao committed 2026-10-07 11:47:54 -07:00
commit 98e32a68b9
14 files changed
+1091 -68

No files matched your search

@@ -107,3 +107,50 @@ describe('Heading.craft.props exposes the box-model + animation/visibility rollo
expect(style.opacity).toBe('1');
});
});
describe('Heading.toHtml inline rich text (#46)', () => {
test('legacy plain text with html="" is escaped', () => {
expect(toHtml({ level: 'h2', text: 'a<b', html: '' }, '').html).toBe('<h2>a&lt;b</h2>');
});
test('marked html is emitted (html-only props)', () => {
expect(toHtml({ level: 'h2', html: '<b>Hi</b> there' }, '').html).toBe('<h2><b>Hi</b> there</h2>');
});
test('html whose text mirror matches text is emitted', () => {
expect(toHtml({ level: 'h2', text: 'Hi there', html: '<b>Hi</b> there' }, '').html).toBe('<h2><b>Hi</b> there</h2>');
});
test('text changed without html (e.g. AI update_props) wins over stale html', () => {
expect(toHtml({ level: 'h2', text: 'New <copy>', html: '<b>Old</b> copy' }, '').html).toBe('<h2>New &lt;copy&gt;</h2>');
});
test.each([
['<img src=x onerror=alert(1)>', '<img'],
['<script>alert(1)</script>', '<script'],
['<a href="javascript:alert(1)">x</a>', 'javascript:'],
['<a href="jav&#x61;script:alert(1)">x</a>', 'href'],
['<b onclick="alert(1)">x</b>', 'onclick'],
['<span style="position:fixed;inset:0">x</span>', 'position'],
['</h2><iframe src="https://evil"></iframe><h2>', 'iframe'],
['<svg onload=alert(1)>', 'onload'],
])('malicious stored html is sanitised at export: %s', (html, banned) => {
const out = toHtml({ level: 'h2', html }, '').html as string;
expect(out.toLowerCase()).not.toContain(banned);
expect(out.startsWith('<h2')).toBe(true);
expect(out.endsWith('</h2>')).toBe(true);
});
test('external _blank link gets rel=noopener at export', () => {
expect(toHtml({ level: 'h2', html: '<a href="https://e.com" target="_blank">x</a>' }, '').html)
.toBe('<h2><a href="https://e.com" target="_blank" rel="noopener">x</a></h2>');
});
test('craft.props carries a blank html default', () => {
expect((Heading as any).craft.props.html).toBe('');
});
});
test('Heading default colour is the text token', () => {
expect((Heading as any).craft.props.style.color).toBe('var(--wsb-text)');
});
+14 -38
View File
@@ -1,7 +1,10 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml } from '../../utils/escape';
import { resolveInlineHtml } from '../../utils/inline-html';
import { tokenRef } from '../../utils/tokens';
import { useInlineEditable } from './useInlineEditable';
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
@@ -17,6 +20,8 @@ const sanitizeHeadingLevel = (level: unknown): HeadingLevel =>
interface HeadingProps {
text?: string;
/** Sanitised inline rich text (#46); '' = plain `text` only. */
html?: string;
level?: HeadingLevel;
style?: CSSProperties;
cssId?: string;
@@ -30,6 +35,7 @@ interface HeadingProps {
export const Heading: UserComponent<HeadingProps> = ({
text = 'Heading',
html = '',
level = 'h2',
style = {},
}) => {
@@ -42,34 +48,7 @@ export const Heading: UserComponent<HeadingProps> = ({
}));
const safeLevel = sanitizeHeadingLevel(level);
const elRef = useRef<HTMLElement | null>(null);
const editedTextRef = useRef<string | null>(null);
const commitText = useCallback(() => {
if (elRef.current) {
const newText = elRef.current.innerText;
editedTextRef.current = newText;
setProp((p: HeadingProps) => { p.text = newText; });
}
}, [setProp]);
// Commit on blur
const handleBlur = useCallback(() => { commitText(); }, [commitText]);
// Also commit on deselect via effect
useEffect(() => {
if (!selected && editedTextRef.current !== null) {
setProp((p: HeadingProps) => { p.text = editedTextRef.current!; });
editedTextRef.current = null;
}
}, [selected, setProp]);
// Set DOM text on mount and when text prop changes externally (not during editing)
useEffect(() => {
if (elRef.current && !selected && editedTextRef.current === null) {
elRef.current.innerText = text || '';
}
}, [text, selected]);
const { elRef, onBlur, onInput, onKeyDown } = useInlineEditable(text, html, selected, setProp);
return React.createElement(safeLevel, {
ref: (ref: HTMLElement | null): void => {
@@ -78,13 +57,9 @@ export const Heading: UserComponent<HeadingProps> = ({
},
contentEditable: selected,
suppressContentEditableWarning: true,
onBlur: handleBlur,
onInput: () => {
// Track that we have unsaved edits
if (elRef.current) {
editedTextRef.current = elRef.current.innerText;
}
},
onBlur,
onInput,
onKeyDown,
style: { outline: 'none', cursor: selected ? 'text' : 'pointer', minHeight: '1em', ...style },
});
};
@@ -93,12 +68,13 @@ Heading.craft = {
displayName: 'Heading',
props: {
text: 'Your Heading',
html: '',
level: 'h2' as HeadingLevel,
style: {
fontSize: '36px',
fontWeight: '700',
fontFamily: 'Inter, sans-serif',
color: '#1f2937',
color: tokenRef('text'),
marginBottom: '16px',
lineHeight: '',
letterSpacing: '',
@@ -126,7 +102,7 @@ Heading.craft = {
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
const tag = sanitizeHeadingLevel(props.level);
const safeText = escapeHtml(props.text || '');
const safeText = resolveInlineHtml(props.text, props.html) ?? escapeHtml(props.text || '');
const styleStr = cssPropsToString(props.style);
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}</${tag}>` };
};
@@ -65,3 +65,46 @@ describe('TextBlock.craft.props exposes the box-model + animation/visibility rol
expect(style.opacity).toBe('1');
});
});
describe('TextBlock.toHtml inline rich text (#46)', () => {
test('legacy plain text with html="" is escaped', () => {
expect(toHtml({ text: 'a<b', html: '' }, '').html).toBe('<p>a&lt;b</p>');
});
test('marked html is emitted (html-only props)', () => {
expect(toHtml({ html: '<b>Hi</b> there' }, '').html).toBe('<p><b>Hi</b> there</p>');
});
test('html whose text mirror matches text is emitted', () => {
expect(toHtml({ text: 'Hi there', html: '<b>Hi</b> there' }, '').html).toBe('<p><b>Hi</b> there</p>');
});
test('text changed without html (e.g. AI update_props) wins over stale html', () => {
expect(toHtml({ text: 'New <copy>', html: '<b>Old</b> copy' }, '').html).toBe('<p>New &lt;copy&gt;</p>');
});
test.each([
['<img src=x onerror=alert(1)>', '<img'],
['<script>alert(1)</script>', '<script'],
['<a href="javascript:alert(1)">x</a>', 'javascript:'],
['<a href="jav&#x61;script:alert(1)">x</a>', 'href'],
['<b onclick="alert(1)">x</b>', 'onclick'],
['<span style="position:fixed;inset:0">x</span>', 'position'],
['</p><iframe src="https://evil"></iframe><p>', 'iframe'],
['<svg onload=alert(1)>', 'onload'],
])('malicious stored html is sanitised at export: %s', (html, banned) => {
const out = toHtml({ html }, '').html as string;
expect(out.toLowerCase()).not.toContain(banned);
expect(out.startsWith('<p')).toBe(true);
expect(out.endsWith('</p>')).toBe(true);
});
test('external _blank link gets rel=noopener at export', () => {
expect(toHtml({ html: '<a href="https://e.com" target="_blank">x</a>' }, '').html)
.toBe('<p><a href="https://e.com" target="_blank" rel="noopener">x</a></p>');
});
test('craft.props carries a blank html default', () => {
expect((TextBlock as any).craft.props.html).toBe('');
});
});
+12 -29
View File
@@ -1,10 +1,14 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml } from '../../utils/escape';
import { resolveInlineHtml } from '../../utils/inline-html';
import { useInlineEditable } from './useInlineEditable';
interface TextBlockProps {
text?: string;
/** Sanitised inline rich text (#46); '' = plain `text` only. */
html?: string;
style?: CSSProperties;
cssId?: string;
cssClass?: string;
@@ -17,6 +21,7 @@ interface TextBlockProps {
export const TextBlock: UserComponent<TextBlockProps> = ({
text = 'Start typing here...',
html = '',
style = {},
}) => {
const {
@@ -27,31 +32,7 @@ export const TextBlock: UserComponent<TextBlockProps> = ({
selected: node.events.selected,
}));
const elRef = useRef<HTMLParagraphElement | null>(null);
const editedTextRef = useRef<string | null>(null);
const commitText = useCallback(() => {
if (elRef.current) {
const newText = elRef.current.innerText;
editedTextRef.current = newText;
setProp((p: TextBlockProps) => { p.text = newText; });
}
}, [setProp]);
const handleBlur = useCallback(() => { commitText(); }, [commitText]);
useEffect(() => {
if (!selected && editedTextRef.current !== null) {
setProp((p: TextBlockProps) => { p.text = editedTextRef.current!; });
editedTextRef.current = null;
}
}, [selected, setProp]);
useEffect(() => {
if (elRef.current && !selected && editedTextRef.current === null) {
elRef.current.innerText = text || '';
}
}, [text, selected]);
const { elRef, onBlur, onInput, onKeyDown } = useInlineEditable(text, html, selected, setProp);
return (
<p
@@ -61,8 +42,9 @@ export const TextBlock: UserComponent<TextBlockProps> = ({
}}
contentEditable={selected}
suppressContentEditableWarning
onBlur={handleBlur}
onInput={() => { if (elRef.current) editedTextRef.current = elRef.current.innerText; }}
onBlur={onBlur}
onInput={onInput}
onKeyDown={onKeyDown}
style={{
outline: 'none',
cursor: selected ? 'text' : 'pointer',
@@ -79,6 +61,7 @@ TextBlock.craft = {
displayName: 'Text',
props: {
text: 'Start typing here...',
html: '',
style: {
fontSize: '16px',
lineHeight: '1.6',
@@ -110,6 +93,6 @@ TextBlock.craft = {
(TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => {
const styleStr = cssPropsToString(props.style);
const escapedText = escapeHtml(props.text || '');
const escapedText = resolveInlineHtml(props.text, props.html) ?? escapeHtml(props.text || '');
return { html: `<p${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</p>` };
};
@@ -0,0 +1,170 @@
import { describe, test, expect, afterEach, vi } from 'vitest';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
/**
* #46 inline rich text against a REAL Craft.js editor: contentEditable
* commits store sanitised `html` + plain `text` as ONE undo step, a deselect
* after blur adds no second step, and load (deserialize + history.clear)
* leaves nothing to undo.
*/
function stateWith(nodeType: 'Heading' | 'TextBlock', props: Record<string, unknown>) {
return JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' },
displayName: 'Container', custom: {}, hidden: false, nodes: ['n-1'], linkedNodes: {},
},
'n-1': {
type: { resolvedName: nodeType }, isCanvas: false,
props: nodeType === 'Heading' ? { level: 'h2', style: {}, ...props } : { style: {}, ...props },
displayName: nodeType === 'Heading' ? 'Heading' : 'Text', custom: {}, hidden: false,
parent: 'ROOT', nodes: [], linkedNodes: {},
},
});
}
let harness: EditorHarness | null = null;
afterEach(() => {
vi.restoreAllMocks();
delete (document as any).execCommand;
harness?.unmount();
harness = null;
});
function mount(nodeType: 'Heading' | 'TextBlock', props: Record<string, unknown>) {
harness = renderEditorHarness({ initialState: stateWith(nodeType, props) });
harness.act(() => { harness!.actions.history.clear(); });
return harness;
}
const el = () => harness!.query.node('n-1').get().dom as HTMLElement;
const nodeProps = () => JSON.parse(harness!.getSerialized())['n-1'].props;
const select = () => harness!.act(() => { harness!.actions.selectNode('n-1'); });
const deselect = () => harness!.act(() => { harness!.actions.selectNode(); });
const typeHtml = (html: string) => harness!.act(() => {
el().innerHTML = html;
el().dispatchEvent(new Event('input', { bubbles: true }));
});
const blur = () => harness!.act(() => { el().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); });
function undoDepth(): number {
let n = 0;
while (harness!.query.history.canUndo() && n < 50) {
harness!.act(() => { harness!.actions.history.undo(); });
n++;
}
return n;
}
describe.each(['Heading', 'TextBlock'] as const)('%s inline editing (#46)', (type) => {
test('a marked edit stores sanitised html + plain text mirror', () => {
mount(type, { text: 'Hello', html: '' });
select();
expect(el().getAttribute('contenteditable')).toBe('true');
typeHtml('Hello <b>World</b>');
blur();
expect(nodeProps().html).toBe('Hello <b>World</b>');
expect(nodeProps().text).toBe('Hello World');
});
test('blur then deselect is ONE undo step (no double commit)', () => {
mount(type, { text: 'Hello', html: '' });
select();
typeHtml('Hello <b>World</b>');
blur();
deselect();
expect(undoDepth()).toBe(1);
expect(nodeProps().text).toBe('Hello');
expect(nodeProps().html).toBe('');
// Undo of a deselected node re-renders the DOM from props.
expect(el().innerHTML).toBe('Hello');
});
test('deselect without blur commits exactly once', () => {
mount(type, { text: 'Hello', html: '' });
select();
typeHtml('Hi <i>there</i>');
deselect();
expect(nodeProps().html).toBe('Hi <i>there</i>');
expect(undoDepth()).toBe(1);
});
test('a plain edit leaves html empty (legacy shape)', () => {
mount(type, { text: 'Hello', html: '' });
select();
typeHtml('Fish &amp; chips');
blur();
expect(nodeProps().html).toBe('');
expect(nodeProps().text).toBe('Fish & chips');
});
test('Enter-created block lines survive as newlines in text', () => {
mount(type, { text: 'a', html: '' });
select();
typeHtml('a<div>b</div>');
blur();
expect(nodeProps().text).toBe('a\nb');
expect(nodeProps().html).toBe('');
});
test('hostile markup typed/pasted into the editable is sanitised on commit', () => {
mount(type, { text: 'x', html: '' });
select();
typeHtml('<img src=x onerror="alert(1)"><a href="javascript:alert(1)" onclick="x()">click</a> <b style="position:fixed">ok</b>');
blur();
expect(nodeProps().html).toBe('click <b>ok</b>');
expect(nodeProps().text).toBe('click ok');
});
test('external links created by createLink get target=_blank + rel=noopener on commit', () => {
mount(type, { text: 'x', html: '' });
select();
typeHtml('<a href="https://example.org/">x</a>');
blur();
expect(nodeProps().html).toBe('<a href="https://example.org/" target="_blank" rel="noopener">x</a>');
});
test('blur with no edit commits nothing', () => {
mount(type, { text: 'Hello', html: '' });
select();
blur();
deselect();
expect(harness!.query.history.canUndo()).toBe(false);
});
test('load (deserialize + history.clear) renders stored html and leaves nothing to undo', () => {
mount(type, { text: 'Hi there', html: '<b>Hi</b> there' });
expect(el().innerHTML).toBe('<b>Hi</b> there');
harness!.act(() => {
harness!.actions.deserialize(stateWith(type, { text: 'Page two', html: '<i>Page</i> two' }));
});
harness!.act(() => { harness!.actions.history.clear(); });
expect(el().innerHTML).toBe('<i>Page</i> two');
expect(harness!.query.history.canUndo()).toBe(false);
});
test('hostile stored html never reaches the canvas DOM', () => {
mount(type, { text: '', html: '<img src=x onerror="alert(1)">safe<script>alert(1)</script>' });
expect(el().innerHTML).toBe('safe');
});
test('stale html is ignored when text was changed without it (AI update_props)', () => {
mount(type, { text: 'Hi there', html: '<b>Hi</b> there' });
harness!.act(() => { harness!.actions.setProp('n-1', (p: any) => { p.text = 'Brand <new>'; }); });
expect(el().innerHTML).toBe('Brand &lt;new&gt;');
});
test('mod+b while typing runs execCommand(bold) and marks the edit pending', () => {
mount(type, { text: 'Hello', html: '' });
select();
// jsdom has no execCommand at all -- install a stub for this test.
const exec = vi.fn(() => { el().innerHTML = '<b>Hello</b>'; return true; });
(document as any).execCommand = exec;
harness!.act(() => {
el().dispatchEvent(new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true }));
});
expect(exec).toHaveBeenCalledWith('bold');
blur();
expect(nodeProps().html).toBe('<b>Hello</b>');
expect(undoDepth()).toBe(1);
});
});
@@ -0,0 +1,75 @@
import React, { useCallback, useEffect, useRef } from 'react';
import {
sanitizeInlineHtml,
inlineHtmlToText,
isPlainInlineHtml,
textToInlineHtml,
resolveInlineHtml,
markExternalLinks,
} from '../../utils/inline-html';
interface InlineTextProps {
text?: string;
html?: string;
}
const SHORTCUTS: Record<string, string> = { b: 'bold', i: 'italic', u: 'underline' };
/**
* contentEditable wiring shared by Heading and TextBlock (#46).
*
* - The DOM is written imperatively (innerHTML) only while the node is NOT
* selected and has no pending edit, so the caret is never reset mid-typing.
* - An edit is committed ONCE -- on blur, or on deselect if no blur came
* first -- as a single `setProp` (= one undo step) that stores BOTH the
* sanitised `html` and its plain `text` mirror. Unmarked content stores
* `html: ''` so legacy nodes keep their legacy shape.
* - mod+b/i/u is handled locally: the global shortcut dispatcher skips
* contentEditable targets.
*/
export function useInlineEditable(
text: string,
html: string,
selected: boolean,
setProp: (cb: (p: InlineTextProps) => void) => void,
) {
const elRef = useRef<HTMLElement | null>(null);
const pendingRef = useRef(false);
const commit = useCallback(() => {
if (!pendingRef.current) return;
pendingRef.current = false;
const el = elRef.current;
if (!el) return;
markExternalLinks(el);
const clean = sanitizeInlineHtml(el.innerHTML);
const nextText = inlineHtmlToText(clean);
const nextHtml = isPlainInlineHtml(clean) ? '' : clean;
setProp((p) => { p.html = nextHtml; p.text = nextText; });
}, [setProp]);
// Deselect without a blur (e.g. Escape, programmatic selection change).
useEffect(() => {
if (!selected) commit();
}, [selected, commit]);
useEffect(() => {
if (elRef.current && !selected && !pendingRef.current) {
elRef.current.innerHTML = resolveInlineHtml(text, html) ?? textToInlineHtml(text || '');
}
}, [text, html, selected]);
const onInput = useCallback(() => { pendingRef.current = true; }, []);
const onKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) return;
const cmd = SHORTCUTS[e.key.toLowerCase()];
if (!cmd) return;
if (typeof document.execCommand !== 'function') return;
e.preventDefault();
document.execCommand(cmd);
pendingRef.current = true;
}, []);
return { elRef, onBlur: commit, onInput, onKeyDown };
}
+2
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { NoticeHost } from './NoticeHost';
import { EmptyContainerAdd } from './EmptyContainerAdd';
import { DropIndicator } from './DropIndicator';
import { RichTextToolbar } from './RichTextToolbar';
/**
* Single mount point for editor-wide overlays rendered next to the shell.
@@ -13,5 +14,6 @@ export const CanvasOverlays: React.FC = () => (
<NoticeHost />
<EmptyContainerAdd />
<DropIndicator />
<RichTextToolbar />
</>
);
+174
View File
@@ -0,0 +1,174 @@
import { describe, test, expect, afterEach, vi } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
import { RichTextToolbar, normalizeLinkInput } from './RichTextToolbar';
const STATE = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' },
displayName: 'Container', custom: {}, hidden: false, nodes: ['h-1', 'b-1'], linkedNodes: {},
},
'h-1': {
type: { resolvedName: 'Heading' }, isCanvas: false,
props: { text: 'Hello World', html: '', level: 'h2', style: {} },
displayName: 'Heading', custom: {}, hidden: false, parent: 'ROOT', nodes: [], linkedNodes: {},
},
'b-1': {
type: { resolvedName: 'ButtonLink' }, isCanvas: false,
props: { text: 'Go', href: '#' },
displayName: 'Button', custom: {}, hidden: false, parent: 'ROOT', nodes: [], linkedNodes: {},
},
});
let harness: EditorHarness | null = null;
let exec: ReturnType<typeof vi.fn>;
afterEach(() => {
window.getSelection()?.removeAllRanges();
delete (document as any).execCommand;
harness?.unmount();
harness = null;
});
function setup() {
harness = renderEditorHarness({ initialState: STATE });
harness.mountChild(<RichTextToolbar />);
harness.act(() => { harness!.actions.history.clear(); });
// jsdom has no execCommand; stub one that records calls.
exec = vi.fn(() => true);
(document as any).execCommand = exec;
}
const heading = () => harness!.query.node('h-1').get().dom as HTMLElement;
const toolbar = () => harness!.container.querySelector('.rt-toolbar');
const btn = (label: string) => harness!.container.querySelector(`[aria-label="${label}"]`) as HTMLElement;
function selectText(el: HTMLElement) {
harness!.act(() => {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
document.dispatchEvent(new Event('selectionchange'));
});
}
function press(el: HTMLElement) {
harness!.act(() => {
const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
el.dispatchEvent(down);
expect(down.defaultPrevented).toBe(true); // selection must survive
el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}
describe('RichTextToolbar (#46)', () => {
test('renders over a non-collapsed selection inside a selected Heading', () => {
setup();
harness!.act(() => { harness!.actions.selectNode('h-1'); });
expect(toolbar()).toBeNull();
selectText(heading());
expect(toolbar()).not.toBeNull();
expect(toolbar()!.getAttribute('role')).toBe('toolbar');
});
test('B / I / U / Unlink run the matching execCommand and mark the edit', () => {
setup();
harness!.act(() => { harness!.actions.selectNode('h-1'); });
selectText(heading());
press(btn('Bold'));
press(btn('Italic'));
press(btn('Underline'));
press(btn('Remove link'));
expect(exec.mock.calls.map((c) => c[0])).toEqual(['bold', 'italic', 'underline', 'unlink']);
});
test('B followed by blur commits the formatted html as one undo step', () => {
setup();
harness!.act(() => { harness!.actions.selectNode('h-1'); });
selectText(heading());
exec.mockImplementation(() => { heading().innerHTML = '<b>Hello</b> World'; return true; });
press(btn('Bold'));
harness!.act(() => { heading().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); });
const props = harness!.query.node('h-1').get().data.props;
expect(props.html).toBe('<b>Hello</b> World');
expect(props.text).toBe('Hello World');
expect(harness!.query.history.canUndo()).toBe(true);
harness!.act(() => { harness!.actions.history.undo(); });
expect(harness!.query.history.canUndo()).toBe(false);
});
test('hidden when the selection collapses', () => {
setup();
harness!.act(() => { harness!.actions.selectNode('h-1'); });
selectText(heading());
expect(toolbar()).not.toBeNull();
harness!.act(() => {
window.getSelection()!.collapseToStart();
document.dispatchEvent(new Event('selectionchange'));
});
expect(toolbar()).toBeNull();
});
test('hidden when the node is deselected', () => {
setup();
harness!.act(() => { harness!.actions.selectNode('h-1'); });
selectText(heading());
harness!.act(() => { harness!.actions.selectNode(); });
expect(toolbar()).toBeNull();
});
test('not shown for a selection in an unselected Heading', () => {
setup();
selectText(heading());
expect(toolbar()).toBeNull();
});
test('not shown for a contenteditable that is not a selected Heading/Text node', () => {
setup();
harness!.act(() => { harness!.actions.selectNode('b-1'); });
const stray = document.createElement('div');
stray.setAttribute('contenteditable', 'true');
stray.textContent = 'elsewhere';
document.body.appendChild(stray);
selectText(stray);
expect(toolbar()).toBeNull();
stray.remove();
});
test('Link: unsafe URL is refused, a bare domain becomes https://', () => {
setup();
harness!.act(() => { harness!.actions.selectNode('h-1'); });
selectText(heading());
press(btn('Link'));
const input = harness!.container.querySelector('input[aria-label="Link URL"]') as HTMLInputElement;
expect(input).not.toBeNull();
const submit = (v: string) => harness!.act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, v);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
});
submit('javascript:alert(1)');
expect(exec).not.toHaveBeenCalled();
expect(input.getAttribute('aria-invalid')).toBe('true');
submit('example.com/path');
expect(exec).toHaveBeenCalledWith('createLink', false, 'https://example.com/path');
});
});
describe('normalizeLinkInput', () => {
test.each([
['example.com', 'https://example.com'],
['https://e.com/x', 'https://e.com/x'],
['/about', '/about'],
['#contact', '#contact'],
['mailto:a@b.co', 'mailto:a@b.co'],
['javascript:alert(1)', ''],
[' JaVa\tScRiPt:alert(1)', ''],
['data:text/html,x', ''],
['', ''],
])('%j -> %j', (input, expected) => {
expect(normalizeLinkInput(input)).toBe(expected);
});
});
+184
View File
@@ -0,0 +1,184 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useEditor } from '@craftjs/core';
import { safeUrl } from '../utils/escape';
import { markExternalLinks } from '../utils/inline-html';
/** Craft displayNames whose editable stores inline rich text (#46). */
const RICH_TEXT_TYPES = new Set(['Heading', 'Text']);
const TOOLBAR_GAP = 8;
/** `example.com` -> `https://example.com`; leaves relative/anchor/mailto/tel and schemed URLs alone. */
export function normalizeLinkInput(raw: string): string {
const v = raw.trim();
if (!v) return '';
if (/^[a-z][a-z0-9+.-]*:/i.test(v) || /^[/#?.]/.test(v)) return safeUrl(v);
if (/^[^\s/]+\.[^\s/]+/.test(v)) return safeUrl(`https://${v}`);
return safeUrl(v);
}
function runCommand(command: string, value?: string): void {
if (typeof document.execCommand === 'function') document.execCommand(command, false, value);
}
interface Placement { top: number; left: number }
/**
* Floating B / I / U / Link / Unlink bar over a non-collapsed selection inside
* the editable of a selected Heading or Text node. Formatting is plain
* `document.execCommand`; the component's own commit (blur/deselect)
* sanitises and stores the result, so this bar never writes props itself.
* Buttons `preventDefault` on mousedown so the text selection survives.
*/
export const RichTextToolbar: React.FC = () => {
const { query, selectedKey } = useEditor((state) => ({
selectedKey: Array.from(state.events.selected).join(','),
}));
const [placement, setPlacement] = useState<Placement | null>(null);
const [linkOpen, setLinkOpen] = useState(false);
const [linkValue, setLinkValue] = useState('');
const [linkError, setLinkError] = useState(false);
const editableRef = useRef<HTMLElement | null>(null);
const savedRangeRef = useRef<Range | null>(null);
const linkOpenRef = useRef(false);
linkOpenRef.current = linkOpen;
/** The selected rich-text node's editable that contains `range`, if any. */
const editableFor = useCallback((range: Range): HTMLElement | null => {
const start = range.commonAncestorContainer;
const host = (start.nodeType === Node.ELEMENT_NODE ? start as Element : start.parentElement)
?.closest('[contenteditable="true"]') as HTMLElement | null;
if (!host) return null;
let ids: string[] = [];
try { ids = query.getEvent('selected').all(); } catch { return null; }
for (const id of ids) {
const node = query.node(id).get();
if (node?.dom === host && RICH_TEXT_TYPES.has(node.data.displayName)) return host;
}
return null;
}, [query]);
const update = useCallback(() => {
if (linkOpenRef.current) return;
const sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { setPlacement(null); return; }
const range = sel.getRangeAt(0);
const host = editableFor(range);
if (!host) { setPlacement(null); return; }
editableRef.current = host;
const rect = typeof range.getBoundingClientRect === 'function'
? range.getBoundingClientRect()
: host.getBoundingClientRect();
setPlacement({ top: Math.max(rect.top - TOOLBAR_GAP, 0), left: rect.left + rect.width / 2 });
}, [editableFor]);
useEffect(() => {
document.addEventListener('selectionchange', update);
return () => document.removeEventListener('selectionchange', update);
}, [update]);
// Node selection changed (deselect, another node): re-evaluate.
useEffect(() => {
setLinkOpen(false);
linkOpenRef.current = false;
update();
}, [selectedKey, update]);
/** Tell the component an edit happened (real browsers also fire `input`). */
const markEdited = () => {
editableRef.current?.dispatchEvent(new Event('input', { bubbles: true }));
};
const run = (command: string) => {
runCommand(command);
markEdited();
update();
};
const restoreSelection = (): boolean => {
const host = editableRef.current;
const range = savedRangeRef.current;
const sel = window.getSelection?.();
if (!host || !range || !sel) return false;
host.focus();
sel.removeAllRanges();
sel.addRange(range);
return true;
};
const openLink = () => {
const sel = window.getSelection?.();
if (!sel || sel.rangeCount === 0) return;
savedRangeRef.current = sel.getRangeAt(0).cloneRange();
const anchor = (sel.anchorNode?.parentElement)?.closest('a');
setLinkValue(anchor?.getAttribute('href') || '');
setLinkError(false);
setLinkOpen(true);
};
const closeLink = (restore: boolean) => {
setLinkOpen(false);
linkOpenRef.current = false;
if (restore) restoreSelection();
savedRangeRef.current = null;
update();
};
const applyLink = () => {
const url = normalizeLinkInput(linkValue);
if (!url) { setLinkError(true); return; }
if (!restoreSelection()) { closeLink(false); return; }
runCommand('createLink', url);
if (editableRef.current) markExternalLinks(editableRef.current);
markEdited();
closeLink(false);
};
if (!placement) return null;
const keep = (e: React.MouseEvent) => e.preventDefault();
const button = (label: string, icon: string, onClick: () => void) => (
<button type="button" className="rt-toolbar-btn" aria-label={label} title={label} onMouseDown={keep} onClick={onClick}>
<i className={`fa ${icon}`} aria-hidden="true" />
</button>
);
return (
<div
className="rt-toolbar"
role="toolbar"
aria-label="Text formatting"
style={{ top: placement.top, left: placement.left }}
>
{linkOpen ? (
<form
className="rt-toolbar-link"
onSubmit={(e) => { e.preventDefault(); applyLink(); }}
>
<input
autoFocus
type="text"
className={`rt-toolbar-input${linkError ? ' is-invalid' : ''}`}
aria-label="Link URL"
aria-invalid={linkError || undefined}
placeholder="https://example.com"
value={linkValue}
onChange={(e) => { setLinkValue(e.target.value); setLinkError(false); }}
onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault(); closeLink(true); } }}
/>
<button type="submit" className="rt-toolbar-btn" aria-label="Apply link" title="Apply link">
<i className="fa fa-check" aria-hidden="true" />
</button>
</form>
) : (
<>
{button('Bold', 'fa-bold', () => run('bold'))}
{button('Italic', 'fa-italic', () => run('italic'))}
{button('Underline', 'fa-underline', () => run('underline'))}
{button('Link', 'fa-link', openLink)}
{button('Remove link', 'fa-unlink', () => run('unlink'))}
</>
)}
</div>
);
};
+61
View File
@@ -2441,3 +2441,64 @@ body {
background: var(--color-accent-subtle);
cursor: default;
}
/* #46 rich text toolbar -- floating B/I/U/Link bar over a canvas text selection */
.rt-toolbar {
position: fixed;
z-index: var(--z-selection-toolbar);
transform: translate(-50%, -100%);
display: flex;
align-items: center;
gap: 2px;
padding: 3px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
}
.rt-toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
font-size: 12px;
color: var(--color-text);
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.rt-toolbar-btn:hover,
.rt-toolbar-btn:focus-visible {
background: var(--color-bg-hover);
outline: none;
}
.rt-toolbar-link {
display: flex;
align-items: center;
gap: 2px;
}
.rt-toolbar-input {
width: 220px;
padding: 5px 8px;
font-size: 12px;
color: var(--color-text);
background: var(--color-bg-base);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
outline: none;
}
.rt-toolbar-input:focus {
border-color: var(--color-accent);
}
.rt-toolbar-input.is-invalid {
border-color: var(--color-danger);
}
+18
View File
@@ -22,3 +22,21 @@ describe('summarizeCanvas', () => {
expect(out).toContain('truncated');
});
});
describe('summarizeCanvas with inline rich text (#46)', () => {
test('summarises the plain text mirror, never the html markup', () => {
const state = {
ROOT: { type: { resolvedName: 'Container' }, props: {}, nodes: ['h'] },
h: {
type: { resolvedName: 'Heading' },
props: { text: 'Hello World', html: 'Hello <b>World</b>', level: 'h2' },
nodes: [],
},
};
const out = summarizeCanvas(state as any);
expect(out).toContain('text=Hello World');
expect(out).toContain('level=h2');
expect(out).not.toContain('<b>');
expect(out).not.toContain('html=');
});
});
+2 -1
View File
@@ -14,7 +14,8 @@ export function summarizeCanvas(state: SerializedNodes, maxChars = 6000): string
const nodeId = (props as any).node_id ?? id;
const interesting: string[] = [];
for (const [k, v] of Object.entries(props)) {
if (k === 'aiName' || k === 'node_id' || k === 'style') continue;
// `html` (#46) is markup for the plain `text` mirror -- summarise text only.
if (k === 'aiName' || k === 'node_id' || k === 'style' || k === 'html') continue;
if (v == null) continue;
const repr = typeof v === 'string' ? v : JSON.stringify(v);
const truncated = repr.length > 60 ? repr.slice(0, 57) + '…' : repr;
+118
View File
@@ -0,0 +1,118 @@
import { describe, test, expect } from 'vitest';
import {
sanitizeInlineHtml,
inlineHtmlToText,
textToInlineHtml,
isPlainInlineHtml,
} from './inline-html';
describe('sanitizeInlineHtml allowlist', () => {
test.each([
['<script>alert(1)</script>hi', 'hi'],
['<b onclick="alert(1)">x</b>', '<b>x</b>'],
['<span style="position:fixed;top:0">x</span>', 'x'],
['<span style="color:var(--wsb-primary)">x</span>', '<span style="color:var(--wsb-primary)">x</span>'],
['<span style="color:#ff0000">x</span>', '<span style="color:#ff0000">x</span>'],
['<span style="color: rgb(255, 0, 0);">x</span>', '<span style="color:rgb(255, 0, 0)">x</span>'],
['<span style="color:red;position:fixed">x</span>', 'x'],
['<span style="color:expression(alert(1))">x</span>', 'x'],
['<span style="color:var(--evil)">x</span>', 'x'],
['<span style="color:#fff;background:url(javascript:alert(1))">x</span>', '<span style="color:#fff">x</span>'],
['<b>x</b> <strong>y</strong> <i>z</i> <em>w</em> <u>v</u>', '<b>x</b> <strong>y</strong> <i>z</i> <em>w</em> <u>v</u>'],
['<div>outer <div>inner</div></div>', 'outer <br>inner'],
['a<div>b</div>', 'a<br>b'],
['<img src=x onerror=alert(1)>t', 't'],
['<svg><script>alert(1)</script></svg>t', 't'],
['<iframe src="https://e.com"></iframe>t', 't'],
['<b data-x="1" id="a" class="c">x</b>', '<b>x</b>'],
['&lt;script&gt;alert(1)&lt;/script&gt;', '&lt;script&gt;alert(1)&lt;/script&gt;'],
['<style>body{display:none}</style>t', 't'],
['<b>x</b><h2>unwrapped</h2>', '<b>x</b>unwrapped'],
])('%s -> %s', (input, expected) => {
expect(sanitizeInlineHtml(input)).toBe(expected);
});
test('empty / non-string input -> empty string', () => {
expect(sanitizeInlineHtml('')).toBe('');
expect(sanitizeInlineHtml(' ')).toBe('');
expect(sanitizeInlineHtml(undefined as any)).toBe('');
expect(sanitizeInlineHtml({} as any)).toBe('');
});
});
describe('sanitizeInlineHtml links', () => {
test.each([
'<a href="javascript:alert(1)">x</a>',
'<a href="JaVaScRiPt:alert(1)">x</a>',
'<a href=" javascript:alert(1)">x</a>',
'<a href="jav&#x61;script:alert(1)">x</a>',
'<a href="jav&#97;script&#58;alert(1)">x</a>',
'<a href="java\tscript:alert(1)">x</a>',
'<a href="vbscript:msgbox(1)">x</a>',
'<a href="data:text/html,<script>alert(1)</script>">x</a>',
'<a href="data:image/svg+xml;base64,PHN2Zz4=">x</a>',
'<a>x</a>',
])('unsafe/missing href unwraps to text: %s', (input) => {
expect(sanitizeInlineHtml(input)).toBe('x');
});
test('target=_blank forces rel=noopener (author rel is replaced)', () => {
expect(sanitizeInlineHtml('<a href="https://e.com" target="_blank">x</a>'))
.toBe('<a href="https://e.com" target="_blank" rel="noopener">x</a>');
expect(sanitizeInlineHtml('<a href="https://e.com" target="_blank" rel="opener">x</a>'))
.toBe('<a href="https://e.com" target="_blank" rel="noopener">x</a>');
});
test('non-_blank targets and stray rel are dropped', () => {
expect(sanitizeInlineHtml('<a href="/about" target="_top" rel="x">x</a>')).toBe('<a href="/about">x</a>');
});
test('href/target/rel on a non-anchor are stripped', () => {
expect(sanitizeInlineHtml('<b href="https://e.com" target="_blank">x</b>')).toBe('<b>x</b>');
});
test('safe relative, mailto and https links survive', () => {
expect(sanitizeInlineHtml('<a href="mailto:a@b.co">m</a>')).toBe('<a href="mailto:a@b.co">m</a>');
expect(sanitizeInlineHtml('<a href="https://e.com/?a=1&amp;b=2">q</a>')).toBe('<a href="https://e.com/?a=1&amp;b=2">q</a>');
});
test('a link carrying an event handler keeps only the href', () => {
expect(sanitizeInlineHtml('<a href="https://e.com" onmouseover="alert(1)">x</a>')).toBe('<a href="https://e.com">x</a>');
});
test('quote breakout in an attribute cannot inject markup', () => {
const out = sanitizeInlineHtml('<a href="https://e.com/&quot;onmouseover=&quot;alert(1)">x</a>');
const tpl = document.createElement('template');
tpl.innerHTML = out;
expect(tpl.content.querySelector('a')!.getAttributeNames()).toEqual(['href']);
});
test('output is idempotent', () => {
const once = sanitizeInlineHtml('<a href="https://e.com" target="_blank">x</a> <b>y</b><div>z</div>');
expect(sanitizeInlineHtml(once)).toBe(once);
});
});
describe('text <-> inline html', () => {
test.each(['plain', 'a<b & "c" \'d\' >e', 'line 1\nline 2', '&lt;already&gt;', '', 'x\n\ny'])(
'round-trip inlineHtmlToText(textToInlineHtml(%j))',
(s) => {
expect(inlineHtmlToText(textToInlineHtml(s))).toBe(s);
},
);
test('inlineHtmlToText strips tags, decodes entities, <br> -> newline', () => {
expect(inlineHtmlToText('Hello <b>World</b>')).toBe('Hello World');
expect(inlineHtmlToText('a &amp; b<br>c &lt;d&gt;')).toBe('a & b\nc <d>');
});
test('textToInlineHtml escapes and turns newlines into <br>', () => {
expect(textToInlineHtml('a<b\nc')).toBe('a&lt;b<br>c');
});
test('isPlainInlineHtml: only text and <br> count as no marks', () => {
expect(isPlainInlineHtml('a &amp; b<br>c')).toBe(true);
expect(isPlainInlineHtml('a <b>b</b>')).toBe(false);
expect(isPlainInlineHtml('<br>')).toBe(true);
});
});
+171
View File
@@ -0,0 +1,171 @@
import createDOMPurify from 'dompurify';
import { escapeHtml, safeUrl } from './escape';
/**
* Inline rich text (#46) for Heading / Text blocks.
*
* Stored on the `html` prop and emitted RAW into the published page, so it is
* an XSS sink: everything goes through `sanitizeInlineHtml`, both when the
* editor commits an edit and again at export (defence in depth -- a saved
* blob or an AI `update_props` op never passed through the editor).
*
* Allowlist only: b/strong/i/em/u/a/br/span; `style` may carry `color` and
* nothing else (hex, rgb()/rgba() or a `var(--wsb-*)` token); `a href` must
* pass `safeUrl` or the link is unwrapped to its text; `target` is only ever
* `_blank` and then always gets `rel="noopener"`.
*/
export const INLINE_ALLOWED_TAGS = ['b', 'strong', 'i', 'em', 'u', 'a', 'br', 'span'];
export const INLINE_ALLOWED_ATTR = ['href', 'target', 'rel', 'style'];
// A private DOMPurify instance: HtmlBlock's `purifyHtml` adds/removes a hook
// on the shared default instance, and nothing here may ever inherit it (or
// leak into it).
let purifier: ReturnType<typeof createDOMPurify> | null = null;
function getPurifier() {
if (!purifier) purifier = createDOMPurify(window);
return purifier;
}
const PURIFY_CONFIG = {
ALLOWED_TAGS: INLINE_ALLOWED_TAGS,
ALLOWED_ATTR: INLINE_ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
KEEP_CONTENT: true,
RETURN_DOM_FRAGMENT: true,
};
const SAFE_COLOR_RE = new RegExp(
'^(?:' +
'#[0-9a-f]{3,8}' +
'|var\\(--wsb-[a-z0-9-]+\\)' +
'|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\)' +
')$',
'i',
);
/** The `color` value of an inline style string if it is a safe colour, else ''. */
function safeColorFromStyle(style: string): string {
let color = '';
for (const decl of style.split(';')) {
const idx = decl.indexOf(':');
if (idx < 0) continue;
if (decl.slice(0, idx).trim().toLowerCase() !== 'color') continue;
const value = decl.slice(idx + 1).trim();
color = SAFE_COLOR_RE.test(value) ? value : '';
}
return color;
}
function unwrap(el: Element): void {
const parent = el.parentNode;
if (!parent) return;
while (el.firstChild) parent.insertBefore(el.firstChild, el);
parent.removeChild(el);
}
/**
* Turn block boundaries that contentEditable inserts on Enter
* (`a<div>b</div>`, `<p>…</p>`) into `<br>` so a multi-line edit keeps its
* line breaks once the block tags are unwrapped by the allowlist. Runs on an
* inert <template> (no script, no resource loads) before DOMPurify.
*/
function blocksToBreaks(html: string): string {
if (!/<(div|p)\b/i.test(html)) return html;
const tpl = document.createElement('template');
tpl.innerHTML = html;
const blocks = Array.from(tpl.content.querySelectorAll('div, p')).reverse();
for (const el of blocks) {
if (el.previousSibling) el.parentNode?.insertBefore(document.createElement('br'), el);
}
return tpl.innerHTML;
}
/** Sanitise inline rich text to the allowlist above. '' for empty/non-string. */
export function sanitizeInlineHtml(html: string): string {
if (typeof html !== 'string' || html.trim() === '') return '';
const frag = getPurifier().sanitize(blocksToBreaks(html), PURIFY_CONFIG as any) as unknown as DocumentFragment;
frag.querySelectorAll('[style]').forEach((el) => {
const color = safeColorFromStyle(el.getAttribute('style') || '');
if (color) el.setAttribute('style', `color:${color}`);
else el.removeAttribute('style');
});
// Only <a> may carry href/target/rel.
frag.querySelectorAll('[href],[target],[rel]').forEach((el) => {
if (el.tagName !== 'A') {
el.removeAttribute('href');
el.removeAttribute('target');
el.removeAttribute('rel');
}
});
frag.querySelectorAll('a').forEach((a) => {
const href = safeUrl(a.getAttribute('href') || '');
if (!href) { unwrap(a); return; }
a.setAttribute('href', href);
const blank = a.getAttribute('target') === '_blank';
a.removeAttribute('target');
a.removeAttribute('rel');
if (blank) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener');
}
});
// A span without a (surviving) colour carries nothing -- drop the wrapper.
frag.querySelectorAll('span:not([style])').forEach(unwrap);
const box = document.createElement('div');
box.appendChild(frag);
return box.innerHTML;
}
/** True when sanitised inline html carries no marks (only text and <br>). */
export function isPlainInlineHtml(html: string): boolean {
return !/<(?!br\b)[a-z]/i.test(html);
}
/** Plain-text mirror of inline html: tags stripped, entities decoded, <br> -> newline. */
export function inlineHtmlToText(html: string): string {
if (typeof html !== 'string' || html === '') return '';
const tpl = document.createElement('template');
tpl.innerHTML = html;
tpl.content.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
return tpl.content.textContent || '';
}
/** Legacy plain `text` rendered as inline html: escaped, newlines -> <br>. */
export function textToInlineHtml(text: string): string {
return escapeHtml(text ?? '').replace(/\r\n?|\n/g, '<br>');
}
/**
* The sanitised rich html to show for a Heading/Text node, or null when the
* plain `text` should be used instead. `text` is the plain mirror of `html`;
* when a writer that only knows `text` (Sitesmith `update_props`, an older
* saved blob) changes it, the mirror no longer matches and the stale `html`
* is ignored -- `text` wins. A missing/empty `text` never overrides `html`.
*/
export function resolveInlineHtml(text: unknown, html: unknown): string | null {
if (typeof html !== 'string' || html === '') return null;
const clean = sanitizeInlineHtml(html);
if (clean === '') return null;
if (typeof text === 'string' && text !== '' && inlineHtmlToText(clean) !== text) return null;
return clean;
}
/** Give absolute http(s) links to another host `target="_blank"` (used on commit). */
export function markExternalLinks(root: ParentNode): void {
root.querySelectorAll('a[href]').forEach((a) => {
if (a.hasAttribute('target')) return;
try {
const url = new URL(a.getAttribute('href') || '', window.location.href);
if (/^https?:$/.test(url.protocol) && url.host !== window.location.host) {
a.setAttribute('target', '_blank');
}
} catch { /* unparseable href: leave as-is, the sanitiser decides */ }
});
}