@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,185 @@
1
+ ---
2
+ import type { CmsAsset } from '@407dev/blocks';
3
+
4
+ interface Props {
5
+ src: CmsAsset | string | { kind?: string; id?: string; src?: string; [key: string]: unknown };
6
+ variant?: 'hero' | 'feed';
7
+ poster?: string | CmsAsset;
8
+ autoplay?: boolean;
9
+ muted?: boolean;
10
+ loop?: boolean;
11
+ controls?: boolean;
12
+ preload?: 'none' | 'metadata' | 'auto';
13
+ class?: string;
14
+ style?: string;
15
+ width?: number;
16
+ height?: number;
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ const {
21
+ src,
22
+ variant = 'hero',
23
+ poster: explicitPoster,
24
+ autoplay: userAutoplay,
25
+ muted: userMuted,
26
+ loop: userLoop,
27
+ controls: userControls,
28
+ preload: explicitPreload,
29
+ class: className,
30
+ style = '',
31
+ width: explicitWidth,
32
+ height: explicitHeight,
33
+ ...rest
34
+ } = Astro.props;
35
+
36
+ // 1. Resolve source URL and poster URL from asset or plain inputs
37
+ const isObject = typeof src === 'object' && src !== null;
38
+ const isUrlObject = isObject && (src as { kind?: string }).kind === 'url';
39
+ const asset = isObject && !isUrlObject ? (src as CmsAsset) : null;
40
+
41
+ // Determine video URL
42
+ let videoUrl = '';
43
+ let autoPosterUrl = '';
44
+
45
+ if (asset) {
46
+ const renditions = asset.renditions || {};
47
+ if (variant === 'hero') {
48
+ videoUrl = renditions.hero?.url || renditions['video-hero']?.url || asset.url || '';
49
+ autoPosterUrl =
50
+ renditions.hero?.poster?.url ||
51
+ renditions['video-hero']?.poster?.url ||
52
+ renditions.poster?.url ||
53
+ '';
54
+ } else {
55
+ // feed variant
56
+ videoUrl = renditions.feed?.url || renditions['video-feed']?.url || asset.url || '';
57
+ autoPosterUrl =
58
+ renditions.feed?.poster?.url ||
59
+ renditions['video-feed']?.poster?.url ||
60
+ renditions.poster?.url ||
61
+ '';
62
+ }
63
+ } else if (isUrlObject) {
64
+ videoUrl = (src as { src?: string }).src || '';
65
+ } else if (typeof src === 'string') {
66
+ videoUrl = src;
67
+ }
68
+
69
+ // Determine Poster URL
70
+ let finalPosterUrl: string | undefined = undefined;
71
+ if (typeof explicitPoster === 'string') {
72
+ finalPosterUrl = explicitPoster;
73
+ } else if (typeof explicitPoster === 'object' && explicitPoster !== null) {
74
+ finalPosterUrl = (explicitPoster as CmsAsset).url;
75
+ } else if (autoPosterUrl) {
76
+ finalPosterUrl = autoPosterUrl;
77
+ }
78
+
79
+ // 2. Variant configurations
80
+ const isHero = variant === 'hero';
81
+ const isFeed = variant === 'feed';
82
+
83
+ const defaultPreload = isHero ? 'none' : 'metadata';
84
+ const preload = explicitPreload ?? defaultPreload;
85
+
86
+ const muted = userMuted ?? (isHero ? true : isFeed ? true : undefined);
87
+ const loop = userLoop ?? (isHero ? true : undefined);
88
+ const playsinline = true;
89
+ const controls = userControls ?? (isFeed ? false : undefined);
90
+
91
+ const intrinsicWidth = explicitWidth ?? asset?.width ?? undefined;
92
+ const intrinsicHeight = explicitHeight ?? asset?.height ?? undefined;
93
+ ---
94
+
95
+ <video
96
+ src={videoUrl}
97
+ poster={finalPosterUrl || undefined}
98
+ preload={preload}
99
+ muted={muted}
100
+ loop={loop}
101
+ playsinline={playsinline}
102
+ controls={controls}
103
+ width={intrinsicWidth}
104
+ height={intrinsicHeight}
105
+ class:list={['cms-video', `cms-video-${variant}`, className]}
106
+ style={style || undefined}
107
+ data-cms-video-variant={variant}
108
+ {...rest}
109
+ >
110
+ <slot />
111
+ </video>
112
+
113
+ {isHero && (
114
+ <script is:inline>
115
+ (() => {
116
+ const initHeroVideos = () => {
117
+ const videos = document.querySelectorAll('video[data-cms-video-variant="hero"]');
118
+ if (!('IntersectionObserver' in window)) {
119
+ videos.forEach((v) => {
120
+ if (v instanceof HTMLVideoElement && v.paused) v.play().catch(() => {});
121
+ });
122
+ return;
123
+ }
124
+
125
+ const observer = new IntersectionObserver(
126
+ (entries) => {
127
+ entries.forEach((entry) => {
128
+ const vid = entry.target;
129
+ if (!(vid instanceof HTMLVideoElement)) return;
130
+ if (entry.isIntersecting) {
131
+ if (vid.preload === 'none') vid.preload = 'auto';
132
+ vid.play().catch(() => {});
133
+ } else {
134
+ vid.pause();
135
+ }
136
+ });
137
+ },
138
+ { threshold: 0.15 }
139
+ );
140
+
141
+ videos.forEach((v) => observer.observe(v));
142
+ };
143
+
144
+ if (document.readyState === 'loading') {
145
+ document.addEventListener('DOMContentLoaded', initHeroVideos, { once: true });
146
+ } else {
147
+ initHeroVideos();
148
+ }
149
+ })();
150
+ </script>
151
+ )}
152
+
153
+ {isFeed && (
154
+ <script is:inline>
155
+ (() => {
156
+ const initFeedVideos = () => {
157
+ const videos = document.querySelectorAll('video[data-cms-video-variant="feed"]');
158
+ if (!('IntersectionObserver' in window)) return;
159
+
160
+ const observer = new IntersectionObserver(
161
+ (entries) => {
162
+ entries.forEach((entry) => {
163
+ const vid = entry.target;
164
+ if (!(vid instanceof HTMLVideoElement)) return;
165
+ if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
166
+ vid.play().catch(() => {});
167
+ } else {
168
+ vid.pause();
169
+ }
170
+ });
171
+ },
172
+ { threshold: [0, 0.5, 1.0] }
173
+ );
174
+
175
+ videos.forEach((v) => observer.observe(v));
176
+ };
177
+
178
+ if (document.readyState === 'loading') {
179
+ document.addEventListener('DOMContentLoaded', initFeedVideos, { once: true });
180
+ } else {
181
+ initFeedVideos();
182
+ }
183
+ })();
184
+ </script>
185
+ )}
@@ -0,0 +1,32 @@
1
+ ---
2
+ import RichTextNode from './richtext/RichTextNode.astro';
3
+ import type { RichTextComponents } from './richtext/RichTextNode.astro';
4
+ import { parseRichTextDoc, renderRichTextHtml } from './richtext/render';
5
+
6
+ interface Props {
7
+ value: unknown;
8
+ components?: RichTextComponents;
9
+ class?: string;
10
+ tag?: string;
11
+ [key: string]: unknown;
12
+ }
13
+
14
+ const { value, components, class: className, tag = 'div', ...rest } = Astro.props;
15
+ const Tag = tag;
16
+
17
+ // Only walk the doc through the component-aware renderer when overrides are actually supplied;
18
+ // otherwise use the plain string renderer, which also handles the non-JSON-string fallback.
19
+ const hasComponents = Boolean(components && Object.keys(components).length > 0);
20
+ const doc = hasComponents ? parseRichTextDoc(value) : null;
21
+ const fallbackHtml = doc ? '' : renderRichTextHtml(value);
22
+ ---
23
+
24
+ {doc ? (
25
+ <Tag class={className} {...rest}>
26
+ <RichTextNode node={doc} components={components} />
27
+ </Tag>
28
+ ) : fallbackHtml && Tag ? (
29
+ <Tag class={className} set:html={fallbackHtml} {...rest} />
30
+ ) : fallbackHtml ? (
31
+ <Fragment set:html={fallbackHtml} />
32
+ ) : null}
@@ -0,0 +1,227 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createCmsClient, signPreviewToken, verifyPreviewToken } from './index';
3
+ import type { ContentBundle } from './types';
4
+
5
+ describe('Astro SDK Client & Helpers', () => {
6
+ const secret = 'super-secret-preview-key-1234567890';
7
+ const siteId = 'test-site-407';
8
+ const contentUrl = 'https://cms-worker.test';
9
+
10
+ const mockBundle: ContentBundle = {
11
+ bundleVersion: 1,
12
+ site_id: siteId,
13
+ fields: {
14
+ site_title: 'My Awesome Site',
15
+ hero_headline: 'Welcome to our platform',
16
+ },
17
+ collections: {
18
+ posts: {
19
+ presentation: 'visual',
20
+ orderable: true,
21
+ route_pattern: '/posts/[slug]',
22
+ schema_version: 1,
23
+ title_field: 'title',
24
+ list_columns: ['title'],
25
+ body_field: 'body',
26
+ },
27
+ },
28
+ entries: {
29
+ posts: [
30
+ {
31
+ slug: 'first-post',
32
+ content: { title: 'First Post', body: 'Hello world' },
33
+ status: 'published',
34
+ published_at: '2026-08-22T00:00:00Z',
35
+ },
36
+ ],
37
+ },
38
+ routes: [
39
+ { path: '/', kind: 'static', collection: null },
40
+ { pattern: '/posts/[slug]', kind: 'collection', collection: 'posts' },
41
+ ],
42
+ settings: {
43
+ robots: { rules: [{ user_agent: '*', allow: ['/'] }] },
44
+ default_seo: { title: 'SEO Title' },
45
+ sitemap: { enabled: true },
46
+ not_found_path: '/404',
47
+ },
48
+ redirects: [
49
+ {
50
+ id: 'r1',
51
+ from_path: '/old',
52
+ to_path: '/new',
53
+ status_code: 301,
54
+ enabled: true,
55
+ order_key: 'a0',
56
+ },
57
+ ],
58
+ };
59
+
60
+ describe('JWT Verification', () => {
61
+ it('signs and verifies a valid preview token', async () => {
62
+ const now = Math.floor(Date.now() / 1000);
63
+ const token = await signPreviewToken(
64
+ {
65
+ site_id: siteId,
66
+ sub: 'preview-user',
67
+ iat: now,
68
+ exp: now + 3600,
69
+ },
70
+ secret,
71
+ );
72
+
73
+ const result = await verifyPreviewToken(token, secret, siteId);
74
+ expect(result.valid).toBe(true);
75
+ expect(result.payload?.site_id).toBe(siteId);
76
+ });
77
+
78
+ it('rejects an expired token', async () => {
79
+ const now = Math.floor(Date.now() / 1000);
80
+ const token = await signPreviewToken(
81
+ {
82
+ site_id: siteId,
83
+ sub: 'preview-user',
84
+ iat: now - 7200,
85
+ exp: now - 3600,
86
+ },
87
+ secret,
88
+ );
89
+
90
+ const result = await verifyPreviewToken(token, secret, siteId);
91
+ expect(result.valid).toBe(false);
92
+ });
93
+
94
+ it('rejects a token scoped to a different site', async () => {
95
+ const now = Math.floor(Date.now() / 1000);
96
+ const token = await signPreviewToken(
97
+ {
98
+ site_id: 'different-site',
99
+ sub: 'preview-user',
100
+ iat: now,
101
+ exp: now + 3600,
102
+ },
103
+ secret,
104
+ );
105
+
106
+ const result = await verifyPreviewToken(token, secret, siteId);
107
+ expect(result.valid).toBe(false);
108
+ });
109
+ });
110
+
111
+ describe('createCmsClient', () => {
112
+ let mockFetch: ReturnType<typeof vi.fn>;
113
+
114
+ beforeEach(() => {
115
+ mockFetch = vi.fn().mockImplementation(async (url: string, init?: RequestInit) => {
116
+ if (url.includes(`/content/${siteId}/bundle`)) {
117
+ return {
118
+ ok: true,
119
+ status: 200,
120
+ json: async () => mockBundle,
121
+ };
122
+ }
123
+ if (url.includes(`/draft/${siteId}/bundle`)) {
124
+ const draftBundle = {
125
+ ...mockBundle,
126
+ fields: {
127
+ ...mockBundle.fields,
128
+ site_title: 'Draft Site Title',
129
+ },
130
+ };
131
+ return {
132
+ ok: true,
133
+ status: 200,
134
+ json: async () => draftBundle,
135
+ };
136
+ }
137
+ return {
138
+ ok: false,
139
+ status: 404,
140
+ statusText: 'Not Found',
141
+ };
142
+ });
143
+ });
144
+
145
+ it('fetches published bundle and caches it', async () => {
146
+ const client = createCmsClient({
147
+ siteId,
148
+ contentUrl,
149
+ fetch: mockFetch,
150
+ });
151
+
152
+ const bundle1 = await client.getBundle();
153
+ expect(bundle1.site_id).toBe(siteId);
154
+ expect(mockFetch).toHaveBeenCalledTimes(1);
155
+ expect(mockFetch).toHaveBeenCalledWith(
156
+ `${contentUrl}/content/${siteId}/bundle`,
157
+ expect.objectContaining({
158
+ headers: expect.objectContaining({
159
+ 'x-cms-sdk': expect.stringContaining('cms-astro@'),
160
+ }),
161
+ }),
162
+ );
163
+
164
+ // Second call uses cached bundle
165
+ const bundle2 = await client.getBundle();
166
+ expect(bundle2).toEqual(bundle1);
167
+ expect(mockFetch).toHaveBeenCalledTimes(1);
168
+ });
169
+
170
+ it('fetches draft bundle bypassing cache when previewToken is passed', async () => {
171
+ const client = createCmsClient({
172
+ siteId,
173
+ contentUrl,
174
+ fetch: mockFetch,
175
+ });
176
+
177
+ const draftBundle = await client.getBundle({ previewToken: 'preview-jwt-123' });
178
+ expect(draftBundle.fields.site_title).toBe('Draft Site Title');
179
+ expect(mockFetch).toHaveBeenCalledWith(
180
+ `${contentUrl}/draft/${siteId}/bundle`,
181
+ expect.objectContaining({
182
+ headers: expect.objectContaining({
183
+ 'x-preview-token': 'preview-jwt-123',
184
+ 'x-cms-sdk': expect.stringContaining('cms-astro@'),
185
+ }),
186
+ }),
187
+ );
188
+ });
189
+
190
+ it('provides helper accessors for fields, collections, and entries', async () => {
191
+ const client = createCmsClient({
192
+ siteId,
193
+ contentUrl,
194
+ fetch: mockFetch,
195
+ });
196
+
197
+ const fields = await client.getFields();
198
+ expect(fields.site_title).toBe('My Awesome Site');
199
+
200
+ const collections = await client.getCollections();
201
+ expect(collections.posts.title_field).toBe('title');
202
+
203
+ const entries = await client.getEntries('posts');
204
+ expect(entries).toHaveLength(1);
205
+ expect(entries[0].slug).toBe('first-post');
206
+
207
+ const entry = await client.getEntry('posts', 'first-post');
208
+ expect(entry?.slug).toBe('first-post');
209
+
210
+ const missing = await client.getEntry('posts', 'nonexistent');
211
+ expect(missing).toBeNull();
212
+ });
213
+
214
+ it('retrieves site config', async () => {
215
+ const client = createCmsClient({
216
+ siteId,
217
+ contentUrl,
218
+ fetch: mockFetch,
219
+ });
220
+
221
+ const config = await client.getSiteConfig();
222
+ expect(config.site_id).toBe(siteId);
223
+ expect(config.redirects).toHaveLength(1);
224
+ expect(config.not_found_path).toBe('/404');
225
+ });
226
+ });
227
+ });
package/src/client.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { DEFAULT_DEV_CONTENT_URL } from '@407dev/config';
2
+ import type {
3
+ CmsClient,
4
+ CmsClientConfig,
5
+ CmsSiteConfig,
6
+ ContentBundle,
7
+ GetBundleOptions,
8
+ } from './types';
9
+ import { CMS_ASTRO_VERSION } from './version.js';
10
+
11
+ export function createCmsClient(config: CmsClientConfig): CmsClient {
12
+ const siteId = config.siteId;
13
+ const contentUrl = config.contentUrl.replace(/\/+$/, '');
14
+ const fetchFn = config.fetch || fetch;
15
+
16
+ // In-memory cache for published bundle to ensure build determinism
17
+ let cachedPublishedBundle: ContentBundle | null = null;
18
+
19
+ const client: CmsClient = {
20
+ siteId,
21
+ contentUrl,
22
+ previewSecret: config.previewSecret,
23
+
24
+ async getBundle(options?: GetBundleOptions): Promise<ContentBundle> {
25
+ const isDraft = Boolean(options?.previewToken);
26
+
27
+ if (!isDraft && cachedPublishedBundle) {
28
+ return cachedPublishedBundle;
29
+ }
30
+
31
+ const url = isDraft
32
+ ? `${contentUrl}/draft/${siteId}/bundle`
33
+ : `${contentUrl}/content/${siteId}/bundle`;
34
+
35
+ const headers: Record<string, string> = {
36
+ 'x-cms-sdk': `cms-astro@${CMS_ASTRO_VERSION}`,
37
+ };
38
+ if (options?.previewToken) {
39
+ headers['x-preview-token'] = options.previewToken;
40
+ }
41
+
42
+ const res = await fetchFn(url, {
43
+ headers,
44
+ cache: 'no-store',
45
+ });
46
+ if (!res.ok) {
47
+ const error = new Error(
48
+ `CMS Client: Failed to fetch bundle from ${url} (status ${res.status}): ${res.statusText}`,
49
+ );
50
+ (error as unknown as { status?: number }).status = res.status;
51
+ throw error;
52
+ }
53
+
54
+ const bundle = (await res.json()) as ContentBundle;
55
+
56
+ if (!isDraft) {
57
+ cachedPublishedBundle = bundle;
58
+ }
59
+
60
+ return bundle;
61
+ },
62
+
63
+ async getFields(options?: GetBundleOptions): Promise<ContentBundle['fields']> {
64
+ const bundle = await this.getBundle(options);
65
+ return bundle.fields || {};
66
+ },
67
+
68
+ async getField<T = unknown>(key: string, options?: GetBundleOptions): Promise<T | null> {
69
+ const fields = await this.getFields(options);
70
+ return (fields[key] as T) ?? null;
71
+ },
72
+
73
+ async getCollections(options?: GetBundleOptions): Promise<ContentBundle['collections']> {
74
+ const bundle = await this.getBundle(options);
75
+ return bundle.collections || {};
76
+ },
77
+
78
+ async getEntries(
79
+ collectionKey: string,
80
+ options?: GetBundleOptions,
81
+ ): Promise<ContentBundle['entries'][string]> {
82
+ const bundle = await this.getBundle(options);
83
+ return bundle.entries?.[collectionKey] || [];
84
+ },
85
+
86
+ async getEntry(
87
+ collectionKey: string,
88
+ slug: string,
89
+ options?: GetBundleOptions,
90
+ ): Promise<ContentBundle['entries'][string][number] | null> {
91
+ const entries = await this.getEntries(collectionKey, options);
92
+ return entries.find((e) => e.slug === slug) || null;
93
+ },
94
+
95
+ async getRoutes(options?: GetBundleOptions): Promise<ContentBundle['routes']> {
96
+ const bundle = await this.getBundle(options);
97
+ return bundle.routes || [];
98
+ },
99
+
100
+ async getSiteConfig(options?: GetBundleOptions): Promise<CmsSiteConfig> {
101
+ const bundle = await this.getBundle(options);
102
+ return {
103
+ site_id: bundle.site_id,
104
+ redirects: bundle.redirects || [],
105
+ robots: bundle.settings?.robots || {},
106
+ default_seo: bundle.settings?.default_seo || {},
107
+ sitemap: bundle.settings?.sitemap || {},
108
+ not_found_path: bundle.settings?.not_found_path || null,
109
+ };
110
+ },
111
+
112
+ clearCache(): void {
113
+ cachedPublishedBundle = null;
114
+ },
115
+ };
116
+
117
+ return client;
118
+ }
119
+
120
+ const globalWithCms = globalThis as typeof globalThis & {
121
+ __CMS_CLIENT__?: CmsClient | null;
122
+ };
123
+
124
+ let globalClient: CmsClient | null =
125
+ (typeof globalThis !== 'undefined' && globalWithCms.__CMS_CLIENT__) || null;
126
+
127
+ export function initCmsClient(options: {
128
+ siteId?: string;
129
+ contentUrl?: string;
130
+ previewSecret?: string;
131
+ client?: CmsClient;
132
+ }): CmsClient {
133
+ if (options.client) {
134
+ globalClient = options.client;
135
+ if (typeof globalThis !== 'undefined') {
136
+ globalWithCms.__CMS_CLIENT__ = options.client;
137
+ }
138
+ return globalClient;
139
+ }
140
+
141
+ globalClient = createCmsClient({
142
+ siteId: options.siteId || process.env.CMS_SITE_ID || '',
143
+ contentUrl: options.contentUrl || process.env.CMS_CONTENT_URL || DEFAULT_DEV_CONTENT_URL,
144
+ previewSecret: options.previewSecret || process.env.CMS_PREVIEW_SECRET,
145
+ });
146
+ if (typeof globalThis !== 'undefined') {
147
+ globalWithCms.__CMS_CLIENT__ = globalClient;
148
+ }
149
+
150
+ return globalClient;
151
+ }
152
+
153
+ export function getClient(): CmsClient | null {
154
+ return (
155
+ globalClient || (typeof globalThis !== 'undefined' && globalWithCms.__CMS_CLIENT__) || null
156
+ );
157
+ }
158
+
159
+ export function setClient(client: CmsClient | null): void {
160
+ globalClient = client;
161
+ if (typeof globalThis !== 'undefined') {
162
+ globalWithCms.__CMS_CLIENT__ = client;
163
+ }
164
+ }