oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1//! Rendering a ProseMirror-shaped Y.XmlFragment to text and HTML.
2//!
3//! This is the only place on the Rust side that knows ProseMirror's
4//! node vocabulary. It exists because the read view is server-rendered
5//! (spec §6.2: reading a document works with no script), so the server
6//! must be able to render what the browser's editor wrote.
7//!
8//! Unknown nodes render their children rather than disappearing. A
9//! future editor build can introduce a node this binary has never
10//! heard of, and a reader silently losing that text would be data
11//! loss, not a degradation.
12
13use std::fmt::Write as _;
14use yrs::types::xml::{XmlFragment, XmlOut, XmlTextRef};
15use yrs::{Doc, GetString, ReadTxn, Text, Transact, Xml, XmlElementRef, XmlFragmentRef};
16
17/// The CRDT root name. y-prosemirror's default, and part of the wire
18/// contract — changing it silently orphans every existing document.
19pub const ROOT: &str = "prosemirror";
20
21fn root(doc: &Doc) -> XmlFragmentRef {
22 doc.get_or_insert_xml_fragment(ROOT)
23}
24
25fn escape(s: &str, out: &mut String) {
26 for c in s.chars() {
27 match c {
28 '&' => out.push_str("&"),
29 '<' => out.push_str("&lt;"),
30 '>' => out.push_str("&gt;"),
31 '"' => out.push_str("&quot;"),
32 '\'' => out.push_str("&#39;"),
33 _ => out.push(c),
34 }
35 }
36}
37
38/// Marks this build knows, in a fixed order so output is deterministic:
39/// `(mark name, opening markup, closing markup)`. A mark not listed here
40/// contributes no wrapper but never suppresses its text.
41///
42/// Every one of these is attribute-free on the CRDT side — the mark's
43/// presence is the whole of its meaning — and the markup each one emits
44/// is a compile-time constant chosen by lookup from this table. That is
45/// what keeps `Store.HTML`'s `template.HTML` return honest
46/// (`internal/docs/store.go`): `fragment_html` escapes every text run
47/// AND puts no byte from a document into attribute position, so the
48/// markup is safe to write out unescaped. A mark carrying a value —
49/// `link`'s `href`, or a `fontFamily` naming its own face — would break
50/// that, and `POST /d/{sqid}/updates` accepts arbitrary client-supplied
51/// Yjs bytes that never went through ProseMirror's schema, so the value
52/// would be genuinely arbitrary. Adding one is a real decision about
53/// attribute escaping and (for `href`) a scheme allowlist, not a row in
54/// this table.
55///
56/// Paragraph alignment (ALIGNMENTS below) is that same discipline
57/// applied to a property that genuinely cannot be a mark: it belongs to
58/// the block, so it arrives as an attribute WITH a value — and that
59/// value is used only as a key into a table whose right-hand side is a
60/// literal in this binary.
61///
62/// The font and size marks are ordered ahead of the semantic ones so a
63/// run carrying both nests as `<span class="…"><strong>…</strong></span>`
64/// rather than the other way round.
65///
66/// All three faces have a mark; "normal" size still has none. The rule
67/// is that the DEFAULT carries no mark — it is what a document is when
68/// nobody has said otherwise, and spending a mark on it would put a
69/// span around most of the text in most documents. What this table does
70/// NOT do is decide which face that is: the default is named by
71/// `internal/fonts`' `Default`, `views/show.html`'s `[docs-text]` rule
72/// and `fixtures/fonts.mjs`'s `DEFAULT_FONT`, and every face here is
73/// spelled out so that moving it is a change to those three and not to
74/// this one. `font_serif` exists for exactly that reason: serif used to
75/// BE the absence of a mark, and became unsayable the moment it stopped
76/// being the default.
77const MARKS: &[(&str, &str, &str)] = &[
78 ("font_serif", "<span class=\"docs-font-serif\">", "</span>"),
79 ("font_sans", "<span class=\"docs-font-sans\">", "</span>"),
80 ("font_mono", "<span class=\"docs-font-mono\">", "</span>"),
81 // The point ladder. Eleven rows rather than one mark carrying a
82 // number, and that is the whole of this table's discipline applied
83 // to the feature people most expect to be a value: a mark spelling
84 // "14pt" would put a document's own byte into attribute position,
85 // which is exactly what the comment above forbids. Eleven
86 // attribute-free marks spend no new safety property at all.
87 //
88 // 12pt is absent because it is the default, and the default carries
89 // no mark — the same rule that keeps "normal" and the default face
90 // out of this table.
91 ("size_8", "<span class=\"docs-size-8\">", "</span>"),
92 ("size_9", "<span class=\"docs-size-9\">", "</span>"),
93 ("size_10", "<span class=\"docs-size-10\">", "</span>"),
94 ("size_11", "<span class=\"docs-size-11\">", "</span>"),
95 ("size_14", "<span class=\"docs-size-14\">", "</span>"),
96 ("size_18", "<span class=\"docs-size-18\">", "</span>"),
97 ("size_24", "<span class=\"docs-size-24\">", "</span>"),
98 ("size_36", "<span class=\"docs-size-36\">", "</span>"),
99 ("size_48", "<span class=\"docs-size-48\">", "</span>"),
100 ("size_72", "<span class=\"docs-size-72\">", "</span>"),
101 // The four-step scale the ladder replaced. These stay here
102 // PERMANENTLY, and not out of politeness: documents in the wild
103 // carry them, and a mark this table stops rendering is a mark whose
104 // text silently loses its size for every reader without JavaScript
105 // — the module comment's own "silence here is data loss". They are
106 // simply no longer offered by the control, and the stylesheet maps
107 // each to its nearest ladder step so one document does not read at
108 // two sizes depending on which renderer looked at it.
109 ("size_sm", "<span class=\"docs-size-sm\">", "</span>"),
110 ("size_lg", "<span class=\"docs-size-lg\">", "</span>"),
111 ("size_xl", "<span class=\"docs-size-xl\">", "</span>"),
112 ("strong", "<strong>", "</strong>"),
113 ("em", "<em>", "</em>"),
114 ("code", "<code>", "</code>"),
115];
116
117/// Paragraph alignments this build knows: `(attribute value, class
118/// name)`. The first block-level formatting, and the first thing in
119/// this file that reads a document attribute whose value is not a
120/// number.
121///
122/// That is why it is a table and not an interpolation. Alignment cannot
123/// be a mark — it belongs to the block, not the run — but it has to
124/// keep the property the MARKS table above exists to protect: nothing
125/// from a document may reach attribute position in `fragment_html`,
126/// whose output `internal/docs/store.go` returns as `template.HTML` and
127/// `views/show.html` writes into the page unescaped. So the value is
128/// only ever a key looked up here, and what gets emitted is the
129/// right-hand side, which is a literal in this binary.
130///
131/// Closed, therefore, in the strong sense: a value that is not in this
132/// table — a typo, a hand-crafted update through
133/// `POST /d/{sqid}/updates`, or a future version's `justify` — is
134/// dropped entirely. Never escaped and emitted, never passed through.
135/// Adding `justify` here later is one row plus one CSS rule plus the
136/// arithmetic in `internal/export/pdf.go`; letting an unknown value
137/// through would be a decision about attribute escaping, which is the
138/// decision this shape refuses to have to make.
139///
140/// `left` is deliberately absent. It is what a paragraph is when nobody
141/// has said otherwise, so it spends no class and no `attrs` — the same
142/// reasoning that keeps "normal" out of MARKS, and what makes
143/// a document written before alignment existed render byte-identically
144/// to one whose author chose left on purpose.
145const ALIGNMENTS: &[(&str, &str)] = &[
146 ("center", "docs-align-center"),
147 ("right", "docs-align-right"),
148];
149
150/// The alignment value, if this build knows it. `None` for left, for an
151/// absent attribute, and for anything unrecognised — the three cases
152/// that all render the same way.
153fn alignment<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> Option<&'static str> {
154 let value = e.get_attribute(txn, "align")?;
155 ALIGNMENTS
156 .iter()
157 .find(|(name, _)| *name == value)
158 .map(|(name, _)| *name)
159}
160
161/// The deepest indent this build renders. Past eight half-inch steps a
162/// paragraph on US Letter has no measure left, and `fixtures/indent.mjs`
163/// states the same limit for the editor.
164const MAX_INDENT: u8 = 8;
165
166/// The indent level, if this block has one: a whole number from 1 to
167/// `MAX_INDENT`, or `None` for 0, for an absent attribute, and for
168/// anything that does not parse.
169///
170/// A NUMBER, where `alignment` above handles a string, and that is the
171/// whole reason this is a separate function rather than another closed
172/// table. Alignment's values are strings, so the only safe thing to do
173/// with one is look it up and emit the table's own right-hand side.
174/// Indent's value is an integer, so it is handled the way `heading`'s
175/// level already is: parsed, then clamped. Both reach the property this
176/// module exists to hold — no byte from a document reaches attribute
177/// position — by different routes, and using the EXISTING route rather
178/// than inventing a third one is the point. A `u8` that has been through
179/// `clamp(1, 8)` can only be one of eight values, so interpolating it
180/// into a class name below is not the injection that interpolating an
181/// arbitrary attribute would be.
182///
183/// Clamped rather than rejected, matching `heading`'s level: a document
184/// arriving through `POST /d/{sqid}/updates` with `indent` 400 renders
185/// at 8, and so does one with 4000000. Anything that is not a run of
186/// digits — `"420; drop"`, `" 3"`, `"3.0"`, an empty string — is `None`
187/// and renders flush left. `fixtures/indent.mjs`'s `normaliseIndent`
188/// applies the same two rules, deliberately, so the two agree on every
189/// input rather than only on the ones a real editor produces.
190///
191/// (`heading`'s own level below still parses as `u8` and so still
192/// rejects 400 where it clamps 99. That is the shape this function had
193/// and stopped having; it is left alone here because changing it would
194/// change what existing documents render as, but it is not the model to
195/// copy.)
196fn indent_level<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> Option<u8> {
197 let raw = e.get_attribute(txn, "indent")?;
198 // DIGITS ONLY, then parse wide, then clamp — and each of those
199 // three is answering a disagreement with the editor rather than
200 // being careful for its own sake.
201 //
202 // This parsed as `u8` for one review cycle, and the doc comment
203 // above claimed "indent 400 renders at 8". It did not: 400
204 // overflows a u8, so the parse failed and the block rendered flush
205 // left — while `fixtures/indent.mjs` used `Number()` and rendered
206 // it at 8. The same split existed for " 3", "3.0", "1e1" and "0x3",
207 // all of which JavaScript accepts and Rust rejected. No safety
208 // property was ever spent (nothing here emits the byte either way),
209 // but the claim that a document cannot indent one distance on
210 // screen and another on paper was false for every one of them.
211 //
212 // Parsing as u32 makes "400" a number rather than an error, so it
213 // clamps like every other too-large value; requiring digits makes
214 // " 3" and "3.0" fail on BOTH sides, which is what indent.mjs's
215 // matching strictness now guarantees.
216 if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
217 return None;
218 }
219 // unwrap_or(u32::MAX), not `.ok()?`. A string that passed the
220 // digits-only check above and still fails to parse can only have
221 // failed by being too large — so treating it as "as large as
222 // possible" clamps it, where propagating the error would render it
223 // flush left. That is the last corner where this and
224 // fixtures/indent.mjs disagreed: JavaScript's Number() turns a
225 // twenty-digit run into a float far above the maximum and clamps to
226 // 8, and now so does this.
227 let level = raw
228 .parse::<u32>()
229 .unwrap_or(u32::MAX)
230 .min(MAX_INDENT as u32) as u8;
231 if level == 0 {
232 None
233 } else {
234 Some(level)
235 }
236}
237
238/// The class `fragment_html` puts on an indented block. `None` when the
239/// block is not indented, so an un-indented paragraph emits exactly the
240/// markup it did before this existed.
241fn indent_class<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> Option<String> {
242 indent_level(txn, e).map(|n| format!("docs-indent-{n}"))
243}
244
245/// The whole `class` attribute for a block, or `None` when it needs
246/// none. `extra` is whatever the caller already worked out (the
247/// alignment class, for a paragraph); the indent class is added here.
248///
249/// One function rather than each call site joining its own, because
250/// the two classes have to be separated by exactly one space and a
251/// block with only one of them must emit no stray space — the sort of
252/// thing that is obviously right in each of two places and wrong in
253/// the one where they meet.
254fn block_class<T: ReadTxn>(
255 txn: &T,
256 e: &XmlElementRef,
257 extra: Option<&'static str>,
258) -> Option<String> {
259 match (extra, indent_class(txn, e)) {
260 (Some(a), Some(i)) => Some(format!("{a} {i}")),
261 (Some(a), None) => Some(a.to_string()),
262 (None, Some(i)) => Some(i),
263 (None, None) => None,
264 }
265}
266
267/// The class `fragment_html` puts on an aligned paragraph, from the
268/// same table.
269fn align_class<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> Option<&'static str> {
270 let value = e.get_attribute(txn, "align")?;
271 ALIGNMENTS
272 .iter()
273 .find(|(name, _)| *name == value)
274 .map(|(_, class)| *class)
275}
276
277fn text_html<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut String) {
278 // Diff gives runs with their attributes, which is where marks live.
279 for chunk in t.diff(txn, yrs::types::text::YChange::identity) {
280 let mut open = Vec::new();
281 if let Some(attrs) = &chunk.attributes {
282 for (name, o, c) in MARKS {
283 if attrs.contains_key(*name) {
284 open.push((*o, *c));
285 }
286 }
287 }
288 for (o, _) in &open {
289 out.push_str(o);
290 }
291 if let yrs::Out::Any(yrs::Any::String(s)) = &chunk.insert {
292 escape(s, out);
293 }
294 for (_, c) in open.iter().rev() {
295 out.push_str(c);
296 }
297 }
298}
299
300fn node_html<T: ReadTxn>(txn: &T, node: &XmlOut, out: &mut String) {
301 match node {
302 XmlOut::Text(t) => text_html(txn, t, out),
303 XmlOut::Element(e) => {
304 let name = e.tag().to_string();
305 let (open, close) = match name.as_str() {
306 // A paragraph can be aligned, indented, both, or
307 // neither. Both classes are literals this binary builds
308 // — one from the ALIGNMENTS table, one from a u8 that
309 // has already been clamped to 1..=8 — so the joined
310 // value can only be one of a small closed set of
311 // strings, and nothing from the document reaches
312 // attribute position.
313 "paragraph" => match block_class(txn, e, align_class(txn, e)) {
314 Some(class) => (format!("<p class=\"{class}\">"), "</p>".to_string()),
315 None => ("<p>".to_string(), "</p>".to_string()),
316 },
317 "heading" => {
318 // Clamp: a corrupt or hostile level must not emit
319 // <h0> or <h99>.
320 let level = e
321 .get_attribute(txn, "level")
322 .and_then(|v| v.parse::<u8>().ok())
323 .unwrap_or(1)
324 .clamp(1, 6);
325 // Headings indent too — every word processor lets
326 // them — but they do not align: alignment is on the
327 // paragraph spec alone, here and in the editor's
328 // schema, and adding it is a change to both.
329 match block_class(txn, e, None) {
330 Some(class) => (
331 format!("<h{level} class=\"{class}\">"),
332 format!("</h{level}>"),
333 ),
334 None => (format!("<h{level}>"), format!("</h{level}>")),
335 }
336 }
337 "bullet_list" => ("<ul>".to_string(), "</ul>".to_string()),
338 "ordered_list" => ("<ol>".to_string(), "</ol>".to_string()),
339 "list_item" => ("<li>".to_string(), "</li>".to_string()),
340 "blockquote" => ("<blockquote>".to_string(), "</blockquote>".to_string()),
341 // Shift+Enter's node (prosemirror-schema-basic's own
342 // `hard_break`; the tag is the node's name because
343 // y-prosemirror builds every non-text node as an
344 // XmlElement named after its type). `<br>` is void, so
345 // it has an opening and no closing markup — the loop
346 // below still walks its children, which a real editor
347 // never gives it and a client posting raw Yjs bytes
348 // could, and they render after the break rather than
349 // vanishing, per the module comment.
350 "hard_break" => ("<br>".to_string(), String::new()),
351 // Unknown: render children with no wrapper. See the
352 // module comment — silence here is data loss.
353 _ => (String::new(), String::new()),
354 };
355 out.push_str(&open);
356 for child in e.children(txn) {
357 node_html(txn, &child, out);
358 }
359 out.push_str(&close);
360 }
361 XmlOut::Fragment(f) => {
362 for child in f.children(txn) {
363 node_html(txn, &child, out);
364 }
365 }
366 }
367}
368
369/// The document rendered as HTML, escaped and ready to place inside the
370/// page container.
371pub fn fragment_html(doc: &Doc) -> String {
372 let f = root(doc);
373 let txn = doc.transact();
374 let mut out = String::new();
375 for child in f.children(&txn) {
376 node_html(&txn, &child, &mut out);
377 }
378 out
379}
380
381fn node_text<T: ReadTxn>(txn: &T, node: &XmlOut, blocks: &mut Vec<String>, cur: &mut String) {
382 match node {
383 XmlOut::Text(t) => cur.push_str(&t.get_string(txn)),
384 XmlOut::Element(e) => {
385 let name = e.tag().to_string();
386 // A hard break ends the current line and the block carries
387 // on: one newline, where the block boundary below is a
388 // blank line. It is deliberately NOT in the is_block set —
389 // treating it as a block would turn one paragraph into two
390 // wherever a person pressed Shift+Enter.
391 if name == "hard_break" {
392 cur.push('\n');
393 return;
394 }
395 let is_block = matches!(
396 name.as_str(),
397 "paragraph" | "heading" | "list_item" | "blockquote"
398 );
399 // A text sibling that precedes this block with no block of
400 // its own (unreachable through ProseMirror's own schema,
401 // but reachable the moment a client posts arbitrary Yjs
402 // bytes — see fragment_text's own doc comment) must not
403 // run straight into this block's text with no separator:
404 // flush whatever is already pending as its own segment
405 // before this block contributes anything.
406 if is_block && !cur.is_empty() {
407 blocks.push(std::mem::take(cur));
408 }
409 let mut had_children = false;
410 for child in e.children(txn) {
411 had_children = true;
412 node_text(txn, &child, blocks, cur);
413 }
414 if is_block {
415 if !cur.is_empty() {
416 blocks.push(std::mem::take(cur));
417 } else if !had_children {
418 // A structurally empty block (`<p></p>`) still
419 // renders as a blank paragraph in fragment_html;
420 // mirror that here rather than silently dropping
421 // it, which would make the two renderers disagree
422 // about how many paragraphs a document has. A
423 // block whose children already flushed their own
424 // text (had_children but cur now empty, e.g. a
425 // list_item wrapping a paragraph) must not get a
426 // second, spurious empty entry — that is exactly
427 // what had_children distinguishes.
428 blocks.push(String::new());
429 }
430 }
431 }
432 XmlOut::Fragment(f) => {
433 for child in f.children(txn) {
434 node_text(txn, &child, blocks, cur);
435 }
436 }
437 }
438}
439
440/// The document as plain text, one blank line between blocks. Used by
441/// the list's preview and by anything that wants the words without the
442/// markup.
443///
444/// Neither ProseMirror's own schema nor its `y-prosemirror` binding can
445/// produce a fragment with a bare text node preceding a block, or a
446/// structurally empty block — so, until this task, node_text's two
447/// edge cases here were unreachable in practice: a text sibling ahead
448/// of a block used to run straight into that block's own text with no
449/// separator (`"orphanactual"` for `[text("orphan"), paragraph(text("actual"))]`),
450/// and an empty `<p></p>` that fragment_html preserves used to vanish
451/// here instead of leaving a blank line. `POST /d/{sqid}/updates`
452/// (Task 5) is what makes them reachable: it accepts arbitrary
453/// client-supplied Yjs bytes, not only what a real ProseMirror editor
454/// would ever emit. Both are fixed in node_text above and covered by
455/// core/tests/fragment.rs.
456pub fn fragment_text(doc: &Doc) -> String {
457 let f = root(doc);
458 let txn = doc.transact();
459 let mut blocks = Vec::new();
460 let mut cur = String::new();
461 for child in f.children(&txn) {
462 node_text(&txn, &child, &mut blocks, &mut cur);
463 }
464 if !cur.is_empty() {
465 blocks.push(cur);
466 }
467 blocks.join("\n\n")
468}
469
470// --- the structured renderer ---
471//
472// `fragment_html` above answers "what does this document look like";
473// `fragment_json` below answers "what is this document made of". The
474// export paths (`internal/export`, `internal/pdf`) need the second
475// question answered: where a list ends, which run is bold, which
476// paragraph is a heading. Re-deriving that in Go by parsing the markup
477// this file emits would mean a parser to keep honest against an
478// emitter, and Go's standard library has no HTML parser to do it with.
479//
480// The shape is ProseMirror's own JSON — `{"type":..,"attrs":..,"content":[..]}`,
481// text as `{"type":"text","text":..,"marks":[..]}` — because that is the
482// vocabulary the editor on the other side of the wire already speaks,
483// so a Go consumer and a future JS one describe the same document the
484// same way. Written by hand rather than through serde: the crate has
485// no serialisation dependency outside dev-dependencies, and this is
486// one escape function and three walks.
487
488/// JSON string escaping, per RFC 8259: the two mandatory escapes, the
489/// three shorthands, and `\u00XX` for everything else below U+0020.
490/// Everything at or above U+0020 other than `"` and `\` goes through
491/// as-is — the output is UTF-8 and JSON says that is legal.
492fn escape_json(s: &str, out: &mut String) {
493 for c in s.chars() {
494 match c {
495 '"' => out.push_str("\\\""),
496 '\\' => out.push_str("\\\\"),
497 '\n' => out.push_str("\\n"),
498 '\r' => out.push_str("\\r"),
499 '\t' => out.push_str("\\t"),
500 c if (c as u32) < 0x20 => {
501 let _ = write!(out, "\\u{:04x}", c as u32);
502 }
503 c => out.push(c),
504 }
505 }
506}
507
508/// The mark names on one text run: the ones this build knows in `MARKS`
509/// order, then any it does not, sorted.
510///
511/// The sort is not cosmetic. `Attrs` is a `HashMap`, so iterating it
512/// yields an order that varies between runs of the same binary on the
513/// same document — an unsorted tail would make this renderer's output
514/// nondeterministic, which would make every golden-file test on the Go
515/// side flake rather than fail. Unknown marks are carried through
516/// rather than dropped for the same reason unknown *nodes* render their
517/// children (see the module comment): a consumer that ignores a mark it
518/// does not recognise still has the text, and one that grows to
519/// understand it needs it to have been there.
520/// WHOEVER ADDS THE FIRST VALUED MARK: THIS FUNCTION IS WHERE THE
521/// ALLOWLIST GOES, AND IT MUST NOT ASSUME ONLY THAT MARK HAS A VALUE.
522///
523/// This returns NAMES. Every mark's value — including an unknown mark's
524/// — is dropped here and reaches no consumer, which is why an unknown
525/// mark carried through by name is safe today: `fragment_html` finds no
526/// MARKS row and emits no wrapper, and `text_json` writes the name
527/// through `escape_json`.
528///
529/// A valued mark changes that. The moment one value is allowed past
530/// this point, the shape of the question becomes "which marks may carry
531/// a value, and what may that value be" — and the tempting answer, that
532/// `link` is the one with an `href`, is wrong on arrival. This function
533/// collects marks it has never heard of, straight off the wire, and
534/// `POST /d/{sqid}/updates` accepts bytes that never went through
535/// ProseMirror's schema. So an unknown mark can carry an arbitrary
536/// value too, and an allowlist written around the known one would let
537/// it past.
538///
539/// It is also the right place, and that is the other half of the note:
540/// this is the single point the HTML renderer and every JSON consumer
541/// agree on what marks a run carries, so a check here cannot be
542/// re-implemented differently by `markdown.go` and `pdf.go` the way one
543/// in `node_html` would have to be. Plan 7's D1 names it.
544///
545/// (Found by the session that owns Plan 7, reading this file after the
546/// point-ladder work restructured around it — recorded here rather than
547/// only in that plan, because the plan may land long after someone else
548/// touches this function.)
549fn mark_names(attrs: &yrs::types::Attrs) -> Vec<String> {
550 let mut names: Vec<String> = MARKS
551 .iter()
552 .filter(|(name, _, _)| attrs.contains_key(*name))
553 .map(|(name, _, _)| (*name).to_string())
554 .collect();
555 let mut unknown: Vec<String> = attrs
556 .keys()
557 .map(|k| k.to_string())
558 .filter(|k| !MARKS.iter().any(|(name, _, _)| *name == k.as_str()))
559 .collect();
560 unknown.sort();
561 names.append(&mut unknown);
562 names
563}
564
565fn text_json<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut String, first: &mut bool) {
566 for chunk in t.diff(txn, yrs::types::text::YChange::identity) {
567 let yrs::Out::Any(yrs::Any::String(s)) = &chunk.insert else {
568 // A non-string insert is an embed. `fragment_html` renders
569 // nothing for one today, and inventing a JSON shape for
570 // something with no renderer would be guessing at a schema
571 // rather than reporting one.
572 continue;
573 };
574 if s.is_empty() {
575 continue;
576 }
577 if !*first {
578 out.push(',');
579 }
580 *first = false;
581 out.push_str("{\"type\":\"text\",\"text\":\"");
582 escape_json(s, out);
583 out.push('"');
584 if let Some(attrs) = &chunk.attributes {
585 let names = mark_names(attrs);
586 if !names.is_empty() {
587 out.push_str(",\"marks\":[");
588 for (i, name) in names.iter().enumerate() {
589 if i > 0 {
590 out.push(',');
591 }
592 out.push_str("{\"type\":\"");
593 escape_json(name, out);
594 out.push_str("\"}");
595 }
596 out.push(']');
597 }
598 }
599 out.push('}');
600 }
601}
602
603// Unlike the two renderers above, this one needs no case of its own
604// for `hard_break`: an element with no children already comes out as
605// `{"type":"hard_break","content":[]}`, which names the node and is
606// what internal/export reads. The empty `content` is how this renderer
607// spells every childless element (an empty paragraph included), so a
608// leaf is not a special shape here.
609fn node_json<T: ReadTxn>(txn: &T, node: &XmlOut, out: &mut String, first: &mut bool) {
610 match node {
611 XmlOut::Text(t) => text_json(txn, t, out, first),
612 XmlOut::Element(e) => {
613 let name = e.tag().to_string();
614 if !*first {
615 out.push(',');
616 }
617 *first = false;
618 out.push_str("{\"type\":\"");
619 escape_json(&name, out);
620 out.push('"');
621 // Attributes are collected and written ONCE, as a single
622 // object.
623 //
624 // This used to be two independent `if name == …` arms, each
625 // writing a complete `,"attrs":{…}`. That was correct only
626 // for as long as no node could carry two attributes: a
627 // heading has a level and a paragraph has an alignment, and
628 // never both. Indent broke that — a paragraph can be
629 // centred AND indented, and a heading can be indented too —
630 // and the old shape would have emitted `"attrs"` twice in
631 // one object. Every JSON parser accepts that and they do not
632 // agree on which wins, so it would not have failed loudly;
633 // internal/export would have quietly lost one of the two.
634 //
635 // Each value below is a number or a literal from a closed
636 // table in this binary, never a byte from the document, so
637 // nothing here needs escaping — the same property
638 // `fragment_html` depends on, held for the same reason.
639 let mut attrs: Vec<String> = Vec::new();
640 if name == "heading" {
641 // Clamped exactly as `node_html` clamps it, and for the
642 // same reason: a corrupt or hostile level must not reach
643 // a consumer as `h0` or `h99`. Both renderers clamping
644 // independently is deliberate — a document must not
645 // paginate as one level on screen and export as another.
646 let level = e
647 .get_attribute(txn, "level")
648 .and_then(|v| v.parse::<u8>().ok())
649 .unwrap_or(1)
650 .clamp(1, 6);
651 attrs.push(format!("\"level\":{level}"));
652 }
653 if name == "paragraph" {
654 // Same allowlist, same drop, and — like the heading
655 // clamp above — arrived at independently in both
656 // renderers, so a paragraph cannot read as centred on
657 // screen and export flush left. `align` is the
658 // allowlist's own key, a literal in this binary, so
659 // nothing here needs escaping and nothing from the
660 // document can appear.
661 if let Some(align) = alignment(txn, e) {
662 attrs.push(format!("\"align\":\"{align}\""));
663 }
664 }
665 if name == "paragraph" || name == "heading" {
666 if let Some(indent) = indent_level(txn, e) {
667 attrs.push(format!("\"indent\":{indent}"));
668 }
669 }
670 if !attrs.is_empty() {
671 let _ = write!(out, ",\"attrs\":{{{}}}", attrs.join(","));
672 }
673 out.push_str(",\"content\":[");
674 let mut inner = true;
675 for child in e.children(txn) {
676 node_json(txn, &child, out, &mut inner);
677 }
678 out.push_str("]}");
679 }
680 XmlOut::Fragment(f) => {
681 // Flattened into the parent's content, exactly as
682 // `node_html` renders a nested fragment's children with no
683 // wrapper of its own.
684 for child in f.children(txn) {
685 node_json(txn, &child, out, first);
686 }
687 }
688 }
689}
690
691/// The document as ProseMirror-shaped JSON: `{"type":"doc","content":[..]}`.
692///
693/// Deterministic for a given document state — see `mark_names` for the
694/// one place that took work — so a Go consumer's golden files are
695/// stable.
696pub fn fragment_json(doc: &Doc) -> String {
697 let f = root(doc);
698 let txn = doc.transact();
699 let mut out = String::from("{\"type\":\"doc\",\"content\":[");
700 let mut first = true;
701 for child in f.children(&txn) {
702 node_json(&txn, &child, &mut out, &mut first);
703 }
704 out.push_str("]}");
705 out
706}
707