react-markdown-parser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # `react-markdown-parser`
2
+
3
+ A React server component to render markdown content.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install react-markdown-parser
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```tsx
14
+ import { Markdown } from "react-markdown-parser";
15
+
16
+ export function Article({ content }: { content: string }) {
17
+ return (
18
+ <div className="prose">
19
+ <Markdown content={content} />
20
+ </div>
21
+ );
22
+ }
23
+ ```
24
+
25
+ It is possible to customize how markdown nodes are rendered by through the `components` property. In this example, we're modifying the rendering logic of code blocks and links.
26
+
27
+
28
+ ```tsx
29
+ import { Markdown } from "react-markdown-parser";
30
+
31
+ export function Article({ content }: { content: string }) {
32
+ return (
33
+ <div className="prose">
34
+ <Markdown
35
+ content={content}
36
+ components={{
37
+ CodeBlock: ({ content, info }) => {
38
+ // Highlight the content using a syntax highlighting library, etc.
39
+ return (
40
+ <pre data-lang={info}>
41
+ <code>{content}</code>
42
+ </pre>
43
+ );
44
+ },
45
+ Link: ({ href, children }) => {
46
+ // Validate the link destination, etc.
47
+ return (
48
+ <a href={href} rel="noreferrer">
49
+ {children}
50
+ </a>
51
+ );
52
+ },
53
+ }}
54
+ />
55
+ </div>
56
+ );
57
+ }
58
+ ```
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "react-markdown-parser",
3
+ "version": "0.1.0",
4
+ "sideEffects": false,
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "import": "./dist/index.js"
9
+ }
10
+ },
11
+ "scripts": {
12
+ "build": "bunchee",
13
+ "test": "vitest run"
14
+ },
15
+ "dependencies": {
16
+ "markdown-parser": "*",
17
+ "server-only": "^0.0.1"
18
+ },
19
+ "devDependencies": {
20
+ "@types/react": "^19.2.10",
21
+ "@types/react-dom": "^19.2.3",
22
+ "bunchee": "^6.9.4",
23
+ "typescript": "^5.9.3",
24
+ "vitest": "^3.2.4"
25
+ },
26
+ "peerDependencies": {
27
+ "react": "^19.2.4",
28
+ "react-dom": "^19.2.4"
29
+ }
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { Markdown } from "./markdown";
@@ -0,0 +1,566 @@
1
+ import "server-only";
2
+
3
+ import {
4
+ type BlockNode,
5
+ type InlineNode,
6
+ MarkdownParser,
7
+ } from "markdown-parser";
8
+ import type { ComponentType, ReactNode } from "react";
9
+
10
+ export type MarkdownComponents = BlockNodeComponents & InlineNodeComponents;
11
+
12
+ interface BlockNodeComponents {
13
+ Table: ComponentType<{
14
+ head: {
15
+ cells: {
16
+ children: ReactNode;
17
+ align: "left" | "right" | "center" | undefined;
18
+ }[];
19
+ };
20
+ body: {
21
+ rows: {
22
+ cells: {
23
+ children: ReactNode;
24
+ align: "left" | "right" | "center" | undefined;
25
+ }[];
26
+ }[];
27
+ };
28
+ }>;
29
+ CodeBlock: ComponentType<{ content: string; info?: string }>;
30
+ Blockquote: ComponentType<{ children: ReactNode }>;
31
+ List: ComponentType<
32
+ | { type: "ordered"; items: { children: ReactNode }[]; start?: number }
33
+ | { type: "unordered"; items: { children: ReactNode }[] }
34
+ >;
35
+ Heading: ComponentType<{ level: 1 | 2 | 3 | 4 | 5 | 6; children: ReactNode }>;
36
+ Paragraph: ComponentType<{ children: ReactNode }>;
37
+ ThematicBreak: ComponentType;
38
+ HtmlBlock: ComponentType<{ content: string }>;
39
+ }
40
+
41
+ interface InlineNodeComponents {
42
+ Text: ComponentType<{ text: string }>;
43
+ CodeSpan: ComponentType<{ text: string }>;
44
+ Emphasis: ComponentType<{ children: ReactNode }>;
45
+ Strong: ComponentType<{ children: ReactNode }>;
46
+ Link: ComponentType<{ href: string; title?: string; children: ReactNode }>;
47
+ Image: ComponentType<{ href: string; title?: string; alt: string }>;
48
+ HardBreak: ComponentType;
49
+ SoftBreak: ComponentType;
50
+ Html: ComponentType<{ content: string }>;
51
+ }
52
+
53
+ export function Markdown({
54
+ content,
55
+ components,
56
+ }: {
57
+ content: string;
58
+ components?: Partial<MarkdownComponents>;
59
+ }) {
60
+ const nodes = new MarkdownParser().parse(content);
61
+
62
+ const {
63
+ Table = DefaultTable,
64
+ CodeBlock = DefaultCodeBlock,
65
+ Blockquote = DefaultBlockquote,
66
+ List = DefaultList,
67
+ Paragraph = DefaultParagraph,
68
+ Heading = DefaultHeading,
69
+ ThematicBreak = DefaultThematicBreak,
70
+ Text = DefaultText,
71
+ CodeSpan = DefaultCodeSpan,
72
+ HardBreak = DefaultHardBreak,
73
+ SoftBreak = DefaultSoftBreak,
74
+ Emphasis = DefaultEmphasis,
75
+ Strong = DefaultStrong,
76
+ Link = DefaultLink,
77
+ Image = DefaultImage,
78
+ HtmlBlock = DefaultHtmlBlock,
79
+ Html = DefaultHtml,
80
+ } = components ?? {};
81
+
82
+ return nodes.map((node, index) => (
83
+ <BlockNodeComponent
84
+ components={{
85
+ Table,
86
+ CodeBlock,
87
+ Blockquote,
88
+ List,
89
+ Paragraph,
90
+ Heading,
91
+ HtmlBlock,
92
+ ThematicBreak,
93
+ Text,
94
+ CodeSpan,
95
+ HardBreak,
96
+ SoftBreak,
97
+ Emphasis,
98
+ Strong,
99
+ Link,
100
+ Image,
101
+ Html,
102
+ }}
103
+ key={index}
104
+ node={node}
105
+ />
106
+ ));
107
+ }
108
+
109
+ export function BlockNodeComponent({
110
+ node,
111
+ components,
112
+ }: {
113
+ node: BlockNode;
114
+ components: MarkdownComponents;
115
+ }) {
116
+ const {
117
+ Table,
118
+ CodeBlock,
119
+ Paragraph,
120
+ Heading,
121
+ Blockquote,
122
+ List,
123
+ HtmlBlock,
124
+ ThematicBreak,
125
+ } = components;
126
+
127
+ switch (node.type) {
128
+ case "table": {
129
+ return (
130
+ <Table
131
+ body={{
132
+ rows: node.body.rows.map((row) => {
133
+ return {
134
+ cells: row.cells.map((cell) => {
135
+ return {
136
+ children: cell.children.map((child, index) => (
137
+ <InlineNodeComponent
138
+ components={components}
139
+ key={index}
140
+ node={child}
141
+ />
142
+ )),
143
+ align: cell.align,
144
+ };
145
+ }),
146
+ };
147
+ }),
148
+ }}
149
+ head={{
150
+ cells: node.head.cells.map((cell) => {
151
+ return {
152
+ children: cell.children.map((child, index) => (
153
+ <InlineNodeComponent
154
+ components={components}
155
+ key={index}
156
+ node={child}
157
+ />
158
+ )),
159
+ align: cell.align,
160
+ };
161
+ }),
162
+ }}
163
+ />
164
+ );
165
+ }
166
+ case "blockquote": {
167
+ return (
168
+ <Blockquote>
169
+ {node.children.map((child, index) => (
170
+ <BlockNodeComponent
171
+ components={components}
172
+ key={index}
173
+ node={child}
174
+ />
175
+ ))}
176
+ </Blockquote>
177
+ );
178
+ }
179
+ case "list": {
180
+ if (node.kind === "ordered") {
181
+ return (
182
+ <List
183
+ items={node.items.map((item) => {
184
+ return {
185
+ children: item.children.map((child, index) => (
186
+ <BlockNodeComponent
187
+ components={components}
188
+ key={index}
189
+ node={child}
190
+ />
191
+ )),
192
+ };
193
+ })}
194
+ start={node.start}
195
+ type="ordered"
196
+ />
197
+ );
198
+ }
199
+ return (
200
+ <List
201
+ items={node.items.map((item) => {
202
+ return {
203
+ children: item.children.map((child, index) => (
204
+ <BlockNodeComponent
205
+ components={components}
206
+ key={index}
207
+ node={child}
208
+ />
209
+ )),
210
+ };
211
+ })}
212
+ type="unordered"
213
+ />
214
+ );
215
+ }
216
+ case "code-block": {
217
+ return <CodeBlock content={node.content} info={node.info} />;
218
+ }
219
+ case "paragraph": {
220
+ return (
221
+ <Paragraph>
222
+ {node.children.map((child, index) => (
223
+ <InlineNodeComponent
224
+ components={components}
225
+ key={index}
226
+ node={child}
227
+ />
228
+ ))}
229
+ </Paragraph>
230
+ );
231
+ }
232
+ case "heading": {
233
+ return (
234
+ <Heading level={node.level}>
235
+ {node.children.map((child, index) => (
236
+ <InlineNodeComponent
237
+ components={components}
238
+ key={index}
239
+ node={child}
240
+ />
241
+ ))}
242
+ </Heading>
243
+ );
244
+ }
245
+ case "thematic-break": {
246
+ return <ThematicBreak />;
247
+ }
248
+ case "html-block": {
249
+ return <HtmlBlock content={node.content} />;
250
+ }
251
+ default:
252
+ return null;
253
+ }
254
+ }
255
+
256
+ export function InlineNodeComponent({
257
+ node,
258
+ components,
259
+ }: {
260
+ node: InlineNode;
261
+ components: InlineNodeComponents;
262
+ }) {
263
+ const {
264
+ Text,
265
+ CodeSpan,
266
+ HardBreak,
267
+ SoftBreak,
268
+ Emphasis,
269
+ Strong,
270
+ Link,
271
+ Image,
272
+ Html,
273
+ } = components;
274
+
275
+ switch (node.type) {
276
+ case "text": {
277
+ return <Text text={node.text} />;
278
+ }
279
+ case "code-span": {
280
+ return <CodeSpan text={node.text} />;
281
+ }
282
+ case "hardbreak": {
283
+ return <HardBreak />;
284
+ }
285
+ case "softbreak": {
286
+ return <SoftBreak />;
287
+ }
288
+ case "emphasis": {
289
+ return (
290
+ <Emphasis>
291
+ {node.children.map((child, index) => (
292
+ <InlineNodeComponent
293
+ components={components}
294
+ key={index}
295
+ node={child}
296
+ />
297
+ ))}
298
+ </Emphasis>
299
+ );
300
+ }
301
+ case "strong": {
302
+ return (
303
+ <Strong>
304
+ {node.children.map((child, index) => (
305
+ <InlineNodeComponent
306
+ components={components}
307
+ key={index}
308
+ node={child}
309
+ />
310
+ ))}
311
+ </Strong>
312
+ );
313
+ }
314
+ case "link": {
315
+ return (
316
+ <Link href={node.href} title={node.title}>
317
+ {node.children.map((child, index) => (
318
+ <InlineNodeComponent
319
+ components={components}
320
+ key={index}
321
+ node={child}
322
+ />
323
+ ))}
324
+ </Link>
325
+ );
326
+ }
327
+ case "image": {
328
+ return (
329
+ <Image
330
+ alt={renderInlineAsPlainText(node.children)}
331
+ href={node.href}
332
+ title={node.title}
333
+ />
334
+ );
335
+ }
336
+ case "html": {
337
+ return <Html content={node.content} />;
338
+ }
339
+ default:
340
+ return null;
341
+ }
342
+ }
343
+
344
+ function DefaultTable({
345
+ head,
346
+ body,
347
+ }: {
348
+ head: {
349
+ cells: {
350
+ children: ReactNode;
351
+ align: "left" | "right" | "center" | undefined;
352
+ }[];
353
+ };
354
+ body: {
355
+ rows: {
356
+ cells: {
357
+ children: ReactNode;
358
+ align: "left" | "right" | "center" | undefined;
359
+ }[];
360
+ }[];
361
+ };
362
+ }) {
363
+ return (
364
+ <table>
365
+ <thead>
366
+ <tr>
367
+ {head.cells.map((cell, index) => (
368
+ <th align={cell.align} key={index}>
369
+ {cell.children}
370
+ </th>
371
+ ))}
372
+ </tr>
373
+ </thead>
374
+ <tbody>
375
+ {body.rows.map((row, index) => (
376
+ <tr key={index}>
377
+ {row.cells.map((cell, index) => (
378
+ <td align={cell.align} key={index}>
379
+ {cell.children}
380
+ </td>
381
+ ))}
382
+ </tr>
383
+ ))}
384
+ </tbody>
385
+ </table>
386
+ );
387
+ }
388
+
389
+ function DefaultCodeBlock({ content }: { content: string; info?: string }) {
390
+ return <pre>{content}</pre>;
391
+ }
392
+
393
+ function DefaultBlockquote({ children }: { children: ReactNode }) {
394
+ return <blockquote>{children}</blockquote>;
395
+ }
396
+
397
+ function DefaultList(
398
+ props:
399
+ | {
400
+ type: "ordered";
401
+ items: { children: ReactNode }[];
402
+ start?: number;
403
+ }
404
+ | {
405
+ type: "unordered";
406
+ items: { children: ReactNode }[];
407
+ },
408
+ ) {
409
+ if (props.type === "ordered") {
410
+ return (
411
+ <ol start={props.start}>
412
+ {props.items.map((item, index) => (
413
+ <li key={index}>{item.children}</li>
414
+ ))}
415
+ </ol>
416
+ );
417
+ }
418
+
419
+ return (
420
+ <ul>
421
+ {props.items.map((item, index) => (
422
+ <li key={index}>{item.children}</li>
423
+ ))}
424
+ </ul>
425
+ );
426
+ }
427
+
428
+ function DefaultHeading({
429
+ level,
430
+ children,
431
+ }: {
432
+ level: 1 | 2 | 3 | 4 | 5 | 6;
433
+ children: ReactNode;
434
+ }) {
435
+ const Heading = `h${level}` as const;
436
+ return <Heading>{children}</Heading>;
437
+ }
438
+
439
+ function DefaultParagraph({ children }: { children: ReactNode }) {
440
+ return <p>{children}</p>;
441
+ }
442
+
443
+ function DefaultThematicBreak() {
444
+ return <hr />;
445
+ }
446
+
447
+ function DefaultHtmlBlock({ content }: { content: string }) {
448
+ return content;
449
+ }
450
+
451
+ function DefaultText({ text }: { text: string }) {
452
+ return text;
453
+ }
454
+
455
+ function DefaultCodeSpan({ text }: { text: string }) {
456
+ return <code>{text}</code>;
457
+ }
458
+
459
+ function DefaultHardBreak() {
460
+ return <br />;
461
+ }
462
+
463
+ function DefaultSoftBreak() {
464
+ return null;
465
+ }
466
+
467
+ function DefaultEmphasis({ children }: { children: ReactNode }) {
468
+ return <em>{children}</em>;
469
+ }
470
+
471
+ function DefaultStrong({ children }: { children: ReactNode }) {
472
+ return <strong>{children}</strong>;
473
+ }
474
+
475
+ function DefaultLink({
476
+ href,
477
+ title,
478
+ children,
479
+ }: {
480
+ href: string;
481
+ title?: string;
482
+ children: ReactNode;
483
+ }) {
484
+ return (
485
+ <a href={isValidUrl(href) ? escapeHTML(href) : undefined} title={title}>
486
+ {children}
487
+ </a>
488
+ );
489
+ }
490
+
491
+ function DefaultImage({
492
+ href,
493
+ title,
494
+ alt,
495
+ }: {
496
+ href: string;
497
+ title?: string;
498
+ alt: string;
499
+ }) {
500
+ return (
501
+ <img
502
+ alt={alt}
503
+ src={isValidUrl(href) ? escapeHTML(href) : undefined}
504
+ title={title}
505
+ />
506
+ );
507
+ }
508
+
509
+ function DefaultHtml({ content }: { content: string }) {
510
+ return content;
511
+ }
512
+
513
+ /**
514
+ * Checks if the provided URL starts with a bad protocol (vbscript:, javascript:, file:, or data:);
515
+ * if so, it only allows data: URLs for safe image types (gif, png, jpeg, webp). Otherwise, it allows the URL.
516
+ * @param url - The URL to check.
517
+ * @returns true if the URL is valid, false otherwise.
518
+ */
519
+ function isValidUrl(url: string): boolean {
520
+ return /^(vbscript|javascript|file|data):/.test(url.trim().toLowerCase())
521
+ ? /^data:image\/(gif|png|jpeg|webp);/.test(url.trim().toLowerCase())
522
+ : true;
523
+ }
524
+
525
+ function escapeHTML(text: string): string {
526
+ if (/[&<>"]/.test(text)) {
527
+ return text.replace(/[&<>"]/g, (match) => {
528
+ if (match === "&") return "&amp;";
529
+ if (match === "<") return "&lt;";
530
+ if (match === ">") return "&gt;";
531
+ if (match === '"') return "&quot;";
532
+ return match;
533
+ });
534
+ }
535
+ return text;
536
+ }
537
+
538
+ function renderInlineAsPlainText(nodes: InlineNode[]): string {
539
+ let result = "";
540
+ for (const node of nodes) {
541
+ switch (node.type) {
542
+ case "text":
543
+ case "code-span":
544
+ result += node.text;
545
+ break;
546
+ case "image":
547
+ case "link":
548
+ result += renderInlineAsPlainText(node.children);
549
+ break;
550
+ case "strong":
551
+ case "emphasis":
552
+ result += renderInlineAsPlainText(node.children);
553
+ break;
554
+ case "html":
555
+ result += node.content;
556
+ break;
557
+ case "hardbreak":
558
+ case "softbreak":
559
+ result += "\n";
560
+ break;
561
+ default:
562
+ break;
563
+ }
564
+ }
565
+ return result;
566
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "noEmit": true,
4
+ "strict": true,
5
+ "skipLibCheck": true,
6
+ "target": "es2022",
7
+ "module": "es2022",
8
+ "moduleResolution": "bundler",
9
+ "esModuleInterop": true,
10
+ "stripInternal": true,
11
+ "allowUnreachableCode": false,
12
+ "allowUnusedLabels": false,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "noImplicitReturns": true,
15
+ "verbatimModuleSyntax": true,
16
+ "noUncheckedIndexedAccess": true,
17
+ "noUnusedLocals": true,
18
+ "noUnusedParameters": true,
19
+ "types": ["react"],
20
+ "jsx": "react-jsx"
21
+ },
22
+ "include": ["src/**/*"],
23
+ "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
24
+ }