cms-renderer 0.0.0 → 0.1.1
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/dist/chunk-HVKFEZBT.js +116 -0
- package/dist/chunk-HVKFEZBT.js.map +1 -0
- package/dist/chunk-JHKDRASN.js +39 -0
- package/dist/chunk-JHKDRASN.js.map +1 -0
- package/dist/chunk-RPM73PQZ.js +17 -0
- package/dist/chunk-RPM73PQZ.js.map +1 -0
- package/dist/lib/block-renderer.d.ts +32 -0
- package/dist/lib/block-renderer.js +7 -0
- package/dist/lib/block-renderer.js.map +1 -0
- package/dist/lib/cms-api.d.ts +25 -0
- package/dist/lib/cms-api.js +7 -0
- package/dist/lib/cms-api.js.map +1 -0
- package/dist/lib/data-utils.d.ts +218 -0
- package/dist/lib/data-utils.js +247 -0
- package/dist/lib/data-utils.js.map +1 -0
- package/dist/lib/image/lazy-load.d.ts +75 -0
- package/dist/lib/image/lazy-load.js +83 -0
- package/dist/lib/image/lazy-load.js.map +1 -0
- package/dist/lib/markdown-utils.d.ts +172 -0
- package/dist/lib/markdown-utils.js +137 -0
- package/dist/lib/markdown-utils.js.map +1 -0
- package/dist/lib/renderer.d.ts +40 -0
- package/dist/lib/renderer.js +371 -0
- package/dist/lib/renderer.js.map +1 -0
- package/{lib/result.ts → dist/lib/result.d.ts} +32 -146
- package/dist/lib/result.js +37 -0
- package/dist/lib/result.js.map +1 -0
- package/dist/lib/schema.d.ts +15 -0
- package/dist/lib/schema.js +35 -0
- package/dist/lib/schema.js.map +1 -0
- package/{lib/trpc.ts → dist/lib/trpc.d.ts} +6 -4
- package/dist/lib/trpc.js +7 -0
- package/dist/lib/trpc.js.map +1 -0
- package/dist/lib/types.d.ts +163 -0
- package/dist/lib/types.js +1 -0
- package/dist/lib/types.js.map +1 -0
- package/package.json +50 -11
- package/.turbo/turbo-check-types.log +0 -2
- package/lib/__tests__/enrich-block-images.test.ts +0 -394
- package/lib/block-renderer.tsx +0 -60
- package/lib/cms-api.ts +0 -86
- package/lib/data-utils.ts +0 -572
- package/lib/image/lazy-load.ts +0 -209
- package/lib/markdown-utils.ts +0 -368
- package/lib/renderer.tsx +0 -189
- package/lib/schema.ts +0 -74
- package/lib/types.ts +0 -201
- package/next.config.ts +0 -39
- package/postcss.config.mjs +0 -5
- package/tsconfig.json +0 -12
- package/tsconfig.tsbuildinfo +0 -1
package/lib/block-renderer.tsx
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
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());
|
package/lib/data-utils.ts
DELETED
|
@@ -1,572 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Data Fetching Utilities
|
|
3
|
-
*
|
|
4
|
-
* Three implementations showing the progression from naive to production-ready:
|
|
5
|
-
* - v1 (Naive): Direct await, crashes on any error
|
|
6
|
-
* - v2 (Defensive): Try-catch with null fallback
|
|
7
|
-
* - v3 (Robust): Result type, retry logic, timeout support
|
|
8
|
-
*
|
|
9
|
-
* The robust version (v3) is exported as the default `fetchPage`.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { err, ok, type Result } from './result';
|
|
13
|
-
import type { BlockData } from './types';
|
|
14
|
-
|
|
15
|
-
// -----------------------------------------------------------------------------
|
|
16
|
-
// Types
|
|
17
|
-
// -----------------------------------------------------------------------------
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Page structure returned by the tRPC API.
|
|
21
|
-
*/
|
|
22
|
-
export interface Page {
|
|
23
|
-
slug: string;
|
|
24
|
-
title: string;
|
|
25
|
-
blocks: BlockData[];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Error codes for data fetching failures.
|
|
30
|
-
*/
|
|
31
|
-
export const FetchErrorCode = {
|
|
32
|
-
INVALID_SLUG: 'INVALID_SLUG',
|
|
33
|
-
NOT_FOUND: 'NOT_FOUND',
|
|
34
|
-
NETWORK_ERROR: 'NETWORK_ERROR',
|
|
35
|
-
TIMEOUT: 'TIMEOUT',
|
|
36
|
-
PARSE_ERROR: 'PARSE_ERROR',
|
|
37
|
-
SERVER_ERROR: 'SERVER_ERROR',
|
|
38
|
-
RETRY_EXHAUSTED: 'RETRY_EXHAUSTED',
|
|
39
|
-
} as const;
|
|
40
|
-
|
|
41
|
-
export type FetchErrorCode = (typeof FetchErrorCode)[keyof typeof FetchErrorCode];
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Structured error for data fetching failures.
|
|
45
|
-
*/
|
|
46
|
-
export interface FetchError {
|
|
47
|
-
code: FetchErrorCode;
|
|
48
|
-
message: string;
|
|
49
|
-
cause?: Error;
|
|
50
|
-
retryCount?: number;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Options for robust data fetching.
|
|
55
|
-
*/
|
|
56
|
-
export interface FetchOptions {
|
|
57
|
-
/**
|
|
58
|
-
* Timeout in milliseconds.
|
|
59
|
-
* @default 10000 (10 seconds)
|
|
60
|
-
*/
|
|
61
|
-
timeout?: number;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Number of retry attempts for transient failures.
|
|
65
|
-
* @default 3
|
|
66
|
-
*/
|
|
67
|
-
retries?: number;
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Base delay between retries in milliseconds.
|
|
71
|
-
* Uses exponential backoff: delay * 2^attempt
|
|
72
|
-
* @default 1000 (1 second)
|
|
73
|
-
*/
|
|
74
|
-
retryDelay?: number;
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* AbortSignal for cancellation support.
|
|
78
|
-
*/
|
|
79
|
-
signal?: AbortSignal;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// -----------------------------------------------------------------------------
|
|
83
|
-
// Constants
|
|
84
|
-
// -----------------------------------------------------------------------------
|
|
85
|
-
|
|
86
|
-
const DEFAULT_TIMEOUT = 10_000; // 10 seconds
|
|
87
|
-
const DEFAULT_RETRIES = 3;
|
|
88
|
-
const DEFAULT_RETRY_DELAY = 1_000; // 1 second
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* HTTP status codes that indicate transient failures (should retry).
|
|
92
|
-
*/
|
|
93
|
-
const TRANSIENT_STATUS_CODES = [408, 429, 500, 502, 503, 504] as const;
|
|
94
|
-
|
|
95
|
-
// -----------------------------------------------------------------------------
|
|
96
|
-
// Mock Data Store (for demonstration)
|
|
97
|
-
// -----------------------------------------------------------------------------
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Simulated data store.
|
|
101
|
-
* In production, this would be replaced with actual tRPC/API calls.
|
|
102
|
-
*/
|
|
103
|
-
const mockPages: Record<string, Page> = {
|
|
104
|
-
demo: {
|
|
105
|
-
slug: 'demo',
|
|
106
|
-
title: 'Demo Page',
|
|
107
|
-
blocks: [
|
|
108
|
-
{
|
|
109
|
-
id: 'mock-header-1',
|
|
110
|
-
type: 'header',
|
|
111
|
-
content: {
|
|
112
|
-
headline: 'Welcome',
|
|
113
|
-
alignment: 'center',
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
id: 'mock-article-1',
|
|
118
|
-
type: 'article',
|
|
119
|
-
content: {
|
|
120
|
-
headline: 'Getting Started',
|
|
121
|
-
body: '## Introduction\n\nThis is a demo article.',
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
],
|
|
125
|
-
},
|
|
126
|
-
about: {
|
|
127
|
-
slug: 'about',
|
|
128
|
-
title: 'About Us',
|
|
129
|
-
blocks: [
|
|
130
|
-
{
|
|
131
|
-
id: 'mock-header-2',
|
|
132
|
-
type: 'header',
|
|
133
|
-
content: {
|
|
134
|
-
headline: 'About Our Company',
|
|
135
|
-
alignment: 'left',
|
|
136
|
-
},
|
|
137
|
-
},
|
|
138
|
-
],
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
// -----------------------------------------------------------------------------
|
|
143
|
-
// Helper Functions
|
|
144
|
-
// -----------------------------------------------------------------------------
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Creates a FetchError with the given code and message.
|
|
148
|
-
*/
|
|
149
|
-
function createFetchError(
|
|
150
|
-
code: FetchErrorCode,
|
|
151
|
-
message: string,
|
|
152
|
-
options: { cause?: Error; retryCount?: number } = {}
|
|
153
|
-
): FetchError {
|
|
154
|
-
return { code, message, ...options };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Delays execution for the specified duration.
|
|
159
|
-
*/
|
|
160
|
-
function delay(ms: number): Promise<void> {
|
|
161
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Calculates exponential backoff delay.
|
|
166
|
-
*/
|
|
167
|
-
function exponentialBackoff(baseDelay: number, attempt: number): number {
|
|
168
|
-
// Add jitter to prevent thundering herd
|
|
169
|
-
const jitter = Math.random() * 100;
|
|
170
|
-
return baseDelay * 2 ** attempt + jitter;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Checks if an error represents a transient failure that should be retried.
|
|
175
|
-
*/
|
|
176
|
-
function isTransientError(error: unknown): boolean {
|
|
177
|
-
if (error instanceof Error) {
|
|
178
|
-
// Network errors
|
|
179
|
-
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
|
180
|
-
return true;
|
|
181
|
-
}
|
|
182
|
-
// Check for HTTP status codes in error message
|
|
183
|
-
for (const code of TRANSIENT_STATUS_CODES) {
|
|
184
|
-
if (error.message.includes(String(code))) {
|
|
185
|
-
return true;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
return false;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Simulates a network request with configurable behavior.
|
|
194
|
-
* In production, this would be an actual fetch/tRPC call.
|
|
195
|
-
*/
|
|
196
|
-
async function simulateFetch(slug: string, signal?: AbortSignal): Promise<Page> {
|
|
197
|
-
// Simulate network latency
|
|
198
|
-
await delay(50 + Math.random() * 100);
|
|
199
|
-
|
|
200
|
-
// Check for cancellation
|
|
201
|
-
if (signal?.aborted) {
|
|
202
|
-
throw new Error('Request aborted');
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const page = mockPages[slug];
|
|
206
|
-
if (!page) {
|
|
207
|
-
const error = new Error(`Page not found: ${slug}`);
|
|
208
|
-
error.name = 'NotFoundError';
|
|
209
|
-
throw error;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return page;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// -----------------------------------------------------------------------------
|
|
216
|
-
// V1: Naive Implementation
|
|
217
|
-
// -----------------------------------------------------------------------------
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Naive data fetcher - crashes on any error.
|
|
221
|
-
*
|
|
222
|
-
* DO NOT USE IN PRODUCTION. This is for demonstration only.
|
|
223
|
-
*
|
|
224
|
-
* Problems:
|
|
225
|
-
* - No input validation
|
|
226
|
-
* - No error handling
|
|
227
|
-
* - Will crash the entire app on network failure
|
|
228
|
-
* - No timeout protection
|
|
229
|
-
* - No retry logic for transient failures
|
|
230
|
-
*
|
|
231
|
-
* @example
|
|
232
|
-
* ```ts
|
|
233
|
-
* // This throws if slug is invalid or network fails
|
|
234
|
-
* const page = await fetchPageV1('demo');
|
|
235
|
-
* ```
|
|
236
|
-
*/
|
|
237
|
-
export async function fetchPageV1(slug: string): Promise<Page> {
|
|
238
|
-
// Direct await - any error crashes the caller
|
|
239
|
-
return simulateFetch(slug);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// -----------------------------------------------------------------------------
|
|
243
|
-
// V2: Defensive Implementation
|
|
244
|
-
// -----------------------------------------------------------------------------
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Defensive data fetcher - catches errors but loses context.
|
|
248
|
-
*
|
|
249
|
-
* Better than v1 but still problematic:
|
|
250
|
-
* - Returns null on any error (loses error details)
|
|
251
|
-
* - Caller can't distinguish between 404 and network failure
|
|
252
|
-
* - No retry logic for transient failures
|
|
253
|
-
* - No timeout protection
|
|
254
|
-
*
|
|
255
|
-
* @example
|
|
256
|
-
* ```ts
|
|
257
|
-
* const page = await fetchPageV2(slug);
|
|
258
|
-
* if (!page) {
|
|
259
|
-
* // What went wrong? 404? Network? Timeout? We don't know.
|
|
260
|
-
* return <NotFound />;
|
|
261
|
-
* }
|
|
262
|
-
* ```
|
|
263
|
-
*/
|
|
264
|
-
export async function fetchPageV2(slug: string): Promise<Page | null> {
|
|
265
|
-
try {
|
|
266
|
-
// Basic validation
|
|
267
|
-
if (!slug || typeof slug !== 'string') {
|
|
268
|
-
return null;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
return await simulateFetch(slug);
|
|
272
|
-
} catch {
|
|
273
|
-
// All errors become null - we lose valuable context
|
|
274
|
-
return null;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// -----------------------------------------------------------------------------
|
|
279
|
-
// V3: Robust Implementation
|
|
280
|
-
// -----------------------------------------------------------------------------
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Robust data fetcher - production-ready with full error context.
|
|
284
|
-
*
|
|
285
|
-
* Features:
|
|
286
|
-
* - Input validation with specific error codes
|
|
287
|
-
* - Result type for explicit error handling
|
|
288
|
-
* - Automatic retry with exponential backoff
|
|
289
|
-
* - Timeout support via AbortController
|
|
290
|
-
* - Distinguishes between error types (404 vs network vs timeout)
|
|
291
|
-
* - Preserves error context for debugging
|
|
292
|
-
*
|
|
293
|
-
* @param slug - Page slug to fetch
|
|
294
|
-
* @param options - Fetch options (timeout, retries, etc.)
|
|
295
|
-
* @returns Result containing the page data or a structured error
|
|
296
|
-
*
|
|
297
|
-
* @example
|
|
298
|
-
* ```ts
|
|
299
|
-
* const result = await fetchPageV3('demo', {
|
|
300
|
-
* timeout: 5000,
|
|
301
|
-
* retries: 3,
|
|
302
|
-
* });
|
|
303
|
-
*
|
|
304
|
-
* if (!result.ok) {
|
|
305
|
-
* switch (result.error.code) {
|
|
306
|
-
* case 'NOT_FOUND':
|
|
307
|
-
* return <NotFoundPage slug={slug} />;
|
|
308
|
-
* case 'TIMEOUT':
|
|
309
|
-
* return <TimeoutError onRetry={handleRetry} />;
|
|
310
|
-
* case 'NETWORK_ERROR':
|
|
311
|
-
* return <NetworkError message={result.error.message} />;
|
|
312
|
-
* case 'RETRY_EXHAUSTED':
|
|
313
|
-
* return <RetryExhausted attempts={result.error.retryCount} />;
|
|
314
|
-
* default:
|
|
315
|
-
* return <GenericError />;
|
|
316
|
-
* }
|
|
317
|
-
* }
|
|
318
|
-
*
|
|
319
|
-
* return <PageRenderer page={result.value} />;
|
|
320
|
-
* ```
|
|
321
|
-
*/
|
|
322
|
-
export async function fetchPageV3(
|
|
323
|
-
slug: string,
|
|
324
|
-
options: FetchOptions = {}
|
|
325
|
-
): Promise<Result<Page, FetchError>> {
|
|
326
|
-
const {
|
|
327
|
-
timeout = DEFAULT_TIMEOUT,
|
|
328
|
-
retries = DEFAULT_RETRIES,
|
|
329
|
-
retryDelay = DEFAULT_RETRY_DELAY,
|
|
330
|
-
signal: externalSignal,
|
|
331
|
-
} = options;
|
|
332
|
-
|
|
333
|
-
// 1. Validate input
|
|
334
|
-
if (slug === null || slug === undefined) {
|
|
335
|
-
return err(
|
|
336
|
-
createFetchError(
|
|
337
|
-
FetchErrorCode.INVALID_SLUG,
|
|
338
|
-
`Slug must be a string, received ${slug === null ? 'null' : 'undefined'}`
|
|
339
|
-
)
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (typeof slug !== 'string') {
|
|
344
|
-
return err(
|
|
345
|
-
createFetchError(
|
|
346
|
-
FetchErrorCode.INVALID_SLUG,
|
|
347
|
-
`Slug must be a string, received ${typeof slug}`
|
|
348
|
-
)
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const trimmedSlug = slug.trim();
|
|
353
|
-
if (trimmedSlug.length === 0) {
|
|
354
|
-
return err(createFetchError(FetchErrorCode.INVALID_SLUG, 'Slug cannot be empty'));
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// 2. Create abort controller for timeout
|
|
358
|
-
const controller = new AbortController();
|
|
359
|
-
const { signal } = controller;
|
|
360
|
-
|
|
361
|
-
// Link external signal if provided
|
|
362
|
-
if (externalSignal) {
|
|
363
|
-
externalSignal.addEventListener('abort', () => controller.abort());
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// 3. Set up timeout
|
|
367
|
-
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
368
|
-
|
|
369
|
-
// 4. Attempt fetch with retry logic
|
|
370
|
-
let lastError: Error | undefined;
|
|
371
|
-
let attempt = 0;
|
|
372
|
-
|
|
373
|
-
try {
|
|
374
|
-
while (attempt <= retries) {
|
|
375
|
-
try {
|
|
376
|
-
const page = await simulateFetch(trimmedSlug, signal);
|
|
377
|
-
clearTimeout(timeoutId);
|
|
378
|
-
return ok(page);
|
|
379
|
-
} catch (error) {
|
|
380
|
-
lastError = error instanceof Error ? error : new Error(String(error));
|
|
381
|
-
|
|
382
|
-
// Check for abort/timeout
|
|
383
|
-
if (signal.aborted) {
|
|
384
|
-
clearTimeout(timeoutId);
|
|
385
|
-
return err(
|
|
386
|
-
createFetchError(FetchErrorCode.TIMEOUT, `Request timed out after ${timeout}ms`, {
|
|
387
|
-
cause: lastError,
|
|
388
|
-
})
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// Check for 404 (not retryable)
|
|
393
|
-
if (lastError.name === 'NotFoundError') {
|
|
394
|
-
clearTimeout(timeoutId);
|
|
395
|
-
return err(
|
|
396
|
-
createFetchError(FetchErrorCode.NOT_FOUND, `Page "${trimmedSlug}" not found`, {
|
|
397
|
-
cause: lastError,
|
|
398
|
-
})
|
|
399
|
-
);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// Check if we should retry
|
|
403
|
-
if (attempt < retries && isTransientError(lastError)) {
|
|
404
|
-
attempt++;
|
|
405
|
-
const backoff = exponentialBackoff(retryDelay, attempt);
|
|
406
|
-
if (process.env.NODE_ENV === 'development') {
|
|
407
|
-
console.warn(
|
|
408
|
-
`[fetchPageV3] Retry ${attempt}/${retries} for "${trimmedSlug}" after ${Math.round(backoff)}ms`
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
await delay(backoff);
|
|
412
|
-
continue;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// No more retries or non-transient error
|
|
416
|
-
break;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// 5. All retries exhausted
|
|
421
|
-
clearTimeout(timeoutId);
|
|
422
|
-
|
|
423
|
-
if (attempt >= retries) {
|
|
424
|
-
return err(
|
|
425
|
-
createFetchError(FetchErrorCode.RETRY_EXHAUSTED, `Failed after ${retries} retry attempts`, {
|
|
426
|
-
cause: lastError,
|
|
427
|
-
retryCount: attempt,
|
|
428
|
-
})
|
|
429
|
-
);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// Non-retryable error
|
|
433
|
-
return err(
|
|
434
|
-
createFetchError(
|
|
435
|
-
FetchErrorCode.NETWORK_ERROR,
|
|
436
|
-
lastError?.message ?? 'Unknown network error',
|
|
437
|
-
{ cause: lastError }
|
|
438
|
-
)
|
|
439
|
-
);
|
|
440
|
-
} catch (error) {
|
|
441
|
-
clearTimeout(timeoutId);
|
|
442
|
-
const cause = error instanceof Error ? error : new Error(String(error));
|
|
443
|
-
return err(
|
|
444
|
-
createFetchError(FetchErrorCode.SERVER_ERROR, `Unexpected error: ${cause.message}`, { cause })
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// -----------------------------------------------------------------------------
|
|
450
|
-
// Default Export
|
|
451
|
-
// -----------------------------------------------------------------------------
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* Production-ready page fetcher.
|
|
455
|
-
*
|
|
456
|
-
* This is an alias for `fetchPageV3` - the robust implementation
|
|
457
|
-
* with validation, retry logic, and Result-based error handling.
|
|
458
|
-
*
|
|
459
|
-
* @example
|
|
460
|
-
* ```ts
|
|
461
|
-
* import { fetchPage } from '@/lib/data-utils';
|
|
462
|
-
*
|
|
463
|
-
* const result = await fetchPage('demo');
|
|
464
|
-
* if (!result.ok) {
|
|
465
|
-
* // Handle error with full context
|
|
466
|
-
* console.error(`[${result.error.code}] ${result.error.message}`);
|
|
467
|
-
* return null;
|
|
468
|
-
* }
|
|
469
|
-
* return result.value;
|
|
470
|
-
* ```
|
|
471
|
-
*/
|
|
472
|
-
export const fetchPage = fetchPageV3;
|
|
473
|
-
|
|
474
|
-
// -----------------------------------------------------------------------------
|
|
475
|
-
// Utility Functions
|
|
476
|
-
// -----------------------------------------------------------------------------
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Validates a page slug format.
|
|
480
|
-
*
|
|
481
|
-
* @param slug - Slug to validate
|
|
482
|
-
* @returns true if slug is valid format
|
|
483
|
-
*/
|
|
484
|
-
export function isValidSlug(slug: string): boolean {
|
|
485
|
-
// Slugs should be lowercase, alphanumeric with hyphens
|
|
486
|
-
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* Normalizes a slug (lowercase, trim, replace spaces with hyphens).
|
|
491
|
-
*
|
|
492
|
-
* @param input - Raw input to normalize
|
|
493
|
-
* @returns Normalized slug
|
|
494
|
-
*/
|
|
495
|
-
export function normalizeSlug(input: string): string {
|
|
496
|
-
return input
|
|
497
|
-
.toLowerCase()
|
|
498
|
-
.trim()
|
|
499
|
-
.replace(/\s+/g, '-')
|
|
500
|
-
.replace(/[^a-z0-9-]/g, '')
|
|
501
|
-
.replace(/-+/g, '-')
|
|
502
|
-
.replace(/^-|-$/g, '');
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* Prefetches multiple pages in parallel with Result types.
|
|
507
|
-
*
|
|
508
|
-
* @param slugs - Array of page slugs to fetch
|
|
509
|
-
* @param options - Fetch options applied to all requests
|
|
510
|
-
* @returns Array of Results for each page
|
|
511
|
-
*
|
|
512
|
-
* @example
|
|
513
|
-
* ```ts
|
|
514
|
-
* const results = await prefetchPages(['home', 'about', 'blog']);
|
|
515
|
-
* const errors = results.filter(r => !r.ok);
|
|
516
|
-
* if (errors.length > 0) {
|
|
517
|
-
* console.warn(`${errors.length} pages failed to load`);
|
|
518
|
-
* }
|
|
519
|
-
* ```
|
|
520
|
-
*/
|
|
521
|
-
export async function prefetchPages(
|
|
522
|
-
slugs: string[],
|
|
523
|
-
options?: FetchOptions
|
|
524
|
-
): Promise<Result<Page, FetchError>[]> {
|
|
525
|
-
return Promise.all(slugs.map((slug) => fetchPageV3(slug, options)));
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
/**
|
|
529
|
-
* Fetches a page with stale-while-revalidate semantics.
|
|
530
|
-
* Returns cached data immediately if available, then updates in background.
|
|
531
|
-
*
|
|
532
|
-
* @param slug - Page slug to fetch
|
|
533
|
-
* @param cache - Cache storage (e.g., Map, localStorage wrapper)
|
|
534
|
-
* @param options - Fetch options
|
|
535
|
-
* @returns Cached data or fresh fetch result
|
|
536
|
-
*/
|
|
537
|
-
export async function fetchPageWithSWR(
|
|
538
|
-
slug: string,
|
|
539
|
-
cache: Map<string, { data: Page; timestamp: number }>,
|
|
540
|
-
options: FetchOptions & { maxAge?: number } = {}
|
|
541
|
-
): Promise<Result<Page, FetchError>> {
|
|
542
|
-
const { maxAge = 60_000 } = options; // 1 minute default
|
|
543
|
-
|
|
544
|
-
const cached = cache.get(slug);
|
|
545
|
-
const now = Date.now();
|
|
546
|
-
|
|
547
|
-
// Return fresh cache immediately
|
|
548
|
-
if (cached && now - cached.timestamp < maxAge) {
|
|
549
|
-
return ok(cached.data);
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
// Fetch fresh data
|
|
553
|
-
const result = await fetchPageV3(slug, options);
|
|
554
|
-
|
|
555
|
-
// Update cache on success
|
|
556
|
-
if (result.ok) {
|
|
557
|
-
cache.set(slug, { data: result.value, timestamp: now });
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// If fetch failed but we have stale cache, return it with warning
|
|
561
|
-
if (!result.ok && cached) {
|
|
562
|
-
if (process.env.NODE_ENV === 'development') {
|
|
563
|
-
console.warn(
|
|
564
|
-
`[fetchPageWithSWR] Returning stale cache for "${slug}" after fetch failure:`,
|
|
565
|
-
result.error.message
|
|
566
|
-
);
|
|
567
|
-
}
|
|
568
|
-
return ok(cached.data);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
return result;
|
|
572
|
-
}
|