Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,52 @@ editor.getCoordsAtPos(pos) // { left, right, top, bottom } | null

</details>

<details>
<summary><b>Server-side rendering &amp; SSG</b> — render Markdown to HTML without a DOM</summary>

`editor.exportHTML()` needs a mounted editor, i.e. a browser. For SEO-critical pages, static-site builds, CLIs and build scripts, run the same pipeline headless:

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

// One-shot
const html = renderMarkdownToHtml("# Hello", { plugins: [createGfmPreset()] });

// Reusable — build the pipeline once, render many documents (SSG builds)
const renderer = createHtmlRenderer({ plugins: [createGfmPreset()] });
const pages = notes.map((markdown) => renderer.render(markdown));
```

Pass the same `plugins` you give `createEditor()` and the server output is exactly what `exportHTML()` returns in the browser. Raw HTML in the source is dropped (remark-rehype default), so the result is safe to embed.

Next.js App Router example — the page is crawlable HTML on first paint, the editor hydrates on the client:

```tsx
// app/notes/[slug]/page.tsx (Server Component)
import { renderMarkdownToHtml } from "@floatboat/nexus-core";
import { createGfmPreset } from "@floatboat/nexus-preset-gfm";
import { NoteEditor } from "./note-editor"; // "use client" wrapper around <Editor />

export default async function NotePage({ params }: { params: { slug: string } }) {
const markdown = await loadNote(params.slug);
const html = renderMarkdownToHtml(markdown, { plugins: [createGfmPreset()] });

return (
<>
{/* crawlable, visible before any JS runs */}
<article dangerouslySetInnerHTML={{ __html: html }} />
{/* interactive once hydrated */}
<NoteEditor initialValue={markdown} />
</>
);
}
```

`<Editor />` from `@floatboat/nexus-react` and `@floatboat/nexus-vue` is SSR-safe: on the server it renders only its container `<div>`; CodeMirror is created in a client-side effect.

</details>

<details>
<summary><b>Plugin authoring</b> — three tiers, one shape</summary>

Expand Down
46 changes: 46 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,52 @@ editor.getCoordsAtPos(pos) // { left, right, top, bottom } | null

</details>

<details>
<summary><b>服务端渲染与 SSG</b> —— 不依赖 DOM 把 Markdown 渲染成 HTML</summary>

`editor.exportHTML()` 需要一个已挂载的编辑器,也就是需要浏览器。对 SEO 敏感的页面、静态站点构建、CLI 和构建脚本,可以在无 DOM 环境跑同一条管线:

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

// 一次性调用
const html = renderMarkdownToHtml("# Hello", { plugins: [createGfmPreset()] });

// 可复用 —— 管线只构建一次,批量渲染多篇文档(SSG 构建)
const renderer = createHtmlRenderer({ plugins: [createGfmPreset()] });
const pages = notes.map((markdown) => renderer.render(markdown));
```

传入和 `createEditor()` 相同的 `plugins`,服务端输出就和浏览器里 `exportHTML()` 的结果完全一致。源码中的原始 HTML 会被丢弃(remark-rehype 默认行为),结果可以直接嵌入页面。

Next.js App Router 示例 —— 首屏就是可被爬虫抓取的 HTML,编辑器在客户端水合后接管:

```tsx
// app/notes/[slug]/page.tsx(Server Component)
import { renderMarkdownToHtml } from "@floatboat/nexus-core";
import { createGfmPreset } from "@floatboat/nexus-preset-gfm";
import { NoteEditor } from "./note-editor"; // 带 "use client" 的 <Editor /> 封装

export default async function NotePage({ params }: { params: { slug: string } }) {
const markdown = await loadNote(params.slug);
const html = renderMarkdownToHtml(markdown, { plugins: [createGfmPreset()] });

return (
<>
{/* 可被抓取,JS 未执行时已可见 */}
<article dangerouslySetInnerHTML={{ __html: html }} />
{/* 水合后可交互 */}
<NoteEditor initialValue={markdown} />
</>
);
}
```

`@floatboat/nexus-react` 与 `@floatboat/nexus-vue` 的 `<Editor />` 对 SSR 安全:服务端只渲染容器 `<div>`,CodeMirror 在客户端 effect 中创建。

</details>

<details>
<summary><b>插件编写</b> —— 三个层级,统一形态</summary>

