@draftbase/renderer 0.3.2 → 0.4.3
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/README.md +81 -14
- package/dist/MDXContent.d.ts +6 -9
- package/dist/MDXContent.js +7 -8
- package/dist/MDXContent.test.d.ts +1 -0
- package/dist/MDXContent.test.js +19 -0
- package/dist/MDXErrorBoundary.js +2 -4
- package/dist/compileMDX.test.js +38 -5
- package/dist/core.d.ts +9 -4
- package/dist/core.js +22 -5
- package/dist/reactNative.d.ts +18 -0
- package/dist/reactNative.js +45 -0
- package/dist/reactNative.test.d.ts +1 -0
- package/dist/reactNative.test.js +35 -0
- package/dist/reactNativeComponents.d.ts +196 -0
- package/dist/reactNativeComponents.js +62 -0
- package/dist/reactNativeComponents.test.d.ts +1 -0
- package/dist/reactNativeComponents.test.js +43 -0
- package/dist/reactNativeStyles.d.ts +3 -0
- package/dist/reactNativeStyles.js +45 -0
- package/dist/toHtml.d.ts +17 -7
- package/dist/toHtml.js +63 -11
- package/dist/toHtml.test.js +40 -0
- package/dist/vue.d.ts +2 -3
- package/dist/vue.js +5 -5
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -21,13 +21,16 @@ pnpm add @draftbase/renderer
|
|
|
21
21
|
|
|
22
22
|
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:
|
|
23
23
|
|
|
24
|
-
| Framework
|
|
25
|
-
|
|
|
26
|
-
| React, Next.js,
|
|
27
|
-
|
|
|
28
|
-
|
|
|
24
|
+
| Framework | Import | `Content` is a... |
|
|
25
|
+
| ---------------------------------------------- | ----------------------------------------- | ------------------------------------------- |
|
|
26
|
+
| React, Next.js, Remix, Astro, Vite | `@draftbase/renderer` | React component |
|
|
27
|
+
| React Native | `@draftbase/renderer/react-native` | React component |
|
|
28
|
+
| Vue, Nuxt | `@draftbase/renderer/vue` | Vue component |
|
|
29
|
+
| Anything else (Svelte, plain HTML, email, RSS) | `toHtml` (below), from either entry point | — (returns an HTML string, not a component) |
|
|
29
30
|
|
|
30
|
-
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.
|
|
31
|
+
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. The `/react-native` entry point never imports the `react-native` package itself (see its section below), so a Vue or web-only React project never pulls it in either.
|
|
32
|
+
|
|
33
|
+
Every entry point renders standard markdown (`p`, `h1`-`h6`, tables, lists, links, ...) **with zero component mapping** — web React/Vue use real DOM elements automatically, and the React Native entry ships default Text/View/Image mappings (see below). You only ever need to pass `components` for content-specific custom JSX tags (e.g. `<Callout>`) that have no sensible default — and even `EntryLink` (the CMS's entry-link tag) has a default (`<a href="/entries/{id}">`) unless you override it.
|
|
31
34
|
|
|
32
35
|
## ⚛️ Next.js App Router
|
|
33
36
|
|
|
@@ -44,14 +47,51 @@ export default async function Page() {
|
|
|
44
47
|
|
|
45
48
|
`MDXContent` is an `async` Server Component — it only works where React can await inside a component body (Next.js RSC).
|
|
46
49
|
|
|
47
|
-
## Other React (client-side web,
|
|
50
|
+
## Other React (client-side web, Remix, ...)
|
|
48
51
|
|
|
49
52
|
Call `compileMDX` yourself from a loader/effect and render the result — no RSC required:
|
|
50
53
|
|
|
51
54
|
```tsx
|
|
52
55
|
import { useEffect, useState } from "react";
|
|
53
56
|
import { compileMDX } from "@draftbase/renderer";
|
|
57
|
+
|
|
58
|
+
function Entry({ source }: { source: string }) {
|
|
59
|
+
const [compiled, setCompiled] = useState<Awaited<ReturnType<typeof compileMDX>>>();
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
compileMDX(source).then(setCompiled);
|
|
63
|
+
}, [source]);
|
|
64
|
+
|
|
65
|
+
if (!compiled) return null;
|
|
66
|
+
if (!compiled.ok) return <p>{source}</p>;
|
|
67
|
+
|
|
68
|
+
const { Content } = compiled;
|
|
69
|
+
return <Content />; // standard markdown renders as real DOM elements, no components map needed
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`compiled.ok` only catches MDX _syntax_ errors. If the source references a custom JSX component you didn't pass in `components` (e.g. `<Callout>` without a `Callout` implementation), React throws while rendering `<Content>` — wrap it in the exported `MDXErrorBoundary` to log it to the console and fail soft instead of crashing the page. `MDXContent` (the Next.js RSC helper above) already does this for you automatically.
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
import { MDXErrorBoundary } from "@draftbase/renderer";
|
|
77
|
+
|
|
78
|
+
<MDXErrorBoundary fallback={<p>{source}</p>}>
|
|
79
|
+
<Content />
|
|
80
|
+
</MDXErrorBoundary>;
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Extended markdown (tables, strikethrough, task lists, autolinks) is supported out of the box via `remark-gfm`.
|
|
84
|
+
|
|
85
|
+
## React Native
|
|
86
|
+
|
|
87
|
+
RN has no intrinsic host tags for `p`/`h1`/`a`/etc the way web React has real DOM elements, so it can't render standard markdown for free the way the web entry point does. Instead of mapping every tag yourself, wire up RN's three core primitives **once** via `@draftbase/renderer/react-native` and every markdown element — including a styled default `EntryLink` — works out of the box from there:
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
import { useEffect, useState } from "react";
|
|
54
91
|
import { View, Text } from "react-native";
|
|
92
|
+
import { createReactNativeRenderer } from "@draftbase/renderer/react-native";
|
|
93
|
+
|
|
94
|
+
const { compileMDX } = createReactNativeRenderer({ Text, View, Image });
|
|
55
95
|
|
|
56
96
|
function Entry({ source }: { source: string }) {
|
|
57
97
|
const [compiled, setCompiled] = useState<Awaited<ReturnType<typeof compileMDX>>>();
|
|
@@ -66,23 +106,25 @@ function Entry({ source }: { source: string }) {
|
|
|
66
106
|
const { Content } = compiled;
|
|
67
107
|
return (
|
|
68
108
|
<View>
|
|
69
|
-
<Content
|
|
109
|
+
<Content /> {/* p, h1-h6, a, table, EntryLink, ... all render already-styled */}
|
|
70
110
|
</View>
|
|
71
111
|
);
|
|
72
112
|
}
|
|
73
113
|
```
|
|
74
114
|
|
|
75
|
-
|
|
115
|
+
Default styling mirrors the web look (heading scale, blue underlined links, monospace code, table borders and row layout, ...). Override or disable it via a second argument:
|
|
76
116
|
|
|
77
117
|
```tsx
|
|
78
|
-
|
|
118
|
+
// override specific tags
|
|
119
|
+
createReactNativeRenderer({ Text, View, Image }, { styles: { h1: { fontSize: 32 } } });
|
|
79
120
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
</MDXErrorBoundary>;
|
|
121
|
+
// drop all default styling
|
|
122
|
+
createReactNativeRenderer({ Text, View, Image }, { unstyled: true });
|
|
83
123
|
```
|
|
84
124
|
|
|
85
|
-
|
|
125
|
+
`components` on the returned `Content` still works the same way, per-tag, for anything you want to swap out entirely (e.g. a tappable `EntryLink` that actually navigates — the default renders a plain styled `Text`, since navigation is app-specific).
|
|
126
|
+
|
|
127
|
+
This package never imports the `react-native` package itself — you pass its `Text`/`View`/`Image` in, so nothing RN-specific ends up in a web or Vue bundle.
|
|
86
128
|
|
|
87
129
|
## Astro
|
|
88
130
|
|
|
@@ -190,6 +232,31 @@ import { toHtml } from "@draftbase/renderer";
|
|
|
190
232
|
const html = await toHtml(entry.fields.body);
|
|
191
233
|
```
|
|
192
234
|
|
|
235
|
+
By default, custom tags in the source (`<Callout>`, `<EntryLink>`, ...) pass through as literal HTML — `toHtml` produces a plain string, not a React tree, so there's no component tree to substitute into. Pass `components` to render one to an HTML string instead, keyed by tag name (case-insensitive):
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
const html = await toHtml(entry.fields.body, {
|
|
239
|
+
components: {
|
|
240
|
+
Callout: (props, childrenHtml) => `<div class="callout-${props.type}">${childrenHtml}</div>`,
|
|
241
|
+
},
|
|
242
|
+
externalLinks: true, // adds target="_blank" rel="noopener noreferrer" to off-site <a> tags
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Unlike a React `components` map, each prop always arrives as a string — real HTML parsing, not JSX evaluation, so `count="3"` is `"3"`, never `3`. `childrenHtml` is the tag's contents already rendered to HTML, with any nested custom tags resolved first.
|
|
247
|
+
|
|
248
|
+
`EntryLink` gets a default (`<a href="/entries/{id}">`) the moment `components` is passed at all, same as the React renderer's default — override it the same way as any other tag:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
const html = await toHtml(entry.fields.body, {
|
|
252
|
+
components: {
|
|
253
|
+
EntryLink: (props, childrenHtml) => `<a href="/blog/${props.id}">${childrenHtml}</a>`,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Omit `components` entirely and every custom tag, `EntryLink` included, stays literal HTML (previous behavior); omit `externalLinks` and links are left as-is.
|
|
259
|
+
|
|
193
260
|
## Custom components
|
|
194
261
|
|
|
195
262
|
Content can invoke JSX components by name inside the MDX source (e.g. `<Callout type="warning">...</Callout>`). Pass the implementations:
|
package/dist/MDXContent.d.ts
CHANGED
|
@@ -9,17 +9,15 @@ export interface CompiledMDX {
|
|
|
9
9
|
}
|
|
10
10
|
export type { FailedMDX };
|
|
11
11
|
/**
|
|
12
|
-
* Compiles raw MDX/markdown into a renderable React component — no DOM assumptions,
|
|
13
|
-
*
|
|
14
|
-
* Component (the `MDXContent` component below does the latter).
|
|
12
|
+
* Compiles raw MDX/markdown into a renderable React component — no DOM assumptions, safe for client effects or RSC.
|
|
13
|
+
* Standard markdown elements render via real DOM tags with zero setup; only a JSX component with no built-in default needs `components`.
|
|
15
14
|
*/
|
|
16
15
|
export declare function compileMDX(source: string): Promise<CompiledMDX | FailedMDX>;
|
|
17
16
|
export interface MDXContentProps {
|
|
18
17
|
/** Raw MDX/markdown string, e.g. an entry's rich text field. */
|
|
19
18
|
source: string;
|
|
20
|
-
/** Custom components available by name inside the MDX source
|
|
21
|
-
*
|
|
22
|
-
* non-DOM renderers such as React Native, which have no intrinsic `div`/`p`/`a` tags. */
|
|
19
|
+
/** Custom components available by name inside the MDX source, and/or overrides for standard markdown elements —
|
|
20
|
+
* required on non-DOM renderers such as React Native, which have no intrinsic `div`/`p`/`a` tags. */
|
|
23
21
|
components?: MDXComponents;
|
|
24
22
|
/** Skip the default `db-content` styling class. */
|
|
25
23
|
unstyled?: boolean;
|
|
@@ -33,8 +31,7 @@ export interface MDXContentProps {
|
|
|
33
31
|
errorTag?: ElementType;
|
|
34
32
|
}
|
|
35
33
|
/**
|
|
36
|
-
* Next.js Server Component wrapper around {@link compileMDX}. For non-RSC React
|
|
37
|
-
*
|
|
38
|
-
* and render `Content` yourself — `await` inside a component body only works as an RSC.
|
|
34
|
+
* Next.js Server Component wrapper around {@link compileMDX}. For non-RSC React, call `compileMDX` directly instead —
|
|
35
|
+
* `await` inside a component body only works as an RSC.
|
|
39
36
|
*/
|
|
40
37
|
export declare function MDXContent({ source, components, unstyled, className, wrapperTag, errorTag, }: MDXContentProps): Promise<ReactNode>;
|
package/dist/MDXContent.js
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import * as runtime from "react/jsx-runtime";
|
|
3
|
-
import { compileMDXCore } from "./core.js";
|
|
3
|
+
import { compileMDXCore, makeDefaultEntryLink } from "./core.js";
|
|
4
4
|
import { wrapperClassName } from "./wrapperClassName.js";
|
|
5
5
|
import { MDXErrorBoundary } from "./MDXErrorBoundary.js";
|
|
6
|
+
const defaultComponents = { EntryLink: makeDefaultEntryLink(runtime) };
|
|
6
7
|
/**
|
|
7
|
-
* Compiles raw MDX/markdown into a renderable React component — no DOM assumptions,
|
|
8
|
-
*
|
|
9
|
-
* Component (the `MDXContent` component below does the latter).
|
|
8
|
+
* Compiles raw MDX/markdown into a renderable React component — no DOM assumptions, safe for client effects or RSC.
|
|
9
|
+
* Standard markdown elements render via real DOM tags with zero setup; only a JSX component with no built-in default needs `components`.
|
|
10
10
|
*/
|
|
11
11
|
export async function compileMDX(source) {
|
|
12
|
-
return compileMDXCore(source, runtime);
|
|
12
|
+
return compileMDXCore(source, runtime, defaultComponents);
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
|
-
* Next.js Server Component wrapper around {@link compileMDX}. For non-RSC React
|
|
16
|
-
*
|
|
17
|
-
* and render `Content` yourself — `await` inside a component body only works as an RSC.
|
|
15
|
+
* Next.js Server Component wrapper around {@link compileMDX}. For non-RSC React, call `compileMDX` directly instead —
|
|
16
|
+
* `await` inside a component body only works as an RSC.
|
|
18
17
|
*/
|
|
19
18
|
export async function MDXContent({ source, components, unstyled, className, wrapperTag = "div", errorTag = "p", }) {
|
|
20
19
|
const Wrapper = wrapperTag;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { MDXContent } from "./MDXContent.js";
|
|
4
|
+
// MDXContent is an async Server Component; call it directly (as React's RSC runtime would) and inspect the returned element tree.
|
|
5
|
+
test("MDXContent wraps compiled MDX in the styled wrapper element", async () => {
|
|
6
|
+
const element = (await MDXContent({ source: "# Hello" }));
|
|
7
|
+
assert.equal(element.type, "div");
|
|
8
|
+
assert.equal(element.props.className, "db-content");
|
|
9
|
+
});
|
|
10
|
+
test("unstyled drops the db-content class", async () => {
|
|
11
|
+
const element = (await MDXContent({ source: "# Hello", unstyled: true }));
|
|
12
|
+
assert.equal(element.props.className, undefined);
|
|
13
|
+
});
|
|
14
|
+
test("falls back to plain text when the source fails to compile as MDX", async () => {
|
|
15
|
+
const element = (await MDXContent({ source: "<broken" }));
|
|
16
|
+
const fallback = element.props.children;
|
|
17
|
+
assert.equal(fallback.type, "p");
|
|
18
|
+
assert.equal(fallback.props.children, "<broken");
|
|
19
|
+
});
|
package/dist/MDXErrorBoundary.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { Component } from "react";
|
|
3
|
-
// Catches render-time errors from compiled MDX content — most commonly a JSX component
|
|
4
|
-
//
|
|
5
|
-
// `components`. Without this, that throws "Element type is invalid" and crashes the whole
|
|
6
|
-
// page instead of just the content block.
|
|
3
|
+
// Catches render-time errors from compiled MDX content — most commonly a JSX component referenced in the
|
|
4
|
+
// source but missing from `components`, which otherwise throws and crashes the whole page.
|
|
7
5
|
export class MDXErrorBoundary extends Component {
|
|
8
6
|
state = { hasError: false };
|
|
9
7
|
static getDerivedStateFromError() {
|
package/dist/compileMDX.test.js
CHANGED
|
@@ -3,14 +3,19 @@ import test from "node:test";
|
|
|
3
3
|
import { compileMDX as compileReactMDX } from "./MDXContent.js";
|
|
4
4
|
import { compileMDX as compileVueMDX } from "./vue.js";
|
|
5
5
|
const SOURCE = "# Hello\n\nWorld";
|
|
6
|
+
/** Content is wrapped to merge in default components (see core.ts) — unwrap by invoking each
|
|
7
|
+
* function-typed element until reaching real output (no renderer mounted). */
|
|
8
|
+
function resolve(element) {
|
|
9
|
+
return typeof element.type === "function"
|
|
10
|
+
? resolve(element.type(element.props))
|
|
11
|
+
: element;
|
|
12
|
+
}
|
|
6
13
|
test("react compileMDX evaluates MDX into a React element tree", async () => {
|
|
7
14
|
const result = await compileReactMDX(SOURCE);
|
|
8
15
|
assert.equal(result.ok, true);
|
|
9
16
|
if (!result.ok)
|
|
10
17
|
return;
|
|
11
|
-
|
|
12
|
-
const render = result.Content;
|
|
13
|
-
const element = render({});
|
|
18
|
+
const element = resolve(result.Content({}));
|
|
14
19
|
assert.equal(element.props.children[0].type, "h1");
|
|
15
20
|
});
|
|
16
21
|
test("vue compileMDX evaluates the same MDX source into a Vue vnode tree", async () => {
|
|
@@ -18,7 +23,35 @@ test("vue compileMDX evaluates the same MDX source into a Vue vnode tree", async
|
|
|
18
23
|
assert.equal(result.ok, true);
|
|
19
24
|
if (!result.ok)
|
|
20
25
|
return;
|
|
21
|
-
|
|
22
|
-
const vnode = result.Content({});
|
|
26
|
+
const vnode = resolve(result.Content({}));
|
|
23
27
|
assert.equal(vnode.children[0].type, "h1");
|
|
24
28
|
});
|
|
29
|
+
test("react EntryLink renders as a link to /entries/{id} with no components map supplied", async () => {
|
|
30
|
+
const result = await compileReactMDX('<EntryLink id="abc123">Read more</EntryLink>');
|
|
31
|
+
assert.equal(result.ok, true);
|
|
32
|
+
if (!result.ok)
|
|
33
|
+
return;
|
|
34
|
+
const link = resolve(result.Content({}));
|
|
35
|
+
assert.equal(link.type, "a");
|
|
36
|
+
assert.equal(link.props.href, "/entries/abc123");
|
|
37
|
+
});
|
|
38
|
+
test("vue EntryLink renders as a link to /entries/{id} with no components map supplied", async () => {
|
|
39
|
+
const result = await compileVueMDX('<EntryLink id="abc123">Read more</EntryLink>');
|
|
40
|
+
assert.equal(result.ok, true);
|
|
41
|
+
if (!result.ok)
|
|
42
|
+
return;
|
|
43
|
+
const link = resolve(result.Content({}));
|
|
44
|
+
assert.equal(link.type, "a");
|
|
45
|
+
assert.equal(link.props.href, "/entries/abc123");
|
|
46
|
+
});
|
|
47
|
+
test("a supplied EntryLink override still wins over the default", async () => {
|
|
48
|
+
const result = await compileReactMDX('<EntryLink id="abc123">Read more</EntryLink>');
|
|
49
|
+
assert.equal(result.ok, true);
|
|
50
|
+
if (!result.ok)
|
|
51
|
+
return;
|
|
52
|
+
const CustomEntryLink = () => ({ type: "custom-entry-link", props: {} });
|
|
53
|
+
const link = resolve(result.Content({
|
|
54
|
+
components: { EntryLink: CustomEntryLink },
|
|
55
|
+
}));
|
|
56
|
+
assert.equal(link.type, "custom-entry-link");
|
|
57
|
+
});
|
package/dist/core.d.ts
CHANGED
|
@@ -9,8 +9,13 @@ export interface FailedMDX {
|
|
|
9
9
|
error: unknown;
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
* Shared MDX-to-component evaluation
|
|
13
|
-
*
|
|
14
|
-
* supplies its own without duplicating the remark/rehype pipeline.
|
|
12
|
+
* Shared MDX-to-component evaluation, parameterized by JSX runtime so each framework entry point avoids duplicating
|
|
13
|
+
* the remark/rehype pipeline. `defaultComponents`, when given, are merged underneath the caller's own `components` (caller always wins per-tag).
|
|
15
14
|
*/
|
|
16
|
-
export declare function compileMDXCore<TComponent>(source: string, jsxRuntime: JsxRuntime): Promise<CompiledMDX<TComponent> | FailedMDX>;
|
|
15
|
+
export declare function compileMDXCore<TComponent>(source: string, jsxRuntime: JsxRuntime, defaultComponents?: Record<string, unknown>): Promise<CompiledMDX<TComponent> | FailedMDX>;
|
|
16
|
+
/** Default `EntryLink` for a given JSX runtime — renders `<a href="/entries/{id}">`; apps that
|
|
17
|
+
* route entries differently override it by passing `components.EntryLink`. */
|
|
18
|
+
export declare function makeDefaultEntryLink(jsxRuntime: JsxRuntime): ({ id, children }: {
|
|
19
|
+
id?: string;
|
|
20
|
+
children?: unknown;
|
|
21
|
+
}) => JSX.Element;
|
package/dist/core.js
CHANGED
|
@@ -2,22 +2,39 @@ import { evaluate } from "@mdx-js/mdx";
|
|
|
2
2
|
import remarkGfm from "remark-gfm";
|
|
3
3
|
import rehypeSlug from "rehype-slug";
|
|
4
4
|
/**
|
|
5
|
-
* Shared MDX-to-component evaluation
|
|
6
|
-
*
|
|
7
|
-
* supplies its own without duplicating the remark/rehype pipeline.
|
|
5
|
+
* Shared MDX-to-component evaluation, parameterized by JSX runtime so each framework entry point avoids duplicating
|
|
6
|
+
* the remark/rehype pipeline. `defaultComponents`, when given, are merged underneath the caller's own `components` (caller always wins per-tag).
|
|
8
7
|
*/
|
|
9
|
-
export async function compileMDXCore(source, jsxRuntime) {
|
|
8
|
+
export async function compileMDXCore(source, jsxRuntime, defaultComponents) {
|
|
10
9
|
try {
|
|
11
10
|
const { default: Content } = await evaluate(source, {
|
|
12
11
|
...jsxRuntime,
|
|
13
12
|
remarkPlugins: [remarkGfm],
|
|
14
13
|
rehypePlugins: [rehypeSlug],
|
|
15
14
|
});
|
|
15
|
+
const OutputContent = defaultComponents
|
|
16
|
+
? withDefaultComponents(Content, defaultComponents, jsxRuntime)
|
|
17
|
+
: Content;
|
|
16
18
|
// evaluate()'s return type assumes React's JSX types regardless of the runtime
|
|
17
19
|
// passed in; TComponent reflects the actual shape for the calling framework.
|
|
18
|
-
return { ok: true, Content:
|
|
20
|
+
return { ok: true, Content: OutputContent };
|
|
19
21
|
}
|
|
20
22
|
catch (error) {
|
|
21
23
|
return { ok: false, error };
|
|
22
24
|
}
|
|
23
25
|
}
|
|
26
|
+
function withDefaultComponents(Content, defaults, jsxRuntime) {
|
|
27
|
+
return function ContentWithDefaults(props) {
|
|
28
|
+
return jsxRuntime.jsx(Content, {
|
|
29
|
+
...props,
|
|
30
|
+
components: { ...defaults, ...props?.components },
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Default `EntryLink` for a given JSX runtime — renders `<a href="/entries/{id}">`; apps that
|
|
35
|
+
* route entries differently override it by passing `components.EntryLink`. */
|
|
36
|
+
export function makeDefaultEntryLink(jsxRuntime) {
|
|
37
|
+
return function EntryLink({ id, children }) {
|
|
38
|
+
return jsxRuntime.jsx("a", { href: id ? `/entries/${id}` : undefined, children });
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { MDXComponents } from "mdx/types";
|
|
2
|
+
import type { ComponentType } from "react";
|
|
3
|
+
import { type FailedMDX } from "./core.js";
|
|
4
|
+
import { type ReactNativePrimitives, type ReactNativeStyleOptions } from "./reactNativeComponents.js";
|
|
5
|
+
export interface CompiledMDX {
|
|
6
|
+
ok: true;
|
|
7
|
+
Content: ComponentType<{
|
|
8
|
+
components?: MDXComponents;
|
|
9
|
+
}>;
|
|
10
|
+
}
|
|
11
|
+
export type { FailedMDX };
|
|
12
|
+
/**
|
|
13
|
+
* Wires up a React Native `compileMDX` with default Text/View/Image mappings for every standard markdown element
|
|
14
|
+
* (and `EntryLink`), so a project sets up its RN primitives once instead of mapping every tag on every call.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createReactNativeRenderer(primitives: ReactNativePrimitives, styleOptions?: ReactNativeStyleOptions): {
|
|
17
|
+
compileMDX: (source: string) => Promise<CompiledMDX | FailedMDX>;
|
|
18
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as runtime from "react/jsx-runtime";
|
|
2
|
+
import { compileMDXCore } from "./core.js";
|
|
3
|
+
import { buildReactNativeComponents, } from "./reactNativeComponents.js";
|
|
4
|
+
/**
|
|
5
|
+
* Wires up a React Native `compileMDX` with default Text/View/Image mappings for every standard markdown element
|
|
6
|
+
* (and `EntryLink`), so a project sets up its RN primitives once instead of mapping every tag on every call.
|
|
7
|
+
*/
|
|
8
|
+
export function createReactNativeRenderer(primitives, styleOptions) {
|
|
9
|
+
const defaultComponents = buildReactNativeComponents(primitives, styleOptions);
|
|
10
|
+
async function compileMDX(source) {
|
|
11
|
+
const result = await compileMDXCore(source, runtime, defaultComponents);
|
|
12
|
+
if (!result.ok)
|
|
13
|
+
return result;
|
|
14
|
+
// MDX bakes the source's literal inter-block whitespace into the compiled output as bare
|
|
15
|
+
// "\n" string children of the root Fragment (e.g. between a heading and the paragraph after
|
|
16
|
+
// it). React Native throws on a bare string child that isn't inside a <Text>, so those need
|
|
17
|
+
// stripping here — element-mapped tags already handle it themselves (see reactNativeComponents.ts).
|
|
18
|
+
const Content = result.Content;
|
|
19
|
+
// withDefaultComponents (core.ts) wraps the compiled MDX component in one props-forwarding
|
|
20
|
+
// layer, so unwrap exactly that to reach the actual root element (a Fragment for a multi-block
|
|
21
|
+
// document, or a single mapped element like Text/View otherwise) before stripping whitespace.
|
|
22
|
+
// Calling any further would start invoking the RN primitives (Text/View/Image) themselves.
|
|
23
|
+
const RootWithoutWhitespace = (props) => {
|
|
24
|
+
const wrapped = Content(props);
|
|
25
|
+
const rootElement = typeof wrapped.type === "function"
|
|
26
|
+
? wrapped.type(wrapped.props)
|
|
27
|
+
: wrapped;
|
|
28
|
+
return stripWhitespaceRootChildren(rootElement);
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
Content: RootWithoutWhitespace,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return { compileMDX };
|
|
36
|
+
}
|
|
37
|
+
function stripWhitespaceRootChildren(element) {
|
|
38
|
+
const children = element.props?.children;
|
|
39
|
+
if (!Array.isArray(children))
|
|
40
|
+
return element;
|
|
41
|
+
const filtered = children.filter((child) => typeof child !== "string" || child.trim() !== "");
|
|
42
|
+
if (filtered.length === children.length)
|
|
43
|
+
return element;
|
|
44
|
+
return runtime.jsxs(element.type, { ...element.props, children: filtered }, element.key ?? undefined);
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createReactNativeRenderer } from "./reactNative.js";
|
|
4
|
+
// Fakes standing in for react-native's Text/View/Image — see reactNativeComponents.test.ts.
|
|
5
|
+
const Text = () => null;
|
|
6
|
+
const View = () => null;
|
|
7
|
+
const Image = () => null;
|
|
8
|
+
const LEAVES = [Text, View, Image];
|
|
9
|
+
/** Unwraps nested function-component elements down to the RN primitive actually used,
|
|
10
|
+
* without calling into it (Text/View/Image are opaque leaves here, not JSX). */
|
|
11
|
+
function resolve(element) {
|
|
12
|
+
return typeof element.type === "function" && !LEAVES.includes(element.type)
|
|
13
|
+
? resolve(element.type(element.props))
|
|
14
|
+
: element;
|
|
15
|
+
}
|
|
16
|
+
test("React Native smoke test: renders standard markdown through Text/View with zero setup beyond the three primitives", async () => {
|
|
17
|
+
const { compileMDX } = createReactNativeRenderer({ Text, View, Image });
|
|
18
|
+
const result = await compileMDX("# Hello\n\nWorld");
|
|
19
|
+
assert.equal(result.ok, true);
|
|
20
|
+
if (!result.ok)
|
|
21
|
+
return;
|
|
22
|
+
const element = resolve(result.Content({}));
|
|
23
|
+
const heading = resolve(element.props.children[0]);
|
|
24
|
+
assert.equal(heading.type, Text);
|
|
25
|
+
assert.equal(heading.props.style.fontSize, 28);
|
|
26
|
+
});
|
|
27
|
+
test("React Native EntryLink renders out of the box (no components map)", async () => {
|
|
28
|
+
const { compileMDX } = createReactNativeRenderer({ Text, View, Image });
|
|
29
|
+
const result = await compileMDX('<EntryLink id="abc123">Read more</EntryLink>');
|
|
30
|
+
assert.equal(result.ok, true);
|
|
31
|
+
if (!result.ok)
|
|
32
|
+
return;
|
|
33
|
+
const element = resolve(result.Content({}));
|
|
34
|
+
assert.equal(element.type, Text);
|
|
35
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
export interface ReactNativePrimitives {
|
|
3
|
+
Text: ComponentType<{
|
|
4
|
+
children?: ReactNode;
|
|
5
|
+
style?: unknown;
|
|
6
|
+
}>;
|
|
7
|
+
View: ComponentType<{
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
style?: unknown;
|
|
10
|
+
}>;
|
|
11
|
+
Image: ComponentType<{
|
|
12
|
+
source: {
|
|
13
|
+
uri?: string;
|
|
14
|
+
};
|
|
15
|
+
accessibilityLabel?: string;
|
|
16
|
+
style?: unknown;
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
19
|
+
export interface ReactNativeStyleOptions {
|
|
20
|
+
/** Skip the built-in default styles (heading scale, link color, table borders, ...)
|
|
21
|
+
* entirely — components render with no `style` unless `styles` supplies one. */
|
|
22
|
+
unstyled?: boolean;
|
|
23
|
+
/** Per-tag style overrides, merged on top of the defaults (or used as-is under
|
|
24
|
+
* `unstyled`). Same tag keys as the components map, e.g. `{ h1: { fontSize: 32 } }`. */
|
|
25
|
+
styles?: Partial<Record<string, Record<string, unknown>>>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Default MDX `components` map for React Native, built from its Text/View/Image primitives. RN has no intrinsic
|
|
29
|
+
* host tags for `p`/`h1`/`a`/etc the way web React does, so every standard markdown element needs an explicit mapping.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildReactNativeComponents({ Text, View, Image }: ReactNativePrimitives, { unstyled, styles }?: ReactNativeStyleOptions): {
|
|
32
|
+
p: ComponentType<{
|
|
33
|
+
children?: ReactNode;
|
|
34
|
+
style?: unknown;
|
|
35
|
+
}> | ((props: {
|
|
36
|
+
children?: ReactNode;
|
|
37
|
+
style?: unknown;
|
|
38
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
39
|
+
h1: ComponentType<{
|
|
40
|
+
children?: ReactNode;
|
|
41
|
+
style?: unknown;
|
|
42
|
+
}> | ((props: {
|
|
43
|
+
children?: ReactNode;
|
|
44
|
+
style?: unknown;
|
|
45
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
46
|
+
h2: ComponentType<{
|
|
47
|
+
children?: ReactNode;
|
|
48
|
+
style?: unknown;
|
|
49
|
+
}> | ((props: {
|
|
50
|
+
children?: ReactNode;
|
|
51
|
+
style?: unknown;
|
|
52
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
53
|
+
h3: ComponentType<{
|
|
54
|
+
children?: ReactNode;
|
|
55
|
+
style?: unknown;
|
|
56
|
+
}> | ((props: {
|
|
57
|
+
children?: ReactNode;
|
|
58
|
+
style?: unknown;
|
|
59
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
60
|
+
h4: ComponentType<{
|
|
61
|
+
children?: ReactNode;
|
|
62
|
+
style?: unknown;
|
|
63
|
+
}> | ((props: {
|
|
64
|
+
children?: ReactNode;
|
|
65
|
+
style?: unknown;
|
|
66
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
67
|
+
h5: ComponentType<{
|
|
68
|
+
children?: ReactNode;
|
|
69
|
+
style?: unknown;
|
|
70
|
+
}> | ((props: {
|
|
71
|
+
children?: ReactNode;
|
|
72
|
+
style?: unknown;
|
|
73
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
74
|
+
h6: ComponentType<{
|
|
75
|
+
children?: ReactNode;
|
|
76
|
+
style?: unknown;
|
|
77
|
+
}> | ((props: {
|
|
78
|
+
children?: ReactNode;
|
|
79
|
+
style?: unknown;
|
|
80
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
81
|
+
strong: ComponentType<{
|
|
82
|
+
children?: ReactNode;
|
|
83
|
+
style?: unknown;
|
|
84
|
+
}> | ((props: {
|
|
85
|
+
children?: ReactNode;
|
|
86
|
+
style?: unknown;
|
|
87
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
88
|
+
em: ComponentType<{
|
|
89
|
+
children?: ReactNode;
|
|
90
|
+
style?: unknown;
|
|
91
|
+
}> | ((props: {
|
|
92
|
+
children?: ReactNode;
|
|
93
|
+
style?: unknown;
|
|
94
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
95
|
+
del: ComponentType<{
|
|
96
|
+
children?: ReactNode;
|
|
97
|
+
style?: unknown;
|
|
98
|
+
}> | ((props: {
|
|
99
|
+
children?: ReactNode;
|
|
100
|
+
style?: unknown;
|
|
101
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
102
|
+
code: ComponentType<{
|
|
103
|
+
children?: ReactNode;
|
|
104
|
+
style?: unknown;
|
|
105
|
+
}> | ((props: {
|
|
106
|
+
children?: ReactNode;
|
|
107
|
+
style?: unknown;
|
|
108
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
109
|
+
pre: ComponentType<{
|
|
110
|
+
children?: ReactNode;
|
|
111
|
+
style?: unknown;
|
|
112
|
+
}> | ((props: {
|
|
113
|
+
children?: ReactNode;
|
|
114
|
+
style?: unknown;
|
|
115
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
116
|
+
li: ComponentType<{
|
|
117
|
+
children?: ReactNode;
|
|
118
|
+
style?: unknown;
|
|
119
|
+
}> | ((props: {
|
|
120
|
+
children?: ReactNode;
|
|
121
|
+
style?: unknown;
|
|
122
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
123
|
+
th: ComponentType<{
|
|
124
|
+
children?: ReactNode;
|
|
125
|
+
style?: unknown;
|
|
126
|
+
}> | ((props: {
|
|
127
|
+
children?: ReactNode;
|
|
128
|
+
style?: unknown;
|
|
129
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
130
|
+
td: ComponentType<{
|
|
131
|
+
children?: ReactNode;
|
|
132
|
+
style?: unknown;
|
|
133
|
+
}> | ((props: {
|
|
134
|
+
children?: ReactNode;
|
|
135
|
+
style?: unknown;
|
|
136
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
137
|
+
ul: (props: {
|
|
138
|
+
children?: ReactNode;
|
|
139
|
+
style?: unknown;
|
|
140
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
141
|
+
ol: (props: {
|
|
142
|
+
children?: ReactNode;
|
|
143
|
+
style?: unknown;
|
|
144
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
145
|
+
blockquote: (props: {
|
|
146
|
+
children?: ReactNode;
|
|
147
|
+
style?: unknown;
|
|
148
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
149
|
+
hr: ComponentType<{
|
|
150
|
+
children?: ReactNode;
|
|
151
|
+
style?: unknown;
|
|
152
|
+
}> | ((props: {
|
|
153
|
+
children?: ReactNode;
|
|
154
|
+
style?: unknown;
|
|
155
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
156
|
+
table: ComponentType<{
|
|
157
|
+
children?: ReactNode;
|
|
158
|
+
style?: unknown;
|
|
159
|
+
}> | ((props: {
|
|
160
|
+
children?: ReactNode;
|
|
161
|
+
style?: unknown;
|
|
162
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
163
|
+
thead: ComponentType<{
|
|
164
|
+
children?: ReactNode;
|
|
165
|
+
style?: unknown;
|
|
166
|
+
}>;
|
|
167
|
+
tbody: ComponentType<{
|
|
168
|
+
children?: ReactNode;
|
|
169
|
+
style?: unknown;
|
|
170
|
+
}>;
|
|
171
|
+
tr: ComponentType<{
|
|
172
|
+
children?: ReactNode;
|
|
173
|
+
style?: unknown;
|
|
174
|
+
}> | ((props: {
|
|
175
|
+
children?: ReactNode;
|
|
176
|
+
style?: unknown;
|
|
177
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
178
|
+
a: ComponentType<{
|
|
179
|
+
children?: ReactNode;
|
|
180
|
+
style?: unknown;
|
|
181
|
+
}> | ((props: {
|
|
182
|
+
children?: ReactNode;
|
|
183
|
+
style?: unknown;
|
|
184
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
185
|
+
EntryLink: ComponentType<{
|
|
186
|
+
children?: ReactNode;
|
|
187
|
+
style?: unknown;
|
|
188
|
+
}> | ((props: {
|
|
189
|
+
children?: ReactNode;
|
|
190
|
+
style?: unknown;
|
|
191
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>);
|
|
192
|
+
img: ({ src, alt }: {
|
|
193
|
+
src?: string;
|
|
194
|
+
alt?: string;
|
|
195
|
+
}) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
196
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as runtime from "react/jsx-runtime";
|
|
2
|
+
import { defaultReactNativeStyles } from "./reactNativeStyles.js";
|
|
3
|
+
/**
|
|
4
|
+
* Default MDX `components` map for React Native, built from its Text/View/Image primitives. RN has no intrinsic
|
|
5
|
+
* host tags for `p`/`h1`/`a`/etc the way web React does, so every standard markdown element needs an explicit mapping.
|
|
6
|
+
*/
|
|
7
|
+
export function buildReactNativeComponents({ Text, View, Image }, { unstyled, styles } = {}) {
|
|
8
|
+
const styleFor = (tag) => unstyled ? styles?.[tag] : { ...defaultReactNativeStyles[tag], ...styles?.[tag] };
|
|
9
|
+
function styled(Base, tag) {
|
|
10
|
+
const style = styleFor(tag);
|
|
11
|
+
if (!style || Object.keys(style).length === 0)
|
|
12
|
+
return Base;
|
|
13
|
+
return (props) => runtime.jsx(Base, { ...props, style: props.style ? [style, props.style] : style });
|
|
14
|
+
}
|
|
15
|
+
// MDX bakes the source's literal formatting whitespace ("\n" between block elements) into the
|
|
16
|
+
// compiled JSX as string children, even though structurally these tags never hold real text.
|
|
17
|
+
// React Native's Text/View don't tolerate a bare string child of View, so container tags need it
|
|
18
|
+
// stripped — Text-mapped tags (p, li, td, ...) keep it, since inline whitespace there is real content.
|
|
19
|
+
function stripWhitespaceChildren(children) {
|
|
20
|
+
if (Array.isArray(children))
|
|
21
|
+
return children.filter((child) => typeof child !== "string" || child.trim() !== "");
|
|
22
|
+
return typeof children === "string" && children.trim() === "" ? undefined : children;
|
|
23
|
+
}
|
|
24
|
+
function container(Base, tag) {
|
|
25
|
+
const Styled = styled(Base, tag);
|
|
26
|
+
return (props) => runtime.jsx(Styled, { ...props, children: stripWhitespaceChildren(props.children) });
|
|
27
|
+
}
|
|
28
|
+
const Img = ({ src, alt }) => runtime.jsx(Image, {
|
|
29
|
+
source: { uri: src },
|
|
30
|
+
accessibilityLabel: alt,
|
|
31
|
+
style: styleFor("img"),
|
|
32
|
+
});
|
|
33
|
+
const Link = styled(({ children, style }) => runtime.jsx(Text, { children, style }), "a");
|
|
34
|
+
return {
|
|
35
|
+
p: styled(Text, "p"),
|
|
36
|
+
h1: styled(Text, "h1"),
|
|
37
|
+
h2: styled(Text, "h2"),
|
|
38
|
+
h3: styled(Text, "h3"),
|
|
39
|
+
h4: styled(Text, "h4"),
|
|
40
|
+
h5: styled(Text, "h5"),
|
|
41
|
+
h6: styled(Text, "h6"),
|
|
42
|
+
strong: styled(Text, "strong"),
|
|
43
|
+
em: styled(Text, "em"),
|
|
44
|
+
del: styled(Text, "del"),
|
|
45
|
+
code: styled(Text, "code"),
|
|
46
|
+
pre: styled(Text, "pre"),
|
|
47
|
+
li: styled(Text, "li"),
|
|
48
|
+
th: styled(Text, "th"),
|
|
49
|
+
td: styled(Text, "td"),
|
|
50
|
+
ul: container(View, "ul"),
|
|
51
|
+
ol: container(View, "ol"),
|
|
52
|
+
blockquote: container(View, "blockquote"),
|
|
53
|
+
hr: styled(View, "hr"),
|
|
54
|
+
table: styled(View, "table"),
|
|
55
|
+
thead: View,
|
|
56
|
+
tbody: View,
|
|
57
|
+
tr: styled(View, "tr"),
|
|
58
|
+
a: Link,
|
|
59
|
+
EntryLink: Link,
|
|
60
|
+
img: Img,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { buildReactNativeComponents } from "./reactNativeComponents.js";
|
|
4
|
+
// Stand-ins for react-native's Text/View/Image — this package doesn't depend on react-native itself
|
|
5
|
+
// (it only resolves inside Metro, not plain Node), so the mapping is tested against fakes with the same shape.
|
|
6
|
+
const Text = () => null;
|
|
7
|
+
const View = () => null;
|
|
8
|
+
const Image = () => null;
|
|
9
|
+
test("maps standard markdown elements onto Text/View, with default styling applied", () => {
|
|
10
|
+
const components = buildReactNativeComponents({ Text, View, Image });
|
|
11
|
+
const h1 = components.h1({});
|
|
12
|
+
assert.equal(h1.type, Text);
|
|
13
|
+
assert.equal(h1.props.style.fontSize, 28);
|
|
14
|
+
const table = components.table({});
|
|
15
|
+
assert.equal(table.type, View);
|
|
16
|
+
const tr = components.tr({});
|
|
17
|
+
assert.equal(tr.props.style.flexDirection, "row");
|
|
18
|
+
});
|
|
19
|
+
test("unstyled drops the defaults", () => {
|
|
20
|
+
const components = buildReactNativeComponents({ Text, View, Image }, { unstyled: true });
|
|
21
|
+
assert.equal(components.h1, Text); // no style to apply -> returns the primitive directly
|
|
22
|
+
});
|
|
23
|
+
test("styles option overrides a specific tag without needing unstyled", () => {
|
|
24
|
+
const components = buildReactNativeComponents({ Text, View, Image }, { styles: { h1: { fontSize: 40 } } });
|
|
25
|
+
const h1 = components.h1({});
|
|
26
|
+
assert.equal(h1.props.style.fontSize, 40);
|
|
27
|
+
assert.equal(h1.props.style.fontWeight, "700"); // default still applied
|
|
28
|
+
});
|
|
29
|
+
test("img wrapper passes src through as Image's source.uri", () => {
|
|
30
|
+
const components = buildReactNativeComponents({ Text, View, Image });
|
|
31
|
+
const element = components.img({ src: "https://example.com/a.png", alt: "a" });
|
|
32
|
+
const props = element.props;
|
|
33
|
+
assert.equal(props.source.uri, "https://example.com/a.png");
|
|
34
|
+
assert.equal(props.accessibilityLabel, "a");
|
|
35
|
+
});
|
|
36
|
+
test("a and EntryLink both render as styled Text", () => {
|
|
37
|
+
const components = buildReactNativeComponents({ Text, View, Image });
|
|
38
|
+
assert.equal(components.EntryLink, components.a);
|
|
39
|
+
const element = components.a({ children: "hi" });
|
|
40
|
+
const inner = element.type(element.props);
|
|
41
|
+
assert.equal(inner.type, Text);
|
|
42
|
+
assert.equal(inner.props.style.color, "#2563eb");
|
|
43
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Default per-tag style objects for the React Native preset — mirrors `styles.css`'s web defaults
|
|
2
|
+
* so RN output looks like the web default instead of unstyled plain text. */
|
|
3
|
+
export const defaultReactNativeStyles = {
|
|
4
|
+
p: { marginBottom: 12, lineHeight: 22 },
|
|
5
|
+
h1: { fontSize: 28, fontWeight: "700", marginTop: 20, marginBottom: 10 },
|
|
6
|
+
h2: { fontSize: 24, fontWeight: "700", marginTop: 18, marginBottom: 8 },
|
|
7
|
+
h3: { fontSize: 20, fontWeight: "700", marginTop: 16, marginBottom: 8 },
|
|
8
|
+
h4: { fontSize: 18, fontWeight: "600", marginTop: 14, marginBottom: 6 },
|
|
9
|
+
h5: { fontSize: 16, fontWeight: "600", marginTop: 12, marginBottom: 6 },
|
|
10
|
+
h6: { fontSize: 14, fontWeight: "600", marginTop: 12, marginBottom: 6 },
|
|
11
|
+
strong: { fontWeight: "700" },
|
|
12
|
+
em: { fontStyle: "italic" },
|
|
13
|
+
del: { textDecorationLine: "line-through" },
|
|
14
|
+
code: {
|
|
15
|
+
fontFamily: "Courier",
|
|
16
|
+
backgroundColor: "#f2f2f2",
|
|
17
|
+
paddingHorizontal: 4,
|
|
18
|
+
borderRadius: 3,
|
|
19
|
+
},
|
|
20
|
+
pre: {
|
|
21
|
+
fontFamily: "Courier",
|
|
22
|
+
backgroundColor: "#f2f2f2",
|
|
23
|
+
padding: 12,
|
|
24
|
+
borderRadius: 6,
|
|
25
|
+
marginBottom: 12,
|
|
26
|
+
},
|
|
27
|
+
blockquote: {
|
|
28
|
+
borderLeftWidth: 3,
|
|
29
|
+
borderLeftColor: "#d0d0d0",
|
|
30
|
+
paddingLeft: 12,
|
|
31
|
+
marginBottom: 12,
|
|
32
|
+
opacity: 0.85,
|
|
33
|
+
},
|
|
34
|
+
li: { marginBottom: 4 },
|
|
35
|
+
ul: { marginBottom: 12 },
|
|
36
|
+
ol: { marginBottom: 12 },
|
|
37
|
+
hr: { borderBottomWidth: 1, borderBottomColor: "#d0d0d0", marginVertical: 16 },
|
|
38
|
+
table: { borderWidth: 1, borderColor: "#d0d0d0", marginBottom: 12 },
|
|
39
|
+
// RN has no native table layout — `tr` lays cells out as a row (flex), `th`/`td` split
|
|
40
|
+
// the row evenly; only a bottom rule separates rows (no per-cell borders, unlike web).
|
|
41
|
+
tr: { flexDirection: "row", borderBottomWidth: 1, borderColor: "#d0d0d0" },
|
|
42
|
+
th: { flex: 1, fontWeight: "700", padding: 6 },
|
|
43
|
+
td: { flex: 1, padding: 6 },
|
|
44
|
+
a: { color: "#2563eb", textDecorationLine: "underline" },
|
|
45
|
+
};
|
package/dist/toHtml.d.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*/
|
|
7
|
-
|
|
1
|
+
/** Renders a custom JSX tag to an HTML string. `props` values are always strings (HTML parsing,
|
|
2
|
+
* not JSX). `childrenHtml` is the tag's contents, already rendered, nested tags resolved first. */
|
|
3
|
+
export type ToHtmlComponent = (props: Record<string, string>, childrenHtml: string) => string;
|
|
4
|
+
export interface ToHtmlOptions {
|
|
5
|
+
/** Renders custom tags (e.g. `<Callout>`, `<EntryLink id>`) by tag name, case-insensitive.
|
|
6
|
+
* `EntryLink` defaults to `<a href="/entries/{id}">` unless overridden. */
|
|
7
|
+
components?: Record<string, ToHtmlComponent>;
|
|
8
|
+
/** Adds `target`/`rel` to links whose `href` has a URL scheme (`https:`, `mailto:`, ...).
|
|
9
|
+
* `true` uses `target="_blank" rel="noopener noreferrer"`; pass an object to override. */
|
|
10
|
+
externalLinks?: boolean | {
|
|
11
|
+
target?: string;
|
|
12
|
+
rel?: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** Renders MDX/markdown to a static HTML string — no React, no mounted tree. Use
|
|
16
|
+
* `compileMDX`/`MDXContent` instead when you need a React tree. */
|
|
17
|
+
export declare function toHtml(source: string, options?: ToHtmlOptions): Promise<string>;
|
package/dist/toHtml.js
CHANGED
|
@@ -2,21 +2,73 @@ import { unified } from "unified";
|
|
|
2
2
|
import remarkParse from "remark-parse";
|
|
3
3
|
import remarkGfm from "remark-gfm";
|
|
4
4
|
import remarkRehype from "remark-rehype";
|
|
5
|
+
import rehypeRaw from "rehype-raw";
|
|
5
6
|
import rehypeSlug from "rehype-slug";
|
|
6
7
|
import rehypeStringify from "rehype-stringify";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
import { toHtml as hastToHtml } from "hast-util-to-html";
|
|
9
|
+
const SCHEME_HREF = /^[a-z][a-z0-9+.-]*:/i;
|
|
10
|
+
const defaultEntryLink = (props, childrenHtml) => `<a href="/entries/${props.id ?? ""}">${childrenHtml}</a>`;
|
|
11
|
+
function hastPropsToStrings(properties) {
|
|
12
|
+
const props = {};
|
|
13
|
+
for (const [key, value] of Object.entries(properties ?? {})) {
|
|
14
|
+
if (typeof value === "string")
|
|
15
|
+
props[key] = value;
|
|
16
|
+
else if (Array.isArray(value))
|
|
17
|
+
props[key] = value.join(" ");
|
|
18
|
+
}
|
|
19
|
+
return props;
|
|
20
|
+
}
|
|
21
|
+
function rehypeDraftbase(options) {
|
|
22
|
+
const componentsByTag = options.components
|
|
23
|
+
? new Map(Object.entries({ EntryLink: defaultEntryLink, ...options.components }).map(([name, render]) => [name.toLowerCase(), render]))
|
|
24
|
+
: undefined;
|
|
25
|
+
return (tree) => {
|
|
26
|
+
function transform(node) {
|
|
27
|
+
for (const child of node.children ?? [])
|
|
28
|
+
transform(child);
|
|
29
|
+
if (node.type !== "element" || !node.tagName)
|
|
30
|
+
return;
|
|
31
|
+
const render = componentsByTag?.get(node.tagName);
|
|
32
|
+
if (render) {
|
|
33
|
+
const childrenHtml = hastToHtml({ type: "root", children: node.children ?? [] }, {
|
|
34
|
+
allowDangerousHtml: true,
|
|
35
|
+
});
|
|
36
|
+
node.value = render(hastPropsToStrings(node.properties), childrenHtml);
|
|
37
|
+
node.type = "raw";
|
|
38
|
+
node.tagName = undefined;
|
|
39
|
+
node.properties = undefined;
|
|
40
|
+
node.children = undefined;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (node.tagName === "a" && options.externalLinks) {
|
|
44
|
+
const href = node.properties?.href;
|
|
45
|
+
if (typeof href !== "string" || !SCHEME_HREF.test(href))
|
|
46
|
+
return;
|
|
47
|
+
const overrides = typeof options.externalLinks === "object" ? options.externalLinks : {};
|
|
48
|
+
node.properties = {
|
|
49
|
+
...node.properties,
|
|
50
|
+
target: overrides.target ?? "_blank",
|
|
51
|
+
rel: overrides.rel ?? "noopener noreferrer",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
transform(tree);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Renders MDX/markdown to a static HTML string — no React, no mounted tree. Use
|
|
59
|
+
* `compileMDX`/`MDXContent` instead when you need a React tree. */
|
|
60
|
+
export async function toHtml(source, options = {}) {
|
|
61
|
+
const needsRawParse = Boolean(options.components);
|
|
62
|
+
const processor = unified()
|
|
15
63
|
.use(remarkParse)
|
|
16
64
|
.use(remarkGfm)
|
|
17
|
-
.use(remarkRehype, { allowDangerousHtml: true })
|
|
65
|
+
.use(remarkRehype, { allowDangerousHtml: true });
|
|
66
|
+
if (needsRawParse)
|
|
67
|
+
processor.use(rehypeRaw);
|
|
68
|
+
processor
|
|
18
69
|
.use(rehypeSlug)
|
|
19
|
-
.use(
|
|
20
|
-
.
|
|
70
|
+
.use(rehypeDraftbase, options)
|
|
71
|
+
.use(rehypeStringify, { allowDangerousHtml: true });
|
|
72
|
+
const file = await processor.process(source);
|
|
21
73
|
return String(file);
|
|
22
74
|
}
|
package/dist/toHtml.test.js
CHANGED
|
@@ -10,3 +10,43 @@ test("supports gfm tables", async () => {
|
|
|
10
10
|
const html = await toHtml("| a | b |\n| - | - |\n| 1 | 2 |");
|
|
11
11
|
assert.match(html, /<table>/);
|
|
12
12
|
});
|
|
13
|
+
test("leaves custom tags as literal HTML when components isn't passed", async () => {
|
|
14
|
+
const html = await toHtml('<EntryLink id="abc">Post</EntryLink><Callout>hi</Callout>');
|
|
15
|
+
assert.match(html, /<EntryLink id="abc">Post<\/EntryLink>/);
|
|
16
|
+
assert.match(html, /<Callout>hi<\/Callout>/);
|
|
17
|
+
});
|
|
18
|
+
test("EntryLink defaults to /entries/{id} once components is passed", async () => {
|
|
19
|
+
const html = await toHtml('<EntryLink id="abc">Post</EntryLink>', { components: {} });
|
|
20
|
+
assert.match(html, /<a href="\/entries\/abc">Post<\/a>/);
|
|
21
|
+
});
|
|
22
|
+
test("components renders a custom tag, keyed case-insensitively, with string props", async () => {
|
|
23
|
+
const html = await toHtml('<Callout type="warning">Careful</Callout>', {
|
|
24
|
+
components: {
|
|
25
|
+
Callout: (props, childrenHtml) => `<div class="callout-${props.type}">${childrenHtml}</div>`,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
assert.match(html, /<div class="callout-warning">Careful<\/div>/);
|
|
29
|
+
});
|
|
30
|
+
test("components overrides the default EntryLink", async () => {
|
|
31
|
+
const html = await toHtml('<EntryLink id="abc">Post</EntryLink>', {
|
|
32
|
+
components: {
|
|
33
|
+
EntryLink: (props, childrenHtml) => `<a href="/blog/${props.id}">${childrenHtml}</a>`,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
assert.match(html, /<a href="\/blog\/abc">Post<\/a>/);
|
|
37
|
+
});
|
|
38
|
+
test("components resolves nested custom tags before serializing the parent's children", async () => {
|
|
39
|
+
const html = await toHtml('<Callout><EntryLink id="abc">Post</EntryLink></Callout>', {
|
|
40
|
+
components: {
|
|
41
|
+
Callout: (_props, childrenHtml) => `<div>${childrenHtml}</div>`,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
assert.match(html, /<div><a href="\/entries\/abc">Post<\/a><\/div>/);
|
|
45
|
+
});
|
|
46
|
+
test("adds target/rel to external links when externalLinks is true", async () => {
|
|
47
|
+
const html = await toHtml("[ext](https://example.com) and [rel](/local)", {
|
|
48
|
+
externalLinks: true,
|
|
49
|
+
});
|
|
50
|
+
assert.match(html, /<a href="https:\/\/example\.com" target="_blank" rel="noopener noreferrer">ext<\/a>/);
|
|
51
|
+
assert.match(html, /<a href="\/local">rel<\/a>/);
|
|
52
|
+
});
|
package/dist/vue.d.ts
CHANGED
|
@@ -6,8 +6,7 @@ export interface CompiledMDX {
|
|
|
6
6
|
}
|
|
7
7
|
export type { FailedMDX };
|
|
8
8
|
/**
|
|
9
|
-
* Compiles raw MDX/markdown into a renderable Vue component. Vue has no RSC-style
|
|
10
|
-
*
|
|
11
|
-
* yourself: `h(Content, props)` or `<component :is="Content" />`.
|
|
9
|
+
* Compiles raw MDX/markdown into a renderable Vue component. Vue has no RSC-style async component, so call this
|
|
10
|
+
* from `setup()`/a composable and render the result yourself: `h(Content, props)`.
|
|
12
11
|
*/
|
|
13
12
|
export declare function compileMDX(source: string): Promise<CompiledMDX | FailedMDX>;
|
package/dist/vue.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { compileMDXCore } from "./core.js";
|
|
1
|
+
import { compileMDXCore, makeDefaultEntryLink } from "./core.js";
|
|
2
2
|
import { vueJsxRuntime } from "./vueRuntime.js";
|
|
3
|
+
const defaultComponents = { EntryLink: makeDefaultEntryLink(vueJsxRuntime) };
|
|
3
4
|
/**
|
|
4
|
-
* Compiles raw MDX/markdown into a renderable Vue component. Vue has no RSC-style
|
|
5
|
-
*
|
|
6
|
-
* yourself: `h(Content, props)` or `<component :is="Content" />`.
|
|
5
|
+
* Compiles raw MDX/markdown into a renderable Vue component. Vue has no RSC-style async component, so call this
|
|
6
|
+
* from `setup()`/a composable and render the result yourself: `h(Content, props)`.
|
|
7
7
|
*/
|
|
8
8
|
export async function compileMDX(source) {
|
|
9
|
-
return compileMDXCore(source, vueJsxRuntime);
|
|
9
|
+
return compileMDXCore(source, vueJsxRuntime, defaultComponents);
|
|
10
10
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@draftbase/renderer",
|
|
3
|
-
"version": "0.3
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Framework-agnostic MDX renderer for Draftbase content (React, Next.js RSC, React Native, Astro, Vite, Vue)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"draftbase",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"exports": {
|
|
32
32
|
".": "./dist/index.js",
|
|
33
33
|
"./vue": "./dist/vue.js",
|
|
34
|
+
"./react-native": "./dist/reactNative.js",
|
|
34
35
|
"./styles.css": "./dist/styles.css"
|
|
35
36
|
},
|
|
36
37
|
"files": [
|
|
@@ -41,7 +42,7 @@
|
|
|
41
42
|
"scripts": {
|
|
42
43
|
"build": "tsc -p tsconfig.json && cp src/styles.css dist/styles.css",
|
|
43
44
|
"lint": "tsc --noEmit",
|
|
44
|
-
"test": "node --test dist/wrapperClassName.test.js dist/toHtml.test.js dist/compileMDX.test.js"
|
|
45
|
+
"test": "node --test dist/wrapperClassName.test.js dist/toHtml.test.js dist/compileMDX.test.js dist/reactNativeComponents.test.js dist/reactNative.test.js dist/MDXContent.test.js"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
47
48
|
"react": ">=18",
|
|
@@ -60,7 +61,9 @@
|
|
|
60
61
|
"remark-gfm": "^4.0.0",
|
|
61
62
|
"remark-parse": "^11.0.0",
|
|
62
63
|
"remark-rehype": "^11.1.2",
|
|
64
|
+
"rehype-raw": "^7.0.0",
|
|
63
65
|
"rehype-slug": "^6.0.0",
|
|
66
|
+
"hast-util-to-html": "^9.0.0",
|
|
64
67
|
"rehype-stringify": "^10.0.1",
|
|
65
68
|
"unified": "^11.0.5"
|
|
66
69
|
},
|