@riducms/plugin-richtext 0.1.4 → 0.2.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.
@@ -0,0 +1,97 @@
1
+ <script lang="ts" generics="Payload extends { blockType: string } = never">
2
+ import type { Component, Snippet } from "svelte";
3
+ import type { RichTextDocument, RichTextNode } from "#richtext/document";
4
+ import {
5
+ documentRecoveryIssue,
6
+ renderRichTextText,
7
+ richTextElementTag,
8
+ safeRichTextURL,
9
+ } from "#richtext/render";
10
+ import type { RichTextBlockComponents } from "#richtext/svelte";
11
+
12
+ let {
13
+ value,
14
+ blocks,
15
+ fallback,
16
+ references,
17
+ }: {
18
+ value: RichTextDocument<Payload>;
19
+ blocks?: RichTextBlockComponents<Payload>;
20
+ fallback?: Snippet<[unknown, Error]>;
21
+ references?: Snippet<[Extract<RichTextNode<Payload>, { type: "upload" | "relationship" }>]>;
22
+ } = $props();
23
+
24
+ function component(block: Payload) {
25
+ return (
26
+ blocks !== undefined && Object.hasOwn(blocks, block.blockType)
27
+ ? blocks[block.blockType as Payload["blockType"]]
28
+ : undefined
29
+ ) as Component<{ block: Payload }> | undefined;
30
+ }
31
+ function unsupported(node: unknown, message: string) {
32
+ const error = new Error(message);
33
+ if (fallback === undefined) throw error;
34
+ return { node, error, render: fallback };
35
+ }
36
+ const recoveryIssue = $derived(documentRecoveryIssue(value));
37
+ </script>
38
+
39
+ {#snippet nodes(values: RichTextNode<Payload>[])}
40
+ {#each values as node}
41
+ {#if node.type === "block"}
42
+ {const Block = $derived(component(node.fields))}
43
+ {#if Block !== undefined}<Block block={node.fields} />
44
+ {:else}
45
+ {const failure = $derived(
46
+ unsupported(
47
+ node,
48
+ `No renderer registered for rich-text block ${JSON.stringify(node.fields.blockType)}`
49
+ )
50
+ )}
51
+ {@render failure.render(failure.node, failure.error)}
52
+ {/if}
53
+ {:else if node.type === "text"}
54
+ {@html renderRichTextText(node)}
55
+ {:else if node.type === "linebreak"}<br />
56
+ {:else if node.type === "horizontalrule"}<hr />
57
+ {:else if node.type === "upload" || node.type === "relationship"}
58
+ {#if references !== undefined}{@render references(node)}
59
+ {:else}
60
+ {const failure = $derived(
61
+ unsupported(node, `No renderer registered for rich-text node ${node.type}`)
62
+ )}
63
+ {@render failure.render(failure.node, failure.error)}
64
+ {/if}
65
+ {:else if node.type === "paragraph" || node.type === "heading" || node.type === "quote" || node.type === "link" || node.type === "list" || node.type === "listitem" || node.type === "code"}
66
+ {#if node.type === "code"}<pre><code>{@render nodes(node.children)}</code></pre>
67
+ {:else}
68
+ <svelte:element
69
+ this={richTextElementTag(node)}
70
+ {...node.type === "link" ? { href: safeRichTextURL(node.url ?? "") } : {}}
71
+ data-align={["center", "right", "justify", "start", "end"].includes(node.format ?? "")
72
+ ? node.format
73
+ : undefined}
74
+ data-indent={(node.indent ?? 0) > 0
75
+ ? Math.min(8, Math.floor(node.indent ?? 0))
76
+ : undefined}
77
+ data-list-type={node.type === "list" && node.listType === "check" ? "check" : undefined}
78
+ data-checked={node.type === "listitem" ? node.checked : undefined}
79
+ >
80
+ {@render nodes(node.children)}
81
+ </svelte:element>
82
+ {/if}
83
+ {:else}
84
+ {const failure = $derived(unsupported(node, "Unsupported rich-text node"))}
85
+ {@render failure.render(failure.node, failure.error)}
86
+ {/if}
87
+ {/each}
88
+ {/snippet}
89
+
90
+ {#if recoveryIssue !== undefined}
91
+ {const failure = $derived(
92
+ unsupported(value, `Unsupported rich-text document envelope or budget at ${recoveryIssue}`)
93
+ )}
94
+ {@render failure.render(failure.node, failure.error)}
95
+ {:else}
96
+ {@render nodes(value.root.children)}
97
+ {/if}
package/src/render.ts ADDED
@@ -0,0 +1,164 @@
1
+ import type {
2
+ RichTextBlockRenderers,
3
+ RichTextDocument,
4
+ RichTextElementNode,
5
+ RichTextNode,
6
+ RichTextTextNode,
7
+ } from "#richtext/document";
8
+ import { documentRecoveryIssue } from "#richtext/document-validation";
9
+
10
+ export type { RichTextBlockRenderers } from "#richtext/document";
11
+ export { documentRecoveryIssue } from "#richtext/document-validation";
12
+
13
+ export interface RichTextRenderOptions<Payload extends { blockType: string }> {
14
+ /** Application renderers return trusted HTML. Ordinary text is always escaped. */
15
+ blocks?: RichTextBlockRenderers<Payload, string>;
16
+ nodes?: Partial<
17
+ Record<
18
+ "upload" | "relationship",
19
+ (node: Extract<RichTextNode<Payload>, { type: "upload" | "relationship" }>) => string
20
+ >
21
+ >;
22
+ /** Unknown nodes/variants fail by default; a fallback must return visible application-owned output. */
23
+ fallback?: (node: unknown, error: Error) => string;
24
+ }
25
+
26
+ export function escapeRichText(value: string): string {
27
+ return value.replace(
28
+ /[&<>"']/g,
29
+ (character) =>
30
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character] ??
31
+ character
32
+ );
33
+ }
34
+
35
+ export function safeRichTextURL(value: string): string {
36
+ const url = value.trim();
37
+ if (
38
+ /[\u0000-\u001f\u007f]/u.test(value) ||
39
+ url.startsWith("//") ||
40
+ (/^[a-z][a-z\d+.-]*:/iu.test(url) && !/^(https?|mailto|tel):/iu.test(url))
41
+ ) {
42
+ throw new Error("Unsafe rich-text link URL");
43
+ }
44
+ return url;
45
+ }
46
+
47
+ export function renderRichTextText(node: RichTextTextNode): string {
48
+ let text = escapeRichText(node.text);
49
+ for (const [mask, tag] of [
50
+ [64, "sup"],
51
+ [32, "sub"],
52
+ [16, "code"],
53
+ [8, "u"],
54
+ [4, "s"],
55
+ [2, "em"],
56
+ [1, "strong"],
57
+ ] as const) {
58
+ if (((node.format ?? 0) & mask) !== 0) text = `<${tag}>${text}</${tag}>`;
59
+ }
60
+ return text;
61
+ }
62
+
63
+ export function richTextElementTag(node: RichTextElementNode<unknown>): string {
64
+ switch (node.type) {
65
+ case "paragraph":
66
+ return "p";
67
+ case "heading":
68
+ return /^h[1-6]$/u.test(node.tag ?? "") ? (node.tag ?? "h2") : "h2";
69
+ case "quote":
70
+ return "blockquote";
71
+ case "link":
72
+ return "a";
73
+ case "list":
74
+ return node.listType === "number" ? "ol" : "ul";
75
+ case "listitem":
76
+ return "li";
77
+ case "code":
78
+ return "pre";
79
+ }
80
+ }
81
+
82
+ /** Pure synchronous rendering: no editor bundle, DOM, Svelte dependency or network requests. */
83
+ export function renderRichTextHTML<Payload extends { blockType: string } = never>(
84
+ value: RichTextDocument<Payload>,
85
+ options: RichTextRenderOptions<Payload> = {}
86
+ ): string {
87
+ const issue = documentRecoveryIssue(value);
88
+ if (issue !== undefined) {
89
+ const error = new Error(`Unsupported rich-text document envelope or budget at ${issue}`);
90
+ if (options.fallback !== undefined) return options.fallback(value, error);
91
+ throw error;
92
+ }
93
+ let count = 0;
94
+ const render = (node: RichTextNode<Payload>, depth: number): string => {
95
+ count += 1;
96
+ if (depth > 64 || count > 10_000)
97
+ throw new Error("Rich-text rendering exceeds the depth or node budget");
98
+ try {
99
+ switch (node.type) {
100
+ case "block": {
101
+ const payload = node.fields;
102
+ const renderer = (
103
+ options.blocks !== undefined && Object.hasOwn(options.blocks, payload.blockType)
104
+ ? options.blocks[payload.blockType as Payload["blockType"]]
105
+ : undefined
106
+ ) as ((block: Payload) => string) | undefined;
107
+ if (renderer === undefined)
108
+ throw new Error(
109
+ `No renderer registered for rich-text block ${JSON.stringify(payload.blockType)}`
110
+ );
111
+ return renderer(payload);
112
+ }
113
+ case "text":
114
+ return renderRichTextText(node);
115
+ case "linebreak":
116
+ return "<br>";
117
+ case "horizontalrule":
118
+ return "<hr>";
119
+ case "upload":
120
+ case "relationship": {
121
+ const renderer = options.nodes?.[node.type];
122
+ if (renderer === undefined)
123
+ throw new Error(
124
+ `No renderer registered for rich-text node ${JSON.stringify(node.type)}`
125
+ );
126
+ return renderer(node);
127
+ }
128
+ case "paragraph":
129
+ case "heading":
130
+ case "quote":
131
+ case "link":
132
+ case "list":
133
+ case "listitem":
134
+ case "code": {
135
+ const tag = richTextElementTag(node);
136
+ const children = node.children.map((child) => render(child, depth + 1)).join("");
137
+ let attributes =
138
+ node.type === "link"
139
+ ? ` href="${escapeRichText(safeRichTextURL(node.url ?? ""))}"`
140
+ : "";
141
+ if (["center", "right", "justify", "start", "end"].includes(node.format ?? ""))
142
+ attributes += ` data-align="${node.format}"`;
143
+ if ((node.indent ?? 0) > 0)
144
+ attributes += ` data-indent="${Math.min(8, Math.floor(node.indent ?? 0))}"`;
145
+ if (node.type === "list" && node.listType === "check")
146
+ attributes += ' data-list-type="check"';
147
+ if (node.type === "listitem" && node.checked !== undefined)
148
+ attributes += ` data-checked="${node.checked}"`;
149
+ return node.type === "code"
150
+ ? `<pre${attributes}><code>${children}</code></pre>`
151
+ : `<${tag}${attributes}>${children}</${tag}>`;
152
+ }
153
+ default:
154
+ throw new Error(
155
+ `Unsupported rich-text node ${JSON.stringify((node as { type: string }).type)}`
156
+ );
157
+ }
158
+ } catch (error) {
159
+ if (options.fallback === undefined) throw error;
160
+ return options.fallback(node, error instanceof Error ? error : new Error(String(error)));
161
+ }
162
+ };
163
+ return value.root.children.map((node) => render(node, 0)).join("");
164
+ }