//! The accepted-shape check on a document's text (spec §2.6, codex 3). //! //! The view (`view.rs`) is tolerant: an unknown formatting attribute is //! dropped from a run, an element inside a paragraph contributes its //! string, a bare text at fragment level reads as a paragraph. That is //! the right thing for READING a document that already exists. It is //! the wrong thing to gate a write on, because the view is a projection //! and the editor does not consume the projection — it binds //! y-prosemirror to the shared text itself, and y-prosemirror 1.3.7 //! deletes an `XmlText` whose attributes name a mark its schema does //! not have (`sync-plugin.js`, `createTextNodesFromYText`'s catch). So //! an update that parses fine through the view can still take the //! words out of a block the moment somebody opens the editor. //! //! [`check`] therefore walks the shared text structure — every slide's //! `notes`, every block's `text`, every table's `cells` — and reports //! the first thing outside the §2.6 vocabulary: //! //! - a fragment child that is not a `` element (another //! element, a bare text, a nested fragment); //! - an element or fragment inside a paragraph (only text belongs //! there); //! - a run whose insert is not a string (an embed); //! - a formatting attribute that is not one of `b`, `i`, `c`, `h`, //! `href`. //! //! The host runs it after applying a posted update and before storing //! it (`internal/slides/live.go`, `previewUpdate`), and refuses the //! update when it reports anything. `fixtures/check.mjs` is the same //! walk over yjs, message for message. use crate::schema; use yrs::types::text::YChange; use yrs::types::xml::XmlOut; use yrs::{Any, Array, Doc, Out, ReadTxn, Text, Transact, XmlFragment, XmlFragmentRef}; /// The formatting attributes §2.6 defines. Anything else on a run is /// outside the vocabulary. pub const MARK_ATTRIBUTES: [&str; 5] = ["b", "i", "c", "h", "href"]; fn tag_of(e: &yrs::XmlElementRef) -> String { e.try_tag().map(|t| t.to_string()).unwrap_or_default() } fn check_text( txn: &T, text: &yrs::XmlTextRef, what: &str, paragraph: usize, ) -> Result<(), String> { for chunk in text.diff(txn, YChange::identity) { let Out::Any(Any::String(_)) = &chunk.insert else { return Err(format!( "{what}: paragraph {paragraph} holds an embed, and the text schema holds only text" )); }; if let Some(attrs) = &chunk.attributes { let mut keys: Vec<&str> = attrs.keys().map(|k| k.as_ref()).collect(); keys.sort_unstable(); for key in keys { if !MARK_ATTRIBUTES.contains(&key) { return Err(format!( "{what}: paragraph {paragraph} carries the formatting attribute {key:?}, which is not one of {}", MARK_ATTRIBUTES.join(", ") )); } } } } Ok(()) } /// Checks one text fragment. `what` names it for the message: `slide /// "x" notes`, `slide "x" block "y" text`, `slide "x" block "y" cell 3`. pub fn check_fragment( txn: &T, frag: &XmlFragmentRef, what: &str, ) -> Result<(), String> { for (i, child) in frag.children(txn).enumerate() { match child { XmlOut::Element(e) => { let tag = tag_of(&e); if tag != "paragraph" { return Err(format!( "{what}: child {i} is a <{tag}> element, and only is allowed" )); } for inner in e.children(txn) { match inner { XmlOut::Text(t) => check_text(txn, &t, what, i)?, XmlOut::Element(inner) => { return Err(format!( "{what}: paragraph {i} holds a <{}> element, and a paragraph holds only text", tag_of(&inner) )); } XmlOut::Fragment(_) => { return Err(format!( "{what}: paragraph {i} holds a nested fragment, and a paragraph holds only text" )); } } } } XmlOut::Text(_) => { return Err(format!( "{what}: child {i} is text outside a paragraph, and only is allowed" )); } XmlOut::Fragment(_) => { return Err(format!( "{what}: child {i} is a nested fragment, and only is allowed" )); } } } Ok(()) } /// Checks every text fragment in `doc`. `Ok(())` when the whole /// document is within the §2.6 vocabulary; `Err(message)` naming the /// first thing that is not. pub fn check(doc: &Doc) -> Result<(), String> { let roots = schema::roots(doc); let txn = doc.transact(); for (_, slide) in schema::ordered_maps(&txn, &roots.slides) { let sid = schema::get_string(&txn, &slide, "id").unwrap_or_default(); if let Some(notes) = schema::get_fragment(&txn, &slide, "notes") { check_fragment(&txn, ¬es, &format!("slide {sid:?} notes"))?; } let Some(blocks) = schema::get_array(&txn, &slide, "blocks") else { continue; }; for (_, block) in schema::ordered_maps(&txn, &blocks) { let bid = schema::get_string(&txn, &block, "id").unwrap_or_default(); if let Some(text) = schema::get_fragment(&txn, &block, "text") { check_fragment(&txn, &text, &format!("slide {sid:?} block {bid:?} text"))?; } if let Some(cells) = schema::get_array(&txn, &block, "cells") { for (i, cell) in cells.iter(&txn).enumerate() { if let Out::YXmlFragment(f) = cell { check_fragment(&txn, &f, &format!("slide {sid:?} block {bid:?} cell {i}"))?; } } } } } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::{apply_ops, new_doc}; use std::collections::HashMap; use std::sync::Arc; use yrs::types::xml::XmlElementPrelim; use yrs::types::Attrs; use yrs::{XmlTextPrelim, XmlTextRef}; const DECK: &str = r#"[ {"op":"add_slide","id":"sld","layout":"flow","template":"title-body"}, {"op":"set_notes","id":"sld","markdown":"Say **hello**"}, {"op":"add_block","slide":"sld","id":"blk","type":"text","markdown":"**b** *i* `c` ==h== [l](https://x)"}, {"op":"add_block","slide":"sld","id":"tbl","type":"table","props":{"rows":1,"cols":2}} ]"#; /// The block's paragraph text, for tests that write into it by hand. fn block_text(doc: &Doc) -> XmlTextRef { let roots = schema::roots(doc); let txn = doc.transact(); let (_, slide) = schema::find_by_id(&txn, &roots.slides, "sld").unwrap(); let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); let (_, blk) = schema::find_by_id(&txn, &blocks, "blk").unwrap(); let text = schema::get_fragment(&txn, &blk, "text").unwrap(); let XmlOut::Element(p) = text.get(&txn, 0).unwrap() else { panic!("a paragraph"); }; let XmlOut::Text(t) = p.get(&txn, 0).unwrap() else { panic!("a text"); }; t } fn deck() -> Doc { let doc = new_doc("check", 1); apply_ops(&doc, DECK).unwrap(); doc } #[test] fn what_the_ops_write_passes() { assert_eq!(check(&deck()), Ok(())); } #[test] fn the_empty_document_passes() { assert_eq!(check(&new_doc("empty", 1)), Ok(())); } #[test] fn y_prosemirror_shapes_pass() { // `b: {}` for an attr-less mark and the map for href are what // the browser writes; a plain-string href is what older // documents hold. All three are inside the vocabulary. let doc = deck(); let t = block_text(&doc); { let mut txn = doc.transact_mut(); let attrs = Attrs::from([ (Arc::from("b"), Any::Map(Arc::new(HashMap::new()))), (Arc::from("href"), Any::from("https://old")), ]); t.insert_with_attributes(&mut txn, 0, "x", attrs); } assert_eq!(check(&doc), Ok(())); } #[test] fn an_unknown_formatting_attribute_is_reported() { let doc = deck(); let t = block_text(&doc); { let mut txn = doc.transact_mut(); let attrs = Attrs::from([(Arc::from("unknown_mark"), Any::Bool(true))]); t.insert_with_attributes(&mut txn, 0, "lost", attrs); } // The view still reads the words, which is the whole point: // the projection passes and the document does not. let v: serde_json::Value = serde_json::from_str(&crate::view_json(&doc)).unwrap(); assert_eq!(v["slides"][0]["blocks"][0]["text"][0][0]["t"], "lost"); assert_eq!( check(&doc), Err("slide \"sld\" block \"blk\" text: paragraph 0 carries the formatting attribute \"unknown_mark\", which is not one of b, i, c, h, href".to_string()) ); } #[test] fn an_element_inside_a_paragraph_is_reported() { let doc = deck(); { let roots = schema::roots(&doc); let mut txn = doc.transact_mut(); let (_, slide) = schema::find_by_id(&txn, &roots.slides, "sld").unwrap(); let notes = schema::get_fragment(&txn, &slide, "notes").unwrap(); let XmlOut::Element(p) = notes.get(&txn, 0).unwrap() else { panic!("a paragraph"); }; p.push_back(&mut txn, XmlElementPrelim::empty("strong")); } assert_eq!( check(&doc), Err("slide \"sld\" notes: paragraph 0 holds a element, and a paragraph holds only text".to_string()) ); } #[test] fn a_non_paragraph_child_is_reported() { let doc = deck(); { let roots = schema::roots(&doc); let mut txn = doc.transact_mut(); let (_, slide) = schema::find_by_id(&txn, &roots.slides, "sld").unwrap(); let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); let (_, tbl) = schema::find_by_id(&txn, &blocks, "tbl").unwrap(); let cells = schema::get_array(&txn, &tbl, "cells").unwrap(); let Out::YXmlFragment(cell) = cells.get(&txn, 1).unwrap() else { panic!("a cell"); }; cell.push_back(&mut txn, XmlElementPrelim::empty("heading")); } assert_eq!( check(&doc), Err("slide \"sld\" block \"tbl\" cell 1: child 0 is a element, and only is allowed".to_string()) ); } #[test] fn bare_text_at_fragment_level_is_reported() { let doc = deck(); { let roots = schema::roots(&doc); let mut txn = doc.transact_mut(); let (_, slide) = schema::find_by_id(&txn, &roots.slides, "sld").unwrap(); let notes = schema::get_fragment(&txn, &slide, "notes").unwrap(); notes.push_back(&mut txn, XmlTextPrelim::new("loose")); } assert_eq!( check(&doc), Err("slide \"sld\" notes: child 1 is text outside a paragraph, and only is allowed".to_string()) ); } }