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
This commit is contained in:
2026-10-12 15:56:59 +02:00
parent 2181c2b5f6
commit cb2987c146
17 changed files with 1668 additions and 2 deletions

View File

@@ -53,7 +53,7 @@ const describedBy = computed(() =>
<span v-if="labelsGroup && field.label" :id="`${controlId}-label`" class="font-semibold">
{{ field.label }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
</span>
<label v-else-if="!selfLabelled && !labelsGroup" :for="controlId" class="font-semibold">
<label v-else-if="!selfLabelled && !labelsGroup" :id="`${controlId}-label`" :for="controlId" class="font-semibold">
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
</label>
<FieldRenderer

View File

@@ -21,7 +21,7 @@ const props = defineProps<{
}>()
/** Types rendered by their own control in its read-only mode. */
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor'])
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor'])
const controlId = computed(() => `${props.idPrefix ?? 'preview'}-${props.field.name}`)
const labelId = computed(() => `${controlId.value}-label`)

View File

@@ -0,0 +1,424 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch, type Component, type ComponentPublicInstance } from 'vue'
import { Bold, Code, Heading3, Info, Italic, Link, List, ListOrdered, Quote, Redo2, Underline, Undo2, Unlink } from '@lucide/vue'
import { ToolbarButton, ToolbarRoot, ToolbarSeparator } from 'reka-ui'
import { t } from '../../../app/i18n'
import { controlClass, type FieldControlProps } from '../control'
import { displayHtml } from '../richeditor/html'
import { isLossy } from '../richeditor/lossy'
import LinkPopover from '../richeditor/LinkPopover.vue'
import type { Editor } from '../richeditor/editor'
// type: richeditor (D-10, UI-SPEC RichEditorField): an HTML WYSIWYG on
// TipTap, which loads as its own chunk (a skeleton meanwhile; when the chunk
// fails, the HTML view with richeditor.load_failed). Value contract:
// - untouched rule: nothing is emitted until the administrator edits, so a
// loaded value is saved byte-identical and never re-serialized by TipTap;
// - after an edit in the editor the value is its HTML, "" for an empty
// paragraph; the HTML view emits the textarea text as typed;
// - a new value from outside (a save's answer, the sanitized HTML) replaces
// the document and makes the field untouched again.
// Read-only (preview) shows the value through displayHtml, never as stored.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
type EditorModule = typeof import('../richeditor/editor')
type Status = 'loading' | 'ready' | 'failed'
const readOnly = computed(() => !!props.field.readOnly)
const stored = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
const display = computed(() => (readOnly.value ? displayHtml(stored.value) : ''))
const status = ref<Status>('loading')
const source = ref(false)
const sourceText = ref('')
const lossy = ref(isLossy(stored.value))
const mount = ref<HTMLElement | null>(null)
const linkButton = ref<ComponentPublicInstance | null>(null)
const editor = shallowRef<Editor | null>(null)
// Bumped on every editor transaction, so the toolbar state recomputes.
const tick = ref(0)
const linkOpen = ref(false)
const linkHref = ref('')
let module: EditorModule | null = null
let disposed = false
// The last value this field emitted: its echo through modelValue is not an
// outside change.
let lastEmitted: string | null = null
// The HTML view's text when it opened: leaving it unchanged re-parses nothing.
let sourceOpenedWith = ''
const labelId = computed(() => `${props.controlId}-label`)
const containerClass = computed(() => [
controlClass(props.invalid).replace(' px-3.5', ''),
'overflow-hidden focus-within:border-primary focus-within:shadow-[0_0_0_3px_var(--c-ring)]',
])
function areaAttributes(): Record<string, string> {
const out: Record<string, string> = {
class: 'summer-markdown min-h-[160px] max-h-[480px] overflow-y-auto px-3.5 py-2.5 outline-none',
role: 'textbox',
'aria-multiline': 'true',
'aria-labelledby': labelId.value,
'data-richeditor-area': '',
}
if (!source.value) {
out.id = props.controlId
}
if (props.describedBy) {
out['aria-describedby'] = props.describedBy
}
if (props.invalid) {
out['aria-invalid'] = 'true'
}
return out
}
function setValue(value: string): void {
lastEmitted = value
emit('update:modelValue', value)
}
function onEditorChange(): void {
if (editor.value && module) {
setValue(module.editorValue(editor.value))
}
}
onMounted(async () => {
if (readOnly.value) {
return
}
try {
module = await import('../richeditor/editor')
} catch {
if (disposed) {
return
}
status.value = 'failed'
sourceText.value = stored.value
sourceOpenedWith = stored.value
source.value = true
return
}
if (disposed) {
return
}
status.value = 'ready'
await nextTick()
if (disposed || !mount.value) {
return
}
editor.value = module.createRichEditor({
element: mount.value,
content: stored.value,
attributes: areaAttributes(),
onChange: onEditorChange,
onTransaction: () => {
tick.value++
},
})
})
onBeforeUnmount(() => {
disposed = true
editor.value?.destroy()
editor.value = null
})
// A value from outside (load, the save's answer): show it, untouched again.
watch(stored, (value) => {
if (value === lastEmitted) {
return
}
lastEmitted = null
lossy.value = isLossy(value)
if (source.value) {
sourceText.value = value
sourceOpenedWith = value
}
if (editor.value && module) {
module.loadContent(editor.value, value)
}
})
watch([() => props.invalid, () => props.describedBy, source], () => {
editor.value?.setOptions({ editorProps: { attributes: areaAttributes() } })
})
interface Tool {
key: string
icon: Component
/** Full phrase key: the SPA key check reads literals only. */
label: string
run: (editor: Editor) => void
active?: (editor: Editor) => boolean
enabled?: (editor: Editor) => boolean
}
const groups: Tool[][] = [
[
{ key: 'bold', icon: Bold, label: 'backend::lang.richeditor.bold', run: (e) => e.chain().focus().toggleBold().run(), active: (e) => e.isActive('bold') },
{ key: 'italic', icon: Italic, label: 'backend::lang.richeditor.italic', run: (e) => e.chain().focus().toggleItalic().run(), active: (e) => e.isActive('italic') },
{
key: 'underline',
icon: Underline,
label: 'backend::lang.richeditor.underline',
run: (e) => e.chain().focus().toggleUnderline().run(),
active: (e) => e.isActive('underline'),
},
],
[
{
key: 'heading',
icon: Heading3,
label: 'backend::lang.richeditor.heading',
run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(),
active: (e) => e.isActive('heading', { level: 3 }),
},
{
key: 'bullet_list',
icon: List,
label: 'backend::lang.richeditor.bullet_list',
run: (e) => e.chain().focus().toggleBulletList().run(),
active: (e) => e.isActive('bulletList'),
},
{
key: 'ordered_list',
icon: ListOrdered,
label: 'backend::lang.richeditor.ordered_list',
run: (e) => e.chain().focus().toggleOrderedList().run(),
active: (e) => e.isActive('orderedList'),
},
{ key: 'quote', icon: Quote, label: 'backend::lang.richeditor.quote', run: (e) => e.chain().focus().toggleBlockquote().run(), active: (e) => e.isActive('blockquote') },
],
]
const history: Tool[] = [
{ key: 'undo', icon: Undo2, label: 'backend::lang.richeditor.undo', run: (e) => e.chain().focus().undo().run(), enabled: (e) => e.can().undo() },
{ key: 'redo', icon: Redo2, label: 'backend::lang.richeditor.redo', run: (e) => e.chain().focus().redo().run(), enabled: (e) => e.can().redo() },
]
/** The toolbar's view of the editor; recomputed after every transaction. */
const view = computed(() => {
void tick.value
const current = editor.value
if (!current || source.value) {
return null
}
return {
editor: current,
inLink: current.isActive('link'),
hasSelection: !current.state.selection.empty,
}
})
function pressed(tool: Tool): 'true' | 'false' | undefined {
if (!tool.active) {
return undefined
}
return view.value && tool.active(view.value.editor) ? 'true' : 'false'
}
function disabled(tool: Tool): boolean {
const state = view.value
return !state || (tool.enabled !== undefined && !tool.enabled(state.editor))
}
function run(tool: Tool): void {
if (view.value && !disabled(tool)) {
tool.run(view.value.editor)
}
}
const linkEnabled = computed(() => !!view.value && (view.value.hasSelection || view.value.inLink))
function openLink(): void {
if (!view.value || !linkEnabled.value) {
return
}
const href = view.value.editor.getAttributes('link').href
linkHref.value = typeof href === 'string' ? href : ''
linkOpen.value = true
}
function applyLink(url: string): void {
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
linkOpen.value = false
}
function removeLink(): void {
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
linkOpen.value = false
}
function onLinkClosed(changed: boolean): void {
linkOpen.value = false
if (changed) {
editor.value?.commands.focus()
return
}
const element = linkButton.value?.$el as HTMLElement | undefined
element?.focus()
}
function toggleSource(): void {
if (!source.value) {
linkOpen.value = false
sourceText.value = stored.value
sourceOpenedWith = stored.value
source.value = true
return
}
// Re-parse only when the text changed; the value stays that text until
// the next edit in the editor.
if (editor.value && module && sourceText.value !== sourceOpenedWith) {
module.loadContent(editor.value, sourceText.value)
}
source.value = false
}
function onSourceInput(event: Event): void {
sourceText.value = (event.target as HTMLTextAreaElement).value
setValue(sourceText.value)
}
function onKeydown(event: KeyboardEvent): void {
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'k' && view.value) {
event.preventDefault()
openLink()
}
}
const toolClass =
'inline-flex size-8 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text aria-pressed:bg-sel aria-pressed:text-text disabled:cursor-not-allowed disabled:opacity-40'
</script>
<template>
<div
v-if="readOnly"
:id="controlId"
data-richeditor-readonly
class="rounded-control border border-border bg-subtle px-3.5 py-2.5"
>
<div v-if="display !== ''" class="summer-markdown" v-html="display" />
<span v-else class="text-muted">{{ t('backend::lang.list.empty_value') }}</span>
</div>
<div v-else-if="status === 'loading'" data-richeditor-loading aria-busy="true" class="h-[200px] rounded-control bg-skel" />
<div v-else class="flex flex-col gap-1.5">
<p v-if="status === 'failed'" data-richeditor-load-failed class="text-[13px] text-muted">
{{ t('backend::lang.richeditor.load_failed') }}
</p>
<div :data-richeditor="field.name" :class="containerClass" @keydown="onKeydown">
<ToolbarRoot
v-if="status === 'ready'"
:aria-label="t('backend::lang.richeditor.toolbar')"
:aria-controls="controlId"
class="flex flex-wrap items-center gap-1 border-b border-border bg-subtle p-1"
>
<template v-for="(group, index) in groups" :key="index">
<ToolbarSeparator v-if="index > 0" class="mx-1 h-5 w-px bg-border" />
<ToolbarButton
v-for="tool in group"
:key="tool.key"
:data-tool="tool.key"
:aria-label="t(tool.label)"
:title="t(tool.label)"
:aria-pressed="pressed(tool)"
:disabled="disabled(tool)"
:class="toolClass"
@mousedown.prevent
@click="run(tool)"
>
<component :is="tool.icon" :size="16" aria-hidden="true" />
</ToolbarButton>
</template>
<ToolbarSeparator class="mx-1 h-5 w-px bg-border" />
<LinkPopover
:open="linkOpen"
:href="linkHref"
:in-link="!!view?.inLink"
:id-prefix="controlId"
@update:open="(open) => { if (!open) linkOpen = false }"
@apply="applyLink"
@remove="removeLink"
@closed="onLinkClosed"
>
<ToolbarButton
ref="linkButton"
data-tool="link"
:aria-label="t('backend::lang.richeditor.link')"
:title="t('backend::lang.richeditor.link')"
:aria-pressed="view?.inLink ? 'true' : 'false'"
aria-haspopup="dialog"
:aria-expanded="linkOpen ? 'true' : 'false'"
:disabled="!linkEnabled"
:class="toolClass"
@mousedown.prevent
@click="openLink"
>
<Link :size="16" aria-hidden="true" />
</ToolbarButton>
</LinkPopover>
<ToolbarButton
data-tool="unlink"
:aria-label="t('backend::lang.richeditor.unlink')"
:title="t('backend::lang.richeditor.unlink')"
:disabled="!view?.inLink"
:class="toolClass"
@mousedown.prevent
@click="removeLink"
>
<Unlink :size="16" aria-hidden="true" />
</ToolbarButton>
<ToolbarSeparator class="mx-1 h-5 w-px bg-border" />
<ToolbarButton
v-for="tool in history"
:key="tool.key"
:data-tool="tool.key"
:aria-label="t(tool.label)"
:title="t(tool.label)"
:disabled="disabled(tool)"
:class="toolClass"
@mousedown.prevent
@click="run(tool)"
>
<component :is="tool.icon" :size="16" aria-hidden="true" />
</ToolbarButton>
<ToolbarButton
data-tool="source"
:aria-pressed="source ? 'true' : 'false'"
class="ml-auto inline-flex h-8 items-center gap-1.5 rounded-pager px-2 text-[13px] text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text aria-pressed:bg-sel aria-pressed:text-text"
@mousedown.prevent
@click="toggleSource"
>
<Code :size="16" aria-hidden="true" />
{{ t('backend::lang.richeditor.source') }}
</ToolbarButton>
</ToolbarRoot>
<p
v-if="lossy && status === 'ready'"
role="note"
data-richeditor-lossy
class="flex items-start gap-2 border-b border-border bg-surface px-3.5 py-2.5 text-[13px] text-muted"
>
<Info :size="14" class="mt-0.5 shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.richeditor.lossy_note') }}</span>
</p>
<div v-show="!source" ref="mount" />
<textarea
v-if="source"
:id="controlId"
data-richeditor-source
:name="field.name"
:value="sourceText"
spellcheck="false"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
class="block min-h-[160px] w-full resize-y bg-surface px-3.5 py-2.5 font-mono text-[13px] text-text outline-none"
@input="onSourceInput"
/>
</div>
</div>
</template>

