@407dev/cms-astro 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.
Files changed (212) hide show
  1. package/README.md +69 -0
  2. package/dist/chunk-2TKIQIOE.js +118 -0
  3. package/dist/chunk-2TKIQIOE.js.map +1 -0
  4. package/dist/chunk-53OQ5RXJ.js +912 -0
  5. package/dist/chunk-53OQ5RXJ.js.map +1 -0
  6. package/dist/chunk-56OMNQLH.js +1013 -0
  7. package/dist/chunk-56OMNQLH.js.map +1 -0
  8. package/dist/chunk-5AOOMQ7K.js +1031 -0
  9. package/dist/chunk-5AOOMQ7K.js.map +1 -0
  10. package/dist/chunk-5XRUCFDC.js +743 -0
  11. package/dist/chunk-5XRUCFDC.js.map +1 -0
  12. package/dist/chunk-62ZHCOLZ.js +25 -0
  13. package/dist/chunk-62ZHCOLZ.js.map +1 -0
  14. package/dist/chunk-6AZLRSYA.js +126 -0
  15. package/dist/chunk-6AZLRSYA.js.map +1 -0
  16. package/dist/chunk-6L3ZTCFB.js +14 -0
  17. package/dist/chunk-6L3ZTCFB.js.map +1 -0
  18. package/dist/chunk-6O7ZNNDT.js +697 -0
  19. package/dist/chunk-6O7ZNNDT.js.map +1 -0
  20. package/dist/chunk-6PQIXTMK.js +697 -0
  21. package/dist/chunk-6PQIXTMK.js.map +1 -0
  22. package/dist/chunk-7AW6M7QW.js +1013 -0
  23. package/dist/chunk-7AW6M7QW.js.map +1 -0
  24. package/dist/chunk-7E3XJLKX.js +73 -0
  25. package/dist/chunk-7E3XJLKX.js.map +1 -0
  26. package/dist/chunk-7LM54OTA.js +1031 -0
  27. package/dist/chunk-7LM54OTA.js.map +1 -0
  28. package/dist/chunk-7LMUFCWX.js +126 -0
  29. package/dist/chunk-7LMUFCWX.js.map +1 -0
  30. package/dist/chunk-A3OGL4OX.js +706 -0
  31. package/dist/chunk-A3OGL4OX.js.map +1 -0
  32. package/dist/chunk-AO4UAWOV.js +1030 -0
  33. package/dist/chunk-AO4UAWOV.js.map +1 -0
  34. package/dist/chunk-B4YYLS7Y.js +14 -0
  35. package/dist/chunk-B4YYLS7Y.js.map +1 -0
  36. package/dist/chunk-BDFVGXM2.js +697 -0
  37. package/dist/chunk-BDFVGXM2.js.map +1 -0
  38. package/dist/chunk-CXM7NZVX.js +1031 -0
  39. package/dist/chunk-CXM7NZVX.js.map +1 -0
  40. package/dist/chunk-DMLOBKYE.js +96 -0
  41. package/dist/chunk-DMLOBKYE.js.map +1 -0
  42. package/dist/chunk-DXJSYGS6.js +743 -0
  43. package/dist/chunk-DXJSYGS6.js.map +1 -0
  44. package/dist/chunk-EFUSAQZM.js +72 -0
  45. package/dist/chunk-EFUSAQZM.js.map +1 -0
  46. package/dist/chunk-F7KDGPBN.js +1013 -0
  47. package/dist/chunk-F7KDGPBN.js.map +1 -0
  48. package/dist/chunk-FBSQT45X.js +126 -0
  49. package/dist/chunk-FBSQT45X.js.map +1 -0
  50. package/dist/chunk-FEPDOMVH.js +697 -0
  51. package/dist/chunk-FEPDOMVH.js.map +1 -0
  52. package/dist/chunk-FQDOL2QW.js +25 -0
  53. package/dist/chunk-FQDOL2QW.js.map +1 -0
  54. package/dist/chunk-GB3LGQJM.js +109 -0
  55. package/dist/chunk-GB3LGQJM.js.map +1 -0
  56. package/dist/chunk-HCNP6R3P.js +736 -0
  57. package/dist/chunk-HCNP6R3P.js.map +1 -0
  58. package/dist/chunk-J6WEJDRI.js +743 -0
  59. package/dist/chunk-J6WEJDRI.js.map +1 -0
  60. package/dist/chunk-JHEAPNJ2.js +117 -0
  61. package/dist/chunk-JHEAPNJ2.js.map +1 -0
  62. package/dist/chunk-JOJDJ6GW.js +25 -0
  63. package/dist/chunk-JOJDJ6GW.js.map +1 -0
  64. package/dist/chunk-KTDV6WYB.js +202 -0
  65. package/dist/chunk-KTDV6WYB.js.map +1 -0
  66. package/dist/chunk-KZWJBKPC.js +734 -0
  67. package/dist/chunk-KZWJBKPC.js.map +1 -0
  68. package/dist/chunk-LEAQBGL6.js +109 -0
  69. package/dist/chunk-LEAQBGL6.js.map +1 -0
  70. package/dist/chunk-LGZJL2Q7.js +697 -0
  71. package/dist/chunk-LGZJL2Q7.js.map +1 -0
  72. package/dist/chunk-LQPBU46R.js +109 -0
  73. package/dist/chunk-LQPBU46R.js.map +1 -0
  74. package/dist/chunk-M55NNUTY.js +1013 -0
  75. package/dist/chunk-M55NNUTY.js.map +1 -0
  76. package/dist/chunk-M6NCYCC3.js +1013 -0
  77. package/dist/chunk-M6NCYCC3.js.map +1 -0
  78. package/dist/chunk-MGN7OZAU.js +109 -0
  79. package/dist/chunk-MGN7OZAU.js.map +1 -0
  80. package/dist/chunk-MGYZOX4K.js +1013 -0
  81. package/dist/chunk-MGYZOX4K.js.map +1 -0
  82. package/dist/chunk-MY4IEKJS.js +1031 -0
  83. package/dist/chunk-MY4IEKJS.js.map +1 -0
  84. package/dist/chunk-NCQRGAB4.js +705 -0
  85. package/dist/chunk-NCQRGAB4.js.map +1 -0
  86. package/dist/chunk-NI4NHXI4.js +697 -0
  87. package/dist/chunk-NI4NHXI4.js.map +1 -0
  88. package/dist/chunk-NPI5N7AU.js +14 -0
  89. package/dist/chunk-NPI5N7AU.js.map +1 -0
  90. package/dist/chunk-QN4CPMJF.js +126 -0
  91. package/dist/chunk-QN4CPMJF.js.map +1 -0
  92. package/dist/chunk-QYUVAKMC.js +1013 -0
  93. package/dist/chunk-QYUVAKMC.js.map +1 -0
  94. package/dist/chunk-RAAT6PDT.js +25 -0
  95. package/dist/chunk-RAAT6PDT.js.map +1 -0
  96. package/dist/chunk-RDPO5VPS.js +1013 -0
  97. package/dist/chunk-RDPO5VPS.js.map +1 -0
  98. package/dist/chunk-RQHIW2PF.js +25 -0
  99. package/dist/chunk-RQHIW2PF.js.map +1 -0
  100. package/dist/chunk-RREVHA52.js +722 -0
  101. package/dist/chunk-RREVHA52.js.map +1 -0
  102. package/dist/chunk-S7PT4PZN.js +25 -0
  103. package/dist/chunk-S7PT4PZN.js.map +1 -0
  104. package/dist/chunk-SUXV5M32.js +109 -0
  105. package/dist/chunk-SUXV5M32.js.map +1 -0
  106. package/dist/chunk-SZEORUY6.js +696 -0
  107. package/dist/chunk-SZEORUY6.js.map +1 -0
  108. package/dist/chunk-T7NGGWGY.js +25 -0
  109. package/dist/chunk-T7NGGWGY.js.map +1 -0
  110. package/dist/chunk-UEVWP5VE.js +696 -0
  111. package/dist/chunk-UEVWP5VE.js.map +1 -0
  112. package/dist/chunk-UJNKNP7U.js +748 -0
  113. package/dist/chunk-UJNKNP7U.js.map +1 -0
  114. package/dist/chunk-V4XVFWFI.js +1013 -0
  115. package/dist/chunk-V4XVFWFI.js.map +1 -0
  116. package/dist/chunk-VJU2CIKZ.js +729 -0
  117. package/dist/chunk-VJU2CIKZ.js.map +1 -0
  118. package/dist/chunk-VRQUP5L3.js +202 -0
  119. package/dist/chunk-VRQUP5L3.js.map +1 -0
  120. package/dist/chunk-WBJ5E7CL.js +742 -0
  121. package/dist/chunk-WBJ5E7CL.js.map +1 -0
  122. package/dist/chunk-XJPVYMZM.js +24 -0
  123. package/dist/chunk-XJPVYMZM.js.map +1 -0
  124. package/dist/chunk-Y3Q65C7R.js +14 -0
  125. package/dist/chunk-Y3Q65C7R.js.map +1 -0
  126. package/dist/chunk-YHEGFPTR.js +1030 -0
  127. package/dist/chunk-YHEGFPTR.js.map +1 -0
  128. package/dist/chunk-ZDLBN46C.js +697 -0
  129. package/dist/chunk-ZDLBN46C.js.map +1 -0
  130. package/dist/dev/index.d.ts +122 -0
  131. package/dist/dev/index.js +662 -0
  132. package/dist/dev/index.js.map +1 -0
  133. package/dist/fields/collections.d.ts +18 -0
  134. package/dist/fields/collections.js +37 -0
  135. package/dist/fields/collections.js.map +1 -0
  136. package/dist/index.d.ts +235 -0
  137. package/dist/index.js +956 -0
  138. package/dist/index.js.map +1 -0
  139. package/dist/middleware/index.d.ts +8 -0
  140. package/dist/middleware/index.js +13 -0
  141. package/dist/middleware/index.js.map +1 -0
  142. package/dist/preview-BEkLMD6N.d.ts +23 -0
  143. package/dist/preview-D9yewK8x.d.ts +24 -0
  144. package/dist/preview-DJEA4fj7.d.ts +23 -0
  145. package/dist/preview-DeD1Jn7U.d.ts +23 -0
  146. package/dist/toolbar/app.d.ts +28 -0
  147. package/dist/toolbar/app.js +1176 -0
  148. package/dist/toolbar/app.js.map +1 -0
  149. package/dist/types-15jfCQKb.d.ts +141 -0
  150. package/dist/types-BREdL9Gx.d.ts +140 -0
  151. package/dist/types-C6sUZNa2.d.ts +104 -0
  152. package/dist/types-CEYS5Fzc.d.ts +112 -0
  153. package/dist/types-DpeHpobR.d.ts +104 -0
  154. package/dist/types-NaKJe6Pg.d.ts +140 -0
  155. package/package.json +90 -0
  156. package/src/CmsHead.astro +25 -0
  157. package/src/CmsImage.astro +62 -0
  158. package/src/CmsVideo.astro +185 -0
  159. package/src/RichText.astro +32 -0
  160. package/src/client.test.ts +227 -0
  161. package/src/client.ts +164 -0
  162. package/src/dev/config.test.ts +258 -0
  163. package/src/dev/config.ts +319 -0
  164. package/src/dev/index.ts +5 -0
  165. package/src/dev/preview-token.ts +39 -0
  166. package/src/dev/routes.test.ts +354 -0
  167. package/src/dev/routes.ts +245 -0
  168. package/src/dev/token.test.ts +280 -0
  169. package/src/dev/token.ts +201 -0
  170. package/src/dev/verifier.test.ts +63 -0
  171. package/src/dev/verifier.ts +161 -0
  172. package/src/fields/attrs.ts +24 -0
  173. package/src/fields/collection.ts +72 -0
  174. package/src/fields/collections.ts +35 -0
  175. package/src/fields/entryScope.test.ts +62 -0
  176. package/src/fields/entryScope.ts +240 -0
  177. package/src/fields/group.ts +51 -0
  178. package/src/fields/helpers.test.ts +51 -0
  179. package/src/fields/helpers.ts +214 -0
  180. package/src/fields/index.ts +46 -0
  181. package/src/fields/scope.ts +21 -0
  182. package/src/fields/types.ts +119 -0
  183. package/src/head.test.ts +74 -0
  184. package/src/head.ts +123 -0
  185. package/src/image.test.ts +42 -0
  186. package/src/image.ts +64 -0
  187. package/src/index.test.ts +76 -0
  188. package/src/index.ts +13 -0
  189. package/src/integration.ts +218 -0
  190. package/src/jwt.ts +131 -0
  191. package/src/middleware/index.ts +4 -0
  192. package/src/middleware/preview.test.ts +184 -0
  193. package/src/middleware/preview.ts +206 -0
  194. package/src/preview/bridge-script.test.ts +683 -0
  195. package/src/preview/bridge-script.ts +763 -0
  196. package/src/preview/origins.ts +29 -0
  197. package/src/preview.ts +49 -0
  198. package/src/richtext/RichTextNode.astro +130 -0
  199. package/src/richtext/render.ts +248 -0
  200. package/src/richtext.test.ts +139 -0
  201. package/src/runtime/bundle.ts +28 -0
  202. package/src/runtime/store.ts +130 -0
  203. package/src/siteFiles.test.ts +93 -0
  204. package/src/siteFiles.ts +111 -0
  205. package/src/toolbar/app.test.ts +191 -0
  206. package/src/toolbar/app.ts +1091 -0
  207. package/src/toolbar/icon.ts +5 -0
  208. package/src/toolbar/widgets.test.ts +153 -0
  209. package/src/toolbar/widgets.ts +365 -0
  210. package/src/types.test.ts +154 -0
  211. package/src/types.ts +163 -0
  212. package/src/version.ts +3 -0
