`; users see nothing until the
+ CodeMirror bundle has executed.
+- **SSG builds and CLIs.** Generating HTML for hundreds of notes at build time
+ currently means re-implementing the pipeline (remark-parse → plugin remark
+ transforms → remark-rehype → rehype-stringify) outside the editor.
+- **Drift.** Anything re-implemented outside `exportHTML()` diverges from what
+ the editor exports the moment a plugin changes.
+
+The pipeline itself is already pure — the private `markdownToHtml()` in
+`packages/core/src/editor.ts` touches nothing DOM-related. It is just not
+reachable without an editor instance.
+
+## What Changes
+
+- **New module `packages/core/src/render-html.ts`**, exported from
+ `@floatboat/nexus-core`:
+ - `createHtmlRenderer(options?) → HtmlRenderer` — builds the unified
+ pipeline once (same "build once, freeze" pattern as `createParser()`) and
+ returns `{ render(markdown): string }`.
+ - `renderMarkdownToHtml(markdown, options?) → string` — one-shot wrapper.
+ - `HtmlRendererOptions { plugins?: NexusPlugin[]; transform?: (tree: Root) => Root }`
+ and `HtmlRenderer` types.
+- **`EditorAPI.exportHTML()` delegates to the shared renderer** (created
+ lazily on first call, reused afterwards) instead of the private
+ `markdownToHtml()`, so browser export and headless render cannot drift.
+ Output is unchanged and covered by an equivalence test.
+- **SSR contract tests for the framework bindings.** `
` from
+ `@floatboat/nexus-react` and `@floatboat/nexus-vue` renders only its
+ container `
` under `react-dom/server` / `vue/server-renderer` in a
+ Node (no-DOM) vitest environment. This guards behaviour that already
+ holds; no binding code changes.
+- **Docs.** README / README.zh gain a "Server-side rendering & SSG" section
+ with a Next.js App Router example; `packages/core/README.md` documents the
+ API; ROADMAP gets a Core Editor row.
+
+No breaking changes. No new runtime dependencies — `remark-parse`,
+`remark-rehype`, `rehype-stringify` and `unified` are already dependencies of
+`@floatboat/nexus-core`.
+
+## Impact
+
+- Affected specs: `html-rendering` (NEW capability).
+- Affected code:
+ - `packages/core/src/render-html.ts` (NEW), `packages/core/src/index.ts`,
+ `packages/core/src/editor.ts`
+ - `packages/core/test/render-html.test.ts` (NEW, `@vitest-environment node`),
+ `packages/core/test/render-html-editor.test.ts` (NEW, jsdom)
+ - `packages/react/test/editor-ssr.test.tsx` (NEW, node),
+ `packages/vue/test/editor-ssr.test.ts` (NEW, node)
+ - `README.md`, `README.zh.md`, `packages/core/README.md`,
+ `docs/ROADMAP.md`, `docs/ROADMAP.zh.md`
+- Out of scope (explicit non-goals):
+ - Server-rendering the *live preview* (CodeMirror decorations, widgets).
+ The static HTML is the crawlable / first-paint representation; the editor
+ hydrates on the client.
+ - Rendering mermaid diagrams, highlight.js tokens or KaTeX server-side.
+ Those are live-preview widgets and are not part of the `exportHTML()`
+ pipeline today.
+ - Raw HTML pass-through (`allowDangerousHtml`). Safe-by-default matches
+ current `exportHTML()` behaviour; an opt-in can be a follow-up change.
+ - An `ssrHtml` / placeholder prop on `
` that shows pre-rendered
+ HTML until hydration. Hosts can compose this themselves (see the README
+ example); a built-in prop deserves its own proposal.
diff --git a/openspec/changes/add-headless-html-render/specs/html-rendering/spec.md b/openspec/changes/add-headless-html-render/specs/html-rendering/spec.md
new file mode 100644
index 00000000..dbece87d
--- /dev/null
+++ b/openspec/changes/add-headless-html-render/specs/html-rendering/spec.md
@@ -0,0 +1,98 @@
+# HTML Rendering Spec — headless Markdown → HTML
+
+## ADDED Requirements
+
+### Requirement: Headless HTML Renderer Factory
+
+`@floatboat/nexus-core` SHALL export
+`createHtmlRenderer(options?: HtmlRendererOptions): HtmlRenderer`. The
+returned renderer SHALL expose `render(markdown: string): string` and SHALL
+NOT require a DOM (`document`, `window`), a container element or an
+`EditorView`. The renderer SHALL build its unified pipeline once at creation
+and reuse it for every `render()` call.
+
+#### Scenario: Render in a Node.js process without a DOM
+- **WHEN** `createHtmlRenderer().render("# Hello")` is invoked where
+ `typeof document === "undefined"`
+- **THEN** it SHALL return HTML containing `
Hello
` and SHALL NOT throw
+
+#### Scenario: Reuse across documents
+- **WHEN** one renderer (with the GFM preset) renders `"~~gone~~"` and then
+ `"# Second"`
+- **THEN** the second result SHALL contain `
Second
` and SHALL NOT
+ contain `
`
+
+#### Scenario: Empty document
+- **WHEN** `render("")` is invoked
+- **THEN** it SHALL return `""`
+
+### Requirement: Plugin Remark Transforms Are Honoured
+
+The renderer SHALL apply the `remarkPlugins` of every `NexusPlugin` in
+`options.plugins`, in plugin order, before converting the tree to HTML.
+
+#### Scenario: GFM table
+- **WHEN** a GFM table source is rendered with `plugins: [createGfmPreset()]`
+- **THEN** the output SHALL contain ``
+- **AND** the same source rendered without plugins SHALL NOT contain ``
+
+### Requirement: Optional mdast Transform Hook
+
+`HtmlRendererOptions.transform?: (tree: Root) => Root` SHALL be applied after
+all plugin remark transforms and before HTML conversion. The tree returned by
+the hook SHALL be the tree that is serialised.
+
+#### Scenario: Transform changes heading depth
+- **WHEN** `"# Title"` is rendered with a `transform` that sets every heading
+ `depth` to `2`
+- **THEN** the output SHALL contain `Title
` and SHALL NOT contain ``
+
+### Requirement: Raw HTML Is Not Passed Through
+
+The renderer SHALL drop raw `html` nodes from the source (remark-rehype
+default) so the output can be embedded without an additional sanitiser.
+
+#### Scenario: Script tag in source
+- **WHEN** `"before\n\n\n\nafter"` is rendered
+- **THEN** the output SHALL NOT contain `\n\nafter");
+
+ expect(html).not.toContain("