@407dev/cms-astro 0.1.0 → 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.
Files changed (145) hide show
  1. package/README.md +4 -4
  2. package/dist/{chunk-7E3XJLKX.js → chunk-SGHAANOW.js} +30 -3
  3. package/dist/chunk-SGHAANOW.js.map +1 -0
  4. package/dist/{chunk-QYUVAKMC.js → chunk-YCQMH2T7.js} +51 -10
  5. package/dist/chunk-YCQMH2T7.js.map +1 -0
  6. package/dist/fields/collections.js +1 -1
  7. package/dist/index.d.ts +8 -1
  8. package/dist/index.js +32 -31
  9. package/dist/index.js.map +1 -1
  10. package/dist/middleware/index.js +2 -2
  11. package/package.json +5 -5
  12. package/src/client.ts +0 -1
  13. package/src/fields/attrs.ts +4 -0
  14. package/src/fields/collection.ts +8 -0
  15. package/src/fields/entryScope.test.ts +22 -1
  16. package/src/fields/index.ts +10 -1
  17. package/src/index.test.ts +117 -1
  18. package/src/integration.ts +29 -7
  19. package/src/middleware/preview.test.ts +39 -7
  20. package/src/middleware/preview.ts +18 -4
  21. package/src/preview/bridge-script.test.ts +81 -2
  22. package/src/preview/bridge-script.ts +30 -1
  23. package/dist/chunk-53OQ5RXJ.js +0 -912
  24. package/dist/chunk-53OQ5RXJ.js.map +0 -1
  25. package/dist/chunk-56OMNQLH.js +0 -1013
  26. package/dist/chunk-56OMNQLH.js.map +0 -1
  27. package/dist/chunk-5AOOMQ7K.js +0 -1031
  28. package/dist/chunk-5AOOMQ7K.js.map +0 -1
  29. package/dist/chunk-5XRUCFDC.js +0 -743
  30. package/dist/chunk-5XRUCFDC.js.map +0 -1
  31. package/dist/chunk-62ZHCOLZ.js +0 -25
  32. package/dist/chunk-62ZHCOLZ.js.map +0 -1
  33. package/dist/chunk-6AZLRSYA.js +0 -126
  34. package/dist/chunk-6AZLRSYA.js.map +0 -1
  35. package/dist/chunk-6L3ZTCFB.js +0 -14
  36. package/dist/chunk-6L3ZTCFB.js.map +0 -1
  37. package/dist/chunk-6O7ZNNDT.js +0 -697
  38. package/dist/chunk-6O7ZNNDT.js.map +0 -1
  39. package/dist/chunk-6PQIXTMK.js +0 -697
  40. package/dist/chunk-6PQIXTMK.js.map +0 -1
  41. package/dist/chunk-7AW6M7QW.js +0 -1013
  42. package/dist/chunk-7AW6M7QW.js.map +0 -1
  43. package/dist/chunk-7E3XJLKX.js.map +0 -1
  44. package/dist/chunk-7LM54OTA.js +0 -1031
  45. package/dist/chunk-7LM54OTA.js.map +0 -1
  46. package/dist/chunk-A3OGL4OX.js +0 -706
  47. package/dist/chunk-A3OGL4OX.js.map +0 -1
  48. package/dist/chunk-AO4UAWOV.js +0 -1030
  49. package/dist/chunk-AO4UAWOV.js.map +0 -1
  50. package/dist/chunk-BDFVGXM2.js +0 -697
  51. package/dist/chunk-BDFVGXM2.js.map +0 -1
  52. package/dist/chunk-CXM7NZVX.js +0 -1031
  53. package/dist/chunk-CXM7NZVX.js.map +0 -1
  54. package/dist/chunk-DXJSYGS6.js +0 -743
  55. package/dist/chunk-DXJSYGS6.js.map +0 -1
  56. package/dist/chunk-EFUSAQZM.js +0 -72
  57. package/dist/chunk-EFUSAQZM.js.map +0 -1
  58. package/dist/chunk-F7KDGPBN.js +0 -1013
  59. package/dist/chunk-F7KDGPBN.js.map +0 -1
  60. package/dist/chunk-FBSQT45X.js +0 -126
  61. package/dist/chunk-FBSQT45X.js.map +0 -1
  62. package/dist/chunk-FEPDOMVH.js +0 -697
  63. package/dist/chunk-FEPDOMVH.js.map +0 -1
  64. package/dist/chunk-FQDOL2QW.js +0 -25
  65. package/dist/chunk-FQDOL2QW.js.map +0 -1
  66. package/dist/chunk-GB3LGQJM.js +0 -109
  67. package/dist/chunk-GB3LGQJM.js.map +0 -1
  68. package/dist/chunk-HCNP6R3P.js +0 -736
  69. package/dist/chunk-HCNP6R3P.js.map +0 -1
  70. package/dist/chunk-J6WEJDRI.js +0 -743
  71. package/dist/chunk-J6WEJDRI.js.map +0 -1
  72. package/dist/chunk-JHEAPNJ2.js +0 -117
  73. package/dist/chunk-JHEAPNJ2.js.map +0 -1
  74. package/dist/chunk-JOJDJ6GW.js +0 -25
  75. package/dist/chunk-JOJDJ6GW.js.map +0 -1
  76. package/dist/chunk-KTDV6WYB.js +0 -202
  77. package/dist/chunk-KTDV6WYB.js.map +0 -1
  78. package/dist/chunk-KZWJBKPC.js +0 -734
  79. package/dist/chunk-KZWJBKPC.js.map +0 -1
  80. package/dist/chunk-LEAQBGL6.js +0 -109
  81. package/dist/chunk-LEAQBGL6.js.map +0 -1
  82. package/dist/chunk-LGZJL2Q7.js +0 -697
  83. package/dist/chunk-LGZJL2Q7.js.map +0 -1
  84. package/dist/chunk-LQPBU46R.js +0 -109
  85. package/dist/chunk-LQPBU46R.js.map +0 -1
  86. package/dist/chunk-M55NNUTY.js +0 -1013
  87. package/dist/chunk-M55NNUTY.js.map +0 -1
  88. package/dist/chunk-M6NCYCC3.js +0 -1013
  89. package/dist/chunk-M6NCYCC3.js.map +0 -1
  90. package/dist/chunk-MGN7OZAU.js +0 -109
  91. package/dist/chunk-MGN7OZAU.js.map +0 -1
  92. package/dist/chunk-MGYZOX4K.js +0 -1013
  93. package/dist/chunk-MGYZOX4K.js.map +0 -1
  94. package/dist/chunk-MY4IEKJS.js +0 -1031
  95. package/dist/chunk-MY4IEKJS.js.map +0 -1
  96. package/dist/chunk-NCQRGAB4.js +0 -705
  97. package/dist/chunk-NCQRGAB4.js.map +0 -1
  98. package/dist/chunk-NI4NHXI4.js +0 -697
  99. package/dist/chunk-NI4NHXI4.js.map +0 -1
  100. package/dist/chunk-NPI5N7AU.js +0 -14
  101. package/dist/chunk-NPI5N7AU.js.map +0 -1
  102. package/dist/chunk-QN4CPMJF.js +0 -126
  103. package/dist/chunk-QN4CPMJF.js.map +0 -1
  104. package/dist/chunk-QYUVAKMC.js.map +0 -1
  105. package/dist/chunk-RAAT6PDT.js +0 -25
  106. package/dist/chunk-RAAT6PDT.js.map +0 -1
  107. package/dist/chunk-RDPO5VPS.js +0 -1013
  108. package/dist/chunk-RDPO5VPS.js.map +0 -1
  109. package/dist/chunk-RQHIW2PF.js +0 -25
  110. package/dist/chunk-RQHIW2PF.js.map +0 -1
  111. package/dist/chunk-RREVHA52.js +0 -722
  112. package/dist/chunk-RREVHA52.js.map +0 -1
  113. package/dist/chunk-S7PT4PZN.js +0 -25
  114. package/dist/chunk-S7PT4PZN.js.map +0 -1
  115. package/dist/chunk-SUXV5M32.js +0 -109
  116. package/dist/chunk-SUXV5M32.js.map +0 -1
  117. package/dist/chunk-SZEORUY6.js +0 -696
  118. package/dist/chunk-SZEORUY6.js.map +0 -1
  119. package/dist/chunk-T7NGGWGY.js +0 -25
  120. package/dist/chunk-T7NGGWGY.js.map +0 -1
  121. package/dist/chunk-UEVWP5VE.js +0 -696
  122. package/dist/chunk-UEVWP5VE.js.map +0 -1
  123. package/dist/chunk-UJNKNP7U.js +0 -748
  124. package/dist/chunk-UJNKNP7U.js.map +0 -1
  125. package/dist/chunk-V4XVFWFI.js +0 -1013
  126. package/dist/chunk-V4XVFWFI.js.map +0 -1
  127. package/dist/chunk-VJU2CIKZ.js +0 -729
  128. package/dist/chunk-VJU2CIKZ.js.map +0 -1
  129. package/dist/chunk-VRQUP5L3.js +0 -202
  130. package/dist/chunk-VRQUP5L3.js.map +0 -1
  131. package/dist/chunk-WBJ5E7CL.js +0 -742
  132. package/dist/chunk-WBJ5E7CL.js.map +0 -1
  133. package/dist/chunk-Y3Q65C7R.js +0 -14
  134. package/dist/chunk-Y3Q65C7R.js.map +0 -1
  135. package/dist/chunk-YHEGFPTR.js +0 -1030
  136. package/dist/chunk-YHEGFPTR.js.map +0 -1
  137. package/dist/chunk-ZDLBN46C.js +0 -697
  138. package/dist/chunk-ZDLBN46C.js.map +0 -1
  139. package/dist/preview-BEkLMD6N.d.ts +0 -23
  140. package/dist/preview-DJEA4fj7.d.ts +0 -23
  141. package/dist/preview-DeD1Jn7U.d.ts +0 -23
  142. package/dist/types-15jfCQKb.d.ts +0 -141
  143. package/dist/types-BREdL9Gx.d.ts +0 -140
  144. package/dist/types-C6sUZNa2.d.ts +0 -104
  145. package/dist/types-DpeHpobR.d.ts +0 -104
package/src/index.test.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
4
  import {
3
5
  cms,
4
6
  cmsAstro,
@@ -12,6 +14,12 @@ import {
12
14
  } from './index';
13
15
 
14
16
  describe('cms-astro package entrypoint', () => {
17
+ afterEach(() => {
18
+ const cmsDir = path.resolve(process.cwd(), '.cms');
19
+ if (fs.existsSync(cmsDir)) {
20
+ fs.rmSync(cmsDir, { recursive: true, force: true });
21
+ }
22
+ });
15
23
  it('exports all expected SDK utilities and functions', () => {
16
24
  expect(typeof createCmsClient).toBe('function');
17
25
  expect(typeof cmsAstro).toBe('function');
@@ -33,6 +41,25 @@ describe('cms-astro package entrypoint', () => {
33
41
  process.env.CMS_CONTENT_URL = 'https://content.example.com';
34
42
  process.env.CMS_PREVIEW_SECRET = 'secret_env_456';
35
43
 
44
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
45
+ new Response(
46
+ JSON.stringify({
47
+ bundleVersion: 1,
48
+ site_id: 'site_env_123',
49
+ fields: {},
50
+ collections: {},
51
+ entries: {},
52
+ routes: [],
53
+ settings: {},
54
+ redirects: [],
55
+ }),
56
+ {
57
+ status: 200,
58
+ headers: { 'Content-Type': 'application/json' },
59
+ },
60
+ ),
61
+ );
62
+
36
63
  try {
37
64
  const integration = cmsAstro();
38
65
  expect(integration.name).toBe('@407dev/cms-astro');
@@ -68,9 +95,98 @@ describe('cms-astro package entrypoint', () => {
68
95
  expect(define['import.meta.env.CMS_PREVIEW_SECRET']).toBeUndefined();
69
96
  expect(warningLogged).toBe(true);
70
97
  } finally {
98
+ fetchSpy.mockRestore();
71
99
  process.env.CMS_SITE_ID = originalSiteId;
72
100
  process.env.CMS_CONTENT_URL = originalContentUrl;
73
101
  process.env.CMS_PREVIEW_SECRET = originalPreviewSecret;
74
102
  }
75
103
  });
104
+
105
+ const buildSetupArgs = () => ({
106
+ updateConfig: () => {},
107
+ command: 'build' as const,
108
+ injectScript: () => {},
109
+ addMiddleware: () => {},
110
+ addDevToolbarApp: () => {},
111
+ logger: { warn: () => {}, error: () => {}, info: () => {} },
112
+ });
113
+
114
+ it('rethrows bundle fetch failures with URL and status on build', async () => {
115
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
116
+ new Response('Boom', {
117
+ status: 500,
118
+ statusText: 'Internal Server Error',
119
+ }),
120
+ );
121
+ try {
122
+ const integration = cmsAstro({
123
+ siteId: 'site-build-fail',
124
+ contentUrl: 'https://content.example.com',
125
+ });
126
+ const setupHook = integration.hooks['astro:config:setup'] as (args: unknown) => Promise<void>;
127
+
128
+ await expect(setupHook(buildSetupArgs())).rejects.toThrow(
129
+ /Failed to fetch bundle from https:\/\/content\.example\.com\/content\/site-build-fail\/bundle.*500/,
130
+ );
131
+ } finally {
132
+ fetchSpy.mockRestore();
133
+ }
134
+ });
135
+
136
+ it('wraps unreachable bundle fetches with the URL on build', async () => {
137
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
138
+ try {
139
+ const integration = cmsAstro({
140
+ siteId: 'site-build-unreachable',
141
+ contentUrl: 'https://content.example.com',
142
+ });
143
+ const setupHook = integration.hooks['astro:config:setup'] as (args: unknown) => Promise<void>;
144
+
145
+ await expect(setupHook(buildSetupArgs())).rejects.toThrow(
146
+ /Failed to fetch CMS bundle from https:\/\/content\.example\.com\/content\/site-build-unreachable\/bundle \(unreachable\).*ECONNREFUSED/,
147
+ );
148
+ } finally {
149
+ fetchSpy.mockRestore();
150
+ }
151
+ });
152
+
153
+ it('tolerates a 404 bundle (never published) on build', async () => {
154
+ const fetchSpy = vi
155
+ .spyOn(globalThis, 'fetch')
156
+ .mockResolvedValue(new Response('Not Found', { status: 404, statusText: 'Not Found' }));
157
+ try {
158
+ const integration = cmsAstro({
159
+ siteId: 'site-never-published',
160
+ contentUrl: 'https://content.example.com',
161
+ });
162
+ const setupHook = integration.hooks['astro:config:setup'] as (args: unknown) => Promise<void>;
163
+
164
+ await expect(setupHook(buildSetupArgs())).resolves.toBeUndefined();
165
+ } finally {
166
+ fetchSpy.mockRestore();
167
+ }
168
+ });
169
+
170
+ it('fails gracefully on bundle fetch failure in dev mode', async () => {
171
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
172
+ try {
173
+ const integration = cmsAstro({
174
+ siteId: 'site-dev-fail',
175
+ contentUrl: 'https://content.example.com',
176
+ });
177
+ const setupHook = integration.hooks['astro:config:setup'] as (args: unknown) => Promise<void>;
178
+ const fakeSetupArgs = {
179
+ updateConfig: () => {},
180
+ command: 'dev' as const,
181
+ injectScript: () => {},
182
+ addMiddleware: () => {},
183
+ addDevToolbarApp: () => {},
184
+ logger: { warn: () => {}, error: () => {}, info: () => {} },
185
+ };
186
+
187
+ await expect(setupHook(fakeSetupArgs)).resolves.not.toThrow();
188
+ } finally {
189
+ fetchSpy.mockRestore();
190
+ }
191
+ });
76
192
  });
@@ -8,7 +8,7 @@ import { buildBridgeScript } from './preview/bridge-script';
8
8
  import { markBundleReady } from './runtime/bundle';
9
9
  import { clearMissingFieldWarnings, getMissingFieldWarnings } from './runtime/store';
10
10
  import { CMS_TOOLBAR_ICON } from './toolbar/icon';
11
- import type { CmsAstroOptions } from './types';
11
+ import type { CmsAstroOptions, ContentBundle } from './types';
12
12
 
13
13
  function deriveRouteFromComponent(componentPath: string): string {
14
14
  let route = componentPath
@@ -133,6 +133,7 @@ export function cmsAstro(options: CmsAstroOptions = {}): AstroIntegration {
133
133
  // Fetch bundle once on setup/build and write to .cms/bundle.json
134
134
  const activeClient = getClient();
135
135
  if (activeClient) {
136
+ let bundle: ContentBundle | undefined;
136
137
  try {
137
138
  // Dev serves draft content; self-mint a preview token from the shared secret
138
139
  // since the content-worker's draft endpoint still requires one (token rules unchanged).
@@ -141,22 +142,43 @@ export function cmsAstro(options: CmsAstroOptions = {}): AstroIntegration {
141
142
  ? await mintDevPreviewToken(activeClient.siteId, effectivePreviewSecret)
142
143
  : null;
143
144
 
144
- const bundle = await activeClient.getBundle(
145
+ bundle = await activeClient.getBundle(
145
146
  devPreviewToken ? { previewToken: devPreviewToken } : undefined,
146
147
  );
148
+ } catch (err) {
149
+ if (command === 'build') {
150
+ const status = (err as { status?: number })?.status;
151
+ // 404 = nothing published yet; the first build/preview-twin build must still succeed.
152
+ if (status === 404) {
153
+ logger.warn(
154
+ 'CMS bundle not found (no published version yet); building without it.',
155
+ );
156
+ } else if (status !== undefined) {
157
+ throw err;
158
+ } else {
159
+ const url = `${activeClient.contentUrl.replace(/\/+$/, '')}/content/${activeClient.siteId}/bundle`;
160
+ const message = err instanceof Error ? err.message : String(err);
161
+ throw new Error(
162
+ `Failed to fetch CMS bundle from ${url} (unreachable): ${message}`,
163
+ {
164
+ cause: err,
165
+ },
166
+ );
167
+ }
168
+ }
169
+ // Fail gracefully if fetch fails in dev
170
+ }
171
+
172
+ if (bundle) {
147
173
  markBundleReady(bundle);
148
174
 
149
175
  const cmsDir = path.resolve(process.cwd(), '.cms');
150
- if (!fs.existsSync(cmsDir)) {
151
- fs.mkdirSync(cmsDir, { recursive: true });
152
- }
176
+ fs.mkdirSync(cmsDir, { recursive: true });
153
177
  fs.writeFileSync(
154
178
  path.join(cmsDir, 'bundle.json'),
155
179
  JSON.stringify(bundle, null, 2),
156
180
  'utf-8',
157
181
  );
158
- } catch {
159
- // Fail gracefully if fetch fails
160
182
  }
161
183
  }
162
184
  },
@@ -104,29 +104,61 @@ describe('createPreviewMiddleware', () => {
104
104
  expect(html).toContain('<h1>Hello</h1>');
105
105
  });
106
106
 
107
- it('returns 404 when bundle fetch returns 401/403 (e.g. wrong site or invalid token)', async () => {
107
+ it('returns 404 when bundle fetch returns 401/403/404 (e.g. wrong site or invalid token)', async () => {
108
+ for (const status of [401, 403, 404]) {
109
+ const failingClient: CmsClient = {
110
+ ...mockClient,
111
+ async getBundle() {
112
+ const error = new Error(`CMS Client: Failed to fetch bundle (status ${status})`);
113
+ (error as unknown as { status: number }).status = status;
114
+ throw error;
115
+ },
116
+ };
117
+
118
+ const middleware = createPreviewMiddleware({ client: failingClient });
119
+ const context = {
120
+ request: new Request('http://localhost:4321/?preview_token=invalid-token'),
121
+ url: new URL('http://localhost:4321/?preview_token=invalid-token'),
122
+ };
123
+ const next = vi.fn();
124
+
125
+ const res = await middleware(context, next);
126
+ expect(res.status).toBe(404);
127
+ expect(await res.text()).toBe('Preview token is not valid for this site');
128
+ expect(res.headers.get('Cache-Control')).toBe('private, no-store');
129
+ expect(res.headers.get('X-Robots-Tag')).toBe('noindex, nofollow');
130
+ expect(next).not.toHaveBeenCalled();
131
+ }
132
+ });
133
+
134
+ it('returns 502 and console.errors when bundle fetch fails with other errors (e.g. 500 or network error)', async () => {
135
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
136
+
108
137
  const failingClient: CmsClient = {
109
138
  ...mockClient,
110
139
  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;
140
+ const error = new Error('Upstream content-worker down');
141
+ (error as unknown as { status: number }).status = 500;
113
142
  throw error;
114
143
  },
115
144
  };
116
145
 
117
146
  const middleware = createPreviewMiddleware({ client: failingClient });
118
147
  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'),
148
+ request: new Request('http://localhost:4321/?preview_token=valid-token'),
149
+ url: new URL('http://localhost:4321/?preview_token=valid-token'),
121
150
  };
122
151
  const next = vi.fn();
123
152
 
124
153
  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');
154
+ expect(res.status).toBe(502);
155
+ expect(await res.text()).toBe('Failed to load preview content');
127
156
  expect(res.headers.get('Cache-Control')).toBe('private, no-store');
128
157
  expect(res.headers.get('X-Robots-Tag')).toBe('noindex, nofollow');
158
+ expect(consoleErrorSpy).toHaveBeenCalled();
129
159
  expect(next).not.toHaveBeenCalled();
160
+
161
+ consoleErrorSpy.mockRestore();
130
162
  });
131
163
 
132
164
  it('resolves singleton client via getClient() if not passed directly in options', async () => {
@@ -111,9 +111,21 @@ export function createPreviewMiddleware(options: PreviewMiddlewareOptions = {})
111
111
  let draftBundle: ContentBundle;
112
112
  try {
113
113
  draftBundle = await client.getBundle({ previewToken });
114
- } catch (_err) {
115
- return new Response('Preview token is not valid for this site', {
116
- status: 404,
114
+ } catch (err) {
115
+ const status = (err as { status?: number })?.status;
116
+ if (status === 401 || status === 403 || status === 404) {
117
+ return new Response('Preview token is not valid for this site', {
118
+ status: 404,
119
+ headers: {
120
+ 'Cache-Control': 'private, no-store',
121
+ 'X-Robots-Tag': 'noindex, nofollow',
122
+ },
123
+ });
124
+ }
125
+
126
+ console.error(err);
127
+ return new Response('Failed to load preview content', {
128
+ status: 502,
117
129
  headers: {
118
130
  'Cache-Control': 'private, no-store',
119
131
  'X-Robots-Tag': 'noindex, nofollow',
@@ -167,7 +179,9 @@ export function createPreviewMiddleware(options: PreviewMiddlewareOptions = {})
167
179
 
168
180
  if (isHtml) {
169
181
  const html = await response.text();
170
- const bridgeScript = buildBridgeScript({ editorOrigins: options.editorOrigins });
182
+ const bridgeScript = buildBridgeScript({
183
+ editorOrigins: options.editorOrigins,
184
+ });
171
185
  const scriptTag = `<script>${bridgeScript}</script>`;
172
186
  const modifiedHtml = html.includes('</body>')
173
187
  ? html.replace('</body>', `${scriptTag}</body>`)
@@ -27,6 +27,7 @@ describe('bridge-script protocol parity', () => {
27
27
  expect(script).toContain("type: 'ready'");
28
28
  expect(script).toContain("type: 'field-click'");
29
29
  expect(script).toContain("type: 'group-click'");
30
+ expect(script).toContain("type: 'collection-click'");
30
31
  expect(script).toContain("type: 'rects-update'");
31
32
  expect(script).toContain("type: 'rendered-fields'");
32
33
 
@@ -204,6 +205,84 @@ describe('bridge-script protocol parity', () => {
204
205
  expect(customEvent.detail.key).toBe('home.hero.heading');
205
206
  });
206
207
 
208
+ it('emits collection-click with or without slug when clicking collection in edit mode', () => {
209
+ document.body.innerHTML = `
210
+ <ul id="collection-list" data-cms-collection="blog">
211
+ <li id="item-1" data-cms-collection="blog" data-cms-entry="post-1">
212
+ <span id="title-1" data-cms-entry-field="title">Post 1</span>
213
+ <span id="gap-1">Unmarked item text</span>
214
+ </li>
215
+ </ul>
216
+ `;
217
+ document.documentElement.setAttribute('data-cms-edit-mode', 'true');
218
+
219
+ (window as unknown as { __CMS_BRIDGE_INITIALIZED__?: boolean }).__CMS_BRIDGE_INITIALIZED__ =
220
+ false;
221
+
222
+ const scriptCode = buildBridgeScript({ editorOrigins: ['http://localhost:5173'] });
223
+ const runScript = new Function(scriptCode);
224
+ runScript();
225
+
226
+ const messages: unknown[] = [];
227
+ const mockParent = {
228
+ postMessage: (msg: unknown) => messages.push(msg),
229
+ };
230
+
231
+ window.dispatchEvent(
232
+ new MessageEvent('message', {
233
+ data: {
234
+ source: 'cms-editor',
235
+ v: PREVIEW_BRIDGE_PROTOCOL_VERSION,
236
+ type: 'hello',
237
+ protocolVersion: PREVIEW_BRIDGE_PROTOCOL_VERSION,
238
+ },
239
+ origin: 'http://localhost:5173',
240
+ source: mockParent as unknown as MessageEventSource,
241
+ }),
242
+ );
243
+
244
+ // 1. Clicking on the wrapper ul (without slug) emits collection-click with slug undefined
245
+ const ul = document.getElementById('collection-list');
246
+ ul?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
247
+
248
+ const ulClickMsg = messages.find(
249
+ (m) => (m as { type?: string }).type === 'collection-click' && !(m as { slug?: string }).slug,
250
+ ) as { type: string; collection: string; slug?: string; rect: unknown } | undefined;
251
+ expect(ulClickMsg).toBeDefined();
252
+ expect(ulClickMsg?.collection).toBe('blog');
253
+ expect(ulClickMsg?.slug).toBeUndefined();
254
+
255
+ // 2. Clicking on an item (with slug) emits collection-click with slug
256
+ const gap = document.getElementById('gap-1');
257
+ gap?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
258
+
259
+ const itemClickMsg = messages.find(
260
+ (m) =>
261
+ (m as { type?: string }).type === 'collection-click' &&
262
+ (m as { slug?: string }).slug === 'post-1',
263
+ ) as { type: string; collection: string; slug?: string; rect: unknown } | undefined;
264
+ expect(itemClickMsg).toBeDefined();
265
+ expect(itemClickMsg?.collection).toBe('blog');
266
+ expect(itemClickMsg?.slug).toBe('post-1');
267
+
268
+ // 3. Clicking on a field inside the item emits field-click, NOT collection-click
269
+ const title = document.getElementById('title-1');
270
+ title?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
271
+
272
+ const fieldClickMsg = messages.find((m) => (m as { type?: string }).type === 'field-click') as
273
+ | { type: string; key: string; entryRef?: { collection: string; slug: string; path: string } }
274
+ | undefined;
275
+ expect(fieldClickMsg).toBeDefined();
276
+ expect(fieldClickMsg?.key).toBe('title');
277
+ expect(fieldClickMsg?.entryRef).toEqual({
278
+ collection: 'blog',
279
+ slug: 'post-1',
280
+ path: 'title',
281
+ });
282
+
283
+ document.documentElement.removeAttribute('data-cms-edit-mode');
284
+ });
285
+
207
286
  it('skips textContent replacement when patching structured non-image object values', () => {
208
287
  document.body.innerHTML = `
209
288
  <div data-cms-field="home.videoFeed.videos">
@@ -326,9 +405,9 @@ describe('bridge-script protocol parity', () => {
326
405
  expect(injectedStyle?.textContent).not.toContain('astro-dev-toolbar');
327
406
  });
328
407
 
329
- it('includes comments capability and handles comment-mode messages', () => {
408
+ it('includes comments and collection-click capabilities and handles comment-mode messages', () => {
330
409
  const script = buildBridgeScript();
331
- expect(script).toContain("capabilities: ['comments']");
410
+ expect(script).toContain("capabilities: ['comments', 'collection-click']");
332
411
  expect(script).toContain("data.type === 'comment-mode'");
333
412
  expect(script).toContain("data.type === 'watch-anchors'");
334
413
  expect(script).toContain("data.type === 'scroll-to-anchor'");
@@ -97,6 +97,13 @@ export function buildBridgeScript(options: BridgeScriptOptions = {}): string {
97
97
  'html[data-cms-edit-mode] [data-cms-group]:hover {',
98
98
  ' outline: 2px solid ${chromeTokens.success} !important;',
99
99
  ' outline-offset: 3px !important;',
100
+ '}',
101
+ 'html[data-cms-edit-mode] [data-cms-collection] {',
102
+ ' cursor: pointer !important;',
103
+ '}',
104
+ 'html[data-cms-edit-mode] [data-cms-collection]:hover {',
105
+ ' outline: 2px dashed ${chromeTokens.warning} !important;',
106
+ ' outline-offset: 4px !important;',
100
107
  '}'
101
108
  ].filter(Boolean).join('\\n');
102
109
 
@@ -589,6 +596,28 @@ export function buildBridgeScript(options: BridgeScriptOptions = {}): string {
589
596
  rect: gRect
590
597
  });
591
598
  }
599
+ return;
600
+ }
601
+
602
+ var collectionEl = target.closest('[data-cms-collection]');
603
+ if (collectionEl) {
604
+ e.preventDefault();
605
+ e.stopPropagation();
606
+
607
+ var collKey = collectionEl.getAttribute('data-cms-collection') || '';
608
+ var entryEl = target.closest('[data-cms-entry]');
609
+ var slug = collectionEl.getAttribute('data-cms-entry') || (entryEl ? entryEl.getAttribute('data-cms-entry') : null) || undefined;
610
+ var cRect = getElementRect(collectionEl);
611
+
612
+ postToEditor({
613
+ source: 'cms-preview',
614
+ v: PROTOCOL_VERSION,
615
+ type: 'collection-click',
616
+ collection: collKey,
617
+ slug: slug,
618
+ rect: cRect
619
+ });
620
+ return;
592
621
  }
593
622
  }, true);
594
623
 
@@ -608,7 +637,7 @@ export function buildBridgeScript(options: BridgeScriptOptions = {}): string {
608
637
  v: PROTOCOL_VERSION,
609
638
  type: 'ready',
610
639
  protocolVersion: PROTOCOL_VERSION,
611
- capabilities: ['comments']
640
+ capabilities: ['comments', 'collection-click']
612
641
  });
613
642
 
614
643
  scanRenderedFields();