Files
site-builder/craft/src/utils/inline-html.test.ts
T
shadowdaoandClaude Opus 5.5 7e8812a752 feat(site-builder): inline bold/italic/underline/links on Heading and Text
Select text inside a selected Heading or Text block and a floating
toolbar offers B / I / U / Link / Unlink (execCommand; mousedown is
prevented so the selection survives; mod+b/i/u handled on the editable).

Storage: new `html` prop holding sanitised inline HTML, with `text` kept
as the plain mirror for AI summaries, search and legacy content.
Unmarked edits store html '' so legacy nodes keep their shape. The
allowlist (b strong i em u a br span; style=color only, hex/rgb/
var(--wsb-*); href via DOMPurify URI check AND safeUrl, unsafe links
unwrapped; target only _blank with forced rel=noopener) runs on commit,
on canvas render and again at export. If `text` was changed without
`html` (Sitesmith update_props), the stale html is ignored.

Each edit commits once (blur, or deselect if no blur came first) as one
setProp = one undo step; the old blur+deselect double commit is gone.
Heading colour default now tokenRef('text'). Sitesmith canvas summary
skips `html`.

Refs #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:41:56 -07:00

119 lines
5.2 KiB
TypeScript

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);
});
});