Files
summercms/admin/src/components/form/richeditor/html.ts
Jakub Zych cb2987c146 feat(17-02): richeditor field on TipTap with the untouched rule
- RichEditorField: Reka toolbar, link popover with the http/https/mailto/tel
  and relative URL policy, Edit HTML view, lossy note, skeleton while the
  lazily imported TipTap chunk loads, HTML view fallback when it fails
- untouched values are submitted byte-identical; after an edit the value is
  the editor HTML (empty paragraph -> ""); a value from outside (the save's
  sanitized answer) reloads the editor
- read-only renders through a client display allowlist, never as stored
- TipTap 3.31.4 (core, pm, starter-kit) pinned exact after the package check
- richeditor.* phrase keys (en, pl), smoke specs, forms docs
2026-10-12 15:56:59 +02:00

105 lines
2.9 KiB
TypeScript

// Link and display policy of the richeditor field (D-10, T-17-10, T-17-11).
// The server sanitizer (seaglass) is the gate on every save; these client
// checks keep the editor from offering what the gate would remove and keep a
// stored value that predates the gate from running in the admin.
import { EDITOR_TAGS, inertFragment } from './lossy'
/** Link schemes the editor applies; anything else must be a relative URL. */
export const LINK_SCHEMES: readonly string[] = ['http', 'https', 'mailto', 'tel']
// Browsers ignore ASCII control characters, spaces and DEL inside a scheme,
// so "java\tscript:" is read with them removed.
const URL_IGNORED = /[\x00-\x20\x7f]+/g
const URL_SCHEME = /^([a-z][a-z0-9+.-]*):/i
/**
* Whether url may be a link href: an http, https, mailto or tel URL, or a
* relative one (a path starting with /, #, ? or ./, or a bare relative path).
*/
export function isAllowedLink(url: string | null | undefined): boolean {
if (url == null) {
return false
}
const cleaned = url.replace(URL_IGNORED, '')
if (cleaned === '') {
return false
}
const scheme = URL_SCHEME.exec(cleaned)
if (scheme) {
return LINK_SCHEMES.includes(scheme[1]!.toLowerCase())
}
try {
new URL(cleaned, 'https://admin.invalid/')
return true
} catch {
return false
}
}
/** Elements dropped with their content when a value is displayed. */
const DROPPED = new Set([
'script',
'style',
'template',
'noscript',
'iframe',
'frame',
'frameset',
'object',
'embed',
'applet',
'svg',
'math',
'title',
'head',
'meta',
'link',
'base',
])
function clean(parent: ParentNode): void {
for (const node of Array.from(parent.childNodes)) {
if (node.nodeType === Node.COMMENT_NODE) {
node.remove()
continue
}
if (node.nodeType !== Node.ELEMENT_NODE) {
continue
}
const element = node as Element
const name = element.localName.toLowerCase()
if (DROPPED.has(name)) {
element.remove()
continue
}
clean(element)
if (!EDITOR_TAGS.has(name)) {
// Another element keeps its text: its children take its place.
element.replaceWith(...Array.from(element.childNodes))
continue
}
for (const attribute of Array.from(element.attributes)) {
const keep = name === 'a' && attribute.name === 'href' && isAllowedLink(attribute.value)
if (!keep) {
element.removeAttribute(attribute.name)
}
}
}
}
/**
* The read-only rendering of a stored value: only the editor's elements,
* only allowed link hrefs, no other attribute, no comment. It is what may be
* bound as HTML in the admin; the stored value itself is never bound.
*/
export function displayHtml(html: string): string {
if (html === '') {
return ''
}
const fragment = inertFragment(html)
clean(fragment)
const holder = document.createElement('div')
holder.append(fragment)
return holder.innerHTML
}