cms-renderer 0.3.7 → 0.4.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.
@@ -121,7 +121,7 @@ function isSafeMarkdown(content) {
121
121
  return detectXssPattern(content) === null;
122
122
  }
123
123
  function estimateWordCount(content) {
124
- const plainText = content.replace(/#+\s/g, "").replace(/\*\*|__|~~|`/g, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/!\[([^\]]*)\]\([^)]+\)/g, "").replace(/```[\s\S]*?```/g, "").replace(/`[^`]+`/g, "");
124
+ const plainText = content.replace(/#+\s/g, "").replace(/```[\s\S]*?```/g, "").replace(/`[^`]+`/g, "").replace(/!\[([^\]]*)\]\([^)]+\)/g, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/\*\*|__|~~/g, "");
125
125
  const words = plainText.trim().split(/\s+/).filter(Boolean);
126
126
  return words.length;
127
127
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../lib/markdown-utils.ts","../../lib/result.ts"],"sourcesContent":["/**\n * Markdown Parsing Utilities\n *\n * Three implementations showing the progression from naive to production-ready:\n * - v1 (Naive): Direct call, crashes on bad input\n * - v2 (Defensive): Try-catch with null fallback\n * - v3 (Robust): Result type, validation, sanitization\n *\n * The robust version (v3) is exported as the default `parseMarkdown`.\n */\n\nimport type { MDTree } from 'md4w';\nimport { mdToJSON } from 'md4w';\n\nimport { err, ok, type Result } from './result';\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\n/**\n * Error codes for markdown parsing failures.\n */\nexport const ParseErrorCode = {\n INVALID_INPUT: 'INVALID_INPUT',\n EMPTY_CONTENT: 'EMPTY_CONTENT',\n PARSE_FAILED: 'PARSE_FAILED',\n CONTENT_TOO_LONG: 'CONTENT_TOO_LONG',\n NESTED_TOO_DEEP: 'NESTED_TOO_DEEP',\n} as const;\n\nexport type ParseErrorCode = (typeof ParseErrorCode)[keyof typeof ParseErrorCode];\n\n/**\n * Structured error for markdown parsing failures.\n */\nexport interface ParseError {\n code: ParseErrorCode;\n message: string;\n cause?: Error;\n}\n\n/**\n * Options for robust markdown parsing.\n */\nexport interface ParseOptions {\n /**\n * Maximum content length in characters.\n * @default 500000 (500KB of text, ~100K words)\n */\n maxLength?: number;\n\n /**\n * Whether to sanitize XSS attempts in the output.\n * @default true\n */\n sanitize?: boolean;\n\n /**\n * Whether to allow empty content.\n * @default false\n */\n allowEmpty?: boolean;\n}\n\n// -----------------------------------------------------------------------------\n// Constants\n// -----------------------------------------------------------------------------\n\nconst DEFAULT_MAX_LENGTH = 500_000; // 500KB of text\n\n/**\n * Patterns that indicate potential XSS attempts in markdown.\n * These are checked in raw content before parsing.\n */\nconst XSS_PATTERNS = [\n /<script\\b/i,\n /javascript:/i,\n /on\\w+\\s*=/i, // onclick=, onerror=, etc.\n /data:/i, // data: URIs can be dangerous\n /vbscript:/i,\n] as const;\n\n// -----------------------------------------------------------------------------\n// V1: Naive Implementation\n// -----------------------------------------------------------------------------\n\n/**\n * Naive markdown parser - crashes on bad input.\n *\n * DO NOT USE IN PRODUCTION. This is for demonstration only.\n *\n * Problems:\n * - No input validation\n * - No error handling\n * - Will crash the entire app on malformed input\n * - No protection against XSS\n * - No content limits\n *\n * @example\n * ```ts\n * // This crashes if content is null, undefined, or malformed\n * const ast = parseMarkdownV1(userInput);\n * ```\n */\nexport function parseMarkdownV1(content: string): MDTree {\n return mdToJSON(content);\n}\n\n// -----------------------------------------------------------------------------\n// V2: Defensive Implementation\n// -----------------------------------------------------------------------------\n\n/**\n * Defensive markdown parser - catches errors but loses context.\n *\n * Better than v1 but still problematic:\n * - Returns null on any error (loses error details)\n * - Caller can't distinguish between empty content and parse failure\n * - Still no XSS protection\n * - Still no content limits\n *\n * @example\n * ```ts\n * const ast = parseMarkdownV2(userInput);\n * if (!ast) {\n * // What went wrong? We don't know.\n * return <ErrorFallback />;\n * }\n * ```\n */\nexport function parseMarkdownV2(content: string): MDTree | null {\n try {\n // Basic null check\n if (content == null) {\n return null;\n }\n return mdToJSON(content);\n } catch {\n return null;\n }\n}\n\n// -----------------------------------------------------------------------------\n// V3: Robust Implementation\n// -----------------------------------------------------------------------------\n\n/**\n * Creates a ParseError with the given code and message.\n */\nfunction createParseError(code: ParseErrorCode, message: string, cause?: Error): ParseError {\n return { code, message, cause };\n}\n\n/**\n * Checks content for potential XSS patterns.\n * Returns the first matched pattern or null if clean.\n */\nfunction detectXssPattern(content: string): RegExp | null {\n for (const pattern of XSS_PATTERNS) {\n if (pattern.test(content)) {\n return pattern;\n }\n }\n return null;\n}\n\n/**\n * Sanitizes content by removing dangerous patterns.\n * This is a basic sanitizer; production should use DOMPurify.\n */\nfunction sanitizeContent(content: string): string {\n let sanitized = content;\n\n // Remove script tags\n sanitized = sanitized.replace(/<script\\b[^<]*(?:(?!<\\/script>)<[^<]*)*<\\/script>/gi, '');\n\n // Remove javascript: and vbscript: URLs\n sanitized = sanitized.replace(/javascript:/gi, 'removed:');\n sanitized = sanitized.replace(/vbscript:/gi, 'removed:');\n\n // Remove event handlers (onclick, onerror, etc.)\n sanitized = sanitized.replace(/\\bon\\w+\\s*=\\s*[\"'][^\"']*[\"']/gi, '');\n sanitized = sanitized.replace(/\\bon\\w+\\s*=\\s*\\S+/gi, '');\n\n return sanitized;\n}\n\n/**\n * Robust markdown parser - production-ready with full error context.\n *\n * Features:\n * - Input validation with specific error codes\n * - Result type for explicit error handling\n * - XSS pattern detection and optional sanitization\n * - Content length limits\n * - Preserves error context for debugging\n *\n * @param content - Markdown string to parse\n * @param options - Parsing options\n * @returns Result containing the AST or a structured error\n *\n * @example\n * ```ts\n * const result = parseMarkdownV3(userInput);\n *\n * if (!result.ok) {\n * switch (result.error.code) {\n * case 'INVALID_INPUT':\n * return <InvalidInputError message={result.error.message} />;\n * case 'CONTENT_TOO_LONG':\n * return <ContentTooLongError />;\n * case 'PARSE_FAILED':\n * console.error('Parse error:', result.error.cause);\n * return <ParseError />;\n * default:\n * return <GenericError />;\n * }\n * }\n *\n * return <MarkdownRenderer ast={result.value} />;\n * ```\n */\nexport function parseMarkdownV3(\n content: string,\n options: ParseOptions = {}\n): Result<MDTree, ParseError> {\n const { maxLength = DEFAULT_MAX_LENGTH, sanitize = true, allowEmpty = false } = options;\n\n // 1. Validate input type\n if (content === null || content === undefined) {\n return err(\n createParseError(\n ParseErrorCode.INVALID_INPUT,\n `Content must be a string, received ${content === null ? 'null' : 'undefined'}`\n )\n );\n }\n\n if (typeof content !== 'string') {\n return err(\n createParseError(\n ParseErrorCode.INVALID_INPUT,\n `Content must be a string, received ${typeof content}`\n )\n );\n }\n\n // 2. Handle empty content\n const trimmed = content.trim();\n if (trimmed.length === 0 && !allowEmpty) {\n return err(\n createParseError(ParseErrorCode.EMPTY_CONTENT, 'Content is empty or contains only whitespace')\n );\n }\n\n // 3. Check content length\n if (content.length > maxLength) {\n return err(\n createParseError(\n ParseErrorCode.CONTENT_TOO_LONG,\n `Content exceeds maximum length of ${maxLength.toLocaleString()} characters ` +\n `(received ${content.length.toLocaleString()})`\n )\n );\n }\n\n // 4. Check for XSS patterns\n const xssPattern = detectXssPattern(content);\n if (xssPattern) {\n if (process.env.NODE_ENV === 'development') {\n console.warn(\n `[parseMarkdownV3] XSS pattern detected: ${xssPattern.toString()}`,\n sanitize ? 'Content will be sanitized.' : 'Sanitization is disabled!'\n );\n }\n }\n\n // 5. Optionally sanitize content\n const processedContent = sanitize ? sanitizeContent(content) : content;\n\n // 6. Parse markdown with error handling\n try {\n const ast = mdToJSON(processedContent);\n return ok(ast);\n } catch (error) {\n const cause = error instanceof Error ? error : new Error(String(error));\n return err(\n createParseError(\n ParseErrorCode.PARSE_FAILED,\n `Failed to parse markdown: ${cause.message}`,\n cause\n )\n );\n }\n}\n\n// -----------------------------------------------------------------------------\n// Default Export\n// -----------------------------------------------------------------------------\n\n/**\n * Production-ready markdown parser.\n *\n * This is an alias for `parseMarkdownV3` - the robust implementation\n * with validation, sanitization, and Result-based error handling.\n *\n * @example\n * ```ts\n * import { parseMarkdown } from '@/lib/markdown-utils';\n *\n * const result = parseMarkdown(content);\n * if (!result.ok) {\n * // Handle error with full context\n * console.error(`[${result.error.code}] ${result.error.message}`);\n * return null;\n * }\n * return result.value;\n * ```\n */\nexport const parseMarkdown = parseMarkdownV3;\n\n// -----------------------------------------------------------------------------\n// Utility Functions\n// -----------------------------------------------------------------------------\n\n/**\n * Checks if content is safe markdown (no XSS patterns detected).\n *\n * @param content - Markdown string to check\n * @returns true if no XSS patterns detected\n */\nexport function isSafeMarkdown(content: string): boolean {\n return detectXssPattern(content) === null;\n}\n\n/**\n * Estimates the word count of markdown content.\n * Useful for content length limits and reading time estimates.\n *\n * @param content - Markdown string to count\n * @returns Estimated word count\n */\nexport function estimateWordCount(content: string): number {\n // Remove markdown syntax for more accurate count\n const plainText = content\n .replace(/#+\\s/g, '') // Remove headings\n .replace(/\\*\\*|__|~~|`/g, '') // Remove formatting\n .replace(/\\[([^\\]]+)\\]\\([^)]+\\)/g, '$1') // Convert links to text\n .replace(/!\\[([^\\]]*)\\]\\([^)]+\\)/g, '') // Remove images\n .replace(/```[\\s\\S]*?```/g, '') // Remove code blocks\n .replace(/`[^`]+`/g, ''); // Remove inline code\n\n const words = plainText.trim().split(/\\s+/).filter(Boolean);\n return words.length;\n}\n\n/**\n * Estimates reading time for markdown content.\n *\n * @param content - Markdown string\n * @param wordsPerMinute - Reading speed (default 200 WPM)\n * @returns Reading time in minutes\n */\nexport function estimateReadingTime(content: string, wordsPerMinute = 200): number {\n const wordCount = estimateWordCount(content);\n return Math.ceil(wordCount / wordsPerMinute);\n}\n","/**\n * Result Type Utilities\n *\n * Go-style error handling with discriminated union types.\n * Provides type-safe success/error handling without exceptions.\n *\n * @example\n * ```ts\n * const [err, data] = await handle(async () => {\n * const response = await fetch(url);\n * if (!response.ok) throw new Error(`HTTP ${response.status}`);\n * return response.json();\n * });\n *\n * if (err) {\n * console.error('Failed:', err.message);\n * return { error: err.message };\n * }\n * return { data };\n * ```\n */\n\n// -----------------------------------------------------------------------------\n// Result Type\n// -----------------------------------------------------------------------------\n\n/**\n * A discriminated union representing either success or failure.\n *\n * @template T - The success value type\n * @template E - The error type (defaults to Error)\n *\n * @example\n * ```ts\n * function divide(a: number, b: number): Result<number, string> {\n * if (b === 0) return err('Division by zero');\n * return ok(a / b);\n * }\n *\n * const result = divide(10, 2);\n * if (isOk(result)) {\n * console.log('Result:', result.value); // 5\n * } else {\n * console.error('Error:', result.error);\n * }\n * ```\n */\nexport type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };\n\n// -----------------------------------------------------------------------------\n// Constructors\n// -----------------------------------------------------------------------------\n\n/**\n * Creates a success Result.\n *\n * @param value - The success value\n * @returns A success Result containing the value\n *\n * @example\n * ```ts\n * const result = ok(42);\n * // { ok: true, value: 42 }\n * ```\n */\nexport function ok<T>(value: T): Result<T, never> {\n return { ok: true, value };\n}\n\n/**\n * Creates a failure Result.\n *\n * @param error - The error value\n * @returns A failure Result containing the error\n *\n * @example\n * ```ts\n * const result = err(new Error('Something went wrong'));\n * // { ok: false, error: Error('Something went wrong') }\n * ```\n */\nexport function err<E>(error: E): Result<never, E> {\n return { ok: false, error };\n}\n\n// -----------------------------------------------------------------------------\n// Type Guards\n// -----------------------------------------------------------------------------\n\n/**\n * Type guard to check if a Result is successful.\n *\n * @param result - The Result to check\n * @returns true if the Result is a success\n *\n * @example\n * ```ts\n * const result = fetchData();\n * if (isOk(result)) {\n * // TypeScript knows result.value exists here\n * console.log(result.value);\n * }\n * ```\n */\nexport function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {\n return result.ok === true;\n}\n\n/**\n * Type guard to check if a Result is a failure.\n *\n * @param result - The Result to check\n * @returns true if the Result is a failure\n *\n * @example\n * ```ts\n * const result = fetchData();\n * if (isErr(result)) {\n * // TypeScript knows result.error exists here\n * console.error(result.error);\n * }\n * ```\n */\nexport function isErr<T, E>(result: Result<T, E>): result is { ok: false; error: E } {\n return result.ok === false;\n}\n\n// -----------------------------------------------------------------------------\n// Extractors\n// -----------------------------------------------------------------------------\n\n/**\n * Extracts the value from a Result, throwing if it's an error.\n *\n * @param result - The Result to unwrap\n * @returns The success value\n * @throws The error if Result is a failure\n *\n * @example\n * ```ts\n * const result = ok(42);\n * const value = unwrap(result); // 42\n *\n * const errorResult = err(new Error('fail'));\n * const value2 = unwrap(errorResult); // throws Error('fail')\n * ```\n */\nexport function unwrap<T, E>(result: Result<T, E>): T {\n if (isOk(result)) {\n return result.value;\n }\n throw result.error;\n}\n\n/**\n * Extracts the value from a Result, returning a default on error.\n *\n * @param result - The Result to unwrap\n * @param defaultValue - The value to return if Result is an error\n * @returns The success value or the default value\n *\n * @example\n * ```ts\n * const result = err(new Error('fail'));\n * const value = unwrapOr(result, 0); // 0\n *\n * const okResult = ok(42);\n * const value2 = unwrapOr(okResult, 0); // 42\n * ```\n */\nexport function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {\n if (isOk(result)) {\n return result.value;\n }\n return defaultValue;\n}\n\n/**\n * Extracts the value from a Result, computing a default on error.\n *\n * @param result - The Result to unwrap\n * @param fn - Function to compute the default value from the error\n * @returns The success value or the computed default\n *\n * @example\n * ```ts\n * const result = err(new Error('not found'));\n * const value = unwrapOrElse(result, (e) => {\n * console.error('Error:', e.message);\n * return [];\n * });\n * ```\n */\nexport function unwrapOrElse<T, E>(result: Result<T, E>, fn: (error: E) => T): T {\n if (isOk(result)) {\n return result.value;\n }\n return fn(result.error);\n}\n\n// -----------------------------------------------------------------------------\n// Transformers\n// -----------------------------------------------------------------------------\n\n/**\n * Maps a successful Result's value.\n *\n * @param result - The Result to map\n * @param fn - Function to transform the value\n * @returns A new Result with the transformed value, or the original error\n *\n * @example\n * ```ts\n * const result = ok(5);\n * const doubled = map(result, (n) => n * 2); // ok(10)\n *\n * const errorResult = err('fail');\n * const still = map(errorResult, (n) => n * 2); // err('fail')\n * ```\n */\nexport function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {\n if (isOk(result)) {\n return ok(fn(result.value));\n }\n return result;\n}\n\n/**\n * Maps a failed Result's error.\n *\n * @param result - The Result to map\n * @param fn - Function to transform the error\n * @returns A new Result with the transformed error, or the original value\n *\n * @example\n * ```ts\n * const result = err('not found');\n * const mapped = mapErr(result, (e) => new Error(e)); // err(Error('not found'))\n * ```\n */\nexport function mapErr<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {\n if (isErr(result)) {\n return err(fn(result.error));\n }\n return result;\n}\n\n/**\n * Chains Result-returning operations.\n *\n * @param result - The Result to chain from\n * @param fn - Function that returns a new Result\n * @returns The chained Result\n *\n * @example\n * ```ts\n * function parse(input: string): Result<number, string> {\n * const n = parseInt(input, 10);\n * return isNaN(n) ? err('not a number') : ok(n);\n * }\n *\n * function double(n: number): Result<number, string> {\n * return ok(n * 2);\n * }\n *\n * const result = flatMap(parse('5'), double); // ok(10)\n * const fail = flatMap(parse('abc'), double); // err('not a number')\n * ```\n */\nexport function flatMap<T, U, E>(\n result: Result<T, E>,\n fn: (value: T) => Result<U, E>\n): Result<U, E> {\n if (isOk(result)) {\n return fn(result.value);\n }\n return result;\n}\n\n// -----------------------------------------------------------------------------\n// Async Helpers\n// -----------------------------------------------------------------------------\n\n/**\n * Wraps a Promise in a Result type.\n *\n * @param promise - The Promise to wrap\n * @returns A Promise that resolves to a Result\n *\n * @example\n * ```ts\n * const result = await fromPromise(fetch('/api/data'));\n * if (isErr(result)) {\n * console.error('Fetch failed:', result.error);\n * return;\n * }\n * const response = result.value;\n * ```\n */\nexport async function fromPromise<T>(promise: Promise<T>): Promise<Result<T, Error>> {\n try {\n const value = await promise;\n return ok(value);\n } catch (error) {\n return err(error instanceof Error ? error : new Error(String(error)));\n }\n}\n\n/**\n * Wraps a throwing function in a Result type.\n *\n * @param fn - The function to wrap\n * @returns A Result containing the return value or the thrown error\n *\n * @example\n * ```ts\n * const result = tryCatch(() => JSON.parse(input));\n * if (isErr(result)) {\n * console.error('Invalid JSON:', result.error);\n * return null;\n * }\n * return result.value;\n * ```\n */\nexport function tryCatch<T>(fn: () => T): Result<T, Error> {\n try {\n return ok(fn());\n } catch (error) {\n return err(error instanceof Error ? error : new Error(String(error)));\n }\n}\n\n/**\n * Wraps an async function in a Result type.\n *\n * @param fn - The async function to wrap\n * @returns A Promise that resolves to a Result\n *\n * @example\n * ```ts\n * const result = await tryCatchAsync(async () => {\n * const response = await fetch('/api/data');\n * if (!response.ok) throw new Error(`HTTP ${response.status}`);\n * return response.json();\n * });\n * ```\n */\nexport async function tryCatchAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {\n try {\n const value = await fn();\n return ok(value);\n } catch (error) {\n return err(error instanceof Error ? error : new Error(String(error)));\n }\n}\n\n// -----------------------------------------------------------------------------\n// Tuple Helpers (Go-style)\n// -----------------------------------------------------------------------------\n\n/**\n * Go-style tuple for error handling: [error, value]\n *\n * @example\n * ```ts\n * const [err, data] = await handle(fetchData);\n * if (err) {\n * console.error(err);\n * return;\n * }\n * console.log(data);\n * ```\n */\nexport type GoTuple<T, E = Error> = [E, undefined] | [undefined, T];\n\n/**\n * Converts a Result to a Go-style tuple.\n *\n * @param result - The Result to convert\n * @returns A tuple of [error, value]\n *\n * @example\n * ```ts\n * const result = await fetchData();\n * const [err, data] = toTuple(result);\n * ```\n */\nexport function toTuple<T, E>(result: Result<T, E>): GoTuple<T, E> {\n if (isOk(result)) {\n return [undefined, result.value];\n }\n return [result.error, undefined];\n}\n\n/**\n * Wraps an async function and returns a Go-style tuple.\n *\n * @param fn - The async function to wrap\n * @returns A Promise that resolves to [error, value] tuple\n *\n * @example\n * ```ts\n * const [err, data] = await handle(async () => {\n * const res = await fetch('/api/users');\n * if (!res.ok) throw new Error(`HTTP ${res.status}`);\n * return res.json();\n * });\n *\n * if (err) {\n * console.error('Failed to fetch users:', err.message);\n * return [];\n * }\n * return data;\n * ```\n */\nexport async function handle<T>(fn: () => Promise<T>): Promise<GoTuple<T, Error>> {\n try {\n const value = await fn();\n return [undefined, value];\n } catch (error) {\n const err = error instanceof Error ? error : new Error(String(error));\n return [err, undefined];\n }\n}\n\n/**\n * Wraps a synchronous function and returns a Go-style tuple.\n *\n * @param fn - The function to wrap\n * @returns A tuple of [error, value]\n *\n * @example\n * ```ts\n * const [err, parsed] = handleSync(() => JSON.parse(input));\n * if (err) {\n * console.error('Invalid JSON:', err.message);\n * return null;\n * }\n * return parsed;\n * ```\n */\nexport function handleSync<T>(fn: () => T): GoTuple<T, Error> {\n try {\n const value = fn();\n return [undefined, value];\n } catch (error) {\n const err = error instanceof Error ? error : new Error(String(error));\n return [err, undefined];\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../lib/markdown-utils.ts","../../lib/result.ts"],"sourcesContent":["/**\n * Markdown Parsing Utilities\n *\n * Three implementations showing the tradeoffs between simple and more\n * defensive parsing:\n * - v1 (Naive): Direct call, crashes on bad input\n * - v2 (Defensive): Try-catch with null fallback\n * - v3 (Robust): Result type, validation, lightweight sanitization\n *\n * The default export points to the v3 implementation.\n */\n\nimport type { MDTree } from 'md4w';\nimport { mdToJSON } from 'md4w';\n\nimport { err, ok, type Result } from './result';\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\n/**\n * Error codes for markdown parsing failures.\n */\nexport const ParseErrorCode = {\n INVALID_INPUT: 'INVALID_INPUT',\n EMPTY_CONTENT: 'EMPTY_CONTENT',\n PARSE_FAILED: 'PARSE_FAILED',\n CONTENT_TOO_LONG: 'CONTENT_TOO_LONG',\n NESTED_TOO_DEEP: 'NESTED_TOO_DEEP',\n} as const;\n\nexport type ParseErrorCode = (typeof ParseErrorCode)[keyof typeof ParseErrorCode];\n\n/**\n * Structured error for markdown parsing failures.\n */\nexport interface ParseError {\n code: ParseErrorCode;\n message: string;\n cause?: Error;\n}\n\n/**\n * Options for robust markdown parsing.\n */\nexport interface ParseOptions {\n /**\n * Maximum content length in characters.\n * @default 500000 (500KB of text, ~100K words)\n */\n maxLength?: number;\n\n /**\n * Whether to run a lightweight string-based sanitization pass before parsing.\n * @default true\n */\n sanitize?: boolean;\n\n /**\n * Whether to allow empty content.\n * @default false\n */\n allowEmpty?: boolean;\n}\n\n// -----------------------------------------------------------------------------\n// Constants\n// -----------------------------------------------------------------------------\n\nconst DEFAULT_MAX_LENGTH = 500_000; // 500KB of text\n\n/**\n * Patterns that indicate potential XSS attempts in markdown.\n * These are checked in raw content before parsing.\n */\nconst XSS_PATTERNS = [\n /<script\\b/i,\n /javascript:/i,\n /on\\w+\\s*=/i, // onclick=, onerror=, etc.\n /data:/i, // data: URIs can be dangerous\n /vbscript:/i,\n] as const;\n\n// -----------------------------------------------------------------------------\n// V1: Naive Implementation\n// -----------------------------------------------------------------------------\n\n/**\n * Naive markdown parser - crashes on bad input.\n *\n * DO NOT USE IN PRODUCTION. This is for demonstration only.\n *\n * Problems:\n * - No input validation\n * - No error handling\n * - Will crash the entire app on malformed input\n * - No protection against XSS\n * - No content limits\n *\n * @example\n * ```ts\n * // This crashes if content is null, undefined, or malformed\n * const ast = parseMarkdownV1(userInput);\n * ```\n */\nexport function parseMarkdownV1(content: string): MDTree {\n return mdToJSON(content);\n}\n\n// -----------------------------------------------------------------------------\n// V2: Defensive Implementation\n// -----------------------------------------------------------------------------\n\n/**\n * Defensive markdown parser - catches errors but loses context.\n *\n * Better than v1 but still problematic:\n * - Returns null on any error (loses error details)\n * - Caller can't distinguish between empty content and parse failure\n * - Still no XSS protection\n * - Still no content limits\n *\n * @example\n * ```ts\n * const ast = parseMarkdownV2(userInput);\n * if (!ast) {\n * // What went wrong? We don't know.\n * return <ErrorFallback />;\n * }\n * ```\n */\nexport function parseMarkdownV2(content: string): MDTree | null {\n try {\n // Basic null check\n if (content == null) {\n return null;\n }\n return mdToJSON(content);\n } catch {\n return null;\n }\n}\n\n// -----------------------------------------------------------------------------\n// V3: Robust Implementation\n// -----------------------------------------------------------------------------\n\n/**\n * Creates a ParseError with the given code and message.\n */\nfunction createParseError(code: ParseErrorCode, message: string, cause?: Error): ParseError {\n return { code, message, cause };\n}\n\n/**\n * Checks content for potential XSS patterns.\n * Returns the first matched pattern or null if clean.\n */\nfunction detectXssPattern(content: string): RegExp | null {\n for (const pattern of XSS_PATTERNS) {\n if (pattern.test(content)) {\n return pattern;\n }\n }\n return null;\n}\n\n/**\n * Sanitizes content by removing dangerous patterns.\n * This is a lightweight, string-based sanitizer and not a full HTML sanitizer.\n */\nfunction sanitizeContent(content: string): string {\n let sanitized = content;\n\n // Remove script tags\n sanitized = sanitized.replace(/<script\\b[^<]*(?:(?!<\\/script>)<[^<]*)*<\\/script>/gi, '');\n\n // Remove javascript: and vbscript: URLs\n sanitized = sanitized.replace(/javascript:/gi, 'removed:');\n sanitized = sanitized.replace(/vbscript:/gi, 'removed:');\n\n // Remove event handlers (onclick, onerror, etc.)\n sanitized = sanitized.replace(/\\bon\\w+\\s*=\\s*[\"'][^\"']*[\"']/gi, '');\n sanitized = sanitized.replace(/\\bon\\w+\\s*=\\s*\\S+/gi, '');\n\n return sanitized;\n}\n\n/**\n * Robust markdown parser with explicit error handling and lightweight sanitization.\n *\n * Features:\n * - Input validation with specific error codes\n * - Result type for explicit error handling\n * - XSS pattern detection and optional lightweight sanitization\n * - Content length limits\n * - Preserves error context for debugging\n *\n * @param content - Markdown string to parse\n * @param options - Parsing options\n * @returns Result containing the AST or a structured error\n *\n * @example\n * ```ts\n * const result = parseMarkdownV3(userInput);\n *\n * if (!result.ok) {\n * switch (result.error.code) {\n * case 'INVALID_INPUT':\n * return <InvalidInputError message={result.error.message} />;\n * case 'CONTENT_TOO_LONG':\n * return <ContentTooLongError />;\n * case 'PARSE_FAILED':\n * console.error('Parse error:', result.error.cause);\n * return <ParseError />;\n * default:\n * return <GenericError />;\n * }\n * }\n *\n * return <MarkdownRenderer ast={result.value} />;\n * ```\n */\nexport function parseMarkdownV3(\n content: string,\n options: ParseOptions = {}\n): Result<MDTree, ParseError> {\n const { maxLength = DEFAULT_MAX_LENGTH, sanitize = true, allowEmpty = false } = options;\n\n // 1. Validate input type\n if (content === null || content === undefined) {\n return err(\n createParseError(\n ParseErrorCode.INVALID_INPUT,\n `Content must be a string, received ${content === null ? 'null' : 'undefined'}`\n )\n );\n }\n\n if (typeof content !== 'string') {\n return err(\n createParseError(\n ParseErrorCode.INVALID_INPUT,\n `Content must be a string, received ${typeof content}`\n )\n );\n }\n\n // 2. Handle empty content\n const trimmed = content.trim();\n if (trimmed.length === 0 && !allowEmpty) {\n return err(\n createParseError(ParseErrorCode.EMPTY_CONTENT, 'Content is empty or contains only whitespace')\n );\n }\n\n // 3. Check content length\n if (content.length > maxLength) {\n return err(\n createParseError(\n ParseErrorCode.CONTENT_TOO_LONG,\n `Content exceeds maximum length of ${maxLength.toLocaleString()} characters ` +\n `(received ${content.length.toLocaleString()})`\n )\n );\n }\n\n // 4. Check for XSS patterns\n const xssPattern = detectXssPattern(content);\n if (xssPattern) {\n if (process.env.NODE_ENV === 'development') {\n console.warn(\n `[parseMarkdownV3] XSS pattern detected: ${xssPattern.toString()}`,\n sanitize ? 'Content will be sanitized.' : 'Sanitization is disabled!'\n );\n }\n }\n\n // 5. Optionally sanitize content\n const processedContent = sanitize ? sanitizeContent(content) : content;\n\n // 6. Parse markdown with error handling\n try {\n const ast = mdToJSON(processedContent);\n return ok(ast);\n } catch (error) {\n const cause = error instanceof Error ? error : new Error(String(error));\n return err(\n createParseError(\n ParseErrorCode.PARSE_FAILED,\n `Failed to parse markdown: ${cause.message}`,\n cause\n )\n );\n }\n}\n\n// -----------------------------------------------------------------------------\n// Default Export\n// -----------------------------------------------------------------------------\n\n/**\n * Default markdown parser.\n *\n * This is an alias for `parseMarkdownV3`, the most defensive implementation\n * in this module.\n *\n * @example\n * ```ts\n * import { parseMarkdown } from '@/lib/markdown-utils';\n *\n * const result = parseMarkdown(content);\n * if (!result.ok) {\n * // Handle error with full context\n * console.error(`[${result.error.code}] ${result.error.message}`);\n * return null;\n * }\n * return result.value;\n * ```\n */\nexport const parseMarkdown = parseMarkdownV3;\n\n// -----------------------------------------------------------------------------\n// Utility Functions\n// -----------------------------------------------------------------------------\n\n/**\n * Checks if content is safe markdown (no XSS patterns detected).\n *\n * @param content - Markdown string to check\n * @returns true if no XSS patterns detected\n */\nexport function isSafeMarkdown(content: string): boolean {\n return detectXssPattern(content) === null;\n}\n\n/**\n * Estimates the word count of markdown content.\n * Useful for content length limits and reading time estimates.\n *\n * @param content - Markdown string to count\n * @returns Estimated word count\n */\nexport function estimateWordCount(content: string): number {\n // Remove markdown syntax for more accurate count\n const plainText = content\n .replace(/#+\\s/g, '') // Remove headings\n .replace(/```[\\s\\S]*?```/g, '') // Remove fenced code blocks (must precede backtick-stripping)\n .replace(/`[^`]+`/g, '') // Remove inline code spans (must precede backtick-stripping)\n .replace(/!\\[([^\\]]*)\\]\\([^)]+\\)/g, '') // Remove images (must precede link regex)\n .replace(/\\[([^\\]]+)\\]\\([^)]+\\)/g, '$1') // Convert links to text\n .replace(/\\*\\*|__|~~/g, ''); // Remove remaining bold/italic/strikethrough markers\n\n const words = plainText.trim().split(/\\s+/).filter(Boolean);\n return words.length;\n}\n\n/**\n * Estimates reading time for markdown content.\n *\n * @param content - Markdown string\n * @param wordsPerMinute - Reading speed (default 200 WPM)\n * @returns Reading time in minutes\n */\nexport function estimateReadingTime(content: string, wordsPerMinute = 200): number {\n const wordCount = estimateWordCount(content);\n return Math.ceil(wordCount / wordsPerMinute);\n}\n","/**\n * Result Type Utilities\n *\n * Go-style error handling with discriminated union types.\n * Provides type-safe success/error handling without exceptions.\n *\n * @example\n * ```ts\n * const [err, data] = await handle(async () => {\n * const response = await fetch(url);\n * if (!response.ok) throw new Error(`HTTP ${response.status}`);\n * return response.json();\n * });\n *\n * if (err) {\n * console.error('Failed:', err.message);\n * return { error: err.message };\n * }\n * return { data };\n * ```\n */\n\n// -----------------------------------------------------------------------------\n// Result Type\n// -----------------------------------------------------------------------------\n\n/**\n * A discriminated union representing either success or failure.\n *\n * @template T - The success value type\n * @template E - The error type (defaults to Error)\n *\n * @example\n * ```ts\n * function divide(a: number, b: number): Result<number, string> {\n * if (b === 0) return err('Division by zero');\n * return ok(a / b);\n * }\n *\n * const result = divide(10, 2);\n * if (isOk(result)) {\n * console.log('Result:', result.value); // 5\n * } else {\n * console.error('Error:', result.error);\n * }\n * ```\n */\nexport type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };\n\n// -----------------------------------------------------------------------------\n// Constructors\n// -----------------------------------------------------------------------------\n\n/**\n * Creates a success Result.\n *\n * @param value - The success value\n * @returns A success Result containing the value\n *\n * @example\n * ```ts\n * const result = ok(42);\n * // { ok: true, value: 42 }\n * ```\n */\nexport function ok<T>(value: T): Result<T, never> {\n return { ok: true, value };\n}\n\n/**\n * Creates a failure Result.\n *\n * @param error - The error value\n * @returns A failure Result containing the error\n *\n * @example\n * ```ts\n * const result = err(new Error('Something went wrong'));\n * // { ok: false, error: Error('Something went wrong') }\n * ```\n */\nexport function err<E>(error: E): Result<never, E> {\n return { ok: false, error };\n}\n\n// -----------------------------------------------------------------------------\n// Type Guards\n// -----------------------------------------------------------------------------\n\n/**\n * Type guard to check if a Result is successful.\n *\n * @param result - The Result to check\n * @returns true if the Result is a success\n *\n * @example\n * ```ts\n * const result = fetchData();\n * if (isOk(result)) {\n * // TypeScript knows result.value exists here\n * console.log(result.value);\n * }\n * ```\n */\nexport function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {\n return result.ok === true;\n}\n\n/**\n * Type guard to check if a Result is a failure.\n *\n * @param result - The Result to check\n * @returns true if the Result is a failure\n *\n * @example\n * ```ts\n * const result = fetchData();\n * if (isErr(result)) {\n * // TypeScript knows result.error exists here\n * console.error(result.error);\n * }\n * ```\n */\nexport function isErr<T, E>(result: Result<T, E>): result is { ok: false; error: E } {\n return result.ok === false;\n}\n\n// -----------------------------------------------------------------------------\n// Extractors\n// -----------------------------------------------------------------------------\n\n/**\n * Extracts the value from a Result, throwing if it's an error.\n *\n * @param result - The Result to unwrap\n * @returns The success value\n * @throws The error if Result is a failure\n *\n * @example\n * ```ts\n * const result = ok(42);\n * const value = unwrap(result); // 42\n *\n * const errorResult = err(new Error('fail'));\n * const value2 = unwrap(errorResult); // throws Error('fail')\n * ```\n */\nexport function unwrap<T, E>(result: Result<T, E>): T {\n if (isOk(result)) {\n return result.value;\n }\n throw result.error;\n}\n\n/**\n * Extracts the value from a Result, returning a default on error.\n *\n * @param result - The Result to unwrap\n * @param defaultValue - The value to return if Result is an error\n * @returns The success value or the default value\n *\n * @example\n * ```ts\n * const result = err(new Error('fail'));\n * const value = unwrapOr(result, 0); // 0\n *\n * const okResult = ok(42);\n * const value2 = unwrapOr(okResult, 0); // 42\n * ```\n */\nexport function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {\n if (isOk(result)) {\n return result.value;\n }\n return defaultValue;\n}\n\n/**\n * Extracts the value from a Result, computing a default on error.\n *\n * @param result - The Result to unwrap\n * @param fn - Function to compute the default value from the error\n * @returns The success value or the computed default\n *\n * @example\n * ```ts\n * const result = err(new Error('not found'));\n * const value = unwrapOrElse(result, (e) => {\n * console.error('Error:', e.message);\n * return [];\n * });\n * ```\n */\nexport function unwrapOrElse<T, E>(result: Result<T, E>, fn: (error: E) => T): T {\n if (isOk(result)) {\n return result.value;\n }\n return fn(result.error);\n}\n\n// -----------------------------------------------------------------------------\n// Transformers\n// -----------------------------------------------------------------------------\n\n/**\n * Maps a successful Result's value.\n *\n * @param result - The Result to map\n * @param fn - Function to transform the value\n * @returns A new Result with the transformed value, or the original error\n *\n * @example\n * ```ts\n * const result = ok(5);\n * const doubled = map(result, (n) => n * 2); // ok(10)\n *\n * const errorResult = err('fail');\n * const still = map(errorResult, (n) => n * 2); // err('fail')\n * ```\n */\nexport function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {\n if (isOk(result)) {\n return ok(fn(result.value));\n }\n return result;\n}\n\n/**\n * Maps a failed Result's error.\n *\n * @param result - The Result to map\n * @param fn - Function to transform the error\n * @returns A new Result with the transformed error, or the original value\n *\n * @example\n * ```ts\n * const result = err('not found');\n * const mapped = mapErr(result, (e) => new Error(e)); // err(Error('not found'))\n * ```\n */\nexport function mapErr<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {\n if (isErr(result)) {\n return err(fn(result.error));\n }\n return result;\n}\n\n/**\n * Chains Result-returning operations.\n *\n * @param result - The Result to chain from\n * @param fn - Function that returns a new Result\n * @returns The chained Result\n *\n * @example\n * ```ts\n * function parse(input: string): Result<number, string> {\n * const n = parseInt(input, 10);\n * return isNaN(n) ? err('not a number') : ok(n);\n * }\n *\n * function double(n: number): Result<number, string> {\n * return ok(n * 2);\n * }\n *\n * const result = flatMap(parse('5'), double); // ok(10)\n * const fail = flatMap(parse('abc'), double); // err('not a number')\n * ```\n */\nexport function flatMap<T, U, E>(\n result: Result<T, E>,\n fn: (value: T) => Result<U, E>\n): Result<U, E> {\n if (isOk(result)) {\n return fn(result.value);\n }\n return result;\n}\n\n// -----------------------------------------------------------------------------\n// Async Helpers\n// -----------------------------------------------------------------------------\n\n/**\n * Wraps a Promise in a Result type.\n *\n * @param promise - The Promise to wrap\n * @returns A Promise that resolves to a Result\n *\n * @example\n * ```ts\n * const result = await fromPromise(fetch('/api/data'));\n * if (isErr(result)) {\n * console.error('Fetch failed:', result.error);\n * return;\n * }\n * const response = result.value;\n * ```\n */\nexport async function fromPromise<T>(promise: Promise<T>): Promise<Result<T, Error>> {\n try {\n const value = await promise;\n return ok(value);\n } catch (error) {\n return err(error instanceof Error ? error : new Error(String(error)));\n }\n}\n\n/**\n * Wraps a throwing function in a Result type.\n *\n * @param fn - The function to wrap\n * @returns A Result containing the return value or the thrown error\n *\n * @example\n * ```ts\n * const result = tryCatch(() => JSON.parse(input));\n * if (isErr(result)) {\n * console.error('Invalid JSON:', result.error);\n * return null;\n * }\n * return result.value;\n * ```\n */\nexport function tryCatch<T>(fn: () => T): Result<T, Error> {\n try {\n return ok(fn());\n } catch (error) {\n return err(error instanceof Error ? error : new Error(String(error)));\n }\n}\n\n/**\n * Wraps an async function in a Result type.\n *\n * @param fn - The async function to wrap\n * @returns A Promise that resolves to a Result\n *\n * @example\n * ```ts\n * const result = await tryCatchAsync(async () => {\n * const response = await fetch('/api/data');\n * if (!response.ok) throw new Error(`HTTP ${response.status}`);\n * return response.json();\n * });\n * ```\n */\nexport async function tryCatchAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {\n try {\n const value = await fn();\n return ok(value);\n } catch (error) {\n return err(error instanceof Error ? error : new Error(String(error)));\n }\n}\n\n// -----------------------------------------------------------------------------\n// Tuple Helpers (Go-style)\n// -----------------------------------------------------------------------------\n\n/**\n * Go-style tuple for error handling: [error, value]\n *\n * @example\n * ```ts\n * const [err, data] = await handle(fetchData);\n * if (err) {\n * console.error(err);\n * return;\n * }\n * console.log(data);\n * ```\n */\nexport type GoTuple<T, E = Error> = [E, undefined] | [undefined, T];\n\n/**\n * Converts a Result to a Go-style tuple.\n *\n * @param result - The Result to convert\n * @returns A tuple of [error, value]\n *\n * @example\n * ```ts\n * const result = await fetchData();\n * const [err, data] = toTuple(result);\n * ```\n */\nexport function toTuple<T, E>(result: Result<T, E>): GoTuple<T, E> {\n if (isOk(result)) {\n return [undefined, result.value];\n }\n return [result.error, undefined];\n}\n\n/**\n * Wraps an async function and returns a Go-style tuple.\n *\n * @param fn - The async function to wrap\n * @returns A Promise that resolves to [error, value] tuple\n *\n * @example\n * ```ts\n * const [err, data] = await handle(async () => {\n * const res = await fetch('/api/users');\n * if (!res.ok) throw new Error(`HTTP ${res.status}`);\n * return res.json();\n * });\n *\n * if (err) {\n * console.error('Failed to fetch users:', err.message);\n * return [];\n * }\n * return data;\n * ```\n */\nexport async function handle<T>(fn: () => Promise<T>): Promise<GoTuple<T, Error>> {\n try {\n const value = await fn();\n return [undefined, value];\n } catch (error) {\n const err = error instanceof Error ? error : new Error(String(error));\n return [err, undefined];\n }\n}\n\n/**\n * Wraps a synchronous function and returns a Go-style tuple.\n *\n * @param fn - The function to wrap\n * @returns A tuple of [error, value]\n *\n * @example\n * ```ts\n * const [err, parsed] = handleSync(() => JSON.parse(input));\n * if (err) {\n * console.error('Invalid JSON:', err.message);\n * return null;\n * }\n * return parsed;\n * ```\n */\nexport function handleSync<T>(fn: () => T): GoTuple<T, Error> {\n try {\n const value = fn();\n return [undefined, value];\n } catch (error) {\n const err = error instanceof Error ? error : new Error(String(error));\n return [err, undefined];\n }\n}\n"],"mappings":"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","names":[]}
package/dist/lib/proxy.js CHANGED
@@ -15,18 +15,24 @@ async function proxyToUpstream(request, pathname, upstream) {
15
15
  const upstreamUrl = new URL(pathname, upstream);
16
16
  upstreamUrl.search = request.nextUrl.search;
17
17
  const headers = new Headers(request.headers);
18
- headers.set("x-forwarded-host", request.headers.get("host") ?? "");
18
+ headers.set("x-forwarded-host", request.headers.get("host") ?? request.nextUrl.host);
19
19
  headers.set("x-forwarded-proto", request.nextUrl.protocol.replace(":", ""));
20
20
  headers.set("x-forwarded-for", request.headers.get("x-forwarded-for") ?? "");
21
- const response = await fetch(upstreamUrl.toString(), {
22
- method: request.method,
23
- headers,
24
- body: request.body,
25
- // @ts-expect-error - duplex is required for streaming bodies
26
- duplex: "half",
27
- redirect: "manual"
28
- // Don't follow redirects, let the client handle them
29
- });
21
+ let response;
22
+ try {
23
+ response = await fetch(upstreamUrl.toString(), {
24
+ method: request.method,
25
+ headers,
26
+ body: request.body,
27
+ // @ts-expect-error - duplex is required for streaming bodies
28
+ duplex: "half",
29
+ redirect: "manual"
30
+ // Don't follow redirects, let the client handle them
31
+ });
32
+ } catch (err) {
33
+ console.error("[cms-proxy] upstream unreachable", upstreamUrl.toString(), err);
34
+ return new NextResponse("Upstream unavailable", { status: 503 });
35
+ }
30
36
  const responseHeaders = new Headers();
31
37
  const upstreamUrlObj = new URL(upstream);
32
38
  const upstreamOrigin = upstreamUrlObj.origin;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../lib/proxy.ts"],"sourcesContent":["import { type NextRequest, NextResponse } from 'next/server';\n\n/**\n * Configuration options for the CMS proxy.\n */\nexport interface ProxyConfig {\n /**\n * The upstream CMS server URL (e.g., 'https://cms.example.com').\n * Defaults to ADMIN_UPSTREAM_ORIGIN environment variable.\n */\n upstream?: string;\n /**\n * Additional path prefixes to proxy (beyond /admin, /api, /auth).\n */\n additionalPaths?: string[];\n}\n\n// Static file extensions to proxy to upstream\nconst STATIC_FILE_REGEX =\n /\\.(css|js|map|png|jpg|jpeg|gif|svg|ico|webp|avif|woff|woff2|ttf|eot|txt|xml)$/;\n\n/**\n * Check if the request originates from an admin page (via Referer header).\n */\nfunction isFromAdminPage(request: NextRequest): boolean {\n const referer = request.headers.get('referer');\n if (!referer) return false;\n\n try {\n const refererUrl = new URL(referer);\n return refererUrl.pathname.startsWith('/admin');\n } catch {\n return false;\n }\n}\n\n/**\n * Proxy a request to the upstream CMS server with proper cookie handling.\n */\nasync function proxyToUpstream(\n request: NextRequest,\n pathname: string,\n upstream: string\n): Promise<NextResponse> {\n const upstreamUrl = new URL(pathname, upstream);\n upstreamUrl.search = request.nextUrl.search;\n\n // Clone all headers from the request\n const headers = new Headers(request.headers);\n\n // Keep the original host header so the upstream app knows the real origin\n // This is important for auth redirects (WorkOS) to use the correct domain\n // The x-forwarded-* headers provide additional context\n headers.set('x-forwarded-host', request.headers.get('host') ?? '');\n headers.set('x-forwarded-proto', request.nextUrl.protocol.replace(':', ''));\n headers.set('x-forwarded-for', request.headers.get('x-forwarded-for') ?? '');\n\n const response = await fetch(upstreamUrl.toString(), {\n method: request.method,\n headers,\n body: request.body,\n // @ts-expect-error - duplex is required for streaming bodies\n duplex: 'half',\n redirect: 'manual', // Don't follow redirects, let the client handle them\n });\n\n // Create response with proper header handling\n const responseHeaders = new Headers();\n\n const upstreamUrlObj = new URL(upstream);\n const upstreamOrigin = upstreamUrlObj.origin;\n const currentOrigin = request.nextUrl.origin;\n\n // Copy headers from upstream response\n response.headers.forEach((value, key) => {\n const lowerKey = key.toLowerCase();\n\n // Handle Set-Cookie specially - rewrite domain to current host\n if (lowerKey === 'set-cookie') {\n let modifiedCookie = value;\n\n // Remove Domain attribute so cookie defaults to current host\n modifiedCookie = modifiedCookie.replace(/;\\s*Domain=[^;]*/gi, '');\n\n // Ensure Path is set (usually /admin or /)\n if (!/;\\s*Path=/i.test(modifiedCookie)) {\n modifiedCookie += '; Path=/';\n }\n\n // For secure cookies in production, ensure SameSite is appropriate\n if (!/;\\s*SameSite=/i.test(modifiedCookie)) {\n modifiedCookie += '; SameSite=Lax';\n }\n\n responseHeaders.append(key, modifiedCookie);\n }\n // Handle Location header - rewrite upstream URLs to current host\n else if (lowerKey === 'location') {\n try {\n // Parse the location (handles both absolute and relative URLs)\n const locationUrl = new URL(value, upstream);\n\n // If redirect points to upstream, rewrite to current origin\n if (locationUrl.origin === upstreamOrigin) {\n const newLocation = `${currentOrigin}${locationUrl.pathname}${locationUrl.search}`;\n responseHeaders.set(key, newLocation);\n } else {\n // External redirect (e.g., to WorkOS) — pass through unchanged.\n // The CMS encodes the frontend origin in the OAuth state parameter and\n // uses a handoff token to set the session cookie on the correct domain\n // after the WorkOS callback. Rewriting redirect_uri here would point\n // WorkOS at an unregistered per-deployment URL.\n responseHeaders.set(key, value);\n }\n } catch {\n // If URL parsing fails, keep original\n responseHeaders.set(key, value);\n }\n }\n // Skip headers that cause issues after fetch decompresses the body\n else if (\n lowerKey !== 'transfer-encoding' &&\n lowerKey !== 'content-encoding' &&\n lowerKey !== 'content-length'\n ) {\n responseHeaders.set(key, value);\n }\n });\n\n // Add debug header to verify middleware is running\n responseHeaders.set('x-proxied-by', 'cms-proxy');\n\n // For HTML responses, rewrite upstream URLs in the body\n const contentType = response.headers.get('content-type') ?? '';\n if (contentType.includes('text/html') && response.body) {\n let text = await response.text();\n\n // Get the upstream host for more comprehensive replacement\n const upstreamHost = upstreamUrlObj.host;\n\n // Replace full origin (https://cms.example.com)\n text = text.replaceAll(upstreamOrigin, currentOrigin);\n\n // Replace protocol-relative URLs (//cms.example.com)\n text = text.replaceAll(`//${upstreamHost}`, `//${request.nextUrl.host}`);\n\n // Replace any remaining absolute URLs with the upstream host\n // This catches cases where protocol might differ\n text = text.replaceAll(`https://${upstreamHost}`, currentOrigin);\n text = text.replaceAll(`http://${upstreamHost}`, currentOrigin);\n\n return new NextResponse(text, {\n status: response.status,\n statusText: response.statusText,\n headers: responseHeaders,\n });\n }\n\n return new NextResponse(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers: responseHeaders,\n });\n}\n\n/**\n * Creates a proxy middleware function for Next.js.\n *\n * @example\n * ```ts\n * // middleware.ts\n * import { createCmsProxy } from 'cms-renderer/lib/proxy';\n *\n * const cmsProxy = createCmsProxy({\n * upstream: process.env.ADMIN_UPSTREAM_ORIGIN,\n * });\n *\n * export async function middleware(request: NextRequest) {\n * return cmsProxy(request);\n * }\n *\n * export const config = {\n * matcher: cmsProxyMatcher,\n * };\n * ```\n */\nexport function createCmsProxy(config: ProxyConfig = {}) {\n const upstream = (config.upstream ?? process.env.ADMIN_UPSTREAM_ORIGIN ?? '').replace(/\\/$/, '');\n const additionalPaths = config.additionalPaths ?? [];\n\n if (!upstream) {\n console.warn(\n '[cms-proxy] No upstream URL configured. Set ADMIN_UPSTREAM_ORIGIN or pass upstream option.'\n );\n }\n\n return async function cmsProxy(request: NextRequest): Promise<NextResponse> {\n if (!upstream) {\n return NextResponse.next();\n }\n\n const { pathname } = request.nextUrl;\n\n // Proxy /admin routes to the upstream CMS\n if (pathname.startsWith('/admin')) {\n return proxyToUpstream(request, pathname, upstream);\n }\n\n // Proxy /api routes to the upstream CMS\n if (pathname.startsWith('/api')) {\n return proxyToUpstream(request, pathname, upstream);\n }\n\n // Proxy auth routes to the upstream CMS (WorkOS callbacks, signin, etc.)\n if (pathname.startsWith('/auth')) {\n return proxyToUpstream(request, pathname, upstream);\n }\n\n // Proxy additional custom paths\n for (const pathPrefix of additionalPaths) {\n if (pathname.startsWith(pathPrefix)) {\n return proxyToUpstream(request, pathname, upstream);\n }\n }\n\n // Only proxy /_next and static files if the request comes from an admin page\n // This prevents breaking the web app's own assets\n if (isFromAdminPage(request)) {\n if (pathname.startsWith('/_next') || STATIC_FILE_REGEX.test(pathname)) {\n return proxyToUpstream(request, pathname, upstream);\n }\n }\n\n return NextResponse.next();\n };\n}\n\n/**\n * Default matcher configuration for the CMS proxy middleware.\n * Use this in your middleware.ts config export.\n *\n * @example\n * ```ts\n * export const config = {\n * matcher: cmsProxyMatcher,\n * };\n * ```\n */\nexport const cmsProxyMatcher = [\n '/admin',\n '/admin/:path*',\n '/api/:path*',\n '/auth/:path*',\n '/_next/:path*',\n '/((?:.*\\\\.(?:css|js|map|png|jpg|jpeg|gif|svg|ico|webp|avif|woff|woff2|ttf|eot|txt|xml))$)',\n];\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../lib/proxy.ts"],"sourcesContent":["import { type NextRequest, NextResponse } from 'next/server';\n\n/**\n * Configuration options for the CMS proxy.\n */\nexport interface ProxyConfig {\n /**\n * The upstream CMS server URL (e.g., 'https://cms.example.com').\n * Defaults to ADMIN_UPSTREAM_ORIGIN environment variable.\n */\n upstream?: string;\n /**\n * Additional path prefixes to proxy (beyond /admin, /api, /auth).\n */\n additionalPaths?: string[];\n}\n\n// Static file extensions to proxy to upstream\nconst STATIC_FILE_REGEX =\n /\\.(css|js|map|png|jpg|jpeg|gif|svg|ico|webp|avif|woff|woff2|ttf|eot|txt|xml)$/;\n\n/**\n * Check if the request originates from an admin page (via Referer header).\n */\nfunction isFromAdminPage(request: NextRequest): boolean {\n const referer = request.headers.get('referer');\n if (!referer) return false;\n\n try {\n const refererUrl = new URL(referer);\n return refererUrl.pathname.startsWith('/admin');\n } catch {\n return false;\n }\n}\n\n/**\n * Proxy a request to the upstream CMS server with proper cookie handling.\n */\nasync function proxyToUpstream(\n request: NextRequest,\n pathname: string,\n upstream: string\n): Promise<NextResponse> {\n const upstreamUrl = new URL(pathname, upstream);\n upstreamUrl.search = request.nextUrl.search;\n\n // Clone all headers from the request\n const headers = new Headers(request.headers);\n\n // Keep the original host header so the upstream app knows the real origin\n // This is important for auth redirects (WorkOS) to use the correct domain\n // The x-forwarded-* headers provide additional context\n // Fall back to nextUrl.host since the Fetch API treats 'host' as a\n // forbidden request header and NextRequest may not expose it directly.\n headers.set('x-forwarded-host', request.headers.get('host') ?? request.nextUrl.host);\n headers.set('x-forwarded-proto', request.nextUrl.protocol.replace(':', ''));\n headers.set('x-forwarded-for', request.headers.get('x-forwarded-for') ?? '');\n\n let response: Response;\n try {\n response = await fetch(upstreamUrl.toString(), {\n method: request.method,\n headers,\n body: request.body,\n // @ts-expect-error - duplex is required for streaming bodies\n duplex: 'half',\n redirect: 'manual', // Don't follow redirects, let the client handle them\n });\n } catch (err) {\n // Upstream is unreachable (connection refused, DNS failure, timeout, etc.).\n // Return a 503 so the caller receives an explicit signal instead of an\n // unhandled exception — prevents cross-tenant information leakage and keeps\n // the Next.js runtime stable.\n console.error('[cms-proxy] upstream unreachable', upstreamUrl.toString(), err);\n return new NextResponse('Upstream unavailable', { status: 503 });\n }\n\n // Create response with proper header handling\n const responseHeaders = new Headers();\n\n const upstreamUrlObj = new URL(upstream);\n const upstreamOrigin = upstreamUrlObj.origin;\n const currentOrigin = request.nextUrl.origin;\n\n // Copy headers from upstream response\n response.headers.forEach((value, key) => {\n const lowerKey = key.toLowerCase();\n\n // Handle Set-Cookie specially - rewrite domain to current host\n if (lowerKey === 'set-cookie') {\n let modifiedCookie = value;\n\n // Remove Domain attribute so cookie defaults to current host\n modifiedCookie = modifiedCookie.replace(/;\\s*Domain=[^;]*/gi, '');\n\n // Ensure Path is set (usually /admin or /)\n if (!/;\\s*Path=/i.test(modifiedCookie)) {\n modifiedCookie += '; Path=/';\n }\n\n // For secure cookies in production, ensure SameSite is appropriate\n if (!/;\\s*SameSite=/i.test(modifiedCookie)) {\n modifiedCookie += '; SameSite=Lax';\n }\n\n responseHeaders.append(key, modifiedCookie);\n }\n // Handle Location header - rewrite upstream URLs to current host\n else if (lowerKey === 'location') {\n try {\n // Parse the location (handles both absolute and relative URLs)\n const locationUrl = new URL(value, upstream);\n\n // If redirect points to upstream, rewrite to current origin\n if (locationUrl.origin === upstreamOrigin) {\n const newLocation = `${currentOrigin}${locationUrl.pathname}${locationUrl.search}`;\n responseHeaders.set(key, newLocation);\n } else {\n // External redirect (e.g., to WorkOS) — pass through unchanged.\n // The CMS encodes the frontend origin in the OAuth state parameter and\n // uses a handoff token to set the session cookie on the correct domain\n // after the WorkOS callback. Rewriting redirect_uri here would point\n // WorkOS at an unregistered per-deployment URL.\n responseHeaders.set(key, value);\n }\n } catch {\n // If URL parsing fails, keep original\n responseHeaders.set(key, value);\n }\n }\n // Skip headers that cause issues after fetch decompresses the body\n else if (\n lowerKey !== 'transfer-encoding' &&\n lowerKey !== 'content-encoding' &&\n lowerKey !== 'content-length'\n ) {\n responseHeaders.set(key, value);\n }\n });\n\n // Add debug header to verify middleware is running\n responseHeaders.set('x-proxied-by', 'cms-proxy');\n\n // For HTML responses, rewrite upstream URLs in the body\n const contentType = response.headers.get('content-type') ?? '';\n if (contentType.includes('text/html') && response.body) {\n let text = await response.text();\n\n // Get the upstream host for more comprehensive replacement\n const upstreamHost = upstreamUrlObj.host;\n\n // Replace full origin (https://cms.example.com)\n text = text.replaceAll(upstreamOrigin, currentOrigin);\n\n // Replace protocol-relative URLs (//cms.example.com)\n text = text.replaceAll(`//${upstreamHost}`, `//${request.nextUrl.host}`);\n\n // Replace any remaining absolute URLs with the upstream host\n // This catches cases where protocol might differ\n text = text.replaceAll(`https://${upstreamHost}`, currentOrigin);\n text = text.replaceAll(`http://${upstreamHost}`, currentOrigin);\n\n return new NextResponse(text, {\n status: response.status,\n statusText: response.statusText,\n headers: responseHeaders,\n });\n }\n\n return new NextResponse(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers: responseHeaders,\n });\n}\n\n/**\n * Creates a proxy middleware function for Next.js.\n *\n * @example\n * ```ts\n * // middleware.ts\n * import { createCmsProxy } from 'cms-renderer/lib/proxy';\n *\n * const cmsProxy = createCmsProxy({\n * upstream: process.env.ADMIN_UPSTREAM_ORIGIN,\n * });\n *\n * export async function middleware(request: NextRequest) {\n * return cmsProxy(request);\n * }\n *\n * export const config = {\n * matcher: cmsProxyMatcher,\n * };\n * ```\n */\nexport function createCmsProxy(config: ProxyConfig = {}) {\n const upstream = (config.upstream ?? process.env.ADMIN_UPSTREAM_ORIGIN ?? '').replace(/\\/$/, '');\n const additionalPaths = config.additionalPaths ?? [];\n\n if (!upstream) {\n console.warn(\n '[cms-proxy] No upstream URL configured. Set ADMIN_UPSTREAM_ORIGIN or pass upstream option.'\n );\n }\n\n return async function cmsProxy(request: NextRequest): Promise<NextResponse> {\n if (!upstream) {\n return NextResponse.next();\n }\n\n const { pathname } = request.nextUrl;\n\n // Proxy /admin routes to the upstream CMS\n if (pathname.startsWith('/admin')) {\n return proxyToUpstream(request, pathname, upstream);\n }\n\n // Proxy /api routes to the upstream CMS\n if (pathname.startsWith('/api')) {\n return proxyToUpstream(request, pathname, upstream);\n }\n\n // Proxy auth routes to the upstream CMS (WorkOS callbacks, signin, etc.)\n if (pathname.startsWith('/auth')) {\n return proxyToUpstream(request, pathname, upstream);\n }\n\n // Proxy additional custom paths\n for (const pathPrefix of additionalPaths) {\n if (pathname.startsWith(pathPrefix)) {\n return proxyToUpstream(request, pathname, upstream);\n }\n }\n\n // Only proxy /_next and static files if the request comes from an admin page\n // This prevents breaking the web app's own assets\n if (isFromAdminPage(request)) {\n if (pathname.startsWith('/_next') || STATIC_FILE_REGEX.test(pathname)) {\n return proxyToUpstream(request, pathname, upstream);\n }\n }\n\n return NextResponse.next();\n };\n}\n\n/**\n * Default matcher configuration for the CMS proxy middleware.\n * Use this in your middleware.ts config export.\n *\n * @example\n * ```ts\n * export const config = {\n * matcher: cmsProxyMatcher,\n * };\n * ```\n */\nexport const cmsProxyMatcher = [\n '/admin',\n '/admin/:path*',\n '/api/:path*',\n '/auth/:path*',\n '/_next/:path*',\n '/((?:.*\\\\.(?:css|js|map|png|jpg|jpeg|gif|svg|ico|webp|avif|woff|woff2|ttf|eot|txt|xml))$)',\n];\n"],"mappings":"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","names":[]}
@@ -309,6 +309,36 @@ function extractContentValues(content, basePath = []) {
309
309
  walk(content, basePath);
310
310
  return map;
311
311
  }
312
+ function pathMatchesPattern(path, pattern) {
313
+ const pathSegs = path.split("/").filter(Boolean);
314
+ const patternSegs = pattern.split("/").filter(Boolean);
315
+ if (pathSegs.length !== patternSegs.length) return false;
316
+ for (let i = 0; i < patternSegs.length; i++) {
317
+ const seg = patternSegs[i];
318
+ if (!seg) return false;
319
+ if (seg.startsWith("{") && seg.endsWith("}") || seg.startsWith("(") && seg.endsWith(")")) {
320
+ continue;
321
+ }
322
+ if (seg !== pathSegs[i]) return false;
323
+ }
324
+ return true;
325
+ }
326
+ function resolveComponent(registry, blockType, path) {
327
+ if (path) {
328
+ for (const key of Object.keys(registry)) {
329
+ if (!key.startsWith("/")) continue;
330
+ const spaceIdx = key.indexOf(" ");
331
+ if (spaceIdx === -1) continue;
332
+ const pathPattern = key.slice(0, spaceIdx);
333
+ const registeredType = key.slice(spaceIdx + 1);
334
+ if (registeredType !== blockType) continue;
335
+ if (pathMatchesPattern(path, pathPattern)) {
336
+ return registry[key];
337
+ }
338
+ }
339
+ }
340
+ return registry[blockType];
341
+ }
312
342
  function renderToWalkableTree(node, keyPrefix = "") {
313
343
  if (node == null || typeof node === "boolean") return node;
314
344
  if (typeof node === "string" || typeof node === "number") return node;
@@ -345,9 +375,10 @@ function BlockRenderer({
345
375
  block,
346
376
  registry,
347
377
  disableEditable,
348
- routeParams
378
+ routeParams,
379
+ path
349
380
  }) {
350
- const Component = registry[block.type];
381
+ const Component = resolveComponent(registry, block.type, path);
351
382
  if (!Component) {
352
383
  if (process.env.NODE_ENV === "development") {
353
384
  console.warn(`[BlockRenderer] Unknown block type: ${block.type}`);
@@ -367,13 +398,13 @@ function BlockRenderer({
367
398
  if (isWalkable) {
368
399
  const usedPaths = /* @__PURE__ */ new Set();
369
400
  renderedComponent = walkReactNode(renderedTree, {
370
- onText: ({ value, key, path }) => {
401
+ onText: ({ value, key, path: path2 }) => {
371
402
  const matches = contentValueMap.get(value);
372
403
  if (!matches || matches.length === 0) return value;
373
404
  const match = matches.find((m) => !usedPaths.has(m.contentPath)) ?? matches[0];
374
405
  if (!match) return value;
375
406
  usedPaths.add(match.contentPath);
376
- const spanKey = key ?? `${block.id}-${match.contentPath}-${path.join("-")}`;
407
+ const spanKey = key ?? `${block.id}-${match.contentPath}-${path2.join("-")}`;
377
408
  return /* @__PURE__ */ jsx(
378
409
  "span",
379
410
  {
@@ -615,7 +646,10 @@ async function ParametricRoutePage({
615
646
  const path = normalizePath(rawPath);
616
647
  const client = getCmsClient({ apiKey, cmsUrl });
617
648
  try {
618
- const { route, resolvedParams } = await client.route.getByPath.query({ websiteId, path });
649
+ const { route, resolvedParams } = await client.route.getByPath.query({
650
+ websiteId,
651
+ path
652
+ });
619
653
  if (route.state !== "Live") {
620
654
  console.error(`Route found but not Live. Path: ${path}, State: ${route.state}`);
621
655
  notFound();
@@ -693,7 +727,8 @@ async function ParametricRoutePage({
693
727
  block,
694
728
  registry: registry ?? {},
695
729
  disableEditable: !editMode,
696
- routeParams
730
+ routeParams,
731
+ path
697
732
  },
698
733
  block.id
699
734
  )) });