Expand Down
1 change: 1 addition & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ This document maps every planned feature to **package ownership / priority / sta
| 6 | Multi-cursor / multi-selection | `core` | P1 | done | Yes | `openspec/changes/add-core-multi-cursor` — opt-in `multiCursor` config; live-preview reveal + table checks verified by regression tests |
| 7 | AST enhancement / Markdown extensions | `core` + `preset-gfm` | P2 | planned | Yes | Affects serialization and every AST-dependent plugin |
| 8 | Undo / redo grouping | `plugin-history` | P1 | planned | No | Coordinate with table's `tableEditingCount`; consolidate competing impls before merge |
| 30 | Headless HTML rendering (SSR / SSG) | `core` | P1 | in-progress | Yes | `createHtmlRenderer()` / `renderMarkdownToHtml()` run the `exportHTML()` pipeline without a DOM; react/vue SSR contract tests — see `openspec/changes/add-headless-html-render` |

## 4. Plugin System

Expand Down
1 change: 1 addition & 0 deletions docs/ROADMAP.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
| 6 | 多光标 / 多选支持 | `core` | P1 | done | 是 | `openspec/changes/add-core-multi-cursor` — opt-in `multiCursor` 配置;live-preview 揭示与表格检查已有回归测试覆盖 |
| 7 | AST 增强 / Markdown 扩展 | `core` + `preset-gfm` | P2 | planned | 是 | 影响序列化与所有依赖 AST 的插件 |
| 8 | undo / redo 分组 | `plugin-history` | P1 | planned | 否 | 注意与表格交互的 `tableEditingCount` 协同;合并前需收敛多个竞品实现 |
| 30 | 无 DOM 的 HTML 渲染(SSR / SSG) | `core` | P1 | in-progress | 是 | `createHtmlRenderer()` / `renderMarkdownToHtml()` 在无 DOM 环境运行 `exportHTML()` 同一条管线;附 react/vue SSR 契约测试 —— 见 `openspec/changes/add-headless-html-render` |

## 4. 插件系统

Expand Down
79 changes: 79 additions & 0 deletions openspec/changes/add-headless-html-render/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Change: Add headless HTML rendering to `@floatboat/nexus-core` (SSR / SSG)

## Why

`EditorAPI.exportHTML()` is the only way to turn a Nexus document into HTML,
and it lives on a mounted editor: it needs a container element, a CodeMirror
`EditorView` and a `document` global. Verified on `main`: importing
`@floatboat/nexus-core` in a plain Node.js process succeeds, but
`createEditor()` throws `ReferenceError: document is not defined`, so there is
no supported way to produce HTML on a server.

