@mzebley/mark-down-react 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -0
- package/dist/index.cjs +126 -0
- package/dist/index.d.cts +29 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +86 -0
- package/package.json +23 -0
- package/src/context.tsx +32 -0
- package/src/hooks.ts +43 -0
- package/src/index.ts +3 -0
- package/src/snippet-view.tsx +41 -0
- package/tsconfig.json +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# mark↓ React Adapter
|
|
2
|
+
*(published as `@mzebley/mark-down-react`)*
|
|
3
|
+
|
|
4
|
+
React bindings for the [mark↓ core runtime](../core/README.md). This package exposes context providers, hooks, and ready-to-use components that make it simple to render Markdown snippets safely. For a broader overview of the project, start with the [root README](../../README.md).
|
|
5
|
+
|
|
6
|
+
## Table of contents
|
|
7
|
+
|
|
8
|
+
1. [Installation](#installation)
|
|
9
|
+
2. [Provider setup](#provider-setup)
|
|
10
|
+
3. [Hook](#hook)
|
|
11
|
+
4. [`<SnippetView />` component](#snippetview--component)
|
|
12
|
+
5. [Server-side rendering](#server-side-rendering)
|
|
13
|
+
6. [TypeScript helpers](#typescript-helpers)
|
|
14
|
+
7. [Roadmap](#roadmap)
|
|
15
|
+
8. [Related packages](#related-packages)
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
Install the adapter along with the core runtime and DOMPurify (used for sanitising HTML):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @mzebley/mark-down-react @mzebley/mark-down dompurify
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Generate a manifest with the [CLI](../cli/README.md) before rendering snippets.
|
|
26
|
+
|
|
27
|
+
## Provider setup
|
|
28
|
+
|
|
29
|
+
Wrap your app with the `SnippetProvider` so that hooks and components can access a shared client instance:
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { SnippetProvider } from '@mzebley/mark-down-react';
|
|
33
|
+
|
|
34
|
+
export function App({ children }: { children: React.ReactNode }) {
|
|
35
|
+
return (
|
|
36
|
+
<SnippetProvider options={{ manifest: '/snippets-index.json' }}>
|
|
37
|
+
{children}
|
|
38
|
+
</SnippetProvider>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`options` maps directly to the [`SnippetClient` configuration](../core/README.md#client-options), so you can provide custom fetchers, renderers, or manifest loaders as needed.
|
|
44
|
+
|
|
45
|
+
## Hook
|
|
46
|
+
|
|
47
|
+
### `useSnippet(slug)`
|
|
48
|
+
|
|
49
|
+
Fetch a single snippet and track loading / error state:
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
import { useSnippet } from '@mzebley/mark-down-react';
|
|
53
|
+
|
|
54
|
+
export function Hero() {
|
|
55
|
+
const { snippet, loading, error } = useSnippet('getting-started-welcome');
|
|
56
|
+
|
|
57
|
+
if (loading) return <p>Loading…</p>;
|
|
58
|
+
if (error) return <p role="alert">Failed to load snippet.</p>;
|
|
59
|
+
if (!snippet) return null;
|
|
60
|
+
|
|
61
|
+
return <div dangerouslySetInnerHTML={{ __html: snippet.html }} />;
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## `<SnippetView />` component
|
|
66
|
+
|
|
67
|
+
Render snippets declaratively with built-in loading and error fallbacks:
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
import { SnippetView } from '@mzebley/mark-down-react';
|
|
71
|
+
|
|
72
|
+
<SnippetView
|
|
73
|
+
slug="components-button"
|
|
74
|
+
loadingFallback={<p>Loading…</p>}
|
|
75
|
+
errorFallback={<p role="alert">Unable to load snippet.</p>}
|
|
76
|
+
onLoaded={(snippet) => console.log('Rendered', snippet.slug)}
|
|
77
|
+
/>;
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Features:
|
|
81
|
+
|
|
82
|
+
- Uses DOMPurify under the hood for HTML sanitisation.
|
|
83
|
+
- Accepts `className` for styling and emits `onLoaded(snippet)` once HTML resolves.
|
|
84
|
+
- Customise UX via `loadingFallback` / `errorFallback`, or render the hook directly for complete control.
|
|
85
|
+
|
|
86
|
+
## Server-side rendering
|
|
87
|
+
|
|
88
|
+
When using Next.js, Remix, or another SSR framework, provide a server-safe fetch implementation:
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
import fetch from 'node-fetch';
|
|
92
|
+
import { SnippetProvider } from '@mzebley/mark-down-react';
|
|
93
|
+
|
|
94
|
+
<SnippetProvider
|
|
95
|
+
options={{
|
|
96
|
+
manifest: () => import('../snippets-index.json'),
|
|
97
|
+
fetcher: (input, init) => fetch(input as string, init),
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{children}
|
|
101
|
+
</SnippetProvider>;
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Because the adapter defers to the core runtime, SSR works the same way as the base client. Pair with framework-specific data fetching if you prefer to prehydrate snippets.
|
|
105
|
+
|
|
106
|
+
## TypeScript helpers
|
|
107
|
+
|
|
108
|
+
All exported hooks and components ship with rich TypeScript definitions:
|
|
109
|
+
|
|
110
|
+
- Use the `Snippet` and `SnippetMeta` types from `@mzebley/mark-down` to annotate props.
|
|
111
|
+
- Narrow snippet metadata with generics: `useSnippet<CustomExtra>('slug')`.
|
|
112
|
+
- Leverage the `SnippetContextValue` interface when mocking providers in tests.
|
|
113
|
+
|
|
114
|
+
## Roadmap
|
|
115
|
+
|
|
116
|
+
- **Collection hooks** – add `useSnippets` for list queries and pagination helpers for design system docs.
|
|
117
|
+
- **Suspense support** – optional wrappers that expose a resource-style API for React 18 concurrent features.
|
|
118
|
+
- **Custom sanitizers** – let consumers inject DOMPurify configs or alternate HTML sanitizers.
|
|
119
|
+
- **Storybook plugin** – surface snippets inside Storybook/Chromatic panels for quick previews.
|
|
120
|
+
|
|
121
|
+
## Related packages
|
|
122
|
+
|
|
123
|
+
- [Core runtime](../core/README.md)
|
|
124
|
+
- [CLI](../cli/README.md)
|
|
125
|
+
- [Angular adapter](../angular/README.md)
|
|
126
|
+
- [Example app](../../examples/basic/README.md)
|
|
127
|
+
- [Monorepo overview](../../README.md)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
SnippetProvider: () => SnippetProvider,
|
|
34
|
+
SnippetView: () => SnippetView,
|
|
35
|
+
useSnippet: () => useSnippet,
|
|
36
|
+
useSnippetClient: () => useSnippetClient
|
|
37
|
+
});
|
|
38
|
+
module.exports = __toCommonJS(index_exports);
|
|
39
|
+
|
|
40
|
+
// src/context.tsx
|
|
41
|
+
var import_react = require("react");
|
|
42
|
+
var import_mark_down = require("@mzebley/mark-down");
|
|
43
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
44
|
+
var SnippetClientContext = (0, import_react.createContext)(null);
|
|
45
|
+
function SnippetProvider({ client, options, children }) {
|
|
46
|
+
const value = (0, import_react.useMemo)(() => {
|
|
47
|
+
if (client) {
|
|
48
|
+
return client;
|
|
49
|
+
}
|
|
50
|
+
if (options) {
|
|
51
|
+
return new import_mark_down.SnippetClient(options);
|
|
52
|
+
}
|
|
53
|
+
throw new Error("SnippetProvider requires either a client or options");
|
|
54
|
+
}, [client, options]);
|
|
55
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SnippetClientContext.Provider, { value, children });
|
|
56
|
+
}
|
|
57
|
+
function useSnippetClient() {
|
|
58
|
+
const client = (0, import_react.useContext)(SnippetClientContext);
|
|
59
|
+
if (!client) {
|
|
60
|
+
throw new Error("useSnippetClient must be used within a SnippetProvider");
|
|
61
|
+
}
|
|
62
|
+
return client;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/hooks.ts
|
|
66
|
+
var import_react2 = require("react");
|
|
67
|
+
function useSnippet(slug) {
|
|
68
|
+
const client = useSnippetClient();
|
|
69
|
+
const [result, setResult] = (0, import_react2.useState)(() => ({
|
|
70
|
+
loading: Boolean(slug)
|
|
71
|
+
}));
|
|
72
|
+
(0, import_react2.useEffect)(() => {
|
|
73
|
+
if (!slug) {
|
|
74
|
+
setResult({ loading: false });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
let cancelled = false;
|
|
78
|
+
setResult((prev) => ({ ...prev, loading: true, error: void 0 }));
|
|
79
|
+
client.get(slug).then((snippet) => {
|
|
80
|
+
if (cancelled) return;
|
|
81
|
+
setResult({ snippet, loading: false });
|
|
82
|
+
}).catch((error) => {
|
|
83
|
+
if (cancelled) return;
|
|
84
|
+
setResult({ loading: false, error });
|
|
85
|
+
});
|
|
86
|
+
return () => {
|
|
87
|
+
cancelled = true;
|
|
88
|
+
};
|
|
89
|
+
}, [client, slug]);
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/snippet-view.tsx
|
|
94
|
+
var import_react3 = require("react");
|
|
95
|
+
var import_dompurify = __toESM(require("dompurify"), 1);
|
|
96
|
+
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
97
|
+
function SnippetView({
|
|
98
|
+
slug,
|
|
99
|
+
className,
|
|
100
|
+
loadingFallback = "Loading\u2026",
|
|
101
|
+
errorFallback = "Unable to load snippet",
|
|
102
|
+
onLoaded
|
|
103
|
+
}) {
|
|
104
|
+
const state = useSnippet(slug);
|
|
105
|
+
const safeHtml = (0, import_react3.useMemo)(() => state.snippet ? import_dompurify.default.sanitize(state.snippet.html) : void 0, [state.snippet]);
|
|
106
|
+
(0, import_react3.useEffect)(() => {
|
|
107
|
+
onLoaded?.(state.snippet);
|
|
108
|
+
}, [state.snippet, onLoaded]);
|
|
109
|
+
if (state.loading) {
|
|
110
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className, children: loadingFallback });
|
|
111
|
+
}
|
|
112
|
+
if (state.error) {
|
|
113
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className, children: errorFallback });
|
|
114
|
+
}
|
|
115
|
+
if (!state.snippet) {
|
|
116
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className, children: "Snippet not found" });
|
|
117
|
+
}
|
|
118
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className, dangerouslySetInnerHTML: { __html: safeHtml ?? "" } });
|
|
119
|
+
}
|
|
120
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
121
|
+
0 && (module.exports = {
|
|
122
|
+
SnippetProvider,
|
|
123
|
+
SnippetView,
|
|
124
|
+
useSnippet,
|
|
125
|
+
useSnippetClient
|
|
126
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { SnippetClient, SnippetClientOptions, Snippet } from '@mzebley/mark-down';
|
|
4
|
+
|
|
5
|
+
interface SnippetProviderProps {
|
|
6
|
+
client?: SnippetClient;
|
|
7
|
+
options?: SnippetClientOptions;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
declare function SnippetProvider({ client, options, children }: SnippetProviderProps): react_jsx_runtime.JSX.Element;
|
|
11
|
+
declare function useSnippetClient(): SnippetClient;
|
|
12
|
+
|
|
13
|
+
interface UseSnippetResult {
|
|
14
|
+
snippet?: Snippet;
|
|
15
|
+
loading: boolean;
|
|
16
|
+
error?: Error;
|
|
17
|
+
}
|
|
18
|
+
declare function useSnippet(slug?: string | null): UseSnippetResult;
|
|
19
|
+
|
|
20
|
+
interface SnippetViewProps {
|
|
21
|
+
slug: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
loadingFallback?: ReactNode;
|
|
24
|
+
errorFallback?: ReactNode;
|
|
25
|
+
onLoaded?: (snippet?: Snippet) => void;
|
|
26
|
+
}
|
|
27
|
+
declare function SnippetView({ slug, className, loadingFallback, errorFallback, onLoaded }: SnippetViewProps): react_jsx_runtime.JSX.Element;
|
|
28
|
+
|
|
29
|
+
export { SnippetProvider, type SnippetProviderProps, SnippetView, type SnippetViewProps, type UseSnippetResult, useSnippet, useSnippetClient };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { SnippetClient, SnippetClientOptions, Snippet } from '@mzebley/mark-down';
|
|
4
|
+
|
|
5
|
+
interface SnippetProviderProps {
|
|
6
|
+
client?: SnippetClient;
|
|
7
|
+
options?: SnippetClientOptions;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
declare function SnippetProvider({ client, options, children }: SnippetProviderProps): react_jsx_runtime.JSX.Element;
|
|
11
|
+
declare function useSnippetClient(): SnippetClient;
|
|
12
|
+
|
|
13
|
+
interface UseSnippetResult {
|
|
14
|
+
snippet?: Snippet;
|
|
15
|
+
loading: boolean;
|
|
16
|
+
error?: Error;
|
|
17
|
+
}
|
|
18
|
+
declare function useSnippet(slug?: string | null): UseSnippetResult;
|
|
19
|
+
|
|
20
|
+
interface SnippetViewProps {
|
|
21
|
+
slug: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
loadingFallback?: ReactNode;
|
|
24
|
+
errorFallback?: ReactNode;
|
|
25
|
+
onLoaded?: (snippet?: Snippet) => void;
|
|
26
|
+
}
|
|
27
|
+
declare function SnippetView({ slug, className, loadingFallback, errorFallback, onLoaded }: SnippetViewProps): react_jsx_runtime.JSX.Element;
|
|
28
|
+
|
|
29
|
+
export { SnippetProvider, type SnippetProviderProps, SnippetView, type SnippetViewProps, type UseSnippetResult, useSnippet, useSnippetClient };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// src/context.tsx
|
|
2
|
+
import { createContext, useContext, useMemo } from "react";
|
|
3
|
+
import { SnippetClient } from "@mzebley/mark-down";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
var SnippetClientContext = createContext(null);
|
|
6
|
+
function SnippetProvider({ client, options, children }) {
|
|
7
|
+
const value = useMemo(() => {
|
|
8
|
+
if (client) {
|
|
9
|
+
return client;
|
|
10
|
+
}
|
|
11
|
+
if (options) {
|
|
12
|
+
return new SnippetClient(options);
|
|
13
|
+
}
|
|
14
|
+
throw new Error("SnippetProvider requires either a client or options");
|
|
15
|
+
}, [client, options]);
|
|
16
|
+
return /* @__PURE__ */ jsx(SnippetClientContext.Provider, { value, children });
|
|
17
|
+
}
|
|
18
|
+
function useSnippetClient() {
|
|
19
|
+
const client = useContext(SnippetClientContext);
|
|
20
|
+
if (!client) {
|
|
21
|
+
throw new Error("useSnippetClient must be used within a SnippetProvider");
|
|
22
|
+
}
|
|
23
|
+
return client;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/hooks.ts
|
|
27
|
+
import { useEffect, useState } from "react";
|
|
28
|
+
function useSnippet(slug) {
|
|
29
|
+
const client = useSnippetClient();
|
|
30
|
+
const [result, setResult] = useState(() => ({
|
|
31
|
+
loading: Boolean(slug)
|
|
32
|
+
}));
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!slug) {
|
|
35
|
+
setResult({ loading: false });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
setResult((prev) => ({ ...prev, loading: true, error: void 0 }));
|
|
40
|
+
client.get(slug).then((snippet) => {
|
|
41
|
+
if (cancelled) return;
|
|
42
|
+
setResult({ snippet, loading: false });
|
|
43
|
+
}).catch((error) => {
|
|
44
|
+
if (cancelled) return;
|
|
45
|
+
setResult({ loading: false, error });
|
|
46
|
+
});
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
};
|
|
50
|
+
}, [client, slug]);
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/snippet-view.tsx
|
|
55
|
+
import { useEffect as useEffect2, useMemo as useMemo2 } from "react";
|
|
56
|
+
import DOMPurify from "dompurify";
|
|
57
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
58
|
+
function SnippetView({
|
|
59
|
+
slug,
|
|
60
|
+
className,
|
|
61
|
+
loadingFallback = "Loading\u2026",
|
|
62
|
+
errorFallback = "Unable to load snippet",
|
|
63
|
+
onLoaded
|
|
64
|
+
}) {
|
|
65
|
+
const state = useSnippet(slug);
|
|
66
|
+
const safeHtml = useMemo2(() => state.snippet ? DOMPurify.sanitize(state.snippet.html) : void 0, [state.snippet]);
|
|
67
|
+
useEffect2(() => {
|
|
68
|
+
onLoaded?.(state.snippet);
|
|
69
|
+
}, [state.snippet, onLoaded]);
|
|
70
|
+
if (state.loading) {
|
|
71
|
+
return /* @__PURE__ */ jsx2("div", { className, children: loadingFallback });
|
|
72
|
+
}
|
|
73
|
+
if (state.error) {
|
|
74
|
+
return /* @__PURE__ */ jsx2("div", { className, children: errorFallback });
|
|
75
|
+
}
|
|
76
|
+
if (!state.snippet) {
|
|
77
|
+
return /* @__PURE__ */ jsx2("div", { className, children: "Snippet not found" });
|
|
78
|
+
}
|
|
79
|
+
return /* @__PURE__ */ jsx2("div", { className, dangerouslySetInnerHTML: { __html: safeHtml ?? "" } });
|
|
80
|
+
}
|
|
81
|
+
export {
|
|
82
|
+
SnippetProvider,
|
|
83
|
+
SnippetView,
|
|
84
|
+
useSnippet,
|
|
85
|
+
useSnippetClient
|
|
86
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mzebley/mark-down-react",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "mark↓ React Adapter",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.cjs",
|
|
7
|
+
"module": "dist/index.mjs",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsup src/index.ts --dts --format esm,cjs"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@mzebley/mark-down": "file:../core",
|
|
17
|
+
"dompurify": "^3.0.5"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": "^18.2.0",
|
|
21
|
+
"react-dom": "^18.2.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/context.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createContext, ReactNode, useContext, useMemo } from "react";
|
|
2
|
+
import { SnippetClient, type SnippetClientOptions } from "@mzebley/mark-down";
|
|
3
|
+
|
|
4
|
+
const SnippetClientContext = createContext<SnippetClient | null>(null);
|
|
5
|
+
|
|
6
|
+
export interface SnippetProviderProps {
|
|
7
|
+
client?: SnippetClient;
|
|
8
|
+
options?: SnippetClientOptions;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function SnippetProvider({ client, options, children }: SnippetProviderProps) {
|
|
13
|
+
const value = useMemo(() => {
|
|
14
|
+
if (client) {
|
|
15
|
+
return client;
|
|
16
|
+
}
|
|
17
|
+
if (options) {
|
|
18
|
+
return new SnippetClient(options);
|
|
19
|
+
}
|
|
20
|
+
throw new Error("SnippetProvider requires either a client or options");
|
|
21
|
+
}, [client, options]);
|
|
22
|
+
|
|
23
|
+
return <SnippetClientContext.Provider value={value}>{children}</SnippetClientContext.Provider>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useSnippetClient(): SnippetClient {
|
|
27
|
+
const client = useContext(SnippetClientContext);
|
|
28
|
+
if (!client) {
|
|
29
|
+
throw new Error("useSnippetClient must be used within a SnippetProvider");
|
|
30
|
+
}
|
|
31
|
+
return client;
|
|
32
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { Snippet } from "@mzebley/mark-down";
|
|
3
|
+
import { useSnippetClient } from "./context";
|
|
4
|
+
|
|
5
|
+
export interface UseSnippetResult {
|
|
6
|
+
snippet?: Snippet;
|
|
7
|
+
loading: boolean;
|
|
8
|
+
error?: Error;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function useSnippet(slug?: string | null): UseSnippetResult {
|
|
12
|
+
const client = useSnippetClient();
|
|
13
|
+
const [result, setResult] = useState<UseSnippetResult>(() => ({
|
|
14
|
+
loading: Boolean(slug)
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!slug) {
|
|
19
|
+
setResult({ loading: false });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let cancelled = false;
|
|
24
|
+
setResult((prev) => ({ ...prev, loading: true, error: undefined }));
|
|
25
|
+
|
|
26
|
+
client
|
|
27
|
+
.get(slug)
|
|
28
|
+
.then((snippet) => {
|
|
29
|
+
if (cancelled) return;
|
|
30
|
+
setResult({ snippet, loading: false });
|
|
31
|
+
})
|
|
32
|
+
.catch((error: Error) => {
|
|
33
|
+
if (cancelled) return;
|
|
34
|
+
setResult({ loading: false, error });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return () => {
|
|
38
|
+
cancelled = true;
|
|
39
|
+
};
|
|
40
|
+
}, [client, slug]);
|
|
41
|
+
|
|
42
|
+
return result;
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ReactNode, useEffect, useMemo } from "react";
|
|
2
|
+
import DOMPurify from "dompurify";
|
|
3
|
+
import type { Snippet } from "@mzebley/mark-down";
|
|
4
|
+
import { useSnippet } from "./hooks";
|
|
5
|
+
|
|
6
|
+
export interface SnippetViewProps {
|
|
7
|
+
slug: string;
|
|
8
|
+
className?: string;
|
|
9
|
+
loadingFallback?: ReactNode;
|
|
10
|
+
errorFallback?: ReactNode;
|
|
11
|
+
onLoaded?: (snippet?: Snippet) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function SnippetView({
|
|
15
|
+
slug,
|
|
16
|
+
className,
|
|
17
|
+
loadingFallback = "Loading…",
|
|
18
|
+
errorFallback = "Unable to load snippet",
|
|
19
|
+
onLoaded
|
|
20
|
+
}: SnippetViewProps) {
|
|
21
|
+
const state = useSnippet(slug);
|
|
22
|
+
const safeHtml = useMemo(() => (state.snippet ? DOMPurify.sanitize(state.snippet.html) : undefined), [state.snippet]);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
onLoaded?.(state.snippet);
|
|
26
|
+
}, [state.snippet, onLoaded]);
|
|
27
|
+
|
|
28
|
+
if (state.loading) {
|
|
29
|
+
return <div className={className}>{loadingFallback}</div>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (state.error) {
|
|
33
|
+
return <div className={className}>{errorFallback}</div>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!state.snippet) {
|
|
37
|
+
return <div className={className}>Snippet not found</div>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return <div className={className} dangerouslySetInnerHTML={{ __html: safeHtml ?? "" }} />;
|
|
41
|
+
}
|