lexical-yjs-html 0.1.3

Render Lexical-shaped yrs documents to HTML, no browser or Node required
Documentation

lexical-yjs-html

Renders a Lexical document to HTML from its Yjs representation, using yrs. It covers core Lexical: paragraphs, headings, quotes, code blocks, lists, tables, links, and the full text-format model. The tests pin the output byte-for-byte to fixtures captured from a live editor.

Usage

[dependencies]
lexical-yjs-html = "0.1"
yrs = { version = "0.27", features = ["sync"] }

The input is a Yjs update: bytes from a durable store, from a provider, or from Y.encodeStateAsUpdate in the browser.

use yrs::updates::decoder::Decode;
use yrs::{Doc, ReadTxn, Transact, Update};

let update_bytes: Vec<u8> = std::fs::read("document.bin").unwrap();

let doc = Doc::new();
doc.transact_mut()
    .apply_update(Update::decode_v1(&update_bytes).unwrap())
    .unwrap();

let txn = doc.transact();
let fragment = txn.get_xml_fragment("root").expect("Lexical's default root");
let html = lexical_yjs_html::render(&txn, &fragment);
// => Some("<h1>Heading One</h1><p>…</p>") — or None if the fragment
//    isn't Lexical-shaped (e.g. a ProseMirror document).

One doc can hold several fragments. Pass the root name your editor binds. render returns None when the fragment is not Lexical-shaped.

Declarative rules

A rule tells the renderer how to draw a node type the built-in schema does not know. A rule is a tag, attribute templates, and a content slot. Declarative rules render inside the document transaction. Nothing calls back into your code.

use yrs::{Doc, Transact, ReadTxn};
use lexical_yjs_html::{flatten, render_segments, Rules};

let rules = Rules::parse(
    r#"{
      "nodes": {
        "callout": {
          "tag": "aside",
          "attrs": [["class", [{"lit": "callout callout--"}, {"ref": "kind"}]]],
          "content": "blocks"
        }
      }
    }"#,
).unwrap();

let doc = Doc::new();
let txn = doc.transact();
let fragment = txn.get_xml_fragment("root").expect("root");
let segments = render_segments(&txn, &fragment, &rules).expect("Lexical-shaped");
let html = flatten(segments).into_html().expect("no callback rules");
// A stored <callout kind="warning"> renders as
// <aside class="callout callout--warning">…</aside>

An attribute template joins literal parts (lit) and stored-attribute references (ref). An attribute that resolves empty is omitted. content is "inline" for formatted text, "blocks" for child block nodes, or "none" for a leaf. "inline" is the default. "void": true skips the closing tag. A rule for a built-in type replaces how that type renders. This crate has no marks tier. Lexical keeps formatting inside its text model, and the renderer handles that natively. prosemirror-yjs-html has the marks side.

Callback rules

A rule marked callback hands the node to your code. Use it for nodes that need logic or a database lookup. Deferred nodes come back as segments with their type, their stored attributes as JSON, and their children already rendered. The render never runs your code. You splice the result in after it returns.

use yrs::{Doc, Transact, ReadTxn};
use lexical_yjs_html::{render_segments, Rules, Segment};

fn splice(segments: Vec<Segment>) -> String {
    segments
        .into_iter()
        .map(|segment| match segment {
            Segment::Html(html) => html,
            Segment::Deferred { node_type, attrs_json, content, .. } => {
                let children = splice(content);
                match node_type.as_str() {
                    "mention" => {
                        let attrs: serde_json::Value =
                            serde_json::from_str(&attrs_json).unwrap();
                        let id = attrs["__id"].as_str().unwrap_or("unknown");
                        // Look the user up, build trusted markup, escape
                        // anything you interpolate.
                        format!(r#"<a class="mention" href="/users/{id}">@{id}</a>"#)
                    }
                    _ => children,
                }
            }
        })
        .collect()
}

let rules = Rules::parse(r#"{"nodes": {"mention": {"callback": true}}}"#).unwrap();

let doc = Doc::new();
let txn = doc.transact();
let fragment = txn.get_xml_fragment("root").expect("root");
let segments = render_segments(&txn, &fragment, &rules).expect("Lexical-shaped");
let html = splice(segments);

Rules, Segment, and flatten are re-exported here. yjs-html-core is an internal implementation crate.

Schema discovery

Editors store types and attributes under their own names. Lexical prefixes its own props with __. collect_node_types reports what a real document holds:

use yrs::{Doc, Transact, ReadTxn};
use lexical_yjs_html::{collect_node_types, is_builtin};

let doc = Doc::new();
let txn = doc.transact();
let fragment = txn.get_xml_fragment("root").expect("root");
for (node_type, info) in collect_node_types(&txn, &fragment).unwrap_or_default() {
    println!(
        "{node_type}: {} seen, attrs {:?}, children {:?}, text: {}, built in: {}",
        info.count, info.attrs, info.children, info.text, is_builtin(&node_type),
    );
}

Anything where is_builtin is false needs a rule. Without one, the node still renders its text and child blocks, just unwrapped.

License

MIT. Developed in yrby, where it backs Y::Lexical and Y::Lexxy.