@draftbase/renderer 0.4.3 → 0.5.2

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 CHANGED
@@ -126,6 +126,8 @@ createReactNativeRenderer({ Text, View, Image }, { unstyled: true });
126
126
 
127
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.
128
128
 
129
+ RN has no DOM to silently fall back on the way web does — a JSX component the source references but you didn't map (a typo'd custom component, a raw HTML tag RN has no native view for) would otherwise crash the whole screen. `Content` catches that automatically: it's logged via `console.error` and the raw source renders as plain text in place of the broken content, instead of taking down the app. No setup needed — this is on by default, unlike the web/Vue entry points where you wrap `Content` in `MDXErrorBoundary` yourself.
130
+
129
131
  ## Astro
130
132
 
131
133
  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:
@@ -290,6 +292,25 @@ function EntryLink({ id, children }: { id: string; children: React.ReactNode })
290
292
  <MDXContent source={entry.fields.body} components={{ EntryLink }} />;
291
293
  ```
292
294
 
295
+ ## Images
296
+
297
+ A markdown image (`![alt](url)`) renders as a plain `<img loading="lazy" decoding="async">` by default — no framework-specific optimizer, so this works identically across every web/RSC/Vue target and `toHtml`. Swap in your own (e.g. Next.js's `<Image>`) by overriding `img` like any other standard element:
298
+
299
+ ```tsx
300
+ import NextImage from "next/image";
301
+
302
+ <MDXContent
303
+ source={entry.fields.body}
304
+ components={{
305
+ img: ({ src, alt }) => <NextImage src={src} alt={alt ?? ""} width={800} height={450} />,
306
+ }}
307
+ />;
308
+ ```
309
+
310
+ An explicit `loading`/`decoding` written into the source (rare, but possible via raw `<img>` JSX in MDX) still wins over the default — only the no-attributes case gets the lazy default filled in.
311
+
312
+ Every Draftbase asset URL — the one in an `![]()` src, or `asset.url` from `@draftbase/sdk` used outside MDX entirely — also supports on-demand resizing via query params: append `?w=800` (and/or `&h=450`) to shrink it server-side, capped to the size it was originally uploaded at (never upscales). Sizes are snapped to a fixed breakpoint set at the CDN edge, so requesting arbitrary values (`?w=803`) still hits a shared cache entry instead of minting a new one per pixel.
313
+
293
314
  ## Styling
294
315
 
295
316
  `@draftbase/renderer/styles.css` wraps output in a `.db-content` class with slim, sensible defaults (typography, tables, code blocks). Web-only, opt-in:
@@ -310,7 +331,7 @@ If you're an agent wiring this into a project, follow this checklist:
310
331
  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.
311
332
  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.
312
333
  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.
313
- 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.
334
+ 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 render as an unstyled RN default (React Native's `createReactNativeRenderer` already ships styled defaults, so this mainly matters for a fully custom RN setup). React Native specifically never crashes the screen over this — an unmapped/missing component logs to `console.error` and falls back to plain text instead. For plain HTML output (email, RSS, non-React embeds) use `toHtml` instead of a component tree.
314
335
  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.
315
336
  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.
316
337
 
@@ -19,7 +19,7 @@ export interface MDXContentProps {
19
19
  /** Custom components available by name inside the MDX source, and/or overrides for standard markdown elements —
20
20
  * required on non-DOM renderers such as React Native, which have no intrinsic `div`/`p`/`a` tags. */
21
21
  components?: MDXComponents;
22
- /** Skip the default `db-content` styling class. */
22
+ /** Skip the default `db-content` styling class and its inlined styles. */
23
23
  unstyled?: boolean;
24
24
  /** Extra class name(s) merged onto the wrapper element. */
25
25
  className?: string;
@@ -29,9 +29,13 @@ export interface MDXContentProps {
29
29
  /** Element/component used to render the fallback plain-text output when `source` fails to
30
30
  * compile as MDX. Defaults to `"p"`; pass React Native's `Text`. */
31
31
  errorTag?: ElementType;
32
+ /** Called (in addition to `console.error`) when `source` fails to compile, or a component
33
+ * throws while rendering. Either way the fallback plain-text output still renders instead of
34
+ * crashing. */
35
+ onError?: (error: unknown) => void;
32
36
  }
33
37
  /**
34
38
  * Next.js Server Component wrapper around {@link compileMDX}. For non-RSC React, call `compileMDX` directly instead —
35
39
  * `await` inside a component body only works as an RSC.
36
40
  */
37
- export declare function MDXContent({ source, components, unstyled, className, wrapperTag, errorTag, }: MDXContentProps): Promise<ReactNode>;
41
+ export declare function MDXContent({ source, components, unstyled, className, wrapperTag, errorTag, onError, }: MDXContentProps): Promise<ReactNode>;
@@ -1,9 +1,13 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as runtime from "react/jsx-runtime";
3
- import { compileMDXCore, makeDefaultEntryLink } from "./core.js";
3
+ import { compileMDXCore, makeDefaultEntryLink, makeDefaultImage } from "./core.js";
4
4
  import { wrapperClassName } from "./wrapperClassName.js";
5
5
  import { MDXErrorBoundary } from "./MDXErrorBoundary.js";
6
- const defaultComponents = { EntryLink: makeDefaultEntryLink(runtime) };
6
+ import { CSS_TEXT } from "./cssText.js";
7
+ const defaultComponents = {
8
+ EntryLink: makeDefaultEntryLink(runtime),
9
+ img: makeDefaultImage(runtime),
10
+ };
7
11
  /**
8
12
  * Compiles raw MDX/markdown into a renderable React component — no DOM assumptions, safe for client effects or RSC.
9
13
  * Standard markdown elements render via real DOM tags with zero setup; only a JSX component with no built-in default needs `components`.
@@ -15,17 +19,21 @@ export async function compileMDX(source) {
15
19
  * Next.js Server Component wrapper around {@link compileMDX}. For non-RSC React, call `compileMDX` directly instead —
16
20
  * `await` inside a component body only works as an RSC.
17
21
  */
18
- export async function MDXContent({ source, components, unstyled, className, wrapperTag = "div", errorTag = "p", }) {
22
+ export async function MDXContent({ source, components, unstyled, className, wrapperTag = "div", errorTag = "p", onError, }) {
19
23
  const Wrapper = wrapperTag;
20
24
  const ErrorTag = errorTag;
21
25
  const wrapperClass = wrapperClassName(unstyled, className);
26
+ // `href` + `precedence` make React DOM treat this as a de-duplicated, hoisted stylesheet
27
+ // resource (React 19+) instead of a plain inline tag repeated per render.
28
+ const styles = unstyled ? null : (_jsx("style", { href: "db-content-styles", precedence: "default", children: CSS_TEXT }));
22
29
  const compiled = await compileMDX(source);
23
30
  if (!compiled.ok) {
24
31
  // Source isn't valid MDX/JSX (e.g. stray `<`/`{` in prose) — fail soft instead
25
32
  // of crashing the page; render it as plain text so the copy still shows.
26
33
  console.error("MDX compile failed, rendering as plain text", compiled.error);
27
- return (_jsx(Wrapper, { className: wrapperClass, children: _jsx(ErrorTag, { children: source }) }));
34
+ onError?.(compiled.error);
35
+ return (_jsxs(_Fragment, { children: [styles, _jsx(Wrapper, { className: wrapperClass, children: _jsx(ErrorTag, { children: source }) })] }));
28
36
  }
29
37
  const { Content } = compiled;
30
- return (_jsx(Wrapper, { className: wrapperClass, children: _jsx(MDXErrorBoundary, { fallback: _jsx(ErrorTag, { children: source }), children: _jsx(Content, { components: components }) }) }));
38
+ return (_jsxs(_Fragment, { children: [styles, _jsx(Wrapper, { className: wrapperClass, children: _jsx(MDXErrorBoundary, { fallback: _jsx(ErrorTag, { children: source }), onError: onError, children: _jsx(Content, { components: components }) }) })] }));
31
39
  }
@@ -1,18 +1,33 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
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.
4
+ // MDXContent is an async Server Component; call it directly (as React's RSC runtime would) and inspect
5
+ // the returned element tree. It returns a fragment: [inlined <style> | null, the wrapper element].
6
+ function wrapperOf(fragment) {
7
+ const [, wrapper] = fragment.props.children;
8
+ return wrapper;
9
+ }
5
10
  test("MDXContent wraps compiled MDX in the styled wrapper element", async () => {
6
- const element = (await MDXContent({ source: "# Hello" }));
11
+ const fragment = (await MDXContent({ source: "# Hello" }));
12
+ const [styles] = fragment.props.children;
13
+ assert.equal(styles.type, "style");
14
+ const element = wrapperOf(fragment);
7
15
  assert.equal(element.type, "div");
8
16
  assert.equal(element.props.className, "db-content");
9
17
  });
10
- test("unstyled drops the db-content class", async () => {
11
- const element = (await MDXContent({ source: "# Hello", unstyled: true }));
18
+ test("unstyled drops the db-content class and the inlined styles", async () => {
19
+ const fragment = (await MDXContent({
20
+ source: "# Hello",
21
+ unstyled: true,
22
+ }));
23
+ const [styles] = fragment.props.children;
24
+ assert.equal(styles, null);
25
+ const element = wrapperOf(fragment);
12
26
  assert.equal(element.props.className, undefined);
13
27
  });
14
28
  test("falls back to plain text when the source fails to compile as MDX", async () => {
15
- const element = (await MDXContent({ source: "<broken" }));
29
+ const fragment = (await MDXContent({ source: "<broken" }));
30
+ const element = wrapperOf(fragment);
16
31
  const fallback = element.props.children;
17
32
  assert.equal(fallback.type, "p");
18
33
  assert.equal(fallback.props.children, "<broken");
@@ -2,6 +2,7 @@ import { Component, type ReactNode } from "react";
2
2
  interface MDXErrorBoundaryProps {
3
3
  children: ReactNode;
4
4
  fallback: ReactNode;
5
+ onError?: (error: unknown) => void;
5
6
  }
6
7
  interface MDXErrorBoundaryState {
7
8
  hasError: boolean;
@@ -9,6 +9,7 @@ export class MDXErrorBoundary extends Component {
9
9
  }
10
10
  componentDidCatch(error) {
11
11
  console.error("MDX content failed to render", error);
12
+ this.props.onError?.(error);
12
13
  }
13
14
  render() {
14
15
  return this.state.hasError ? this.props.fallback : this.props.children;
@@ -44,6 +44,35 @@ test("vue EntryLink renders as a link to /entries/{id} with no components map su
44
44
  assert.equal(link.type, "a");
45
45
  assert.equal(link.props.href, "/entries/abc123");
46
46
  });
47
+ // A standalone `![]()` compiles to a <p><img/></p> — resolve the paragraph, then its one child.
48
+ function imgFrom(paragraph) {
49
+ const child = paragraph.props.children;
50
+ return resolve(Array.isArray(child) ? child[0] : child);
51
+ }
52
+ test("react img gets lazy-loading defaults with no components map supplied", async () => {
53
+ const result = await compileReactMDX("![alt text](https://example.com/a.png)");
54
+ assert.equal(result.ok, true);
55
+ if (!result.ok)
56
+ return;
57
+ const paragraph = resolve(result.Content({}));
58
+ const img = imgFrom(paragraph);
59
+ assert.equal(img.type, "img");
60
+ assert.equal(img.props.loading, "lazy");
61
+ assert.equal(img.props.decoding, "async");
62
+ assert.equal(img.props.src, "https://example.com/a.png");
63
+ });
64
+ test("a supplied img override still wins over the default", async () => {
65
+ const result = await compileReactMDX("![alt text](https://example.com/a.png)");
66
+ assert.equal(result.ok, true);
67
+ if (!result.ok)
68
+ return;
69
+ const CustomImage = () => ({ type: "custom-image", props: {} });
70
+ const paragraph = resolve(result.Content({
71
+ components: { img: CustomImage },
72
+ }));
73
+ const img = imgFrom(paragraph);
74
+ assert.equal(img.type, "custom-image");
75
+ });
47
76
  test("a supplied EntryLink override still wins over the default", async () => {
48
77
  const result = await compileReactMDX('<EntryLink id="abc123">Read more</EntryLink>');
49
78
  assert.equal(result.ok, true);
package/dist/core.d.ts CHANGED
@@ -19,3 +19,7 @@ export declare function makeDefaultEntryLink(jsxRuntime: JsxRuntime): ({ id, chi
19
19
  id?: string;
20
20
  children?: unknown;
21
21
  }) => JSX.Element;
22
+ /** Default `img` for a given JSX runtime — defers offscreen image loads instead of the browser's
23
+ * eager default. Apps that want `next/image` (or another optimizer) override it via `components.img`;
24
+ * an explicit `loading`/`decoding` on the element itself (rare, but author-settable) still wins. */
25
+ export declare function makeDefaultImage(jsxRuntime: JsxRuntime): (props: Record<string, unknown>) => JSX.Element;
package/dist/core.js CHANGED
@@ -38,3 +38,11 @@ export function makeDefaultEntryLink(jsxRuntime) {
38
38
  return jsxRuntime.jsx("a", { href: id ? `/entries/${id}` : undefined, children });
39
39
  };
40
40
  }
41
+ /** Default `img` for a given JSX runtime — defers offscreen image loads instead of the browser's
42
+ * eager default. Apps that want `next/image` (or another optimizer) override it via `components.img`;
43
+ * an explicit `loading`/`decoding` on the element itself (rare, but author-settable) still wins. */
44
+ export function makeDefaultImage(jsxRuntime) {
45
+ return function Img(props) {
46
+ return jsxRuntime.jsx("img", { loading: "lazy", decoding: "async", ...props });
47
+ };
48
+ }
@@ -0,0 +1 @@
1
+ export declare const CSS_TEXT = "/* Slim default typography for Draftbase MDX content.\n Opt-in: import \"@draftbase/renderer/styles.css\".\n Disable per-render: <MDXContent unstyled /> or toHtml(source, { unstyled: true }).\n Theme via custom properties: --db-heading-color, --db-link-color, --db-accent-border,\n --db-target-bg. Override further: target \".db-content\" with higher-specificity or\n later-loaded rules. */\n\n.db-content {\n line-height: 1.6;\n color: inherit;\n overflow-wrap: break-word;\n}\n\n.db-content h1,\n.db-content h2,\n.db-content h3,\n.db-content h4,\n.db-content h5,\n.db-content h6 {\n color: var(--db-heading-color, inherit);\n}\n\n.db-content a {\n color: var(--db-link-color, inherit);\n}\n\n/* remark-gfm emits the footnotes label as .sr-only; define it so it stays hidden\n without the consumer needing Tailwind or their own utility. */\n.db-content .sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n}\n\n.db-content > :first-child {\n margin-top: 0;\n}\n.db-content > :last-child {\n margin-bottom: 0;\n}\n\n.db-content h1,\n.db-content h2,\n.db-content h3,\n.db-content h4,\n.db-content h5,\n.db-content h6 {\n font-weight: 600;\n line-height: 1.25;\n margin: 1.5em 0 0.5em;\n}\n\n/* Highlights a footnote/citation anchor when the URL hash lands on it, e.g. #fn-1. */\n.db-content :target {\n background: var(--db-target-bg, rgba(127, 127, 127, 0.15));\n}\n\n.db-content h1 {\n font-size: 2.25em;\n font-weight: 700;\n line-height: 1.2;\n letter-spacing: -0.025em;\n margin-top: 1.25em;\n}\n\n.db-content h2 {\n font-size: 1.5em;\n line-height: 1.25;\n letter-spacing: -0.02em;\n margin-top: 1.75em;\n}\n\n.db-content h3 {\n font-size: 1.25em;\n line-height: 1.3;\n}\n\n.db-content h4 {\n font-size: 1.125em;\n line-height: 1.35;\n}\n\n.db-content h5 {\n font-size: 1em;\n line-height: 1.4;\n}\n\n.db-content h6 {\n font-size: 0.875em;\n line-height: 1.4;\n}\n\n@media (max-width: 640px) {\n .db-content h1 {\n font-size: 1.875em;\n }\n .db-content h2 {\n font-size: 1.25em;\n }\n .db-content h3 {\n font-size: 1.125em;\n }\n}\n\n.db-content p,\n.db-content ul,\n.db-content ol,\n.db-content blockquote,\n.db-content pre,\n.db-content table {\n margin: 0 0 1em;\n}\n\n.db-content ul,\n.db-content ol {\n padding-left: 1.5em;\n}\n\n.db-content ul {\n list-style-type: disc;\n}\n\n.db-content ol {\n list-style-type: decimal;\n}\n\n.db-content ul ul {\n list-style-type: circle;\n}\n\n.db-content ol ol {\n list-style-type: lower-alpha;\n}\n\n.db-content ul ul ul,\n.db-content ol ol ol {\n list-style-type: square;\n}\n\n.db-content li {\n margin: 0.25em 0;\n}\n.db-content li > ul,\n.db-content li > ol {\n margin: 0.25em 0 0;\n}\n.db-content li > p {\n margin: 0;\n}\n\n.db-content ul.contains-task-list {\n list-style: none;\n padding-left: 0.25em;\n}\n.db-content li.task-list-item {\n list-style: none;\n margin-left: 0;\n}\n.db-content li.task-list-item input[type=\"checkbox\"] {\n margin-right: 0.5em;\n}\n\n.db-content strong {\n font-weight: 600;\n}\n.db-content em {\n font-style: italic;\n}\n.db-content del {\n text-decoration: line-through;\n opacity: 0.75;\n}\n\n.db-content blockquote {\n margin-left: 0;\n padding-left: 1em;\n border-left: 3px solid var(--db-accent-border, currentColor);\n opacity: 0.85;\n}\n\n.db-content code {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 0.9em;\n padding: 0.15em 0.35em;\n background: rgba(127, 127, 127, 0.15);\n border-radius: 4px;\n}\n\n.db-content pre {\n padding: 1em;\n overflow-x: auto;\n background: rgba(127, 127, 127, 0.1);\n border-radius: 8px;\n}\n.db-content pre code {\n padding: 0;\n background: none;\n}\n\n/* display:block so wide tables scroll instead of overflowing the page on mobile;\n costs full-bleed stretch for narrow tables. */\n.db-content table {\n border-collapse: collapse;\n display: block;\n width: max-content;\n max-width: 100%;\n overflow-x: auto;\n}\n\n.db-content th,\n.db-content td {\n border: 1px solid rgba(127, 127, 127, 0.3);\n padding: 0.5em 0.75em;\n text-align: left;\n}\n\n.db-content th {\n background: rgba(127, 127, 127, 0.08);\n font-weight: 600;\n}\n\n.db-content img {\n max-width: 100%;\n height: auto;\n border-radius: 8px;\n}\n\n.db-content a {\n text-decoration: underline;\n text-underline-offset: 2px;\n}\n.db-content a:hover {\n opacity: 0.8;\n}\n\n.db-content hr {\n border: none;\n border-top: 1px solid rgba(127, 127, 127, 0.3);\n margin: 2em 0;\n}\n";
@@ -0,0 +1,2 @@
1
+ // Generated from styles.css by scripts/generate-css-text.mjs — do not edit directly.
2
+ export const CSS_TEXT = "/* Slim default typography for Draftbase MDX content.\n Opt-in: import \"@draftbase/renderer/styles.css\".\n Disable per-render: <MDXContent unstyled /> or toHtml(source, { unstyled: true }).\n Theme via custom properties: --db-heading-color, --db-link-color, --db-accent-border,\n --db-target-bg. Override further: target \".db-content\" with higher-specificity or\n later-loaded rules. */\n\n.db-content {\n line-height: 1.6;\n color: inherit;\n overflow-wrap: break-word;\n}\n\n.db-content h1,\n.db-content h2,\n.db-content h3,\n.db-content h4,\n.db-content h5,\n.db-content h6 {\n color: var(--db-heading-color, inherit);\n}\n\n.db-content a {\n color: var(--db-link-color, inherit);\n}\n\n/* remark-gfm emits the footnotes label as .sr-only; define it so it stays hidden\n without the consumer needing Tailwind or their own utility. */\n.db-content .sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n}\n\n.db-content > :first-child {\n margin-top: 0;\n}\n.db-content > :last-child {\n margin-bottom: 0;\n}\n\n.db-content h1,\n.db-content h2,\n.db-content h3,\n.db-content h4,\n.db-content h5,\n.db-content h6 {\n font-weight: 600;\n line-height: 1.25;\n margin: 1.5em 0 0.5em;\n}\n\n/* Highlights a footnote/citation anchor when the URL hash lands on it, e.g. #fn-1. */\n.db-content :target {\n background: var(--db-target-bg, rgba(127, 127, 127, 0.15));\n}\n\n.db-content h1 {\n font-size: 2.25em;\n font-weight: 700;\n line-height: 1.2;\n letter-spacing: -0.025em;\n margin-top: 1.25em;\n}\n\n.db-content h2 {\n font-size: 1.5em;\n line-height: 1.25;\n letter-spacing: -0.02em;\n margin-top: 1.75em;\n}\n\n.db-content h3 {\n font-size: 1.25em;\n line-height: 1.3;\n}\n\n.db-content h4 {\n font-size: 1.125em;\n line-height: 1.35;\n}\n\n.db-content h5 {\n font-size: 1em;\n line-height: 1.4;\n}\n\n.db-content h6 {\n font-size: 0.875em;\n line-height: 1.4;\n}\n\n@media (max-width: 640px) {\n .db-content h1 {\n font-size: 1.875em;\n }\n .db-content h2 {\n font-size: 1.25em;\n }\n .db-content h3 {\n font-size: 1.125em;\n }\n}\n\n.db-content p,\n.db-content ul,\n.db-content ol,\n.db-content blockquote,\n.db-content pre,\n.db-content table {\n margin: 0 0 1em;\n}\n\n.db-content ul,\n.db-content ol {\n padding-left: 1.5em;\n}\n\n.db-content ul {\n list-style-type: disc;\n}\n\n.db-content ol {\n list-style-type: decimal;\n}\n\n.db-content ul ul {\n list-style-type: circle;\n}\n\n.db-content ol ol {\n list-style-type: lower-alpha;\n}\n\n.db-content ul ul ul,\n.db-content ol ol ol {\n list-style-type: square;\n}\n\n.db-content li {\n margin: 0.25em 0;\n}\n.db-content li > ul,\n.db-content li > ol {\n margin: 0.25em 0 0;\n}\n.db-content li > p {\n margin: 0;\n}\n\n.db-content ul.contains-task-list {\n list-style: none;\n padding-left: 0.25em;\n}\n.db-content li.task-list-item {\n list-style: none;\n margin-left: 0;\n}\n.db-content li.task-list-item input[type=\"checkbox\"] {\n margin-right: 0.5em;\n}\n\n.db-content strong {\n font-weight: 600;\n}\n.db-content em {\n font-style: italic;\n}\n.db-content del {\n text-decoration: line-through;\n opacity: 0.75;\n}\n\n.db-content blockquote {\n margin-left: 0;\n padding-left: 1em;\n border-left: 3px solid var(--db-accent-border, currentColor);\n opacity: 0.85;\n}\n\n.db-content code {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 0.9em;\n padding: 0.15em 0.35em;\n background: rgba(127, 127, 127, 0.15);\n border-radius: 4px;\n}\n\n.db-content pre {\n padding: 1em;\n overflow-x: auto;\n background: rgba(127, 127, 127, 0.1);\n border-radius: 8px;\n}\n.db-content pre code {\n padding: 0;\n background: none;\n}\n\n/* display:block so wide tables scroll instead of overflowing the page on mobile;\n costs full-bleed stretch for narrow tables. */\n.db-content table {\n border-collapse: collapse;\n display: block;\n width: max-content;\n max-width: 100%;\n overflow-x: auto;\n}\n\n.db-content th,\n.db-content td {\n border: 1px solid rgba(127, 127, 127, 0.3);\n padding: 0.5em 0.75em;\n text-align: left;\n}\n\n.db-content th {\n background: rgba(127, 127, 127, 0.08);\n font-weight: 600;\n}\n\n.db-content img {\n max-width: 100%;\n height: auto;\n border-radius: 8px;\n}\n\n.db-content a {\n text-decoration: underline;\n text-underline-offset: 2px;\n}\n.db-content a:hover {\n opacity: 0.8;\n}\n\n.db-content hr {\n border: none;\n border-top: 1px solid rgba(127, 127, 127, 0.3);\n margin: 2em 0;\n}\n";
@@ -12,6 +12,9 @@ export type { FailedMDX };
12
12
  /**
13
13
  * Wires up a React Native `compileMDX` with default Text/View/Image mappings for every standard markdown element
14
14
  * (and `EntryLink`), so a project sets up its RN primitives once instead of mapping every tag on every call.
15
+ * A tag/component the source references but nothing maps (a typo'd custom component, a raw HTML tag RN has no
16
+ * native view for) is caught at render — logged via `console.error` and replaced with the raw source as
17
+ * plain text, instead of crashing the screen.
15
18
  */
16
19
  export declare function createReactNativeRenderer(primitives: ReactNativePrimitives, styleOptions?: ReactNativeStyleOptions): {
17
20
  compileMDX: (source: string) => Promise<CompiledMDX | FailedMDX>;
@@ -1,9 +1,13 @@
1
1
  import * as runtime from "react/jsx-runtime";
2
2
  import { compileMDXCore } from "./core.js";
3
+ import { MDXErrorBoundary } from "./MDXErrorBoundary.js";
3
4
  import { buildReactNativeComponents, } from "./reactNativeComponents.js";
4
5
  /**
5
6
  * Wires up a React Native `compileMDX` with default Text/View/Image mappings for every standard markdown element
6
7
  * (and `EntryLink`), so a project sets up its RN primitives once instead of mapping every tag on every call.
8
+ * A tag/component the source references but nothing maps (a typo'd custom component, a raw HTML tag RN has no
9
+ * native view for) is caught at render — logged via `console.error` and replaced with the raw source as
10
+ * plain text, instead of crashing the screen.
7
11
  */
8
12
  export function createReactNativeRenderer(primitives, styleOptions) {
9
13
  const defaultComponents = buildReactNativeComponents(primitives, styleOptions);
@@ -27,9 +31,17 @@ export function createReactNativeRenderer(primitives, styleOptions) {
27
31
  : wrapped;
28
32
  return stripWhitespaceRootChildren(rootElement);
29
33
  };
34
+ // A JSX component referenced in `source` but missing from `components`, or a raw HTML tag RN has
35
+ // no native view for, throws mid-render — RN has no DOM to silently fall back on the way web
36
+ // does, so catch it here instead of taking down the whole screen, and log it loudly so the
37
+ // content author (or whoever wired up `components`) notices and fixes it.
38
+ const SafeContent = (props) => runtime.jsx(MDXErrorBoundary, {
39
+ fallback: runtime.jsx(primitives.Text, { children: source }),
40
+ children: runtime.jsx(RootWithoutWhitespace, props),
41
+ });
30
42
  return {
31
43
  ok: true,
32
- Content: RootWithoutWhitespace,
44
+ Content: SafeContent,
33
45
  };
34
46
  }
35
47
  return { compileMDX };
@@ -1,17 +1,23 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { createReactNativeRenderer } from "./reactNative.js";
4
+ import { MDXErrorBoundary } from "./MDXErrorBoundary.js";
4
5
  // Fakes standing in for react-native's Text/View/Image — see reactNativeComponents.test.ts.
5
6
  const Text = () => null;
6
7
  const View = () => null;
7
8
  const Image = () => null;
8
9
  const LEAVES = [Text, View, Image];
9
10
  /** 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
+ * without calling into it (Text/View/Image are opaque leaves here, not JSX). Class components
12
+ * (MDXErrorBoundary) can't be invoked directly like a function — unwrap via `children`,
13
+ * matching its non-error render path. */
11
14
  function resolve(element) {
12
- return typeof element.type === "function" && !LEAVES.includes(element.type)
13
- ? resolve(element.type(element.props))
14
- : element;
15
+ if (typeof element.type !== "function" || LEAVES.includes(element.type))
16
+ return element;
17
+ const type = element.type;
18
+ return type.prototype?.isReactComponent
19
+ ? resolve(element.props.children)
20
+ : resolve(element.type(element.props));
15
21
  }
16
22
  test("React Native smoke test: renders standard markdown through Text/View with zero setup beyond the three primitives", async () => {
17
23
  const { compileMDX } = createReactNativeRenderer({ Text, View, Image });
@@ -24,6 +30,38 @@ test("React Native smoke test: renders standard markdown through Text/View with
24
30
  assert.equal(heading.type, Text);
25
31
  assert.equal(heading.props.style.fontSize, 28);
26
32
  });
33
+ test("React Native falls back to the raw source and logs to console instead of crashing on an unknown tag", async () => {
34
+ const { compileMDX } = createReactNativeRenderer({ Text, View, Image });
35
+ const result = await compileMDX("<Callout>hi</Callout>");
36
+ assert.equal(result.ok, true);
37
+ if (!result.ok)
38
+ return;
39
+ const originalConsoleError = console.error;
40
+ const logged = [];
41
+ console.error = (...args) => logged.push(args);
42
+ try {
43
+ // No react-dom/test-renderer in this package — drive the real MDXErrorBoundary class by
44
+ // hand the way React would: render children, and on throw, catch + re-render the fallback.
45
+ const boundaryElement = result.Content({});
46
+ assert.equal(boundaryElement.type, MDXErrorBoundary);
47
+ const boundary = new MDXErrorBoundary(boundaryElement.props);
48
+ try {
49
+ resolve(boundaryElement.props.children);
50
+ assert.fail("expected the missing <Callout> component to throw");
51
+ }
52
+ catch (error) {
53
+ boundary.componentDidCatch(error);
54
+ Object.assign(boundary.state, MDXErrorBoundary.getDerivedStateFromError());
55
+ }
56
+ const fallback = resolve(boundary.render());
57
+ assert.equal(fallback.type, Text);
58
+ assert.equal(fallback.props.children, "<Callout>hi</Callout>");
59
+ }
60
+ finally {
61
+ console.error = originalConsoleError;
62
+ }
63
+ assert.equal(logged.length, 1);
64
+ });
27
65
  test("React Native EntryLink renders out of the box (no components map)", async () => {
28
66
  const { compileMDX } = createReactNativeRenderer({ Text, View, Image });
29
67
  const result = await compileMDX('<EntryLink id="abc123">Read more</EntryLink>');
package/dist/styles.css CHANGED
@@ -1,7 +1,9 @@
1
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. */
2
+ Opt-in: import "@draftbase/renderer/styles.css".
3
+ Disable per-render: <MDXContent unstyled /> or toHtml(source, { unstyled: true }).
4
+ Theme via custom properties: --db-heading-color, --db-link-color, --db-accent-border,
5
+ --db-target-bg. Override further: target ".db-content" with higher-specificity or
6
+ later-loaded rules. */
5
7
 
6
8
  .db-content {
7
9
  line-height: 1.6;
@@ -9,6 +11,19 @@
9
11
  overflow-wrap: break-word;
10
12
  }
11
13
 
14
+ .db-content h1,
15
+ .db-content h2,
16
+ .db-content h3,
17
+ .db-content h4,
18
+ .db-content h5,
19
+ .db-content h6 {
20
+ color: var(--db-heading-color, inherit);
21
+ }
22
+
23
+ .db-content a {
24
+ color: var(--db-link-color, inherit);
25
+ }
26
+
12
27
  /* remark-gfm emits the footnotes label as .sr-only; define it so it stays hidden
13
28
  without the consumer needing Tailwind or their own utility. */
14
29
  .db-content .sr-only {
@@ -38,7 +53,6 @@
38
53
  font-weight: 600;
39
54
  line-height: 1.25;
40
55
  margin: 1.5em 0 0.5em;
41
- color: inherit;
42
56
  }
43
57
 
44
58
  /* Highlights a footnote/citation anchor when the URL hash lands on it, e.g. #fn-1. */
@@ -165,7 +179,7 @@
165
179
  .db-content blockquote {
166
180
  margin-left: 0;
167
181
  padding-left: 1em;
168
- border-left: 3px solid currentColor;
182
+ border-left: 3px solid var(--db-accent-border, currentColor);
169
183
  opacity: 0.85;
170
184
  }
171
185
 
@@ -217,7 +231,6 @@
217
231
  }
218
232
 
219
233
  .db-content a {
220
- color: inherit;
221
234
  text-decoration: underline;
222
235
  text-underline-offset: 2px;
223
236
  }
package/dist/toHtml.d.ts CHANGED
@@ -11,7 +11,19 @@ export interface ToHtmlOptions {
11
11
  target?: string;
12
12
  rel?: string;
13
13
  };
14
+ /** Skip wrapping the output in a `db-content`-classed `<div>` and the inlined default
15
+ * styles (see below). Same semantics as `MDXContent`'s `unstyled` prop. */
16
+ unstyled?: boolean;
17
+ /** Extra class name(s) merged onto the wrapper `<div>`. */
18
+ className?: string;
19
+ /** Called (in addition to `console.error`) when a custom component (from `components`) throws
20
+ * while rendering. The tag is left as literal HTML instead of failing the whole render. */
21
+ onError?: (error: unknown, tagName: string) => void;
14
22
  }
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. */
23
+ /** Renders MDX/markdown to a static, self-contained HTML string: pre-wrapped in
24
+ * `<div class="db-content">` with the default styles inlined as a `<style>` tag ahead of it —
25
+ * no separate `styles.css` import needed. Calling `toHtml` more than once on the same page
26
+ * repeats that `<style>` tag (harmless, just redundant bytes); pass `unstyled: true` and load
27
+ * `@draftbase/renderer/styles.css` yourself once if that matters. No React, no mounted tree —
28
+ * use `compileMDX`/`MDXContent` instead when you need a React tree. */
17
29
  export declare function toHtml(source: string, options?: ToHtmlOptions): Promise<string>;
package/dist/toHtml.js CHANGED
@@ -6,6 +6,8 @@ import rehypeRaw from "rehype-raw";
6
6
  import rehypeSlug from "rehype-slug";
7
7
  import rehypeStringify from "rehype-stringify";
8
8
  import { toHtml as hastToHtml } from "hast-util-to-html";
9
+ import { wrapperClassName } from "./wrapperClassName.js";
10
+ import { CSS_TEXT } from "./cssText.js";
9
11
  const SCHEME_HREF = /^[a-z][a-z0-9+.-]*:/i;
10
12
  const defaultEntryLink = (props, childrenHtml) => `<a href="/entries/${props.id ?? ""}">${childrenHtml}</a>`;
11
13
  function hastPropsToStrings(properties) {
@@ -28,16 +30,26 @@ function rehypeDraftbase(options) {
28
30
  transform(child);
29
31
  if (node.type !== "element" || !node.tagName)
30
32
  return;
31
- const render = componentsByTag?.get(node.tagName);
33
+ const tagName = node.tagName;
34
+ const render = componentsByTag?.get(tagName);
32
35
  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;
36
+ try {
37
+ const childrenHtml = hastToHtml({ type: "root", children: node.children ?? [] }, { allowDangerousHtml: true });
38
+ node.value = render(hastPropsToStrings(node.properties), childrenHtml);
39
+ node.type = "raw";
40
+ node.tagName = undefined;
41
+ node.properties = undefined;
42
+ node.children = undefined;
43
+ }
44
+ catch (error) {
45
+ // Leave the tag as literal HTML instead of failing the whole render.
46
+ console.error(`Draftbase component "${tagName}" failed to render`, error);
47
+ options.onError?.(error, tagName);
48
+ }
49
+ return;
50
+ }
51
+ if (node.tagName === "img") {
52
+ node.properties = { loading: "lazy", decoding: "async", ...node.properties };
41
53
  return;
42
54
  }
43
55
  if (node.tagName === "a" && options.externalLinks) {
@@ -55,8 +67,12 @@ function rehypeDraftbase(options) {
55
67
  transform(tree);
56
68
  };
57
69
  }
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. */
70
+ /** Renders MDX/markdown to a static, self-contained HTML string: pre-wrapped in
71
+ * `<div class="db-content">` with the default styles inlined as a `<style>` tag ahead of it —
72
+ * no separate `styles.css` import needed. Calling `toHtml` more than once on the same page
73
+ * repeats that `<style>` tag (harmless, just redundant bytes); pass `unstyled: true` and load
74
+ * `@draftbase/renderer/styles.css` yourself once if that matters. No React, no mounted tree —
75
+ * use `compileMDX`/`MDXContent` instead when you need a React tree. */
60
76
  export async function toHtml(source, options = {}) {
61
77
  const needsRawParse = Boolean(options.components);
62
78
  const processor = unified()
@@ -70,5 +86,11 @@ export async function toHtml(source, options = {}) {
70
86
  .use(rehypeDraftbase, options)
71
87
  .use(rehypeStringify, { allowDangerousHtml: true });
72
88
  const file = await processor.process(source);
73
- return String(file);
89
+ const html = String(file);
90
+ const wrapperClass = wrapperClassName(options.unstyled, options.className);
91
+ if (!wrapperClass)
92
+ return html;
93
+ return options.unstyled
94
+ ? `<div class="${wrapperClass}">${html}</div>`
95
+ : `<style>${CSS_TEXT}</style><div class="${wrapperClass}">${html}</div>`;
74
96
  }
@@ -1,11 +1,28 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
  import { toHtml } from "./toHtml.js";
4
+ import { CSS_TEXT } from "./cssText.js";
4
5
  test("renders markdown to html", async () => {
5
6
  const html = await toHtml("# Title\n\nSome **bold** text.");
6
7
  assert.match(html, /<h1 id="title">Title<\/h1>/);
7
8
  assert.match(html, /<strong>bold<\/strong>/);
8
9
  });
10
+ test("wraps output in a db-content div with inlined styles by default", async () => {
11
+ const html = await toHtml("hi");
12
+ assert.equal(html, `<style>${CSS_TEXT}</style><div class="db-content"><p>hi</p></div>`);
13
+ });
14
+ test("unstyled skips the wrapper div and the inlined styles", async () => {
15
+ const html = await toHtml("hi", { unstyled: true });
16
+ assert.equal(html, "<p>hi</p>");
17
+ });
18
+ test("className merges onto the wrapper div, styles still inlined", async () => {
19
+ const html = await toHtml("hi", { className: "prose" });
20
+ assert.equal(html, `<style>${CSS_TEXT}</style><div class="db-content prose"><p>hi</p></div>`);
21
+ });
22
+ test("img gets lazy-loading defaults with no components option supplied", async () => {
23
+ const html = await toHtml("![alt text](https://example.com/a.png)", { unstyled: true });
24
+ assert.equal(html, '<p><img loading="lazy" decoding="async" src="https://example.com/a.png" alt="alt text"></p>');
25
+ });
9
26
  test("supports gfm tables", async () => {
10
27
  const html = await toHtml("| a | b |\n| - | - |\n| 1 | 2 |");
11
28
  assert.match(html, /<table>/);
@@ -43,6 +60,21 @@ test("components resolves nested custom tags before serializing the parent's chi
43
60
  });
44
61
  assert.match(html, /<div><a href="\/entries\/abc">Post<\/a><\/div>/);
45
62
  });
63
+ test("a component that throws leaves the tag as literal HTML and calls onError", async () => {
64
+ const errors = [];
65
+ const html = await toHtml('<Callout type="warning">Careful</Callout>', {
66
+ components: {
67
+ Callout: () => {
68
+ throw new Error("boom");
69
+ },
70
+ },
71
+ onError: (error, tagName) => errors.push([error, tagName]),
72
+ });
73
+ assert.match(html, /<callout type="warning">Careful<\/callout>/);
74
+ assert.equal(errors.length, 1);
75
+ assert.equal(errors[0][0].message, "boom");
76
+ assert.equal(errors[0][1], "callout");
77
+ });
46
78
  test("adds target/rel to external links when externalLinks is true", async () => {
47
79
  const html = await toHtml("[ext](https://example.com) and [rel](/local)", {
48
80
  externalLinks: true,
package/dist/vue.js CHANGED
@@ -1,6 +1,9 @@
1
- import { compileMDXCore, makeDefaultEntryLink } from "./core.js";
1
+ import { compileMDXCore, makeDefaultEntryLink, makeDefaultImage } from "./core.js";
2
2
  import { vueJsxRuntime } from "./vueRuntime.js";
3
- const defaultComponents = { EntryLink: makeDefaultEntryLink(vueJsxRuntime) };
3
+ const defaultComponents = {
4
+ EntryLink: makeDefaultEntryLink(vueJsxRuntime),
5
+ img: makeDefaultImage(vueJsxRuntime),
6
+ };
4
7
  /**
5
8
  * Compiles raw MDX/markdown into a renderable Vue component. Vue has no RSC-style async component, so call this
6
9
  * from `setup()`/a composable and render the result yourself: `h(Content, props)`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@draftbase/renderer",
3
- "version": "0.4.3",
3
+ "version": "0.5.2",
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",
@@ -40,8 +40,8 @@
40
40
  "README.md"
41
41
  ],
42
42
  "scripts": {
43
- "build": "tsc -p tsconfig.json && cp src/styles.css dist/styles.css",
44
- "lint": "tsc --noEmit",
43
+ "build": "node scripts/generate-css-text.mjs && tsc -p tsconfig.json && cp src/styles.css dist/styles.css",
44
+ "lint": "node scripts/generate-css-text.mjs && tsc --noEmit",
45
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"
46
46
  },
47
47
  "peerDependencies": {