View File

@@ -22,6 +22,9 @@
// MLField locale switcher around it, when the type is translatable), holds a
// form value and is labelled as a group. Look such fields up with rendererForField and
// isRegisteredField, which know the field, not only its type.
// Phase 17 adds the richeditor control (D-10): an HTML string value edited
// with a lazily loaded TipTap editor, sent unchanged until the administrator
// edits it.
import type { Component } from 'vue'
import type { FormField } from '../../api/types'
import CheckboxField from './fields/CheckboxField.vue'
@@ -34,6 +37,7 @@ import PasswordField from './fields/PasswordField.vue'
import MLPluginField from './fields/MLPluginField.vue'
import PluginField from './fields/PluginField.vue'
import PermissionEditorField from './fields/PermissionEditorField.vue'
import RichEditorField from './fields/RichEditorField.vue'
import RelationManager from '../relation/RelationManager.vue'
import RelationField from './fields/RelationField.vue'
import SwitchField from './fields/SwitchField.vue'
@@ -79,6 +83,7 @@ const renderers = new Map<string, Component>([
['mltext', MLTextField],
['mltextarea', MLTextareaField],
['mlmarkdown', MLMarkdownField],
['richeditor', RichEditorField],
])
/** Types whose control shows the label itself (toggle cards, relation manager). */

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { CircleAlert } from '@lucide/vue'
import { PopoverAnchor, PopoverContent, PopoverPortal, PopoverRoot } from 'reka-ui'
import { t } from '../../../app/i18n'
import Button from '../../ui/Button.vue'
import { controlClass } from '../control'
import { isAllowedLink } from './html'
// The richeditor link popover (UI-SPEC RichEditorField, "Link popover"),
// anchored on the toolbar's Link button (the default slot). Enter applies,
// Esc closes; on close the field decides where focus goes (`closed` tells it
// whether a link was applied or removed). A URL outside the client policy
// (http, https, mailto, tel or relative) shows richeditor.link_invalid and
// is not applied; the server sanitizer is still the gate.
const props = defineProps<{
open: boolean
/** The current link's href, or "" when the selection is not in a link. */
href: string
/** Whether the selection is inside a link: offers "Remove link". */
inLink: boolean
/** Prefix of the element ids. */
idPrefix: string
}>()
const emit = defineEmits<{
apply: [url: string]
remove: []
/** Esc or a click outside asks to close. */
'update:open': [open: boolean]
/** The popover has closed; `changed` is true after apply or remove. */
closed: [changed: boolean]
}>()
const url = ref('')
const invalid = ref(false)
const input = ref<HTMLInputElement | null>(null)
let changed = false
watch(
() => props.open,
async (open) => {
if (!open) {
return
}
url.value = props.href
invalid.value = false
changed = false
await nextTick()
input.value?.focus()
input.value?.select()
},
)
function apply(): void {
const value = url.value.trim()
if (!isAllowedLink(value)) {
invalid.value = true
input.value?.focus()
return
}
changed = true
emit('apply', value)
}
function remove(): void {
changed = true
emit('remove')
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
// The popover sits in a form: Enter applies the link, it never submits.
event.preventDefault()
apply()
}
}
function onOpenChange(open: boolean): void {
emit('update:open', open)
}
function onCloseAutoFocus(event: Event): void {
// There is no trigger to return to: the field focuses the Link button or
// the editor itself.
event.preventDefault()
emit('closed', changed)
}
</script>
<template>
<PopoverRoot :open="open" @update:open="onOpenChange">
<PopoverAnchor as-child>
<slot />
</PopoverAnchor>
<PopoverPortal>
<PopoverContent
data-richeditor-link
align="start"
:side-offset="8"
class="z-50 flex w-[320px] flex-col gap-2 rounded-inner border border-border bg-surface p-4 text-text shadow-menu"
@close-auto-focus="onCloseAutoFocus"
>
<label :for="`${idPrefix}-link-url`" class="font-semibold">{{ t('backend::lang.richeditor.link_url') }}</label>
<input
:id="`${idPrefix}-link-url`"
ref="input"
v-model="url"
type="url"
autocomplete="off"
spellcheck="false"
:class="controlClass(invalid)"
class="h-input"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="invalid ? `${idPrefix}-link-error` : undefined"
@input="invalid = false"
@keydown="onKeydown"
/>
<p v-if="invalid" :id="`${idPrefix}-link-error`" class="flex items-center gap-1.5 text-[13px] text-danger">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.richeditor.link_invalid') }}</span>
</p>
<div class="flex justify-end gap-2">
<Button v-if="inLink" variant="ghost" size="sm" data-action="richeditor-unlink" @click="remove">
{{ t('backend::lang.richeditor.unlink') }}
</Button>
<Button variant="primary" size="sm" data-action="richeditor-link-apply" @click="apply">
{{ t('backend::lang.richeditor.link_apply') }}
</Button>
</div>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>

