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 = { 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 | null { const out: Record = {} 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 { if (!captured || typeof captured !== 'object') return {} const out: Record = {} for (const [name, value] of Object.entries(captured as Record)) { 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) => 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) => 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 { 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({ 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 *
; the editor shows a * card and never an iframe. The layer expands the placeholder on the site. */ export const Embed = Node.create({ 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[] = [{ 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>(attributes: T | undefined): Omit { 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, ] }