diff --git a/__tests__/unit/node/markdown/markdown.test.ts b/__tests__/unit/node/markdown/markdown.test.ts index 5db12abd2..6475773f5 100644 --- a/__tests__/unit/node/markdown/markdown.test.ts +++ b/__tests__/unit/node/markdown/markdown.test.ts @@ -4,6 +4,7 @@ import { MarkdownItAsync } from 'markdown-it-async' import { createMarkdownRenderer, disposeMdItInstance, + renderMd, type MarkdownOptions } from 'node/markdown/markdown' @@ -203,4 +204,24 @@ describe('node/markdown/markdown', () => { ) } }) + + describe('renderMd', () => { + test('rejects until the renderer exists', async () => { + disposeMdItInstance() + await expect(renderMd('hi')).rejects.toThrow( + 'only available while VitePress is running' + ) + }) + + test('renders with the shared instance', async () => { + disposeMdItInstance() + await createMarkdownRenderer('.', { highlight: (code) => code }) + expect(await renderMd('Hello **world**')).toBe( + '

Hello world

\n' + ) + expect(await renderMd('Hello **world**', { inline: true })).toBe( + 'Hello world' + ) + }) + }) }) diff --git a/docs/config.ts b/docs/config.ts index aa7519830..86a8df7e9 100644 --- a/docs/config.ts +++ b/docs/config.ts @@ -114,6 +114,7 @@ function sidebarReference(): DefaultTheme.SidebarItem[] { { text: 'Frontmatter Config', link: 'frontmatter-config' }, { text: 'Runtime API', link: 'runtime-api' }, { text: 'CLI', link: 'cli' }, + { text: 'Node API', link: 'node-api' }, { text: 'Default Theme', base: '/reference/default-theme-', diff --git a/docs/en/guide/data-loading.md b/docs/en/guide/data-loading.md index 1c11e4698..407887f6d 100644 --- a/docs/en/guide/data-loading.md +++ b/docs/en/guide/data-loading.md @@ -83,6 +83,32 @@ export default { } ``` +## Rendering Markdown + +Loaders run in Node.js, so Markdown they produce - fetched from a CMS, generated by a script or read from a file - can be rendered with the same parser and plugins VitePress uses for pages via [`renderMd`](../reference/node-api#rendermd), and displayed with `v-html`: + +```js +// posts.data.js +import { renderMd } from 'vitepress' + +export default { + async load() { + const posts = await (await fetch('https://cms.example.com/posts')).json() + return Promise.all( + posts.map(async (post) => ({ ...post, html: await renderMd(post.body) })) + ) + } +} +``` + +```md + + +
+``` + ## `createContentLoader` When building a content focused site, we often need to create an "archive" or "index" page: a page where we list all available entries in our content collection, for example blog posts or API pages. We **can** implement this directly with the data loader API, but since this is such a common use case, VitePress also provides a `createContentLoader` helper to simplify this: diff --git a/docs/en/reference/node-api.md b/docs/en/reference/node-api.md new file mode 100644 index 000000000..5d19ef6c9 --- /dev/null +++ b/docs/en/reference/node-api.md @@ -0,0 +1,75 @@ +--- +description: Reference of the VitePress Node API for building, serving and rendering Markdown programmatically. +--- + +# Node API + +Besides the [CLI](./cli), VitePress can be driven from Node.js. The functions below are exported from `vitepress` for build scripts, tests and the Node-side extension points - [build hooks](./site-config#build-hooks) and [data loaders](../guide/data-loading). They are not available in the browser; see the [Runtime API](./runtime-api) for that. + +Other Node exports are documented with the feature they belong to: [`defineConfig`](./site-config#config-intellisense), [`createContentLoader` and `defineLoader`](../guide/data-loading), [`defineRoutes`](../guide/routing#dynamic-routes), [`loadEnv`](../guide/cms) and [`postcssIsolateStyles`](../guide/markdown#raw). + +## `build` + +- Type: `(root?: string, options?: BuildOptions & { base?: string }) => Promise` + +Builds the site, like [`vitepress build`](./cli#vitepress-build). `root` defaults to the current working directory. `options` accepts Vite's [build options](https://vite.dev/config/build-options) (`outDir` is resolved against the current working directory) plus a `base` override. + +```ts +import { build } from 'vitepress' + +await build('docs', { outDir: 'dist' }) +``` + +## `createServer` + +- Type: `(root?: string, options?: ServerOptions & { base?: string }) => Promise` + +Creates the dev server behind [`vitepress dev`](./cli#vitepress-dev) without starting it. `options` accepts Vite's [server options](https://vite.dev/config/server-options) plus a `base` override. + +```ts +import { createServer } from 'vitepress' + +const server = await createServer('docs', { port: 5173 }) +await server.listen() +// ... +await server.close() +``` + +## `serve` + +- Type: `(options?: { root?: string; base?: string; port?: number }) => Promise` + +Serves a built site, like [`vitepress preview`](./cli#vitepress-preview). It resolves once the server is listening (on port `4173` by default) with the underlying [Polka](https://github.com/lukeed/polka) app. + +```ts +import { serve } from 'vitepress' + +const app = await serve({ root: 'docs', port: 4173 }) +// ... +app.server.close() +``` + +## `renderMd` + +- Type: `(src: string, options?: RenderMdOptions) => Promise` + +Renders Markdown to HTML with the `markdown-it` instance VitePress uses for pages, so every [Markdown extension](../guide/markdown) and [`markdown`](./site-config#markdown) config option applies. It is available once VitePress is running - in [build hooks](./site-config#build-hooks), [`transformPageData`](./site-config#transformpagedata) and [data loaders](../guide/data-loading#rendering-markdown) - and rejects when called earlier, for example while the config file is evaluated. + +```ts +import { renderMd } from 'vitepress' + +const html = await renderMd('Hello **world**') +// '

Hello world

\n' + +const inline = await renderMd('Hello **world**', { inline: true }) +// 'Hello world' +``` + +```ts +interface RenderMdOptions extends Partial { + // render without the wrapping paragraph, like markdown-it's `renderInline` + inline?: boolean +} +``` + +The other options are the `MarkdownEnv` fields the renderer reads, mainly `path` - the absolute path of the file the Markdown belongs to, used to resolve relative [includes](../guide/markdown#markdown-file-inclusion) and [snippets](../guide/markdown#import-code-snippets) - and `cleanUrls`, which defaults to the [site config](./site-config#cleanurls). The result is plain HTML for `v-html`: Vue components and expressions in it are not processed. diff --git a/docs/en/reference/site-config.md b/docs/en/reference/site-config.md index d79eb6378..0d3563ccf 100644 --- a/docs/en/reference/site-config.md +++ b/docs/en/reference/site-config.md @@ -784,3 +784,27 @@ export default { } } ``` + +#### Example: Rendering Markdown in frontmatter + +Frontmatter values are plain strings. To write Markdown in them, render the value with [`renderMd`](./node-api#rendermd) and display the result with `v-html`: + +```ts +import { renderMd } from 'vitepress' + +export default { + async transformPageData(pageData) { + const { intro } = pageData.frontmatter + if (intro) pageData.frontmatter.introHtml = await renderMd(intro) + } +} +``` + +```md +--- +intro: | + Welcome to **our** docs. +--- + +
+``` diff --git a/src/node/cli.ts b/src/node/cli.ts index 76da98c4e..59f901e3d 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -1,14 +1,9 @@ import minimist from 'minimist' import c from 'picocolors' import { createLogger } from 'vite' -import { - build, - createServer, - disposeMdItInstance, - resolveConfig, - serve -} from '.' +import { build, createServer, resolveConfig, serve } from '.' import { init } from './init/init' +import { disposeMdItInstance } from './markdown/markdown' import { clearCache } from './markdownToVue' import { bindShortcuts } from './shortcuts' import { logVersion } from './utils/logVersion' diff --git a/src/node/index.ts b/src/node/index.ts index a86f49b20..80a5b9160 100644 --- a/src/node/index.ts +++ b/src/node/index.ts @@ -4,7 +4,13 @@ export * from './config' export * from './contentLoader' export type { DefaultTheme } from './defaultTheme' export * from './init/init' -export * from './markdown/markdown' +export { + renderMd, + type MarkdownOptions, + type MarkdownRenderer, + type RenderMdOptions, + type ThemeOptions +} from './markdown/markdown' export { defineRoutes, type ResolvedRouteConfig, @@ -20,6 +26,7 @@ export * from './utils/getGitTimestamp' export type { HeadConfig, Header, + MarkdownEnv, MarkdownLocaleOptions, SiteData } from '../../types/shared' diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index a5f2e8ae1..5b18d1a8b 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -42,6 +42,7 @@ import type { Awaitable, CodeCopyButtonOptions, LocaleConfig, + MarkdownEnv, MarkdownLocaleOptions } from '../shared' import { @@ -109,8 +110,7 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions { /** * Per-locale overrides for build-time markdown strings (container titles * and the code copy button title), keyed by locale index. Populated - * automatically from `locales..markdown` in the site config - pass - * directly only when using `createMarkdownRenderer` standalone. + * automatically from `locales..markdown` in the site config. */ locales?: Record @@ -358,9 +358,33 @@ export function disposeMdItInstance() { } } +export interface RenderMdOptions extends Partial { + /** + * Render without the wrapping paragraph, like markdown-it's `renderInline`. + */ + inline?: boolean +} + /** - * @experimental + * Renders markdown with the site's markdown-it instance. Available once + * VitePress is running - in build hooks, `transformPageData`, data loaders + * and content loaders - and rejects otherwise. */ +export async function renderMd( + src: string, + { inline, ...env }: RenderMdOptions = {} +): Promise { + if (!md) { + throw new Error( + 'renderMd() is only available while VitePress is running, e.g. inside build hooks or data loaders.' + ) + } + env.cleanUrls ??= (global as any).VITEPRESS_CONFIG?.cleanUrls ?? false + return inline ? md.renderInline(src, env) : md.renderAsync(src, env) +} + +// shared by everything that renders markdown in a run (pages, the search +// index, content loaders and `renderMd()`), so the first caller's options win export async function createMarkdownRenderer( srcDir: string, options: MarkdownOptions = {},