View File

@@ -0,0 +1,71 @@
// The TipTap editor of the richeditor field (D-10). RichEditorField loads
// this module with a dynamic import, so TipTap is its own chunk and a form
// without a richeditor never downloads it. The editor mounts on an element
// the field owns (no @tiptap/vue-3) with StarterKit configured as the
// UI-SPEC says: headings level 3 only; no code, code block or horizontal
// rule; links that never open on click, never autolink and take only the
// policy URLs; no trailing node (it would append a paragraph and count as an
// edit); TipTap's injected CSS off, the content uses .summer-markdown.
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { LINK_SCHEMES, isAllowedLink } from './html'
export type { Editor }
export interface RichEditorOptions {
/** The element the editing area mounts in. */
element: HTMLElement
/** The stored HTML, parsed once; the field keeps the string itself. */
content: string
/** Attributes of the editing area (class, role, aria-*). */
attributes: Record<string, string>
/** Called after every transaction that changed the document. */
onChange: () => void
/** Called after every transaction (toolbar state). */
onTransaction: () => void
}
/** Builds the field's editor on options.element. */
export function createRichEditor(options: RichEditorOptions): Editor {
return new Editor({
element: options.element,
injectCSS: false,
content: options.content,
extensions: [
StarterKit.configure({
heading: { levels: [3] },
code: false,
codeBlock: false,
horizontalRule: false,
trailingNode: false,
link: {
openOnClick: false,
autolink: false,
protocols: [...LINK_SCHEMES],
HTMLAttributes: { target: null, rel: null, class: null },
isAllowedUri: (url) => isAllowedLink(url),
},
}),
],
editorProps: { attributes: options.attributes },
onUpdate: ({ transaction, appendedTransactions }) => {
if (transaction.docChanged || appendedTransactions.some((tr) => tr.docChanged)) {
options.onChange()
}
},
onTransaction: () => options.onTransaction(),
})
}
/**
* Replaces the document with html without an update event and outside the
* undo history, so neither the untouched rule nor undo sees the load.
*/
export function loadContent(editor: Editor, html: string): void {
editor.chain().setMeta('addToHistory', false).setContent(html, { emitUpdate: false }).run()
}
/** The editor's value: its HTML, or "" when it holds only an empty paragraph. */
export function editorValue(editor: Editor): string {
return editor.isEmpty ? '' : editor.getHTML()
}

