From 9f7557be30d78b6c2d5ec7543955d0479489d8a2 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 14 Nov 2026 17:03:12 +0000 Subject: [PATCH] feat(rust): a DOM for @lexical/html, with DOMParser and jsdom's inline style declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The Rust port had no DOM, so nothing could run `createDOM`, `exportDOM` or HTML import and export, and the `html/*` and `css/*` vectors were not replayed. A new `html` feature brings `lexical_editor::dom`, a small DOM ported from the Swift port's (`swift/Sources/Lexical/DOM`): `DomNode` (one shared handle for `Node`, `Element`, `Text`, `Comment`, `DocumentFragment` and `Document`), `DOMTokenList`, an inline `CSSStyleDeclaration` that normalizes values as jsdom's does and keeps the `style` attribute in step, `DOMParser` (a WHATWG tokenizer and the tree builder HTML import meets), the HTML fragment serializer, and one `document` per thread (`get_document`). The property and entity tables are generated from the vectors by `rust/scripts/generate-dom-tables.mjs`, as Swift's are. The core gets `utils/setDOMStyle.ts`: `getStyleObjectFromCSS` (always, with JS's property order for array-index keys) and, with `html`, `setDOMStyleObject` and `setDOMStyleFromCSS`. The conformance runner replays `html/parse`, `html/entities`, `css/properties` and `css/values`. For `css/properties` it checks the declaration's behavior with the same probes `css.test.ts` asks jsdom, not only the table; that showed that jsdom rejects a shadow of nothing but identifiers (`box-shadow: Foo`) and lower-cases CSS-wide keywords for every property, which the declaration now does. Swift's declaration keeps such shadows (its test compares only the table); that is fixed in Swift separately. ## Test plan ### Before ``` $ cargo run -q --release -p lexical-conformance | grep -E 'css/|html/|Total' | `css/properties` | – | – | – | – | – | not implemented | | `css/values` | – | – | – | – | – | not implemented | | `html/entities` | – | – | – | – | – | not implemented | | `html/export` | – | – | – | – | – | not implemented | | `html/import` | – | – | – | – | – | not implemented | | `html/parse` | – | – | – | – | – | not implemented | | **Total** | 488 | 488 | 0 | 0 | 0 | | ``` ### After ``` $ cargo run -q --release -p lexical-conformance | grep -E 'css/|html/|Total' | `css/properties` | 814 | 814 | 0 | 0 | 0 | pass | | `css/values` | 69 | 69 | 0 | 0 | 0 | pass | | `html/entities` | 2125 | 2125 | 0 | 0 | 0 | pass | | `html/export` | – | – | – | – | – | not implemented | | `html/import` | – | – | – | – | – | not implemented | | `html/parse` | 37 | 37 | 0 | 0 | 0 | pass | | **Total** | 3533 | 3533 | 0 | 0 | 0 | | $ cargo clippy --workspace --all-targets --all-features --locked -- -D warnings # clean $ cargo test --workspace --all-features --locked # all pass ``` Before the shadow fix, `css/properties` failed `box-shadow`, `-webkit-box-shadow` and `text-shadow` (`preserveCase: Rust true, JS false`). Amp-Thread-ID: https://ampcode.com/threads/T-506c8fa1-4309-40f1-9810-732b311eaeab --- rust/DESIGN.md | 36 +- rust/PARITY.md | 2 + rust/conformance/Cargo.toml | 2 +- rust/conformance/src/suites/css.rs | 78 + rust/conformance/src/suites/html.rs | 48 + rust/conformance/src/suites/mod.rs | 22 + rust/lexical-editor/Cargo.toml | 4 + rust/lexical-editor/src/dom/css_properties.rs | 823 ++++++ .../src/dom/css_style_declaration.rs | 249 ++ rust/lexical-editor/src/dom/css_values.rs | 475 ++++ rust/lexical-editor/src/dom/html_entities.rs | 2147 ++++++++++++++++ rust/lexical-editor/src/dom/html_parser.rs | 2210 +++++++++++++++++ .../lexical-editor/src/dom/html_serializer.rs | 95 + rust/lexical-editor/src/dom/mod.rs | 42 + rust/lexical-editor/src/dom/node.rs | 798 ++++++ rust/lexical-editor/src/lib.rs | 6 + rust/lexical-editor/src/set_dom_style.rs | 313 +++ rust/scripts/generate-dom-tables.mjs | 80 + 18 files changed, 7427 insertions(+), 3 deletions(-) create mode 100644 rust/conformance/src/suites/css.rs create mode 100644 rust/conformance/src/suites/html.rs create mode 100644 rust/lexical-editor/src/dom/css_properties.rs create mode 100644 rust/lexical-editor/src/dom/css_style_declaration.rs create mode 100644 rust/lexical-editor/src/dom/css_values.rs create mode 100644 rust/lexical-editor/src/dom/html_entities.rs create mode 100644 rust/lexical-editor/src/dom/html_parser.rs create mode 100644 rust/lexical-editor/src/dom/html_serializer.rs create mode 100644 rust/lexical-editor/src/dom/mod.rs create mode 100644 rust/lexical-editor/src/dom/node.rs create mode 100644 rust/lexical-editor/src/set_dom_style.rs create mode 100644 rust/scripts/generate-dom-tables.mjs diff --git a/rust/DESIGN.md b/rust/DESIGN.md index b7e2e3b2904..d34ffb52ac6 100644 --- a/rust/DESIGN.md +++ b/rust/DESIGN.md @@ -64,7 +64,7 @@ depends on as the upstream package depends on them: | `text` | `@lexical/text` | | | `hashtag` | `@lexical/hashtag` | `text` (upstream also lists `extension` and `utils`, for `HashtagExtension` and `mergeRegister`, which aren't ported) | | `history` | `@lexical/history` | (upstream lists `utils`, but uses nothing from it) | -| `html` | `@lexical/html` | `selection` | +| `html` | `@lexical/html`, with `dom` (a DOM in place of the browser's, which the nodes' DOM hooks use) | `selection` | | `markdown` | `@lexical/markdown` | `rich-text`, `list`, `link`, `code` | | `file` | `@lexical/file` | | | `yjs` | `@lexical/yjs` | `selection`, `yrs` | @@ -139,6 +139,38 @@ Names may change as packages land; this table is updated with them. commits it at the next `read` or discrete update, which JS also does when one comes first. Update closures that may be queued are `'static`. +### The DOM + +`createDOM`, `exportDOM`, `importDOM` and HTML export and import need a +DOM, which a browser or (in upstream's unit tests) jsdom gives JS. The +`html` feature brings one, `lexical_editor::dom`, ported from the Swift +port's (`swift/Sources/Lexical/DOM`, `swift/DESIGN.md`, "The DOM is a small +model of jsdom's"), since the vectors record what jsdom does: + +- **One node type.** `DomNode` is a shared handle (`Rc`) compared by + identity, as JS compares node objects, standing for `Node` and every + interface Lexical reads through it (`Element`, `Text`, `Comment`, + `DocumentFragment`, `Document`); an element's methods answer as empty + values on other nodes. A node holds its children; its parent and + document are weak links. `get_document()` is one `document` per thread, + as JS has one global `document`. +- **The inline style declaration normalizes as jsdom's does** for what + Lexical writes (colors to `rgb()`, trimmed numbers, `0px`, lower-case + keywords, `calc()` of a product, CSS-wide keywords), and is read from the + `style` attribute lazily and written back as `cssText`, as jsdom's is. + The accepted properties and how each normalizes are a table generated + from the `css/properties` vectors (`rust/scripts/generate-dom-tables.mjs`, + which also writes the entity table from `html/entities`); the runner + checks both the tables and the declaration's behavior against them. + Unlike jsdom it keeps most values it can't parse, collapsing their + whitespace, as Swift's does; it rejects a shadow of nothing but + identifiers, as jsdom does. +- **`DOMParser` is a WHATWG tokenizer and tree builder**, checked against + jsdom's by the `html/parse` and `html/entities` vectors. As in Swift it + leaves out templates, framesets, the `` and `` insertion modes (their contents +//! parse as ordinary body content), and the case adjustments of SVG and +//! MathML names. + +use super::html_entities::{HTML_LEGACY_ENTITY_NAMES, HTML_NAMED_ENTITIES}; +use super::node::DomNode; +use std::collections::HashSet; + +/// `new DOMParser().parseFromString(html, 'text/html')`. +pub fn parse_from_string(html: &str) -> DomNode { + TreeBuilder::new(html).run() +} + +/// The text a named character reference (without `&` and `;`) decodes to. +pub fn named_entity(name: &str) -> Option<&'static str> { + HTML_NAMED_ENTITIES + .binary_search_by(|(n, _)| (*n).cmp(name)) + .ok() + .map(|i| HTML_NAMED_ENTITIES[i].1) +} + +/// How many named character references there are. +pub fn named_entity_count() -> usize { + HTML_NAMED_ENTITIES.len() +} + +/// Whether a named character reference also decodes without its semicolon. +pub fn is_legacy_entity_name(name: &str) -> bool { + HTML_LEGACY_ENTITY_NAMES.binary_search(&name).is_ok() +} + +// Tokenizer + +#[derive(Clone, Debug)] +struct Attribute { + name: String, + value: String, +} + +#[derive(Clone, Debug)] +enum Token { + Doctype(String), + StartTag { + name: String, + attributes: Vec, + self_closing: bool, + }, + EndTag(String), + Comment(String), + Characters(String), + Eof, +} + +impl Token { + fn is_start(&self, tag: &str) -> bool { + matches!(self, Token::StartTag { name, .. } if name == tag) + } + + fn is_end(&self, tag: &str) -> bool { + matches!(self, Token::EndTag(name) if name == tag) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum TokenizerState { + Data, + Rcdata, + Rawtext, + Plaintext, +} + +/// Tab, LF, FF, CR and space. +fn is_html_whitespace(c: char) -> bool { + matches!(c, ' ' | '\t' | '\n' | '\u{0C}' | '\r') +} + +/// Code points 0x80–0x9F of a numeric reference, read as windows-1252. +fn windows1252(value: u32) -> Option { + Some(match value { + 0x80 => 0x20AC, + 0x82 => 0x201A, + 0x83 => 0x0192, + 0x84 => 0x201E, + 0x85 => 0x2026, + 0x86 => 0x2020, + 0x87 => 0x2021, + 0x88 => 0x02C6, + 0x89 => 0x2030, + 0x8A => 0x0160, + 0x8B => 0x2039, + 0x8C => 0x0152, + 0x8E => 0x017D, + 0x91 => 0x2018, + 0x92 => 0x2019, + 0x93 => 0x201C, + 0x94 => 0x201D, + 0x95 => 0x2022, + 0x96 => 0x2013, + 0x97 => 0x2014, + 0x98 => 0x02DC, + 0x99 => 0x2122, + 0x9A => 0x0161, + 0x9B => 0x203A, + 0x9C => 0x0153, + 0x9E => 0x017E, + 0x9F => 0x0178, + _ => return None, + }) +} + +struct Tokenizer { + input: Vec, + pos: usize, + state: TokenizerState, + /// The name of the element whose raw text is being read. + last_start_tag: String, +} + +impl Tokenizer { + fn new(html: &str) -> Self { + // Newlines are normalized to LF before tokenizing. + let mut input = Vec::with_capacity(html.len()); + let mut previous_cr = false; + for c in html.chars() { + if c == '\r' { + input.push('\n'); + previous_cr = true; + continue; + } + if c == '\n' && previous_cr { + previous_cr = false; + continue; + } + previous_cr = false; + input.push(c); + } + Self { + input, + pos: 0, + state: TokenizerState::Data, + last_start_tag: String::new(), + } + } + + fn peek(&self, offset: usize) -> Option { + self.input.get(self.pos + offset).copied() + } + + fn string(&self, start: usize, end: usize) -> String { + self.input[start..end].iter().collect() + } + + fn next(&mut self) -> Token { + while self.pos < self.input.len() { + match self.state { + TokenizerState::Plaintext => { + let text = self.string(self.pos, self.input.len()); + self.pos = self.input.len(); + return Token::Characters(text); + } + TokenizerState::Rawtext | TokenizerState::Rcdata => { + if let Some(token) = self.next_raw_text(self.state == TokenizerState::Rcdata) { + return token; + } + } + TokenizerState::Data => { + if self.is_tag_start(self.pos) { + if let Some(token) = self.tag_open() { + return token; + } + } else { + return Token::Characters(self.data_text()); + } + } + } + } + Token::Eof + } + + /// Whether a tag, comment or declaration starts at `i` (a `<` that is + /// not literal text). + fn is_tag_start(&self, i: usize) -> bool { + if self.input[i] != '<' || i + 1 >= self.input.len() { + return false; + } + let c = self.input[i + 1]; + if c.is_ascii_alphabetic() || c == '!' || c == '?' { + return true; + } + c == '/' && i + 2 < self.input.len() + } + + fn data_text(&mut self) -> String { + let mut text = String::new(); + while self.pos < self.input.len() { + let c = self.input[self.pos]; + if c == '<' && self.is_tag_start(self.pos) { + break; + } + if c == '&' { + self.consume_character_reference(&mut text, false); + continue; + } + if c != '\0' { + text.push(c); + } + self.pos += 1; + } + text + } + + /// At `<`: the tag, comment or declaration, or `None` for one that is + /// dropped (``, a tag cut off by the end of input). + fn tag_open(&mut self) -> Option { + self.pos += 1; + let c = self.input[self.pos]; + if c == '!' { + self.pos += 1; + return Some(self.markup_declaration()); + } + if c == '?' { + return Some(self.bogus_comment()); + } + if c == '/' { + self.pos += 1; + let d = self.peek(0)?; + if d == '>' { + self.pos += 1; + return None; + } + if d.is_ascii_alphabetic() { + let (name, _, _) = self.tag()?; + return Some(Token::EndTag(name)); + } + return Some(self.bogus_comment()); + } + let (name, attributes, self_closing) = self.tag()?; + Some(Token::StartTag { + name, + attributes, + self_closing, + }) + } + + /// Text up to the next `>` as a comment. + fn bogus_comment(&mut self) -> Token { + let start = self.pos; + while self.peek(0).is_some_and(|c| c != '>') { + self.pos += 1; + } + let data: String = self.input[start..self.pos] + .iter() + .map(|&c| if c == '\0' { '\u{FFFD}' } else { c }) + .collect(); + if self.pos < self.input.len() { + self.pos += 1; + } + Token::Comment(data) + } + + fn matches_case_insensitive(&self, word: &str) -> bool { + word.chars().enumerate().all(|(k, expected)| { + self.peek(k) + .is_some_and(|c| c.eq_ignore_ascii_case(&expected)) + }) + } + + /// After ` Token { + if self.peek(0) == Some('-') && self.peek(1) == Some('-') { + self.pos += 2; + return self.comment(); + } + if self.matches_case_insensitive("doctype") { + self.pos += 7; + while self.peek(0).is_some_and(is_html_whitespace) { + self.pos += 1; + } + let mut name = String::new(); + while let Some(c) = self.peek(0) { + if is_html_whitespace(c) || c == '>' { + break; + } + name.push(c.to_ascii_lowercase()); + self.pos += 1; + } + while self.peek(0).is_some_and(|c| c != '>') { + self.pos += 1; + } + if self.pos < self.input.len() { + self.pos += 1; + } + return Token::Doctype(name); + } + self.bogus_comment() + } + + /// After `"); + } + DomNodeType::Document | DomNodeType::DocumentFragment => { + for child in node.child_nodes() { + serialize_html(&child, out); + } + } + } +} + +fn escape_html(string: &str, attribute: bool, out: &mut String) { + for c in string.chars() { + match c { + '&' => out.push_str("&"), + '\u{A0}' => out.push_str(" "), + '"' if attribute => out.push_str("""), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + c => out.push(c), + } + } +} diff --git a/rust/lexical-editor/src/dom/mod.rs b/rust/lexical-editor/src/dom/mod.rs new file mode 100644 index 00000000000..769e401cf1b --- /dev/null +++ b/rust/lexical-editor/src/dom/mod.rs @@ -0,0 +1,42 @@ +//! A small DOM (the `html` feature): what a browser, or jsdom in upstream's +//! unit tests, gives Lexical's `createDOM`, `exportDOM` and HTML import and +//! export. Nothing renders it; it exists so that HTML export and import run +//! the same node methods as JS and write the same bytes, as the Swift +//! port's `DOMDocument` does (`swift/DESIGN.md`, "The DOM is a small model of +//! jsdom's"). +//! +//! - [`DomNode`]: `Node` and the interfaces Lexical reads through it +//! (`Element`, `Text`, `Comment`, `DocumentFragment`, `Document`). +//! - [`CssStyle`] and [`CssStyleDeclaration`]: an element's inline style, +//! which normalizes values as jsdom's declaration does (colors to +//! `rgb()`, `0` lengths to `0px`, lower-case keywords), pinned by the +//! `css/properties` and `css/values` vectors. +//! - [`parse_from_string`]: `DOMParser`, a WHATWG tokenizer and tree +//! builder pinned against jsdom's by the `html/parse` and `html/entities` +//! vectors. +//! - [`get_document`]: the one `document` of the thread, as JS has one +//! global `document`. + +mod css_properties; +mod css_style_declaration; +mod css_values; +mod html_entities; +mod html_parser; +mod html_serializer; +mod node; + +pub use css_style_declaration::{CssStyle, CssStyleDeclaration}; +pub use css_values::{CssPropertyTraits, css_property_names, css_property_traits}; +pub use html_parser::{is_legacy_entity_name, named_entity, named_entity_count, parse_from_string}; +pub use html_serializer::is_void_html_element; +pub use node::{DomNode, DomNodeType, DomTokenList}; + +thread_local! { + static DOCUMENT: DomNode = DomNode::new_document(); +} + +/// The thread's `document`, which `$getDocument()` returns when no editor +/// root names another. +pub fn get_document() -> DomNode { + DOCUMENT.with(DomNode::clone) +} diff --git a/rust/lexical-editor/src/dom/node.rs b/rust/lexical-editor/src/dom/node.rs new file mode 100644 index 00000000000..fba8c998559 --- /dev/null +++ b/rust/lexical-editor/src/dom/node.rs @@ -0,0 +1,798 @@ +//! `Node`, `Element`, `Text`, `Comment`, `DocumentFragment` and `Document`: +//! the part of the WHATWG DOM that Lexical's `createDOM`, `exportDOM` and +//! HTML import and export use. One handle type, [`DomNode`], stands for every +//! kind of node, as JS code reads them all through `Node`; the methods of a +//! more specific interface answer as an empty value on other nodes. + +use super::css_style_declaration::{CssStyle, CssStyleDeclaration}; +use super::html_serializer::{serialize_html, serialize_html_children}; +use std::cell::RefCell; +use std::fmt; +use std::rc::{Rc, Weak}; + +/// `Node.nodeType`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DomNodeType { + Element = 1, + Text = 3, + Comment = 8, + Document = 9, + DocumentFragment = 11, +} + +/// A DOM node: a shared handle, compared by identity, as JS compares node +/// objects. A node holds its children; its parent and document are weak +/// links. +#[derive(Clone)] +pub struct DomNode(Rc); + +struct Inner { + node_type: DomNodeType, + owner_document: Weak, + parent: RefCell>, + children: RefCell>, + /// `CharacterData.data` of a text or comment node. + data: RefCell, + element: Option, +} + +/// What an element adds: its tag, its attributes in the order they were +/// set, and its inline style declaration once something reads it. +struct ElementInner { + local_name: String, + attributes: RefCell>, + pub(super) style: RefCell>, +} + +impl PartialEq for DomNode { + fn eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.0, &other.0) + } +} + +impl Eq for DomNode {} + +impl fmt::Debug for DomNode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.node_type() { + DomNodeType::Element => write!(f, "{}", self.outer_html()), + DomNodeType::Text => write!(f, "#text {:?}", self.data()), + DomNodeType::Comment => write!(f, "", self.data()), + DomNodeType::Document | DomNodeType::DocumentFragment => { + write!(f, "{} {}", self.node_name(), self.inner_html()) + } + } + } +} + +impl DomNode { + fn new( + node_type: DomNodeType, + owner_document: Option<&DomNode>, + data: String, + element: Option, + ) -> Self { + Self(Rc::new(Inner { + node_type, + owner_document: owner_document.map_or_else(Weak::new, |d| Rc::downgrade(&d.0)), + parent: RefCell::new(Weak::new()), + children: RefCell::new(Vec::new()), + data: RefCell::new(data), + element, + })) + } + + /// `new Document()`: an empty document, which creates nodes. A parsed + /// HTML document has `documentElement`, `head` and `body`. + pub fn new_document() -> Self { + Self::new(DomNodeType::Document, None, String::new(), None) + } + + /// An element with `local_name` (already lower case), as the parser and + /// `createElement` make one. + pub(crate) fn new_element(local_name: String, owner_document: Option<&DomNode>) -> Self { + Self::new( + DomNodeType::Element, + owner_document, + String::new(), + Some(ElementInner { + local_name, + attributes: RefCell::new(Vec::new()), + style: RefCell::new(None), + }), + ) + } + + /// `new Text(data)`. + pub fn new_text(data: impl Into, owner_document: Option<&DomNode>) -> Self { + Self::new(DomNodeType::Text, owner_document, data.into(), None) + } + + /// `new Comment(data)`. + pub fn new_comment(data: impl Into, owner_document: Option<&DomNode>) -> Self { + Self::new(DomNodeType::Comment, owner_document, data.into(), None) + } + + /// `new DocumentFragment()`. + pub fn new_document_fragment(owner_document: Option<&DomNode>) -> Self { + Self::new( + DomNodeType::DocumentFragment, + owner_document, + String::new(), + None, + ) + } + + /// An identity for sets of nodes. + pub(crate) fn identity(&self) -> usize { + Rc::as_ptr(&self.0) as usize + } + + pub fn node_type(&self) -> DomNodeType { + self.0.node_type + } + + /// `nodeName`: the upper-case tag name of an element, `#text`, + /// `#comment`, `#document` or `#document-fragment`. + pub fn node_name(&self) -> String { + match self.node_type() { + DomNodeType::Element => self.tag_name(), + DomNodeType::Text => "#text".to_owned(), + DomNodeType::Comment => "#comment".to_owned(), + DomNodeType::Document => "#document".to_owned(), + DomNodeType::DocumentFragment => "#document-fragment".to_owned(), + } + } + + pub fn is_element(&self) -> bool { + self.node_type() == DomNodeType::Element + } + + pub fn is_text(&self) -> bool { + self.node_type() == DomNodeType::Text + } + + pub fn is_document(&self) -> bool { + self.node_type() == DomNodeType::Document + } + + pub fn is_document_fragment(&self) -> bool { + self.node_type() == DomNodeType::DocumentFragment + } + + /// `ownerDocument`: the document that created the node. + pub fn owner_document(&self) -> Option { + self.0.owner_document.upgrade().map(DomNode) + } + + /// The document new nodes made for this one belong to: its owner, or + /// itself when it is a document. + fn document_for_new_nodes(&self) -> Option { + if self.is_document() { + Some(self.clone()) + } else { + self.owner_document() + } + } + + pub fn parent_node(&self) -> Option { + self.0.parent.borrow().upgrade().map(DomNode) + } + + pub fn parent_element(&self) -> Option { + self.parent_node().filter(DomNode::is_element) + } + + /// `childNodes`, as a snapshot. + pub fn child_nodes(&self) -> Vec { + self.0.children.borrow().clone() + } + + pub fn first_child(&self) -> Option { + self.0.children.borrow().first().cloned() + } + + pub fn last_child(&self) -> Option { + self.0.children.borrow().last().cloned() + } + + pub fn has_child_nodes(&self) -> bool { + !self.0.children.borrow().is_empty() + } + + fn index_of(&self, child: &DomNode) -> Option { + self.0.children.borrow().iter().position(|c| c == child) + } + + fn sibling(&self, offset: isize) -> Option { + let parent = self.parent_node()?; + let index = parent.index_of(self)?; + let at = index.checked_add_signed(offset)?; + parent.0.children.borrow().get(at).cloned() + } + + pub fn next_sibling(&self) -> Option { + self.sibling(1) + } + + pub fn previous_sibling(&self) -> Option { + self.sibling(-1) + } + + /// `previousElementSibling`: the nearest element before this node. + pub fn previous_element_sibling(&self) -> Option { + let parent = self.parent_node()?; + let index = parent.index_of(self)?; + parent.0.children.borrow()[..index] + .iter() + .rev() + .find(|n| n.is_element()) + .cloned() + } + + /// `nextElementSibling`: the nearest element after this node. + pub fn next_element_sibling(&self) -> Option { + let parent = self.parent_node()?; + let index = parent.index_of(self)?; + parent.0.children.borrow()[index + 1..] + .iter() + .find(|n| n.is_element()) + .cloned() + } + + /// `children`: the element children. + pub fn children(&self) -> Vec { + self.0 + .children + .borrow() + .iter() + .filter(|n| n.is_element()) + .cloned() + .collect() + } + + /// `textContent`: a text or comment node's data, or the text of the + /// descendants. + pub fn text_content(&self) -> String { + match self.node_type() { + DomNodeType::Text | DomNodeType::Comment => self.data(), + _ => { + let mut out = String::new(); + self.collect_text(&mut out); + out + } + } + } + + fn collect_text(&self, out: &mut String) { + for child in self.0.children.borrow().iter() { + match child.node_type() { + DomNodeType::Text => out.push_str(&child.0.data.borrow()), + DomNodeType::Comment => {} + _ => child.collect_text(out), + } + } + } + + /// Setting `textContent`: a text or comment node's data, or the + /// children replaced by one text node (none for empty text). + pub fn set_text_content(&self, text: &str) { + match self.node_type() { + DomNodeType::Text | DomNodeType::Comment => self.set_data(text), + _ => { + let nodes = if text.is_empty() { + Vec::new() + } else { + vec![DomNode::new_text( + text, + self.document_for_new_nodes().as_ref(), + )] + }; + self.replace_children(&nodes); + } + } + } + + /// `CharacterData.data` (and `nodeValue`) of a text or comment node. + pub fn data(&self) -> String { + self.0.data.borrow().clone() + } + + pub fn set_data(&self, data: &str) { + *self.0.data.borrow_mut() = data.to_owned(); + } + + /// Appends to a text node's data, as the parser does. + pub(crate) fn push_data(&self, data: &str) { + self.0.data.borrow_mut().push_str(data); + } + + /// The nodes `node` stands for when inserted: a fragment's children. + fn expand(node: &DomNode) -> Vec { + if node.is_document_fragment() { + node.child_nodes() + } else { + vec![node.clone()] + } + } + + fn detach(&self, child: &DomNode) { + if let Some(index) = self.index_of(child) { + self.0.children.borrow_mut().remove(index); + *child.0.parent.borrow_mut() = Weak::new(); + } + } + + /// `insertBefore(node, child)`: moves `node` (a fragment's children) in + /// front of `child`, or to the end when `child` is `None`. + pub fn insert_before(&self, node: &DomNode, child: Option<&DomNode>) -> DomNode { + let nodes = Self::expand(node); + for n in &nodes { + if let Some(parent) = n.parent_node() { + parent.detach(n); + } + } + let index = child + .and_then(|c| self.index_of(c)) + .unwrap_or_else(|| self.0.children.borrow().len()); + for n in &nodes { + *n.0.parent.borrow_mut() = Rc::downgrade(&self.0); + } + self.0.children.borrow_mut().splice(index..index, nodes); + node.clone() + } + + /// `appendChild(node)`. + pub fn append_child(&self, node: &DomNode) -> DomNode { + self.insert_before(node, None) + } + + /// `append(...nodes)`. + pub fn append(&self, nodes: &[DomNode]) { + for node in nodes { + self.append_child(node); + } + } + + /// `append(text)`. + pub fn append_text(&self, text: &str) { + self.append_child(&DomNode::new_text( + text, + self.document_for_new_nodes().as_ref(), + )); + } + + /// `prepend(...nodes)`. + pub fn prepend(&self, nodes: &[DomNode]) { + let first = self.first_child(); + for node in nodes { + self.insert_before(node, first.as_ref()); + } + } + + /// `removeChild(child)`. + pub fn remove_child(&self, child: &DomNode) -> DomNode { + self.detach(child); + child.clone() + } + + /// `replaceChild(node, child)`. + pub fn replace_child(&self, node: &DomNode, child: &DomNode) -> DomNode { + let next = child.next_sibling(); + self.detach(child); + self.insert_before(node, next.as_ref()); + child.clone() + } + + /// `remove()`. + pub fn remove(&self) { + if let Some(parent) = self.parent_node() { + parent.detach(self); + } + } + + /// `replaceWith(...nodes)`. + pub fn replace_with(&self, nodes: &[DomNode]) { + let Some(parent) = self.parent_node() else { + return; + }; + let next = self.next_sibling(); + parent.detach(self); + for node in nodes { + parent.insert_before(node, next.as_ref()); + } + } + + /// `replaceChildren(...nodes)`. + pub fn replace_children(&self, nodes: &[DomNode]) { + let old = std::mem::take(&mut *self.0.children.borrow_mut()); + for child in old { + *child.0.parent.borrow_mut() = Weak::new(); + } + self.append(nodes); + } + + /// `contains(other)`: whether `other` is this node or a descendant. + pub fn contains(&self, other: Option<&DomNode>) -> bool { + let mut node = other.cloned(); + while let Some(current) = node { + if ¤t == self { + return true; + } + node = current.parent_node(); + } + false + } + + /// `cloneNode(deep)`. + pub fn clone_node(&self, deep: bool) -> DomNode { + let document = self.owner_document(); + let copy = match self.node_type() { + DomNodeType::Element => { + let copy = DomNode::new_element(self.local_name().to_owned(), document.as_ref()); + *copy.element_inner().attributes.borrow_mut() = self.attributes(); + copy + } + DomNodeType::Text => DomNode::new_text(self.data(), document.as_ref()), + DomNodeType::Comment => DomNode::new_comment(self.data(), document.as_ref()), + DomNodeType::Document => DomNode::new_document(), + DomNodeType::DocumentFragment => DomNode::new_document_fragment(document.as_ref()), + }; + if deep { + for child in self.child_nodes() { + copy.append_child(&child.clone_node(true)); + } + } + copy + } + + // Document + + /// `document.createElement(tagName)`. + pub fn create_element(&self, tag_name: &str) -> DomNode { + DomNode::new_element(tag_name.to_ascii_lowercase(), Some(self)) + } + + /// `document.createTextNode(data)`. + pub fn create_text_node(&self, data: &str) -> DomNode { + DomNode::new_text(data, Some(self)) + } + + /// `document.createComment(data)`. + pub fn create_comment(&self, data: &str) -> DomNode { + DomNode::new_comment(data, Some(self)) + } + + /// `document.createDocumentFragment()`. + pub fn create_document_fragment(&self) -> DomNode { + DomNode::new_document_fragment(Some(self)) + } + + /// `document.documentElement`. + pub fn document_element(&self) -> Option { + self.children().into_iter().next() + } + + /// `document.head`. + pub fn head(&self) -> Option { + self.document_element()? + .children() + .into_iter() + .find(|e| e.local_name() == "head") + } + + /// `document.body`. + pub fn body(&self) -> Option { + self.document_element()? + .children() + .into_iter() + .find(|e| e.local_name() == "body") + } + + // Element + + fn element_inner(&self) -> &ElementInner { + self.0 + .element + .as_ref() + .expect("an element method on a node that is not an element") + } + + /// The element's style slot, for the declaration. + pub(super) fn style_slot(&self) -> Option<&RefCell>> { + self.0.element.as_ref().map(|e| &e.style) + } + + /// `localName`: the lower-case tag name, or empty for a node that is not + /// an element. + pub fn local_name(&self) -> &str { + self.0 + .element + .as_ref() + .map_or("", |e| e.local_name.as_str()) + } + + /// `tagName`: the upper-case tag name. + pub fn tag_name(&self) -> String { + self.local_name().to_ascii_uppercase() + } + + /// `attributes`, as `(name, value)` pairs in order. + pub fn attributes(&self) -> Vec<(String, String)> { + self.0 + .element + .as_ref() + .map(|e| e.attributes.borrow().clone()) + .unwrap_or_default() + } + + /// `getAttribute(name)`. + pub fn get_attribute(&self, name: &str) -> Option { + let element = self.0.element.as_ref()?; + let name = name.to_ascii_lowercase(); + element + .attributes + .borrow() + .iter() + .find(|(n, _)| *n == name) + .map(|(_, v)| v.clone()) + } + + pub fn has_attribute(&self, name: &str) -> bool { + self.get_attribute(name).is_some() + } + + pub fn get_attribute_names(&self) -> Vec { + self.attributes().into_iter().map(|(n, _)| n).collect() + } + + /// `setAttribute(name, value)`. Setting `style` replaces the inline + /// declaration's properties with those of the value. + pub fn set_attribute(&self, name: &str, value: &str) { + let name = name.to_ascii_lowercase(); + self.set_attribute_value(&name, value); + if name == "style" + && let Some(declaration) = self.element_inner().style.borrow_mut().as_mut() + { + declaration.parse(value); + } + } + + /// `removeAttribute(name)`. + pub fn remove_attribute(&self, name: &str) { + let name = name.to_ascii_lowercase(); + let element = self.element_inner(); + element.attributes.borrow_mut().retain(|(n, _)| *n != name); + if name == "style" + && let Some(declaration) = element.style.borrow_mut().as_mut() + { + declaration.parse(""); + } + } + + /// `toggleAttribute(name, force)`. + pub fn toggle_attribute(&self, name: &str, force: Option) -> bool { + let present = self.has_attribute(name); + let next = force.unwrap_or(!present); + if next && !present { + self.set_attribute(name, ""); + } + if !next && present { + self.remove_attribute(name); + } + next + } + + /// Writes an attribute without touching the style declaration (which is + /// how the declaration itself updates the `style` attribute). + pub(super) fn set_attribute_value(&self, name: &str, value: &str) { + let mut attributes = self.element_inner().attributes.borrow_mut(); + match attributes.iter_mut().find(|(n, _)| n == name) { + Some(entry) => value.clone_into(&mut entry.1), + None => attributes.push((name.to_owned(), value.to_owned())), + } + } + + /// `id`. + pub fn id(&self) -> String { + self.get_attribute("id").unwrap_or_default() + } + + pub fn set_id(&self, id: &str) { + self.set_attribute("id", id); + } + + /// `dir`. + pub fn dir(&self) -> String { + self.get_attribute("dir").unwrap_or_default() + } + + pub fn set_dir(&self, dir: &str) { + self.set_attribute("dir", dir); + } + + /// `className`. + pub fn class_name(&self) -> String { + self.get_attribute("class").unwrap_or_default() + } + + pub fn set_class_name(&self, class_name: &str) { + self.set_attribute("class", class_name); + } + + /// `classList`. + pub fn class_list(&self) -> DomTokenList { + DomTokenList { + element: self.clone(), + attribute: "class", + } + } + + /// `style`: the inline style declaration, kept in step with the `style` + /// attribute. + pub fn style(&self) -> CssStyle { + CssStyle::new(self.clone()) + } + + /// `innerHTML`: the serialized children. + pub fn inner_html(&self) -> String { + serialize_html_children(self) + } + + /// `outerHTML`: the element serialized. + pub fn outer_html(&self) -> String { + let mut out = String::new(); + serialize_html(self, &mut out); + out + } + + /// The descendant elements in document order. + pub fn descendant_elements(&self) -> Vec { + fn walk(node: &DomNode, out: &mut Vec) { + for child in node.0.children.borrow().iter() { + if child.is_element() { + out.push(child.clone()); + walk(child, out); + } + } + } + let mut out = Vec::new(); + walk(self, &mut out); + out + } + + /// `getElementsByTagName(name)`. + pub fn get_elements_by_tag_name(&self, name: &str) -> Vec { + let name = name.to_ascii_lowercase(); + self.descendant_elements() + .into_iter() + .filter(|e| name == "*" || e.local_name() == name) + .collect() + } +} + +/// `DOMTokenList` over an attribute: unique tokens, separated by single +/// spaces once changed. +#[derive(Clone, Debug)] +pub struct DomTokenList { + element: DomNode, + attribute: &'static str, +} + +impl DomTokenList { + /// The tokens, without duplicates. + pub fn tokens(&self) -> Vec { + let mut out: Vec = Vec::new(); + let value = self + .element + .get_attribute(self.attribute) + .unwrap_or_default(); + for token in value + .split([' ', '\t', '\n', '\r', '\u{0C}']) + .filter(|t| !t.is_empty()) + { + if !out.iter().any(|t| t == token) { + out.push(token.to_owned()); + } + } + out + } + + pub fn length(&self) -> usize { + self.tokens().len() + } + + pub fn value(&self) -> String { + self.element + .get_attribute(self.attribute) + .unwrap_or_default() + } + + pub fn contains(&self, token: &str) -> bool { + self.tokens().iter().any(|t| t == token) + } + + fn update(&self, tokens: &[String]) { + // The spec skips the update when there is no attribute and no tokens. + if !self.element.has_attribute(self.attribute) && tokens.is_empty() { + return; + } + self.element + .set_attribute(self.attribute, &tokens.join(" ")); + } + + /// `add(...tokens)`. + pub fn add>(&self, tokens: &[S]) { + let mut list = self.tokens(); + for token in tokens { + let token = token.as_ref(); + if !list.iter().any(|t| t == token) { + list.push(token.to_owned()); + } + } + self.update(&list); + } + + /// `remove(...tokens)`. + pub fn remove>(&self, tokens: &[S]) { + let list: Vec = self + .tokens() + .into_iter() + .filter(|t| !tokens.iter().any(|r| r.as_ref() == t)) + .collect(); + self.update(&list); + } + + /// `toggle(token, force)`. + pub fn toggle(&self, token: &str, force: Option) -> bool { + let present = self.contains(token); + let next = force.unwrap_or(!present); + if next && !present { + self.add(&[token]); + } + if !next && present { + self.remove(&[token]); + } + next + } +} + +#[cfg(test)] +mod tests { + use crate::dom::get_document; + + /// The style attribute and the declaration stay in step as jsdom keeps + /// them: an attribute reads as written until the declaration changes, + /// then as its `cssText`; removing every property leaves `style=""`. + #[test] + fn style_attribute_follows_the_declaration() { + let document = get_document(); + let span = document.create_element("span"); + span.set_attribute("style", "color:RED"); + assert_eq!(span.get_attribute("style").as_deref(), Some("color:RED")); + assert_eq!(span.style().get_property_value("color"), "red"); + span.style().set_property("font-size", "0", ""); + assert_eq!( + span.get_attribute("style").as_deref(), + Some("color: red; font-size: 0px;") + ); + span.style().remove_property("color"); + span.style().remove_property("font-size"); + assert_eq!(span.outer_html(), r#""#); + span.set_attribute("style", "margin: 0"); + assert_eq!(span.style().css_text(), "margin: 0px;"); + } + + /// Appending a fragment moves its children and leaves it empty. + #[test] + fn appending_a_fragment_moves_its_children() { + let document = get_document(); + let fragment = document.create_document_fragment(); + fragment.append(&[document.create_element("b")]); + fragment.append_text("x"); + let p = document.create_element("p"); + p.append(std::slice::from_ref(&fragment)); + assert_eq!(p.outer_html(), "

