oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
20import * as Y from 'yjs'
21import { Schema } from 'prosemirror-model'
22import { 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.
29export 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). */
32export 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
39export 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). */
67export const paragraphsFromMarkdown = parse
68
69const MARK_KEYS = ['b', 'i', 'c', 'h']
70
71/** The XmlText formatting attributes for a run, in the core's shape. */
72export 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 */
83export 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. */
90export 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). */
105export 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. */
112export 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. */
118function truthy (v) {
119 return v !== null && v !== undefined && v !== false
120}
121
122function 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 */
142export 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). */
167export 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