oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

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
32use crate::schema;
33use yrs::types::text::YChange;
34use yrs::types::xml::XmlOut;
35use 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.
39pub const MARK_ATTRIBUTES: [&str; 5] = ["b", "i", "c", "h", "href"];
40
41fn tag_of(e: &yrs::XmlElementRef) -> String {
42 e.try_tag().map(|t| t.to_string()).unwrap_or_default()
43}
44
45fn 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`.
75pub 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.
124pub 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, &notes, &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)]
153mod 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