That blocks use cases the README explicitly targets ("a docs CMS, a
static-site authoring tool, an LLM-powered writing assistant"):

- **SEO / first paint.** A Next.js / Nuxt / Astro page that shows a Nexus
document must ship the Markdown to the browser and let the editor render it
client-side. Crawlers see an empty `<div>`; 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.** `<Editor />` from
`@floatboat/nexus-react` and `@floatboat/nexus-vue` renders only its
container `<div>` 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 `<Editor />` that shows pre-rendered
HTML until hydration. Hosts can compose this themselves (see the README
example); a built-in prop deserves its own proposal.
Original file line number Diff line number Diff line change
@@ -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 `<h1>Hello</h1>` 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 `<h1>Second</h1>` and SHALL NOT
contain `<del>`

#### 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 `<table>`
- **AND** the same source rendered without plugins SHALL NOT contain `<table>`

### 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 `<h2>Title</h2>` and SHALL NOT contain `<h1>`

### 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<script>alert(1)</script>\n\nafter"` is rendered
- **THEN** the output SHALL NOT contain `<script`
- **AND** SHALL contain `<p>before</p>` and `<p>after</p>`

### Requirement: One-Shot Helper

`@floatboat/nexus-core` SHALL export
`renderMarkdownToHtml(markdown: string, options?: HtmlRendererOptions): string`,
equivalent to `createHtmlRenderer(options).render(markdown)`.

#### Scenario: Equivalence with the factory
- **WHEN** the same markdown and options are rendered via
`renderMarkdownToHtml()` and via `createHtmlRenderer().render()`
- **THEN** both results SHALL be identical strings

### Requirement: Editor Export Uses the Same Pipeline

`EditorAPI.exportHTML()` SHALL produce output identical to
`renderMarkdownToHtml(editor.getDocument(), { plugins })` for the same plugin
list, with the editor's dynamic markdown transform snapshots applied through
the `transform` hook.

#### Scenario: Browser export equals headless render
- **WHEN** an editor is created with a document and
`plugins: [createGfmPreset()]`
- **THEN** `editor.exportHTML()` SHALL equal
`renderMarkdownToHtml(document, { plugins: [createGfmPreset()] })`

### Requirement: Framework Bindings Are SSR-Safe

`<Editor />` from `@floatboat/nexus-react` and `@floatboat/nexus-vue` SHALL
render only its container element when rendered to a string on the server,
and SHALL NOT access `document` or `window` during that render.

#### Scenario: React server render
- **WHEN** `renderToString(<Editor initialValue="# Hello" className="x" />)`
runs in a Node environment without a DOM
- **THEN** the output SHALL be `<div class="x"></div>`

#### Scenario: Vue server render
- **WHEN** `renderToString(createSSRApp({ render: () => h(Editor, { initialValue: "# Hello", class: "x" }) }))`
runs in a Node environment without a DOM
- **THEN** the output SHALL be `<div class="x"></div>`
46 changes: 46 additions & 0 deletions openspec/changes/add-headless-html-render/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Implementation Tasks

## 1. Headless renderer (`packages/core/src/render-html.ts`)

- [x] 1.1 Add `HtmlRendererOptions` (`plugins`, `transform`) and
`HtmlRenderer` (`render`) types.
- [x] 1.2 Implement `createHtmlRenderer()`: remark-parse → each plugin's
`remarkPlugins` in order → optional `transform` → remark-rehype →
rehype-stringify; `freeze()` the processor once at creation.
- [x] 1.3 Implement `renderMarkdownToHtml()` as a one-shot wrapper.
- [x] 1.4 Export both functions and both types from
`packages/core/src/index.ts`.

## 2. Reuse in the editor (`packages/core/src/editor.ts`)

- [x] 2.1 Replace the private `markdownToHtml()` with a lazily-created
`HtmlRenderer` whose `transform` applies
`applyMarkdownTransformSnapshots(view.state, tree)`.
- [x] 2.2 Drop the now-unused `remark-rehype` / `rehype-stringify` imports.

## 3. Tests

- [x] 3.1 `packages/core/test/render-html.test.ts` (`@vitest-environment node`):
`document` / `window` are undefined; basic rendering; empty input; plugin
`remarkPlugins` honoured (GFM table); raw HTML dropped; `transform` hook;
renderer reuse; factory ≡ one-shot helper.
- [x] 3.2 `packages/core/test/render-html-editor.test.ts` (jsdom):
`renderMarkdownToHtml()` output is identical to `editor.exportHTML()` for
the same document and plugins.
- [x] 3.3 `packages/react/test/editor-ssr.test.tsx` (node):
`renderToString(<Editor />)` yields the container `<div>` with
pass-through attributes.
- [x] 3.4 `packages/vue/test/editor-ssr.test.ts` (node):
`renderToString(createSSRApp(...))` yields the container `<div>`.

## 4. Docs

- [x] 4.1 `README.md` / `README.zh.md`: "Server-side rendering & SSG"
section under API Reference with a Next.js App Router example.
- [x] 4.2 `packages/core/README.md`: API section for the two functions.
- [x] 4.3 `docs/ROADMAP.md` / `docs/ROADMAP.zh.md`: new Core Editor row
linking this change.

## 5. Verification

- [x] 5.1 `pnpm typecheck`, `pnpm test` and `pnpm build` pass locally.
25 changes: 25 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,28 @@ Multiple ranges in `setSelections` require `multiCursor: true` — without the f
## Other config highlights

See the `EditorConfig` type for the full surface: `livePreview`, `plugins`, `theme` / `setTheme`, `locale`, `readOnly`, `tabSize`, `direction`, `indentGuides`, `parseDelayMs`, `slashMenuLimit`, `onChange` / `onFocus` / `onBlur` / `onAssetUpload`.

## Headless HTML rendering (SSR / SSG)

`editor.exportHTML()` requires a mounted editor. The same pipeline is available
without a DOM for server-side rendering, static-site builds and scripts:

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

renderMarkdownToHtml("# Hello", { plugins: [createGfmPreset()] });
// => "<h1>Hello</h1>"

const renderer = createHtmlRenderer({ plugins: [createGfmPreset()] }); // build once
renderer.render(markdownA);
renderer.render(markdownB);
```

- `plugins` — the `NexusPlugin[]` you pass to `createEditor()`; only their
`remarkPlugins` participate. Same plugins ⇒ same HTML as `exportHTML()`.
- `transform?: (tree: Root) => Root` — optional mdast hook applied after the
remark plugins, before HTML conversion.
- Raw HTML nodes are dropped (remark-rehype default), so output is safe to embed.
- Live-preview widgets (mermaid, syntax highlighting, KaTeX) are not part of
this pipeline; they render in the browser once the editor hydrates.
Loading