diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 7605318d87..346105f9e1 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -48,7 +48,7 @@ jobs: env: CARGO_LLVM_COV_FLAGS_NO_RUNNER: --no-sparse run: | - cargo llvm-cov nextest --profile ci --config-file .cargo/nextest.toml --lcov --output-path lcov.info --release + cargo llvm-cov nextest --all-features --profile ci --config-file .cargo/nextest.toml --lcov --output-path lcov.info --release - name: Upload coverage reports to Codecov uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5 with: @@ -90,4 +90,4 @@ jobs: components: clippy cache: false - name: Clippy - run: cargo clippy --tests -- -D warnings + run: cargo clippy --tests --all-features -- -D warnings diff --git a/.vscode/settings.json b/.vscode/settings.json index 74804fedcb..235b095d91 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -38,4 +38,6 @@ "typescript.preferences.preferTypeOnlyAutoImports": true, "typescript.preferences.importModuleSpecifierEnding": "js", "rust-analyzer.check.command": "clippy", + "rust-analyzer.check.features": "all", + "rust-analyzer.cargo.features": "all", } diff --git a/AGENTS.md b/AGENTS.md index f508849587..6fa4c93d24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,7 +174,7 @@ The CI runs these checks (replicate locally for confidence): 5. **TypeScript compilation**: Part of `pnpm turbo build` 6. **Unit tests**: `pnpm test` (vitest-based, requires build) 7. **E2E tests**: Web platform tests with Playwright -8. **Rust tests**: `cargo test` in Rust packages +8. **Rust tests**: `cargo test --all-features` in Rust packages 9. **Type checking**: `pnpm -r run test:type` ### GitHub Workflows diff --git a/Cargo.lock b/Cargo.lock index 81d203e59a..2b29146fe4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -147,6 +147,26 @@ dependencies = [ "scoped-tls", ] +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -3632,6 +3652,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "url" version = "2.5.4" @@ -3704,6 +3730,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "vsimd" version = "0.8.0" @@ -3779,6 +3811,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-core-wasm" +version = "0.0.0" +dependencies = [ + "bincode", + "fnv", + "js-sys", + "lazy_static", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "web-mainthread-apis" version = "0.0.0" @@ -3788,6 +3832,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 99b92fb945..191da8d103 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "packages/react/transform/crates/*", "packages/react/transform/swc-plugin-reactlynx", "packages/react/transform/swc-plugin-reactlynx-compat", + "packages/web-platform/web-core-wasm", "packages/web-platform/web-mainthread-apis", ] @@ -30,6 +31,7 @@ swc_core = "47.0.3" swc_sourcemap = "9.3.0" version-compare = "0.2.0" wasm-bindgen = "0.2.105" +web-sys = "0.3.81" [profile.release] codegen-units = 1 diff --git a/packages/web-platform/web-core-wasm/AGENTS.md b/packages/web-platform/web-core-wasm/AGENTS.md new file mode 100644 index 0000000000..7c6cbf236e --- /dev/null +++ b/packages/web-platform/web-core-wasm/AGENTS.md @@ -0,0 +1,263 @@ +# Web Core WASM + +This package (`web-core-wasm`) is a critical component of the Lynx Web Platform, implemented in Rust and compiled to WebAssembly (WASM). It handles performance-sensitive tasks such as CSS tokenization, style transformation, template processing, and main-thread element management. + +## Overview + +The primary goal of this package is to offload heavy computations from the JavaScript main thread to WASM, thereby improving the overall performance and responsiveness of Lynx applications on the web. Key responsibilities include: + +- **CSS Processing**: Tokenizing and transforming CSS according to Lynx-specific rules +- **Template Serialization**: Encoding/decoding element templates and style information using `bincode` +- **DOM Management**: Managing element state and event handling via the main thread context (client feature) +- **Style Transformation**: Converting Lynx-specific CSS properties (e.g., `display: linear`, `rpx` units) to web-compatible CSS + +## Feature Flags + +The crate uses Cargo feature flags to conditionally compile code: + +| Feature | Description | +| -------- | ------------------------------------------------------------------------------------------------------------------------- | +| `client` | Enables main-thread functionality (`main_thread`, `js_binding` modules, inline style transformation). Requires `web-sys`. | +| `encode` | Enables encoding/serialization of templates via `wasm_bindgen` exports. Used at build time. | +| `server` | Reserved for server-side rendering scenarios. | + +## Dependencies + +Key external dependencies used in this crate: + +| Dependency | Purpose | +| ---------------- | ----------------------------------------------------------------- | +| `wasm-bindgen` | Rust/JS interop for WASM | +| `js-sys` | JavaScript API bindings | +| `web-sys` | Web API bindings (client feature) | +| `bincode` (v2.0) | Binary serialization for templates | +| `fnv` | Fast hash maps and sets (`FnvHashMap`, `FnvHashSet`) | +| `lazy_static` | Lazy-initialized static data (transformation rules, tag mappings) | + +## Architecture + +The codebase is organized into several modules, each responsible for a specific domain: + +- **`css_tokenizer`**: A CSS tokenizer based on CSS Syntax Level 3, ported from `css-tree`. Tokenizes CSS strings into tokens (ident, function, number, dimension, etc.) for downstream processing. +- **`style_transformer`**: Parses and transforms CSS declarations. Applies Lynx-to-web transformation rules (e.g., `display: linear` → flexbox, `rpx` → `calc()`). Uses `css_tokenizer` internally. +- **`template`**: Defines template structures (`RawElementTemplate`, `RawStyleInfo`, `StyleSheet`, `Rule`) and handles serialization/deserialization using `bincode`. +- **`leo_asm`**: Defines the "Leo Assembly" instruction set (`LEOAsmOpcode`). These opcodes represent DOM operations (create element, set attribute, append child, etc.). +- **`js_binding`** (Feature: `client`): Defines the Rust-to-JavaScript interface via `wasm-bindgen`. Exports `RustMainthreadContextBinding` for invoking JS methods (publishing events, running worklets, loading elements). +- **`main_thread`** (Feature: `client`): Core runtime logic for the main thread. Manages `MainThreadWasmContext` (element state, templates, events, DOM references). +- **`constants`**: Shared constants including attribute names, tag mappings (`LYNX_TAG_TO_HTML_TAG_MAP`), and the full CSS property map. + +## Data Flow + +### Build-time (encode feature) + +``` +Template Definition (JS) + → RawElementTemplate (Rust) + → bincode::encode + → Uint8Array (serialized) + +Style Definition (JS) + → RawStyleInfo (Rust) + → StyleInfoDecoder + → DecodedStyleData + → bincode::encode + → Uint8Array +``` + +### Runtime (client feature) + +``` +Serialized Template (Uint8Array) + → bincode::decode + → ElementTemplateSection + → DecodedElementTemplate + → Execute Leo ASM operations + → DOM elements + +Inline Style (String) + → transform_inline_style_string() + → Transformed CSS string + → Apply to element.style +``` + +## Guidelines for LLMs + +When generating or modifying code in this package, please adhere to the following guidelines: + +### 1. Code Quality and Consistency + +- **Comments**: Ensure that all public structs, enums, and functions have clear documentation comments (`///`). Comments must accurately reflect the logic. +- **Logic**: Verify that the implementation matches the comments and the intended behavior. +- **Quality**: Prioritize code quality. "Better to have nothing than garbage". +- **Idiomatic Rust**: Use idiomatic Rust patterns. Prefer `Option::map`, `Result::and_then`, iterators, and pattern matching over imperative code where appropriate. +- **Error Handling**: Use `Result` types for fallible operations. Avoid `unwrap()` in production code paths; prefer `?` operator or explicit error handling. + +### 2. Testing + +- **Unit Tests**: Run `cargo test --all-features` to execute unit tests. Tests are co-located with the source code using `#[cfg(test)]`. +- **Coverage**: All new features and bug fixes must be accompanied by unit tests. +- **Verification**: Ensure that tests cover edge cases and verify the correctness of the logic. +- **Test Naming**: Use descriptive test names that explain what is being tested (e.g., `test_transform_rpx_case_insensitive`). + +### 3. Performance + +- **Main Thread**: Be mindful of the main thread execution time. Heavy tasks should be optimized or offloaded where possible. +- **Bundle Size**: Consider the impact on the WASM binary size. Keep the LCP (Largest Contentful Paint) in mind. +- **Legacy Support**: Ensure that the code performs well on browsers up to 2 years old. Provide performance downgrades for legacy environments if necessary, but ensure functionality remains intact. +- **Modern Browsers**: Aim for extreme performance and experience on modern browsers. +- **Memory Allocation**: Minimize allocations in hot paths. Use `String::with_capacity()` when the size is known, reuse buffers where possible. +- **WASM/JS Boundary**: Minimize crossing the WASM/JS boundary. Batch operations where possible. + +### 4. Best Practices + +- **Rust Idioms**: Use idiomatic Rust code. +- **WASM**: Be aware of WASM limitations and interop costs with JavaScript. Minimize crossing the WASM/JS boundary. + +### 5. Feature Flags + +- **Conditional Compilation**: Use `#[cfg(feature = "...")]` to conditionally compile code based on features. +- **Feature Dependencies**: When adding new functionality, carefully consider which feature flag it belongs to (`client`, `encode`, or `server`). +- **Testing Features**: When running tests, use `cargo test --all-features` to ensure all feature combinations are tested. + +### 6. Others + +- **Documentation**: Check if `AGENTS.md` needs to be updated to reflect your changes (e.g., new feature flags, structural changes, new dependencies). +- **In file documentation**: Check if the comments in the code need to be updated to reflect your changes. + +## Development & Build + +- **Build WASM**: `cargo build --target wasm32-unknown-unknown` +- **Test**: `cargo test --all-features` + +## Module Details + +### `css_tokenizer` + +Implements CSS tokenization compliant with CSS Syntax Level 3. This module is a Rust port of the `css-tree` tokenizer. + +- **Key files**: `tokenize.rs`, `token_types.rs`, `char_code_definitions.rs`, `utils.rs`. +- **Usage**: Used by `style_transformer` to parse CSS declarations. +- **Token Types**: Defines 26 token types including `IDENT_TOKEN`, `FUNCTION_TOKEN`, `NUMBER_TOKEN`, `DIMENSION_TOKEN`, `PERCENTAGE_TOKEN`, `STRING_TOKEN`, `URL_TOKEN`, `WHITESPACE_TOKEN`, and various punctuation tokens. +- **Character Classification**: Provides inline functions for efficient character classification (`is_digit`, `is_hex_digit`, `is_letter`, `is_name_start`, `is_name`, `is_white_space`, `is_newline`, etc.). +- **Parser Trait**: Defines a `Parser` trait with `on_token(token_type, token_value)` method that consumers implement to process tokens. + +### `style_transformer` + +Transforms CSS styles from Lynx-specific syntax to web-compatible CSS. + +- **Key files**: `transformer.rs`, `rules.rs`, `token_transformer.rs`, `inline_style.rs` (client feature). +- **Usage**: Processes CSS declarations and applies transformation rules. +- **Transformation Pipeline**: + 1. CSS string → `tokenize()` → token stream + 2. Token stream → `transform_one_token()` → transformed tokens (e.g., `rpx` → `calc()`) + 3. Transformed tokens → `StyleTransformer` (state machine) → parsed declarations + 4. Parsed declarations → `query_transform_rules()` → final CSS output +- **Rule Types**: + - **Rename Rules** (`RENAME_RULE`): Simple property renaming (e.g., `linear-weight` → `--lynx-linear-weight`, `flex-direction` → `--flex-direction`) + - **Replace Rules** (`REPLACE_RULE`): Value-dependent transformations that expand to multiple properties + - **Token Rules**: Per-token transformations (e.g., `rpx` unit conversion) + - **Special Rules**: Hardcoded logic for `color` (gradient support) and `linear-weight-sum` (children styles) +- **Key Transformations**: + - `display: linear` → `display: flex` with CSS custom property toggles (`--lynx-display-toggle`, `--lynx-display`) + - `rpx` units → `calc(value * var(--rpx-unit))` + - `linear-orientation`, `linear-direction` → `--lynx-linear-orientation` with toggle variables + - `linear-gravity` → `--justify-content-*` variables for directional alignment + - `linear-layout-gravity` → `--align-self-*` variables + - `linear-cross-gravity` → `align-items` + - `direction: lynx-rtl` → `direction: rtl` + - `color: linear-gradient(...)` → transparent color + background-clip + custom property + - `color: ` → adds background-clip reset for text gradient support +- **Architecture**: + - `StyleTransformer`: Implements the `Parser` trait, parses CSS declarations using a state machine (status 0→1→2→3→0). + - `Generator` trait: Consumers implement `push_transformed_style()` and `push_transform_kids_style()` to receive transformed declarations. + - `transform_inline_style_string()`: Convenience function for transforming inline style strings (client feature). + - `query_transform_rules()`: Returns a tuple of `(current_declarations, kids_declarations)` for a given property/value pair. + +### `template` + +Handles template structures for element trees and style information. + +- **Key files**: `template_sections/element_template/raw_element_template.rs`, `template_sections/style_info/raw_style_info.rs`. +- **Usage**: Serialization and deserialization of templates using `bincode`. +- **Key Structures**: + - `RawElementTemplate`: Contains a list of `Operation`s (Leo ASM instructions) and a set of tag names. Provides builder methods (encode feature) for constructing templates. + - `ElementTemplateSection`: A map of template names to `RawElementTemplate` instances. Supports encoding/decoding via `bincode`. + - `RawStyleInfo`: Maps CSS IDs to `StyleSheet` instances. Contains imports and rules. + - `StyleSheet`: Contains imports (CSS ID references) and a list of `Rule` objects. + - `Rule`: Contains rule type (Declaration, FontFace, KeyFrames), prelude (selectors), declaration block, and nested rules. + - `Selector`: A list of `OneSimpleSelector` (class, id, attribute, type, pseudo-class, pseudo-element, universal, combinator). + - `DecodedStyleData`: Decoded style content with style string, font-face content, and CSS-OG class selector mappings. + +### `leo_asm` + +Defines operations for the Leo engine's element tree manipulation. + +- **Key files**: `operation.rs`. +- **Usage**: Used by templates to represent DOM manipulation instructions. +- **Opcodes**: + - `SetAttribute (1)`: Set an attribute on an element. + - `RemoveChild (3)`: Remove a child element. + - `AppendChild (5)`: Append a child element to a parent. + - `CreateElement (6)`: Create a new element with a tag name. + - `SetAttributeSlot (7)`: Set an attribute slot for dynamic binding. + - `AppendElementSlot (8)`: Append an element slot for dynamic children. + - `SetDataset (10)`: Set a dataset property on an element. + - `AddEvent (11)`: Add an event listener. + - `AppendToRoot (12)`: Append an element to the root. +- **Operation Structure**: Each operation has an opcode, numeric operands (`Vec`), and string operands (`Vec`). + +### `js_binding` (Feature: `client`) + +Defines the JS <-> Rust bridge, primarily used when the `client` feature is enabled. + +- **Key files**: `mts_js_binding.rs`. +- **Exports**: `RustMainthreadContextBinding` (extern "C" type imported from JS). +- **Key Methods** (imported from JS): + - `publish_mts_event` (js_name: `runWorklet`): Dispatches worklet events with target/current-target information. + - `publish_event` (js_name: `publishEvent`): Dispatches cross-thread events to the JS side. + - `add_event_listener` (js_name: `addEventListener`): Registers global event listeners. + - `load_internal_web_element` (js_name: `loadInternalWebElement`): Loads internal web elements by ID. + - `load_unknown_element` (js_name: `loadUnknownElement`): Loads unknown/custom elements by tag name. + - `mark_exposure_related_element_by_unique_id` (js_name: `markExposureRelatedElementByUniqueId`): Marks elements for exposure/visibility tracking. + +### `main_thread` (Feature: `client`) + +Manages the main thread state. + +- **Key files**: `main_thread_context.rs`, `element_apis/`. +- **Usage**: Central hub for element and template management at runtime. +- **`MainThreadWasmContext`**: The main state holder, containing: + - `root_node`: The root DOM node. + - `document`: The web document reference. + - `unique_id_to_element_map`: Maps unique IDs to `LynxElementData` (element metadata). + - `element_templates_instances`: Cached decoded element templates by URL and name. + - `enabled_events`: Set of event names that have been globally enabled. + - `page_element_unique_id`: The unique ID of the page element. + - `mts_binding`: The JS binding for communication. + - `config_enable_css_selector`: Whether CSS selector mode is enabled. +- **Element APIs** (submodules): + - `component_apis`: Component-related operations. + - `dataset_apis`: Dataset (data-* attributes) management. + - `element_data`: `LynxElementData` struct holding per-element metadata (css_id, component_id, dataset, event handlers, exposure tracking). + - `element_template_apis`: Template instantiation and DOM construction from templates. + - `event_apis`: Event handler registration and dispatching (cross-thread and worklet events). + - `style_apis`: Style manipulation using `transform_inline_style_string()`. +- **Key WASM Exports**: + - `__CreateElementCommon`: Creates an element and registers it in the context. + - `__wasm_add_event_bts`: Adds cross-thread event handlers. + - `__wasm_add_event_run_worklet`: Adds worklet event handlers. + - `__GetEvent`, `__GetEvents`: Retrieves event handler information. + - `__wasm_take_timing_flags`: Retrieves and clears timing flags for performance tracking. + +### `constants` + +Defines shared constants and mappings used across modules. + +- **Key files**: `constants.rs`. +- **Content**: + - **Attribute Names**: `CSS_ID_ATTRIBUTE` (`l-css-id`), `LYNX_ENTRY_NAME_ATTRIBUTE` (`l-e-name`), `LYNX_UNIQUE_ID_ATTRIBUTE` (`l-uid`), `LYNX_TEMPLATE_MEMBER_ID_ATTRIBUTE` (`l-t-e-id`), `LYNX_EXPOSURE_ID_ATTRIBUTE` (`exposure-id`), `LYNX_TIMING_FLAG_ATTRIBUTE` (`__lynx_timing_flag`). + - **Event Names**: `APPEAR_EVENT_NAME`, `DISAPPEAR_EVENT_NAME`. + - **Tag Mappings**: `LYNX_TAG_TO_HTML_TAG_MAP` maps Lynx tags (`view`, `text`, `image`, `raw-text`, `scroll-view`, `wrapper`, `list`, `page`) to HTML custom element tags (`x-view`, `x-text`, etc.). + - **Dynamic Loading**: `LYNX_TAG_TO_DYNAMIC_LOAD_TAG_ID` maps component tags to loader IDs for lazy loading. + - **Pre-loaded Tags**: `ALREADY_LOADED_TAGS` lists tags that don't require dynamic loading. + - **CSS Property Map**: `STYLE_PROPERTY_MAP` (client feature) - a comprehensive list of ~150 CSS properties for indexed access. diff --git a/packages/web-platform/web-core-wasm/Cargo.toml b/packages/web-platform/web-core-wasm/Cargo.toml new file mode 100644 index 0000000000..1cdd098474 --- /dev/null +++ b/packages/web-platform/web-core-wasm/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "web-core-wasm" +version = "0.0.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +bincode = { version = "2.0.1" } +fnv = "1.0.7" +js-sys = { workspace = true } +lazy_static = { workspace = true } +wasm-bindgen = { workspace = true } +web-sys = { workspace = true, optional = true, features = ["HtmlCollection", "CssStyleDeclaration", "CssRule", "CssRuleList", "HtmlElement", "Blob", "BlobPropertyBag", "Url", "HtmlTemplateElement", "HtmlStyleElement", "DocumentFragment", "Document", "NodeList", "DomTokenList", "CssStyleSheet"] } +[features] +default = [] +encode = [] +client = ["web-sys"] +server = [] diff --git a/packages/web-platform/web-core-wasm/src/constants.rs b/packages/web-platform/web-core-wasm/src/constants.rs new file mode 100644 index 0000000000..cc62a458ea --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/constants.rs @@ -0,0 +1,296 @@ +/* + * Copyright 2025 The Lynx Authors. All rights reserved. + * Licensed under the Apache License Version 2.0 that can be found in the + * LICENSE file in the root directory of this source tree. + */ + +use fnv::{FnvHashMap, FnvHashSet}; + +pub const CSS_ID_ATTRIBUTE: &str = "l-css-id"; +pub const LYNX_ENTRY_NAME_ATTRIBUTE: &str = "l-e-name"; + +#[cfg(feature = "client")] +pub const LYNX_UNIQUE_ID_ATTRIBUTE: &str = "l-uid"; +#[cfg(feature = "client")] +pub const LYNX_TEMPLATE_MEMBER_ID_ATTRIBUTE: &str = "l-t-e-id"; +#[cfg(feature = "client")] +pub const APPEAR_EVENT_NAME: &str = "appear"; +#[cfg(feature = "client")] +pub const DISAPPEAR_EVENT_NAME: &str = "disappear"; +#[cfg(feature = "client")] +pub const LYNX_EXPOSURE_ID_ATTRIBUTE: &str = "exposure-id"; // if this attribute is present, the exposure event is enabled +#[cfg(feature = "client")] +pub const LYNX_TIMING_FLAG_ATTRIBUTE: &str = "__lynx_timing_flag"; // if this attribute is present, we should collect timing flags on creating and send it on calling __flushElementTree +#[cfg(feature = "client")] +pub(crate) const STYLE_PROPERTY_MAP: &[&str] = &[ + "", + "top", + "left", + "right", + "bottom", + "position", + "box-sizing", + "background-color", + "border-left-color", + "border-right-color", + "border-top-color", + "border-bottom-color", + "border-radius", + "border-top-left-radius", + "border-bottom-left-radius", + "border-top-right-radius", + "border-bottom-right-radius", + "border-width", + "border-left-width", + "border-right-width", + "border-top-width", + "border-bottom-width", + "color", + "opacity", + "display", + "overflow", + "height", + "width", + "max-width", + "min-width", + "max-height", + "min-height", + "padding", + "padding-left", + "padding-right", + "padding-top", + "padding-bottom", + "margin", + "margin-left", + "margin-right", + "margin-top", + "margin-bottom", + "white-space", + "letter-spacing", + "text-align", + "line-height", + "text-overflow", + "font-size", + "font-weight", + "flex", + "flex-grow", + "flex-shrink", + "flex-basis", + "flex-direction", + "flex-wrap", + "align-items", + "align-self", + "align-content", + "justify-content", + "background", + "border-color", + "font-family", + "font-style", + "transform", + "animation", + "animation-name", + "animation-duration", + "animation-timing-function", + "animation-delay", + "animation-iteration-count", + "animation-direction", + "animation-fill-mode", + "animation-play-state", + "line-spacing", + "border-style", + "order", + "box-shadow", + "transform-origin", + "linear-orientation", + "linear-weight-sum", + "linear-weight", + "linear-gravity", + "linear-layout-gravity", + "layout-animation-create-duration", + "layout-animation-create-timing-function", + "layout-animation-create-delay", + "layout-animation-create-property", + "layout-animation-delete-duration", + "layout-animation-delete-timing-function", + "layout-animation-delete-delay", + "layout-animation-delete-property", + "layout-animation-update-duration", + "layout-animation-update-timing-function", + "layout-animation-update-delay", + "adapt-font-size", + "aspect-ratio", + "text-decoration", + "text-shadow", + "background-image", + "background-position", + "background-origin", + "background-repeat", + "background-size", + "border", + "visibility", + "border-right", + "border-left", + "border-top", + "border-bottom", + "transition", + "transition-property", + "transition-duration", + "transition-delay", + "transition-timing-function", + "content", + "border-left-style", + "border-right-style", + "border-top-style", + "border-bottom-style", + "implicit-animation", + "overflow-x", + "overflow-y", + "word-break", + "background-clip", + "outline", + "outline-color", + "outline-style", + "outline-width", + "vertical-align", + "caret-color", + "direction", + "relative-id", + "relative-align-top", + "relative-align-right", + "relative-align-bottom", + "relative-align-left", + "relative-top-of", + "relative-right-of", + "relative-bottom-of", + "relative-left-of", + "relative-layout-once", + "relative-center", + "enter-transition-name", + "exit-transition-name", + "pause-transition-name", + "resume-transition-name", + "flex-flow", + "z-index", + "text-decoration-color", + "linear-cross-gravity", + "margin-inline-start", + "margin-inline-end", + "padding-inline-start", + "padding-inline-end", + "border-inline-start-color", + "border-inline-end-color", + "border-inline-start-width", + "border-inline-end-width", + "border-inline-start-style", + "border-inline-end-style", + "border-start-start-radius", + "border-end-start-radius", + "border-start-end-radius", + "border-end-end-radius", + "relative-align-inline-start", + "relative-align-inline-end", + "relative-inline-start-of", + "relative-inline-end-of", + "inset-inline-start", + "inset-inline-end", + "mask-image", + "grid-template-columns", + "grid-template-rows", + "grid-auto-columns", + "grid-auto-rows", + "grid-column-span", + "grid-row-span", + "grid-column-start", + "grid-column-end", + "grid-row-start", + "grid-row-end", + "grid-column-gap", + "grid-row-gap", + "justify-items", + "justify-self", + "grid-auto-flow", + "filter", + "list-main-axis-gap", + "list-cross-axis-gap", + "linear-direction", + "perspective", + "cursor", + "text-indent", + "clip-path", + "text-stroke", + "text-stroke-width", + "text-stroke-color", + "-x-auto-font-size", + "-x-auto-font-size-preset-sizes", + "mask", + "mask-repeat", + "mask-position", + "mask-clip", + "mask-origin", + "mask-size", + "gap", + "column-gap", + "row-gap", + "image-rendering", + "hyphens", + "-x-app-region", + "-x-animation-color-interpolation", + "-x-handle-color", + "-x-handle-size", + "offset-path", + "offset-distance", +]; + +lazy_static::lazy_static! { + pub static ref LYNX_TAG_TO_HTML_TAG_MAP: FnvHashMap<&'static str, &'static str> = FnvHashMap::from_iter(vec![ + ("view", "x-view"), + ("text", "x-text"), + ("image", "x-image"), + ("raw-text", "raw-text"), + ("scroll-view", "x-scroll-view"), + ("wrapper", "lynx-wrapper"), + ("list", "x-list"), + ("page", "div"), + ]); + + pub static ref HTML_TAG_TO_LYNX_TAG_MAP: FnvHashMap<&'static str, &'static str> = FnvHashMap::from_iter(LYNX_TAG_TO_HTML_TAG_MAP + .iter() + .map(|(k, v)| (*v, *k)) + ); + + /** + * See packages/web-platform/web-core-wasm/ts/client/webElementsDynamicLoader.ts + * This is a replica of the map in packages/web-platform/web-core-wasm/ts/constants.ts + */ + pub static ref LYNX_TAG_TO_DYNAMIC_LOAD_TAG_ID: FnvHashMap<&'static str, usize> = FnvHashMap::from_iter(vec![ + ("list", 0), + ("x-swiper", 1), + ("x-input", 2), + ("x-input-ng", 2), + ("input", 2), + ("x-textarea", 3), + ("x-audio-tt", 4), + ("x-foldview-ng", 5), + ("x-foldview-header-ng", 5), + ("x-foldview-slot-drag-ng", 5), + ("x-foldview-slot-ng", 5), + ("x-foldview-toolbar-ng", 5), + ("x-refresh-view", 6), + ("x-refresh-header", 6), + ("x-refresh-footer", 6), + ("x-overlay-ng", 7), + ("x-viewpager-ng", 8), + ("x-viewpager-item-ng", 8), + ]); + + pub static ref ALREADY_LOADED_TAGS: FnvHashSet<&'static str> = FnvHashSet::from_iter(vec![ + "view", + "text", + "image", + "raw-text", + "scroll-view", + "wrapper", + "div", + "svg" + ]); +} diff --git a/packages/web-platform/web-core-wasm/src/css_tokenizer/char_code_definitions.rs b/packages/web-platform/web-core-wasm/src/css_tokenizer/char_code_definitions.rs new file mode 100644 index 0000000000..7e50d49df9 --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/css_tokenizer/char_code_definitions.rs @@ -0,0 +1,190 @@ +/* + * Copyright 2025 The Lynx Authors. All rights reserved. + * Licensed under the Apache License Version 2.0 that can be found in the + * LICENSE file in the root directory of this source tree. + */ + +pub const EOF_CATEGORY: u8 = 0x80; +pub const WHITE_SPACE_CATEGORY: u8 = 0x82; +pub const DIGIT_CATEGORY: u8 = 0x83; +pub const NAME_START_CATEGORY: u8 = 0x84; +pub const NON_PRINTABLE_CATEGORY: u8 = 0x85; + +// Character category constants + +// Public character check macros (mirroring C macros) + +// A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9). +#[inline(always)] +pub const fn is_digit(code: u8) -> bool { + (code >= 0x30_u8) && (code <= 0x39_u8) +} + +// A digit, or a code point between U+0041 (A) and U+0046 (F), +// or a code point between U+0061 (a) and U+0066 (f). +#[inline(always)] +pub const fn is_hex_digit(code: u8) -> bool { + is_digit(code) + || ((code >= 0x41) && (code <= 0x46)) // A-F + || ((code >= 0x61) && (code <= 0x66)) // a-f +} + +// A code point between U+0041 (A) and U+005A (Z). +#[inline(always)] +pub const fn is_uppercase_letter(code: u8) -> bool { + (code >= 0x41) && (code <= 0x5A) +} + +// A code point between U+0061 (a) and U+007A (z). +#[inline(always)] +pub const fn is_lowercase_letter(code: u8) -> bool { + (code >= 0x61) && (code <= 0x7A) +} + +// An uppercase letter or a lowercase letter. +#[inline(always)] +pub const fn is_letter(code: u8) -> bool { + is_uppercase_letter(code) || is_lowercase_letter(code) +} + +// A code point with a value equal to or greater than U+0080 . +#[inline(always)] +pub const fn is_non_ascii(code: u8) -> bool { + code >= 0x80 +} + +// A letter, a non-ASCII code point, or U+005F LOW LINE (_). +#[inline(always)] +pub const fn is_name_start(code: u8) -> bool { + is_letter(code) || is_non_ascii(code) || code == 0x5F +} + +// A name-start code point, a digit, or U+002D HYPHEN-MINUS (-). +#[inline(always)] +pub const fn is_name(code: u8) -> bool { + is_name_start(code) || is_digit(code) || code == 0x2D +} + +// A code point between U+0000 NULL and U+0008 BACKSPACE, or U+000B LINE TABULATION, +// or a code point between U+000E SHIFT OUT and U+001F INFORMATION SEPARATOR ONE, or U+007F DELETE. +#[inline(always)] +pub const fn is_non_printable(code: u8) -> bool { + (code <= 0x08) || (code == 0x0B) || ((code >= 0x0E) && (code <= 0x1F)) || (code == 0x7F) +} + +// U+000A LINE FEED. (Also U+000D CR and U+000C FF for preprocessing equivalence) +#[inline(always)] +pub const fn is_newline(code: u8) -> bool { + (code == 0x0A_u8) || (code == 0x0D_u8) || (code == 0x0C_u8) +} + +// A newline, U+0009 CHARACTER TABULATION, or U+0020 SPACE. +#[inline(always)] +pub const fn is_white_space(code: u8) -> bool { + is_newline(code) || code == 0x09_u8 || code == 0x20_u8 +} + +// Check if two code points are a valid escape. +// If the first code point is not U+005C REVERSE SOLIDUS (\), return false. +// Otherwise, if the second code point is a newline or EOF (0), return false. +#[inline(always)] +pub const fn is_valid_escape(first: u8, second: u8) -> bool { + (first == 0x5C) && !is_newline(second) && (second != 0) +} + +// Check for Byte Order Mark +#[inline(always)] +pub fn get_start_offset(source: &str) -> usize { + let bom = "\u{FEFF}"; + let bom_le = "\u{FFFE}"; + if source.starts_with(bom) || source.starts_with(bom_le) { + 3usize // BOM found + } else { + 0usize + } +} + +// Check if three code points would start an identifier. +#[inline(always)] +pub fn is_identifier_start(first: u8, second: u8, third: u8) -> bool { + /* Look at the first code point: + U+002D HYPHEN-MINUS */ + if first == 0x2D { + /* If the second code point is a name-start code point, return true. */ + /* or the second and third code points are a valid escape, return true. Otherwise, return false. */ + is_name_start(second) || (second == 0x2D) || is_valid_escape(second, third) + /* name-start code point */ + } else if is_name_start(first) { + true + /*U+005C REVERSE SOLIDUS (\)*/ + } else if first == 0x5C { + /* If the second code point is a name-start code point, return true. Otherwise, return false.*/ + is_valid_escape(first, second) + } else { + false + } +} + +// Check if three code points would start a number. +#[inline(always)] +pub fn is_number_start(first: u8, second: u8, third: u8) -> bool { + if first == 0x2B || first == 0x2D { + // U+002B PLUS SIGN (+) or U+002D HYPHEN-MINUS (-) + if is_digit(second) { + true + } else { + (second == 0x2E) && is_digit(third) // U+002E FULL STOP (.) + } + } else if first == 0x2E { + // U+002E FULL STOP (.) + is_digit(second) + } else { + is_digit(first) + } +} + +// Get the category of a character code. +#[inline(always)] +pub fn char_code_category(char_code: u8) -> u8 { + match char_code { + 0 => EOF_CATEGORY, + c if c >= 0x80 => { + // For char_code >= 0x80, it's considered NameStart_Category. + // This aligns with CSS syntax where non-ASCII characters are name-start characters. + NAME_START_CATEGORY + } + c if is_white_space(c) => WHITE_SPACE_CATEGORY, + c if is_digit(c) => DIGIT_CATEGORY, + c if is_name_start(c) => NAME_START_CATEGORY, + c if is_non_printable(c) => NON_PRINTABLE_CATEGORY, + _ => char_code, + } +} + +#[inline(always)] +pub fn cmp_char(test_str: &str, offset: usize, reference_code: u8) -> bool { + if offset >= test_str.len() { + return false; + } + test_str.as_bytes()[offset].eq_ignore_ascii_case(&reference_code) +} + +#[inline(always)] +pub fn get_char_code(source: &str, offset: usize) -> u8 { + if offset < source.len() { + source.as_bytes()[offset] + } else { + 0 // EOF + } +} + +#[inline(always)] +pub fn get_new_line_length(source: &str, offset: usize, code: u8) -> usize { + if code == 13 /* \r */ && get_char_code(source, offset + 1) == 10 + /* \n */ + { + 2 + } else { + 1 + } +} diff --git a/packages/web-platform/web-core-wasm/src/css_tokenizer/mod.rs b/packages/web-platform/web-core-wasm/src/css_tokenizer/mod.rs new file mode 100644 index 0000000000..b61ddaca01 --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/css_tokenizer/mod.rs @@ -0,0 +1,762 @@ +/* + * Copyright 2025 The Lynx Authors. All rights reserved. + * Licensed under the Apache License Version 2.0 that can be found in the + * LICENSE file in the root directory of this source tree. + */ + +/// CSS Tokenizer module. +/// +/// This module implements a CSS tokenizer based on the CSS Syntax Level 3 specification. +/// It is a port of the `css-tree` tokenizer. +/// +/// Key components: +/// - `tokenize`: Contains functions to consume different types of tokens (numeric, ident-like, etc.). +/// - `token_types`: Defines constants for different token types. +/// - `char_code_definitions`: Utility macros and functions for character classification. +pub(crate) mod char_code_definitions; +pub(crate) mod token_types; +pub(crate) mod tokenize; +mod utils; + +#[cfg(test)] +mod tests { + // Tests for utility functions and character definitions + + #[test] + fn test_character_classification_macros() { + use super::char_code_definitions::*; + + // Test digit classification + assert!(is_digit(b'0')); + assert!(is_digit(b'9')); + assert!(!is_digit(b'a')); + + // Test hex digit classification + assert!(is_hex_digit(b'0')); + assert!(is_hex_digit(b'A')); + assert!(is_hex_digit(b'f')); + assert!(!is_hex_digit(b'g')); + + // Test letter classification + assert!(is_uppercase_letter(b'A')); + assert!(is_uppercase_letter(b'Z')); + assert!(!is_uppercase_letter(b'a')); + + assert!(is_lowercase_letter(b'a')); + assert!(is_lowercase_letter(b'z')); + assert!(!is_lowercase_letter(b'A')); + + assert!(is_letter(b'A')); + assert!(is_letter(b'z')); + assert!(!is_letter(b'1')); + + // Test non-ASCII + assert!(is_non_ascii(0x0080)); + assert!(!is_non_ascii(0x007F)); + + // Test name-start + assert!(is_name_start(b'a')); + assert!(is_name_start(b'_')); + assert!(is_name_start(0x0080)); + assert!(!is_name_start(b'1')); + + // Test name + assert!(is_name(b'a')); + assert!(is_name(b'1')); + assert!(is_name(b'-')); + assert!(!is_name(b' ')); + + // Test non-printable + assert!(is_non_printable(0x0008)); + assert!(is_non_printable(0x000B)); + assert!(is_non_printable(0x007F)); + assert!(!is_non_printable(0x0020)); + + // Test newline + assert!(is_newline(0x000A)); // LF + assert!(is_newline(0x000D)); // CR + assert!(is_newline(0x000C)); // FF + assert!(!is_newline(0x0020)); // SPACE + + // Test whitespace + assert!(is_white_space(0x0020)); // SPACE + assert!(is_white_space(0x0009)); // TAB + assert!(is_white_space(0x000A)); // LF + assert!(!is_white_space(0x0041)); // 'A' + + // Test valid escape + assert!(is_valid_escape(0x005C, 0x0041)); // \A + assert!(!is_valid_escape(0x005C, 0x000A)); // \newline + assert!(!is_valid_escape(0x0041, 0x0041)); // AA + + // Test identifier start + assert!(is_identifier_start(0x0041, 0x0042, 0x0043)); // ABC + assert!(is_identifier_start(0x002D, 0x0041, 0x0042)); // -AB + assert!(is_identifier_start(0x002D, 0x002D, 0x0041)); // --A + assert!(is_identifier_start(0x005C, 0x0041, 0x0042)); // \AB + assert!(!is_identifier_start(0x0031, 0x0032, 0x0033)); // 123 + + // Test number start + assert!(is_number_start(0x0031, 0x0032, 0x0033)); // 123 + assert!(is_number_start(0x002B, 0x0031, 0x0032)); // +12 + assert!(is_number_start(0x002D, 0x0031, 0x0032)); // -12 + assert!(is_number_start(0x002E, 0x0031, 0x0032)); // .12 + assert!(is_number_start(0x002B, 0x002E, 0x0031)); // +.1 + assert!(!is_number_start(0x0041, 0x0042, 0x0043)); // ABC + } + + #[test] + fn test_char_code_category() { + use super::char_code_definitions::*; + + // Test basic categories + assert_eq!(char_code_category(0x0020), WHITE_SPACE_CATEGORY); // SPACE + assert_eq!(char_code_category(0x0031), DIGIT_CATEGORY); // '1' + assert_eq!(char_code_category(0x0041), NAME_START_CATEGORY); // 'A' + assert_eq!(char_code_category(0x0008), NON_PRINTABLE_CATEGORY); + assert_eq!(char_code_category(0x0080), NAME_START_CATEGORY); // non-ASCII + + // Test specific character codes + assert_eq!(char_code_category(0x0022), 0x0022); // quote + assert_eq!(char_code_category(0x0023), 0x0023); // hash + assert_eq!(char_code_category(0x0028), 0x0028); // left paren + } + + #[test] + fn test_utility_functions() { + use super::utils::*; + + // Test cmp_str function + let test_str: &str = "hello"; + let reference: &str = "hello"; + assert!(cmp_str(test_str, 0, 5, reference)); + + let reference2: &str = "world"; + assert!(!cmp_str(test_str, 0, 5, reference2)); + + // Test case insensitive comparison + let test_str_upper = "HELLO"; + let reference_lower = "hello"; + assert!(cmp_str(test_str_upper, 0, 5, reference_lower)); + + // Test partial string comparison + let test_str_long = "hello world"; + assert!(cmp_str(test_str_long, 0, 5, reference)); + let world_ref = "world"; + assert!(cmp_str(test_str_long, 6, 11, world_ref)); + + // Test out of bounds + assert!(!cmp_str(test_str, 0, 10, reference)); // end > length + assert!(!cmp_str(test_str, 0, 3, reference)); // different lengths + + // Test find_white_space_end + let whitespace_str = " hello"; + assert_eq!(find_white_space_end(whitespace_str, 0), 3); + + let no_whitespace = "hello"; + assert_eq!(find_white_space_end(no_whitespace, 0), 0); + + let all_whitespace = " "; + assert_eq!(find_white_space_end(all_whitespace, 0), 3); + + // Test find_decimal_number_end + let number_str = "123abc"; + assert_eq!(find_decimal_number_end(number_str, 0), 3); + + let no_number = "abc123"; + assert_eq!(find_decimal_number_end(no_number, 0), 0); + + let all_numbers = "123456"; + assert_eq!(find_decimal_number_end(all_numbers, 0), 6); + + // Test consume_number + let simple_number = "123"; + assert_eq!(consume_number(simple_number, 0), 3); + + let signed_number = "+123"; + assert_eq!(consume_number(signed_number, 0), 4); + + let negative_number = "-123"; + assert_eq!(consume_number(negative_number, 0), 4); + + let decimal_number: &str = "123.456"; + assert_eq!(consume_number(decimal_number, 0), 7); + + let scientific_number: &str = "123e456"; + assert_eq!(consume_number(scientific_number, 0), 7); + + let scientific_signed: &str = "123e+456"; + assert_eq!(consume_number(scientific_signed, 0), 8); + + let scientific_negative: &str = "123e-456"; + assert_eq!(consume_number(scientific_negative, 0), 8); + + // Test consume_name + let simple_name: &str = "hello"; + assert_eq!(consume_name(simple_name, 0), 5); + + let hyphenated_name: &str = "hello-world"; + assert_eq!(consume_name(hyphenated_name, 0), 11); + + let name_with_digits: &str = "hello123"; + assert_eq!(consume_name(name_with_digits, 0), 8); + + let name_with_underscore: &str = "_hello"; + assert_eq!(consume_name(name_with_underscore, 0), 6); + + // Test consume_escaped + let escaped_char: &str = "\\41 "; // \41 = 'A' + assert_eq!(consume_escaped(escaped_char, 0), 4); // includes whitespace consumption + + let escaped_simple: &str = "\\A"; + assert_eq!(consume_escaped(escaped_simple, 0), 2); + + let escaped_hex: &str = "\\41424344"; + assert_eq!(consume_escaped(escaped_hex, 0), 7); // max 6 hex digits after \ + + // Test consume_bad_url_remnants + let bad_url: &str = "test)"; + assert_eq!(consume_bad_url_remnants(bad_url, 0), 5); + + let bad_url_no_close: &str = "test"; + assert_eq!(consume_bad_url_remnants(bad_url_no_close, 0), 4); + + let bad_url_with_escape: &str = "te\\)st)"; + assert_eq!(consume_bad_url_remnants(bad_url_with_escape, 0), 7); + } + + // Additional tests to reach 100% coverage + + #[test] + fn test_tokenizer_specific_cases() { + use super::tokenize::{self, Parser}; + + struct TokenCollector { + tokens: Vec<(u8, String)>, + } + + impl TokenCollector { + fn new() -> Self { + Self { tokens: Vec::new() } + } + } + + impl Parser for TokenCollector { + fn on_token(&mut self, token_type: u8, value: &str) { + self.tokens.push((token_type, value.to_string())); + } + } + + // Test hash token with name following + let source: &str = "#id"; + let mut collector = TokenCollector::new(); + tokenize::tokenize(source, &mut collector); + assert!(!collector.tokens.is_empty()); + + // Test hash token without name following + let source: &str = "#123"; + let mut collector = TokenCollector::new(); + tokenize::tokenize(source, &mut collector); + assert!(!collector.tokens.is_empty()); + + // Test at-keyword token + let source: &str = "@media"; + let mut collector = TokenCollector::new(); + tokenize::tokenize(source, &mut collector); + assert!(!collector.tokens.is_empty()); + + // Test at-keyword without identifier + let source: &str = "@123"; + let mut collector = TokenCollector::new(); + tokenize::tokenize(source, &mut collector); + assert!(!collector.tokens.is_empty()); + + // Test escaped identifier + let source: &str = "\\61 bc"; // \61 = 'a', so "abc" + let mut collector = TokenCollector::new(); + tokenize::tokenize(source, &mut collector); + assert!(!collector.tokens.is_empty()); + + // Test invalid escape + let source: &str = "\\"; + let mut collector = TokenCollector::new(); + tokenize::tokenize(source, &mut collector); + assert!(!collector.tokens.is_empty()); + + // Test delim tokens + let source: &str = "!@#$%^&*"; + let mut collector = TokenCollector::new(); + tokenize::tokenize(source, &mut collector); + assert!(!collector.tokens.is_empty()); + + // Test CDC token + let source: &str = "-->"; + let mut collector = TokenCollector::new(); + tokenize::tokenize(source, &mut collector); + assert!(!collector.tokens.is_empty()); + + // Test CDO token + let source: &str = " --lynx-linear-weight-sum: ; + */ + if name == "linear-weight-sum" { + result_children.push(("--lynx-linear-weight-sum", value)); + } + /* + * There is a special rule for linear-weight + * linear-weight: 0; --> do nothing + * linear-weight: --> --lynx-linear-weight: 0; + */ + if name == "linear-weight" && value != "0" { + result.push(("--lynx-linear-weight-basis", "0")); + } + (result, result_children) +} +#[cfg(test)] +mod tests { + use super::{get_rename_rule_value, get_replace_rule_value, query_transform_rules}; + + #[test] + fn test_rename_rule_flex_direction() { + let source = "flex-direction:row"; + let name = &source[0..source.len() - 4]; + let result = get_rename_rule_value(name).unwrap(); + assert_eq!(result, "--flex-direction"); + } + #[test] + fn test_rename_rule_flex_direction_at_mid() { + let source = "height:1px;flex-direction:row"; + let offset = "height:1px;".len(); + let name = &source[offset..source.len() - 4]; + let result = get_rename_rule_value(name).unwrap(); + assert_eq!(result, "--flex-direction"); + } + #[test] + fn test_replace_rule_display_linear() { + let source = "display:linear"; + let name = &source[0..7]; + let value = &source[8..]; + let result = get_replace_rule_value(name, value) + .unwrap() + .iter() + .map(|pair| format!("{}:{}", pair.0, pair.1)) + .collect::>() + .join(";"); + assert_eq!( + result, + "--lynx-display-toggle:var(--lynx-display-linear);--lynx-display:linear;display:flex" + ); + } + #[test] + fn test_replace_rule_display_linear_at_mid() { + let source = "height:1px;display:linear"; + let offset = "height:1px;".len(); + let name = &source[offset..offset + 7]; + let value = &source[offset + 8..]; + let result = get_replace_rule_value(name, value) + .unwrap() + .iter() + .map(|pair| format!("{}:{}", pair.0, pair.1)) + .collect::>() + .join(";"); + assert_eq!( + result, + "--lynx-display-toggle:var(--lynx-display-linear);--lynx-display:linear;display:flex" + ); + } + + #[test] + fn test_rename_rule_not_exist() { + let source = "background-image:url(\"https://example.com\")"; + let name = &source[0.."background-image".len()]; + let result = get_rename_rule_value(name); + assert_eq!(result, None); + } + + #[test] + fn test_replace_rule_value_not_match() { + let source = "display:grid"; + let name = &source[0..7]; + let value = &source[8..]; + let result = get_replace_rule_value(name, value); + assert_eq!(result, None); + } + + #[test] + fn test_replace_rule_name_not_match() { + let source = "height:1px"; + let name = &source[0..6]; + let value = &source[7..]; + let result = get_replace_rule_value(name, value); + assert_eq!(result, None); + } + + #[test] + fn test_query_transform_rules_rename() { + let (res, children) = query_transform_rules("flex-direction", "row"); + assert_eq!(res, vec![("--flex-direction", "row")]); + assert!(children.is_empty()); + } + + #[test] + fn test_query_transform_rules_replace() { + let (res, children) = query_transform_rules("display", "linear"); + assert_eq!( + res, + vec![ + ("--lynx-display-toggle", "var(--lynx-display-linear)"), + ("--lynx-display", "linear"), + ("display", "flex") + ] + ); + assert!(children.is_empty()); + } + + #[test] + fn test_query_transform_rules_color_linear_gradient() { + let (res, children) = query_transform_rules("color", "linear-gradient(red, blue)"); + assert_eq!( + res, + vec![ + ("color", "transparent"), + ("-webkit-background-clip", "text"), + ("background-clip", "text"), + ("--lynx-text-bg-color", "linear-gradient(red, blue)") + ] + ); + assert!(children.is_empty()); + } + + #[test] + fn test_query_transform_rules_color_normal() { + let (res, children) = query_transform_rules("color", "red"); + assert_eq!( + res, + vec![ + ("--lynx-text-bg-color", "initial"), + ("-webkit-background-clip", "initial"), + ("background-clip", "initial"), + ("color", "red") + ] + ); + assert!(children.is_empty()); + } + + #[test] + fn test_query_transform_rules_linear_weight_sum() { + let (res, children) = query_transform_rules("linear-weight-sum", "1"); + assert!(res.is_empty()); + assert_eq!(children, vec![("--lynx-linear-weight-sum", "1")]); + } + + #[test] + fn test_query_transform_rules_linear_weight() { + let (res, children) = query_transform_rules("linear-weight", "1"); + assert_eq!( + res, + vec![ + ("--lynx-linear-weight", "1"), + ("--lynx-linear-weight-basis", "0") + ] + ); + assert!(children.is_empty()); + } + + #[test] + fn test_query_transform_rules_linear_weight_zero() { + let (res, children) = query_transform_rules("linear-weight", "0"); + assert_eq!(res, vec![("--lynx-linear-weight", "0")]); + assert!(children.is_empty()); + } + #[test] + fn test_query_transform_rules_linear_direction() { + let name = "linear-direction"; + let value = "row"; + let (result, _) = query_transform_rules(name, value); + assert_eq!(result[0].0, "--lynx-linear-orientation"); + assert_eq!(result[0].1, "horizontal"); + } +} diff --git a/packages/web-platform/web-core-wasm/src/style_transformer/token_transformer.rs b/packages/web-platform/web-core-wasm/src/style_transformer/token_transformer.rs new file mode 100644 index 0000000000..973714a33e --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/style_transformer/token_transformer.rs @@ -0,0 +1,64 @@ +/* + * Copyright 2025 The Lynx Authors. All rights reserved. + * Licensed under the Apache License Version 2.0 that can be found in the + * LICENSE file in the root directory of this source tree. +*/ + +use crate::css_tokenizer::token_types::*; +use std::borrow::Cow; + +/** + * Transform one token according to specific rules. + * Rule list: + * 1. If the token is a DIMENSION_TOKEN with "rpx" unit, convert it to a calc(value * var(--rpx-unit)); + */ +pub(crate) fn transform_one_token<'a>(token_type: u8, token_value: &'a str) -> (u8, Cow<'a, str>) { + match token_type { + DIMENSION_TOKEN => { + if token_value.len() > 3 && token_value.to_ascii_lowercase().ends_with("rpx") { + let value = &token_value[..token_value.len() - 3]; + return ( + token_type, + Cow::Owned(format!("calc({value} * var(--rpx-unit))")), + ); + } + (token_type, Cow::Borrowed(token_value)) + } + _ => (token_type, Cow::Borrowed(token_value)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transform_rpx() { + let (_, tv) = transform_one_token(DIMENSION_TOKEN, "100rpx"); + assert_eq!(tv, "calc(100 * var(--rpx-unit))"); + } + + #[test] + fn test_transform_rpx_float() { + let (_, tv) = transform_one_token(DIMENSION_TOKEN, "100.5rpx"); + assert_eq!(tv, "calc(100.5 * var(--rpx-unit))"); + } + + #[test] + fn test_transform_px() { + let (_, tv) = transform_one_token(DIMENSION_TOKEN, "100px"); + assert_eq!(tv, "100px"); + } + + #[test] + fn test_transform_rpx_case_insensitive() { + let (_, tv) = transform_one_token(DIMENSION_TOKEN, "100RPX"); + assert_eq!(tv, "calc(100 * var(--rpx-unit))"); + } + + #[test] + fn test_transform_other_token() { + let (_, tv) = transform_one_token(IDENT_TOKEN, "red"); + assert_eq!(tv, "red"); + } +} diff --git a/packages/web-platform/web-core-wasm/src/style_transformer/transformer.rs b/packages/web-platform/web-core-wasm/src/style_transformer/transformer.rs new file mode 100644 index 0000000000..01a050ad33 --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/style_transformer/transformer.rs @@ -0,0 +1,666 @@ +/* + * Copyright 2025 The Lynx Authors. All rights reserved. + * Licensed under the Apache License Version 2.0 that can be found in the + * LICENSE file in the root directory of this source tree. + */ +#[cfg(feature = "client")] +use crate::css_tokenizer::tokenize; +use crate::css_tokenizer::{ + char_code_definitions::is_white_space, token_types::*, tokenize::Parser, +}; +#[cfg_attr(test, derive(Debug, PartialEq))] +pub(crate) struct ParsedDeclaration { + pub(crate) property_name: String, + pub(crate) property_value: String, + pub(crate) is_important: bool, +} + +impl ParsedDeclaration { + pub(crate) fn generate_to_string_buf(&self, string_buffer: &mut String) { + string_buffer.push_str(&self.property_name); + string_buffer.push(':'); + string_buffer.push_str(&self.property_value); + if self.is_important { + string_buffer.push_str(" !important"); + } + string_buffer.push(';'); + } +} + +use super::rules::query_transform_rules; + +const IMPORTANT_STR: &str = "important"; +pub struct StyleTransformer<'a, T: Generator> { + generator: &'a mut T, + + status: usize, + current_property: Option, + current_value: String, + is_important: bool, + prev_token_type: u8, +} + +pub(crate) trait Generator { + fn push_transformed_style(&mut self, declaration: ParsedDeclaration); + fn push_transform_kids_style(&mut self, declaration: ParsedDeclaration); +} + +impl<'a, T: Generator> Parser for StyleTransformer<'a, T> { + fn on_token(&mut self, token_type: u8, token_value: &str) { + let (token_type, token_value) = + super::token_transformer::transform_one_token(token_type, token_value); + //https://drafts.csswg.org/css-syntax-3/#consume-declaration + // on_token(type, start, offset); + /* + explain the status:code + height : 1px !important ; + ^status = 0 ^status = 2 + ^status = 1 + ^status = 3 + + */ + if token_type == IDENT_TOKEN && self.status == 0 { + /* + 1. If the next token is an , consume a token from input and set decl's name to the token’s value. + Otherwise, consume the remnants of a bad declaration from input, with nested, and return nothing. + */ + self.current_property = Some(token_value.to_string()); + self.prev_token_type = token_type; + self.status = 1; + } + // 2. Discard whitespace from input. + else if self.status == 1 && token_type == WHITESPACE_TOKEN { + // do nothing, just skip whitespace + } else if self.status == 1 && token_type == COLON_TOKEN { + /* + 3. If the next token is a , discard a token from input. + Otherwise, consume the remnants of a bad declaration from input, with nested, and return nothing. + */ + self.status = 2; // now find a value + } else if self.status == 2 + && token_type != LEFT_CURLY_BRACKET_TOKEN + && token_type != LEFT_PARENTHESES_TOKEN + && token_type != LEFT_SQUARE_BRACKET_TOKEN + && token_type != SEMICOLON_TOKEN + { + if token_type == WHITESPACE_TOKEN { + // 4. Discard whitespace from input. + } else { + /* + 5. Consume a list of component values from input, with nested, and with s, <{-token>s, <(-token>s, and <[-token>s. + result: except <{-token>s, <(-token>s, and <[-token>s + */ + self.current_value.push_str(&token_value); + self.status = 3; // now find a semicolon + } + } else if self.status == 3 && token_type == SEMICOLON_TOKEN { + /* + 6. If the next token is a , consume a token from input. + Otherwise, consume the remnants of a bad declaration from input, with nested, and return nothing. + */ + while !self.current_value.is_empty() + && is_white_space(*self.current_value.as_bytes().last().unwrap_or(&0)) + { + self.current_value.pop(); + } + assert!( + self.current_property.is_some(), + "property name should be set before semicolon" + ); + let property_name = self.current_property.take().unwrap(); + // create a string with buf size 8 chars + let property_value = std::mem::replace(&mut self.current_value, String::with_capacity(8)); + self.status = 0; // reset + self.on_declaration_parsed(ParsedDeclaration { + property_name, + property_value, + is_important: self.is_important, + }); + self.is_important = false; + } else if self.status == 3 + && self.prev_token_type == DELIM_TOKEN + && token_value.eq_ignore_ascii_case(IMPORTANT_STR) + { + // here we will have some bad caes: like + // height: 1px !important 2px; + // height: 1px /important; + // we accept such limited cases for performance consideration + self.is_important = true; + self.current_value.pop(); // remove the '!' char + } else if self.status == 3 + && token_type != LEFT_CURLY_BRACKET_TOKEN + && token_type != LEFT_PARENTHESES_TOKEN + && token_type != LEFT_SQUARE_BRACKET_TOKEN + && token_type != SEMICOLON_TOKEN + { + self.current_value.push_str(&token_value); + } else if self.status != 0 { + // we have a bad declaration + self.status = 0; // reset + self.current_property = None; + self.current_value = String::with_capacity(8); + self.is_important = false; + } + self.prev_token_type = token_type; + } +} +impl<'a, T: Generator> StyleTransformer<'a, T> { + pub(crate) fn new(generator: &'a mut T) -> Self { + StyleTransformer { + generator, + status: 0, + current_property: None, + current_value: String::with_capacity(8), + is_important: false, + prev_token_type: WHITESPACE_TOKEN, // start with whitespace + } + } + + #[cfg(any(feature = "client", test))] + pub(crate) fn parse(&mut self, source: &str) { + tokenize::tokenize(source, self); + if self.prev_token_type != SEMICOLON_TOKEN { + self.on_token(SEMICOLON_TOKEN, ";"); + } + } + + fn on_declaration_parsed(&mut self, declaration: ParsedDeclaration) { + let empty: bool = { + let (current_declarations, kids_declarations) = + query_transform_rules(&declaration.property_name, &declaration.property_value); + for (name, value) in kids_declarations.into_iter() { + self.generator.push_transform_kids_style(ParsedDeclaration { + property_name: name.to_string(), + property_value: value.to_string(), + is_important: declaration.is_important, + }); + } + if current_declarations.is_empty() { + true + } else { + for (name, value) in current_declarations.into_iter() { + self.generator.push_transformed_style(ParsedDeclaration { + property_name: name.to_string(), + property_value: value.to_string(), + is_important: declaration.is_important, + }); + } + false + } + }; + if empty { + self.generator.push_transformed_style(declaration); + } + } +} + +#[cfg(test)] +mod tests { + use super::Generator; + use super::ParsedDeclaration; + + struct TestTransformer { + pub declarations: Vec, + } + + impl TestTransformer { + fn get_name<'a>(&self, _source: &'a str, decl: &'a ParsedDeclaration) -> &'a str { + &decl.property_name + } + + fn get_value<'a>(&self, _source: &'a str, decl: &'a ParsedDeclaration) -> &'a str { + &decl.property_value + } + } + impl Generator for TestTransformer { + fn push_transform_kids_style(&mut self, _decl: ParsedDeclaration) { + // TestTransformer does not need to handle kids styles + } + fn push_transformed_style(&mut self, decl: ParsedDeclaration) { + self.declarations.push(decl); + } + } + + fn parse_css(css: &str) -> (TestTransformer, &str) { + let mut test_transformer = TestTransformer { + declarations: Vec::new(), + }; + let mut style_transformer = super::StyleTransformer::new(&mut test_transformer); + style_transformer.parse(css); + (test_transformer, css) + } + + #[test] + fn test_basic_declaration() { + let (transformer, source) = parse_css("background-color: red;"); + + assert_eq!(transformer.declarations.len(), 1); + let decl = &transformer.declarations[0]; + assert_eq!(transformer.get_name(source, decl), "background-color"); + assert_eq!(transformer.get_value(source, decl), "red"); + assert!(!decl.is_important); + } + + #[test] + fn test_important_declaration() { + let (transformer, source) = parse_css("background-color: red !important;"); + + assert_eq!(transformer.declarations.len(), 1); + let decl = &transformer.declarations[0]; + assert_eq!(transformer.get_name(source, decl), "background-color"); + assert_eq!(transformer.get_value(source, decl), "red"); + assert!(decl.is_important); + } + + #[test] + fn test_multiple_declarations() { + let (transformer, source) = + parse_css("background-color: red; margin: 10px; padding: 5px !important;"); + + assert_eq!(transformer.declarations.len(), 3); + + assert_eq!( + transformer.get_name(source, &transformer.declarations[0]), + "background-color" + ); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "red" + ); + assert!(!transformer.declarations[0].is_important); + + assert_eq!( + transformer.get_name(source, &transformer.declarations[1]), + "margin" + ); + assert_eq!( + transformer.get_value(source, &transformer.declarations[1]), + "10px" + ); + assert!(!transformer.declarations[1].is_important); + + assert_eq!( + transformer.get_name(source, &transformer.declarations[2]), + "padding" + ); + assert_eq!( + transformer.get_value(source, &transformer.declarations[2]), + "5px" + ); + assert!(transformer.declarations[2].is_important); + } + + #[test] + fn test_whitespace_handling() { + let (transformer, source) = parse_css(" background-color : red ; margin : 10px ; "); + + assert_eq!(transformer.declarations.len(), 2); + assert_eq!( + transformer.get_name(source, &transformer.declarations[0]), + "background-color" + ); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "red" + ); + assert_eq!( + transformer.get_name(source, &transformer.declarations[1]), + "margin" + ); + assert_eq!( + transformer.get_value(source, &transformer.declarations[1]), + "10px" + ); + } + + #[test] + fn test_missing_semicolon() { + let (transformer, source) = parse_css("background-color: red"); + + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_name(source, &transformer.declarations[0]), + "background-color" + ); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "red" + ); + } + + #[test] + fn test_bad_declarations() { + // Invalid: missing colon + let (transformer, _) = parse_css("background-color red;"); + assert_eq!(transformer.declarations.len(), 0); + + // Invalid: missing value + let (transformer, _) = parse_css("background-color:;"); + assert_eq!(transformer.declarations.len(), 0); + + // Invalid: starting with non-ident + let (transformer, _) = parse_css("123: red;"); + assert_eq!(transformer.declarations.len(), 0); + } + + #[test] + fn test_complex_values() { + let (transformer, source) = parse_css("background: url(image.png) no-repeat center;"); + + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_name(source, &transformer.declarations[0]), + "background" + ); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "url(image.png) no-repeat center" + ); + } + + #[test] + fn test_empty_string() { + let (transformer, _) = parse_css(""); + assert_eq!(transformer.declarations.len(), 0); + } + + #[test] + fn test_only_whitespace() { + let (transformer, _) = parse_css(" \t\n "); + assert_eq!(transformer.declarations.len(), 0); + } + + #[test] + fn test_hyphenated_properties() { + let (transformer, source) = parse_css("font-size: 14px; background-color: blue;"); + + assert_eq!(transformer.declarations.len(), 2); + assert_eq!( + transformer.get_name(source, &transformer.declarations[0]), + "font-size" + ); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "14px" + ); + assert_eq!( + transformer.get_name(source, &transformer.declarations[1]), + "background-color" + ); + assert_eq!( + transformer.get_value(source, &transformer.declarations[1]), + "blue" + ); + } + + // Additional tests to improve coverage + + #[test] + fn test_parser_edge_cases() { + // Test consecutive semicolons + let (transformer, _) = parse_css("background-color: red;;"); + assert_eq!(transformer.declarations.len(), 1); + + // Test missing value with semicolon + let (transformer, _) = parse_css("background-color:;"); + assert_eq!(transformer.declarations.len(), 0); + + // Test bad declaration with brackets + let (transformer, _) = parse_css("background-color: red{};"); + assert_eq!(transformer.declarations.len(), 0); + + // Test values with brackets + let (transformer, source) = parse_css("background: url(test.png);"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "url(test.png)" + ); + } + + #[test] + fn test_important_edge_cases() { + // Important with space before ! + let (transformer, source) = parse_css("background-color: red !important;"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "red" + ); + assert!(transformer.declarations[0].is_important); + + // Important with extra spaces - the parser includes the spaces in the value but doesn't recognize as important + let (transformer, source) = parse_css("background-color: red ! important ;"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "red ! important" + ); + assert!(!transformer.declarations[0].is_important); // Extra space breaks the important detection + + // Important without space - this actually does get recognized as important + let (transformer, source) = parse_css("background-color: red!important;"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "red" + ); + assert!(transformer.declarations[0].is_important); // Actually recognized as important + + // Debug: let's see what happens with extra content after !important + let (transformer, source) = parse_css("background-color: red !important extra;"); + assert_eq!(transformer.declarations.len(), 1); + // The parser actually includes extra content but still marks as important + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "red extra" + ); + assert!(transformer.declarations[0].is_important); + } + + #[test] + fn test_special_characters_and_escapes() { + // Test escaped characters in property names + let css = "\\62 order: red;"; // \62 = 'b', so this should be "border" + let (transformer, _source) = parse_css(css); + assert_eq!(transformer.declarations.len(), 1); + + // Test unicode characters + let (transformer, source) = parse_css("background-color: #fff;"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "#fff" + ); + + // Test with newlines + let (transformer, source) = parse_css("background-color:\nred;"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "red" + ); + } + + #[test] + fn test_numeric_values() { + // Test integer values + let (transformer, source) = parse_css("z-index: 10;"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "10" + ); + + // Test decimal values + let (transformer, source) = parse_css("opacity: 0.5;"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "0.5" + ); + + // Test negative values + let (transformer, source) = parse_css("margin: -10px;"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "-10px" + ); + + // Test percentage values + let (transformer, source) = parse_css("width: 100%;"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "100%" + ); + } + + #[test] + fn test_string_values() { + // Test quoted strings + let (transformer, source) = parse_css("content: \"hello world\";"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "\"hello world\"" + ); + + // Test single quoted strings + let (transformer, source) = parse_css("content: 'hello world';"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "'hello world'" + ); + + // Test strings with escapes + let (transformer, _source) = parse_css("content: \"hello\\\"world\";"); + assert_eq!(transformer.declarations.len(), 1); + } + + #[test] + fn test_url_values() { + // Test unquoted URL + let (transformer, source) = parse_css("background: url(test.png);"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "url(test.png)" + ); + + // Test quoted URL + let (transformer, source) = parse_css("background: url(\"test.png\");"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "url(\"test.png\")" + ); + + // Test URL with spaces + let (transformer, source) = parse_css("background: url( test.png );"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "url( test.png )" + ); + } + + #[test] + fn test_function_values() { + // Test calc function + let (transformer, source) = parse_css("width: calc(100% - 20px);"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "calc(100% - 20px)" + ); + + // Test rgb function + let (transformer, source) = parse_css("background-color: rgb(255, 0, 0);"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "rgb(255, 0, 0)" + ); + + // Test nested functions + let (transformer, source) = parse_css("transform: translateX(calc(100% + 10px));"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "translateX(calc(100% + 10px))" + ); + } + + #[test] + fn test_comments() { + // Test comments in values - these should be tokenized but ignored in parsing + let (transformer, source) = parse_css("background-color: red /* comment */;"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "red /* comment */" + ); + + // Test comment between declarations + let (transformer, source) = parse_css("background-color: red; /* comment */ margin: 10px;"); + assert_eq!(transformer.declarations.len(), 2); + assert_eq!( + transformer.get_name(source, &transformer.declarations[0]), + "background-color" + ); + assert_eq!( + transformer.get_name(source, &transformer.declarations[1]), + "margin" + ); + } + + #[test] + fn test_malformed_css() { + // Test invalid characters + let (transformer, _) = parse_css("background-color: red;; invalid: ;;"); + // This should parse "background-color: red" successfully, others may fail + assert_eq!(transformer.declarations.len(), 1); // At least one valid declaration + } + + #[test] + fn test_whitespace_variants() { + // Test different whitespace characters + let css = "background-color:\t\nred\r\n;"; + let (transformer, source) = parse_css(css); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "red" + ); + + // Test tabs and multiple spaces + let (transformer, source) = parse_css("background-color: \t\t red \t\t;"); + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "red" + ); + } + + #[test] + fn test_bom_handling() { + // Test with Byte Order Mark + let css_with_bom = "\u{FEFF}background-color: red;"; + let (transformer, source) = parse_css(css_with_bom); + + assert_eq!(transformer.declarations.len(), 1); + assert_eq!( + transformer.get_name(source, &transformer.declarations[0]), + "background-color" + ); + assert_eq!( + transformer.get_value(source, &transformer.declarations[0]), + "red" + ); + } +} diff --git a/packages/web-platform/web-core-wasm/src/template/mod.rs b/packages/web-platform/web-core-wasm/src/template/mod.rs new file mode 100644 index 0000000000..f28c58059e --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/template/mod.rs @@ -0,0 +1,16 @@ +/* + * Copyright 2025 The Lynx Authors. All rights reserved. + * Licensed under the Apache License Version 2.0 that can be found in the + * LICENSE file in the root directory of this source tree. + */ + +/// Template module. +/// +/// This module defines the structure of Lynx templates, including element templates and style information. +/// It handles the serialization and deserialization of templates using `bincode`. +/// +/// Key components: +/// - `template_sections`: Contains submodules for different sections of a template. +/// - `element_template`: Defines `RawElementTemplate` which contains operations to build the element tree. +/// - `style_info`: Defines `RawStyleInfo` which contains style sheets and rules. +pub(crate) mod template_sections; diff --git a/packages/web-platform/web-core-wasm/src/template/template_sections/element_template/mod.rs b/packages/web-platform/web-core-wasm/src/template/template_sections/element_template/mod.rs new file mode 100644 index 0000000000..e853da9928 --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/template/template_sections/element_template/mod.rs @@ -0,0 +1,61 @@ +/* + * Copyright 2025 The Lynx Authors. All rights reserved. + * Licensed under the Apache License Version 2.0 that can be found in the + * LICENSE file in the root directory of this source tree. + */ + +// mod decoded_element_template; +mod raw_element_template; +use bincode::Decode; +#[cfg(feature = "encode")] +use bincode::Encode; +use fnv::FnvHashMap; +pub(crate) use raw_element_template::RawElementTemplate; +use wasm_bindgen::prelude::*; +#[derive(Decode, Default)] +#[cfg_attr(feature = "encode", derive(Encode))] +#[wasm_bindgen] +pub struct ElementTemplateSection { + pub(crate) element_templates_map: FnvHashMap, +} + +#[wasm_bindgen] +impl ElementTemplateSection { + #[cfg(feature = "encode")] + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + ElementTemplateSection::default() + } + + #[wasm_bindgen] + pub fn from_encoded( + buffer: js_sys::Uint8Array, + ) -> Result { + let (data, _) = bincode::decode_from_slice::( + &buffer.to_vec(), + bincode::config::standard(), + ) + .map_err(|e| { + wasm_bindgen::JsError::new(&format!( + "Failed to decode ElementTemplateSection from Uint8Array: {e}", + )) + })?; + Ok(data) + } + + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn add_element_template(&mut self, id: String, raw_element_template: RawElementTemplate) { + self.element_templates_map.insert(id, raw_element_template); + } + + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn encode(&self) -> js_sys::Uint8Array { + js_sys::Uint8Array::from( + bincode::encode_to_vec(self, bincode::config::standard()) + .unwrap() + .as_slice(), + ) + } +} diff --git a/packages/web-platform/web-core-wasm/src/template/template_sections/element_template/raw_element_template.rs b/packages/web-platform/web-core-wasm/src/template/template_sections/element_template/raw_element_template.rs new file mode 100644 index 0000000000..b1e26e359a --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/template/template_sections/element_template/raw_element_template.rs @@ -0,0 +1,119 @@ +/* + * Copyright 2025 The Lynx Authors. All rights reserved. + * Licensed under the Apache License Version 2.0 that can be found in the + * LICENSE file in the root directory of this source tree. + */ + +#[cfg(feature = "encode")] +use crate::leo_asm::LEOAsmOpcode; +use crate::leo_asm::Operation; +use bincode::Decode; +#[cfg(feature = "encode")] +use bincode::Encode; +use fnv::FnvHashSet; +#[cfg(feature = "encode")] +use wasm_bindgen::prelude::*; + +#[cfg_attr(feature = "encode", wasm_bindgen)] +#[cfg_attr(feature = "encode", derive(Encode, Default))] +#[derive(Decode)] +pub struct RawElementTemplate { + pub(crate) operations: Vec, + pub(crate) tag_names: FnvHashSet, +} + +#[cfg_attr(feature = "encode", wasm_bindgen)] +impl RawElementTemplate { + #[cfg(feature = "encode")] + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + RawElementTemplate::default() + } + + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn append_to_root(&mut self, element_id: i32) { + self.operations.push(Operation { + opcode: LEOAsmOpcode::AppendToRoot, // APPEND_TO_ROOT + operands_num: vec![element_id], + operands_str: vec![], + }); + } + + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn create_element(&mut self, tag_names: String, element_id: i32) { + self.tag_names.insert(tag_names.clone()); + self.operations.push(Operation { + opcode: LEOAsmOpcode::CreateElement, // CREATE_ELEMENT + operands_num: vec![element_id], + operands_str: vec![tag_names], + }); + } + + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn set_attribute(&mut self, element_id: i32, attr_name: String, attr_value: String) { + self.operations.push(Operation { + opcode: LEOAsmOpcode::SetAttribute, // SET_ATTRIBUTE + operands_num: vec![element_id], + operands_str: vec![attr_name, attr_value], + }); + } + + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn set_dataset(&mut self, element_id: i32, data_name: String, data_value: String) { + self.operations.push(Operation { + opcode: LEOAsmOpcode::SetDataset, // SET_DATASET + operands_num: vec![element_id], + operands_str: vec![data_name, data_value], + }); + } + + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn append_child(&mut self, parent_element_id: i32, child_element_id: i32) { + self.operations.push(Operation { + opcode: LEOAsmOpcode::AppendChild, // APPEND_CHILD + operands_num: vec![parent_element_id, child_element_id], + operands_str: vec![], + }); + } + + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn set_cross_thread_event( + &mut self, + element_id: i32, + event_type: String, + event_name: String, + event_value: String, + ) { + self.operations.push(Operation { + opcode: LEOAsmOpcode::AddEvent, // SET_CROSS_THREAD_EVENT + operands_num: vec![element_id], + operands_str: vec![event_type, event_name, event_value], + }); + } + + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn set_attribute_slot(&mut self, element_id: i32, attribute_slot_id: i32, attr_name: String) { + self.operations.push(Operation { + opcode: LEOAsmOpcode::SetAttributeSlot, + operands_num: vec![element_id, attribute_slot_id], + operands_str: vec![attr_name], + }); + } + + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn append_element_slot(&mut self, parent_element_id: i32, child_element_slot_id: i32) { + self.operations.push(Operation { + opcode: LEOAsmOpcode::AppendElementSlot, + operands_num: vec![parent_element_id, child_element_slot_id], + operands_str: vec![], + }); + } +} diff --git a/packages/web-platform/web-core-wasm/src/template/template_sections/mod.rs b/packages/web-platform/web-core-wasm/src/template/template_sections/mod.rs new file mode 100644 index 0000000000..2160766073 --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/template/template_sections/mod.rs @@ -0,0 +1,8 @@ +/* + * Copyright 2025 The Lynx Authors. All rights reserved. + * Licensed under the Apache License Version 2.0 that can be found in the + * LICENSE file in the root directory of this source tree. + */ + +pub(crate) mod element_template; +pub(crate) mod style_info; diff --git a/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/decoded_style_info.rs b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/decoded_style_info.rs new file mode 100644 index 0000000000..a5fdb8169f --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/decoded_style_info.rs @@ -0,0 +1,843 @@ +/* + * Copyright 2025 The Lynx Authors. All rights reserved. + * Licensed under the Apache License Version 2.0 that can be found in the + * LICENSE file in the root directory of this source tree. +*/ + +use super::flattened_style_info::FlattenedStyleInfo; +use super::raw_style_info::RuleType; +use crate::css_tokenizer::tokenize::Parser; +use crate::style_transformer::{Generator, ParsedDeclaration, StyleTransformer}; +use crate::template::template_sections::style_info::raw_style_info::{ + DeclarationBlock, OneSimpleSelector, OneSimpleSelectorType, +}; +use crate::template::template_sections::style_info::RawStyleInfo; +use fnv::FnvHashMap; +#[cfg(feature = "encode")] +use wasm_bindgen::prelude::*; + +#[cfg_attr(feature = "encode", wasm_bindgen)] // for testing purpose +pub(crate) struct StyleInfoDecoder { + pub(super) style_content: String, + // the font face should be placed at the head of the css content, therefore we use a separate buffer + pub(super) font_face_content: String, + // if we are processing font_face, the declaration should be pushed to font_face_content for generating + pub(super) css_og_css_id_to_class_selector_name_to_declarations_map: + Option, + is_processing_font_face: bool, + temp_child_rules_buffer: String, + config_enable_css_selector: bool, + entry_name: Option, + css_og_current_processing_css_id: Option, + css_og_current_processing_class_selector_names: Option>, +} + +impl StyleInfoDecoder { + pub(crate) fn new( + raw_style_info: RawStyleInfo, + entry_name: Option, + config_enable_css_selector: bool, + ) -> Result { + let flattened_style_info: FlattenedStyleInfo = raw_style_info.into(); + let mut decoded_style_info = StyleInfoDecoder { + style_content: String::with_capacity(flattened_style_info.style_content_str_size_hint + 64), + font_face_content: String::with_capacity(256), + temp_child_rules_buffer: String::new(), + css_og_css_id_to_class_selector_name_to_declarations_map: if !config_enable_css_selector { + Some(FnvHashMap::default()) + } else { + None + }, + entry_name, + config_enable_css_selector, + is_processing_font_face: false, + css_og_current_processing_css_id: None, + css_og_current_processing_class_selector_names: None, + }; + decoded_style_info.decode(flattened_style_info)?; + Ok(decoded_style_info) + } + + fn decode(&mut self, flattened_style_info: FlattenedStyleInfo) -> Result<(), JsError> { + for (css_id, style_sheet) in flattened_style_info.css_id_to_style_sheet.into_iter() { + for mut style_rule in style_sheet.rules.into_iter() { + match style_rule.rule_type { + RuleType::Declaration => { + if !self.config_enable_css_selector { + self.css_og_current_processing_css_id = Some(css_id); + self.css_og_current_processing_class_selector_names = Some(Vec::new()); + } + let mut new_selectors_to_add = Vec::new(); // selectors will be added for removeCSSScope false + // handle selectors + for (selector_index, selector) in + style_rule.prelude.selector_list.iter_mut().enumerate() + { + /* + 1. for :root selector section, we should transform it to [lynx-tag="page"] and move it to the start of the current compound selector + 2. for ::placeholder selector section, we should transform it to ::part(placeholder)::placeholder + 3. for type selector section, we should transform it to [lynx-tag="type"] + 4 if enableCSSSelector is false: + 4.1 if the current selector has only one class selector, we extract the class selector name and use it to map to the declarations in css_og_css_id_to_class_selector_name_to_declarations_map + the declarations should be transformed by calling transform_one_declaration function. + the current selector should be skipped in following phases. + 5 if the self.entryName is Some, we should add a [{constants::LYNX_CSS_ENTRY_NAME_ATTRIBUTE}="{entry_name}"] to the last compound selector just before the first pseudo class or pseudo element + otherwise, we should add a :not({constants::LYNX_CSS_ENTRY_NAME_ATTRIBUTE}) just before the first pseudo class or pseudo element in the current compound selector + 6 if imported_by_css_id != 0, we should add a :where([{constants::LYNX_CSS_ID_ATTRIBUTE}="{imported_by_css_id}"]) to the last compound selector just before the first pseudo class or pseudo element + */ + // process rule 4 + if !self.config_enable_css_selector + && selector.simple_selectors.len() == 1 + && selector.simple_selectors[0].selector_type + == OneSimpleSelectorType::ClassSelector + { + if let Some(names) = self.css_og_current_processing_class_selector_names.as_mut() { + names.push(selector.simple_selectors[0].value.clone()); + } + continue; + } + let mut the_index_of_last_compound_selector = 0; + let mut simple_selector_index = 0; + while simple_selector_index < selector.simple_selectors.len() { + let simple_selector = &mut selector.simple_selectors[simple_selector_index]; + if simple_selector.selector_type == OneSimpleSelectorType::PseudoClassSelector + && simple_selector.value == "root" + { + // transform :root to [part="page"] + simple_selector.selector_type = OneSimpleSelectorType::AttributeSelector; + simple_selector.value = "part=\"page\"".to_string(); + // find the position to insert + let mut compound_selector_start_index = simple_selector_index; + while compound_selector_start_index > 0 { + let prev_simple_selector = + &selector.simple_selectors[compound_selector_start_index - 1]; + if prev_simple_selector.selector_type == OneSimpleSelectorType::Combinator { + break; + } + compound_selector_start_index -= 1; + } + // move the current simple selector to the compound selector start index + let root_simple_selector = + selector.simple_selectors.remove(simple_selector_index); + selector + .simple_selectors + .insert(compound_selector_start_index, root_simple_selector); + } else if simple_selector.selector_type + == OneSimpleSelectorType::PseudoElementSelector + && simple_selector.value == "placeholder" + { + // transform ::placeholder to ::part(placeholder)::placeholder + selector.simple_selectors.insert( + simple_selector_index, + OneSimpleSelector { + selector_type: OneSimpleSelectorType::PseudoElementSelector, + value: "part(placeholder)".to_string(), + }, + ); + simple_selector_index += 1; // skip the newly inserted simple selector + } else if simple_selector.selector_type == OneSimpleSelectorType::TypeSelector { + // transform type selector + let simple_selector = &mut selector.simple_selectors[simple_selector_index]; + if let Some(mapped_tag) = + crate::constants::LYNX_TAG_TO_HTML_TAG_MAP.get(simple_selector.value.as_str()) + { + simple_selector.value = mapped_tag.to_string(); + } + } + if matches!( + selector.simple_selectors[simple_selector_index].selector_type, + OneSimpleSelectorType::ClassSelector + | OneSimpleSelectorType::IdSelector + | OneSimpleSelectorType::AttributeSelector + | OneSimpleSelectorType::TypeSelector + | OneSimpleSelectorType::UniversalSelector + | OneSimpleSelectorType::PseudoClassSelector + ) { + the_index_of_last_compound_selector = simple_selector_index + 1; + } + simple_selector_index += 1; + } + // rule 5 + if let Some(entry_name) = &self.entry_name { + selector.simple_selectors.insert( + the_index_of_last_compound_selector, + OneSimpleSelector { + selector_type: OneSimpleSelectorType::AttributeSelector, + value: format!( + "{}=\"{}\"", + crate::constants::LYNX_ENTRY_NAME_ATTRIBUTE, + entry_name + ), + }, + ); + } else { + selector.simple_selectors.insert( + the_index_of_last_compound_selector, + OneSimpleSelector { + selector_type: OneSimpleSelectorType::PseudoClassSelector, + value: format!("not([{}])", crate::constants::LYNX_ENTRY_NAME_ATTRIBUTE), + }, + ); + } + + // for rule 6, we should copy selectors and add the css id attribute selector + for (imported_by_index, imported_by_css_id) in + style_sheet.imported_by.iter().enumerate() + { + // add a comma separator if not the last selector + if selector_index != 0 || imported_by_index != 0 { + self.style_content.push(','); + } + + if *imported_by_css_id == 0 { + selector.generate_to_string_buf(&mut self.style_content); + } else { + let mut new_selector = selector.clone(); + // for the first imported_by_css_id, we can reuse the current selector + new_selector.simple_selectors.insert( + the_index_of_last_compound_selector, + OneSimpleSelector { + selector_type: OneSimpleSelectorType::PseudoClassSelector, + value: format!( + "where([{}=\"{}\"])", + crate::constants::CSS_ID_ATTRIBUTE, + imported_by_css_id + ), + }, + ); + new_selector.generate_to_string_buf(&mut self.style_content); + new_selectors_to_add.push(new_selector); + }; + } + } + style_rule + .prelude + .selector_list + .extend(new_selectors_to_add); + self.temp_child_rules_buffer.clear(); + self.generate_one_declaration_block(style_rule.declaration_block); + if !self.temp_child_rules_buffer.is_empty() { + // regenerate the child rules by adding > * for all selectors + for (selector_index, selector) in style_rule.prelude.selector_list.iter().enumerate() + { + selector.generate_to_string_buf(&mut self.style_content); + self.style_content.push_str(" > *"); + // add a comma separator if not the last selector + if selector_index < style_rule.prelude.selector_list.len() - 1 { + self.style_content.push(','); + } + } + self.style_content.push('{'); + self.style_content.push_str(&self.temp_child_rules_buffer); + self.style_content.push('}'); + } + } + RuleType::FontFace => { + self.font_face_content.push_str("@font-face"); + self.is_processing_font_face = true; + self.generate_one_declaration_block(style_rule.declaration_block); + self.is_processing_font_face = false; + } + RuleType::KeyFrames => { + self.style_content.push_str("@keyframes "); + if style_rule.prelude.selector_list.len() != 1 { + return Err(JsError::new( + "KeyFrames rule must have exactly one selector", + )); + } + let keyframes_name = &style_rule.prelude.selector_list[0]; + keyframes_name.generate_to_string_buf(&mut self.style_content); + self.style_content.push('{'); + for nested_rule in style_rule.nested_rules.into_iter() { + for (selector_index, selector) in nested_rule.prelude.selector_list.iter().enumerate() + { + // add a comma separator if not the last selector + if selector_index > 0 { + self.style_content.push(','); + } + selector.generate_to_string_buf(&mut self.style_content); + } + if nested_rule.rule_type == RuleType::Declaration { + self.generate_one_declaration_block(nested_rule.declaration_block); + } + } + + self.style_content.push('}'); + } + } + } + } + Ok(()) + } + fn generate_one_declaration_block(&mut self, declaration_block: DeclarationBlock) { + (if self.is_processing_font_face { + &mut self.font_face_content + } else { + &mut self.style_content + }) + .push('{'); + let mut transformer = StyleTransformer::new(self); + + for token in declaration_block.tokens.into_iter() { + transformer.on_token(token.token_type, token.value.as_str()); + } + (if self.is_processing_font_face { + &mut self.font_face_content + } else { + &mut self.style_content + }) + .push('}'); + } +} + +impl Generator for StyleInfoDecoder { + fn push_transform_kids_style(&mut self, declaration: ParsedDeclaration) { + declaration.generate_to_string_buf(&mut self.temp_child_rules_buffer); + if !self.config_enable_css_selector { + if let (Some(map), Some(css_id), Some(names)) = ( + self + .css_og_css_id_to_class_selector_name_to_declarations_map + .as_mut(), + self.css_og_current_processing_css_id, + self.css_og_current_processing_class_selector_names.as_ref(), + ) { + let class_selector_map = map.entry(css_id).or_default(); + for class_selector_name in names.iter() { + let string_buf = class_selector_map + .entry(class_selector_name.clone()) + .or_default(); + declaration.generate_to_string_buf(string_buf); + } + } + } + } + fn push_transformed_style(&mut self, declaration: ParsedDeclaration) { + if !self.config_enable_css_selector && !self.is_processing_font_face { + if let (Some(map), Some(css_id), Some(names)) = ( + self + .css_og_css_id_to_class_selector_name_to_declarations_map + .as_mut(), + self.css_og_current_processing_css_id, + self.css_og_current_processing_class_selector_names.as_ref(), + ) { + let class_selector_map = map.entry(css_id).or_default(); + for class_selector_name in names.iter() { + let string_buf = class_selector_map + .entry(class_selector_name.clone()) + .or_default(); + declaration.generate_to_string_buf(string_buf); + } + } + } + declaration.generate_to_string_buf(if self.is_processing_font_face { + &mut self.font_face_content + } else { + &mut self.style_content + }); + } +} + +#[cfg(test)] +mod test { + use fnv::FnvHashMap; + + use crate::template::template_sections::style_info::{ + raw_style_info::StyleSheet, Rule, RulePrelude, Selector, ValueToken, + }; + + use super::super::{ + DeclarationBlock, OneSimpleSelector, OneSimpleSelectorType, RawStyleInfo, RuleType, + StyleInfoDecoder, + }; + + fn generate_string_buf( + raw_style_info: RawStyleInfo, + config_enable_css_selector: bool, + entry_name: Option, + ) -> StyleInfoDecoder { + StyleInfoDecoder::new(raw_style_info, entry_name, config_enable_css_selector).unwrap() + } + + #[test] + fn test_generate_string_buf() { + let raw_style_info = RawStyleInfo::new(); + let result = generate_string_buf(raw_style_info, true, None); + assert_eq!(result.style_content, ""); + } + + #[test] + fn test_one_font_face() { + let raw_style_info = RawStyleInfo { + css_id_to_style_sheet: FnvHashMap::from_iter(vec![( + 0, + StyleSheet { + imports: vec![], + rules: vec![Rule { + nested_rules: vec![], + rule_type: RuleType::FontFace, + prelude: RulePrelude { + selector_list: vec![], + }, + declaration_block: DeclarationBlock { + tokens: vec![ + ValueToken { + token_type: crate::css_tokenizer::token_types::IDENT_TOKEN, + value: "font-family".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::COLON_TOKEN, + value: ":".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::IDENT_TOKEN, + value: "MyFont".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::SEMICOLON_TOKEN, + value: ";".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::IDENT_TOKEN, + value: "src".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::COLON_TOKEN, + value: ":".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::IDENT_TOKEN, + value: "url('myfont.woff2')".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::IDENT_TOKEN, + value: "format('woff2')".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::SEMICOLON_TOKEN, + value: ";".to_string(), + }, + ], + }, + }], + }, + )]), + style_content_str_size_hint: 0, + }; + let result = generate_string_buf(raw_style_info, true, None); + let expected = "@font-face{font-family:MyFont;src:url('myfont.woff2')format('woff2');}"; + assert_eq!(result.font_face_content, expected); + } + + #[test] + fn test_rpx_declaration() { + let raw_style_info = RawStyleInfo { + css_id_to_style_sheet: FnvHashMap::from_iter(vec![( + 0, + StyleSheet { + imports: vec![], + rules: vec![Rule { + nested_rules: vec![], + rule_type: RuleType::Declaration, + prelude: RulePrelude { + selector_list: vec![Selector { + simple_selectors: vec![OneSimpleSelector { + selector_type: OneSimpleSelectorType::ClassSelector, + value: "test-class".to_string(), + }], + }], + }, + declaration_block: DeclarationBlock { + tokens: vec![ + ValueToken { + token_type: crate::css_tokenizer::token_types::IDENT_TOKEN, + value: "width".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::COLON_TOKEN, + value: ":".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::DIMENSION_TOKEN, + value: "100rpx".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::SEMICOLON_TOKEN, + value: ";".to_string(), + }, + ], + }, + }], + }, + )]), + style_content_str_size_hint: 0, + }; + let result = generate_string_buf(raw_style_info, true, None); + let expected = ".test-class:not([l-e-name]){width:calc(100 * var(--rpx-unit));}"; + assert_eq!(result.style_content, expected); + } + + #[test] + fn test_css_og_one_class() { + let raw_style_info = RawStyleInfo { + css_id_to_style_sheet: FnvHashMap::from_iter(vec![( + 0, + StyleSheet { + imports: vec![], + rules: vec![Rule { + nested_rules: vec![], + rule_type: RuleType::Declaration, + prelude: RulePrelude { + selector_list: vec![Selector { + simple_selectors: vec![OneSimpleSelector { + selector_type: OneSimpleSelectorType::ClassSelector, + value: "test-class".to_string(), + }], + }], + }, + declaration_block: DeclarationBlock { + tokens: vec![ + ValueToken { + token_type: crate::css_tokenizer::token_types::IDENT_TOKEN, + value: "width".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::COLON_TOKEN, + value: ":".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::DIMENSION_TOKEN, + value: "100px".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::SEMICOLON_TOKEN, + value: ";".to_string(), + }, + ], + }, + }], + }, + )]), + style_content_str_size_hint: 0, + }; + let result = generate_string_buf(raw_style_info, false, None); + let expected = "{width:100px;}"; + assert_eq!(result.style_content, expected); + } + + #[test] + fn test_css_og_one_class_in_lazy_bundle() { + let raw_style_info = RawStyleInfo { + css_id_to_style_sheet: FnvHashMap::from_iter(vec![( + 0, + StyleSheet { + imports: vec![], + rules: vec![Rule { + nested_rules: vec![], + rule_type: RuleType::Declaration, + prelude: RulePrelude { + selector_list: vec![Selector { + simple_selectors: vec![OneSimpleSelector { + selector_type: OneSimpleSelectorType::ClassSelector, + value: "test-class".to_string(), + }], + }], + }, + declaration_block: DeclarationBlock { + tokens: vec![ + ValueToken { + token_type: crate::css_tokenizer::token_types::IDENT_TOKEN, + value: "width".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::COLON_TOKEN, + value: ":".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::DIMENSION_TOKEN, + value: "100px".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::SEMICOLON_TOKEN, + value: ";".to_string(), + }, + ], + }, + }], + }, + )]), + style_content_str_size_hint: 0, + }; + let result = generate_string_buf(raw_style_info, false, Some("lazy-bundle".to_string())); + let expected = "{width:100px;}"; + assert_eq!(result.style_content, expected); + } + + #[test] + fn push_import_only() { + let raw_style_info = RawStyleInfo { + css_id_to_style_sheet: FnvHashMap::from_iter(vec![ + ( + 1, + StyleSheet { + imports: vec![2], + rules: vec![], + }, + ), + ( + 2, + StyleSheet { + imports: vec![], + rules: vec![], + }, + ), + ]), + style_content_str_size_hint: 0, + }; + let result = generate_string_buf(raw_style_info, true, None); + assert_eq!(result.style_content, ""); + } + #[test] + fn test_keyframes_at_rule() { + let raw_style_info = RawStyleInfo { + css_id_to_style_sheet: FnvHashMap::from_iter(vec![( + 0, + StyleSheet { + imports: vec![], + rules: vec![Rule { + rule_type: RuleType::KeyFrames, + prelude: RulePrelude { + selector_list: vec![Selector { + simple_selectors: vec![OneSimpleSelector { + selector_type: OneSimpleSelectorType::UnknownText, + value: "animation-name".to_string(), + }], + }], + }, + nested_rules: vec![Rule { + rule_type: RuleType::Declaration, + prelude: RulePrelude { + selector_list: vec![Selector { + simple_selectors: vec![OneSimpleSelector { + selector_type: OneSimpleSelectorType::UnknownText, + value: "0%".to_string(), + }], + }], + }, + nested_rules: vec![], + declaration_block: DeclarationBlock { + tokens: vec![ + ValueToken { + token_type: crate::css_tokenizer::token_types::IDENT_TOKEN, + value: "width".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::COLON_TOKEN, + value: ":".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::DIMENSION_TOKEN, + value: "100rpx".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::SEMICOLON_TOKEN, + value: ";".to_string(), + }, + ], + }, + }], + declaration_block: DeclarationBlock { tokens: vec![] }, + }], + }, + )]), + style_content_str_size_hint: 0, + }; + let result = generate_string_buf(raw_style_info, true, None); + let expected = "@keyframes animation-name{0%{width:calc(100 * var(--rpx-unit));}}"; + assert_eq!(result.style_content, expected); + } + + #[test] + fn test_type_selector() { + let raw_style_info = RawStyleInfo { + css_id_to_style_sheet: FnvHashMap::from_iter(vec![( + 0, + StyleSheet { + imports: vec![], + rules: vec![ + Rule { + nested_rules: vec![], + rule_type: RuleType::Declaration, + prelude: RulePrelude { + selector_list: vec![Selector { + simple_selectors: vec![OneSimpleSelector { + selector_type: OneSimpleSelectorType::TypeSelector, + value: "view".to_string(), + }], + }], + }, + declaration_block: DeclarationBlock { + tokens: vec![ + ValueToken { + token_type: crate::css_tokenizer::token_types::IDENT_TOKEN, + value: "width".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::COLON_TOKEN, + value: ":".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::DIMENSION_TOKEN, + value: "100px".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::SEMICOLON_TOKEN, + value: ";".to_string(), + }, + ], + }, + }, + Rule { + nested_rules: vec![], + rule_type: RuleType::Declaration, + prelude: RulePrelude { + selector_list: vec![Selector { + simple_selectors: vec![OneSimpleSelector { + selector_type: OneSimpleSelectorType::TypeSelector, + value: "unknown-tag".to_string(), + }], + }], + }, + declaration_block: DeclarationBlock { + tokens: vec![ + ValueToken { + token_type: crate::css_tokenizer::token_types::IDENT_TOKEN, + value: "height".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::COLON_TOKEN, + value: ":".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::DIMENSION_TOKEN, + value: "100px".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::SEMICOLON_TOKEN, + value: ";".to_string(), + }, + ], + }, + }, + ], + }, + )]), + style_content_str_size_hint: 0, + }; + let result = generate_string_buf(raw_style_info, true, None); + let expected = "x-view:not([l-e-name]){width:100px;}unknown-tag:not([l-e-name]){height:100px;}"; + assert_eq!(result.style_content, expected); + } + + #[test] + fn test_multiple_selectors() { + let raw_style_info = RawStyleInfo { + css_id_to_style_sheet: FnvHashMap::from_iter(vec![( + 0, + StyleSheet { + imports: vec![], + rules: vec![Rule { + nested_rules: vec![], + rule_type: RuleType::Declaration, + prelude: RulePrelude { + selector_list: vec![ + Selector { + simple_selectors: vec![OneSimpleSelector { + selector_type: OneSimpleSelectorType::ClassSelector, + value: "foo".to_string(), + }], + }, + Selector { + simple_selectors: vec![OneSimpleSelector { + selector_type: OneSimpleSelectorType::ClassSelector, + value: "bar".to_string(), + }], + }, + ], + }, + declaration_block: DeclarationBlock { + tokens: vec![ + ValueToken { + token_type: crate::css_tokenizer::token_types::IDENT_TOKEN, + value: "height".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::COLON_TOKEN, + value: ":".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::DIMENSION_TOKEN, + value: "100px".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::SEMICOLON_TOKEN, + value: ";".to_string(), + }, + ], + }, + }], + }, + )]), + style_content_str_size_hint: 0, + }; + let result = generate_string_buf(raw_style_info, true, None); + let expected = ".foo:not([l-e-name]),.bar:not([l-e-name]){height:100px;}"; + assert_eq!(result.style_content, expected); + } + + #[test] + fn test_pseudo_class_selector_with_args() { + let raw_style_info = RawStyleInfo { + css_id_to_style_sheet: FnvHashMap::from_iter(vec![( + 0, + StyleSheet { + imports: vec![], + rules: vec![Rule { + nested_rules: vec![], + rule_type: RuleType::Declaration, + prelude: RulePrelude { + selector_list: vec![Selector { + simple_selectors: vec![OneSimpleSelector { + selector_type: OneSimpleSelectorType::PseudoClassSelector, + value: "not([hidden])".to_string(), + }], + }], + }, + declaration_block: DeclarationBlock { + tokens: vec![ + ValueToken { + token_type: crate::css_tokenizer::token_types::IDENT_TOKEN, + value: "width".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::COLON_TOKEN, + value: ":".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::DIMENSION_TOKEN, + value: "100px".to_string(), + }, + ValueToken { + token_type: crate::css_tokenizer::token_types::SEMICOLON_TOKEN, + value: ";".to_string(), + }, + ], + }, + }], + }, + )]), + style_content_str_size_hint: 0, + }; + let result = generate_string_buf(raw_style_info, true, None); + let expected = ":not([hidden]):not([l-e-name]){width:100px;}"; + assert_eq!(result.style_content, expected); + } +} diff --git a/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/flattened_style_info.rs b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/flattened_style_info.rs new file mode 100644 index 0000000000..35009ab177 --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/flattened_style_info.rs @@ -0,0 +1,332 @@ +/** + * Copyright 2025 The Lynx Authors. All rights reserved. + * Licensed under the Apache License Version 2.0 that can be found in the + * LICENSE file in the root directory of this source tree. + */ +use super::raw_style_info::{RawStyleInfo, Rule}; +use fnv::{FnvHashMap, FnvHashSet}; + +#[derive(Default)] +pub(super) struct FlattenedStyleSheet { + pub(super) imported_by: Vec, + pub(super) rules: Vec, +} + +#[derive(Default)] +pub(super) struct FlattenedStyleInfo { + pub(super) css_id_to_style_sheet: FnvHashMap, + pub(super) style_content_str_size_hint: usize, +} + +impl From for FlattenedStyleInfo { + fn from(mut style_info: RawStyleInfo) -> Self { + // Step 1. Topological sorting + /* + * kahn's algorithm + * 1. The RawStyleInfo is already equivalent to a adjacency list. (cssId, import) + * 2. The RawStyleInfo is a DAG therefore we don't need to do cyclic detection + */ + let mut in_degree_map: FnvHashMap = FnvHashMap::default(); + let mut sorted_css_ids: Vec = Vec::with_capacity(style_info.css_id_to_style_sheet.len()); + let mut imported_by_map: FnvHashMap> = FnvHashMap::default(); + let mut flattened_style_info = FlattenedStyleInfo::default(); + + for (css_id, style_sheet) in style_info.css_id_to_style_sheet.iter() { + in_degree_map.entry(*css_id).or_insert(0); + for imported_css_id in style_sheet.imports.iter() { + let in_degree = in_degree_map.entry(*imported_css_id).or_insert(0); + *in_degree += 1; + } + } + + // Initialize the queue with nodes having in-in_degree of 0 + for (css_id, in_degree) in in_degree_map.iter() { + if *in_degree == 0 { + sorted_css_ids.push(*css_id); + } + } + + let mut index = 0; + // Process the queue in place + while index < sorted_css_ids.len() { + let css_id = sorted_css_ids[index]; + index += 1; + // Decrease the in-in_degree of all imported CSS files + if let Some(style_sheet) = style_info.css_id_to_style_sheet.get(&css_id) { + for imported_css_id in style_sheet.imports.iter() { + let in_degree = in_degree_map.entry(*imported_css_id).or_insert(1); + *in_degree -= 1; + if *in_degree == 0 { + sorted_css_ids.push(*imported_css_id); + } + } + } + } + + // Step 2. generate deps; + for css_id in sorted_css_ids.iter() { + if let Some(style_sheet) = style_info.css_id_to_style_sheet.get(css_id) { + // mark it is imported by itself + imported_by_map.entry(*css_id).or_default().insert(*css_id); + let current_css_id_imported_by = imported_by_map.get(css_id).unwrap().clone(); + for importing_css_id in style_sheet.imports.iter() { + let importing_css_id_imported_by = imported_by_map.entry(*importing_css_id).or_default(); + importing_css_id_imported_by.extend(current_css_id_imported_by.iter().cloned()); + } + } + } + + // Step 3. generate flattened style info + for css_id in sorted_css_ids.iter() { + if let Some(style_sheet) = style_info.css_id_to_style_sheet.remove(css_id) { + let imported_by_set = imported_by_map.get(css_id).unwrap(); + let imported_by: Vec = imported_by_set.iter().cloned().collect(); + let flattened_style_sheet = FlattenedStyleSheet { + imported_by, + rules: style_sheet.rules, + }; + flattened_style_info + .css_id_to_style_sheet + .insert(*css_id, flattened_style_sheet); + } + } + flattened_style_info.style_content_str_size_hint = style_info.style_content_str_size_hint; + + flattened_style_info + } +} + +#[cfg(test)] +mod tests { + use super::super::raw_style_info::StyleSheet; + use super::{FlattenedStyleInfo, RawStyleInfo}; + use fnv::FnvHashSet; + + #[test] + fn test_flatten_style_info() { + let mut style_info: RawStyleInfo = RawStyleInfo::new(); + style_info.css_id_to_style_sheet.insert( + 1, + StyleSheet { + rules: vec![], + imports: vec![2], + }, + ); + style_info.css_id_to_style_sheet.insert( + 2, + StyleSheet { + rules: vec![], + imports: vec![3], + }, + ); + style_info.css_id_to_style_sheet.insert( + 3, + StyleSheet { + rules: vec![], + imports: vec![], + }, + ); + + let flattened_info: FlattenedStyleInfo = style_info.into(); + + // Since the output is a Vec, we need to find the items. + // The order is not guaranteed, so we'll check for existence and properties. + assert_eq!(flattened_info.css_id_to_style_sheet.len(), 3); + + let _sheet1 = flattened_info + .css_id_to_style_sheet + .iter() + .find(|(_, s)| s.imported_by.contains(&1) && s.imported_by.len() == 1) + .unwrap(); + let _sheet2 = flattened_info + .css_id_to_style_sheet + .iter() + .find(|(_, s)| s.imported_by.contains(&2) && s.imported_by.len() == 2) + .unwrap(); + let _sheet3 = flattened_info + .css_id_to_style_sheet + .iter() + .find(|(_, s)| s.imported_by.contains(&3) && s.imported_by.len() == 3) + .unwrap(); + + let imported_by_1: FnvHashSet = flattened_info + .css_id_to_style_sheet + .get(&1) + .unwrap() + .imported_by + .iter() + .cloned() + .collect(); + let imported_by_2: FnvHashSet = flattened_info + .css_id_to_style_sheet + .get(&2) + .unwrap() + .imported_by + .iter() + .cloned() + .collect(); + let imported_by_3: FnvHashSet = flattened_info + .css_id_to_style_sheet + .get(&3) + .unwrap() + .imported_by + .iter() + .cloned() + .collect(); + + let expected_imported_by_1: FnvHashSet = [1].iter().cloned().collect(); + let expected_imported_by_2: FnvHashSet = [1, 2].iter().cloned().collect(); + let expected_imported_by_3: FnvHashSet = [1, 2, 3].iter().cloned().collect(); + + assert_eq!(imported_by_1, expected_imported_by_1); + assert_eq!(imported_by_2, expected_imported_by_2); + assert_eq!(imported_by_3, expected_imported_by_3); + } + + #[test] + fn test_flatten_style_info_empty() { + let style_info: RawStyleInfo = RawStyleInfo::new(); + let flattened_info: FlattenedStyleInfo = style_info.into(); + assert!(flattened_info.css_id_to_style_sheet.is_empty()); + } + + #[test] + fn test_flatten_style_info_diamond() { + let mut style_info: RawStyleInfo = RawStyleInfo::new(); + // 1 -> 2, 1 -> 3 + style_info.css_id_to_style_sheet.insert( + 1, + StyleSheet { + rules: vec![], + imports: vec![2, 3], + }, + ); + // 2 -> 4 + style_info.css_id_to_style_sheet.insert( + 2, + StyleSheet { + rules: vec![], + imports: vec![4], + }, + ); + // 3 -> 4 + style_info.css_id_to_style_sheet.insert( + 3, + StyleSheet { + rules: vec![], + imports: vec![4], + }, + ); + // 4 -> [] + style_info.css_id_to_style_sheet.insert( + 4, + StyleSheet { + rules: vec![], + imports: vec![], + }, + ); + + let flattened_info: FlattenedStyleInfo = style_info.into(); + + let imported_by_4: FnvHashSet = flattened_info + .css_id_to_style_sheet + .get(&4) + .unwrap() + .imported_by + .iter() + .cloned() + .collect(); + let expected_imported_by_4: FnvHashSet = [1, 2, 3, 4].iter().cloned().collect(); + assert_eq!(imported_by_4, expected_imported_by_4); + } + + #[test] + fn test_flatten_style_info_disjoint() { + let mut style_info: RawStyleInfo = RawStyleInfo::new(); + // 1 -> 2 + style_info.css_id_to_style_sheet.insert( + 1, + StyleSheet { + rules: vec![], + imports: vec![2], + }, + ); + style_info.css_id_to_style_sheet.insert( + 2, + StyleSheet { + rules: vec![], + imports: vec![], + }, + ); + // 3 -> 4 + style_info.css_id_to_style_sheet.insert( + 3, + StyleSheet { + rules: vec![], + imports: vec![4], + }, + ); + style_info.css_id_to_style_sheet.insert( + 4, + StyleSheet { + rules: vec![], + imports: vec![], + }, + ); + + let flattened_info: FlattenedStyleInfo = style_info.into(); + + let imported_by_2: FnvHashSet = flattened_info + .css_id_to_style_sheet + .get(&2) + .unwrap() + .imported_by + .iter() + .cloned() + .collect(); + let expected_imported_by_2: FnvHashSet = [1, 2].iter().cloned().collect(); + assert_eq!(imported_by_2, expected_imported_by_2); + + let imported_by_4: FnvHashSet = flattened_info + .css_id_to_style_sheet + .get(&4) + .unwrap() + .imported_by + .iter() + .cloned() + .collect(); + let expected_imported_by_4: FnvHashSet = [3, 4].iter().cloned().collect(); + assert_eq!(imported_by_4, expected_imported_by_4); + } + + #[test] + fn test_flatten_style_info_cycle() { + let mut style_info: RawStyleInfo = RawStyleInfo::new(); + // 1 -> 2 + style_info.css_id_to_style_sheet.insert( + 1, + StyleSheet { + rules: vec![], + imports: vec![2], + }, + ); + // 2 -> 1 (cycle) + style_info.css_id_to_style_sheet.insert( + 2, + StyleSheet { + rules: vec![], + imports: vec![1], + }, + ); + + let flattened_info: FlattenedStyleInfo = style_info.into(); + + // In a cycle, topological sort might fail or produce partial results depending on implementation. + // Kahn's algorithm will output nodes with 0 in-degree. + // Here both 1 and 2 have in-degree 1. + // So sorted_css_ids will be empty. + // And flattened_info will be empty. + + assert!(flattened_info.css_id_to_style_sheet.is_empty()); + } +} diff --git a/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/mod.rs b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/mod.rs new file mode 100644 index 0000000000..319b92f99d --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/mod.rs @@ -0,0 +1,103 @@ +/* + * Copyright 2025 The Lynx Authors. All rights reserved. + * Licensed under the Apache License Version 2.0 that can be found in the + * LICENSE file in the root directory of this source tree. + */ + +mod decoded_style_info; +mod flattened_style_info; +mod raw_style_info; +use bincode::{Decode, Encode}; +use decoded_style_info::StyleInfoDecoder; +use fnv::FnvHashMap; +use raw_style_info::RawStyleInfo; +use wasm_bindgen::prelude::*; + +type CssOgClassSelectorNameToDeclarationsMap = FnvHashMap; +type CssOgCssIdToClassSelectorNameToDeclarationsMap = + FnvHashMap; + +#[cfg(test)] +use raw_style_info::*; + +#[derive(Encode, Decode)] +#[wasm_bindgen] +pub struct DecodedStyleData { + pub(crate) style_content: Option, + // the font face should be placed at the head of the css content, therefore we use a separate buffer + pub(crate) font_face_content: Option, + // if we are processing font_face, the declaration should be pushed to font_face_content for generating + css_og_css_id_to_class_selector_name_to_declarations_map: + Option, +} + +impl From for DecodedStyleData { + fn from(decoder: StyleInfoDecoder) -> Self { + DecodedStyleData { + style_content: Some(decoder.style_content), + font_face_content: Some(decoder.font_face_content), + css_og_css_id_to_class_selector_name_to_declarations_map: decoder + .css_og_css_id_to_class_selector_name_to_declarations_map, + } + } +} + +#[wasm_bindgen] +impl DecodedStyleData { + #[wasm_bindgen(constructor)] + pub fn new(buffer: js_sys::Uint8Array) -> Result { + let (data, _) = bincode::decode_from_slice::( + &buffer.to_vec(), + bincode::config::standard(), + ) + .map_err(|e| wasm_bindgen::JsError::new(&format!("Failed to decode from Uint8Array: {e}",)))?; + Ok(data) + } + + #[wasm_bindgen(getter)] + pub fn style_content(&mut self) -> String { + self.style_content.take().unwrap_or_default() + } + + #[wasm_bindgen(getter)] + pub fn font_face_content(&mut self) -> String { + self.font_face_content.take().unwrap_or_default() + } + + #[wasm_bindgen] + pub fn query_css_og_declarations_by_css_id( + &self, + css_id: i32, + class_name: Vec, + ) -> String { + let mut result = String::new(); + if let Some(map) = &self.css_og_css_id_to_class_selector_name_to_declarations_map { + if let Some(class_selector_map) = map.get(&css_id) { + for class_name in class_name.iter() { + if let Some(declarations) = class_selector_map.get(class_name) { + result.push_str(declarations); + } + } + } + } + result + } + + #[wasm_bindgen] + pub fn decode_into( + buffer: js_sys::Uint8Array, + entry_name: Option, + config_enable_css_selector: bool, + ) -> Result { + let (data, _) = + bincode::decode_from_slice::(&buffer.to_vec(), bincode::config::standard()) + .map_err(|e| { + wasm_bindgen::JsError::new(&format!("Failed to decode from Uint8Array: {e}",)) + })?; + let decode_data: DecodedStyleData = + StyleInfoDecoder::new(data, entry_name, config_enable_css_selector)?.into(); + let data = &bincode::encode_to_vec(&decode_data, bincode::config::standard()) + .map_err(|e| wasm_bindgen::JsError::new(&format!("Failed to encode to Uint8Array: {e}",)))?; + Ok(js_sys::Uint8Array::from(data.as_slice())) + } +} diff --git a/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/raw_style_info.rs b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/raw_style_info.rs new file mode 100644 index 0000000000..15bf0b30f8 --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/raw_style_info.rs @@ -0,0 +1,373 @@ +/* + * Copyright 2025 The Lynx Authors. All rights reserved. + * Licensed under the Apache License Version 2.0 that can be found in the + * LICENSE file in the root directory of this source tree. + */ + +#[cfg(feature = "encode")] +use crate::css_tokenizer::token_types::{COLON_TOKEN, IDENT_TOKEN, SEMICOLON_TOKEN}; +#[cfg(feature = "encode")] +use crate::css_tokenizer::tokenize; +use bincode::Decode; +#[cfg(feature = "encode")] +use bincode::Encode; +use fnv::FnvHashMap; +#[cfg(feature = "encode")] +use wasm_bindgen::prelude::*; + +/** + * key: cssId + * value: StyleSheet + */ +#[derive(Decode)] +#[cfg_attr(feature = "encode", derive(Encode, Clone, Default))] +#[cfg_attr(feature = "encode", wasm_bindgen)] +pub struct RawStyleInfo { + pub(super) css_id_to_style_sheet: FnvHashMap, + pub(super) style_content_str_size_hint: usize, +} + +#[derive(Decode)] +#[cfg_attr(feature = "encode", derive(Encode, Default, Clone))] +pub(crate) struct StyleSheet { + pub(super) imports: Vec, + pub(super) rules: Vec, +} + +#[derive(Decode)] +#[cfg_attr(feature = "encode", derive(Encode, Clone))] +#[cfg_attr(feature = "encode", wasm_bindgen)] +pub struct Rule { + pub(super) rule_type: RuleType, + pub(super) prelude: RulePrelude, + pub(super) declaration_block: DeclarationBlock, + pub(super) nested_rules: Vec, +} + +#[derive(Decode, PartialEq)] +#[cfg_attr(feature = "encode", derive(Encode, Clone))] +pub(super) enum RuleType { + Declaration = 1_isize, + FontFace = 2_isize, + KeyFrames = 3_isize, +} + +#[derive(Decode, Default)] +#[cfg_attr(feature = "encode", derive(Encode, Clone))] +#[cfg_attr(feature = "encode", wasm_bindgen)] +/** + * Either SelectorList or KeyFramesPrelude + * Depending on the RuleType + * If it is SelectorList, then selectors is a list of Selector + * If it is KeyFramesPrelude, then selectors has only one selector which is Prelude text, its simple_selectors is empty + * If the parent is FontFace, then selectors is empty + */ +pub struct RulePrelude { + pub(super) selector_list: Vec, +} + +#[derive(Decode, Clone, Default)] +#[cfg_attr(feature = "encode", derive(Encode))] +#[cfg_attr(feature = "encode", wasm_bindgen)] +pub struct Selector { + pub(super) simple_selectors: Vec, +} + +#[derive(Decode, PartialEq, Clone)] +#[cfg_attr(feature = "encode", derive(Encode))] +pub(super) struct OneSimpleSelector { + pub(super) selector_type: OneSimpleSelectorType, + pub(super) value: String, +} + +#[derive(Decode, PartialEq, Clone)] +#[cfg_attr(feature = "encode", derive(Encode))] +/** + * All possible OneSimpleSelector types + */ +pub(super) enum OneSimpleSelectorType { + ClassSelector = 1_isize, + IdSelector = 2_isize, + AttributeSelector = 3_isize, + TypeSelector = 4_isize, + Combinator = 5_isize, + PseudoClassSelector = 6_isize, + PseudoElementSelector = 7_isize, + UniversalSelector = 8_isize, + UnknownText = 9_isize, +} + +#[derive(Decode)] +#[cfg_attr(feature = "encode", derive(Encode, Clone))] +pub(super) struct DeclarationBlock { + pub(super) tokens: Vec, +} + +#[derive(Decode)] +#[cfg_attr(feature = "encode", derive(Encode, Clone))] +pub(super) struct ValueToken { + pub(super) token_type: u8, + pub(super) value: String, +} + +#[cfg(feature = "encode")] +#[wasm_bindgen] +impl RawStyleInfo { + #[cfg(feature = "encode")] + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self::default() + } + + /** + * Appends an import to the stylesheet identified by `css_id`. + * If the stylesheet does not exist, it is created. + * @param css_id - The ID of the CSS file. + * @param import_css_id - The ID of the imported CSS file. + */ + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn append_import(&mut self, css_id: i32, import_css_id: i32) { + // if css_id not exist, create a new StyleSheet + let style_sheet = self.css_id_to_style_sheet.entry(css_id).or_default(); + style_sheet.imports.push(import_css_id); + } + + /** + * Pushes a rule to the stylesheet identified by `css_id`. + * If the stylesheet does not exist, it is created. + * @param css_id - The ID of the CSS file. + * @param rule - The rule to append. + */ + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn push_rule(&mut self, css_id: i32, rule: Rule) { + let style_sheet = self.css_id_to_style_sheet.entry(css_id).or_default(); + style_sheet.rules.push(rule); + } + + /** + * Encodes the RawStyleInfo into a Uint8Array using bincode serialization. + * @returns A Uint8Array containing the serialized RawStyleInfo. + */ + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn encode(&mut self) -> Result { + use crate::template::template_sections::style_info::decoded_style_info::StyleInfoDecoder; + let decoded_style_info = StyleInfoDecoder::new(self.clone(), None, true)?; + self.style_content_str_size_hint = decoded_style_info.style_content.len(); + let serialized = bincode::encode_to_vec(&*self, bincode::config::standard()) + .map_err(|e| JsError::new(&format!("Failed to encode RawStyleInfo: {e:?}")))?; + Ok(js_sys::Uint8Array::from(serialized.as_slice())) + } +} + +#[cfg_attr(feature = "encode", wasm_bindgen)] +impl Rule { + /** + * Creates a new Rule with the specified type. + * @param rule_type - The type of the rule (e.g., "StyleRule", "FontFaceRule", "KeyframesRule"). + */ + #[cfg(feature = "encode")] + #[wasm_bindgen(constructor)] + pub fn new(rule_type: String) -> Result { + let rule_type_enum = match rule_type.as_str() { + "StyleRule" => RuleType::Declaration, + "FontFaceRule" => RuleType::FontFace, + "KeyframesRule" => RuleType::KeyFrames, + _ => { + return Err(JsError::new(&format!("Unknown rule type: {rule_type}"))); + } + }; + Ok(Rule { + rule_type: rule_type_enum, + prelude: RulePrelude { + selector_list: vec![], + }, + declaration_block: DeclarationBlock { tokens: vec![] }, + nested_rules: vec![], + }) + } + + /** + * Sets the prelude for the rule. + * @param prelude - The prelude to set (SelectorList or KeyFramesPrelude). + */ + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn set_prelude(&mut self, prelude: RulePrelude) { + self.prelude = prelude; + } + + /** + * Pushes a declaration to the rule's declaration block. + * @param property_name - The property name. + * @param value - The property value. + */ + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn push_declaration(&mut self, property_name: String, value: String) { + // 1. property name + self.declaration_block.tokens.push(ValueToken { + token_type: IDENT_TOKEN, + value: property_name, + }); + // 2. colon + self.declaration_block.tokens.push(ValueToken { + token_type: COLON_TOKEN, + value: ":".to_string(), + }); + // 3. value tokens + let mut parser = DeclarationParser { + value_token_list: vec![], + }; + tokenize::tokenize(&value, &mut parser); + self + .declaration_block + .tokens + .append(&mut parser.value_token_list); + + // 4. semicolon + self.declaration_block.tokens.push(ValueToken { + token_type: SEMICOLON_TOKEN, + value: ";".to_string(), + }); + } + + /** + * Pushes a nested rule to the rule. + * @param rule - The nested rule to add. + */ + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn push_rule_children(&mut self, rule: Rule) { + self.nested_rules.push(rule); + } +} + +#[cfg_attr(feature = "encode", wasm_bindgen)] +impl RulePrelude { + #[cfg(feature = "encode")] + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { + selector_list: vec![], + } + } + + /** + * Pushes a selector to the list. + * @param selector - The selector to add. + */ + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn push_selector(&mut self, selector: Selector) { + self.selector_list.push(selector); + } +} + +#[cfg_attr(feature = "encode", wasm_bindgen)] +impl Selector { + #[cfg(feature = "encode")] + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { + simple_selectors: vec![], + } + } + + /** + * Pushes a selector section to the selector. + * @param selector_type - The type of the selector section (e.g., "ClassSelector", "IdSelector"). + * @param value - The value of the selector section. + */ + #[cfg(feature = "encode")] + #[wasm_bindgen] + pub fn push_one_selector_section( + &mut self, + selector_type: String, + value: String, + ) -> Result<(), JsError> { + let selector_selector_type = match selector_type.as_str() { + "ClassSelector" => OneSimpleSelectorType::ClassSelector, + "IdSelector" => OneSimpleSelectorType::IdSelector, + "AttributeSelector" => OneSimpleSelectorType::AttributeSelector, + "TypeSelector" => OneSimpleSelectorType::TypeSelector, + "Combinator" => OneSimpleSelectorType::Combinator, + "PseudoClassSelector" => OneSimpleSelectorType::PseudoClassSelector, + "PseudoElementSelector" => OneSimpleSelectorType::PseudoElementSelector, + "UniversalSelector" => OneSimpleSelectorType::UniversalSelector, + "UnknownText" => OneSimpleSelectorType::UnknownText, + _ => { + return Err(JsError::new(&format!( + "Unknown selector section type: {selector_type}" + ))) + } + }; + let selector_section = OneSimpleSelector { + selector_type: selector_selector_type, + value, + }; + self.simple_selectors.push(selector_section); + Ok(()) + } +} + +impl Selector { + pub(crate) fn generate_to_string_buf(&self, buf: &mut String) { + for selector in self.simple_selectors.iter() { + match selector.selector_type { + OneSimpleSelectorType::TypeSelector => { + buf.push_str(&selector.value); + } + OneSimpleSelectorType::ClassSelector => { + buf.push('.'); + buf.push_str(&selector.value); + } + OneSimpleSelectorType::IdSelector => { + buf.push('#'); + buf.push_str(&selector.value); + } + OneSimpleSelectorType::AttributeSelector => { + buf.push('['); + buf.push_str(&selector.value); + buf.push(']'); + } + OneSimpleSelectorType::PseudoClassSelector => { + buf.push(':'); + buf.push_str(&selector.value); + } + OneSimpleSelectorType::PseudoElementSelector => { + buf.push_str("::"); + buf.push_str(&selector.value); + } + OneSimpleSelectorType::UniversalSelector => { + buf.push('*'); + } + OneSimpleSelectorType::Combinator => { + buf.push(' '); + buf.push_str(&selector.value); + buf.push(' '); + } + OneSimpleSelectorType::UnknownText => { + buf.push_str(&selector.value); + } + } + } + } +} +#[cfg(feature = "encode")] +struct DeclarationParser { + value_token_list: Vec, +} + +#[cfg(feature = "encode")] +impl tokenize::Parser for DeclarationParser { + fn on_token(&mut self, token_type: u8, token_value: &str) { + let value_token = ValueToken { + token_type, + value: token_value.to_string(), + }; + self.value_token_list.push(value_token); + } +}