diff --git a/README.md b/README.md index e12a11ae..a4971754 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,52 @@ editor.getCoordsAtPos(pos) // { left, right, top, bottom } | null +
+Server-side rendering & SSG — render Markdown to HTML without a DOM + +`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 + +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 */} +
+ {/* interactive once hydrated */} + + + ); +} +``` + +`` from `@floatboat/nexus-react` and `@floatboat/nexus-vue` is SSR-safe: on the server it renders only its container `
`; CodeMirror is created in a client-side effect. + +
+
Plugin authoring — three tiers, one shape diff --git a/README.zh.md b/README.zh.md index 9f749dfc..231ac3f7 100644 --- a/README.zh.md +++ b/README.zh.md @@ -228,6 +228,52 @@ editor.getCoordsAtPos(pos) // { left, right, top, bottom } | null
+
+服务端渲染与 SSG —— 不依赖 DOM 把 Markdown 渲染成 HTML + +`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" 的 封装 + +export default async function NotePage({ params }: { params: { slug: string } }) { + const markdown = await loadNote(params.slug); + const html = renderMarkdownToHtml(markdown, { plugins: [createGfmPreset()] }); + + return ( + <> + {/* 可被抓取,JS 未执行时已可见 */} +
+ {/* 水合后可交互 */} + + + ); +} +``` + +`@floatboat/nexus-react` 与 `@floatboat/nexus-vue` 的 `` 对 SSR 安全:服务端只渲染容器 `
`,CodeMirror 在客户端 effect 中创建。 + +
+
插件编写 —— 三个层级,统一形态 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7c04d0e6..40cb55d4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/ROADMAP.zh.md b/docs/ROADMAP.zh.md index 11d0f65b..3405a13c 100644 --- a/docs/ROADMAP.zh.md +++ b/docs/ROADMAP.zh.md @@ -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. 插件系统 diff --git a/openspec/changes/add-headless-html-render/proposal.md b/openspec/changes/add-headless-html-render/proposal.md new file mode 100644 index 00000000..b8fabf06 --- /dev/null +++ b/openspec/changes/add-headless-html-render/proposal.md @@ -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 `
`; 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 `before

` and `

after

` + +### 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 + +`` 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()` + runs in a Node environment without a DOM +- **THEN** the output SHALL be `
` + +#### 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 `
` diff --git a/openspec/changes/add-headless-html-render/tasks.md b/openspec/changes/add-headless-html-render/tasks.md new file mode 100644 index 00000000..0c79a3fc --- /dev/null +++ b/openspec/changes/add-headless-html-render/tasks.md @@ -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()` yields the container `
` with + pass-through attributes. +- [x] 3.4 `packages/vue/test/editor-ssr.test.ts` (node): + `renderToString(createSSRApp(...))` yields the container `
`. + +## 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. diff --git a/packages/core/README.md b/packages/core/README.md index 267f274e..278247bb 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -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()] }); +// => "

Hello

