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.
562 lines
16 KiB
TypeScript
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,
|
|
]
|
|
}
|