// The text schema (spec §2.6) on both sides of the bridge: the ProseMirror // Schema the editor binds, and the Y structure the core reads and writes // (core/src/schema.rs): an XmlFragment of elements, each // holding one XmlText whose formatting attributes are the marks — b, i, // c, h with the value true, href with the one-key map {href: url}. // // The ProseMirror marks are NAMED after those attributes, and href is // stored as a map, because of y-prosemirror 1.3.7's attribute mapping. // It has no mark mapping hook: sync-plugin.js writes // `attrs[mark.type.name] = mark.attrs` and reads // `schema.mark(attrName, attrValue)`. So a mark named `b` lands as the // attribute `b` with the value {} — which the core's reader takes as // true — and a core-written `b: true` comes back as the mark `b`; the // mark `href` with attrs {href} lands as the attribute `href` with that // object as its value, so the core writes the same map. A plain string // there would throw in schema.mark, and a throw makes y-prosemirror // DELETE the Y.XmlText. Reading is tolerant on both sides: href is // either the map or a plain string (documents written before this // shape), and any truthy value sets b/i/c/h. import * as Y from 'yjs' import { Schema } from 'prosemirror-model' import { parse, run, pushRun } from './markdown.mjs' // LINK_SCHEMES is what a link may point at — the server's own // allowlist (render.go's linkSchemes), mirrored so the editor's DOM // and the rendered page agree. Everything else — a javascript: URL, // a data: document, a relative path somebody pasted — is drawn as the // text it was, with no anchor. export const LINK_SCHEMES = ['http', 'https', 'mailto'] /** The href when it is one this app links to, else '' (render.go's safeHref, rule for rule). */ export function safeHref (raw) { if (typeof raw !== 'string' || raw === '' || raw.trim() !== raw) return '' const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(raw) if (!m || !LINK_SCHEMES.includes(m[1].toLowerCase())) return '' return raw } export const schema = new Schema({ nodes: { doc: { content: 'paragraph+' }, paragraph: { content: 'text*', parseDOM: [{ tag: 'p' }], toDOM: () => ['p', 0] }, text: {} }, marks: { b: { parseDOM: [{ tag: 'strong' }, { tag: 'b' }], toDOM: () => ['strong', 0] }, i: { parseDOM: [{ tag: 'em' }, { tag: 'i' }], toDOM: () => ['em', 0] }, c: { parseDOM: [{ tag: 'code' }], toDOM: () => ['code', 0] }, h: { parseDOM: [{ tag: 'mark' }], toDOM: () => ['mark', 0] }, href: { attrs: { href: { default: null } }, inclusive: false, parseDOM: [{ tag: 'a[href]', getAttrs: dom => ({ href: dom.getAttribute('href') }) }], // A disallowed href is kept in the document (the reader is // tolerant, the writer exact) but never becomes a live anchor // in the editor's DOM: it draws as a span carrying the value. toDOM: mark => (safeHref(mark.attrs.href) ? ['a', { href: mark.attrs.href }, 0] : ['span', { 'data-href': String(mark.attrs.href ?? '') }, 0]) } } }) /** The Markdown subset as paragraphs of runs (the core's markdown::parse). */ export const paragraphsFromMarkdown = parse const MARK_KEYS = ['b', 'i', 'c', 'h'] /** The XmlText formatting attributes for a run, in the core's shape. */ export function runAttributes (r) { const attrs = {} for (const k of MARK_KEYS) if (r[k]) attrs[k] = true if (r.href != null) attrs.href = { href: r.href } return attrs } /** * The URL an `href` attribute carries: the {href: url} map both sides * write, or the plain string documents written before it carry. */ export function hrefOf (v) { if (typeof v === 'string') return v if (v !== null && typeof v === 'object' && typeof v.href === 'string') return v.href return null } /** One element holding one XmlText with the paragraph's runs. */ export function paragraphElement (paragraph) { const el = new Y.XmlElement('paragraph') const text = new Y.XmlText() el.insert(0, [text]) const delta = [] for (const r of paragraph) { if (r.t === '') continue const attrs = runAttributes(r) delta.push(Object.keys(attrs).length ? { insert: r.t, attributes: attrs } : { insert: r.t }) } if (delta.length) text.applyDelta(delta) return el } /** A new fragment holding paragraphs, ready to insert (the core's text_prelim). */ export function textFragment (paragraphs) { const f = new Y.XmlFragment() f.insert(0, paragraphs.map(paragraphElement)) return f } /** Replaces the whole content of an integrated XmlFragment with paragraphs. */ export function writeText (fragment, paragraphs) { if (fragment.length > 0) fragment.delete(0, fragment.length) fragment.insert(0, paragraphs.map(paragraphElement)) } /** The core's `truthy`: anything but null, undefined and false is on. */ function truthy (v) { return v !== null && v !== undefined && v !== false } function pushRuns (text, out) { for (const chunk of text.toDelta()) { if (typeof chunk.insert !== 'string' || chunk.insert === '') continue const a = chunk.attributes || {} pushRun(out, run(chunk.insert, { b: truthy(a.b), i: truthy(a.i), c: truthy(a.c), h: truthy(a.h), href: hrefOf(a.href) })) } } /** * The paragraphs of a text fragment, as tolerant as the core's reader: * several texts in one paragraph concatenate, a bare text at fragment * level is its own paragraph, an element inside a paragraph contributes * its string. */ export function readText (fragment) { const paragraphs = [] for (const child of fragment.toArray()) { if (child instanceof Y.XmlText) { const p = [] pushRuns(child, p) paragraphs.push(p) } else if (child instanceof Y.XmlElement) { const p = [] for (const inner of child.toArray()) { if (inner instanceof Y.XmlText) pushRuns(inner, p) else { const s = inner.toString() if (s !== '') p.push(run(s)) } } paragraphs.push(p) } else if (child instanceof Y.XmlFragment) { paragraphs.push(...readText(child)) } } return paragraphs } /** A ProseMirror doc for the fragment's paragraphs (what the stage binds). */ export function docFromParagraphs (paragraphs) { const nodes = paragraphs.map(p => schema.node('paragraph', null, p.map(r => { const marks = [] for (const k of MARK_KEYS) if (r[k]) marks.push(schema.mark(k)) if (r.href != null) marks.push(schema.mark('href', { href: r.href })) return schema.text(r.t, marks) }))) return schema.node('doc', null, nodes.length ? nodes : [schema.node('paragraph')]) }