diff --git a/Cargo.lock b/Cargo.lock
index 91a7c98..d62bb04 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -12318,6 +12318,26 @@ dependencies = [
"web-time",
]
+[[package]]
+name = "weaver-editor-js"
+version = "0.1.0"
+dependencies = [
+ "console_error_panic_hook",
+ "js-sys",
+ "serde",
+ "serde-wasm-bindgen 0.6.5",
+ "serde_bytes",
+ "tsify-next",
+ "wasm-bindgen",
+ "weaver-api",
+ "weaver-common",
+ "weaver-editor-browser",
+ "weaver-editor-core",
+ "weaver-editor-crdt",
+ "weaver-embed-worker",
+ "web-sys",
+]
+
[[package]]
name = "weaver-embed-worker"
version = "0.1.0"
diff --git a/crates/weaver-editor-js/.gitignore b/crates/weaver-editor-js/.gitignore
new file mode 100644
index 0000000..ebf1dde
--- /dev/null
+++ b/crates/weaver-editor-js/.gitignore
@@ -0,0 +1,2 @@
+pkg/
+target/
diff --git a/crates/weaver-editor-js/Cargo.toml b/crates/weaver-editor-js/Cargo.toml
new file mode 100644
index 0000000..bee5c43
--- /dev/null
+++ b/crates/weaver-editor-js/Cargo.toml
@@ -0,0 +1,57 @@
+[package]
+name = "weaver-editor-js"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+repository = "https://tangled.org/nonbinary.computer/weaver"
+description = "WASM bindings for the weaver markdown editor"
+
+[lib]
+crate-type = ["cdylib"]
+
+[features]
+default = []
+collab = ["weaver-editor-crdt"]
+
+[dependencies]
+weaver-editor-core = { path = "../weaver-editor-core" }
+weaver-editor-browser = { path = "../weaver-editor-browser" }
+weaver-editor-crdt = { path = "../weaver-editor-crdt", optional = true }
+weaver-embed-worker = { path = "../weaver-embed-worker" }
+weaver-api = { path = "../weaver-api" }
+weaver-common = { path = "../weaver-common" }
+
+wasm-bindgen = "0.2"
+serde = { workspace = true }
+serde_bytes = "0.11"
+serde-wasm-bindgen = "0.6"
+tsify-next = "0.5"
+js-sys = "0.3"
+console_error_panic_hook = "0.1"
+
+[dependencies.web-sys]
+version = "0.3"
+features = [
+ "console",
+ "Document",
+ "Element",
+ "HtmlElement",
+ "HtmlDivElement",
+ "Node",
+ "Window",
+ "Selection",
+ "Range",
+ "InputEvent",
+ "KeyboardEvent",
+ "ClipboardEvent",
+ "CompositionEvent",
+ "DataTransfer",
+ "EventTarget",
+]
+
+[package.metadata.wasm-pack.profile.dev]
+wasm-opt = false
+
+[package.metadata.wasm-pack.profile.release]
+wasm-opt = ['-Oz', '--enable-bulk-memory-opt', '--enable-nontrapping-float-to-int']
diff --git a/crates/weaver-editor-js/build.sh b/crates/weaver-editor-js/build.sh
new file mode 100755
index 0000000..6014984
--- /dev/null
+++ b/crates/weaver-editor-js/build.sh
@@ -0,0 +1,290 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+PKG_NAME="@weaver.sh/editor"
+PKG_VERSION="0.1.0"
+
+# Targets to build
+TARGETS=(bundler web nodejs deno)
+
+COMMAND="${1:-build}"
+shift || true
+
+# Feature variants
+declare -A VARIANTS=(
+ ["core"]=""
+ ["collab"]="collab"
+)
+
+build() {
+ local target="$1"
+ local variant="$2"
+ local features="$3"
+ local out_dir="pkg/${variant}/${target}"
+
+ echo "Building ${variant}/${target}..."
+
+ local feature_args="--no-default-features"
+ if [[ -n "$features" ]]; then
+ feature_args="$feature_args --features $features"
+ fi
+
+ wasm-pack build \
+ --out-name weaver_editor \
+ --out-dir "$out_dir" \
+ --target "$target" \
+ $feature_args
+
+ # Report size
+ local wasm_file="${out_dir}/weaver_editor_bg.wasm"
+ if [[ -f "$wasm_file" ]]; then
+ local size=$(ls -lh "$wasm_file" | awk '{print $5}')
+ echo " → ${size}"
+ fi
+}
+
+generate_package_json() {
+ local variant="$1"
+ local out_dir="pkg/${variant}"
+ local pkg_suffix=""
+ local description=""
+
+ if [[ "$variant" == "collab" ]]; then
+ pkg_suffix="-collab"
+ description="Weaver markdown editor with collaborative editing (Loro CRDT + iroh P2P)"
+ else
+ pkg_suffix="-core"
+ description="Weaver markdown editor (local editing, lightweight)"
+ fi
+
+ cat > "${out_dir}/package.json" << EOF
+{
+ "name": "${PKG_NAME}${pkg_suffix}",
+ "version": "${PKG_VERSION}",
+ "description": "${description}",
+ "license": "MPL-2.0",
+ "repository": {
+ "type": "git",
+ "url": "https://tangled.org/nonbinary.computer/weaver"
+ },
+ "keywords": ["atproto", "markdown", "editor", "wasm", "weaver"],
+ "main": "nodejs/weaver_editor.js",
+ "module": "bundler/weaver_editor.js",
+ "browser": "web/weaver_editor.js",
+ "types": "bundler/weaver_editor.d.ts",
+ "exports": {
+ ".": {
+ "deno": "./deno/weaver_editor.js",
+ "node": {
+ "import": "./nodejs/weaver_editor.js",
+ "require": "./nodejs/weaver_editor.js"
+ },
+ "browser": {
+ "import": "./web/weaver_editor.js"
+ },
+ "default": "./bundler/weaver_editor.js"
+ },
+ "./bundler": {
+ "import": "./bundler/weaver_editor.js",
+ "types": "./bundler/weaver_editor.d.ts"
+ },
+ "./web": {
+ "import": "./web/weaver_editor.js",
+ "types": "./web/weaver_editor.d.ts"
+ },
+ "./nodejs": {
+ "import": "./nodejs/weaver_editor.js",
+ "require": "./nodejs/weaver_editor.js",
+ "types": "./nodejs/weaver_editor.d.ts"
+ },
+ "./deno": {
+ "import": "./deno/weaver_editor.js",
+ "types": "./deno/weaver_editor.d.ts"
+ }
+ },
+ "files": [
+ "bundler/",
+ "web/",
+ "nodejs/",
+ "deno/",
+ "README.md"
+ ]
+}
+EOF
+}
+
+generate_readme() {
+ local variant="$1"
+ local out_dir="pkg/${variant}"
+
+ cat > "${out_dir}/README.md" << 'EOF'
+# @weaver.sh/editor
+
+WASM-based markdown editor for the weaver.sh ecosystem.
+
+## Installation
+
+```bash
+npm install @weaver.sh/editor-core # Local editing only
+npm install @weaver.sh/editor-collab # With collaborative editing
+```
+
+## Usage
+
+### With a bundler (webpack, vite, etc.)
+
+```javascript
+import init, { JsEditor } from '@weaver.sh/editor-core';
+
+await init();
+
+const editor = JsEditor.fromMarkdown('# Hello\n\nWorld');
+console.log(editor.getMarkdown());
+```
+
+### Direct browser usage (no bundler)
+
+```html
+
+```
+
+### Node.js
+
+```javascript
+const { JsEditor } = require('@weaver.sh/editor-core/nodejs');
+```
+
+## API
+
+See the TypeScript definitions for full API documentation.
+
+### Core
+
+- `JsEditor.new()` - Create empty editor
+- `JsEditor.fromMarkdown(content)` - Create from markdown
+- `JsEditor.fromSnapshot(entry)` - Create from EntryJson snapshot
+- `editor.getMarkdown()` - Get markdown content
+- `editor.getSnapshot()` - Get EntryJson for drafts
+- `editor.toEntry()` - Get validated EntryJson for publishing
+- `editor.executeAction(action)` - Execute an EditorAction
+- `editor.setTitle(title)` / `editor.setPath(path)` / `editor.setTags(tags)`
+
+### Images
+
+- `editor.addPendingImage(image)` - Track pending upload
+- `editor.finalizeImage(localId, finalized)` - Mark upload complete
+- `editor.getPendingImages()` - Get images awaiting upload
+- `editor.getStagingUris()` - Get staging record URIs for cleanup
+
+### Collab (editor-collab only)
+
+- `JsCollabEditor` - Collaborative editor with Loro CRDT
+- `editor.exportUpdates()` / `editor.importUpdates(bytes)`
+- `editor.addPeer(nodeId)` / `editor.removePeer(nodeId)`
+EOF
+}
+
+do_build() {
+ # Clean previous builds
+ rm -rf pkg
+
+ # Build all combinations
+ for variant in "${!VARIANTS[@]}"; do
+ features="${VARIANTS[$variant]}"
+
+ for target in "${TARGETS[@]}"; do
+ build "$target" "$variant" "$features"
+ done
+
+ generate_package_json "$variant"
+ generate_readme "$variant"
+
+ # Clean up wasm-pack artifacts we don't need
+ find "pkg/${variant}" -name ".gitignore" -delete
+ find "pkg/${variant}" -name "package.json" -path "*/bundler/*" -delete
+ find "pkg/${variant}" -name "package.json" -path "*/web/*" -delete
+ find "pkg/${variant}" -name "package.json" -path "*/nodejs/*" -delete
+ find "pkg/${variant}" -name "package.json" -path "*/deno/*" -delete
+ done
+
+ echo ""
+ echo "Build complete!"
+ echo ""
+ ls -lh pkg/core/web/*.wasm pkg/collab/web/*.wasm 2>/dev/null || true
+ echo ""
+ echo "Packages:"
+ echo " pkg/core/ - @weaver.sh/editor-core (local editing)"
+ echo " pkg/collab/ - @weaver.sh/editor-collab (with CRDT collab)"
+}
+
+do_pack() {
+ echo "Packing..."
+ for variant in "${!VARIANTS[@]}"; do
+ echo " ${variant}..."
+ (cd "pkg/${variant}" && npm pack)
+ done
+ echo ""
+ echo "Tarballs created:"
+ ls -lh pkg/*/*.tgz 2>/dev/null || true
+}
+
+do_publish() {
+ local tag="${1:-}"
+ local tag_arg=""
+ if [[ -n "$tag" ]]; then
+ tag_arg="--tag $tag"
+ fi
+
+ echo "Publishing..."
+ for variant in "${!VARIANTS[@]}"; do
+ echo " ${variant}..."
+ (cd "pkg/${variant}" && npm publish --access public $tag_arg)
+ done
+ echo ""
+ echo "Published!"
+}
+
+usage() {
+ echo "Usage: $0 [command]"
+ echo ""
+ echo "Commands:"
+ echo " build Build all variants and targets (default)"
+ echo " pack Create npm tarballs"
+ echo " publish Publish to npm registry"
+ echo " all Build, pack, and publish"
+ echo ""
+ echo "Options for publish:"
+ echo " --tag Publish with a specific tag (e.g., 'next', 'beta')"
+}
+
+case "$COMMAND" in
+ build)
+ do_build
+ ;;
+ pack)
+ do_pack
+ ;;
+ publish)
+ do_publish "$@"
+ ;;
+ all)
+ do_build
+ do_pack
+ do_publish "$@"
+ ;;
+ -h|--help|help)
+ usage
+ ;;
+ *)
+ echo "Unknown command: $COMMAND"
+ usage
+ exit 1
+ ;;
+esac
diff --git a/crates/weaver-editor-js/src/actions.rs b/crates/weaver-editor-js/src/actions.rs
new file mode 100644
index 0000000..6952f52
--- /dev/null
+++ b/crates/weaver-editor-js/src/actions.rs
@@ -0,0 +1,153 @@
+//! EditorAction conversion for JavaScript.
+
+use serde::{Deserialize, Serialize};
+use tsify_next::Tsify;
+use wasm_bindgen::prelude::*;
+use weaver_editor_core::{EditorAction, FormatAction, Range};
+
+/// JavaScript-friendly editor action.
+///
+/// Mirrors EditorAction from core but with JS-compatible types.
+/// Also includes FormatAction variants for extended formatting.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum JsEditorAction {
+ // Text insertion
+ Insert { text: String, start: usize, end: usize },
+ InsertLineBreak { start: usize, end: usize },
+ InsertParagraph { start: usize, end: usize },
+
+ // Deletion
+ DeleteBackward { start: usize, end: usize },
+ DeleteForward { start: usize, end: usize },
+ DeleteWordBackward { start: usize, end: usize },
+ DeleteWordForward { start: usize, end: usize },
+ DeleteToLineStart { start: usize, end: usize },
+ DeleteToLineEnd { start: usize, end: usize },
+ DeleteSoftLineBackward { start: usize, end: usize },
+ DeleteSoftLineForward { start: usize, end: usize },
+
+ // History
+ Undo,
+ Redo,
+
+ // Inline formatting (EditorAction variants)
+ ToggleBold,
+ ToggleItalic,
+ ToggleCode,
+ ToggleStrikethrough,
+ InsertLink,
+
+ // Extended formatting (FormatAction variants)
+ InsertImage,
+ InsertHeading { level: u8 },
+ ToggleBulletList,
+ ToggleNumberedList,
+ ToggleQuote,
+
+ // Clipboard
+ Cut,
+ Copy,
+ Paste { start: usize, end: usize },
+ CopyAsHtml,
+
+ // Selection
+ SelectAll,
+
+ // Cursor
+ MoveCursor { offset: usize },
+ ExtendSelection { offset: usize },
+}
+
+/// Result of converting JsEditorAction.
+pub enum ActionKind {
+ /// Standard EditorAction.
+ Editor(EditorAction),
+ /// FormatAction (needs apply_formatting).
+ Format(FormatAction),
+}
+
+impl JsEditorAction {
+ /// Convert to ActionKind (either EditorAction or FormatAction).
+ pub fn to_action_kind(&self) -> ActionKind {
+ match self {
+ // Text insertion
+ Self::Insert { text, start, end } => ActionKind::Editor(EditorAction::Insert {
+ text: text.clone(),
+ range: Range::new(*start, *end),
+ }),
+ Self::InsertLineBreak { start, end } => ActionKind::Editor(EditorAction::InsertLineBreak {
+ range: Range::new(*start, *end),
+ }),
+ Self::InsertParagraph { start, end } => ActionKind::Editor(EditorAction::InsertParagraph {
+ range: Range::new(*start, *end),
+ }),
+
+ // Deletion
+ Self::DeleteBackward { start, end } => ActionKind::Editor(EditorAction::DeleteBackward {
+ range: Range::new(*start, *end),
+ }),
+ Self::DeleteForward { start, end } => ActionKind::Editor(EditorAction::DeleteForward {
+ range: Range::new(*start, *end),
+ }),
+ Self::DeleteWordBackward { start, end } => ActionKind::Editor(EditorAction::DeleteWordBackward {
+ range: Range::new(*start, *end),
+ }),
+ Self::DeleteWordForward { start, end } => ActionKind::Editor(EditorAction::DeleteWordForward {
+ range: Range::new(*start, *end),
+ }),
+ Self::DeleteToLineStart { start, end } => ActionKind::Editor(EditorAction::DeleteToLineStart {
+ range: Range::new(*start, *end),
+ }),
+ Self::DeleteToLineEnd { start, end } => ActionKind::Editor(EditorAction::DeleteToLineEnd {
+ range: Range::new(*start, *end),
+ }),
+ Self::DeleteSoftLineBackward { start, end } => ActionKind::Editor(EditorAction::DeleteSoftLineBackward {
+ range: Range::new(*start, *end),
+ }),
+ Self::DeleteSoftLineForward { start, end } => ActionKind::Editor(EditorAction::DeleteSoftLineForward {
+ range: Range::new(*start, *end),
+ }),
+
+ // History
+ Self::Undo => ActionKind::Editor(EditorAction::Undo),
+ Self::Redo => ActionKind::Editor(EditorAction::Redo),
+
+ // Inline formatting (EditorAction)
+ Self::ToggleBold => ActionKind::Editor(EditorAction::ToggleBold),
+ Self::ToggleItalic => ActionKind::Editor(EditorAction::ToggleItalic),
+ Self::ToggleCode => ActionKind::Editor(EditorAction::ToggleCode),
+ Self::ToggleStrikethrough => ActionKind::Editor(EditorAction::ToggleStrikethrough),
+ Self::InsertLink => ActionKind::Editor(EditorAction::InsertLink),
+
+ // Extended formatting (FormatAction)
+ Self::InsertImage => ActionKind::Format(FormatAction::Image),
+ Self::InsertHeading { level } => ActionKind::Format(FormatAction::Heading(*level)),
+ Self::ToggleBulletList => ActionKind::Format(FormatAction::BulletList),
+ Self::ToggleNumberedList => ActionKind::Format(FormatAction::NumberedList),
+ Self::ToggleQuote => ActionKind::Format(FormatAction::Quote),
+
+ // Clipboard
+ Self::Cut => ActionKind::Editor(EditorAction::Cut),
+ Self::Copy => ActionKind::Editor(EditorAction::Copy),
+ Self::Paste { start, end } => ActionKind::Editor(EditorAction::Paste {
+ range: Range::new(*start, *end),
+ }),
+ Self::CopyAsHtml => ActionKind::Editor(EditorAction::CopyAsHtml),
+
+ // Selection
+ Self::SelectAll => ActionKind::Editor(EditorAction::SelectAll),
+
+ // Cursor
+ Self::MoveCursor { offset } => ActionKind::Editor(EditorAction::MoveCursor { offset: *offset }),
+ Self::ExtendSelection { offset } => ActionKind::Editor(EditorAction::ExtendSelection { offset: *offset }),
+ }
+ }
+}
+
+/// Parse a JsValue into JsEditorAction.
+pub fn parse_action(value: JsValue) -> Result {
+ serde_wasm_bindgen::from_value(value)
+ .map_err(|e| JsError::new(&format!("Invalid action: {}", e)))
+}
diff --git a/crates/weaver-editor-js/src/collab.rs b/crates/weaver-editor-js/src/collab.rs
new file mode 100644
index 0000000..ac0353d
--- /dev/null
+++ b/crates/weaver-editor-js/src/collab.rs
@@ -0,0 +1,43 @@
+//! JsCollabEditor - collaborative editor with Loro CRDT.
+//!
+//! Only available with the `collab` feature.
+
+use wasm_bindgen::prelude::*;
+
+use weaver_editor_crdt::LoroTextBuffer;
+
+/// Collaborative editor with CRDT sync.
+///
+/// Wraps LoroTextBuffer for collaborative editing with iroh P2P transport.
+#[wasm_bindgen]
+pub struct JsCollabEditor {
+ // TODO: Implement collab editor
+ // - LoroTextBuffer for CRDT-backed text
+ // - iroh node for P2P transport
+ // - Session management callbacks
+ _marker: std::marker::PhantomData,
+}
+
+#[wasm_bindgen]
+impl JsCollabEditor {
+ /// Create a new collaborative editor.
+ #[wasm_bindgen(constructor)]
+ pub fn new() -> Result {
+ Err(JsError::new("CollabEditor not yet implemented"))
+ }
+}
+
+impl Default for JsCollabEditor {
+ fn default() -> Self {
+ Self {
+ _marker: std::marker::PhantomData,
+ }
+ }
+}
+
+// TODO: Implement these when ready:
+// - from_snapshot / from_loro_doc
+// - export_updates / import_updates
+// - get_version
+// - add_peer / remove_peer / get_connected_peers
+// - Session callbacks (onSessionNeeded, onSessionRefresh, onSessionEnd, onPeersNeeded)
diff --git a/crates/weaver-editor-js/src/editor.rs b/crates/weaver-editor-js/src/editor.rs
new file mode 100644
index 0000000..56a62e7
--- /dev/null
+++ b/crates/weaver-editor-js/src/editor.rs
@@ -0,0 +1,344 @@
+//! JsEditor - the main editor wrapper for JavaScript.
+
+use std::collections::HashMap;
+
+use wasm_bindgen::prelude::*;
+use web_sys::HtmlElement;
+
+use weaver_editor_browser::BrowserClipboard;
+use weaver_editor_core::{
+ EditorDocument, EditorRope, PlainEditor, RenderCache, UndoableBuffer, apply_formatting,
+ execute_action_with_clipboard,
+};
+
+use crate::actions::{ActionKind, parse_action};
+use crate::types::{EntryEmbeds, EntryJson, FinalizedImage, JsResolvedContent, PendingImage};
+
+type InnerEditor = PlainEditor>;
+
+/// The main editor instance exposed to JavaScript.
+///
+/// Wraps the core editor with WASM bindings for browser use.
+#[wasm_bindgen]
+pub struct JsEditor {
+ doc: InnerEditor,
+ cache: RenderCache,
+ resolved_content: weaver_common::ResolvedContent,
+
+ // Metadata
+ title: String,
+ path: String,
+ tags: Vec,
+ created_at: String,
+
+ // Image tracking
+ pending_images: HashMap,
+ finalized_images: HashMap,
+}
+
+#[wasm_bindgen]
+impl JsEditor {
+ /// Create a new empty editor.
+ #[wasm_bindgen(constructor)]
+ pub fn new() -> Self {
+ let rope = EditorRope::new();
+ let buffer = UndoableBuffer::new(rope, 100);
+ let doc = PlainEditor::new(buffer);
+
+ Self {
+ doc,
+ cache: RenderCache::default(),
+ resolved_content: weaver_common::ResolvedContent::new(),
+ title: String::new(),
+ path: String::new(),
+ tags: Vec::new(),
+ created_at: now_iso(),
+ pending_images: HashMap::new(),
+ finalized_images: HashMap::new(),
+ }
+ }
+
+ /// Create an editor from markdown content.
+ #[wasm_bindgen(js_name = fromMarkdown)]
+ pub fn from_markdown(content: &str) -> Self {
+ let rope = EditorRope::from_str(content);
+ let buffer = UndoableBuffer::new(rope, 100);
+ let doc = PlainEditor::new(buffer);
+
+ Self {
+ doc,
+ cache: RenderCache::default(),
+ resolved_content: weaver_common::ResolvedContent::new(),
+ title: String::new(),
+ path: String::new(),
+ tags: Vec::new(),
+ created_at: now_iso(),
+ pending_images: HashMap::new(),
+ finalized_images: HashMap::new(),
+ }
+ }
+
+ /// Create an editor from a snapshot (EntryJson).
+ #[wasm_bindgen(js_name = fromSnapshot)]
+ pub fn from_snapshot(snapshot: JsValue) -> Result {
+ let entry: EntryJson = serde_wasm_bindgen::from_value(snapshot)
+ .map_err(|e| JsError::new(&format!("Invalid snapshot: {}", e)))?;
+
+ let rope = EditorRope::from_str(&entry.content);
+ let buffer = UndoableBuffer::new(rope, 100);
+ let doc = PlainEditor::new(buffer);
+
+ Ok(Self {
+ doc,
+ cache: RenderCache::default(),
+ resolved_content: weaver_common::ResolvedContent::new(),
+ title: entry.title,
+ path: entry.path,
+ tags: entry.tags.unwrap_or_default(),
+ created_at: entry.created_at,
+ pending_images: HashMap::new(),
+ finalized_images: HashMap::new(),
+ })
+ }
+
+ /// Set pre-resolved embed content.
+ #[wasm_bindgen(js_name = setResolvedContent)]
+ pub fn set_resolved_content(&mut self, content: JsResolvedContent) {
+ self.resolved_content = content.into_inner();
+ }
+
+ // === Content access ===
+
+ /// Get the markdown content.
+ #[wasm_bindgen(js_name = getMarkdown)]
+ pub fn get_markdown(&self) -> String {
+ self.doc.content_string()
+ }
+
+ /// Get the current state as a snapshot (EntryJson).
+ #[wasm_bindgen(js_name = getSnapshot)]
+ pub fn get_snapshot(&self) -> Result {
+ let entry = EntryJson {
+ title: self.title.clone(),
+ path: self.path.clone(),
+ content: self.doc.content_string(),
+ created_at: self.created_at.clone(),
+ updated_at: Some(now_iso()),
+ tags: if self.tags.is_empty() {
+ None
+ } else {
+ Some(self.tags.clone())
+ },
+ embeds: self.build_embeds(),
+ authors: None,
+ content_warnings: None,
+ rating: None,
+ };
+
+ serde_wasm_bindgen::to_value(&entry)
+ .map_err(|e| JsError::new(&format!("Serialization error: {}", e)))
+ }
+
+ /// Get the entry JSON, validating required fields.
+ ///
+ /// Throws if title or path is empty, or if there are pending images.
+ #[wasm_bindgen(js_name = toEntry)]
+ pub fn to_entry(&self) -> Result {
+ if self.title.is_empty() {
+ return Err(JsError::new("Title is required"));
+ }
+ if self.path.is_empty() {
+ return Err(JsError::new("Path is required"));
+ }
+ if !self.pending_images.is_empty() {
+ return Err(JsError::new(
+ "Pending images must be finalized before publishing",
+ ));
+ }
+
+ self.get_snapshot()
+ }
+
+ // === Metadata ===
+
+ /// Get the title.
+ #[wasm_bindgen(js_name = getTitle)]
+ pub fn get_title(&self) -> String {
+ self.title.clone()
+ }
+
+ /// Set the title.
+ #[wasm_bindgen(js_name = setTitle)]
+ pub fn set_title(&mut self, title: &str) {
+ self.title = title.to_string();
+ }
+
+ /// Get the path.
+ #[wasm_bindgen(js_name = getPath)]
+ pub fn get_path(&self) -> String {
+ self.path.clone()
+ }
+
+ /// Set the path.
+ #[wasm_bindgen(js_name = setPath)]
+ pub fn set_path(&mut self, path: &str) {
+ self.path = path.to_string();
+ }
+
+ /// Get the tags.
+ #[wasm_bindgen(js_name = getTags)]
+ pub fn get_tags(&self) -> Vec {
+ self.tags.clone()
+ }
+
+ /// Set the tags.
+ #[wasm_bindgen(js_name = setTags)]
+ pub fn set_tags(&mut self, tags: Vec) {
+ self.tags = tags;
+ }
+
+ // === Actions ===
+
+ /// Execute an editor action.
+ #[wasm_bindgen(js_name = executeAction)]
+ pub fn execute_action(&mut self, action: JsValue) -> Result<(), JsError> {
+ let js_action = parse_action(action)?;
+ let kind = js_action.to_action_kind();
+
+ let clipboard = BrowserClipboard::empty();
+ match kind {
+ ActionKind::Editor(editor_action) => {
+ execute_action_with_clipboard(&mut self.doc, &editor_action, &clipboard);
+ }
+ ActionKind::Format(format_action) => {
+ apply_formatting(&mut self.doc, format_action);
+ }
+ }
+
+ Ok(())
+ }
+
+ // === Image handling ===
+
+ /// Add a pending image (called when user adds an image).
+ #[wasm_bindgen(js_name = addPendingImage)]
+ pub fn add_pending_image(&mut self, image: JsValue) -> Result<(), JsError> {
+ let pending: PendingImage = serde_wasm_bindgen::from_value(image)
+ .map_err(|e| JsError::new(&format!("Invalid pending image: {}", e)))?;
+
+ self.pending_images
+ .insert(pending.local_id.clone(), pending);
+ Ok(())
+ }
+
+ /// Finalize an image after upload.
+ #[wasm_bindgen(js_name = finalizeImage)]
+ pub fn finalize_image(&mut self, local_id: &str, finalized: JsValue) -> Result<(), JsError> {
+ let finalized: FinalizedImage = serde_wasm_bindgen::from_value(finalized)
+ .map_err(|e| JsError::new(&format!("Invalid finalized image: {}", e)))?;
+
+ self.pending_images.remove(local_id);
+ self.finalized_images
+ .insert(local_id.to_string(), finalized);
+ Ok(())
+ }
+
+ /// Remove a pending image.
+ #[wasm_bindgen(js_name = removeImage)]
+ pub fn remove_image(&mut self, local_id: &str) {
+ self.pending_images.remove(local_id);
+ self.finalized_images.remove(local_id);
+ }
+
+ /// Get pending images that need upload.
+ #[wasm_bindgen(js_name = getPendingImages)]
+ pub fn get_pending_images(&self) -> Result {
+ let pending: Vec<_> = self.pending_images.values().cloned().collect();
+ serde_wasm_bindgen::to_value(&pending)
+ .map_err(|e| JsError::new(&format!("Serialization error: {}", e)))
+ }
+
+ /// Get staging URIs for cleanup after publish.
+ #[wasm_bindgen(js_name = getStagingUris)]
+ pub fn get_staging_uris(&self) -> Vec {
+ self.finalized_images
+ .values()
+ .map(|f| f.staging_uri.clone())
+ .collect()
+ }
+
+ // === Cursor/selection ===
+
+ /// Get the current cursor offset.
+ #[wasm_bindgen(js_name = getCursorOffset)]
+ pub fn get_cursor_offset(&self) -> usize {
+ self.doc.cursor_offset()
+ }
+
+ /// Set the cursor offset.
+ #[wasm_bindgen(js_name = setCursorOffset)]
+ pub fn set_cursor_offset(&mut self, offset: usize) {
+ self.doc.set_cursor_offset(offset);
+ }
+
+ /// Get the document length in characters.
+ #[wasm_bindgen(js_name = getLength)]
+ pub fn get_length(&self) -> usize {
+ self.doc.len_chars()
+ }
+
+ // === Undo/redo ===
+
+ /// Check if undo is available.
+ #[wasm_bindgen(js_name = canUndo)]
+ pub fn can_undo(&self) -> bool {
+ self.doc.can_undo()
+ }
+
+ /// Check if redo is available.
+ #[wasm_bindgen(js_name = canRedo)]
+ pub fn can_redo(&self) -> bool {
+ self.doc.can_redo()
+ }
+}
+
+impl Default for JsEditor {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl JsEditor {
+ /// Build embeds from finalized images.
+ fn build_embeds(&self) -> Option {
+ if self.finalized_images.is_empty() {
+ return None;
+ }
+
+ use crate::types::{ImageEmbed, ImagesEmbed};
+
+ let images: Vec = self
+ .finalized_images
+ .values()
+ .map(|f| ImageEmbed {
+ image: f.blob_ref.clone(),
+ alt: String::new(), // TODO: track alt text
+ aspect_ratio: None,
+ })
+ .collect();
+
+ Some(EntryEmbeds {
+ images: Some(ImagesEmbed { images }),
+ records: None,
+ externals: None,
+ videos: None,
+ })
+ }
+}
+
+/// Get current time as ISO string.
+fn now_iso() -> String {
+ // Use js_sys::Date for browser-compatible time
+ let date = js_sys::Date::new_0();
+ date.to_iso_string().into()
+}
diff --git a/crates/weaver-editor-js/src/lib.rs b/crates/weaver-editor-js/src/lib.rs
new file mode 100644
index 0000000..b1a933f
--- /dev/null
+++ b/crates/weaver-editor-js/src/lib.rs
@@ -0,0 +1,30 @@
+//! WASM bindings for the weaver markdown editor.
+//!
+//! Provides embeddable editor components for JavaScript/TypeScript apps.
+//!
+//! # Features
+//!
+//! - `collab`: Enable collaborative editing via Loro CRDT + iroh P2P
+//! - `syntax-highlighting`: Enable syntax highlighting for code blocks
+
+mod actions;
+mod editor;
+mod types;
+
+#[cfg(feature = "collab")]
+mod collab;
+
+pub use actions::*;
+pub use editor::*;
+pub use types::*;
+
+#[cfg(feature = "collab")]
+pub use collab::*;
+
+use wasm_bindgen::prelude::*;
+
+/// Initialize panic hook for better error messages in console.
+#[wasm_bindgen(start)]
+pub fn init() {
+ console_error_panic_hook::set_once();
+}
diff --git a/crates/weaver-editor-js/src/types.rs b/crates/weaver-editor-js/src/types.rs
new file mode 100644
index 0000000..22bc21c
--- /dev/null
+++ b/crates/weaver-editor-js/src/types.rs
@@ -0,0 +1,225 @@
+//! Types exposed to JavaScript via wasm-bindgen.
+
+use serde::{Deserialize, Serialize};
+use tsify_next::Tsify;
+use wasm_bindgen::prelude::*;
+
+/// Pending image waiting for upload.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+#[serde(rename_all = "camelCase")]
+pub struct PendingImage {
+ pub local_id: String,
+ #[tsify(type = "Uint8Array")]
+ #[serde(with = "serde_bytes")]
+ pub data: Vec,
+ pub mime_type: String,
+ pub name: String,
+}
+
+/// Finalized image with blob ref and staging URI.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+#[serde(rename_all = "camelCase")]
+pub struct FinalizedImage {
+ pub blob_ref: JsBlobRef,
+ /// AT URI of the staging record (sh.weaver.publish.blob).
+ pub staging_uri: String,
+}
+
+/// Blob reference matching AT Protocol blob format.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+#[serde(rename_all = "camelCase")]
+pub struct JsBlobRef {
+ #[serde(rename = "$type")]
+ pub type_marker: String, // "blob"
+ pub r#ref: BlobLink,
+ pub mime_type: String,
+ pub size: u64,
+}
+
+/// CID link for blob.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+pub struct BlobLink {
+ #[serde(rename = "$link")]
+ pub link: String,
+}
+
+/// Entry JSON matching sh.weaver.notebook.entry lexicon.
+///
+/// Used for snapshots (drafts) and final entry output.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+#[serde(rename_all = "camelCase")]
+pub struct EntryJson {
+ pub title: String,
+ pub path: String,
+ pub content: String,
+ pub created_at: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub updated_at: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub tags: Option>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub embeds: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub authors: Option>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub content_warnings: Option>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub rating: Option,
+}
+
+/// Entry embeds container.
+#[derive(Debug, Clone, Default, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+#[serde(rename_all = "camelCase")]
+pub struct EntryEmbeds {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub images: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub records: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub externals: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub videos: Option,
+}
+
+/// Image embed container.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+pub struct ImagesEmbed {
+ pub images: Vec,
+}
+
+/// Single image embed.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+#[serde(rename_all = "camelCase")]
+pub struct ImageEmbed {
+ pub image: JsBlobRef,
+ pub alt: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub aspect_ratio: Option,
+}
+
+/// Aspect ratio for images/videos.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+pub struct AspectRatio {
+ pub width: u32,
+ pub height: u32,
+}
+
+/// Record embed container.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+pub struct RecordsEmbed {
+ pub records: Vec,
+}
+
+/// Single record embed (strong ref).
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+pub struct RecordEmbed {
+ pub uri: String,
+ pub cid: String,
+}
+
+/// External link embed container.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+pub struct ExternalsEmbed {
+ pub externals: Vec,
+}
+
+/// Single external link embed.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+pub struct ExternalEmbed {
+ pub uri: String,
+ pub title: String,
+ pub description: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub thumb: Option,
+}
+
+/// Video embed container.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+pub struct VideosEmbed {
+ pub videos: Vec,
+}
+
+/// Single video embed.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+#[serde(rename_all = "camelCase")]
+pub struct VideoEmbed {
+ pub video: JsBlobRef,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub alt: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub aspect_ratio: Option,
+}
+
+/// Author reference.
+#[derive(Debug, Clone, Serialize, Deserialize, Tsify)]
+#[tsify(into_wasm_abi, from_wasm_abi)]
+pub struct Author {
+ pub did: String,
+}
+
+/// Pre-rendered embed content for initial load.
+#[wasm_bindgen]
+pub struct JsResolvedContent {
+ inner: weaver_common::ResolvedContent,
+}
+
+#[wasm_bindgen]
+impl JsResolvedContent {
+ #[wasm_bindgen(constructor)]
+ pub fn new() -> Self {
+ Self {
+ inner: weaver_common::ResolvedContent::new(),
+ }
+ }
+
+ /// Add pre-rendered HTML for an AT URI.
+ #[wasm_bindgen(js_name = addEmbed)]
+ pub fn add_embed(&mut self, at_uri: &str, html: &str) -> Result<(), JsError> {
+ use weaver_common::jacquard::{CowStr, IntoStatic, types::string::AtUri};
+
+ let uri = AtUri::new(at_uri)
+ .map_err(|e| JsError::new(&format!("Invalid AT URI: {}", e)))?
+ .into_static();
+
+ self.inner
+ .add_embed(uri, CowStr::from(html.to_string()), None);
+ Ok(())
+ }
+}
+
+impl Default for JsResolvedContent {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl JsResolvedContent {
+ pub fn into_inner(self) -> weaver_common::ResolvedContent {
+ self.inner
+ }
+
+ pub fn inner_ref(&self) -> &weaver_common::ResolvedContent {
+ &self.inner
+ }
+}
+
+/// Create an empty resolved content container.
+#[wasm_bindgen]
+pub fn create_resolved_content() -> JsResolvedContent {
+ JsResolvedContent::new()
+}