Files
sm-journal-plugin/assets/src/blogeditor/extensions.ts
Jakub Zych f1e6c11107 fix(16.2): WR-05 keep only view-safe attributes in the editor schema
captureAttributes and renderCaptured dropped only on* names, so id,
name, style, srcset, background and the like from stored HTML were
rendered by ProseMirror into the live admin DOM (DOM clobbering of
document.querySelector, fixed-position overlays, remote loads). htmlAttrs
now keeps an allowlist: class, title, alt, width, height, colspan,
rowspan, start, rel, target and data-*; href and src stay with the link
and image nodes. Other attributes are dropped from a block once it is
edited; untouched documents and untouched blocks keep their bytes.
2026-10-10 16:38:25 +02:00

562 lines
16 KiB
TypeScript

import { Extension, Mark, Node, mergeAttributes, type AnyExtension } from '@tiptap/core'
import type { DOMOutputSpec } from '@tiptap/pm/model'
import Image from '@tiptap/extension-image'
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
import StarterKit from '@tiptap/starter-kit'
import { EMBED_PROVIDERS, isEmbedProvider } from './embed'
import { labelsFor, type Labels } from './labels'
/** The event a raw HTML card dispatches to open its source in the Source tab. */
export const EDIT_HTML_EVENT = 'blogeditor-edit-html'
/**
* Block elements the schema does not model. Each becomes one atomic rawHtml
* node holding its outer HTML, shown in the editor as text (D-04).
*/
export const RAW_HTML_TAGS = [
'footer',
'section',
'aside',
'header',
'nav',
'details',
'style',
'div',
'article',
'main',
'address',
'dl',
'iframe',
'video',
'audio',
'picture',
'object',
'embed',
'form',
'svg',
'math',
'script',
'noscript',
] as const
const URL_IGNORED = /[\x00-\x20\x7f]+/g
const URL_SCHEME = /^([a-z][a-z0-9+.-]*):/i
const POLICY_SCHEMES = ['http', 'https', 'mailto']
/**
* isPolicyUrl reports whether url is one the server and layer sanitizer
* policies keep (D-05, D-10 amended): a relative URL (including # and ?) or
* an http, https or mailto URL. The scheme is read after removing ASCII
* control characters, spaces and DEL, as browsers do, so "java\tscript:" is
* refused. It mirrors allowedUrl in the layer's htmlGate.ts.
*/
export function isPolicyUrl(url: string | null | undefined): boolean {
if (url == null) return false
const scheme = URL_SCHEME.exec(url.replace(URL_IGNORED, ''))
if (scheme && !POLICY_SCHEMES.includes(scheme[1]!.toLowerCase())) return false
try {
new URL(url, 'https://journal.invalid/')
return true
} catch {
return false
}
}
/**
* The attributes each node or mark models itself; htmlAttrs captures the
* view-safe others (KEPT_ATTRIBUTES).
*/
const MODELLED: Record<string, readonly string[]> = {
orderedList: ['start', 'type'],
link: ['href', 'target', 'rel', 'class', 'title'],
image: ['src', 'alt', 'title', 'width', 'height'],
figureImage: ['src', 'alt', 'title', 'width', 'height'],
tableCell: ['colspan', 'rowspan', 'colwidth'],
tableHeader: ['colspan', 'rowspan', 'colwidth'],
}
const HTML_ATTRIBUTE_TYPES = [
'paragraph',
'heading',
'blockquote',
'bulletList',
'orderedList',
'listItem',
'codeBlock',
'horizontalRule',
'hardBreak',
'table',
'tableRow',
'tableCell',
'tableHeader',
'image',
'figure',
'figcaption',
'link',
]
/**
* KEPT_ATTRIBUTES are the unmodelled attributes htmlAttrs keeps (besides
* data-*): ones that are safe to render in the live admin DOM, where
* ProseMirror draws the document (WR-05). Every other attribute (id, name,
* style, srcset, form, contenteditable, popover, event handlers and the
* like) is dropped from a block once the block is edited; an untouched
* document and every untouched block keep their stored bytes. href and src
* are kept through the link and image nodes, which check them.
*/
export const KEPT_ATTRIBUTES: readonly string[] = [
'class',
'title',
'alt',
'width',
'height',
'colspan',
'rowspan',
'start',
'rel',
'target',
]
const DATA_ATTRIBUTE = /^data-[a-z0-9][a-z0-9._-]*$/
/** isKeptAttribute reports whether htmlAttrs keeps an attribute (lower-case name). */
export function isKeptAttribute(name: string): boolean {
return KEPT_ATTRIBUTES.includes(name) || DATA_ATTRIBUTE.test(name)
}
/** captureAttributes returns the element's kept attributes outside skip, in order. */
function captureAttributes(element: HTMLElement, skip: readonly string[]): Record<string, string> | null {
const out: Record<string, string> = {}
let any = false
for (const attr of Array.from(element.attributes)) {
const name = attr.name.toLowerCase()
if (skip.includes(name) || !isKeptAttribute(name)) continue
out[name] = attr.value
any = true
}
return any ? out : null
}
function renderCaptured(captured: unknown): Record<string, string> {
if (!captured || typeof captured !== 'object') return {}
const out: Record<string, string> = {}
for (const [name, value] of Object.entries(captured as Record<string, unknown>)) {
if (!isKeptAttribute(name.toLowerCase()) || typeof value !== 'string') continue
out[name] = value
}
return out
}
/**
* HtmlAttributes keeps the view-safe attributes the schema does not model
* (KEPT_ATTRIBUTES and data-*) on paragraphs, headings, lists, list items,
* blockquotes, tables, cells, images and links, so they survive an edit
* elsewhere in the block (D-03).
*/
export const HtmlAttributes = Extension.create({
name: 'htmlAttributes',
addGlobalAttributes() {
return HTML_ATTRIBUTE_TYPES.map((type) => ({
types: [type],
attributes: {
htmlAttrs: {
default: null,
keepOnSplit: false,
parseHTML: (element: HTMLElement) => captureAttributes(element, MODELLED[type] ?? []),
renderHTML: (attributes: Record<string, unknown>) => renderCaptured(attributes.htmlAttrs),
},
},
}))
},
})
/**
* Span keeps inline spans with kept attributes (class, data-*, title) as a
* mark; a span with none of them is dropped and its text kept.
*/
export const Span = Mark.create({
name: 'span',
priority: 40,
addAttributes() {
return {
htmlAttrs: {
default: null,
parseHTML: (element: HTMLElement) => captureAttributes(element, []),
renderHTML: (attributes: Record<string, unknown>) => renderCaptured(attributes.htmlAttrs),
},
}
},
parseHTML() {
return [{ tag: 'span', getAttrs: (element) => (captureAttributes(element as HTMLElement, []) ? null : false) }]
},
renderHTML({ HTMLAttributes }) {
return ['span', HTMLAttributes, 0]
},
})
let inertDocument: Document | undefined
/**
* inert returns a document without a browsing context: markup parsed into
* it loads no image and runs no handler, so stored HTML never reaches the
* live admin DOM.
*/
export function inert(): Document {
inertDocument ??= document.implementation.createHTMLDocument('')
return inertDocument
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
rawHtml: {
/** Inserts an atomic raw HTML block. */
insertRawHtml: (html: string) => ReturnType
}
embed: {
/** Inserts an embed placeholder for an allowlisted provider. */
setEmbed: (attributes: { provider: string; id: string }) => ReturnType
}
figure: {
/** Inserts an image with a caption as figure, img and figcaption. */
setFigure: (attributes: { src: string; alt: string; caption: string }) => ReturnType
}
}
}
export interface RawHtmlOptions {
labels: Labels
}
/**
* RawHtml is an atomic block holding the outer HTML of markup the schema
* does not model (D-04). Its card renders the source with textContent,
* never as live DOM.
*/
export const RawHtml = Node.create<RawHtmlOptions>({
name: 'rawHtml',
group: 'block',
atom: true,
selectable: true,
draggable: true,
priority: 10,
addOptions() {
return { labels: labelsFor('en') }
},
addAttributes() {
return {
html: {
default: '',
parseHTML: (element: HTMLElement) => element.outerHTML,
renderHTML: () => ({}),
},
}
},
parseHTML() {
// The last rule takes a figure that is not exactly img plus an optional
// figcaption.
return [...RAW_HTML_TAGS, 'figure'].map((tag) => ({ tag, priority: 10 }))
},
renderHTML({ node }) {
// Serializers only (the editor shows the card): parsed in the inert
// document and returned whole.
const template = inert().createElement('template')
template.innerHTML = String(node.attrs.html ?? '')
// ProseMirror accepts any DOM node here; the typing names only elements.
return template.content as unknown as DOMOutputSpec
},
addCommands() {
return {
insertRawHtml:
(html) =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs: { html } }),
}
},
addNodeView() {
const labels = this.options.labels
return ({ node }) => {
const dom = document.createElement('div')
dom.className = 'blogeditor__raw'
dom.contentEditable = 'false'
const head = document.createElement('div')
head.className = 'blogeditor__card-head'
const title = document.createElement('span')
title.textContent = labels.rawHtmlCard
const edit = document.createElement('button')
edit.type = 'button'
edit.textContent = labels.editAsHtml
head.append(title, edit)
const source = document.createElement('pre')
source.className = 'blogeditor__raw-source'
source.textContent = String(node.attrs.html ?? '')
dom.append(head, source)
let current = node
edit.addEventListener('click', (event) => {
event.preventDefault()
dom.dispatchEvent(
new CustomEvent(EDIT_HTML_EVENT, { bubbles: true, detail: { html: String(current.attrs.html ?? '') } }),
)
})
return {
dom,
update: (next) => {
if (next.type !== current.type) return false
current = next
source.textContent = String(next.attrs.html ?? '')
return true
},
stopEvent: (event) => event.target === edit,
ignoreMutation: () => true,
}
}
},
})
export interface EmbedOptions {
labels: Labels
}
/**
* Embed is the allowlisted video placeholder (D-05). It renders exactly
* <figure data-embed="PROVIDER" data-id="ID"></figure>; the editor shows a
* card and never an iframe. The layer expands the placeholder on the site.
*/
export const Embed = Node.create<EmbedOptions>({
name: 'embed',
group: 'block',
atom: true,
selectable: true,
draggable: true,
priority: 70,
addOptions() {
return { labels: labelsFor('en') }
},
addAttributes() {
return {
provider: { default: null, parseHTML: (element: HTMLElement) => element.getAttribute('data-embed'), renderHTML: () => ({}) },
id: { default: null, parseHTML: (element: HTMLElement) => element.getAttribute('data-id'), renderHTML: () => ({}) },
}
},
parseHTML() {
return [
{
tag: 'figure[data-embed]',
priority: 70,
getAttrs: (element) => {
const el = element as HTMLElement
const provider = el.getAttribute('data-embed') ?? ''
const id = el.getAttribute('data-id') ?? ''
// Exactly the placeholder: two attributes and no content.
if (el.attributes.length !== 2 || el.childNodes.length !== 0) return false
if (!isEmbedProvider(provider) || !EMBED_PROVIDERS[provider].idPattern.test(id)) return false
return null
},
},
]
},
renderHTML({ node }) {
return ['figure', { 'data-embed': node.attrs.provider, 'data-id': node.attrs.id }]
},
addCommands() {
return {
setEmbed:
({ provider, id }) =>
({ commands }) => {
if (!isEmbedProvider(provider) || !EMBED_PROVIDERS[provider].idPattern.test(id)) return false
return commands.insertContent({ type: this.name, attrs: { provider, id } })
},
}
},
addNodeView() {
const labels = this.options.labels
return ({ node }) => {
const dom = document.createElement('div')
dom.className = 'blogeditor__embed'
dom.contentEditable = 'false'
const render = (n: typeof node) => {
const provider = String(n.attrs.provider ?? '')
const name = isEmbedProvider(provider) ? EMBED_PROVIDERS[provider].name : provider
dom.textContent = `${labels.embedCard}: ${name} ${String(n.attrs.id ?? '')}`
}
render(node)
let current = node
return {
dom,
update: (next) => {
if (next.type !== current.type) return false
current = next
render(next)
return true
},
ignoreMutation: () => true,
}
}
},
})
function isFigureShape(element: HTMLElement): boolean {
if (element.hasAttribute('data-embed')) return false
const children: Element[] = []
for (const child of Array.from(element.childNodes)) {
if (child.nodeType === 1) children.push(child as Element)
else if (child.nodeType === 3 && /[^ \t\r\n\f]/.test(child.nodeValue ?? '')) return false
else if (child.nodeType !== 3) return false
}
if (children.length < 1 || children.length > 2) return false
const img = children[0]!
if (img.nodeName !== 'IMG') return false
const src = img.getAttribute('src')
if (!src || /^\s*data:/i.test(src)) return false
return children.length === 1 || children[1]!.nodeName === 'FIGCAPTION'
}
/** Figure holds an image and an optional caption (D-18). */
export const Figure = Node.create({
name: 'figure',
group: 'block',
content: 'figureImage figcaption?',
draggable: true,
isolating: true,
priority: 60,
parseHTML() {
return [
{
tag: 'figure',
priority: 60,
getAttrs: (element) => (isFigureShape(element as HTMLElement) ? null : false),
},
]
},
renderHTML({ HTMLAttributes }) {
return ['figure', HTMLAttributes, 0]
},
addCommands() {
return {
setFigure:
({ src, alt, caption }) =>
({ commands }) => {
const content: Record<string, unknown>[] = [{ type: 'figureImage', attrs: { src, alt } }]
if (caption) content.push({ type: 'figcaption', content: [{ type: 'text', text: caption }] })
return commands.insertContent({ type: this.name, content })
},
}
},
})
/** FigureImage is the img inside a figure. */
export const FigureImage = Node.create({
name: 'figureImage',
atom: true,
selectable: false,
priority: 60,
addAttributes() {
return {
src: { default: null },
alt: { default: null },
title: { default: null },
width: { default: null },
height: { default: null },
}
},
parseHTML() {
return [{ tag: 'img[src]:not([src^="data:"])', context: 'figure/', priority: 60 }]
},
renderHTML({ HTMLAttributes }) {
return ['img', HTMLAttributes]
},
})
/** Figcaption is the editable caption of a figure. */
export const Figcaption = Node.create({
name: 'figcaption',
content: 'inline*',
priority: 60,
parseHTML() {
return [{ tag: 'figcaption', context: 'figure/' }]
},
renderHTML({ HTMLAttributes }) {
return ['figcaption', HTMLAttributes, 0]
},
})
/** Tables render without TipTap's colgroup and min-width style. */
export const PlainTable = Table.extend({
renderHTML({ HTMLAttributes }) {
return ['table', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), ['tbody', 0]]
},
})
// Cell alignment is kept through htmlAttrs (style or align, as stored)
// instead of TipTap's align attribute, which rewrites it as a style.
function withoutAlign<T extends Record<string, unknown>>(attributes: T | undefined): Omit<T, 'align'> {
const { align: _align, ...rest } = attributes ?? ({} as T)
return rest
}
export const PlainTableCell = TableCell.extend({
addAttributes() {
return withoutAlign(this.parent?.())
},
})
export const PlainTableHeader = TableHeader.extend({
addAttributes() {
return withoutAlign(this.parent?.())
},
})
/**
* preservationExtensions is the editor schema (D-03, D-04): StarterKit
* without the trailing node and with links that gain no target, rel or class
* and accept only policy URLs; inline images without base64; tables; and
* the preservation nodes and marks.
*/
export function preservationExtensions(labels: Labels = labelsFor('en')): AnyExtension[] {
return [
StarterKit.configure({
trailingNode: false,
link: {
openOnClick: false,
HTMLAttributes: { target: null, rel: null, class: null },
isAllowedUri: (url) => isPolicyUrl(url),
},
}),
Image.configure({ inline: true, allowBase64: false }),
PlainTable,
TableRow,
PlainTableHeader,
PlainTableCell,
Figure,
FigureImage,
Figcaption,
Embed.configure({ labels }),
RawHtml.configure({ labels }),
Span,
HtmlAttributes,
]
}