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.
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Catch-all Route Handler for Parametric Routes
3
+ *
4
+ * Handles routes with multiple segments like /us/en/products.
5
+ * Uses the CMS API to fetch and render blocks.
6
+ */
7
+
8
+ import {
9
+ isArticlePublished,
10
+ isValidBlockSchemaName,
11
+ normalizeArticleContent,
12
+ } from '@repo/cms-schema/blocks';
13
+ import type { Metadata } from 'next';
14
+ import { unstable_noStore } from 'next/cache';
15
+ import { notFound } from 'next/navigation';
16
+ import { BlockRenderer } from './block-renderer';
17
+ import { getCmsClient } from './cms-api';
18
+ import type { BlockComponentRegistry, BlockData } from './types';
19
+
20
+ type PageProps = {
21
+ params: Promise<{ slug: string[] }>;
22
+ registry?: Partial<BlockComponentRegistry>;
23
+ /** API key for CMS API authentication */
24
+ apiKey?: string;
25
+ };
26
+
27
+ /**
28
+ * Force dynamic rendering to ensure routes are always fresh.
29
+ * This prevents Next.js from caching pages when routes are published.
30
+ */
31
+ export const dynamic = 'force-dynamic';
32
+
33
+ /**
34
+ * Catch-all route handler for parametric routes.
35
+ *
36
+ * Handles paths like:
37
+ * - /us/en/products -> slug = ['us', 'en', 'products']
38
+ * - /about -> slug = ['about']
39
+ *
40
+ * Reconstructs the full path and fetches route via tRPC.
41
+ */
42
+ export default async function ParametricRoutePage({ params, registry, apiKey }: PageProps) {
43
+ // Prevent any caching - ensure we always fetch fresh route data
44
+ unstable_noStore();
45
+
46
+ const { slug } = await params;
47
+
48
+ // Reconstruct full path from slug segments and normalize it
49
+ const rawPath = `/${slug.join('/')}`;
50
+ const path = normalizePath(rawPath);
51
+
52
+ // Get CMS API client with optional API key
53
+ const client = getCmsClient({ apiKey });
54
+
55
+ try {
56
+ // Fetch route by path via CMS API
57
+ const { route } = await client.route.getByPath.query({ path });
58
+
59
+ // Only show Live routes on public website
60
+ if (route.state !== 'Live') {
61
+ console.error(`Route found but not Live. Path: ${path}, State: ${route.state}`);
62
+ notFound();
63
+ }
64
+
65
+ // Fetch all blocks by ID via CMS API (skip any that fail)
66
+ const blockPromises = route.block_ids.map(async (blockId) => {
67
+ try {
68
+ const result = await client.block.getById.query({ id: blockId });
69
+ return result.block;
70
+ } catch (error) {
71
+ // Log error but don't fail the entire page
72
+ console.error(`Failed to fetch block ${blockId}:`, error);
73
+ return null;
74
+ }
75
+ });
76
+ const blockResults = await Promise.all(blockPromises);
77
+
78
+ // Transform blocks to BlockData format for BlockRenderer
79
+ // Filter out any blocks that failed to load
80
+ const blocks: BlockData[] = [];
81
+
82
+ for (const block of blockResults) {
83
+ if (!block || block.published_content === null) continue;
84
+
85
+ const content = block.published_content as Record<string, unknown> | null;
86
+ if (!content) continue;
87
+
88
+ // Handle 'article' blocks separately before checking schema type
89
+ if (block.schema_name === 'article') {
90
+ const article = normalizeArticleContent(content);
91
+ const isPublished = article ? isArticlePublished(article) : null;
92
+ if (article && isPublished) {
93
+ blocks.push({ id: block.id, type: 'article', content: article });
94
+ }
95
+ continue;
96
+ }
97
+
98
+ // Skip blocks with invalid schema names (after handling 'article')
99
+ if (!isValidBlockSchemaName(block.schema_name)) {
100
+ continue;
101
+ }
102
+
103
+ // For all block types, map schema_name to type and include content
104
+ // Image references are automatically resolved by block.getById
105
+ blocks.push({
106
+ id: block.id,
107
+ type: block.schema_name,
108
+ content,
109
+ } as BlockData);
110
+ }
111
+
112
+ return (
113
+ <main>
114
+ {blocks.map((block) => (
115
+ <BlockRenderer registry={registry ?? {}} key={block.id} block={block} />
116
+ ))}
117
+ </main>
118
+ );
119
+ } catch (error) {
120
+ // Log error for debugging
121
+ console.error(`Route fetch error for path: ${path}`, error);
122
+
123
+ // If route not found or param validation fails, show 404
124
+ // TRPCClientError has data.code for the error code
125
+ const errorCode =
126
+ error instanceof Error && 'data' in error
127
+ ? (error as { data?: { code?: string } }).data?.code
128
+ : error instanceof Error && 'code' in error
129
+ ? (error as { code: string }).code
130
+ : undefined;
131
+
132
+ if (errorCode === 'NOT_FOUND' || errorCode === 'P0002') {
133
+ notFound();
134
+ }
135
+
136
+ // Re-throw other errors
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ // -----------------------------------------------------------------------------
142
+ // Metadata
143
+ // -----------------------------------------------------------------------------
144
+
145
+ /**
146
+ * Generate metadata for the page.
147
+ * Uses Next.js 15+ async params pattern.
148
+ */
149
+ export async function generateMetadata({ params, apiKey }: PageProps): Promise<Metadata> {
150
+ const { slug } = await params;
151
+ const rawPath = `/${slug.join('/')}`;
152
+ const path = normalizePath(rawPath);
153
+ const client = getCmsClient({ apiKey });
154
+
155
+ try {
156
+ const { route } = await client.route.getByPath.query({ path });
157
+ return {
158
+ title: `${route.path} | Website`,
159
+ description: `Content page: ${route.path}`,
160
+ };
161
+ } catch {
162
+ return {
163
+ title: 'Page Not Found | Website',
164
+ description: 'The requested page could not be found.',
165
+ };
166
+ }
167
+ }
168
+
169
+ export function normalizePath(path: string): string {
170
+ if (!path || path === '/') {
171
+ return '/';
172
+ }
173
+
174
+ // Remove trailing slashes, ensure leading slash
175
+ let normalized = path.trim();
176
+
177
+ // Remove trailing slashes (but keep root "/")
178
+ normalized = normalized.replace(/\/+$/, '');
179
+
180
+ // Ensure leading slash
181
+ if (!normalized.startsWith('/')) {
182
+ normalized = `/${normalized}`;
183
+ }
184
+
185
+ // Collapse multiple consecutive slashes to single slash
186
+ normalized = normalized.replace(/\/+/g, '/');
187
+
188
+ return normalized;
189
+ }
package/lib/result.ts ADDED
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Result Type Utilities
3
+ *
4
+ * Go-style error handling with discriminated union types.
5
+ * Provides type-safe success/error handling without exceptions.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * const [err, data] = await handle(async () => {
10
+ * const response = await fetch(url);
11
+ * if (!response.ok) throw new Error(`HTTP ${response.status}`);
12
+ * return response.json();
13
+ * });
14
+ *
15
+ * if (err) {
16
+ * console.error('Failed:', err.message);
17
+ * return { error: err.message };
18
+ * }
19
+ * return { data };
20
+ * ```
21
+ */
22
+
23
+ // -----------------------------------------------------------------------------
24
+ // Result Type
25
+ // -----------------------------------------------------------------------------
26
+
27
+ /**
28
+ * A discriminated union representing either success or failure.
29
+ *
30
+ * @template T - The success value type
31
+ * @template E - The error type (defaults to Error)
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * function divide(a: number, b: number): Result<number, string> {
36
+ * if (b === 0) return err('Division by zero');
37
+ * return ok(a / b);
38
+ * }
39
+ *
40
+ * const result = divide(10, 2);
41
+ * if (isOk(result)) {
42
+ * console.log('Result:', result.value); // 5
43
+ * } else {
44
+ * console.error('Error:', result.error);
45
+ * }
46
+ * ```
47
+ */
48
+ export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
49
+
50
+ // -----------------------------------------------------------------------------
51
+ // Constructors
52
+ // -----------------------------------------------------------------------------
53
+
54
+ /**
55
+ * Creates a success Result.
56
+ *
57
+ * @param value - The success value
58
+ * @returns A success Result containing the value
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * const result = ok(42);
63
+ * // { ok: true, value: 42 }
64
+ * ```
65
+ */
66
+ export function ok<T>(value: T): Result<T, never> {
67
+ return { ok: true, value };
68
+ }
69
+
70
+ /**
71
+ * Creates a failure Result.
72
+ *
73
+ * @param error - The error value
74
+ * @returns A failure Result containing the error
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * const result = err(new Error('Something went wrong'));
79
+ * // { ok: false, error: Error('Something went wrong') }
80
+ * ```
81
+ */
82
+ export function err<E>(error: E): Result<never, E> {
83
+ return { ok: false, error };
84
+ }
85
+
86
+ // -----------------------------------------------------------------------------
87
+ // Type Guards
88
+ // -----------------------------------------------------------------------------
89
+
90
+ /**
91
+ * Type guard to check if a Result is successful.
92
+ *
93
+ * @param result - The Result to check
94
+ * @returns true if the Result is a success
95
+ *
96
+ * @example
97
+ * ```ts
98
+ * const result = fetchData();
99
+ * if (isOk(result)) {
100
+ * // TypeScript knows result.value exists here
101
+ * console.log(result.value);
102
+ * }
103
+ * ```
104
+ */
105
+ export function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
106
+ return result.ok === true;
107
+ }
108
+
109
+ /**
110
+ * Type guard to check if a Result is a failure.
111
+ *
112
+ * @param result - The Result to check
113
+ * @returns true if the Result is a failure
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * const result = fetchData();
118
+ * if (isErr(result)) {
119
+ * // TypeScript knows result.error exists here
120
+ * console.error(result.error);
121
+ * }
122
+ * ```
123
+ */
124
+ export function isErr<T, E>(result: Result<T, E>): result is { ok: false; error: E } {
125
+ return result.ok === false;
126
+ }
127
+
128
+ // -----------------------------------------------------------------------------
129
+ // Extractors
130
+ // -----------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Extracts the value from a Result, throwing if it's an error.
134
+ *
135
+ * @param result - The Result to unwrap
136
+ * @returns The success value
137
+ * @throws The error if Result is a failure
138
+ *
139
+ * @example
140
+ * ```ts
141
+ * const result = ok(42);
142
+ * const value = unwrap(result); // 42
143
+ *
144
+ * const errorResult = err(new Error('fail'));
145
+ * const value2 = unwrap(errorResult); // throws Error('fail')
146
+ * ```
147
+ */
148
+ export function unwrap<T, E>(result: Result<T, E>): T {
149
+ if (isOk(result)) {
150
+ return result.value;
151
+ }
152
+ throw result.error;
153
+ }
154
+
155
+ /**
156
+ * Extracts the value from a Result, returning a default on error.
157
+ *
158
+ * @param result - The Result to unwrap
159
+ * @param defaultValue - The value to return if Result is an error
160
+ * @returns The success value or the default value
161
+ *
162
+ * @example
163
+ * ```ts
164
+ * const result = err(new Error('fail'));
165
+ * const value = unwrapOr(result, 0); // 0
166
+ *
167
+ * const okResult = ok(42);
168
+ * const value2 = unwrapOr(okResult, 0); // 42
169
+ * ```
170
+ */
171
+ export function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {
172
+ if (isOk(result)) {
173
+ return result.value;
174
+ }
175
+ return defaultValue;
176
+ }
177
+
178
+ /**
179
+ * Extracts the value from a Result, computing a default on error.
180
+ *
181
+ * @param result - The Result to unwrap
182
+ * @param fn - Function to compute the default value from the error
183
+ * @returns The success value or the computed default
184
+ *
185
+ * @example
186
+ * ```ts
187
+ * const result = err(new Error('not found'));
188
+ * const value = unwrapOrElse(result, (e) => {
189
+ * console.error('Error:', e.message);
190
+ * return [];
191
+ * });
192
+ * ```
193
+ */
194
+ export function unwrapOrElse<T, E>(result: Result<T, E>, fn: (error: E) => T): T {
195
+ if (isOk(result)) {
196
+ return result.value;
197
+ }
198
+ return fn(result.error);
199
+ }
200
+
201
+ // -----------------------------------------------------------------------------
202
+ // Transformers
203
+ // -----------------------------------------------------------------------------
204
+
205
+ /**
206
+ * Maps a successful Result's value.
207
+ *
208
+ * @param result - The Result to map
209
+ * @param fn - Function to transform the value
210
+ * @returns A new Result with the transformed value, or the original error
211
+ *
212
+ * @example
213
+ * ```ts
214
+ * const result = ok(5);
215
+ * const doubled = map(result, (n) => n * 2); // ok(10)
216
+ *
217
+ * const errorResult = err('fail');
218
+ * const still = map(errorResult, (n) => n * 2); // err('fail')
219
+ * ```
220
+ */
221
+ export function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
222
+ if (isOk(result)) {
223
+ return ok(fn(result.value));
224
+ }
225
+ return result;
226
+ }
227
+
228
+ /**
229
+ * Maps a failed Result's error.
230
+ *
231
+ * @param result - The Result to map
232
+ * @param fn - Function to transform the error
233
+ * @returns A new Result with the transformed error, or the original value
234
+ *
235
+ * @example
236
+ * ```ts
237
+ * const result = err('not found');
238
+ * const mapped = mapErr(result, (e) => new Error(e)); // err(Error('not found'))
239
+ * ```
240
+ */
241
+ export function mapErr<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {
242
+ if (isErr(result)) {
243
+ return err(fn(result.error));
244
+ }
245
+ return result;
246
+ }
247
+
248
+ /**
249
+ * Chains Result-returning operations.
250
+ *
251
+ * @param result - The Result to chain from
252
+ * @param fn - Function that returns a new Result
253
+ * @returns The chained Result
254
+ *
255
+ * @example
256
+ * ```ts
257
+ * function parse(input: string): Result<number, string> {
258
+ * const n = parseInt(input, 10);
259
+ * return isNaN(n) ? err('not a number') : ok(n);
260
+ * }
261
+ *
262
+ * function double(n: number): Result<number, string> {
263
+ * return ok(n * 2);
264
+ * }
265
+ *
266
+ * const result = flatMap(parse('5'), double); // ok(10)
267
+ * const fail = flatMap(parse('abc'), double); // err('not a number')
268
+ * ```
269
+ */
270
+ export function flatMap<T, U, E>(
271
+ result: Result<T, E>,
272
+ fn: (value: T) => Result<U, E>
273
+ ): Result<U, E> {
274
+ if (isOk(result)) {
275
+ return fn(result.value);
276
+ }
277
+ return result;
278
+ }
279
+
280
+ // -----------------------------------------------------------------------------
281
+ // Async Helpers
282
+ // -----------------------------------------------------------------------------
283
+
284
+ /**
285
+ * Wraps a Promise in a Result type.
286
+ *
287
+ * @param promise - The Promise to wrap
288
+ * @returns A Promise that resolves to a Result
289
+ *
290
+ * @example
291
+ * ```ts
292
+ * const result = await fromPromise(fetch('/api/data'));
293
+ * if (isErr(result)) {
294
+ * console.error('Fetch failed:', result.error);
295
+ * return;
296
+ * }
297
+ * const response = result.value;
298
+ * ```
299
+ */
300
+ export async function fromPromise<T>(promise: Promise<T>): Promise<Result<T, Error>> {
301
+ try {
302
+ const value = await promise;
303
+ return ok(value);
304
+ } catch (error) {
305
+ return err(error instanceof Error ? error : new Error(String(error)));
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Wraps a throwing function in a Result type.
311
+ *
312
+ * @param fn - The function to wrap
313
+ * @returns A Result containing the return value or the thrown error
314
+ *
315
+ * @example
316
+ * ```ts
317
+ * const result = tryCatch(() => JSON.parse(input));
318
+ * if (isErr(result)) {
319
+ * console.error('Invalid JSON:', result.error);
320
+ * return null;
321
+ * }
322
+ * return result.value;
323
+ * ```
324
+ */
325
+ export function tryCatch<T>(fn: () => T): Result<T, Error> {
326
+ try {
327
+ return ok(fn());
328
+ } catch (error) {
329
+ return err(error instanceof Error ? error : new Error(String(error)));
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Wraps an async function in a Result type.
335
+ *
336
+ * @param fn - The async function to wrap
337
+ * @returns A Promise that resolves to a Result
338
+ *
339
+ * @example
340
+ * ```ts
341
+ * const result = await tryCatchAsync(async () => {
342
+ * const response = await fetch('/api/data');
343
+ * if (!response.ok) throw new Error(`HTTP ${response.status}`);
344
+ * return response.json();
345
+ * });
346
+ * ```
347
+ */
348
+ export async function tryCatchAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {
349
+ try {
350
+ const value = await fn();
351
+ return ok(value);
352
+ } catch (error) {
353
+ return err(error instanceof Error ? error : new Error(String(error)));
354
+ }
355
+ }
356
+
357
+ // -----------------------------------------------------------------------------
358
+ // Tuple Helpers (Go-style)
359
+ // -----------------------------------------------------------------------------
360
+
361
+ /**
362
+ * Go-style tuple for error handling: [error, value]
363
+ *
364
+ * @example
365
+ * ```ts
366
+ * const [err, data] = await handle(fetchData);
367
+ * if (err) {
368
+ * console.error(err);
369
+ * return;
370
+ * }
371
+ * console.log(data);
372
+ * ```
373
+ */
374
+ export type GoTuple<T, E = Error> = [E, undefined] | [undefined, T];
375
+
376
+ /**
377
+ * Converts a Result to a Go-style tuple.
378
+ *
379
+ * @param result - The Result to convert
380
+ * @returns A tuple of [error, value]
381
+ *
382
+ * @example
383
+ * ```ts
384
+ * const result = await fetchData();
385
+ * const [err, data] = toTuple(result);
386
+ * ```
387
+ */
388
+ export function toTuple<T, E>(result: Result<T, E>): GoTuple<T, E> {
389
+ if (isOk(result)) {
390
+ return [undefined, result.value];
391
+ }
392
+ return [result.error, undefined];
393
+ }
394
+
395
+ /**
396
+ * Wraps an async function and returns a Go-style tuple.
397
+ *
398
+ * @param fn - The async function to wrap
399
+ * @returns A Promise that resolves to [error, value] tuple
400
+ *
401
+ * @example
402
+ * ```ts
403
+ * const [err, data] = await handle(async () => {
404
+ * const res = await fetch('/api/users');
405
+ * if (!res.ok) throw new Error(`HTTP ${res.status}`);
406
+ * return res.json();
407
+ * });
408
+ *
409
+ * if (err) {
410
+ * console.error('Failed to fetch users:', err.message);
411
+ * return [];
412
+ * }
413
+ * return data;
414
+ * ```
415
+ */
416
+ export async function handle<T>(fn: () => Promise<T>): Promise<GoTuple<T, Error>> {
417
+ try {
418
+ const value = await fn();
419
+ return [undefined, value];
420
+ } catch (error) {
421
+ const err = error instanceof Error ? error : new Error(String(error));
422
+ return [err, undefined];
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Wraps a synchronous function and returns a Go-style tuple.
428
+ *
429
+ * @param fn - The function to wrap
430
+ * @returns A tuple of [error, value]
431
+ *
432
+ * @example
433
+ * ```ts
434
+ * const [err, parsed] = handleSync(() => JSON.parse(input));
435
+ * if (err) {
436
+ * console.error('Invalid JSON:', err.message);
437
+ * return null;
438
+ * }
439
+ * return parsed;
440
+ * ```
441
+ */
442
+ export function handleSync<T>(fn: () => T): GoTuple<T, Error> {
443
+ try {
444
+ const value = fn();
445
+ return [undefined, value];
446
+ } catch (error) {
447
+ const err = error instanceof Error ? error : new Error(String(error));
448
+ return [err, undefined];
449
+ }
450
+ }
package/lib/schema.ts ADDED
@@ -0,0 +1,74 @@
1
+ // Fetch schema from the CMS for a headless setup
2
+ // This should look like this
3
+ //
4
+ // import { schema, configureSchema } from "@profound/cms";
5
+ //
6
+ // // Option 1: Use default config (reads from CMS_API_URL env var)
7
+ // const footerData = schema.name("footer").fetchAll();
8
+ //
9
+ // // Option 2: Configure with custom API base and fetch options
10
+ // const customSchema = configureSchema({
11
+ // apiBase: "https://my-cms.example.com/api",
12
+ // fetchOptions: { headers: { Authorization: "Bearer token" } },
13
+ // });
14
+ // const footerData = customSchema.name("footer").fetchAll();
15
+ //
16
+ // console.log(footerData); # [{ logo: "<cloudflare_signed_img_url>", "Footer_text": "Profound" }]
17
+
18
+ const DEFAULT_API_BASE = process.env.NEXT_PUBLIC_CMS_API_URL ?? 'http://localhost:4000/api';
19
+
20
+ interface SchemaConfig {
21
+ apiBase?: string;
22
+ fetchOptions?: RequestInit;
23
+ }
24
+
25
+ interface SchemaQuery {
26
+ fetchAll: <T = Record<string, unknown>>() => Promise<T[]>;
27
+ fetchSingle: <T = Record<string, unknown>>() => Promise<T | null>;
28
+ }
29
+
30
+ interface SchemaClient {
31
+ name: (schemaName: string) => SchemaQuery;
32
+ }
33
+
34
+ function createSchemaQuery(
35
+ apiBase: string,
36
+ schemaName: string,
37
+ fetchOptions: RequestInit = {}
38
+ ): SchemaQuery {
39
+ const baseUrl = `${apiBase}/schemas/${schemaName}`;
40
+
41
+ return {
42
+ async fetchAll<T = Record<string, unknown>>(): Promise<T[]> {
43
+ const response = await fetch(baseUrl, fetchOptions);
44
+
45
+ if (!response.ok) {
46
+ throw new Error(`Failed to fetch schema "${schemaName}": ${response.statusText}`);
47
+ }
48
+
49
+ return response.json();
50
+ },
51
+
52
+ async fetchSingle<T = Record<string, unknown>>(): Promise<T | null> {
53
+ const response = await fetch(`${baseUrl}?limit=1`, fetchOptions);
54
+
55
+ if (!response.ok) {
56
+ throw new Error(`Failed to fetch schema "${schemaName}": ${response.statusText}`);
57
+ }
58
+
59
+ const data = await response.json();
60
+ return Array.isArray(data) ? (data[0] ?? null) : data;
61
+ },
62
+ };
63
+ }
64
+
65
+ export function configureSchema(config: SchemaConfig = {}): SchemaClient {
66
+ const apiBase = config.apiBase || DEFAULT_API_BASE;
67
+ const fetchOptions = config.fetchOptions || {};
68
+
69
+ return {
70
+ name: (schemaName: string): SchemaQuery => createSchemaQuery(apiBase, schemaName, fetchOptions),
71
+ };
72
+ }
73
+
74
+ export const schema: SchemaClient = configureSchema();