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.
Files changed (51) hide show
  1. package/dist/chunk-HVKFEZBT.js +116 -0
  2. package/dist/chunk-HVKFEZBT.js.map +1 -0
  3. package/dist/chunk-JHKDRASN.js +39 -0
  4. package/dist/chunk-JHKDRASN.js.map +1 -0
  5. package/dist/chunk-RPM73PQZ.js +17 -0
  6. package/dist/chunk-RPM73PQZ.js.map +1 -0
  7. package/dist/lib/block-renderer.d.ts +32 -0
  8. package/dist/lib/block-renderer.js +7 -0
  9. package/dist/lib/block-renderer.js.map +1 -0
  10. package/dist/lib/cms-api.d.ts +25 -0
  11. package/dist/lib/cms-api.js +7 -0
  12. package/dist/lib/cms-api.js.map +1 -0
  13. package/dist/lib/data-utils.d.ts +218 -0
  14. package/dist/lib/data-utils.js +247 -0
  15. package/dist/lib/data-utils.js.map +1 -0
  16. package/dist/lib/image/lazy-load.d.ts +75 -0
  17. package/dist/lib/image/lazy-load.js +83 -0
  18. package/dist/lib/image/lazy-load.js.map +1 -0
  19. package/dist/lib/markdown-utils.d.ts +172 -0
  20. package/dist/lib/markdown-utils.js +137 -0
  21. package/dist/lib/markdown-utils.js.map +1 -0
  22. package/dist/lib/renderer.d.ts +40 -0
  23. package/dist/lib/renderer.js +371 -0
  24. package/dist/lib/renderer.js.map +1 -0
  25. package/{lib/result.ts → dist/lib/result.d.ts} +32 -146
  26. package/dist/lib/result.js +37 -0
  27. package/dist/lib/result.js.map +1 -0
  28. package/dist/lib/schema.d.ts +15 -0
  29. package/dist/lib/schema.js +35 -0
  30. package/dist/lib/schema.js.map +1 -0
  31. package/{lib/trpc.ts → dist/lib/trpc.d.ts} +6 -4
  32. package/dist/lib/trpc.js +7 -0
  33. package/dist/lib/trpc.js.map +1 -0
  34. package/dist/lib/types.d.ts +163 -0
  35. package/dist/lib/types.js +1 -0
  36. package/dist/lib/types.js.map +1 -0
  37. package/package.json +50 -11
  38. package/.turbo/turbo-check-types.log +0 -2
  39. package/lib/__tests__/enrich-block-images.test.ts +0 -394
  40. package/lib/block-renderer.tsx +0 -60
  41. package/lib/cms-api.ts +0 -86
  42. package/lib/data-utils.ts +0 -572
  43. package/lib/image/lazy-load.ts +0 -209
  44. package/lib/markdown-utils.ts +0 -368
  45. package/lib/renderer.tsx +0 -189
  46. package/lib/schema.ts +0 -74
  47. package/lib/types.ts +0 -201
  48. package/next.config.ts +0 -39
  49. package/postcss.config.mjs +0 -5
  50. package/tsconfig.json +0 -12
  51. package/tsconfig.tsbuildinfo +0 -1
@@ -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
- }