@@ -0,0 +1,29 @@
1
+ export const DEFAULT_EDITOR_ORIGINS: string[] = ['https://web.localhost', 'http://localhost:5173'];
2
+
3
+ export function resolveEditorOrigins(explicit?: string[] | string): string[] {
4
+ if (explicit) {
5
+ if (Array.isArray(explicit) && explicit.length > 0) {
6
+ return explicit;
7
+ }
8
+ if (typeof explicit === 'string' && explicit.trim()) {
9
+ return [explicit.trim()];
10
+ }
11
+ }
12
+
13
+ const envVal =
14
+ typeof process !== 'undefined'
15
+ ? process.env?.CMS_EDITOR_ORIGINS || process.env?.EDITOR_ORIGIN
16
+ : undefined;
17
+
18
+ if (envVal) {
19
+ const parsed = envVal
20
+ .split(',')
21
+ .map((s) => s.trim())
22
+ .filter(Boolean);
23
+ if (parsed.length > 0) {
24
+ return parsed;
25
+ }
26
+ }
27
+
28
+ return DEFAULT_EDITOR_ORIGINS;
29
+ }
package/src/preview.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { CmsClient, ContentBundle } from './types';
2
+
3
+ export * from './preview/origins';
4
+
5
+ export interface GetPreviewDataOptions {
6
+ previewToken?: string | null;
7
+ client: CmsClient;
8
+ /** @deprecated Site hosts no longer verify preview tokens locally. */
9
+ previewSecret?: string;
10
+ siteId?: string;
11
+ }
12
+
13
+ export interface PreviewBundleResult {
14
+ ok: boolean;
15
+ status: number;
16
+ bundle?: ContentBundle;
17
+ error?: string;
18
+ }
19
+
20
+ /**
21
+ * Retrieves draft content bundle using preview token (verified by content-worker).
22
+ */
23
+ export async function getPreviewData(options: GetPreviewDataOptions): Promise<PreviewBundleResult> {
24
+ const { previewToken, client } = options;
25
+
26
+ if (!previewToken) {
27
+ return {
28
+ ok: false,
29
+ status: 404,
30
+ error: 'Preview token is required',
31
+ };
32
+ }
33
+
34
+ // Fetch draft content bundle from CMS client (verified by content-worker)
35
+ try {
36
+ const bundle = await client.getBundle({ previewToken });
37
+ return {
38
+ ok: true,
39
+ status: 200,
40
+ bundle,
41
+ };
42
+ } catch (_err) {
43
+ return {
44
+ ok: false,
45
+ status: 404,
46
+ error: 'Preview token is not valid for this site',
47
+ };
48
+ }
49
+ }
@@ -0,0 +1,130 @@
1
+ ---
2
+ import { KNOWN_NODES, getLinkMarkAttrs, renderMarksOnly } from './render';
3
+ import type { RichTextNodeShape } from './render';
4
+
5
+ export interface RichTextComponents {
6
+ heading?: unknown;
7
+ bulletList?: unknown;
8
+ orderedList?: unknown;
9
+ listItem?: unknown;
10
+ blockquote?: unknown;
11
+ link?: unknown;
12
+ image?: unknown;
13
+ }
14
+
15
+ interface Props {
16
+ node: unknown;
17
+ components?: RichTextComponents;
18
+ depth?: number;
19
+ maxDepth?: number;
20
+ }
21
+
22
+ const { node, components, depth = 0, maxDepth = 50 } = Astro.props as Props;
23
+ const isDev = import.meta.env.DEV;
24
+
25
+ const n = (node && typeof node === 'object' ? node : {}) as RichTextNodeShape;
26
+ const nodeType = n.type || '';
27
+ const withinBudget = depth <= maxDepth;
28
+ const known = withinBudget && KNOWN_NODES.has(nodeType);
29
+
30
+ if (!known && withinBudget && isDev) {
31
+ console.warn(`[RichText] Skipped unknown node type: "${nodeType}"`);
32
+ }
33
+
34
+ const children = known && Array.isArray(n.content) ? n.content : [];
35
+
36
+ const HeadingComponent = components?.heading;
37
+ const level =
38
+ typeof n.attrs?.level === 'number' && n.attrs.level >= 1 && n.attrs.level <= 6
39
+ ? n.attrs.level
40
+ : 2;
41
+ const HeadingTag = `h${level}`;
42
+
43
+ const BulletListComponent = components?.bulletList;
44
+ const OrderedListComponent = components?.orderedList;
45
+ const orderStart =
46
+ typeof n.attrs?.start === 'number' && n.attrs.start !== 1 ? n.attrs.start : undefined;
47
+
48
+ const ListItemComponent = components?.listItem;
49
+ const BlockquoteComponent = components?.blockquote;
50
+
51
+ const ImageComponent = components?.image;
52
+ const imageSrc = typeof n.attrs?.src === 'string' ? n.attrs.src : '';
53
+ const imageAlt = typeof n.attrs?.alt === 'string' ? n.attrs.alt : '';
54
+
55
+ const LinkComponent = components?.link;
56
+ const linkAttrs = nodeType === 'text' ? getLinkMarkAttrs(n.marks) : null;
57
+ const markedText = nodeType === 'text' ? renderMarksOnly(n.text || '', n.marks) : '';
58
+
59
+ const codeLang = typeof n.attrs?.language === 'string' ? n.attrs.language : undefined;
60
+ ---
61
+
62
+ {!known ? null : nodeType === 'doc' ? (
63
+ children.map((c) => <Astro.self node={c} components={components} depth={depth + 1} maxDepth={maxDepth} />)
64
+ ) : nodeType === 'paragraph' ? (
65
+ <p>{children.map((c) => <Astro.self node={c} components={components} depth={depth + 1} maxDepth={maxDepth} />)}</p>
66
+ ) : nodeType === 'heading' ? (
67
+ HeadingComponent ? (
68
+ <HeadingComponent level={level}>
69
+ {children.map((c) => <Astro.self node={c} components={components} depth={depth + 1} maxDepth={maxDepth} />)}
70
+ </HeadingComponent>
71
+ ) : (
72
+ <HeadingTag>
73
+ {children.map((c) => <Astro.self node={c} components={components} depth={depth + 1} maxDepth={maxDepth} />)}
74
+ </HeadingTag>
75
+ )
76
+ ) : nodeType === 'bulletList' ? (
77
+ BulletListComponent ? (
78
+ <BulletListComponent>
79
+ {children.map((c) => <Astro.self node={c} components={components} depth={depth + 1} maxDepth={maxDepth} />)}
80
+ </BulletListComponent>
81
+ ) : (
82
+ <ul>{children.map((c) => <Astro.self node={c} components={components} depth={depth + 1} maxDepth={maxDepth} />)}</ul>
83
+ )
84
+ ) : nodeType === 'orderedList' ? (
85
+ OrderedListComponent ? (
86
+ <OrderedListComponent start={orderStart}>
87
+ {children.map((c) => <Astro.self node={c} components={components} depth={depth + 1} maxDepth={maxDepth} />)}
88
+ </OrderedListComponent>
89
+ ) : (
90
+ <ol start={orderStart}>
91
+ {children.map((c) => <Astro.self node={c} components={components} depth={depth + 1} maxDepth={maxDepth} />)}
92
+ </ol>
93
+ )
94
+ ) : nodeType === 'listItem' ? (
95
+ ListItemComponent ? (
96
+ <ListItemComponent>
97
+ {children.map((c) => <Astro.self node={c} components={components} depth={depth + 1} maxDepth={maxDepth} />)}
98
+ </ListItemComponent>
99
+ ) : (
100
+ <li>{children.map((c) => <Astro.self node={c} components={components} depth={depth + 1} maxDepth={maxDepth} />)}</li>
101
+ )
102
+ ) : nodeType === 'blockquote' ? (
103
+ BlockquoteComponent ? (
104
+ <BlockquoteComponent>
105
+ {children.map((c) => <Astro.self node={c} components={components} depth={depth + 1} maxDepth={maxDepth} />)}
106
+ </BlockquoteComponent>
107
+ ) : (
108
+ <blockquote>
109
+ {children.map((c) => <Astro.self node={c} components={components} depth={depth + 1} maxDepth={maxDepth} />)}
110
+ </blockquote>
111
+ )
112
+ ) : nodeType === 'codeBlock' ? (
113
+ <pre><code data-language={codeLang}>{children.map((c) => <Astro.self node={c} components={components} depth={depth + 1} maxDepth={maxDepth} />)}</code></pre>
114
+ ) : nodeType === 'hardBreak' ? (
115
+ <br />
116
+ ) : nodeType === 'horizontalRule' ? (
117
+ <hr />
118
+ ) : nodeType === 'image' ? (
119
+ ImageComponent ? <ImageComponent src={imageSrc} alt={imageAlt} /> : null
120
+ ) : nodeType === 'text' ? (
121
+ linkAttrs && LinkComponent ? (
122
+ <LinkComponent href={linkAttrs.href} title={linkAttrs.title} target={linkAttrs.target} rel={linkAttrs.rel}>
123
+ <Fragment set:html={markedText} />
124
+ </LinkComponent>
125
+ ) : linkAttrs ? (
126
+ <a href={linkAttrs.href} title={linkAttrs.title} target={linkAttrs.target} rel={linkAttrs.rel} set:html={markedText} />
127
+ ) : (
128
+ <Fragment set:html={markedText} />
129
+ )
130
+ ) : null}
@@ -0,0 +1,248 @@
1
+ import { isValidLinkHref } from '@407dev/field-types';
2
+
3
+ export interface RichTextRenderOptions {
4
+ components?: Record<string, unknown>;
5
+ maxDepth?: number;
6
+ }
7
+
8
+ export function escapeHtml(str: string): string {
9
+ if (typeof str !== 'string') return '';
10
+ return str
11
+ .replace(/&/g, '&amp;')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ .replace(/'/g, '&#39;');
16
+ }
17
+
18
+ export const KNOWN_NODES = new Set([
19
+ 'doc',
20
+ 'paragraph',
21
+ 'heading',
22
+ 'bulletList',
23
+ 'orderedList',
24
+ 'listItem',
25
+ 'blockquote',
26
+ 'codeBlock',
27
+ 'hardBreak',
28
+ 'horizontalRule',
29
+ 'text',
30
+ 'image',
31
+ ]);
32
+
33
+ const KNOWN_MARKS = new Set(['bold', 'italic', 'underline', 'strike', 'code', 'link']);
34
+
35
+ export interface RichTextNodeShape {
36
+ type?: string;
37
+ text?: string;
38
+ attrs?: Record<string, unknown>;
39
+ marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
40
+ content?: unknown[];
41
+ }
42
+
43
+ export interface LinkMarkAttrs {
44
+ href: string;
45
+ target?: string;
46
+ rel?: string;
47
+ title?: string;
48
+ }
49
+
50
+ /** Parses a richtext value (doc object or JSON string) into a doc root, or null if invalid/empty. */
51
+ export function parseRichTextDoc(value: unknown): { type?: string; content?: unknown[] } | null {
52
+ if (!value) return null;
53
+ let parsed: unknown = value;
54
+ if (typeof value === 'string') {
55
+ try {
56
+ parsed = JSON.parse(value);
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+ if (typeof parsed !== 'object' || parsed === null) return null;
62
+ const root = parsed as { type?: string; content?: unknown[] };
63
+ if (root.type !== 'doc' && !Array.isArray(root.content)) return null;
64
+ return root;
65
+ }
66
+
67
+ /** Applies non-link marks to escaped text; `link` is resolved separately via getLinkMarkAttrs so callers can swap in a component. */
68
+ export function renderMarksOnly(
69
+ text: string,
70
+ marks: Array<{ type: string; attrs?: Record<string, unknown> }> | undefined,
71
+ ): string {
72
+ let out = escapeHtml(text || '');
73
+ if (!Array.isArray(marks)) return out;
74
+ for (const mark of marks) {
75
+ if (!mark || typeof mark !== 'object' || mark.type === 'link' || !KNOWN_MARKS.has(mark.type)) {
76
+ continue;
77
+ }
78
+ if (mark.type === 'bold') out = `<strong>${out}</strong>`;
79
+ else if (mark.type === 'italic') out = `<em>${out}</em>`;
80
+ else if (mark.type === 'underline') out = `<u>${out}</u>`;
81
+ else if (mark.type === 'strike') out = `<s>${out}</s>`;
82
+ else if (mark.type === 'code') out = `<code>${out}</code>`;
83
+ }
84
+ return out;
85
+ }
86
+
87
+ export function getLinkMarkAttrs(
88
+ marks: Array<{ type: string; attrs?: Record<string, unknown> }> | undefined,
89
+ ): LinkMarkAttrs | null {
90
+ if (!Array.isArray(marks)) return null;
91
+ const linkMark = marks.find((m) => m && typeof m === 'object' && m.type === 'link');
92
+ if (!linkMark) return null;
93
+
94
+ const rawHref = linkMark.attrs?.href;
95
+ if (typeof rawHref !== 'string' || !isValidLinkHref(rawHref)) return null;
96
+
97
+ const target = typeof linkMark.attrs?.target === 'string' ? linkMark.attrs.target : undefined;
98
+ const rel =
99
+ typeof linkMark.attrs?.rel === 'string'
100
+ ? linkMark.attrs.rel
101
+ : target === '_blank'
102
+ ? 'noopener noreferrer'
103
+ : undefined;
104
+ const title = typeof linkMark.attrs?.title === 'string' ? linkMark.attrs.title : undefined;
105
+
106
+ return { href: rawHref, target, rel, title };
107
+ }
108
+
109
+ export function renderRichTextHtml(doc: unknown, options: RichTextRenderOptions = {}): string {
110
+ if (!doc) return '';
111
+
112
+ let parsedDoc: unknown = doc;
113
+ if (typeof doc === 'string') {
114
+ try {
115
+ parsedDoc = JSON.parse(doc);
116
+ } catch {
117
+ return `<p>${escapeHtml(doc)}</p>`;
118
+ }
119
+ }
120
+
121
+ if (typeof parsedDoc !== 'object' || parsedDoc === null) {
122
+ return '';
123
+ }
124
+
125
+ const root = parsedDoc as { type?: string; content?: unknown[] };
126
+ if (root.type !== 'doc' && !Array.isArray(root.content)) {
127
+ return '';
128
+ }
129
+
130
+ const isDev = typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
131
+ const maxDepth = options.maxDepth ?? 50;
132
+
133
+ function renderNode(node: unknown, depth = 0): string {
134
+ if (depth > maxDepth) return '';
135
+ if (!node || typeof node !== 'object') return '';
136
+
137
+ const n = node as {
138
+ type?: string;
139
+ text?: string;
140
+ attrs?: Record<string, unknown>;
141
+ marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
142
+ content?: unknown[];
143
+ };
144
+
145
+ const nodeType = n.type || '';
146
+ if (!KNOWN_NODES.has(nodeType)) {
147
+ if (isDev) {
148
+ console.warn(`[RichText] Skipped unknown node type: "${nodeType}"`);
149
+ }
150
+ return '';
151
+ }
152
+
153
+ if (nodeType === 'text') {
154
+ let text = escapeHtml(n.text || '');
155
+ if (Array.isArray(n.marks)) {
156
+ for (const mark of n.marks) {
157
+ if (!mark || typeof mark !== 'object') continue;
158
+ const markType = mark.type;
159
+ if (!KNOWN_MARKS.has(markType)) {
160
+ if (isDev) {
161
+ console.warn(`[RichText] Skipped unknown mark type: "${markType}"`);
162
+ }
163
+ continue;
164
+ }
165
+
166
+ if (markType === 'bold') {
167
+ text = `<strong>${text}</strong>`;
168
+ } else if (markType === 'italic') {
169
+ text = `<em>${text}</em>`;
170
+ } else if (markType === 'underline') {
171
+ text = `<u>${text}</u>`;
172
+ } else if (markType === 'strike') {
173
+ text = `<s>${text}</s>`;
174
+ } else if (markType === 'code') {
175
+ text = `<code>${text}</code>`;
176
+ } else if (markType === 'link') {
177
+ const rawHref = mark.attrs?.href;
178
+ if (typeof rawHref === 'string' && isValidLinkHref(rawHref)) {
179
+ const href = escapeHtml(rawHref);
180
+ const target =
181
+ typeof mark.attrs?.target === 'string'
182
+ ? ` target="${escapeHtml(mark.attrs.target)}"`
183
+ : '';
184
+ const rel =
185
+ typeof mark.attrs?.rel === 'string'
186
+ ? ` rel="${escapeHtml(mark.attrs.rel)}"`
187
+ : target.includes('_blank')
188
+ ? ' rel="noopener noreferrer"'
189
+ : '';
190
+ text = `<a href="${href}"${target}${rel}>${text}</a>`;
191
+ }
192
+ }
193
+ }
194
+ }
195
+ return text;
196
+ }
197
+
198
+ const children = Array.isArray(n.content)
199
+ ? n.content.map((c) => renderNode(c, depth + 1)).join('')
200
+ : '';
201
+
202
+ switch (nodeType) {
203
+ case 'doc':
204
+ return children;
205
+ case 'paragraph':
206
+ return `<p>${children}</p>`;
207
+ case 'heading': {
208
+ const rawLevel = n.attrs?.level;
209
+ const level = typeof rawLevel === 'number' && rawLevel >= 1 && rawLevel <= 6 ? rawLevel : 2;
210
+ return `<h${level}>${children}</h${level}>`;
211
+ }
212
+ case 'bulletList':
213
+ return `<ul>${children}</ul>`;
214
+ case 'orderedList': {
215
+ const start = n.attrs?.start || n.attrs?.order;
216
+ const startAttr = typeof start === 'number' && start !== 1 ? ` start="${start}"` : '';
217
+ return `<ol${startAttr}>${children}</ol>`;
218
+ }
219
+ case 'listItem':
220
+ return `<li>${children}</li>`;
221
+ case 'blockquote':
222
+ return `<blockquote>${children}</blockquote>`;
223
+ case 'codeBlock': {
224
+ const lang =
225
+ typeof n.attrs?.language === 'string'
226
+ ? ` data-language="${escapeHtml(n.attrs.language)}"`
227
+ : '';
228
+ return `<pre><code${lang}>${children}</code></pre>`;
229
+ }
230
+ case 'hardBreak':
231
+ return '<br />';
232
+ case 'horizontalRule':
233
+ return '<hr />';
234
+ case 'image': {
235
+ if (options.components?.image) {
236
+ const src = typeof n.attrs?.src === 'string' ? escapeHtml(n.attrs.src) : '';
237
+ const alt = typeof n.attrs?.alt === 'string' ? escapeHtml(n.attrs.alt) : '';
238
+ return `<img src="${src}" alt="${alt}" />`;
239
+ }
240
+ return '';
241
+ }
242
+ default:
243
+ return children;
244
+ }
245
+ }
246
+
247
+ return renderNode(root);
248
+ }
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { renderRichTextHtml } from './richtext/render';
3
+
4
+ describe('RichText Defensive Rendering', () => {
5
+ it('renders standard ProseMirror nodes and marks to valid HTML', () => {
6
+ const doc = {
7
+ type: 'doc',
8
+ content: [
9
+ {
10
+ type: 'heading',
11
+ attrs: { level: 1 },
12
+ content: [{ type: 'text', text: 'Hello World' }],
13
+ },
14
+ {
15
+ type: 'paragraph',
16
+ content: [
17
+ { type: 'text', text: 'This is ' },
18
+ {
19
+ type: 'text',
20
+ text: 'bold and linked',
21
+ marks: [
22
+ { type: 'bold' },
23
+ {
24
+ type: 'link',
25
+ attrs: { href: 'https://example.com', target: '_blank' },
26
+ },
27
+ ],
28
+ },
29
+ { type: 'text', text: '.' },
30
+ ],
31
+ },
32
+ ],
33
+ };
34
+
35
+ const html = renderRichTextHtml(doc);
36
+ expect(html).toBe(
37
+ '<h1>Hello World</h1><p>This is <a href="https://example.com" target="_blank" rel="noopener noreferrer"><strong>bold and linked</strong></a>.</p>',
38
+ );
39
+ });
40
+
41
+ it('safely escapes script-shaped text content', () => {
42
+ const doc = {
43
+ type: 'doc',
44
+ content: [
45
+ {
46
+ type: 'paragraph',
47
+ content: [{ type: 'text', text: '<script>alert("xss")</script>' }],
48
+ },
49
+ ],
50
+ };
51
+
52
+ const html = renderRichTextHtml(doc);
53
+ expect(html).toBe('<p>&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</p>');
54
+ expect(html).not.toContain('<script>');
55
+ });
56
+
57
+ it('rejects javascript: and protocol-relative links', () => {
58
+ const doc = {
59
+ type: 'doc',
60
+ content: [
61
+ {
62
+ type: 'paragraph',
63
+ content: [
64
+ {
65
+ type: 'text',
66
+ text: 'Malicious Link',
67
+ marks: [{ type: 'link', attrs: { href: 'javascript:alert(1)' } }],
68
+ },
69
+ {
70
+ type: 'text',
71
+ text: 'Protocol Relative',
72
+ marks: [{ type: 'link', attrs: { href: '//evil.com' } }],
73
+ },
74
+ ],
75
+ },
76
+ ],
77
+ };
78
+
79
+ const html = renderRichTextHtml(doc);
80
+ // Invalid href links should omit the <a> wrapper
81
+ expect(html).toBe('<p>Malicious LinkProtocol Relative</p>');
82
+ expect(html).not.toContain('href');
83
+ });
84
+
85
+ it('skips unknown node types with warning', () => {
86
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
87
+ const doc = {
88
+ type: 'doc',
89
+ content: [
90
+ {
91
+ type: 'unsupportedNode',
92
+ content: [{ type: 'text', text: 'Mystery' }],
93
+ },
94
+ {
95
+ type: 'paragraph',
96
+ content: [{ type: 'text', text: 'Normal' }],
97
+ },
98
+ ],
99
+ };
100
+
101
+ const html = renderRichTextHtml(doc);
102
+ expect(html).toBe('<p>Normal</p>');
103
+ warnSpy.mockRestore();
104
+ });
105
+
106
+ it('skips unknown mark types with warning', () => {
107
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
108
+ const doc = {
109
+ type: 'doc',
110
+ content: [
111
+ {
112
+ type: 'paragraph',
113
+ content: [
114
+ {
115
+ type: 'text',
116
+ text: 'Decorated',
117
+ marks: [{ type: 'glitterMark' }],
118
+ },
119
+ ],
120
+ },
121
+ ],
122
+ };
123
+
124
+ const html = renderRichTextHtml(doc);
125
+ expect(html).toBe('<p>Decorated</p>');
126
+ warnSpy.mockRestore();
127
+ });
128
+
129
+ it('handles deeply nested docs without blowing call stack', () => {
130
+ let current: Record<string, unknown> = { type: 'text', text: 'Deep text' };
131
+ for (let i = 0; i < 60; i++) {
132
+ current = { type: 'blockquote', content: [current] };
133
+ }
134
+ const doc = { type: 'doc', content: [current] };
135
+
136
+ const html = renderRichTextHtml(doc, { maxDepth: 10 });
137
+ expect(html).not.toContain('Deep text');
138
+ });
139
+ });
@@ -0,0 +1,28 @@
1
+ import type { ContentBundle } from '../types';
2
+ import { getActiveBundle, setBuildBundle } from './store';
3
+
4
+ let resolveBundleReady: ((bundle: ContentBundle) => void) | null = null;
5
+ let isReady = false;
6
+
7
+ export const bundleReady = new Promise<ContentBundle>((resolve) => {
8
+ const active = getActiveBundle();
9
+ if (active) {
10
+ isReady = true;
11
+ resolve(active);
12
+ } else {
13
+ resolveBundleReady = resolve;
14
+ }
15
+ });
16
+
17
+ export function markBundleReady(bundle: ContentBundle): void {
18
+ setBuildBundle(bundle);
19
+ isReady = true;
20
+ if (resolveBundleReady) {
21
+ resolveBundleReady(bundle);
22
+ resolveBundleReady = null;
23
+ }
24
+ }
25
+
26
+ export function isBundleReady(): boolean {
27
+ return isReady || getActiveBundle() !== null;
28
+ }