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,209 @@
1
+ /**
2
+ * Lazy Loading Utilities
3
+ *
4
+ * Reusable IntersectionObserver-based lazy loading with
5
+ * connection-aware quality adjustment.
6
+ *
7
+ * This module provides:
8
+ * - useLazyLoad: Hook for deferred loading when elements enter viewport
9
+ * - getConnectionAwareQuality: Adjusts image quality based on network speed
10
+ */
11
+
12
+ 'use client';
13
+
14
+ import { useCallback, useEffect, useRef, useState } from 'react';
15
+
16
+ // =============================================================================
17
+ // Types
18
+ // =============================================================================
19
+
20
+ export interface LazyLoadOptions {
21
+ /** Root margin for early loading (default: "200px") */
22
+ rootMargin?: string;
23
+ /** Threshold for intersection (default: 0) */
24
+ threshold?: number;
25
+ /** Callback when element enters viewport */
26
+ onEnter?: () => void;
27
+ }
28
+
29
+ export interface LazyLoadResult {
30
+ /** Callback ref to attach to the element */
31
+ ref: (node: HTMLElement | null) => void;
32
+ /** Whether the element has entered the viewport */
33
+ inView: boolean;
34
+ }
35
+
36
+ // =============================================================================
37
+ // useLazyLoad Hook
38
+ // =============================================================================
39
+
40
+ /**
41
+ * IntersectionObserver-based lazy loading hook.
42
+ *
43
+ * Triggers when an element approaches the viewport. Once triggered,
44
+ * the observer disconnects (one-shot behavior).
45
+ *
46
+ * @param options - Configuration options
47
+ * @returns Object with ref callback and inView state
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * function LazyComponent() {
52
+ * const { ref, inView } = useLazyLoad({ rootMargin: '200px' });
53
+ *
54
+ * return (
55
+ * <div ref={ref}>
56
+ * {inView && <ExpensiveContent />}
57
+ * </div>
58
+ * );
59
+ * }
60
+ * ```
61
+ */
62
+ export function useLazyLoad(options: LazyLoadOptions = {}): LazyLoadResult {
63
+ const { rootMargin = '200px', threshold = 0, onEnter } = options;
64
+ const [inView, setInView] = useState(false);
65
+ const observerRef = useRef<IntersectionObserver | null>(null);
66
+
67
+ const ref = useCallback(
68
+ (node: HTMLElement | null) => {
69
+ // Disconnect previous observer
70
+ if (observerRef.current) {
71
+ observerRef.current.disconnect();
72
+ observerRef.current = null;
73
+ }
74
+
75
+ // No node to observe
76
+ if (!node) return;
77
+
78
+ // Create new observer
79
+ observerRef.current = new IntersectionObserver(
80
+ ([entry]) => {
81
+ if (entry?.isIntersecting) {
82
+ setInView(true);
83
+ onEnter?.();
84
+ // Disconnect after first intersection (one-shot)
85
+ observerRef.current?.disconnect();
86
+ observerRef.current = null;
87
+ }
88
+ },
89
+ { rootMargin, threshold }
90
+ );
91
+
92
+ observerRef.current.observe(node);
93
+ },
94
+ [rootMargin, threshold, onEnter]
95
+ );
96
+
97
+ // Cleanup on unmount
98
+ useEffect(() => {
99
+ return () => {
100
+ if (observerRef.current) {
101
+ observerRef.current.disconnect();
102
+ observerRef.current = null;
103
+ }
104
+ };
105
+ }, []);
106
+
107
+ return { ref, inView };
108
+ }
109
+
110
+ // =============================================================================
111
+ // Connection-Aware Quality
112
+ // =============================================================================
113
+
114
+ /**
115
+ * Network Information API types (not in standard TypeScript lib).
116
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API
117
+ */
118
+ interface NetworkInformation {
119
+ effectiveType?: 'slow-2g' | '2g' | '3g' | '4g';
120
+ downlink?: number;
121
+ saveData?: boolean;
122
+ }
123
+
124
+ interface NavigatorWithConnection extends Navigator {
125
+ connection?: NetworkInformation;
126
+ }
127
+
128
+ /**
129
+ * Quality presets based on connection type.
130
+ */
131
+ const QUALITY_PRESETS = {
132
+ 'slow-2g': 30,
133
+ '2g': 50,
134
+ '3g': 65,
135
+ '4g': 80,
136
+ saveData: 40,
137
+ default: 80,
138
+ } as const;
139
+
140
+ /**
141
+ * Returns appropriate image quality based on network conditions.
142
+ *
143
+ * Uses the Network Information API when available to detect:
144
+ * - Data saver mode (returns lowest quality)
145
+ * - Effective connection type (slow-2g, 2g, 3g, 4g)
146
+ *
147
+ * Falls back to default quality (80) when:
148
+ * - Running on server (SSR)
149
+ * - Network Information API not supported
150
+ *
151
+ * @returns Quality value between 30-80
152
+ *
153
+ * @example
154
+ * ```typescript
155
+ * const quality = getConnectionAwareQuality();
156
+ * const imageUrl = `${baseUrl}?q=${quality}`;
157
+ * ```
158
+ */
159
+ export function getConnectionAwareQuality(): number {
160
+ // SSR fallback
161
+ if (typeof navigator === 'undefined') {
162
+ return QUALITY_PRESETS.default;
163
+ }
164
+
165
+ const nav = navigator as NavigatorWithConnection;
166
+ const connection = nav.connection;
167
+
168
+ // No Network Information API support
169
+ if (!connection) {
170
+ return QUALITY_PRESETS.default;
171
+ }
172
+
173
+ // User has enabled data saver - respect their preference
174
+ if (connection.saveData) {
175
+ return QUALITY_PRESETS.saveData;
176
+ }
177
+
178
+ // Adjust based on effective connection type
179
+ const effectiveType = connection.effectiveType;
180
+ if (effectiveType && effectiveType in QUALITY_PRESETS) {
181
+ return QUALITY_PRESETS[effectiveType];
182
+ }
183
+
184
+ return QUALITY_PRESETS.default;
185
+ }
186
+
187
+ /**
188
+ * Check if the user prefers reduced data usage.
189
+ *
190
+ * @returns true if Save-Data is enabled or connection is slow
191
+ */
192
+ export function prefersReducedData(): boolean {
193
+ if (typeof navigator === 'undefined') {
194
+ return false;
195
+ }
196
+
197
+ const nav = navigator as NavigatorWithConnection;
198
+ const connection = nav.connection;
199
+
200
+ if (!connection) {
201
+ return false;
202
+ }
203
+
204
+ return (
205
+ connection.saveData === true ||
206
+ connection.effectiveType === 'slow-2g' ||
207
+ connection.effectiveType === '2g'
208
+ );
209
+ }
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Markdown Parsing Utilities
3
+ *
4
+ * Three implementations showing the progression from naive to production-ready:
5
+ * - v1 (Naive): Direct call, crashes on bad input
6
+ * - v2 (Defensive): Try-catch with null fallback
7
+ * - v3 (Robust): Result type, validation, sanitization
8
+ *
9
+ * The robust version (v3) is exported as the default `parseMarkdown`.
10
+ */
11
+
12
+ import type { MDTree } from '@repo/markdown-wasm';
13
+ import { mdToJSON } from '@repo/markdown-wasm';
14
+
15
+ import { err, ok, type Result } from './result';
16
+
17
+ // -----------------------------------------------------------------------------
18
+ // Types
19
+ // -----------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Error codes for markdown parsing failures.
23
+ */
24
+ export const ParseErrorCode = {
25
+ INVALID_INPUT: 'INVALID_INPUT',
26
+ EMPTY_CONTENT: 'EMPTY_CONTENT',
27
+ PARSE_FAILED: 'PARSE_FAILED',
28
+ CONTENT_TOO_LONG: 'CONTENT_TOO_LONG',
29
+ NESTED_TOO_DEEP: 'NESTED_TOO_DEEP',
30
+ } as const;
31
+
32
+ export type ParseErrorCode = (typeof ParseErrorCode)[keyof typeof ParseErrorCode];
33
+
34
+ /**
35
+ * Structured error for markdown parsing failures.
36
+ */
37
+ export interface ParseError {
38
+ code: ParseErrorCode;
39
+ message: string;
40
+ cause?: Error;
41
+ }
42
+
43
+ /**
44
+ * Options for robust markdown parsing.
45
+ */
46
+ export interface ParseOptions {
47
+ /**
48
+ * Maximum content length in characters.
49
+ * @default 500000 (500KB of text, ~100K words)
50
+ */
51
+ maxLength?: number;
52
+
53
+ /**
54
+ * Whether to sanitize XSS attempts in the output.
55
+ * @default true
56
+ */
57
+ sanitize?: boolean;
58
+
59
+ /**
60
+ * Whether to allow empty content.
61
+ * @default false
62
+ */
63
+ allowEmpty?: boolean;
64
+ }
65
+
66
+ // -----------------------------------------------------------------------------
67
+ // Constants
68
+ // -----------------------------------------------------------------------------
69
+
70
+ const DEFAULT_MAX_LENGTH = 500_000; // 500KB of text
71
+
72
+ /**
73
+ * Patterns that indicate potential XSS attempts in markdown.
74
+ * These are checked in raw content before parsing.
75
+ */
76
+ const XSS_PATTERNS = [
77
+ /<script\b/i,
78
+ /javascript:/i,
79
+ /on\w+\s*=/i, // onclick=, onerror=, etc.
80
+ /data:/i, // data: URIs can be dangerous
81
+ /vbscript:/i,
82
+ ] as const;
83
+
84
+ // -----------------------------------------------------------------------------
85
+ // V1: Naive Implementation
86
+ // -----------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Naive markdown parser - crashes on bad input.
90
+ *
91
+ * DO NOT USE IN PRODUCTION. This is for demonstration only.
92
+ *
93
+ * Problems:
94
+ * - No input validation
95
+ * - No error handling
96
+ * - Will crash the entire app on malformed input
97
+ * - No protection against XSS
98
+ * - No content limits
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * // This crashes if content is null, undefined, or malformed
103
+ * const ast = parseMarkdownV1(userInput);
104
+ * ```
105
+ */
106
+ export function parseMarkdownV1(content: string): MDTree {
107
+ return mdToJSON(content);
108
+ }
109
+
110
+ // -----------------------------------------------------------------------------
111
+ // V2: Defensive Implementation
112
+ // -----------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Defensive markdown parser - catches errors but loses context.
116
+ *
117
+ * Better than v1 but still problematic:
118
+ * - Returns null on any error (loses error details)
119
+ * - Caller can't distinguish between empty content and parse failure
120
+ * - Still no XSS protection
121
+ * - Still no content limits
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * const ast = parseMarkdownV2(userInput);
126
+ * if (!ast) {
127
+ * // What went wrong? We don't know.
128
+ * return <ErrorFallback />;
129
+ * }
130
+ * ```
131
+ */
132
+ export function parseMarkdownV2(content: string): MDTree | null {
133
+ try {
134
+ // Basic null check
135
+ if (content == null) {
136
+ return null;
137
+ }
138
+ return mdToJSON(content);
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ // -----------------------------------------------------------------------------
145
+ // V3: Robust Implementation
146
+ // -----------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Creates a ParseError with the given code and message.
150
+ */
151
+ function createParseError(code: ParseErrorCode, message: string, cause?: Error): ParseError {
152
+ return { code, message, cause };
153
+ }
154
+
155
+ /**
156
+ * Checks content for potential XSS patterns.
157
+ * Returns the first matched pattern or null if clean.
158
+ */
159
+ function detectXssPattern(content: string): RegExp | null {
160
+ for (const pattern of XSS_PATTERNS) {
161
+ if (pattern.test(content)) {
162
+ return pattern;
163
+ }
164
+ }
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * Sanitizes content by removing dangerous patterns.
170
+ * This is a basic sanitizer; production should use DOMPurify.
171
+ */
172
+ function sanitizeContent(content: string): string {
173
+ let sanitized = content;
174
+
175
+ // Remove script tags
176
+ sanitized = sanitized.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
177
+
178
+ // Remove javascript: and vbscript: URLs
179
+ sanitized = sanitized.replace(/javascript:/gi, 'removed:');
180
+ sanitized = sanitized.replace(/vbscript:/gi, 'removed:');
181
+
182
+ // Remove event handlers (onclick, onerror, etc.)
183
+ sanitized = sanitized.replace(/\bon\w+\s*=\s*["'][^"']*["']/gi, '');
184
+ sanitized = sanitized.replace(/\bon\w+\s*=\s*\S+/gi, '');
185
+
186
+ return sanitized;
187
+ }
188
+
189
+ /**
190
+ * Robust markdown parser - production-ready with full error context.
191
+ *
192
+ * Features:
193
+ * - Input validation with specific error codes
194
+ * - Result type for explicit error handling
195
+ * - XSS pattern detection and optional sanitization
196
+ * - Content length limits
197
+ * - Preserves error context for debugging
198
+ *
199
+ * @param content - Markdown string to parse
200
+ * @param options - Parsing options
201
+ * @returns Result containing the AST or a structured error
202
+ *
203
+ * @example
204
+ * ```ts
205
+ * const result = parseMarkdownV3(userInput);
206
+ *
207
+ * if (!result.ok) {
208
+ * switch (result.error.code) {
209
+ * case 'INVALID_INPUT':
210
+ * return <InvalidInputError message={result.error.message} />;
211
+ * case 'CONTENT_TOO_LONG':
212
+ * return <ContentTooLongError />;
213
+ * case 'PARSE_FAILED':
214
+ * console.error('Parse error:', result.error.cause);
215
+ * return <ParseError />;
216
+ * default:
217
+ * return <GenericError />;
218
+ * }
219
+ * }
220
+ *
221
+ * return <MarkdownRenderer ast={result.value} />;
222
+ * ```
223
+ */
224
+ export function parseMarkdownV3(
225
+ content: string,
226
+ options: ParseOptions = {}
227
+ ): Result<MDTree, ParseError> {
228
+ const { maxLength = DEFAULT_MAX_LENGTH, sanitize = true, allowEmpty = false } = options;
229
+
230
+ // 1. Validate input type
231
+ if (content === null || content === undefined) {
232
+ return err(
233
+ createParseError(
234
+ ParseErrorCode.INVALID_INPUT,
235
+ `Content must be a string, received ${content === null ? 'null' : 'undefined'}`
236
+ )
237
+ );
238
+ }
239
+
240
+ if (typeof content !== 'string') {
241
+ return err(
242
+ createParseError(
243
+ ParseErrorCode.INVALID_INPUT,
244
+ `Content must be a string, received ${typeof content}`
245
+ )
246
+ );
247
+ }
248
+
249
+ // 2. Handle empty content
250
+ const trimmed = content.trim();
251
+ if (trimmed.length === 0 && !allowEmpty) {
252
+ return err(
253
+ createParseError(ParseErrorCode.EMPTY_CONTENT, 'Content is empty or contains only whitespace')
254
+ );
255
+ }
256
+
257
+ // 3. Check content length
258
+ if (content.length > maxLength) {
259
+ return err(
260
+ createParseError(
261
+ ParseErrorCode.CONTENT_TOO_LONG,
262
+ `Content exceeds maximum length of ${maxLength.toLocaleString()} characters ` +
263
+ `(received ${content.length.toLocaleString()})`
264
+ )
265
+ );
266
+ }
267
+
268
+ // 4. Check for XSS patterns
269
+ const xssPattern = detectXssPattern(content);
270
+ if (xssPattern) {
271
+ if (process.env.NODE_ENV === 'development') {
272
+ console.warn(
273
+ `[parseMarkdownV3] XSS pattern detected: ${xssPattern.toString()}`,
274
+ sanitize ? 'Content will be sanitized.' : 'Sanitization is disabled!'
275
+ );
276
+ }
277
+ }
278
+
279
+ // 5. Optionally sanitize content
280
+ const processedContent = sanitize ? sanitizeContent(content) : content;
281
+
282
+ // 6. Parse markdown with error handling
283
+ try {
284
+ const ast = mdToJSON(processedContent);
285
+ return ok(ast);
286
+ } catch (error) {
287
+ const cause = error instanceof Error ? error : new Error(String(error));
288
+ return err(
289
+ createParseError(
290
+ ParseErrorCode.PARSE_FAILED,
291
+ `Failed to parse markdown: ${cause.message}`,
292
+ cause
293
+ )
294
+ );
295
+ }
296
+ }
297
+
298
+ // -----------------------------------------------------------------------------
299
+ // Default Export
300
+ // -----------------------------------------------------------------------------
301
+
302
+ /**
303
+ * Production-ready markdown parser.
304
+ *
305
+ * This is an alias for `parseMarkdownV3` - the robust implementation
306
+ * with validation, sanitization, and Result-based error handling.
307
+ *
308
+ * @example
309
+ * ```ts
310
+ * import { parseMarkdown } from '@/lib/markdown-utils';
311
+ *
312
+ * const result = parseMarkdown(content);
313
+ * if (!result.ok) {
314
+ * // Handle error with full context
315
+ * console.error(`[${result.error.code}] ${result.error.message}`);
316
+ * return null;
317
+ * }
318
+ * return result.value;
319
+ * ```
320
+ */
321
+ export const parseMarkdown = parseMarkdownV3;
322
+
323
+ // -----------------------------------------------------------------------------
324
+ // Utility Functions
325
+ // -----------------------------------------------------------------------------
326
+
327
+ /**
328
+ * Checks if content is safe markdown (no XSS patterns detected).
329
+ *
330
+ * @param content - Markdown string to check
331
+ * @returns true if no XSS patterns detected
332
+ */
333
+ export function isSafeMarkdown(content: string): boolean {
334
+ return detectXssPattern(content) === null;
335
+ }
336
+
337
+ /**
338
+ * Estimates the word count of markdown content.
339
+ * Useful for content length limits and reading time estimates.
340
+ *
341
+ * @param content - Markdown string to count
342
+ * @returns Estimated word count
343
+ */
344
+ export function estimateWordCount(content: string): number {
345
+ // Remove markdown syntax for more accurate count
346
+ const plainText = content
347
+ .replace(/#+\s/g, '') // Remove headings
348
+ .replace(/\*\*|__|~~|`/g, '') // Remove formatting
349
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Convert links to text
350
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, '') // Remove images
351
+ .replace(/```[\s\S]*?```/g, '') // Remove code blocks
352
+ .replace(/`[^`]+`/g, ''); // Remove inline code
353
+
354
+ const words = plainText.trim().split(/\s+/).filter(Boolean);
355
+ return words.length;
356
+ }
357
+
358
+ /**
359
+ * Estimates reading time for markdown content.
360
+ *
361
+ * @param content - Markdown string
362
+ * @param wordsPerMinute - Reading speed (default 200 WPM)
363
+ * @returns Reading time in minutes
364
+ */
365
+ export function estimateReadingTime(content: string, wordsPerMinute = 200): number {
366
+ const wordCount = estimateWordCount(content);
367
+ return Math.ceil(wordCount / wordsPerMinute);
368
+ }