x

"); + assert!(!fragment.has_child_nodes()); + } +} diff --git a/rust/lexical-editor/src/lib.rs b/rust/lexical-editor/src/lib.rs index ebe6f9e1f4e..34202c1f252 100644 --- a/rust/lexical-editor/src/lib.rs +++ b/rust/lexical-editor/src/lib.rs @@ -51,6 +51,8 @@ mod caret_utils; pub mod code; mod commands; pub mod constants; +#[cfg(feature = "html")] +pub mod dom; mod editor; mod editor_state; mod error; @@ -88,6 +90,7 @@ mod selection_format; mod selection_insert_nodes; mod selection_insert_text; mod serialized_export; +mod set_dom_style; #[cfg(feature = "text")] pub mod text; mod updates; @@ -149,6 +152,9 @@ pub use nodes::text::{TextData, create_text_node, toggle_text_format_type}; pub use selection_deletion::{SelectionAlter, SelectionGranularity, SelectionMovement}; pub use selection_insert_nodes::{insert_nodes, select_all}; pub use serialized_export::{export_node_json, is_compact_export, with_compact_export}; +pub use set_dom_style::{StyleObject, get_style_object_from_css, js_trim}; +#[cfg(feature = "html")] +pub use set_dom_style::{set_dom_style_from_css, set_dom_style_object}; pub use updates::parse_serialized_node; /// The upstream Lexical release this port conforms to. The conformance diff --git a/rust/lexical-editor/src/set_dom_style.rs b/rust/lexical-editor/src/set_dom_style.rs new file mode 100644 index 00000000000..3be790197ee --- /dev/null +++ b/rust/lexical-editor/src/set_dom_style.rs @@ -0,0 +1,313 @@ +//! `utils/setDOMStyle.ts`: inline CSS text as a style object +//! ([`get_style_object_from_css`]), and, with the `html` feature, a style +//! object or CSS text applied to a DOM style declaration +//! ([`set_dom_style_object`], [`set_dom_style_from_css`]). + +use crate::json::array_index; +use crate::selection_deletion::is_js_whitespace; + +/// `String.prototype.trim`: without JS's whitespace (and line terminators) +/// at either end. +pub fn js_trim(s: &str) -> &str { + s.trim_matches(is_js_whitespace) +} + +/// A `Record` of CSS properties, as +/// [`get_style_object_from_css`] returns it: keys in JS's property order +/// (array indices first, ascending, then the rest in the order they were +/// first set), so iterating it writes CSS in the order JS does. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct StyleObject { + entries: Vec<(String, String)>, +} + +impl StyleObject { + pub fn new() -> Self { + Self::default() + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn get(&self, key: &str) -> Option<&str> { + self.entries + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + } + + pub fn contains_key(&self, key: &str) -> bool { + self.get(key).is_some() + } + + /// `styles[key] = value`: an existing key keeps its position. + pub fn insert(&mut self, key: impl Into, value: impl Into) { + let key = key.into(); + let value = value.into(); + if let Some(entry) = self.entries.iter_mut().find(|(k, _)| *k == key) { + entry.1 = value; + return; + } + let at = match array_index(&key) { + Some(index) => self + .entries + .iter() + .position(|(k, _)| array_index(k).is_none_or(|i| i > index)) + .unwrap_or(self.entries.len()), + None => self.entries.len(), + }; + self.entries.insert(at, (key, value)); + } + + /// `delete styles[key]`. + pub fn remove(&mut self, key: &str) -> Option { + let index = self.entries.iter().position(|(k, _)| k == key)?; + Some(self.entries.remove(index).1) + } + + pub fn iter(&self) -> impl Iterator { + self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str())) + } + + pub fn keys(&self) -> impl Iterator { + self.entries.iter().map(|(k, _)| k.as_str()) + } +} + +impl IntoIterator for StyleObject { + type Item = (String, String); + type IntoIter = std::vec::IntoIter<(String, String)>; + + fn into_iter(self) -> Self::IntoIter { + self.entries.into_iter() + } +} + +impl, V: Into> FromIterator<(K, V)> for StyleObject { + fn from_iter>(iter: I) -> Self { + let mut styles = StyleObject::new(); + for (k, v) in iter { + styles.insert(k, v); + } + styles + } +} + +/// `getStyleObjectFromCSS(css)`: parses inline CSS text into an object that +/// is compatible with `CSSStyleDeclaration.setProperty()`. Comments are +/// dropped; `:` and `;` inside quotes or parentheses are not delimiters. +pub fn get_style_object_from_css(css: &str) -> StyleObject { + let mut styles = StyleObject::new(); + if css.is_empty() { + return styles; + } + // Every delimiter is ASCII, so byte offsets work as JS's code unit + // offsets do: a slice never starts or ends inside a character. + let bytes = css.as_bytes(); + let length = bytes.len(); + let mut current_property = String::new(); + let mut current_value = String::new(); + let mut current_quote: Option = None; + let mut in_comment = false; + let mut is_escaped = false; + let mut is_parsing_value = false; + let mut parenthesis_depth = 0usize; + // The start of the pending run of kept characters, if any. + let mut chunk_start: Option = None; + + let commit = |property: &str, value: &str, styles: &mut StyleObject| { + let property = js_trim(property); + let value = js_trim(value); + if !property.is_empty() && !value.is_empty() { + styles.insert(property, value); + } + }; + + let mut i = 0; + while i < length { + let char = bytes[i]; + let next = bytes.get(i + 1).copied(); + if in_comment { + if char == b'*' && next == Some(b'/') { + in_comment = false; + i += 1; + } + i += 1; + continue; + } + if is_escaped { + chunk_start.get_or_insert(i); + is_escaped = false; + i += 1; + continue; + } + if let Some(quote) = current_quote { + chunk_start.get_or_insert(i); + if char == b'\\' { + is_escaped = true; + } else if char == quote { + current_quote = None; + } + i += 1; + continue; + } + if char == b'/' && next == Some(b'*') { + // The comment is dropped, so flush everything accumulated before it. + if let Some(start) = chunk_start.take() { + if is_parsing_value { + current_value.push_str(&css[start..i]); + } else { + current_property.push_str(&css[start..i]); + } + } + in_comment = true; + i += 2; + continue; + } + match char { + b'"' | b'\'' => { + chunk_start.get_or_insert(i); + current_quote = Some(char); + } + b'(' => { + chunk_start.get_or_insert(i); + parenthesis_depth += 1; + } + b')' => { + chunk_start.get_or_insert(i); + parenthesis_depth = parenthesis_depth.saturating_sub(1); + } + b':' if !is_parsing_value && parenthesis_depth == 0 => { + // The separator is dropped; flush the accumulated property name. + if let Some(start) = chunk_start.take() { + current_property.push_str(&css[start..i]); + } + is_parsing_value = true; + } + b';' if parenthesis_depth == 0 => { + if let Some(start) = chunk_start.take() { + if is_parsing_value { + current_value.push_str(&css[start..i]); + } else { + current_property.push_str(&css[start..i]); + } + } + commit(¤t_property, ¤t_value, &mut styles); + current_property.clear(); + current_value.clear(); + is_parsing_value = false; + } + _ => { + chunk_start.get_or_insert(i); + } + } + i += 1; + } + if let Some(start) = chunk_start { + if is_parsing_value { + current_value.push_str(&css[start..]); + } else { + current_property.push_str(&css[start..]); + } + } + commit(¤t_property, ¤t_value, &mut styles); + styles +} + +#[cfg(feature = "html")] +mod dom_style { + use super::{StyleObject, get_style_object_from_css, js_trim}; + use crate::dom::CssStyle; + + const IMPORTANT_FLAG: &str = "!important"; + + /// `setDOMStyleProperty(domStyle, property, value)`: a trailing + /// `!important` (in any case) is set as the priority. + fn set_dom_style_property(dom_style: &CssStyle, property: &str, value: &str) { + let trimmed = value.trim_end_matches(crate::selection_deletion::is_js_whitespace); + let n = trimmed.len(); + let flag_start = n.checked_sub(IMPORTANT_FLAG.len()); + let has_important = flag_start.is_some_and(|start| { + trimmed.is_char_boundary(start) && trimmed[start..].to_lowercase() == IMPORTANT_FLAG + }); + match flag_start { + Some(start) if has_important => { + dom_style.set_property(property, js_trim(&trimmed[..start]), "important"); + } + _ => dom_style.set_property(property, value, ""), + } + } + + /// `setDOMStyleObject(domStyle, styleObject)`: sets each property with a + /// value and removes each one without (`null`). + pub fn set_dom_style_object<'a>( + dom_style: &CssStyle, + style_object: impl IntoIterator)>, + ) { + for (property, value) in style_object { + match value { + None => { + dom_style.remove_property(property); + } + Some(value) => set_dom_style_property(dom_style, property, value), + } + } + } + + /// `setDOMStyleFromCSS(domStyle, cssText, prevCSSText)`: sets the + /// properties of `css_text` and removes those only `prev_css_text` has. + pub fn set_dom_style_from_css(dom_style: &CssStyle, css_text: &str, prev_css_text: &str) { + if css_text == prev_css_text { + return; + } + let mut prev_css: StyleObject = get_style_object_from_css(prev_css_text); + let next_css = get_style_object_from_css(css_text); + for (property, value) in next_css.iter() { + prev_css.remove(property); + set_dom_style_property(dom_style, property, value); + } + for property in prev_css.keys() { + dom_style.remove_property(property); + } + } +} + +#[cfg(feature = "html")] +pub use dom_style::{set_dom_style_from_css, set_dom_style_object}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn style_objects_follow_js_property_order() { + // Object.keys(getStyleObjectFromCSS('b: 1; 2: x; a: 2; 1: y; b: 3')) + // is ['1', '2', 'b', 'a'], with b: '3'. + let styles = get_style_object_from_css("b: 1; 2: x; a: 2; 1: y; b: 3"); + let entries: Vec<(&str, &str)> = styles.iter().collect(); + assert_eq!(entries, [("1", "y"), ("2", "x"), ("b", "3"), ("a", "2")]); + } + + #[test] + fn delimiters_in_quotes_parentheses_and_comments() { + // getStyleObjectFromCSS( + // 'content: "a;b:c"; /* x: y; */ background: url(a;b) ;font : \'q\\\'\' ') + let styles = get_style_object_from_css( + r#"content: "a;b:c"; /* x: y; */ background: url(a;b) ;font : 'q\'' "#, + ); + let entries: Vec<(&str, &str)> = styles.iter().collect(); + assert_eq!( + entries, + [ + ("content", r#""a;b:c""#), + ("background", "url(a;b)"), + ("font", r"'q\''"), + ] + ); + } +} diff --git a/rust/scripts/generate-dom-tables.mjs b/rust/scripts/generate-dom-tables.mjs new file mode 100644 index 00000000000..7aded406bb6 --- /dev/null +++ b/rust/scripts/generate-dom-tables.mjs @@ -0,0 +1,80 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +// Generates the DOM's tables from the conformance vectors JS records, as +// packages/lexical-conformance/scripts/css-properties.mjs and +// html-entities.mjs do for the Swift port: +// +// - rust/lexical-editor/src/dom/css_properties.rs from +// vectors/css/properties.json: the properties an inline style accepts +// (jsdom's CSSStyleDeclaration) and how each normalizes values. +// - rust/lexical-editor/src/dom/html_entities.rs from +// vectors/html/entities.json: the named character references and which of +// them decode without a semicolon. +// +// The conformance runner fails when a table and its vectors disagree. +// +// Run from the repository root: +// node rust/scripts/generate-dom-tables.mjs +import {readFileSync, writeFileSync} from 'node:fs'; + +const VECTORS = 'packages/lexical-conformance/vectors'; +const OUT = 'rust/lexical-editor/src/dom'; + +const load = file => + JSON.parse(readFileSync(`${VECTORS}/${file}.json`, 'utf8')).cases; + +/** Sorted as Rust compares `&str` (bytes), for a binary search. */ +const byName = (a, b) => + Buffer.compare(Buffer.from(a.name), Buffer.from(b.name)); + +const properties = load('css/properties').sort(byName); +writeFileSync( + `${OUT}/css_properties.rs`, + `// Generated by rust/scripts/generate-dom-tables.mjs from +// packages/lexical-conformance/vectors/css/properties.json. Do not edit. + +//! The properties an inline style declaration accepts, as jsdom's does, +//! each with its [\`CssPropertyTraits\`](super::css_values::CssPropertyTraits) +//! bits, sorted by name. + +pub(crate) static CSS_PROPERTY_TRAITS: &[(&str, u8)] = &[ +${properties + .map( + ({name, color, zeroLength, preserveCase, verbatim}) => + ` ("${name}", ${(color ? 1 : 0) | (zeroLength ? 2 : 0) | (preserveCase ? 4 : 0) | (verbatim ? 8 : 0)}),`, + ) + .join('\n')} +]; +`, +); + +const escape = value => + [...value] + .map(c => `\\u{${c.codePointAt(0).toString(16).toUpperCase()}}`) + .join(''); +const entities = load('html/entities').sort(byName); +writeFileSync( + `${OUT}/html_entities.rs`, + `// Generated by rust/scripts/generate-dom-tables.mjs from +// packages/lexical-conformance/vectors/html/entities.json. Do not edit. + +//! The named character references of HTML, without their \`&\` and \`;\`, +//! sorted by name, and the names that also decode without a semicolon. + +pub(crate) static HTML_NAMED_ENTITIES: &[(&str, &str)] = &[ +${entities.map(({name, value}) => ` ("${name}", "${escape(value)}"),`).join('\n')} +]; + +pub(crate) static HTML_LEGACY_ENTITY_NAMES: &[&str] = &[ +${entities + .filter(c => c.legacy) + .map(c => ` "${c.name}",`) + .join('\n')} +]; +`, +);