Skip to content

feat(core): add headless HTML renderer for SSR/SSG - #358

Open
DeerDavids wants to merge 1 commit into
floatboatai:mainfrom
DeerDavids:feat/core-headless-html-render
Open

DeerDavids wants to merge 1 commit into
floatboatai:mainfrom
DeerDavids:feat/core-headless-html-render

Conversation

@DeerDavids

Copy link
Copy Markdown

Summary / 摘要

Expose the exportHTML() pipeline as a DOM-free API — createHtmlRenderer() / renderMarkdownToHtml() — so hosts can render Nexus documents to HTML in Node.js (SSR, SSG, CLIs), and add SSR contract tests for the React / Vue <Editor /> bindings.

Motivation / 背景与动机

EditorAPI.exportHTML() is the only way to get HTML out of a Nexus document, and it lives on a mounted editor: it needs a container element, a CodeMirror view and a document global. On main, importing @floatboat/nexus-core in plain Node.js works, but createEditor() throws ReferenceError: document is not defined — so there is no supported way to produce HTML on a server.

That blocks the use cases the README targets (docs CMS, static-site authoring, LLM writing tools):

  • SEO / first paint — a Next.js / Nuxt / Astro page has to ship raw Markdown and let the editor render client-side; crawlers see an empty <div> and users see nothing until the CodeMirror bundle runs.
  • SSG builds / CLIs — rendering hundreds of notes at build time means re-implementing the remark → rehype pipeline outside the editor.
  • Drift — anything re-implemented outside exportHTML() diverges from what the editor exports as soon as a plugin changes.

The pipeline itself (markdownToHtml() in editor.ts) was already pure; it was just unreachable without an editor instance.

  • Issue: none (gap found while evaluating SSR for a Next.js host)
  • Roadmap (docs/ROADMAP.md): new row feat(react,vue): add controlled document mode to Editor #30 Headless HTML rendering (SSR / SSG) under Core Editor
  • OpenSpec change: openspec/changes/add-headless-html-render (openspec validate --strict passes)

Changes / 变更内容

  • packages/core:
    • New src/render-html.ts — createHtmlRenderer({ plugins?, transform? }) builds the unified pipeline once (same "build once, freeze" pattern as createParser()) and returns { render(markdown) }; renderMarkdownToHtml() is the one-shot wrapper. Exported from index.ts together with the HtmlRenderer / HtmlRendererOptions types.
    • exportHTML() now delegates to the shared renderer (created lazily on first call, reused afterwards) instead of the private markdownToHtml(). Output is unchanged; the remark-rehype / rehype-stringify imports move out of editor.ts.
    • Raw HTML nodes are still dropped (remark-rehype default), so server output is safe to embed without an extra sanitizer.
    • README.md documents the API.
  • packages/react, packages/vue: tests only — <Editor /> renders its container <div> under react-dom/server / vue/server-renderer in a Node (no-DOM) vitest environment. No binding code changes; this locks in the SSR contract the README now promises.
  • README.md / README.zh.md: new "Server-side rendering & SSG" section under API Reference with a Next.js App Router example (crawlable HTML on first paint, editor hydrates on the client).
  • docs/ROADMAP.md / docs/ROADMAP.zh.md: row feat(react,vue): add controlled document mode to Editor #30.
  • openspec/: changes/add-headless-html-render/ — proposal, tasks, specs/html-rendering/spec.md.

No breaking changes. No new runtime dependencies.

Out of scope (listed in the proposal): server-rendering the live preview / widgets (mermaid, highlight.js, KaTeX), raw-HTML pass-through, and an ssrHtml placeholder prop on <Editor />.

Testing / 测试

  • pnpm test passes / 全绿 — 899 passed on Windows; the only failures (10 in apps/electron-demo/test/plugin-host-broker.test.ts) are EPERM: operation not permitted, symlink and fail identically on an untouched main checkout (Windows needs elevated rights to create symlinks). They pass on Linux CI.
  • Affected packages build (pnpm build) / 受影响包构建通过 — also pnpm typecheck, pnpm check:api, pnpm build:electron-demo.
  • New / updated vitest cases / 新增或更新的 vitest 用例:
    • packages/core/test/render-html.test.ts (@vitest-environment node, 8 cases): asserts document / window are undefined; basic rendering; empty input; plugin remarkPlugins honoured (GFM table); raw HTML dropped; transform hook; renderer reuse across documents; factory ≡ one-shot helper.
    • packages/core/test/render-html-editor.test.ts (jsdom): renderMarkdownToHtml() output is byte-identical to editor.exportHTML() for the same document and plugins.
    • packages/react/test/editor-ssr.test.tsx, packages/vue/test/editor-ssr.test.ts (node): server-rendered <Editor /> is exactly the container <div>.
    • Tests were written first and watched fail (createHtmlRenderer is not a function) before the implementation.
  • Manual UI check in electron-demo / electron-demo 手动验证:N/A — no UI change.

Compliance / 合规自检

  • CLA signed — will sign when the CLA bot prompts.
  • AI disclosure: the functional code in this PR is not primarily generated by AI. AI assistance, if any, is described below.
    AI-assisted notes / AI 使用说明:This PR was developed with substantial AI assistance (Claude Code). The direction — headless rendering for SSR/SSG — was chosen by me; the implementation, tests, docs and OpenSpec files were drafted with Claude Code following the repo's OpenSpec + test-first workflow, then verified locally (pnpm typecheck / test / build / check:api, openspec validate --strict). I am leaving the checkbox above unticked because the functional code was largely AI-drafted; disclosing that honestly seemed more useful than ticking the box.
  • New dependencies (if any) listed with license & rationale (none if blank): none — remark-parse, remark-rehype, rehype-stringify, unified are existing core dependencies.
  • No build artifacts committed (dist/, dist-electron/, compiled .js from .ts) / 未提交构建产物
  • No secrets / .env / personal vault data committed / 无敏感信息

Checklist / 自检清单

  • Title follows Conventional Commits / 标题遵循 Conventional Commits
  • Public API changes update package README / types — 改了公共 API 已同步 README 与类型
  • Touched live-preview-table.ts → N/A (not touched)
  • New capability / breaking change → OpenSpec proposal linked / 新 capability 或破坏性变更已附 OpenSpec
  • Change aligns with project scope (GOVERNANCE.md §4) / 改动符合 GOVERNANCE.md §4 的项目范围

Screenshots / Recordings · 截图或录屏 (UI changes)

N/A — no visible UI change. Headless usage:

import { renderMarkdownToHtml } from "@floatboat/nexus-core";
import { createGfmPreset } from "@floatboat/nexus-preset-gfm";

renderMarkdownToHtml("# Hello\n\n| a | b |\n|---|---|\n| 1 | 2 |", { plugins: [createGfmPreset()] });
// => "<h1>Hello</h1>\n<table>…</table>"   (runs in plain Node.js, no DOM)

🤖 Generated with Claude Code

Expose the exportHTML() pipeline as createHtmlRenderer() /
renderMarkdownToHtml() so hosts can render Markdown to HTML in Node.js
(SSR, SSG, CLIs) without a DOM. exportHTML() now delegates to the shared
renderer, built lazily and reused, so browser export and server render
cannot drift.

Also adds SSR contract tests for the react/vue <Editor /> bindings,
README / ROADMAP docs and the OpenSpec change add-headless-html-render.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Sep 20, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

This branch has not been deployed

No deployments
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.

2 participants