@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
package/src/image.ts ADDED
@@ -0,0 +1,64 @@
1
+ export interface ImageTransformOptions {
2
+ width?: number;
3
+ height?: number;
4
+ format?: string;
5
+ fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad' | string;
6
+ quality?: number;
7
+ }
8
+
9
+ /**
10
+ * Determines whether a URL is hosted on a zone domain eligible for /cdn-cgi/image transforms.
11
+ * Non-zone URLs (*.r2.dev, localhost, relative paths, data/blob URLs) return false.
12
+ */
13
+ export function isCdnTransformableUrl(url: string): boolean {
14
+ if (!url || typeof url !== 'string') return false;
15
+ if (url.startsWith('data:') || url.startsWith('blob:')) return false;
16
+ if (url.includes('localhost') || url.includes('127.0.0.1') || url.includes('.r2.dev')) {
17
+ return false;
18
+ }
19
+ return url.startsWith('http://') || url.startsWith('https://');
20
+ }
21
+
22
+ /**
23
+ * Builds a Cloudflare /cdn-cgi/image/... transform URL.
24
+ * Automatically degrades to the raw source URL for non-transformable hostnames.
25
+ */
26
+ export function buildCdnImageUrl(url: string, options: ImageTransformOptions = {}): string {
27
+ if (!isCdnTransformableUrl(url)) {
28
+ return url;
29
+ }
30
+
31
+ const params: string[] = [];
32
+ if (options.width !== undefined && options.width !== null) {
33
+ params.push(`width=${options.width}`);
34
+ }
35
+ if (options.height !== undefined && options.height !== null) {
36
+ params.push(`height=${options.height}`);
37
+ }
38
+
39
+ params.push(`format=${options.format || 'auto'}`);
40
+
41
+ if (options.fit) {
42
+ params.push(`fit=${options.fit}`);
43
+ }
44
+ if (options.quality !== undefined && options.quality !== null) {
45
+ params.push(`quality=${options.quality}`);
46
+ }
47
+
48
+ const optionString = params.join(',');
49
+ return `/cdn-cgi/image/${optionString}/${url}`;
50
+ }
51
+
52
+ /**
53
+ * Generates an HTML srcset string with Cloudflare /cdn-cgi/image transforms for specified widths.
54
+ */
55
+ export function buildSrcSet(
56
+ url: string,
57
+ widths: number[] = [400, 800, 1200, 1600],
58
+ options: Omit<ImageTransformOptions, 'width'> = {},
59
+ ): string {
60
+ if (!url) return '';
61
+ if (!isCdnTransformableUrl(url)) return '';
62
+
63
+ return widths.map((w) => `${buildCdnImageUrl(url, { ...options, width: w })} ${w}w`).join(', ');
64
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ cms,
4
+ cmsAstro,
5
+ createCmsClient,
6
+ getPreviewData,
7
+ renderRedirectsFile,
8
+ renderRobotsTxt,
9
+ renderSitemapXml,
10
+ signPreviewToken,
11
+ verifyPreviewToken,
12
+ } from './index';
13
+
14
+ describe('cms-astro package entrypoint', () => {
15
+ it('exports all expected SDK utilities and functions', () => {
16
+ expect(typeof createCmsClient).toBe('function');
17
+ expect(typeof cmsAstro).toBe('function');
18
+ expect(typeof cms).toBe('function');
19
+ expect(typeof verifyPreviewToken).toBe('function');
20
+ expect(typeof signPreviewToken).toBe('function');
21
+ expect(typeof getPreviewData).toBe('function');
22
+ expect(typeof renderRobotsTxt).toBe('function');
23
+ expect(typeof renderSitemapXml).toBe('function');
24
+ expect(typeof renderRedirectsFile).toBe('function');
25
+ });
26
+
27
+ it('configures cmsAstro with empty options falling back to process.env', async () => {
28
+ const originalSiteId = process.env.CMS_SITE_ID;
29
+ const originalContentUrl = process.env.CMS_CONTENT_URL;
30
+ const originalPreviewSecret = process.env.CMS_PREVIEW_SECRET;
31
+
32
+ process.env.CMS_SITE_ID = 'site_env_123';
33
+ process.env.CMS_CONTENT_URL = 'https://content.example.com';
34
+ process.env.CMS_PREVIEW_SECRET = 'secret_env_456';
35
+
36
+ try {
37
+ const integration = cmsAstro();
38
+ expect(integration.name).toBe('@407dev/cms-astro');
39
+
40
+ let updatedViteConfig: Record<string, unknown> | null = null;
41
+ let warningLogged = false;
42
+ const fakeSetupArgs = {
43
+ updateConfig: ({ vite }: { vite: Record<string, unknown> }) => {
44
+ updatedViteConfig = vite;
45
+ },
46
+ command: 'build' as const,
47
+ injectScript: () => {},
48
+ addMiddleware: () => {},
49
+ addDevToolbarApp: () => {},
50
+ logger: {
51
+ warn: (msg: string) => {
52
+ if (msg.includes('deprecated')) warningLogged = true;
53
+ },
54
+ error: () => {},
55
+ info: () => {},
56
+ },
57
+ };
58
+
59
+ const setupHook = integration.hooks['astro:config:setup'];
60
+ if (typeof setupHook === 'function') {
61
+ await (setupHook as (args: unknown) => Promise<void>)(fakeSetupArgs);
62
+ }
63
+
64
+ expect(updatedViteConfig).toBeDefined();
65
+ const define = (updatedViteConfig as unknown as { define: Record<string, string> }).define;
66
+ expect(define['import.meta.env.CMS_SITE_ID']).toBe('"site_env_123"');
67
+ expect(define['import.meta.env.CMS_CONTENT_URL']).toBe('"https://content.example.com"');
68
+ expect(define['import.meta.env.CMS_PREVIEW_SECRET']).toBeUndefined();
69
+ expect(warningLogged).toBe(true);
70
+ } finally {
71
+ process.env.CMS_SITE_ID = originalSiteId;
72
+ process.env.CMS_CONTENT_URL = originalContentUrl;
73
+ process.env.CMS_PREVIEW_SECRET = originalPreviewSecret;
74
+ }
75
+ });
76
+ });
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export * from './client';
2
+ export * from './fields';
3
+ export * from './head';
4
+ export * from './image';
5
+ export * from './integration';
6
+ export * from './jwt';
7
+ export * from './middleware/preview';
8
+ export * from './preview';
9
+ export * from './richtext/render';
10
+ export * from './runtime/bundle';
11
+ export * from './runtime/store';
12
+ export * from './siteFiles';
13
+ export * from './types';
@@ -0,0 +1,218 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import type { AstroIntegration } from 'astro';
5
+ import { getClient, initCmsClient } from './client';
6
+ import { mintDevPreviewToken } from './dev/preview-token';
7
+ import { buildBridgeScript } from './preview/bridge-script';
8
+ import { markBundleReady } from './runtime/bundle';
9
+ import { clearMissingFieldWarnings, getMissingFieldWarnings } from './runtime/store';
10
+ import { CMS_TOOLBAR_ICON } from './toolbar/icon';
11
+ import type { CmsAstroOptions } from './types';
12
+
13
+ function deriveRouteFromComponent(componentPath: string): string {
14
+ let route = componentPath
15
+ .replace(/\\/g, '/')
16
+ .replace(/^.*src\/pages\/?/, '/')
17
+ .replace(/\.(astro|mdx|md|ts|js|jsx|tsx)$/, '')
18
+ .replace(/\/index$/, '');
19
+ if (!route.startsWith('/')) {
20
+ route = `/${route}`;
21
+ }
22
+ return route === '' ? '/' : route;
23
+ }
24
+
25
+ export function cmsAstro(options: CmsAstroOptions = {}): AstroIntegration {
26
+ return {
27
+ name: '@407dev/cms-astro',
28
+ hooks: {
29
+ 'astro:config:setup': async ({
30
+ updateConfig,
31
+ command,
32
+ injectScript,
33
+ addMiddleware,
34
+ addDevToolbarApp,
35
+ logger,
36
+ }) => {
37
+ // Register preview middleware
38
+ addMiddleware({
39
+ entrypoint: '@407dev/cms-astro/middleware',
40
+ order: 'pre',
41
+ });
42
+
43
+ // Initialize client singleton
44
+ if (options.client) {
45
+ initCmsClient({ client: options.client });
46
+ } else {
47
+ initCmsClient({
48
+ siteId: options.siteId,
49
+ contentUrl: options.contentUrl,
50
+ previewSecret: options.previewSecret,
51
+ });
52
+ }
53
+
54
+ // Register environment variables
55
+ const effectiveSiteId = options.siteId || process.env.CMS_SITE_ID || '';
56
+ const effectiveContentUrl = options.contentUrl || process.env.CMS_CONTENT_URL || '';
57
+ const effectivePreviewSecret =
58
+ options.previewSecret || process.env.CMS_PREVIEW_SECRET || '';
59
+
60
+ if (command !== 'dev' && (options.previewSecret || process.env.CMS_PREVIEW_SECRET)) {
61
+ logger.warn(
62
+ 'previewSecret and CMS_PREVIEW_SECRET are deprecated and ignored outside astro dev. Site hosts no longer verify preview tokens.',
63
+ );
64
+ }
65
+
66
+ if (effectiveContentUrl || effectiveSiteId) {
67
+ updateConfig({
68
+ vite: {
69
+ define: {
70
+ 'import.meta.env.CMS_CONTENT_URL': JSON.stringify(effectiveContentUrl),
71
+ 'import.meta.env.CMS_SITE_ID': JSON.stringify(effectiveSiteId),
72
+ },
73
+ },
74
+ });
75
+ }
76
+
77
+ // In dev, inject full bridge script and dev toolbar
78
+ if (command === 'dev') {
79
+ injectScript(
80
+ 'page',
81
+ buildBridgeScript({
82
+ editorOrigins: options.editorOrigins,
83
+ }),
84
+ );
85
+
86
+ const devModulePath = './dev/index.js';
87
+ const { resolveDevConfig, createVerifier, cmsDevApiPlugin } = await import(
88
+ /* @vite-ignore */ devModulePath
89
+ );
90
+
91
+ const devConfig = await resolveDevConfig({
92
+ siteId: options.siteId,
93
+ apiUrl: options.apiUrl,
94
+ siteDir: process.cwd(),
95
+ });
96
+
97
+ if (devConfig.status === 'refused') {
98
+ logger.error(`CMS dev routes disabled: ${devConfig.reason}`);
99
+ }
100
+ if (devConfig.status === 'read-only') {
101
+ logger.warn(
102
+ `CMS dev toolbar is READ-ONLY: ${devConfig.mismatch?.source} (${devConfig.mismatch?.actual}) ≠ site rendered (${devConfig.mismatch?.expected})`,
103
+ );
104
+ }
105
+
106
+ const verifier = await createVerifier({
107
+ siteDir: process.cwd(),
108
+ siteId: devConfig.siteId,
109
+ });
110
+
111
+ addDevToolbarApp({
112
+ id: 'cms-content',
113
+ name: 'CMS Content',
114
+ icon: CMS_TOOLBAR_ICON,
115
+ entrypoint: fileURLToPath(new URL('./toolbar/app.js', import.meta.url)),
116
+ });
117
+
118
+ updateConfig({
119
+ vite: {
120
+ plugins: [
121
+ cmsDevApiPlugin({
122
+ devConfig,
123
+ client: getClient(),
124
+ verifier,
125
+ previewSecret: effectivePreviewSecret,
126
+ editorOrigins: options.editorOrigins,
127
+ }),
128
+ ],
129
+ },
130
+ });
131
+ }
132
+
133
+ // Fetch bundle once on setup/build and write to .cms/bundle.json
134
+ const activeClient = getClient();
135
+ if (activeClient) {
136
+ try {
137
+ // Dev serves draft content; self-mint a preview token from the shared secret
138
+ // since the content-worker's draft endpoint still requires one (token rules unchanged).
139
+ const devPreviewToken =
140
+ command === 'dev'
141
+ ? await mintDevPreviewToken(activeClient.siteId, effectivePreviewSecret)
142
+ : null;
143
+
144
+ const bundle = await activeClient.getBundle(
145
+ devPreviewToken ? { previewToken: devPreviewToken } : undefined,
146
+ );
147
+ markBundleReady(bundle);
148
+
149
+ const cmsDir = path.resolve(process.cwd(), '.cms');
150
+ if (!fs.existsSync(cmsDir)) {
151
+ fs.mkdirSync(cmsDir, { recursive: true });
152
+ }
153
+ fs.writeFileSync(
154
+ path.join(cmsDir, 'bundle.json'),
155
+ JSON.stringify(bundle, null, 2),
156
+ 'utf-8',
157
+ );
158
+ } catch {
159
+ // Fail gracefully if fetch fails
160
+ }
161
+ }
162
+ },
163
+
164
+ 'astro:routes:resolved': async ({ routes }) => {
165
+ try {
166
+ const cmsDir = path.resolve(process.cwd(), '.cms');
167
+ if (!fs.existsSync(cmsDir)) {
168
+ fs.mkdirSync(cmsDir, { recursive: true });
169
+ }
170
+
171
+ const serializedRoutes = routes.map(
172
+ (r: {
173
+ route?: string;
174
+ pathname?: string;
175
+ component?: string;
176
+ entrypoint?: string;
177
+ type?: string;
178
+ params?: string[];
179
+ }) => {
180
+ const comp = r.component || r.entrypoint || '';
181
+ let routePattern = r.route || r.pathname || '';
182
+ if (!routePattern && comp) {
183
+ routePattern = deriveRouteFromComponent(comp);
184
+ }
185
+ return {
186
+ route: routePattern,
187
+ component: comp,
188
+ type: r.type || 'page',
189
+ params: r.params || [],
190
+ };
191
+ },
192
+ );
193
+
194
+ fs.writeFileSync(
195
+ path.join(cmsDir, 'routes.json'),
196
+ JSON.stringify(serializedRoutes, null, 2),
197
+ 'utf-8',
198
+ );
199
+ } catch {
200
+ // Ignore if .cms directory write fails
201
+ }
202
+ },
203
+
204
+ 'astro:build:done': async ({ logger }) => {
205
+ // Report missing field warnings recorded during SSG
206
+ const missing = getMissingFieldWarnings();
207
+ if (missing.length > 0) {
208
+ logger.warn(
209
+ `CMS Build Warning: ${missing.length} field(s) were rendered with empty/missing values: ${missing.join(', ')}`,
210
+ );
211
+ clearMissingFieldWarnings();
212
+ }
213
+ },
214
+ },
215
+ };
216
+ }
217
+
218
+ export const cms = cmsAstro;
package/src/jwt.ts ADDED
@@ -0,0 +1,131 @@
1
+ import type { PreviewTokenPayload, VerifyTokenResult } from './types';
2
+
3
+ // Base64url encoding and decoding helpers using standard Web APIs
4
+ function base64UrlEncode(data: Uint8Array | string): string {
5
+ let bytes: Uint8Array;
6
+ if (typeof data === 'string') {
7
+ bytes = new TextEncoder().encode(data);
8
+ } else {
9
+ bytes = data;
10
+ }
11
+
12
+ let binary = '';
13
+ for (let i = 0; i < bytes.byteLength; i++) {
14
+ binary += String.fromCharCode(bytes[i]);
15
+ }
16
+ const base64 = btoa(binary);
17
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
18
+ }
19
+
20
+ function base64UrlDecode(str: string): Uint8Array {
21
+ let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
22
+ while (base64.length % 4 !== 0) {
23
+ base64 += '=';
24
+ }
25
+ const binary = atob(base64);
26
+ const bytes = new Uint8Array(binary.length);
27
+ for (let i = 0; i < binary.length; i++) {
28
+ bytes[i] = binary.charCodeAt(i);
29
+ }
30
+ return bytes;
31
+ }
32
+
33
+ function base64UrlDecodeToString(str: string): string {
34
+ const bytes = base64UrlDecode(str);
35
+ return new TextDecoder().decode(bytes);
36
+ }
37
+
38
+ async function getHmacKey(secret: string, usage: 'sign' | 'verify'): Promise<CryptoKey> {
39
+ const enc = new TextEncoder();
40
+ return crypto.subtle.importKey(
41
+ 'raw',
42
+ enc.encode(secret),
43
+ { name: 'HMAC', hash: 'SHA-256' },
44
+ false,
45
+ [usage],
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Signs a payload with HS256 using standard Web Crypto API.
51
+ */
52
+ export async function signPreviewToken(
53
+ payload: Record<string, unknown>,
54
+ secret: string,
55
+ ): Promise<string> {
56
+ const header = { alg: 'HS256', typ: 'JWT' };
57
+ const encodedHeader = base64UrlEncode(JSON.stringify(header));
58
+ const encodedPayload = base64UrlEncode(JSON.stringify(payload));
59
+ const data = new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`);
60
+
61
+ const key = await getHmacKey(secret, 'sign');
62
+ const signature = await crypto.subtle.sign('HMAC', key, data);
63
+ const encodedSignature = base64UrlEncode(new Uint8Array(signature));
64
+
65
+ return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
66
+ }
67
+
68
+ /**
69
+ * Locally verifies a signed JWT token against a shared secret using standard Web Crypto API.
70
+ * Never performs network requests.
71
+ */
72
+ export async function verifyPreviewToken(
73
+ token: string | null | undefined,
74
+ secret: string | undefined,
75
+ expectedSiteId?: string,
76
+ ): Promise<VerifyTokenResult> {
77
+ if (!token || typeof token !== 'string') {
78
+ return { valid: false, error: 'Preview token is missing or empty' };
79
+ }
80
+
81
+ if (!secret || typeof secret !== 'string') {
82
+ return { valid: false, error: 'Preview token secret is not configured' };
83
+ }
84
+
85
+ const parts = token.trim().split('.');
86
+ if (parts.length !== 3) {
87
+ return { valid: false, error: 'Invalid JWT structure' };
88
+ }
89
+
90
+ const [encodedHeader, encodedPayload, encodedSignature] = parts;
91
+
92
+ try {
93
+ // 1. Verify signature with SubtleCrypto
94
+ const key = await getHmacKey(secret, 'verify');
95
+ const data = new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`);
96
+ const signatureBytes = base64UrlDecode(encodedSignature);
97
+
98
+ const isSigValid = await crypto.subtle.verify(
99
+ 'HMAC',
100
+ key,
101
+ signatureBytes as unknown as BufferSource,
102
+ data as unknown as BufferSource,
103
+ );
104
+ if (!isSigValid) {
105
+ return { valid: false, error: 'Invalid token signature' };
106
+ }
107
+
108
+ // 2. Decode and parse payload
109
+ const payloadJson = base64UrlDecodeToString(encodedPayload);
110
+ const payload = JSON.parse(payloadJson) as PreviewTokenPayload;
111
+
112
+ // 3. Verify expiration
113
+ const now = Math.floor(Date.now() / 1000);
114
+ if (typeof payload.exp === 'number' && payload.exp < now) {
115
+ return { valid: false, error: 'Preview token has expired' };
116
+ }
117
+
118
+ // 4. Verify site scope if specified; fail closed when the payload has no site_id
119
+ if (expectedSiteId && payload.site_id !== expectedSiteId) {
120
+ return {
121
+ valid: false,
122
+ error: `Preview token site_id (${payload.site_id}) does not match expected site_id (${expectedSiteId})`,
123
+ };
124
+ }
125
+
126
+ return { valid: true, payload };
127
+ } catch (err) {
128
+ const msg = err instanceof Error ? err.message : String(err);
129
+ return { valid: false, error: `Failed to verify preview token: ${msg}` };
130
+ }
131
+ }
@@ -0,0 +1,4 @@
1
+ import { createPreviewMiddleware } from './preview';
2
+
3
+ export const onRequest = createPreviewMiddleware();
4
+ export * from './preview';
@@ -0,0 +1,184 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { initCmsClient } from '../client';
3
+ import type { CmsClient, ContentBundle } from '../types';
4
+ import { createPreviewMiddleware } from './preview';
5
+
6
+ const TEST_SITE_ID = '11111111-1111-1111-1111-111111111111';
7
+
8
+ const mockBundle: ContentBundle = {
9
+ bundleVersion: 1,
10
+ site_id: TEST_SITE_ID,
11
+ fields: { 'home.title': 'Draft Home' },
12
+ collections: {},
13
+ entries: {},
14
+ routes: [],
15
+ settings: {},
16
+ redirects: [],
17
+ };
18
+
19
+ const mockClient: CmsClient = {
20
+ siteId: TEST_SITE_ID,
21
+ contentUrl: 'http://localhost:8788',
22
+ async getBundle() {
23
+ return mockBundle;
24
+ },
25
+ async getFields() {
26
+ return mockBundle.fields;
27
+ },
28
+ async getField<T = unknown>(key: string) {
29
+ return (mockBundle.fields[key] as T) ?? null;
30
+ },
31
+ async getCollections() {
32
+ return {};
33
+ },
34
+ async getEntries() {
35
+ return [];
36
+ },
37
+ async getEntry() {
38
+ return null;
39
+ },
40
+ async getRoutes() {
41
+ return [];
42
+ },
43
+ async getSiteConfig() {
44
+ return {
45
+ site_id: TEST_SITE_ID,
46
+ redirects: [],
47
+ robots: {},
48
+ default_seo: {},
49
+ sitemap: {},
50
+ not_found_path: null,
51
+ };
52
+ },
53
+ clearCache() {},
54
+ };
55
+
56
+ describe('createPreviewMiddleware', () => {
57
+ it('passes through standard non-preview requests without tokens', async () => {
58
+ const middleware = createPreviewMiddleware({ client: mockClient });
59
+ const context = {
60
+ request: new Request('http://localhost:4321/about'),
61
+ url: new URL('http://localhost:4321/about'),
62
+ };
63
+ const next = vi.fn().mockResolvedValue(new Response('Standard Page', { status: 200 }));
64
+
65
+ const res = await middleware(context, next);
66
+ expect(next).toHaveBeenCalledTimes(1);
67
+ expect(await res.text()).toBe('Standard Page');
68
+ });
69
+
70
+ it('rejects /_preview route when preview token is missing', async () => {
71
+ const middleware = createPreviewMiddleware({ client: mockClient });
72
+ const context = {
73
+ request: new Request('http://localhost:4321/_preview'),
74
+ url: new URL('http://localhost:4321/_preview'),
75
+ };
76
+ const next = vi.fn();
77
+
78
+ const res = await middleware(context, next);
79
+ expect(res.status).toBe(400);
80
+ expect(await res.text()).toBe('Preview token is missing');
81
+ expect(next).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it('handles /_preview rewrite and injects bridge script when bundle fetch succeeds', async () => {
85
+ const middleware = createPreviewMiddleware({ client: mockClient });
86
+ const context = {
87
+ request: new Request('http://localhost:4321/_preview?preview_token=valid-token'),
88
+ url: new URL('http://localhost:4321/_preview?preview_token=valid-token'),
89
+ rewrite: vi.fn().mockImplementation(async (targetPath: string) => {
90
+ expect(targetPath).toBe('/');
91
+ return new Response('<html><body><h1>Hello</h1></body></html>', {
92
+ status: 200,
93
+ headers: { 'content-type': 'text/html' },
94
+ });
95
+ }),
96
+ };
97
+ const next = vi.fn();
98
+
99
+ const res = await middleware(context, next);
100
+ expect(res.status).toBe(200);
101
+ expect(context.rewrite).toHaveBeenCalledWith('/');
102
+ const html = await res.text();
103
+ expect(html).toContain('window.__CMS_BRIDGE_INITIALIZED__');
104
+ expect(html).toContain('<h1>Hello</h1>');
105
+ });
106
+
107
+ it('returns 404 when bundle fetch returns 401/403 (e.g. wrong site or invalid token)', async () => {
108
+ const failingClient: CmsClient = {
109
+ ...mockClient,
110
+ async getBundle() {
111
+ const error = new Error('CMS Client: Failed to fetch bundle (status 401): Unauthorized');
112
+ (error as unknown as { status: number }).status = 401;
113
+ throw error;
114
+ },
115
+ };
116
+
117
+ const middleware = createPreviewMiddleware({ client: failingClient });
118
+ const context = {
119
+ request: new Request('http://localhost:4321/?preview_token=invalid-or-wrong-site-token'),
120
+ url: new URL('http://localhost:4321/?preview_token=invalid-or-wrong-site-token'),
121
+ };
122
+ const next = vi.fn();
123
+
124
+ const res = await middleware(context, next);
125
+ expect(res.status).toBe(404);
126
+ expect(await res.text()).toBe('Preview token is not valid for this site');
127
+ expect(res.headers.get('Cache-Control')).toBe('private, no-store');
128
+ expect(res.headers.get('X-Robots-Tag')).toBe('noindex, nofollow');
129
+ expect(next).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it('resolves singleton client via getClient() if not passed directly in options', async () => {
133
+ initCmsClient({ client: mockClient });
134
+
135
+ const middleware = createPreviewMiddleware();
136
+ const context = {
137
+ request: new Request('http://localhost:4321/_preview/about?preview_token=valid-token'),
138
+ url: new URL('http://localhost:4321/_preview/about?preview_token=valid-token'),
139
+ rewrite: vi.fn().mockImplementation(async (targetPath: string) => {
140
+ expect(targetPath).toBe('/about');
141
+ return new Response('<html><body><h1>About</h1></body></html>', {
142
+ status: 200,
143
+ headers: { 'content-type': 'text/html' },
144
+ });
145
+ }),
146
+ };
147
+ const next = vi.fn();
148
+
149
+ const res = await middleware(context, next);
150
+ expect(res.status).toBe(200);
151
+ expect(context.rewrite).toHaveBeenCalledWith('/about');
152
+ });
153
+
154
+ it('keeps draft bundle active in AsyncLocalStorage while response stream is consumed', async () => {
155
+ const { getActiveBundle } = await import('../runtime/store');
156
+
157
+ const middleware = createPreviewMiddleware({ client: mockClient });
158
+
159
+ const context = {
160
+ request: new Request('http://localhost:4321/?preview_token=valid-token'),
161
+ url: new URL('http://localhost:4321/?preview_token=valid-token'),
162
+ };
163
+ const next = vi.fn().mockImplementation(async () => {
164
+ const stream = new ReadableStream({
165
+ start(controller) {
166
+ const active = getActiveBundle();
167
+ const content = `<html><body><h1>${active?.fields?.['home.title'] || 'Missing'}</h1></body></html>`;
168
+ controller.enqueue(new TextEncoder().encode(content));
169
+ controller.close();
170
+ },
171
+ });
172
+ return new Response(stream, {
173
+ status: 200,
174
+ headers: { 'content-type': 'text/html' },
175
+ });
176
+ });
177
+
178
+ const res = await middleware(context, next);
179
+ expect(res.status).toBe(200);
180
+ const html = await res.text();
181
+ expect(html).toContain('<h1>Draft Home</h1>');
182
+ expect(html).toContain('window.__CMS_BRIDGE_INITIALIZED__');
183
+ });
184
+ });