@draftbase/renderer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +221 -0
- package/dist/MDXContent.d.ts +40 -0
- package/dist/MDXContent.js +31 -0
- package/dist/compileMDX.test.d.ts +1 -0
- package/dist/compileMDX.test.js +24 -0
- package/dist/core.d.ts +16 -0
- package/dist/core.js +23 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/styles.css +208 -0
- package/dist/toHtml.d.ts +7 -0
- package/dist/toHtml.js +22 -0
- package/dist/toHtml.test.d.ts +1 -0
- package/dist/toHtml.test.js +12 -0
- package/dist/vue.d.ts +13 -0
- package/dist/vue.js +10 -0
- package/dist/vueRuntime.d.ts +3 -0
- package/dist/vueRuntime.js +13 -0
- package/dist/wrapperClassName.d.ts +1 -0
- package/dist/wrapperClassName.js +3 -0
- package/dist/wrapperClassName.test.d.ts +1 -0
- package/dist/wrapperClassName.test.js +15 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Draftbase
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# @draftbase/renderer
|
|
2
|
+
|
|
3
|
+
Framework-agnostic MDX renderer for [Draftbase](https://draftbase.co), the MDX-based headless CMS for React developers. Takes an entry's MDX/markdown field and renders it into a real component tree (React or Vue) or a plain HTML string — no vendor lock-in to one frontend framework. `compileMDX` is a plain async function with no dependency on Next.js, a bundler, or a router.
|
|
4
|
+
|
|
5
|
+
Every framework entry point exposes the **same API shape** — `compileMDX(source)` resolving to `{ ok: true, Content }` or `{ ok: false, error }` — so switching frameworks (or supporting several in one monorepo) means changing the import path, not the calling code:
|
|
6
|
+
|
|
7
|
+
| Framework | Import | `Content` is a... |
|
|
8
|
+
| ------------------------------------------------ | ----------------------------------------- | ------------------------------------------- |
|
|
9
|
+
| React, Next.js, React Native, Remix, Astro, Vite | `@draftbase/renderer` | React component |
|
|
10
|
+
| Vue | `@draftbase/renderer/vue` | Vue component |
|
|
11
|
+
| Anything else (Svelte, plain HTML, email, RSS) | `toHtml` (below), from either entry point | — (returns an HTML string, not a component) |
|
|
12
|
+
|
|
13
|
+
Only import the entry point for the framework you use — each pulls in just that framework's peer dependency (React or Vue), never both, so an app using one never bundles code for the other.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @draftbase/renderer
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Next.js App Router
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { MDXContent } from "@draftbase/renderer";
|
|
25
|
+
import "@draftbase/renderer/styles.css"; // optional slim default styling
|
|
26
|
+
|
|
27
|
+
export default async function Page() {
|
|
28
|
+
const entry = await draftbase.getEntry<{ body: string }>("<entry id>");
|
|
29
|
+
|
|
30
|
+
return <MDXContent source={entry.fields.body} />;
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`MDXContent` is an `async` Server Component — it only works where React can await inside a component body (Next.js RSC).
|
|
35
|
+
|
|
36
|
+
## Other React (client-side web, React Native, Remix, ...)
|
|
37
|
+
|
|
38
|
+
Call `compileMDX` yourself from a loader/effect and render the result — no RSC required:
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { useEffect, useState } from "react";
|
|
42
|
+
import { compileMDX } from "@draftbase/renderer";
|
|
43
|
+
import { View, Text } from "react-native";
|
|
44
|
+
|
|
45
|
+
function Entry({ source }: { source: string }) {
|
|
46
|
+
const [compiled, setCompiled] = useState<Awaited<ReturnType<typeof compileMDX>>>();
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
compileMDX(source).then(setCompiled);
|
|
50
|
+
}, [source]);
|
|
51
|
+
|
|
52
|
+
if (!compiled) return null;
|
|
53
|
+
if (!compiled.ok) return <Text>{source}</Text>;
|
|
54
|
+
|
|
55
|
+
const { Content } = compiled;
|
|
56
|
+
return (
|
|
57
|
+
<View>
|
|
58
|
+
<Content components={{ p: Text, h1: Text /* ... */ }} />
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Extended markdown (tables, strikethrough, task lists, autolinks) is supported out of the box via `remark-gfm`.
|
|
65
|
+
|
|
66
|
+
## Astro
|
|
67
|
+
|
|
68
|
+
Astro components aren't React, so render through a React island. Compile server-side in the `.astro` frontmatter (Astro's runtime does allow `await` there), then hand the compiled `Content` to a small client React wrapper component:
|
|
69
|
+
|
|
70
|
+
```astro
|
|
71
|
+
---
|
|
72
|
+
import { compileMDX } from "@draftbase/renderer";
|
|
73
|
+
import MDXIsland from "../components/MDXIsland"; // the client wrapper below
|
|
74
|
+
|
|
75
|
+
const compiled = await compileMDX(entry.fields.body);
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
<MDXIsland client:load compiled={compiled} />
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
// src/components/MDXIsland.tsx
|
|
83
|
+
import type { CompiledMDX, FailedMDX } from "@draftbase/renderer";
|
|
84
|
+
|
|
85
|
+
export default function MDXIsland({ compiled }: { compiled: CompiledMDX | FailedMDX }) {
|
|
86
|
+
if (!compiled.ok) return <p>{/* fallback text */}</p>;
|
|
87
|
+
const { Content } = compiled;
|
|
88
|
+
return <Content />;
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Vite + React
|
|
93
|
+
|
|
94
|
+
No RSC in a Vite SPA — use the `compileMDX`-in-`useEffect` pattern from the section above.
|
|
95
|
+
|
|
96
|
+
## Vue
|
|
97
|
+
|
|
98
|
+
`compileMDX` from the `/vue` entry point returns a Vue component instead of a React one — same `{ ok, Content }` / `{ ok, error }` shape. Vue has no RSC-style async component, so call it from `setup()`/a composable and render the result yourself, the same pattern as client-side React above:
|
|
99
|
+
|
|
100
|
+
```vue
|
|
101
|
+
<script setup>
|
|
102
|
+
import { ref, onMounted } from "vue";
|
|
103
|
+
import { compileMDX } from "@draftbase/renderer/vue";
|
|
104
|
+
|
|
105
|
+
const props = defineProps<{ source: string }>();
|
|
106
|
+
const compiled = ref();
|
|
107
|
+
|
|
108
|
+
onMounted(async () => {
|
|
109
|
+
compiled.value = await compileMDX(props.source);
|
|
110
|
+
});
|
|
111
|
+
</script>
|
|
112
|
+
|
|
113
|
+
<template>
|
|
114
|
+
<component :is="compiled.Content" v-if="compiled?.ok" />
|
|
115
|
+
<p v-else-if="compiled">{{ props.source }}</p>
|
|
116
|
+
</template>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Custom components and markdown-element overrides pass through the same way, as a `components` prop on `Content`.
|
|
120
|
+
|
|
121
|
+
## Nuxt
|
|
122
|
+
|
|
123
|
+
Nuxt is Vue, so use the `/vue` entry point — same composable pattern as plain Vue above, from a Nuxt page/component:
|
|
124
|
+
|
|
125
|
+
```vue
|
|
126
|
+
<script setup>
|
|
127
|
+
import { compileMDX } from "@draftbase/renderer/vue";
|
|
128
|
+
|
|
129
|
+
const { data: entry } = await useAsyncData("entry", () => $fetch(`/api/blog/${route.params.slug}`));
|
|
130
|
+
const compiled = ref();
|
|
131
|
+
onMounted(async () => {
|
|
132
|
+
compiled.value = await compileMDX(entry.value.fields.body);
|
|
133
|
+
});
|
|
134
|
+
</script>
|
|
135
|
+
|
|
136
|
+
<template>
|
|
137
|
+
<component :is="compiled.Content" v-if="compiled?.ok" />
|
|
138
|
+
</template>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## SvelteKit, Angular, Solid, and other non-React/Vue frameworks
|
|
142
|
+
|
|
143
|
+
This package ships React and Vue component output only. For any other framework, use `toHtml` (below) to get a plain HTML string and render it with each framework's raw-HTML primitive (Svelte's `{@html ...}`, Angular's `[innerHTML]`, Solid's `innerHTML` prop) — same as the Static HTML section:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
// SvelteKit +page.server.ts
|
|
147
|
+
import { toHtml } from "@draftbase/renderer";
|
|
148
|
+
export async function load({ params }) {
|
|
149
|
+
const entry = await draftbase.getEntry(params.slug);
|
|
150
|
+
return { html: await toHtml(entry.fields.body) };
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```svelte
|
|
155
|
+
<!-- +page.svelte -->
|
|
156
|
+
<script>export let data;</script>
|
|
157
|
+
{@html data.html}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`rehype-slug` still adds heading `id`s for anchor links even in the HTML-string path — sanitize/escape user-controlled content upstream as you would with any `{@html}`/`innerHTML` usage.
|
|
161
|
+
|
|
162
|
+
## Static HTML
|
|
163
|
+
|
|
164
|
+
For contexts that need a plain HTML string instead of a mounted React tree (email, RSS, non-React embeds), use `toHtml` — headings get `id` slugs (via `rehype-slug`) for anchor links:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { toHtml } from "@draftbase/renderer";
|
|
168
|
+
|
|
169
|
+
const html = await toHtml(entry.fields.body);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Custom components
|
|
173
|
+
|
|
174
|
+
Content can invoke JSX components by name inside the MDX source (e.g. `<Callout type="warning">...</Callout>`). Pass the implementations:
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
import { Callout, ImageBlock } from "@/components/content";
|
|
178
|
+
|
|
179
|
+
<MDXContent source={entry.fields.body} components={{ Callout, ImageBlock }} />;
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Any standard markdown element (`h1`, `table`, `a`, ...) can also be overridden the same way, by key — **required** on non-DOM renderers like React Native, which have no intrinsic `div`/`p`/`a`/`img` tags.
|
|
183
|
+
|
|
184
|
+
## Styling
|
|
185
|
+
|
|
186
|
+
`@draftbase/renderer/styles.css` wraps output in a `.db-content` class with slim, sensible defaults (typography, tables, code blocks). Web-only, opt-in:
|
|
187
|
+
|
|
188
|
+
- Skip it entirely: don't import the CSS.
|
|
189
|
+
- Disable per-render: `<MDXContent source={...} unstyled />`.
|
|
190
|
+
- Override: import your own CSS after it (or with higher specificity) targeting `.db-content`.
|
|
191
|
+
- Extra classes: `<MDXContent source={...} className="prose" />`.
|
|
192
|
+
- Custom wrapper/error element (e.g. React Native's `View`/`Text`): `<MDXContent source={...} wrapperTag={View} errorTag={Text} />`.
|
|
193
|
+
|
|
194
|
+
## Using with Claude Code / AI coding agents
|
|
195
|
+
|
|
196
|
+
If you're an agent wiring this into a project, follow this checklist:
|
|
197
|
+
|
|
198
|
+
1. **Install**: `pnpm add @draftbase/renderer` (or `npm`/`yarn` — detect the project's package manager first).
|
|
199
|
+
2. **Pick the right entry point first** — check the target framework in the table at the top of this file, then import only that one (`@draftbase/renderer` vs `@draftbase/renderer/vue`). Importing the wrong one pulls in a peer dependency (React or Vue) the project may not have installed, and will fail to resolve.
|
|
200
|
+
3. **Detect RSC support before choosing a pattern**: Next.js App Router (or another RSC framework) → use `<MDXContent source={...} />` directly, it's an async Server Component. Everything else (client-side React, React Native, Remix, Vite SPA, Vue) → use the `compileMDX(source)` + state/ref pattern shown above; do not try to `await` it inside a plain client component render.
|
|
201
|
+
4. **Every `compileMDX`/`MDXContent` result is a discriminated union** — always branch on `.ok` before touching `.Content`; treat `!ok` as a real render path (show `.error` or fallback text), don't just assume success.
|
|
202
|
+
5. **Non-DOM renderers (React Native, custom email/RSS pipelines) have no intrinsic HTML tags** — you must pass a `components` map covering every markdown element actually used in the source (`p`, `h1`-`h6`, `a`, `img`, `table`, ...), or those elements will fail to render. For plain HTML output (email, RSS, non-React embeds) use `toHtml` instead of a component tree.
|
|
203
|
+
6. **Don't hand-roll styling** — import `@draftbase/renderer/styles.css` for sensible defaults, or pass `unstyled`/`className` on `MDXContent`, rather than writing new prose/typography CSS from scratch.
|
|
204
|
+
7. **This package ships zero bundled React/Vue** — both are optional peer dependencies. If the target project doesn't already have the matching framework installed, install it too or the build will fail.
|
|
205
|
+
|
|
206
|
+
## FAQ
|
|
207
|
+
|
|
208
|
+
**What is Draftbase?**
|
|
209
|
+
Draftbase is a lightweight, MDX-based headless CMS built for React and Next.js developers. Content is authored as MDX/markdown with typed fields, fetched via [`@draftbase/sdk`](https://www.npmjs.com/package/@draftbase/sdk), and rendered into real components with this package.
|
|
210
|
+
|
|
211
|
+
**Why MDX instead of plain markdown or a block-based rich-text editor?**
|
|
212
|
+
MDX lets authors drop live, typed React/Vue components (callouts, embeds, product cards) directly inside prose, while still compiling down to plain HTML for frameworks that don't run JSX. Plain markdown can't embed components; block editors trade that flexibility for a rigid, CMS-specific JSON schema.
|
|
213
|
+
|
|
214
|
+
**Does this work with static site generators (SSG) as well as SSR?**
|
|
215
|
+
Yes — `compileMDX`/`MDXContent` are plain async functions with no request-scoped state, so they run identically at build time (Next.js `generateStaticParams`, Astro static output, Nuxt `nitro` prerender) or at request time (SSR/RSC).
|
|
216
|
+
|
|
217
|
+
**Is the compiled output safe for SEO?**
|
|
218
|
+
Yes — `compileMDX`/`toHtml` produce standard semantic HTML (headings, lists, tables, links) server-side, so it's fully crawlable and indexable with no client-side rendering required; `rehype-slug` also adds heading `id`s for deep-linkable anchor URLs.
|
|
219
|
+
|
|
220
|
+
**Which frontend frameworks are supported?**
|
|
221
|
+
React (Next.js App Router/RSC, plain client React, React Native, Remix, Astro islands, Vite) and Vue (including Nuxt) get first-class component output. Any other framework (Svelte, Angular, Solid, plain HTML/email/RSS) can use `toHtml` to get a plain HTML string instead.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { MDXComponents } from "mdx/types";
|
|
2
|
+
import type { ComponentType, ElementType, ReactNode } from "react";
|
|
3
|
+
import { type FailedMDX } from "./core.js";
|
|
4
|
+
export interface CompiledMDX {
|
|
5
|
+
ok: true;
|
|
6
|
+
Content: ComponentType<{
|
|
7
|
+
components?: MDXComponents;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
export type { FailedMDX };
|
|
11
|
+
/**
|
|
12
|
+
* Compiles raw MDX/markdown into a renderable React component — no DOM assumptions,
|
|
13
|
+
* safe to call from a React Native loader, a client-side effect, or a Next.js Server
|
|
14
|
+
* Component (the `MDXContent` component below does the latter).
|
|
15
|
+
*/
|
|
16
|
+
export declare function compileMDX(source: string): Promise<CompiledMDX | FailedMDX>;
|
|
17
|
+
export interface MDXContentProps {
|
|
18
|
+
/** Raw MDX/markdown string, e.g. an entry's rich text field. */
|
|
19
|
+
source: string;
|
|
20
|
+
/** Custom components available by name inside the MDX source (e.g. `<Callout>`), and/or
|
|
21
|
+
* overrides for standard markdown elements (`p`, `h1`, `a`, `img`, ...) — required on
|
|
22
|
+
* non-DOM renderers such as React Native, which have no intrinsic `div`/`p`/`a` tags. */
|
|
23
|
+
components?: MDXComponents;
|
|
24
|
+
/** Skip the default `db-content` styling class. */
|
|
25
|
+
unstyled?: boolean;
|
|
26
|
+
/** Extra class name(s) merged onto the wrapper element. */
|
|
27
|
+
className?: string;
|
|
28
|
+
/** Wrapper element/component. Defaults to `"div"`; pass React Native's `View` if it accepts
|
|
29
|
+
* `className` (e.g. NativeWind), otherwise drop `className`/`unstyled` and style via `components`. */
|
|
30
|
+
wrapperTag?: ElementType;
|
|
31
|
+
/** Element/component used to render the fallback plain-text output when `source` fails to
|
|
32
|
+
* compile as MDX. Defaults to `"p"`; pass React Native's `Text`. */
|
|
33
|
+
errorTag?: ElementType;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Next.js Server Component wrapper around {@link compileMDX}. For non-RSC React (client-side
|
|
37
|
+
* web, React Native, Remix, ...), call `compileMDX` directly from your own data loader/effect
|
|
38
|
+
* and render `Content` yourself — `await` inside a component body only works as an RSC.
|
|
39
|
+
*/
|
|
40
|
+
export declare function MDXContent({ source, components, unstyled, className, wrapperTag, errorTag, }: MDXContentProps): Promise<ReactNode>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import * as runtime from "react/jsx-runtime";
|
|
3
|
+
import { compileMDXCore } from "./core.js";
|
|
4
|
+
import { wrapperClassName } from "./wrapperClassName.js";
|
|
5
|
+
/**
|
|
6
|
+
* Compiles raw MDX/markdown into a renderable React component — no DOM assumptions,
|
|
7
|
+
* safe to call from a React Native loader, a client-side effect, or a Next.js Server
|
|
8
|
+
* Component (the `MDXContent` component below does the latter).
|
|
9
|
+
*/
|
|
10
|
+
export async function compileMDX(source) {
|
|
11
|
+
return compileMDXCore(source, runtime);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Next.js Server Component wrapper around {@link compileMDX}. For non-RSC React (client-side
|
|
15
|
+
* web, React Native, Remix, ...), call `compileMDX` directly from your own data loader/effect
|
|
16
|
+
* and render `Content` yourself — `await` inside a component body only works as an RSC.
|
|
17
|
+
*/
|
|
18
|
+
export async function MDXContent({ source, components, unstyled, className, wrapperTag = "div", errorTag = "p", }) {
|
|
19
|
+
const Wrapper = wrapperTag;
|
|
20
|
+
const ErrorTag = errorTag;
|
|
21
|
+
const wrapperClass = wrapperClassName(unstyled, className);
|
|
22
|
+
const compiled = await compileMDX(source);
|
|
23
|
+
if (!compiled.ok) {
|
|
24
|
+
// Source isn't valid MDX/JSX (e.g. stray `<`/`{` in prose) — fail soft instead
|
|
25
|
+
// of crashing the page; render it as plain text so the copy still shows.
|
|
26
|
+
console.error("MDX compile failed, rendering as plain text", compiled.error);
|
|
27
|
+
return (_jsx(Wrapper, { className: wrapperClass, children: _jsx(ErrorTag, { children: source }) }));
|
|
28
|
+
}
|
|
29
|
+
const { Content } = compiled;
|
|
30
|
+
return (_jsx(Wrapper, { className: wrapperClass, children: _jsx(Content, { components: components }) }));
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { compileMDX as compileReactMDX } from "./MDXContent.js";
|
|
4
|
+
import { compileMDX as compileVueMDX } from "./vue.js";
|
|
5
|
+
const SOURCE = "# Hello\n\nWorld";
|
|
6
|
+
test("react compileMDX evaluates MDX into a React element tree", async () => {
|
|
7
|
+
const result = await compileReactMDX(SOURCE);
|
|
8
|
+
assert.equal(result.ok, true);
|
|
9
|
+
if (!result.ok)
|
|
10
|
+
return;
|
|
11
|
+
assert.equal(typeof result.Content, "function");
|
|
12
|
+
const render = result.Content;
|
|
13
|
+
const element = render({});
|
|
14
|
+
assert.equal(element.props.children[0].type, "h1");
|
|
15
|
+
});
|
|
16
|
+
test("vue compileMDX evaluates the same MDX source into a Vue vnode tree", async () => {
|
|
17
|
+
const result = await compileVueMDX(SOURCE);
|
|
18
|
+
assert.equal(result.ok, true);
|
|
19
|
+
if (!result.ok)
|
|
20
|
+
return;
|
|
21
|
+
assert.equal(typeof result.Content, "function");
|
|
22
|
+
const vnode = result.Content({});
|
|
23
|
+
assert.equal(vnode.children[0].type, "h1");
|
|
24
|
+
});
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { EvaluateOptions } from "@mdx-js/mdx";
|
|
2
|
+
export type JsxRuntime = Pick<EvaluateOptions, "Fragment" | "jsx" | "jsxs" | "jsxDEV">;
|
|
3
|
+
export interface CompiledMDX<TComponent> {
|
|
4
|
+
ok: true;
|
|
5
|
+
Content: TComponent;
|
|
6
|
+
}
|
|
7
|
+
export interface FailedMDX {
|
|
8
|
+
ok: false;
|
|
9
|
+
error: unknown;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Shared MDX-to-component evaluation. Parameterized by JSX runtime (React's
|
|
13
|
+
* `react/jsx-runtime`, a Vue `h()`-based shim, ...) so each framework entry point
|
|
14
|
+
* supplies its own without duplicating the remark/rehype pipeline.
|
|
15
|
+
*/
|
|
16
|
+
export declare function compileMDXCore<TComponent>(source: string, jsxRuntime: JsxRuntime): Promise<CompiledMDX<TComponent> | FailedMDX>;
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { evaluate } from "@mdx-js/mdx";
|
|
2
|
+
import remarkGfm from "remark-gfm";
|
|
3
|
+
import rehypeSlug from "rehype-slug";
|
|
4
|
+
/**
|
|
5
|
+
* Shared MDX-to-component evaluation. Parameterized by JSX runtime (React's
|
|
6
|
+
* `react/jsx-runtime`, a Vue `h()`-based shim, ...) so each framework entry point
|
|
7
|
+
* supplies its own without duplicating the remark/rehype pipeline.
|
|
8
|
+
*/
|
|
9
|
+
export async function compileMDXCore(source, jsxRuntime) {
|
|
10
|
+
try {
|
|
11
|
+
const { default: Content } = await evaluate(source, {
|
|
12
|
+
...jsxRuntime,
|
|
13
|
+
remarkPlugins: [remarkGfm],
|
|
14
|
+
rehypePlugins: [rehypeSlug],
|
|
15
|
+
});
|
|
16
|
+
// evaluate()'s return type assumes React's JSX types regardless of the runtime
|
|
17
|
+
// passed in; TComponent reflects the actual shape for the calling framework.
|
|
18
|
+
return { ok: true, Content: Content };
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
return { ok: false, error };
|
|
22
|
+
}
|
|
23
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/styles.css
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/* Slim default typography for Draftbase MDX content.
|
|
2
|
+
Opt-in: import "@draftbase/react/styles.css".
|
|
3
|
+
Disable per-render: <MDXContent unstyled />.
|
|
4
|
+
Override: target ".db-content" with higher-specificity or later-loaded rules. */
|
|
5
|
+
|
|
6
|
+
.db-content {
|
|
7
|
+
line-height: 1.6;
|
|
8
|
+
color: inherit;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.db-content > :first-child {
|
|
12
|
+
margin-top: 0;
|
|
13
|
+
}
|
|
14
|
+
.db-content > :last-child {
|
|
15
|
+
margin-bottom: 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.db-content h1,
|
|
19
|
+
.db-content h2,
|
|
20
|
+
.db-content h3,
|
|
21
|
+
.db-content h4,
|
|
22
|
+
.db-content h5,
|
|
23
|
+
.db-content h6 {
|
|
24
|
+
font-weight: 600;
|
|
25
|
+
line-height: 1.25;
|
|
26
|
+
margin: 1.5em 0 0.5em;
|
|
27
|
+
color: inherit;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.db-content h1 {
|
|
31
|
+
font-size: 2em;
|
|
32
|
+
font-weight: 700;
|
|
33
|
+
line-height: 1.2;
|
|
34
|
+
letter-spacing: -0.025em;
|
|
35
|
+
margin-top: 1.25em;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.db-content h2 {
|
|
39
|
+
font-size: 1.625em;
|
|
40
|
+
line-height: 1.25;
|
|
41
|
+
letter-spacing: -0.02em;
|
|
42
|
+
margin-top: 1.75em;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.db-content h3 {
|
|
46
|
+
font-size: 1.375em;
|
|
47
|
+
line-height: 1.3;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.db-content h4 {
|
|
51
|
+
font-size: 1.25em;
|
|
52
|
+
line-height: 1.35;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.db-content h5 {
|
|
56
|
+
font-size: 1.125em;
|
|
57
|
+
line-height: 1.4;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.db-content h6 {
|
|
61
|
+
font-size: 1em;
|
|
62
|
+
line-height: 1.4;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@media (max-width: 640px) {
|
|
66
|
+
.db-content h1 {
|
|
67
|
+
font-size: 1.75em;
|
|
68
|
+
}
|
|
69
|
+
.db-content h2 {
|
|
70
|
+
font-size: 1.4em;
|
|
71
|
+
}
|
|
72
|
+
.db-content h3 {
|
|
73
|
+
font-size: 1.2em;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.db-content p,
|
|
78
|
+
.db-content ul,
|
|
79
|
+
.db-content ol,
|
|
80
|
+
.db-content blockquote,
|
|
81
|
+
.db-content pre,
|
|
82
|
+
.db-content table {
|
|
83
|
+
margin: 0 0 1em;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.db-content ul,
|
|
87
|
+
.db-content ol {
|
|
88
|
+
padding-left: 1.5em;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.db-content ul {
|
|
92
|
+
list-style-type: disc;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.db-content ol {
|
|
96
|
+
list-style-type: decimal;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.db-content ul ul {
|
|
100
|
+
list-style-type: circle;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.db-content ol ol {
|
|
104
|
+
list-style-type: lower-alpha;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.db-content ul ul ul,
|
|
108
|
+
.db-content ol ol ol {
|
|
109
|
+
list-style-type: square;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.db-content li {
|
|
113
|
+
margin: 0.25em 0;
|
|
114
|
+
}
|
|
115
|
+
.db-content li > ul,
|
|
116
|
+
.db-content li > ol {
|
|
117
|
+
margin: 0.25em 0 0;
|
|
118
|
+
}
|
|
119
|
+
.db-content li > p {
|
|
120
|
+
margin: 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.db-content ul.contains-task-list {
|
|
124
|
+
list-style: none;
|
|
125
|
+
padding-left: 0.25em;
|
|
126
|
+
}
|
|
127
|
+
.db-content li.task-list-item {
|
|
128
|
+
list-style: none;
|
|
129
|
+
margin-left: 0;
|
|
130
|
+
}
|
|
131
|
+
.db-content li.task-list-item input[type="checkbox"] {
|
|
132
|
+
margin-right: 0.5em;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.db-content strong {
|
|
136
|
+
font-weight: 600;
|
|
137
|
+
}
|
|
138
|
+
.db-content em {
|
|
139
|
+
font-style: italic;
|
|
140
|
+
}
|
|
141
|
+
.db-content del {
|
|
142
|
+
text-decoration: line-through;
|
|
143
|
+
opacity: 0.75;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.db-content blockquote {
|
|
147
|
+
margin-left: 0;
|
|
148
|
+
padding-left: 1em;
|
|
149
|
+
border-left: 3px solid currentColor;
|
|
150
|
+
opacity: 0.85;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.db-content code {
|
|
154
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
155
|
+
font-size: 0.9em;
|
|
156
|
+
padding: 0.15em 0.35em;
|
|
157
|
+
background: rgba(127, 127, 127, 0.15);
|
|
158
|
+
border-radius: 4px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.db-content pre {
|
|
162
|
+
padding: 1em;
|
|
163
|
+
overflow-x: auto;
|
|
164
|
+
background: rgba(127, 127, 127, 0.1);
|
|
165
|
+
border-radius: 8px;
|
|
166
|
+
}
|
|
167
|
+
.db-content pre code {
|
|
168
|
+
padding: 0;
|
|
169
|
+
background: none;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.db-content table {
|
|
173
|
+
border-collapse: collapse;
|
|
174
|
+
width: 100%;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.db-content th,
|
|
178
|
+
.db-content td {
|
|
179
|
+
border: 1px solid rgba(127, 127, 127, 0.3);
|
|
180
|
+
padding: 0.5em 0.75em;
|
|
181
|
+
text-align: left;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.db-content th {
|
|
185
|
+
background: rgba(127, 127, 127, 0.08);
|
|
186
|
+
font-weight: 600;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.db-content img {
|
|
190
|
+
max-width: 100%;
|
|
191
|
+
height: auto;
|
|
192
|
+
border-radius: 8px;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.db-content a {
|
|
196
|
+
color: inherit;
|
|
197
|
+
text-decoration: underline;
|
|
198
|
+
text-underline-offset: 2px;
|
|
199
|
+
}
|
|
200
|
+
.db-content a:hover {
|
|
201
|
+
opacity: 0.8;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.db-content hr {
|
|
205
|
+
border: none;
|
|
206
|
+
border-top: 1px solid rgba(127, 127, 127, 0.3);
|
|
207
|
+
margin: 2em 0;
|
|
208
|
+
}
|
package/dist/toHtml.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders raw MDX/markdown to a static HTML string — no React, no JSX component
|
|
3
|
+
* evaluation. For contexts that need plain HTML (email, RSS, non-React embeds),
|
|
4
|
+
* not a mounted React tree; use `compileMDX`/`MDXContent` for that instead.
|
|
5
|
+
* JSX inside the source (e.g. `<Callout>`) is passed through as literal HTML tags.
|
|
6
|
+
*/
|
|
7
|
+
export declare function toHtml(source: string): Promise<string>;
|
package/dist/toHtml.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { unified } from "unified";
|
|
2
|
+
import remarkParse from "remark-parse";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import remarkRehype from "remark-rehype";
|
|
5
|
+
import rehypeSlug from "rehype-slug";
|
|
6
|
+
import rehypeStringify from "rehype-stringify";
|
|
7
|
+
/**
|
|
8
|
+
* Renders raw MDX/markdown to a static HTML string — no React, no JSX component
|
|
9
|
+
* evaluation. For contexts that need plain HTML (email, RSS, non-React embeds),
|
|
10
|
+
* not a mounted React tree; use `compileMDX`/`MDXContent` for that instead.
|
|
11
|
+
* JSX inside the source (e.g. `<Callout>`) is passed through as literal HTML tags.
|
|
12
|
+
*/
|
|
13
|
+
export async function toHtml(source) {
|
|
14
|
+
const file = await unified()
|
|
15
|
+
.use(remarkParse)
|
|
16
|
+
.use(remarkGfm)
|
|
17
|
+
.use(remarkRehype, { allowDangerousHtml: true })
|
|
18
|
+
.use(rehypeSlug)
|
|
19
|
+
.use(rehypeStringify, { allowDangerousHtml: true })
|
|
20
|
+
.process(source);
|
|
21
|
+
return String(file);
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { toHtml } from "./toHtml.js";
|
|
4
|
+
test("renders markdown to html", async () => {
|
|
5
|
+
const html = await toHtml("# Title\n\nSome **bold** text.");
|
|
6
|
+
assert.match(html, /<h1 id="title">Title<\/h1>/);
|
|
7
|
+
assert.match(html, /<strong>bold<\/strong>/);
|
|
8
|
+
});
|
|
9
|
+
test("supports gfm tables", async () => {
|
|
10
|
+
const html = await toHtml("| a | b |\n| - | - |\n| 1 | 2 |");
|
|
11
|
+
assert.match(html, /<table>/);
|
|
12
|
+
});
|
package/dist/vue.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Component } from "vue";
|
|
2
|
+
import { type FailedMDX } from "./core.js";
|
|
3
|
+
export interface CompiledMDX {
|
|
4
|
+
ok: true;
|
|
5
|
+
Content: Component;
|
|
6
|
+
}
|
|
7
|
+
export type { FailedMDX };
|
|
8
|
+
/**
|
|
9
|
+
* Compiles raw MDX/markdown into a renderable Vue component. Vue has no RSC-style
|
|
10
|
+
* async component, so call this from `setup()`/a composable and render the result
|
|
11
|
+
* yourself: `h(Content, props)` or `<component :is="Content" />`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function compileMDX(source: string): Promise<CompiledMDX | FailedMDX>;
|
package/dist/vue.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { compileMDXCore } from "./core.js";
|
|
2
|
+
import { vueJsxRuntime } from "./vueRuntime.js";
|
|
3
|
+
/**
|
|
4
|
+
* Compiles raw MDX/markdown into a renderable Vue component. Vue has no RSC-style
|
|
5
|
+
* async component, so call this from `setup()`/a composable and render the result
|
|
6
|
+
* yourself: `h(Content, props)` or `<component :is="Content" />`.
|
|
7
|
+
*/
|
|
8
|
+
export async function compileMDX(source) {
|
|
9
|
+
return compileMDXCore(source, vueJsxRuntime);
|
|
10
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { h, Fragment as VueFragment } from "vue";
|
|
2
|
+
function toVNode(type, props) {
|
|
3
|
+
const { children, ...rest } = props ?? {};
|
|
4
|
+
// Vue's h() takes children as a separate argument; the automatic JSX runtime
|
|
5
|
+
// embeds them in props instead — unwrap before handing off.
|
|
6
|
+
return h(type, rest, children);
|
|
7
|
+
}
|
|
8
|
+
/** `evaluate()`-compatible JSX runtime backed by Vue's `h()`, used by `./vue.js`. */
|
|
9
|
+
export const vueJsxRuntime = {
|
|
10
|
+
Fragment: VueFragment,
|
|
11
|
+
jsx: toVNode,
|
|
12
|
+
jsxs: toVNode,
|
|
13
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function wrapperClassName(unstyled: boolean | undefined, className: string | undefined): string | undefined;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { wrapperClassName } from "./wrapperClassName.js";
|
|
4
|
+
test("defaults to db-content", () => {
|
|
5
|
+
assert.equal(wrapperClassName(undefined, undefined), "db-content");
|
|
6
|
+
});
|
|
7
|
+
test("unstyled drops db-content", () => {
|
|
8
|
+
assert.equal(wrapperClassName(true, undefined), undefined);
|
|
9
|
+
});
|
|
10
|
+
test("merges custom className", () => {
|
|
11
|
+
assert.equal(wrapperClassName(false, "prose"), "db-content prose");
|
|
12
|
+
});
|
|
13
|
+
test("unstyled keeps custom className", () => {
|
|
14
|
+
assert.equal(wrapperClassName(true, "prose"), "prose");
|
|
15
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@draftbase/renderer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic MDX renderer for Draftbase content (React, Next.js RSC, React Native, Astro, Vite, Vue)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"draftbase",
|
|
7
|
+
"mdx",
|
|
8
|
+
"cms",
|
|
9
|
+
"react",
|
|
10
|
+
"vue",
|
|
11
|
+
"renderer",
|
|
12
|
+
"astro",
|
|
13
|
+
"vite"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Draftbase",
|
|
17
|
+
"homepage": "https://draftbase.co",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/draftbase-co/draftbase-monorepo.git",
|
|
21
|
+
"directory": "packages/renderer"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"sideEffects": [
|
|
30
|
+
"*.css"
|
|
31
|
+
],
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./dist/index.js",
|
|
34
|
+
"./vue": "./dist/vue.js",
|
|
35
|
+
"./styles.css": "./dist/styles.css"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"README.md"
|
|
41
|
+
],
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"react": ">=18",
|
|
44
|
+
"vue": ">=3"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"react": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"vue": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@mdx-js/mdx": "^3.1.1",
|
|
56
|
+
"remark-gfm": "^4.0.0",
|
|
57
|
+
"remark-parse": "^11.0.0",
|
|
58
|
+
"remark-rehype": "^11.1.2",
|
|
59
|
+
"rehype-slug": "^6.0.0",
|
|
60
|
+
"rehype-stringify": "^10.0.1",
|
|
61
|
+
"unified": "^11.0.5"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@types/mdx": "^2.0.13",
|
|
65
|
+
"@types/node": "^22.9.0",
|
|
66
|
+
"@types/react": "^19.0.0",
|
|
67
|
+
"react": "^19.0.0",
|
|
68
|
+
"vue": "^3.5.0",
|
|
69
|
+
"typescript": "^5.6.0"
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"build": "tsc -p tsconfig.json && cp src/styles.css dist/styles.css",
|
|
73
|
+
"lint": "tsc --noEmit",
|
|
74
|
+
"test": "node --test dist/wrapperClassName.test.js dist/toHtml.test.js dist/compileMDX.test.js"
|
|
75
|
+
}
|
|
76
|
+
}
|