" + +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. diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts index 4d356ea2..158b290f 100644 --- a/packages/core/src/editor.ts +++ b/packages/core/src/editor.ts @@ -9,9 +9,7 @@ import { indentWithTab, undo as cmUndo, redo as cmRedo } from "@codemirror/comma import { closeBrackets } from "@codemirror/autocomplete"; import type { Root } from "mdast"; import type { Heading } from "mdast"; -import rehypeStringify from "rehype-stringify"; import remarkParse from "remark-parse"; -import remarkRehype from "remark-rehype"; import { unified } from "unified"; import { EventEmitter } from "./event-emitter"; @@ -49,6 +47,7 @@ import type { SetDocumentOptions, TocEntry, } from "./types"; +import { createHtmlRenderer, type HtmlRenderer } from "./render-html"; import { createWidgetExtension } from "./widget-extension"; const FLOATBOAT_MARKDOWN_DEBUG_STORAGE_KEY = "floatboat:markdown-debug"; @@ -139,22 +138,6 @@ function lezerAstFromAnywhere( return lezerStringToMdast(fallbackMarkdown); } -function markdownToHtml( - markdown: string, - plugins: NexusPlugin[], - applyDynamicTransforms: (tree: Root) => Root, -): string { - const processor = unified().use(remarkParse); - for (const plugin of plugins) { - for (const rp of plugin.remarkPlugins ?? []) { - processor.use(rp); - } - } - processor.use(() => (tree) => applyDynamicTransforms(tree as Root)); - processor.use(remarkRehype).use(rehypeStringify); - return String(processor.processSync(markdown)); -} - function extractToc(ast: Root): TocEntry[] { const entries: TocEntry[] = []; for (const node of ast.children) { @@ -245,6 +228,9 @@ function createTransformProcessor(plugins: NexusPlugin[]): { runSync(tree: Root) export function createEditor(config: EditorConfig): EditorAPI { const plugins = config.plugins ?? []; + // Built lazily on first exportHTML() and reused afterwards: hosts that never + // export do not pay for the pipeline, hosts that export often pay once. + let htmlRenderer: HtmlRenderer | null = null; debugNexus("create", { initialLength: (config.initialValue ?? "").length, pluginNames: plugins.map((plugin) => plugin.name), @@ -787,11 +773,13 @@ export function createEditor(config: EditorConfig): EditorAPI { return extractToc(currentAst); }, exportHTML() { - return markdownToHtml( - view.state.doc.toString(), - plugins, - (tree) => applyMarkdownTransformSnapshots(view.state, tree), - ); + if (!htmlRenderer) { + htmlRenderer = createHtmlRenderer({ + plugins, + transform: (tree) => applyMarkdownTransformSnapshots(view.state, tree), + }); + } + return htmlRenderer.render(view.state.doc.toString()); }, setTheme(theme: NexusTheme) { if (destroyed) return; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 11b2a643..47ff20f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,12 @@ export { widgetDefinitionSnapshotExtension, } from "./markdown-contributions"; export { markdownAutoPair } from "./markdown-autopair"; +export { + createHtmlRenderer, + renderMarkdownToHtml, + type HtmlRenderer, + type HtmlRendererOptions, +} from "./render-html"; export { markdownFold, markdownFoldService } from "./markdown-fold"; export { markdownKeymap, handleMarkdownEnter } from "./markdown-keymap"; export { diff --git a/packages/core/src/render-html.ts b/packages/core/src/render-html.ts new file mode 100644 index 00000000..d623caf8 --- /dev/null +++ b/packages/core/src/render-html.ts @@ -0,0 +1,75 @@ +import type { Root } from "mdast"; +import rehypeStringify from "rehype-stringify"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; +import { unified } from "unified"; + +import type { NexusPlugin } from "./types"; + +/** + * Options shared by {@link createHtmlRenderer} and {@link renderMarkdownToHtml}. + */ +export interface HtmlRendererOptions { + /** + * Plugins whose `remarkPlugins` take part in the render pipeline. Pass the + * same list you give `createEditor()` so server-rendered HTML matches what + * `editor.exportHTML()` produces in the browser. + */ + plugins?: NexusPlugin[]; + /** + * Optional mdast transform applied after all remark plugins and before the + * tree is converted to HTML. The editor uses this hook to apply dynamic + * (runtime-contributed) transforms; hosts can use it to inject their own. + */ + transform?: (tree: Root) => Root; +} + +/** + * A reusable Markdown → HTML pipeline. Build it once and call `render()` for + * every document — the unified processor is resolved and frozen a single time, + * which matters when a static-site build renders hundreds of pages. + */ +export interface HtmlRenderer { + render(markdown: string): string; +} + +/** + * Create a DOM-free Markdown → HTML renderer. + * + * It runs the exact pipeline behind `editor.exportHTML()` (remark-parse → + * plugin remark transforms → remark-rehype → rehype-stringify) but does not + * need a CodeMirror view, a container element, or a `document` global, so it + * can be used in Node.js for server-side rendering (SSR), static-site + * generation (SSG), CLIs and build scripts. + * + * Raw HTML nodes in the source are dropped (remark-rehype default), so the + * output is safe to embed without an extra sanitizer. + */ +export function createHtmlRenderer(options: HtmlRendererOptions = {}): HtmlRenderer { + const { plugins = [], transform } = options; + const processor = unified().use(remarkParse); + for (const plugin of plugins) { + for (const remarkPlugin of plugin.remarkPlugins ?? []) { + processor.use(remarkPlugin); + } + } + if (transform) { + processor.use(() => (tree) => transform(tree as Root)); + } + processor.use(remarkRehype).use(rehypeStringify); + processor.freeze(); + + return { + render(markdown) { + return String(processor.processSync(markdown)); + }, + }; +} + +/** + * One-shot convenience wrapper around {@link createHtmlRenderer}. Prefer the + * factory when rendering many documents with the same plugin set. + */ +export function renderMarkdownToHtml(markdown: string, options?: HtmlRendererOptions): string { + return createHtmlRenderer(options).render(markdown); +} diff --git a/packages/core/test/render-html-editor.test.ts b/packages/core/test/render-html-editor.test.ts new file mode 100644 index 00000000..f1f3f872 --- /dev/null +++ b/packages/core/test/render-html-editor.test.ts @@ -0,0 +1,36 @@ +// Default (jsdom) environment: compares the headless renderer with the +// browser-side `editor.exportHTML()` so server and client output never drift. + +import { describe, expect, it } from "vitest"; +import { createGfmPreset } from "@floatboat/nexus-preset-gfm"; +import { createEditor, renderMarkdownToHtml } from "../src/index"; + +describe("renderMarkdownToHtml vs editor.exportHTML()", () => { + it("produces identical HTML for the same document and plugins", () => { + const markdown = [ + "# Title", + "", + "Some *emphasis*, `code` and a [link](https://example.com).", + "", + "| a | b |", + "|---|---|", + "| 1 | 2 |", + "", + "- [ ] todo", + "- [x] done", + ].join("\n"); + const plugins = [createGfmPreset()]; + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: markdown, plugins }); + + try { + const fromEditor = editor.exportHTML(); + const headless = renderMarkdownToHtml(markdown, { plugins }); + + expect(fromEditor).toContain("

"); + expect(headless).toBe(fromEditor); + } finally { + editor.destroy(); + } + }); +}); diff --git a/packages/core/test/render-html.test.ts b/packages/core/test/render-html.test.ts new file mode 100644 index 00000000..4cd63544 --- /dev/null +++ b/packages/core/test/render-html.test.ts @@ -0,0 +1,75 @@ +// @vitest-environment node +// +// Runs WITHOUT jsdom on purpose: this file proves the HTML renderer works in a +// plain Node.js process (SSR / SSG / build scripts), where `document` and +// `window` do not exist. + +import { describe, expect, it } from "vitest"; +import { createGfmPreset } from "@floatboat/nexus-preset-gfm"; +import { createHtmlRenderer, renderMarkdownToHtml } from "../src/index"; + +describe("renderMarkdownToHtml (headless)", () => { + it("runs in an environment without a DOM", () => { + expect(typeof document).toBe("undefined"); + expect(typeof window).toBe("undefined"); + }); + + it("renders basic Markdown to HTML without a DOM", () => { + const html = renderMarkdownToHtml("# Hello\n\nSome **bold** text."); + + expect(html).toContain("

Hello

"); + expect(html).toContain("bold"); + }); + + it("returns an empty string for an empty document", () => { + expect(renderMarkdownToHtml("")).toBe(""); + }); + + it("honours remark plugins contributed by NexusPlugin instances", () => { + const table = "| a | b |\n|---|---|\n| 1 | 2 |"; + + expect(renderMarkdownToHtml(table)).not.toContain("
"); + expect(renderMarkdownToHtml(table, { plugins: [createGfmPreset()] })).toContain("
"); + }); + + it("does not pass raw HTML through by default", () => { + const html = renderMarkdownToHtml("before\n\n\n\nafter"); + + expect(html).not.toContain("before

"); + expect(html).toContain("

after

"); + }); + + it("applies an optional mdast transform before HTML conversion", () => { + const html = renderMarkdownToHtml("# Title", { + transform: (tree) => { + for (const node of tree.children) { + if (node.type === "heading") node.depth = 2; + } + return tree; + }, + }); + + expect(html).toContain("

Title

"); + expect(html).not.toContain("

"); + }); +}); + +describe("createHtmlRenderer", () => { + it("can render many documents with one pipeline", () => { + const renderer = createHtmlRenderer({ plugins: [createGfmPreset()] }); + + expect(renderer.render("~~gone~~")).toContain("gone"); + expect(renderer.render("# Second")).toContain("

Second

"); + expect(renderer.render("# Second")).not.toContain(""); + }); + + it("produces the same output as the one-shot helper", () => { + const markdown = "- [ ] todo\n- [x] done"; + const plugins = [createGfmPreset()]; + + expect(createHtmlRenderer({ plugins }).render(markdown)).toBe( + renderMarkdownToHtml(markdown, { plugins }), + ); + }); +}); diff --git a/packages/react/test/editor-ssr.test.tsx b/packages/react/test/editor-ssr.test.tsx new file mode 100644 index 00000000..a64a09b6 --- /dev/null +++ b/packages/react/test/editor-ssr.test.tsx @@ -0,0 +1,22 @@ +// @vitest-environment node +// +// Guards the SSR contract of `@floatboat/nexus-react`: rendering on +// the server (Next.js, Remix, ...) must produce the container markup without +// touching `document` or `window`. The CodeMirror instance is created only +// inside an effect, which never runs during server rendering. + +import { renderToString } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { Editor } from "../src/index"; + +describe(" server-side rendering", () => { + it("renders the container element without a DOM", () => { + expect(typeof document).toBe("undefined"); + + const html = renderToString( + , + ); + + expect(html).toBe('
'); + }); +}); diff --git a/packages/vue/test/editor-ssr.test.ts b/packages/vue/test/editor-ssr.test.ts new file mode 100644 index 00000000..396c2caa --- /dev/null +++ b/packages/vue/test/editor-ssr.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment node +// +// Guards the SSR contract of `@floatboat/nexus-vue`: rendering on +// the server (Nuxt, ...) must produce the container markup without touching +// `document` or `window`. The CodeMirror instance is created only in +// `onMounted`, which never runs during server rendering. + +import { createSSRApp, h } from "vue"; +import { renderToString } from "vue/server-renderer"; +import { describe, expect, it } from "vitest"; +import { Editor } from "../src/index"; + +describe(" server-side rendering", () => { + it("renders the container element without a DOM", async () => { + expect(typeof document).toBe("undefined"); + + const app = createSSRApp({ + render: () => h(Editor, { initialValue: "# Hello", class: "nexus-editor" }), + }); + const html = await renderToString(app); + + expect(html).toBe('
'); + }); +});