| 1 | // The text schema (spec §2.6) on both sides of the bridge: the ProseMirror |
| 2 | // Schema the editor binds, and the Y structure the core reads and writes |
| 3 | // (core/src/schema.rs): an XmlFragment of <paragraph> elements, each |
| 4 | // holding one XmlText whose formatting attributes are the marks — b, i, |
| 5 | // c, h with the value true, href with the one-key map {href: url}. |
| 6 | // |
| 7 | // The ProseMirror marks are NAMED after those attributes, and href is |
| 8 | // stored as a map, because of y-prosemirror 1.3.7's attribute mapping. |
| 9 | // It has no mark mapping hook: sync-plugin.js writes |
| 10 | // `attrs[mark.type.name] = mark.attrs` and reads |
| 11 | // `schema.mark(attrName, attrValue)`. So a mark named `b` lands as the |
| 12 | // attribute `b` with the value {} — which the core's reader takes as |
| 13 | // true — and a core-written `b: true` comes back as the mark `b`; the |
| 14 | // mark `href` with attrs {href} lands as the attribute `href` with that |
| 15 | // object as its value, so the core writes the same map. A plain string |
| 16 | // there would throw in schema.mark, and a throw makes y-prosemirror |
| 17 | // DELETE the Y.XmlText. Reading is tolerant on both sides: href is |
| 18 | // either the map or a plain string (documents written before this |
| 19 | // shape), and any truthy value sets b/i/c/h. |
| 20 | import * as Y from 'yjs' |
| 21 | import { Schema } from 'prosemirror-model' |
| 22 | import { parse, run, pushRun } from './markdown.mjs' |
| 23 | |
| 24 | // LINK_SCHEMES is what a link may point at — the server's own |
| 25 | // allowlist (render.go's linkSchemes), mirrored so the editor's DOM |
| 26 | // and the rendered page agree. Everything else — a javascript: URL, |
| 27 | // a data: document, a relative path somebody pasted — is drawn as the |
| 28 | // text it was, with no anchor. |
| 29 | export const LINK_SCHEMES = ['http', 'https', 'mailto'] |
| 30 | |
| 31 | /** The href when it is one this app links to, else '' (render.go's safeHref, rule for rule). */ |
| 32 | export function safeHref (raw) { |
| 33 | if (typeof raw !== 'string' || raw === '' || raw.trim() !== raw) return '' |
| 34 | const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(raw) |
| 35 | if (!m || !LINK_SCHEMES.includes(m[1].toLowerCase())) return '' |
| 36 | return raw |
| 37 | } |
| 38 | |
| 39 | export const schema = new Schema({ |
| 40 | nodes: { |
| 41 | doc: { content: 'paragraph+' }, |
| 42 | paragraph: { |
| 43 | content: 'text*', |
| 44 | parseDOM: [{ tag: 'p' }], |
| 45 | toDOM: () => ['p', 0] |
| 46 | }, |
| 47 | text: {} |
| 48 | }, |
| 49 | marks: { |
| 50 | b: { parseDOM: [{ tag: 'strong' }, { tag: 'b' }], toDOM: () => ['strong', 0] }, |
| 51 | i: { parseDOM: [{ tag: 'em' }, { tag: 'i' }], toDOM: () => ['em', 0] }, |
| 52 | c: { parseDOM: [{ tag: 'code' }], toDOM: () => ['code', 0] }, |
| 53 | h: { parseDOM: [{ tag: 'mark' }], toDOM: () => ['mark', 0] }, |
| 54 | href: { |
| 55 | attrs: { href: { default: null } }, |
| 56 | inclusive: false, |
| 57 | parseDOM: [{ tag: 'a[href]', getAttrs: dom => ({ href: dom.getAttribute('href') }) }], |
| 58 | // A disallowed href is kept in the document (the reader is |
| 59 | // tolerant, the writer exact) but never becomes a live anchor |
| 60 | // in the editor's DOM: it draws as a span carrying the value. |
| 61 | toDOM: mark => (safeHref(mark.attrs.href) ? ['a', { href: mark.attrs.href }, 0] : ['span', { 'data-href': String(mark.attrs.href ?? '') }, 0]) |
| 62 | } |
| 63 | } |
| 64 | }) |
| 65 | |
| 66 | /** The Markdown subset as paragraphs of runs (the core's markdown::parse). */ |
| 67 | export const paragraphsFromMarkdown = parse |
| 68 | |
| 69 | const MARK_KEYS = ['b', 'i', 'c', 'h'] |
| 70 | |
| 71 | /** The XmlText formatting attributes for a run, in the core's shape. */ |
| 72 | export function runAttributes (r) { |
| 73 | const attrs = {} |
| 74 | for (const k of MARK_KEYS) if (r[k]) attrs[k] = true |
| 75 | if (r.href != null) attrs.href = { href: r.href } |
| 76 | return attrs |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * The URL an `href` attribute carries: the {href: url} map both sides |
| 81 | * write, or the plain string documents written before it carry. |
| 82 | */ |
| 83 | export function hrefOf (v) { |
| 84 | if (typeof v === 'string') return v |
| 85 | if (v !== null && typeof v === 'object' && typeof v.href === 'string') return v.href |
| 86 | return null |
| 87 | } |
| 88 | |
| 89 | /** One <paragraph> element holding one XmlText with the paragraph's runs. */ |
| 90 | export function paragraphElement (paragraph) { |
| 91 | const el = new Y.XmlElement('paragraph') |
| 92 | const text = new Y.XmlText() |
| 93 | el.insert(0, [text]) |
| 94 | const delta = [] |
| 95 | for (const r of paragraph) { |
| 96 | if (r.t === '') continue |
| 97 | const attrs = runAttributes(r) |
| 98 | delta.push(Object.keys(attrs).length ? { insert: r.t, attributes: attrs } : { insert: r.t }) |
| 99 | } |
| 100 | if (delta.length) text.applyDelta(delta) |
| 101 | return el |
| 102 | } |
| 103 | |
| 104 | /** A new fragment holding paragraphs, ready to insert (the core's text_prelim). */ |
| 105 | export function textFragment (paragraphs) { |
| 106 | const f = new Y.XmlFragment() |
| 107 | f.insert(0, paragraphs.map(paragraphElement)) |
| 108 | return f |
| 109 | } |
| 110 | |
| 111 | /** Replaces the whole content of an integrated XmlFragment with paragraphs. */ |
| 112 | export function writeText (fragment, paragraphs) { |
| 113 | if (fragment.length > 0) fragment.delete(0, fragment.length) |
| 114 | fragment.insert(0, paragraphs.map(paragraphElement)) |
| 115 | } |
| 116 | |
| 117 | /** The core's `truthy`: anything but null, undefined and false is on. */ |
| 118 | function truthy (v) { |
| 119 | return v !== null && v !== undefined && v !== false |
| 120 | } |
| 121 | |
| 122 | function pushRuns (text, out) { |
| 123 | for (const chunk of text.toDelta()) { |
| 124 | if (typeof chunk.insert !== 'string' || chunk.insert === '') continue |
| 125 | const a = chunk.attributes || {} |
| 126 | pushRun(out, run(chunk.insert, { |
| 127 | b: truthy(a.b), |
| 128 | i: truthy(a.i), |
| 129 | c: truthy(a.c), |
| 130 | h: truthy(a.h), |
| 131 | href: hrefOf(a.href) |
| 132 | })) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * The paragraphs of a text fragment, as tolerant as the core's reader: |
| 138 | * several texts in one paragraph concatenate, a bare text at fragment |
| 139 | * level is its own paragraph, an element inside a paragraph contributes |
| 140 | * its string. |
| 141 | */ |
| 142 | export function readText (fragment) { |
| 143 | const paragraphs = [] |
| 144 | for (const child of fragment.toArray()) { |
| 145 | if (child instanceof Y.XmlText) { |
| 146 | const p = [] |
| 147 | pushRuns(child, p) |
| 148 | paragraphs.push(p) |
| 149 | } else if (child instanceof Y.XmlElement) { |
| 150 | const p = [] |
| 151 | for (const inner of child.toArray()) { |
| 152 | if (inner instanceof Y.XmlText) pushRuns(inner, p) |
| 153 | else { |
| 154 | const s = inner.toString() |
| 155 | if (s !== '') p.push(run(s)) |
| 156 | } |
| 157 | } |
| 158 | paragraphs.push(p) |
| 159 | } else if (child instanceof Y.XmlFragment) { |
| 160 | paragraphs.push(...readText(child)) |
| 161 | } |
| 162 | } |
| 163 | return paragraphs |
| 164 | } |
| 165 | |
| 166 | /** A ProseMirror doc for the fragment's paragraphs (what the stage binds). */ |
| 167 | export function docFromParagraphs (paragraphs) { |
| 168 | const nodes = paragraphs.map(p => schema.node('paragraph', null, p.map(r => { |
| 169 | const marks = [] |
| 170 | for (const k of MARK_KEYS) if (r[k]) marks.push(schema.mark(k)) |
| 171 | if (r.href != null) marks.push(schema.mark('href', { href: r.href })) |
| 172 | return schema.text(r.t, marks) |
| 173 | }))) |
| 174 | return schema.node('doc', null, nodes.length ? nodes : [schema.node('paragraph')]) |
| 175 | } |
| 176 | |