Skip to content

feat(preview): live-reload the browser preview on every edit - #16

Open
nibzard wants to merge 8 commits into
pataruco:mainfrom
nibzard:feat/preview-live-m2
Open

nibzard wants to merge 8 commits into
pataruco:mainfrom
nibzard:feat/preview-live-m2

Conversation

@nibzard

@nibzard nibzard commented Aug 4, 2026

Copy link
Copy Markdown

Stacked on #15 — this branch includes #15's commits, so the diff shows both until it lands; only the last two commits (a4c14c6, 4fd0cf9) are new here. I'll rebase once #15 merges so this shrinks to just the live-preview change, or I can fold both into one PR if you'd rather review them together.

This picks up the live-preview follow-up mentioned in #15: instead of writing a static temp file, the language server serves the preview from a localhost-only HTTP server and pushes a server-sent event on every didChange. A small script injected into the served page fetches the fresh render and swaps document.documentElement in place, so the open tab follows the editor as you type — scroll position preserved, no visible flicker. Still no new dependencies, no unsafe, default feature set, behind the same mjml.preview.enabled option.

How it works:

  • The code action now opens http://127.0.0.1:<port>/<stem>/ — server bound lazily on the first preview, ephemeral port, 127.0.0.1 only.
  • Every didChange re-renders documents that have been previewed and bumps a version; subscribers on the SSE endpoint (/<stem>/__mjml-preview-events__) wake up and refetch.
  • Relative mj-image/mj-font assets are served from the source document's directory by the same server (path-traversal guarded), replacing the <base href> injection for served pages.
  • Invalid MJML swaps in the error page and recovers on the next parseable edit.
  • If the server can't bind a port, the action falls back to the static temp file from feat(preview): add browser preview code action #15.

The server is hand-rolled over std::net (GET-only): SSE needs no WebSocket dependency, and a single-user localhost preview didn't seem to justify pulling in an HTTP crate. Happy to swap it for tiny_http or similar if you'd rather not own that code.

Changes (on top of #15):

  • crates/mjml-lsp/src/preview_server.rs (new): shared state, routing, asset resolution, SSE, reload-script injection — unit- and socket-tested.
  • crates/mjml-lsp/src/main.rs: bundle the preview trigger state with the server handle, register/refresh documents on didChange, live-or-static open.
  • crates/mjml-lsp/src/preview.rs: open_in_browser now takes a URL-or-path string.
  • README.md: Preview section update.

Test plan:

  • cargo test --manifest-path crates/mjml-lsp/Cargo.toml (147 tests, including real-socket server tests: page serving, asset serving, traversal rejection, SSE event delivery, non-GET rejection)
  • cargo clippy --manifest-path crates/mjml-lsp/Cargo.toml -- -D warnings
  • Scripted end-to-end run against the built binary over stdio, replaying Zed's exact message flow from feat(preview): add browser preview code action #15's verification: initialize → didOpen → marker didChange → applyEdit strip → fetch the served page → edit → served page updated → broken MJML serves the error page and recovers.
  • Manual in Zed: the trigger path is byte-for-byte the one verified in feat(preview): add browser preview code action #15; the live path serves edits to the open tab as you type.

nibzard and others added 8 commits June 30, 2026 23:01
Add an 'Open Preview in Browser' code action that renders the current MJML document to standalone HTML via mrml and opens it in the system browser. No new dependencies (mrml already provides render), no unsafe, in the default feature set.

Zed has no webview and no workspace/executeCommand, so the action carries no command: it inserts a nonce marker comment, the server reacts to the resulting didChange (the only selection signal), renders, opens the browser, then strips the marker via workspace/applyEdit. A handled-set makes it fire exactly once per selection.

Gated behind the mjml.preview.enabled initialization option (default on). Invalid MJML opens an error page instead of failing silently.

preview.rs: render, error-page, browser-open, temp-file, and marker-pulse helpers (pure, unit-tested). main.rs: wire the action into the code-action response and handle the marker in didChange.
The unit-tests job ran only 'cargo test' (the root wasm crate), so mjml-lsp tests - including the new preview tests - never ran in CI. Add 'cargo test --manifest-path crates/mjml-lsp/Cargo.toml'.
Add a Preview section: how to trigger it, the single-shot behavior, the reason for the code-action + marker (Zed has no webview/executeCommand), error-page handling, and the mjml.preview.enabled opt-out.
MRML passes mj-image src and mj-font href values through unchanged, so relative paths like 'img/logo.png' resolved against the temp preview file's location and showed broken images. Inject a <base href> pointing at the source document's directory so relative resources load from where the MJML project keeps them. Absolute URLs are unaffected.
MRML parse errors can contain angle brackets (e.g. <mj-foo>), which the browser would parse as a tag and swallow. Escape &, <, > before placing the message in the error page.
If the browser can't be opened (no xdg-open on headless Linux, etc.) or the temp file can't be written, the user previously saw nothing. Send a window/showMessage with the temp file path so they can open it manually.
Serve previews from a localhost-only HTTP server instead of a static temp
file. Every didChange re-renders the registered document and pushes a
server-sent event; a small injected script swaps the new HTML in place, so
the open tab follows the editor with scroll position intact. Relative
images and fonts are served from the source directory (path-traversal
guarded), replacing the base-href approach for served pages. The server is
hand-rolled over std::net — a single-user localhost GET server does not
justify a dependency — and the static temp file remains as a fallback when
the server cannot start.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant