cms-renderer 0.0.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.
- package/.turbo/turbo-check-types.log +2 -0
- package/README.md +362 -0
- package/lib/__tests__/enrich-block-images.test.ts +394 -0
- package/lib/block-renderer.tsx +60 -0
- package/lib/cms-api.ts +86 -0
- package/lib/data-utils.ts +572 -0
- package/lib/image/lazy-load.ts +209 -0
- package/lib/markdown-utils.ts +368 -0
- package/lib/renderer.tsx +189 -0
- package/lib/result.ts +450 -0
- package/lib/schema.ts +74 -0
- package/lib/trpc.ts +28 -0
- package/lib/types.ts +201 -0
- package/next.config.ts +39 -0
- package/package.json +61 -0
- package/postcss.config.mjs +5 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Mocks - Must be set up BEFORE any imports that use these modules
|
|
5
|
+
// =============================================================================
|
|
6
|
+
|
|
7
|
+
// Mock 'server-only' to allow running in test environment
|
|
8
|
+
// This must be called before the module that imports it is loaded
|
|
9
|
+
mock.module('server-only', () => ({}));
|
|
10
|
+
|
|
11
|
+
// Mock generateSignedImageData to return predictable signed data
|
|
12
|
+
const mockGenerateSignedImageData = mock(
|
|
13
|
+
(config: { assetId: string; width: number; height: number; url: string }) => ({
|
|
14
|
+
signedSrcset: `signed-srcset-${config.assetId}`,
|
|
15
|
+
signedFallbackSrcset: `signed-fallback-srcset-${config.assetId}`,
|
|
16
|
+
signedDefaultSrc: `signed-default-src-${config.assetId}`,
|
|
17
|
+
})
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
mock.module('../image-transforms.server', () => ({
|
|
21
|
+
generateSignedImageData: mockGenerateSignedImageData,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Dynamic Import - Must happen after mocks are registered
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
type EnrichFunction = <T extends Record<string, unknown>>(content: T | null) => T | null;
|
|
29
|
+
let enrichBlockWithSignedUrls: EnrichFunction;
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
const mod = await import('../enrich-block-images');
|
|
33
|
+
enrichBlockWithSignedUrls = mod.enrichBlockWithSignedUrls;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// Test Data
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
const createResolvedImageRef = (id: string = '12345678-1234-1234-1234-123456789abc') => ({
|
|
41
|
+
alt: 'Test image',
|
|
42
|
+
_asset: {
|
|
43
|
+
id,
|
|
44
|
+
url: '/api/dev/storage/test.jpg',
|
|
45
|
+
width: 1920,
|
|
46
|
+
height: 1080,
|
|
47
|
+
lqip: 'data:image/jpeg;base64,/9j/...',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// =============================================================================
|
|
52
|
+
// Tests
|
|
53
|
+
// =============================================================================
|
|
54
|
+
|
|
55
|
+
describe('enrichBlockWithSignedUrls', () => {
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
mockGenerateSignedImageData.mockClear();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('Null/Undefined Handling', () => {
|
|
61
|
+
it('returns null when content is null', () => {
|
|
62
|
+
const result = enrichBlockWithSignedUrls(null);
|
|
63
|
+
expect(result).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns content unchanged when it has no image references', () => {
|
|
67
|
+
const content = { title: 'Hello', count: 42 };
|
|
68
|
+
const result = enrichBlockWithSignedUrls(content);
|
|
69
|
+
expect(result).toEqual(content);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('Image Reference Detection', () => {
|
|
74
|
+
it('detects resolved image reference with alt and _asset', () => {
|
|
75
|
+
const imageRef = createResolvedImageRef();
|
|
76
|
+
const content = { backgroundImage: imageRef };
|
|
77
|
+
|
|
78
|
+
const result = enrichBlockWithSignedUrls(content);
|
|
79
|
+
|
|
80
|
+
expect(mockGenerateSignedImageData).toHaveBeenCalledTimes(1);
|
|
81
|
+
expect(result?.backgroundImage._signed).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('ignores objects missing _asset.id', () => {
|
|
85
|
+
const content = {
|
|
86
|
+
notAnImage: {
|
|
87
|
+
alt: 'Test',
|
|
88
|
+
_asset: {
|
|
89
|
+
url: '/test.jpg',
|
|
90
|
+
width: 1920,
|
|
91
|
+
height: 1080,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const result = enrichBlockWithSignedUrls(content);
|
|
97
|
+
|
|
98
|
+
expect(mockGenerateSignedImageData).not.toHaveBeenCalled();
|
|
99
|
+
expect((result?.notAnImage as Record<string, unknown>)._signed).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('ignores objects missing alt', () => {
|
|
103
|
+
const content = {
|
|
104
|
+
notAnImage: {
|
|
105
|
+
_asset: {
|
|
106
|
+
id: '12345678-1234-1234-1234-123456789abc',
|
|
107
|
+
url: '/test.jpg',
|
|
108
|
+
width: 1920,
|
|
109
|
+
height: 1080,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const result = enrichBlockWithSignedUrls(content);
|
|
115
|
+
|
|
116
|
+
expect(mockGenerateSignedImageData).not.toHaveBeenCalled();
|
|
117
|
+
expect((result?.notAnImage as Record<string, unknown>)._signed).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('ignores objects where _asset is null', () => {
|
|
121
|
+
const content = {
|
|
122
|
+
notAnImage: {
|
|
123
|
+
alt: 'Test',
|
|
124
|
+
_asset: null,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
enrichBlockWithSignedUrls(content);
|
|
129
|
+
|
|
130
|
+
expect(mockGenerateSignedImageData).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('ignores objects where _asset is missing id', () => {
|
|
134
|
+
const content = {
|
|
135
|
+
notAnImage: {
|
|
136
|
+
alt: 'Test',
|
|
137
|
+
_asset: {
|
|
138
|
+
url: '/test.jpg',
|
|
139
|
+
width: 1920,
|
|
140
|
+
height: 1080,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
enrichBlockWithSignedUrls(content);
|
|
146
|
+
|
|
147
|
+
expect(mockGenerateSignedImageData).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('ignores objects where _asset is missing url', () => {
|
|
151
|
+
const content = {
|
|
152
|
+
notAnImage: {
|
|
153
|
+
alt: 'Test',
|
|
154
|
+
_asset: {
|
|
155
|
+
id: '12345678-1234-1234-1234-123456789abc',
|
|
156
|
+
width: 1920,
|
|
157
|
+
height: 1080,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
enrichBlockWithSignedUrls(content);
|
|
163
|
+
|
|
164
|
+
expect(mockGenerateSignedImageData).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('ignores objects where _asset is missing width', () => {
|
|
168
|
+
const content = {
|
|
169
|
+
notAnImage: {
|
|
170
|
+
alt: 'Test',
|
|
171
|
+
_asset: {
|
|
172
|
+
id: '12345678-1234-1234-1234-123456789abc',
|
|
173
|
+
url: '/test.jpg',
|
|
174
|
+
height: 1080,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
enrichBlockWithSignedUrls(content);
|
|
180
|
+
|
|
181
|
+
expect(mockGenerateSignedImageData).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('ignores objects where _asset is missing height', () => {
|
|
185
|
+
const content = {
|
|
186
|
+
notAnImage: {
|
|
187
|
+
alt: 'Test',
|
|
188
|
+
_asset: {
|
|
189
|
+
id: '12345678-1234-1234-1234-123456789abc',
|
|
190
|
+
url: '/test.jpg',
|
|
191
|
+
width: 1920,
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
enrichBlockWithSignedUrls(content);
|
|
197
|
+
|
|
198
|
+
expect(mockGenerateSignedImageData).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('requires _asset.id to be a 36-character UUID', () => {
|
|
202
|
+
const content = {
|
|
203
|
+
notAnImage: {
|
|
204
|
+
alt: 'Test',
|
|
205
|
+
_asset: {
|
|
206
|
+
id: 'short-id', // Not 36 characters
|
|
207
|
+
url: '/test.jpg',
|
|
208
|
+
width: 1920,
|
|
209
|
+
height: 1080,
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
enrichBlockWithSignedUrls(content);
|
|
215
|
+
|
|
216
|
+
expect(mockGenerateSignedImageData).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('Recursive Traversal', () => {
|
|
221
|
+
it('traverses nested objects', () => {
|
|
222
|
+
const imageRef = createResolvedImageRef();
|
|
223
|
+
const content = {
|
|
224
|
+
section: {
|
|
225
|
+
hero: {
|
|
226
|
+
backgroundImage: imageRef,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const result = enrichBlockWithSignedUrls(content);
|
|
232
|
+
|
|
233
|
+
expect(mockGenerateSignedImageData).toHaveBeenCalledTimes(1);
|
|
234
|
+
expect(result?.section.hero.backgroundImage._signed).toBeDefined();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('traverses arrays', () => {
|
|
238
|
+
const imageRef1 = createResolvedImageRef('11111111-1111-1111-1111-111111111111');
|
|
239
|
+
const imageRef2 = createResolvedImageRef('22222222-2222-2222-2222-222222222222');
|
|
240
|
+
|
|
241
|
+
const content = {
|
|
242
|
+
images: [imageRef1, imageRef2],
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const result = enrichBlockWithSignedUrls(content);
|
|
246
|
+
|
|
247
|
+
expect(mockGenerateSignedImageData).toHaveBeenCalledTimes(2);
|
|
248
|
+
expect(result?.images[0]._signed).toBeDefined();
|
|
249
|
+
expect(result?.images[1]._signed).toBeDefined();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('traverses deeply nested arrays within objects', () => {
|
|
253
|
+
const imageRef = createResolvedImageRef();
|
|
254
|
+
const content = {
|
|
255
|
+
sections: [
|
|
256
|
+
{
|
|
257
|
+
cards: [
|
|
258
|
+
{
|
|
259
|
+
thumbnail: imageRef,
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const result = enrichBlockWithSignedUrls(content);
|
|
267
|
+
|
|
268
|
+
expect(mockGenerateSignedImageData).toHaveBeenCalledTimes(1);
|
|
269
|
+
expect(result?.sections[0].cards[0].thumbnail._signed).toBeDefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('handles multiple image references at different levels', () => {
|
|
273
|
+
const heroImage = createResolvedImageRef('11111111-1111-1111-1111-111111111111');
|
|
274
|
+
const thumbnailImage = createResolvedImageRef('22222222-2222-2222-2222-222222222222');
|
|
275
|
+
|
|
276
|
+
const content = {
|
|
277
|
+
hero: heroImage,
|
|
278
|
+
sidebar: {
|
|
279
|
+
thumbnail: thumbnailImage,
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const result = enrichBlockWithSignedUrls(content);
|
|
284
|
+
|
|
285
|
+
expect(mockGenerateSignedImageData).toHaveBeenCalledTimes(2);
|
|
286
|
+
expect(result?.hero._signed).toBeDefined();
|
|
287
|
+
expect(result?.sidebar.thumbnail._signed).toBeDefined();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('Signed URL Data', () => {
|
|
292
|
+
it('adds _signed with signedSrcset, signedFallbackSrcset, and signedDefaultSrc', () => {
|
|
293
|
+
const imageRef = createResolvedImageRef();
|
|
294
|
+
const content = { image: imageRef };
|
|
295
|
+
|
|
296
|
+
const result = enrichBlockWithSignedUrls(content);
|
|
297
|
+
|
|
298
|
+
expect(result?.image._signed).toEqual({
|
|
299
|
+
signedSrcset: 'signed-srcset-asset-123',
|
|
300
|
+
signedFallbackSrcset: 'signed-fallback-srcset-asset-123',
|
|
301
|
+
signedDefaultSrc: 'signed-default-src-asset-123',
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('passes correct config to generateSignedImageData', () => {
|
|
306
|
+
const imageRef = createResolvedImageRef();
|
|
307
|
+
const content = { image: imageRef };
|
|
308
|
+
|
|
309
|
+
enrichBlockWithSignedUrls(content);
|
|
310
|
+
|
|
311
|
+
expect(mockGenerateSignedImageData).toHaveBeenCalledWith(
|
|
312
|
+
{
|
|
313
|
+
assetId: 'asset-123',
|
|
314
|
+
width: 1920,
|
|
315
|
+
height: 1080,
|
|
316
|
+
url: '/api/dev/storage/test.jpg',
|
|
317
|
+
},
|
|
318
|
+
expect.objectContaining({})
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('passes focalPoint option when transformation includes hx/hy', () => {
|
|
323
|
+
const imageRef = {
|
|
324
|
+
...createResolvedImageRef(),
|
|
325
|
+
_asset: {
|
|
326
|
+
...createResolvedImageRef()._asset,
|
|
327
|
+
transformation: 'hx=0.3&hy=0.7',
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
const content = { image: imageRef };
|
|
331
|
+
|
|
332
|
+
enrichBlockWithSignedUrls(content);
|
|
333
|
+
|
|
334
|
+
expect(mockGenerateSignedImageData).toHaveBeenCalledWith(expect.objectContaining({}), {
|
|
335
|
+
focalPoint: { x: 0.3, y: 0.7 },
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe('Immutability', () => {
|
|
341
|
+
it('does not mutate the original object', () => {
|
|
342
|
+
const imageRef = createResolvedImageRef();
|
|
343
|
+
const content = { image: imageRef };
|
|
344
|
+
const originalContent = JSON.stringify(content);
|
|
345
|
+
|
|
346
|
+
enrichBlockWithSignedUrls(content);
|
|
347
|
+
|
|
348
|
+
expect(JSON.stringify(content)).toBe(originalContent);
|
|
349
|
+
expect((content.image as Record<string, unknown>)._signed).toBeUndefined();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('returns a new object with signed data', () => {
|
|
353
|
+
const imageRef = createResolvedImageRef();
|
|
354
|
+
const content = { image: imageRef };
|
|
355
|
+
|
|
356
|
+
const result = enrichBlockWithSignedUrls(content);
|
|
357
|
+
|
|
358
|
+
expect(result).not.toBe(content);
|
|
359
|
+
expect(result?.image).not.toBe(content.image);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe('Primitive Values', () => {
|
|
364
|
+
it('preserves primitive values', () => {
|
|
365
|
+
const content = {
|
|
366
|
+
title: 'Hello World',
|
|
367
|
+
count: 42,
|
|
368
|
+
enabled: true,
|
|
369
|
+
ratio: 3.14,
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const result = enrichBlockWithSignedUrls(content);
|
|
373
|
+
|
|
374
|
+
expect(result).toEqual(content);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('preserves nested primitive values alongside images', () => {
|
|
378
|
+
const imageRef = createResolvedImageRef();
|
|
379
|
+
const content = {
|
|
380
|
+
title: 'Hero Section',
|
|
381
|
+
order: 1,
|
|
382
|
+
visible: true,
|
|
383
|
+
image: imageRef,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const result = enrichBlockWithSignedUrls(content);
|
|
387
|
+
|
|
388
|
+
expect(result?.title).toBe('Hero Section');
|
|
389
|
+
expect(result?.order).toBe(1);
|
|
390
|
+
expect(result?.visible).toBe(true);
|
|
391
|
+
expect(result?.image._signed).toBeDefined();
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block Renderer Component
|
|
3
|
+
*
|
|
4
|
+
* Dispatches block data to the appropriate component using the ComponentMap pattern.
|
|
5
|
+
* This is the main entry point for rendering blocks from the CMS.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BlockComponentRegistry, BlockData } from './types';
|
|
9
|
+
|
|
10
|
+
// -----------------------------------------------------------------------------
|
|
11
|
+
// Props
|
|
12
|
+
// -----------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
interface BlockRendererProps {
|
|
15
|
+
/**
|
|
16
|
+
* The block data to render.
|
|
17
|
+
* Must have a `type` field that maps to a registered component.
|
|
18
|
+
*/
|
|
19
|
+
block: BlockData;
|
|
20
|
+
registry: Partial<BlockComponentRegistry>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// -----------------------------------------------------------------------------
|
|
24
|
+
// Component
|
|
25
|
+
// -----------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Renders a single block by dispatching to the appropriate component.
|
|
29
|
+
*
|
|
30
|
+
* Uses the ComponentMap pattern: the block's `type` field determines which
|
|
31
|
+
* component renders the block's `content`.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```tsx
|
|
35
|
+
* // Render a single block
|
|
36
|
+
* <BlockRenderer block={{ type: 'header', content: { headline: 'Hello' } }} />
|
|
37
|
+
*
|
|
38
|
+
* // Render an array of blocks
|
|
39
|
+
* {page.blocks.map((block, index) => (
|
|
40
|
+
* <BlockRenderer key={index} block={block} />
|
|
41
|
+
* ))}
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function BlockRenderer({ block, registry }: BlockRendererProps) {
|
|
45
|
+
const Component = registry[block.type];
|
|
46
|
+
|
|
47
|
+
if (!Component) {
|
|
48
|
+
// Log warning in development, render nothing in production
|
|
49
|
+
if (process.env.NODE_ENV === 'development') {
|
|
50
|
+
console.warn(`[BlockRenderer] Unknown block type: ${block.type}`);
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// TypeScript cannot narrow the content type through the component lookup,
|
|
56
|
+
// so we use a type assertion here. Runtime safety is guaranteed by the
|
|
57
|
+
// discriminated union and the blockComponents registry.
|
|
58
|
+
// biome-ignore lint/suspicious/noExplicitAny: Type safety ensured by BlockData discriminated union
|
|
59
|
+
return <Component content={block.content as any} />;
|
|
60
|
+
}
|
package/lib/cms-api.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS API Client
|
|
3
|
+
*
|
|
4
|
+
* Creates an HTTP-based tRPC client for calling the CMS API.
|
|
5
|
+
* Used in Server Components to fetch routes and blocks from the CMS.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AppRouter } from '@repo/cms-schema/trpc';
|
|
9
|
+
import { type CreateTRPCClient, createTRPCClient, httpBatchLink } from '@trpc/client';
|
|
10
|
+
import { cache } from 'react';
|
|
11
|
+
import superjson from 'superjson';
|
|
12
|
+
|
|
13
|
+
/** Type alias for the CMS API client */
|
|
14
|
+
type CmsClient = CreateTRPCClient<AppRouter>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get the CMS API URL from environment variables.
|
|
18
|
+
* Falls back to localhost:3000 for development.
|
|
19
|
+
*/
|
|
20
|
+
function getCmsApiUrl(): string {
|
|
21
|
+
const cmsUrl = process.env.NEXT_PUBLIC_CMS_URL || 'http://localhost:3000';
|
|
22
|
+
return `${cmsUrl}/api/trpc`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Options for creating a CMS client */
|
|
26
|
+
export interface CmsClientOptions {
|
|
27
|
+
/** API key for authentication (passed as query parameter) */
|
|
28
|
+
apiKey?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a custom fetch function that appends API key as query parameter.
|
|
33
|
+
*/
|
|
34
|
+
function createFetchWithApiKey(apiKey?: string) {
|
|
35
|
+
return async (url: URL | RequestInfo, options?: RequestInit): Promise<Response> => {
|
|
36
|
+
let finalUrl = url;
|
|
37
|
+
|
|
38
|
+
// Append api_key to URL if provided
|
|
39
|
+
if (apiKey) {
|
|
40
|
+
const urlObj = new URL(url.toString());
|
|
41
|
+
urlObj.searchParams.set('api_key', apiKey);
|
|
42
|
+
finalUrl = urlObj.toString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const response = await fetch(finalUrl, options);
|
|
46
|
+
|
|
47
|
+
return response;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a tRPC client for the CMS API.
|
|
53
|
+
* Client is created lazily to ensure environment variables are available at runtime.
|
|
54
|
+
*/
|
|
55
|
+
function createCmsClient(options?: CmsClientOptions): CmsClient {
|
|
56
|
+
const url = getCmsApiUrl();
|
|
57
|
+
console.log('[CMS API] Creating client with URL:', url);
|
|
58
|
+
|
|
59
|
+
return createTRPCClient<AppRouter>({
|
|
60
|
+
links: [
|
|
61
|
+
httpBatchLink({
|
|
62
|
+
url,
|
|
63
|
+
transformer: superjson,
|
|
64
|
+
fetch: createFetchWithApiKey(options?.apiKey),
|
|
65
|
+
}),
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get a CMS client with optional API key.
|
|
72
|
+
* Uses React's cache() to dedupe requests within a single render.
|
|
73
|
+
*/
|
|
74
|
+
export function getCmsClient(options?: CmsClientOptions): CmsClient {
|
|
75
|
+
// If no API key, use the cached default client
|
|
76
|
+
if (!options?.apiKey) {
|
|
77
|
+
return getDefaultCmsClient();
|
|
78
|
+
}
|
|
79
|
+
// With API key, create a new client (keys are request-specific)
|
|
80
|
+
return createCmsClient(options);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Cached default CMS client (no auth) for public requests.
|
|
85
|
+
*/
|
|
86
|
+
const getDefaultCmsClient = cache(() => createCmsClient());
|