| 1 | //! The accepted-shape check on a document's text (spec §2.6, codex 3). |
| 2 | //! |
| 3 | //! The view (`view.rs`) is tolerant: an unknown formatting attribute is |
| 4 | //! dropped from a run, an element inside a paragraph contributes its |
| 5 | //! string, a bare text at fragment level reads as a paragraph. That is |
| 6 | //! the right thing for READING a document that already exists. It is |
| 7 | //! the wrong thing to gate a write on, because the view is a projection |
| 8 | //! and the editor does not consume the projection — it binds |
| 9 | //! y-prosemirror to the shared text itself, and y-prosemirror 1.3.7 |
| 10 | //! deletes an `XmlText` whose attributes name a mark its schema does |
| 11 | //! not have (`sync-plugin.js`, `createTextNodesFromYText`'s catch). So |
| 12 | //! an update that parses fine through the view can still take the |
| 13 | //! words out of a block the moment somebody opens the editor. |
| 14 | //! |
| 15 | //! [`check`] therefore walks the shared text structure — every slide's |
| 16 | //! `notes`, every block's `text`, every table's `cells` — and reports |
| 17 | //! the first thing outside the §2.6 vocabulary: |
| 18 | //! |
| 19 | //! - a fragment child that is not a `<paragraph>` element (another |
| 20 | //! element, a bare text, a nested fragment); |
| 21 | //! - an element or fragment inside a paragraph (only text belongs |
| 22 | //! there); |
| 23 | //! - a run whose insert is not a string (an embed); |
| 24 | //! - a formatting attribute that is not one of `b`, `i`, `c`, `h`, |
| 25 | //! `href`. |
| 26 | //! |
| 27 | //! The host runs it after applying a posted update and before storing |
| 28 | //! it (`internal/slides/live.go`, `previewUpdate`), and refuses the |
| 29 | //! update when it reports anything. `fixtures/check.mjs` is the same |
| 30 | //! walk over yjs, message for message. |
| 31 | |
| 32 | use crate::schema; |
| 33 | use yrs::types::text::YChange; |
| 34 | use yrs::types::xml::XmlOut; |
| 35 | use yrs::{Any, Array, Doc, Out, ReadTxn, Text, Transact, XmlFragment, XmlFragmentRef}; |
| 36 | |
| 37 | /// The formatting attributes §2.6 defines. Anything else on a run is |
| 38 | /// outside the vocabulary. |
| 39 | pub const MARK_ATTRIBUTES: [&str; 5] = ["b", "i", "c", "h", "href"]; |
| 40 | |
| 41 | fn tag_of(e: &yrs::XmlElementRef) -> String { |
| 42 | e.try_tag().map(|t| t.to_string()).unwrap_or_default() |
| 43 | } |
| 44 | |
| 45 | fn check_text<T: ReadTxn>( |
| 46 | txn: &T, |
| 47 | text: &yrs::XmlTextRef, |
| 48 | what: &str, |
| 49 | paragraph: usize, |
| 50 | ) -> Result<(), String> { |
| 51 | for chunk in text.diff(txn, YChange::identity) { |
| 52 | let Out::Any(Any::String(_)) = &chunk.insert else { |
| 53 | return Err(format!( |
| 54 | "{what}: paragraph {paragraph} holds an embed, and the text schema holds only text" |
| 55 | )); |
| 56 | }; |
| 57 | if let Some(attrs) = &chunk.attributes { |
| 58 | let mut keys: Vec<&str> = attrs.keys().map(|k| k.as_ref()).collect(); |
| 59 | keys.sort_unstable(); |
| 60 | for key in keys { |
| 61 | if !MARK_ATTRIBUTES.contains(&key) { |
| 62 | return Err(format!( |
| 63 | "{what}: paragraph {paragraph} carries the formatting attribute {key:?}, which is not one of {}", |
| 64 | MARK_ATTRIBUTES.join(", ") |
| 65 | )); |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | } |
| 70 | Ok(()) |
| 71 | } |
| 72 | |
| 73 | /// Checks one text fragment. `what` names it for the message: `slide |
| 74 | /// "x" notes`, `slide "x" block "y" text`, `slide "x" block "y" cell 3`. |
| 75 | pub fn check_fragment<T: ReadTxn>( |
| 76 | txn: &T, |
| 77 | frag: &XmlFragmentRef, |
| 78 | what: &str, |
| 79 | ) -> Result<(), String> { |
| 80 | for (i, child) in frag.children(txn).enumerate() { |
| 81 | match child { |
| 82 | XmlOut::Element(e) => { |
| 83 | let tag = tag_of(&e); |
| 84 | if tag != "paragraph" { |
| 85 | return Err(format!( |
| 86 | "{what}: child {i} is a <{tag}> element, and only <paragraph> is allowed" |
| 87 | )); |
| 88 | } |
| 89 | for inner in e.children(txn) { |
| 90 | match inner { |
| 91 | XmlOut::Text(t) => check_text(txn, &t, what, i)?, |
| 92 | XmlOut::Element(inner) => { |
| 93 | return Err(format!( |
| 94 | "{what}: paragraph {i} holds a <{}> element, and a paragraph holds only text", |
| 95 | tag_of(&inner) |
| 96 | )); |
| 97 | } |
| 98 | XmlOut::Fragment(_) => { |
| 99 | return Err(format!( |
| 100 | "{what}: paragraph {i} holds a nested fragment, and a paragraph holds only text" |
| 101 | )); |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | XmlOut::Text(_) => { |
| 107 | return Err(format!( |
| 108 | "{what}: child {i} is text outside a paragraph, and only <paragraph> is allowed" |
| 109 | )); |
| 110 | } |
| 111 | XmlOut::Fragment(_) => { |
| 112 | return Err(format!( |
| 113 | "{what}: child {i} is a nested fragment, and only <paragraph> is allowed" |
| 114 | )); |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | Ok(()) |
| 119 | } |
| 120 | |
| 121 | /// Checks every text fragment in `doc`. `Ok(())` when the whole |
| 122 | /// document is within the §2.6 vocabulary; `Err(message)` naming the |
| 123 | /// first thing that is not. |
| 124 | pub fn check(doc: &Doc) -> Result<(), String> { |
| 125 | let roots = schema::roots(doc); |
| 126 | let txn = doc.transact(); |
| 127 | for (_, slide) in schema::ordered_maps(&txn, &roots.slides) { |
| 128 | let sid = schema::get_string(&txn, &slide, "id").unwrap_or_default(); |
| 129 | if let Some(notes) = schema::get_fragment(&txn, &slide, "notes") { |
| 130 | check_fragment(&txn, ¬es, &format!("slide {sid:?} notes"))?; |
| 131 | } |
| 132 | let Some(blocks) = schema::get_array(&txn, &slide, "blocks") else { |
| 133 | continue; |
| 134 | }; |
| 135 | for (_, block) in schema::ordered_maps(&txn, &blocks) { |
| 136 | let bid = schema::get_string(&txn, &block, "id").unwrap_or_default(); |
| 137 | if let Some(text) = schema::get_fragment(&txn, &block, "text") { |
| 138 | check_fragment(&txn, &text, &format!("slide {sid:?} block {bid:?} text"))?; |
| 139 | } |
| 140 | if let Some(cells) = schema::get_array(&txn, &block, "cells") { |
| 141 | for (i, cell) in cells.iter(&txn).enumerate() { |
| 142 | if let Out::YXmlFragment(f) = cell { |
| 143 | check_fragment(&txn, &f, &format!("slide {sid:?} block {bid:?} cell {i}"))?; |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | Ok(()) |
| 150 | } |
| 151 | |
| 152 | #[cfg(test)] |
| 153 | mod tests { |
| 154 | use super::*; |
| 155 | use crate::{apply_ops, new_doc}; |
| 156 | use std::collections::HashMap; |
| 157 | use std::sync::Arc; |
| 158 | use yrs::types::xml::XmlElementPrelim; |
| 159 | use yrs::types::Attrs; |
| 160 | use yrs::{XmlTextPrelim, XmlTextRef}; |
| 161 | |
| 162 | const DECK: &str = r#"[ |
| 163 | {"op":"add_slide","id":"sld","layout":"flow","template":"title-body"}, |
| 164 | {"op":"set_notes","id":"sld","markdown":"Say **hello**"}, |
| 165 | {"op":"add_block","slide":"sld","id":"blk","type":"text","markdown":"**b** *i* `c` ==h== [l](https://x)"}, |
| 166 | {"op":"add_block","slide":"sld","id":"tbl","type":"table","props":{"rows":1,"cols":2}} |
| 167 | ]"#; |
| 168 | |
| 169 | /// The block's paragraph text, for tests that write into it by hand. |
| 170 | fn block_text(doc: &Doc) -> XmlTextRef { |
| 171 | let roots = schema::roots(doc); |
| 172 | let txn = doc.transact(); |
| 173 | let (_, slide) = schema::find_by_id(&txn, &roots.slides, "sld").unwrap(); |
| 174 | let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); |
| 175 | let (_, blk) = schema::find_by_id(&txn, &blocks, "blk").unwrap(); |
| 176 | let text = schema::get_fragment(&txn, &blk, "text").unwrap(); |
| 177 | let XmlOut::Element(p) = text.get(&txn, 0).unwrap() else { |
| 178 | panic!("a paragraph"); |
| 179 | }; |
| 180 | let XmlOut::Text(t) = p.get(&txn, 0).unwrap() else { |
| 181 | panic!("a text"); |
| 182 | }; |
| 183 | t |
| 184 | } |
| 185 | |
| 186 | fn deck() -> Doc { |
| 187 | let doc = new_doc("check", 1); |
| 188 | apply_ops(&doc, DECK).unwrap(); |
| 189 | doc |
| 190 | } |
| 191 | |
| 192 | #[test] |
| 193 | fn what_the_ops_write_passes() { |
| 194 | assert_eq!(check(&deck()), Ok(())); |
| 195 | } |
| 196 | |
| 197 | #[test] |
| 198 | fn the_empty_document_passes() { |
| 199 | assert_eq!(check(&new_doc("empty", 1)), Ok(())); |
| 200 | } |
| 201 | |
| 202 | #[test] |
| 203 | fn y_prosemirror_shapes_pass() { |
| 204 | // `b: {}` for an attr-less mark and the map for href are what |
| 205 | // the browser writes; a plain-string href is what older |
| 206 | // documents hold. All three are inside the vocabulary. |
| 207 | let doc = deck(); |
| 208 | let t = block_text(&doc); |
| 209 | { |
| 210 | let mut txn = doc.transact_mut(); |
| 211 | let attrs = Attrs::from([ |
| 212 | (Arc::from("b"), Any::Map(Arc::new(HashMap::new()))), |
| 213 | (Arc::from("href"), Any::from("https://old")), |
| 214 | ]); |
| 215 | t.insert_with_attributes(&mut txn, 0, "x", attrs); |
| 216 | } |
| 217 | assert_eq!(check(&doc), Ok(())); |
| 218 | } |
| 219 | |
| 220 | #[test] |
| 221 | fn an_unknown_formatting_attribute_is_reported() { |
| 222 | let doc = deck(); |
| 223 | let t = block_text(&doc); |
| 224 | { |
| 225 | let mut txn = doc.transact_mut(); |
| 226 | let attrs = Attrs::from([(Arc::from("unknown_mark"), Any::Bool(true))]); |
| 227 | t.insert_with_attributes(&mut txn, 0, "lost", attrs); |
| 228 | } |
| 229 | // The view still reads the words, which is the whole point: |
| 230 | // the projection passes and the document does not. |
| 231 | let v: serde_json::Value = serde_json::from_str(&crate::view_json(&doc)).unwrap(); |
| 232 | assert_eq!(v["slides"][0]["blocks"][0]["text"][0][0]["t"], "lost"); |
| 233 | assert_eq!( |
| 234 | check(&doc), |
| 235 | 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()) |
| 236 | ); |
| 237 | } |
| 238 | |
| 239 | #[test] |
| 240 | fn an_element_inside_a_paragraph_is_reported() { |
| 241 | let doc = deck(); |
| 242 | { |
| 243 | let roots = schema::roots(&doc); |
| 244 | let mut txn = doc.transact_mut(); |
| 245 | let (_, slide) = schema::find_by_id(&txn, &roots.slides, "sld").unwrap(); |
| 246 | let notes = schema::get_fragment(&txn, &slide, "notes").unwrap(); |
| 247 | let XmlOut::Element(p) = notes.get(&txn, 0).unwrap() else { |
| 248 | panic!("a paragraph"); |
| 249 | }; |
| 250 | p.push_back(&mut txn, XmlElementPrelim::empty("strong")); |
| 251 | } |
| 252 | assert_eq!( |
| 253 | check(&doc), |
| 254 | Err("slide \"sld\" notes: paragraph 0 holds a <strong> element, and a paragraph holds only text".to_string()) |
| 255 | ); |
| 256 | } |
| 257 | |
| 258 | #[test] |
| 259 | fn a_non_paragraph_child_is_reported() { |
| 260 | let doc = deck(); |
| 261 | { |
| 262 | let roots = schema::roots(&doc); |
| 263 | let mut txn = doc.transact_mut(); |
| 264 | let (_, slide) = schema::find_by_id(&txn, &roots.slides, "sld").unwrap(); |
| 265 | let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); |
| 266 | let (_, tbl) = schema::find_by_id(&txn, &blocks, "tbl").unwrap(); |
| 267 | let cells = schema::get_array(&txn, &tbl, "cells").unwrap(); |
| 268 | let Out::YXmlFragment(cell) = cells.get(&txn, 1).unwrap() else { |
| 269 | panic!("a cell"); |
| 270 | }; |
| 271 | cell.push_back(&mut txn, XmlElementPrelim::empty("heading")); |
| 272 | } |
| 273 | assert_eq!( |
| 274 | check(&doc), |
| 275 | Err("slide \"sld\" block \"tbl\" cell 1: child 0 is a <heading> element, and only <paragraph> is allowed".to_string()) |
| 276 | ); |
| 277 | } |
| 278 | |
| 279 | #[test] |
| 280 | fn bare_text_at_fragment_level_is_reported() { |
| 281 | let doc = deck(); |
| 282 | { |
| 283 | let roots = schema::roots(&doc); |
| 284 | let mut txn = doc.transact_mut(); |
| 285 | let (_, slide) = schema::find_by_id(&txn, &roots.slides, "sld").unwrap(); |
| 286 | let notes = schema::get_fragment(&txn, &slide, "notes").unwrap(); |
| 287 | notes.push_back(&mut txn, XmlTextPrelim::new("loose")); |
| 288 | } |
| 289 | assert_eq!( |
| 290 | check(&doc), |
| 291 | Err("slide \"sld\" notes: child 1 is text outside a paragraph, and only <paragraph> is allowed".to_string()) |
| 292 | ); |
| 293 | } |
| 294 | } |
| 295 | |