View File

@@ -0,0 +1,104 @@
// 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
}

View File

@@ -0,0 +1,52 @@
// What the richeditor's TipTap schema can show (UI-SPEC RichEditorField,
// "Lossy note"). Stored HTML with any other element still opens: the field
// keeps the stored string until the administrator edits (the untouched
// rule), and the HTML view edits it without loss. This module has no TipTap
// import, so the field can decide before the editor chunk loads.
/** Elements the editor shows; compared by lower-cased tag name. */
export const EDITOR_TAGS: ReadonlySet<string> = new Set([
'p',
'br',
'strong',
'b',
'em',
'i',
'u',
's',
'h3',
'ul',
'ol',
'li',
'blockquote',
'a',
'div',
'span',
])
/**
* Parses html into an inert template: nothing in it runs or loads (no
* scripts, no image requests), so stored markup can be inspected safely.
*/
export function inertFragment(html: string): DocumentFragment {
const template = document.createElement('template')
template.innerHTML = html
return template.content
}
/** The distinct lower-cased tag names of html's elements, in document order. */
export function tagNames(html: string): string[] {
if (html === '') {
return []
}
const out = new Set<string>()
for (const element of Array.from(inertFragment(html).querySelectorAll('*'))) {
out.add(element.localName.toLowerCase())
}
return [...out]
}
/** Whether html holds an element the editor cannot show (the lossy note). */
export function isLossy(html: string): boolean {
return tagNames(html).some((name) => !EDITOR_TAGS.has(name))
}