From e671e99f55f345f38ec0bd5efabdba3b6bfcc17e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 07:19:55 +0000 Subject: [PATCH 1/4] Initial plan From 8ff6827c5ade348b7659d430e0e3d45ba3c005a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 07:41:39 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20add=20@ottabase/youch=20package=20?= =?UTF-8?q?=E2=80=94=20edge-compatible=20pretty=20error=20pages=20(inspire?= =?UTF-8?q?d=20by=20poppinss/youch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- .../cloudflare-worker.ts | 17 + .../package.json | 1 + packages/youch/.gitignore | 2 + packages/youch/README.md | 117 ++++ packages/youch/package.json | 52 ++ packages/youch/src/__tests__/parser.test.ts | 111 +++ packages/youch/src/__tests__/renderer.test.ts | 168 +++++ packages/youch/src/__tests__/youch.test.ts | 155 +++++ packages/youch/src/index.ts | 140 ++++ packages/youch/src/parser.ts | 164 +++++ packages/youch/src/renderer.ts | 643 ++++++++++++++++++ packages/youch/src/types.ts | 62 ++ packages/youch/tsconfig.json | 9 + packages/youch/tsup.config.ts | 8 + packages/youch/vitest.config.ts | 18 + pnpm-lock.yaml | 23 +- 16 files changed, 1689 insertions(+), 1 deletion(-) create mode 100644 packages/youch/.gitignore create mode 100644 packages/youch/README.md create mode 100644 packages/youch/package.json create mode 100644 packages/youch/src/__tests__/parser.test.ts create mode 100644 packages/youch/src/__tests__/renderer.test.ts create mode 100644 packages/youch/src/__tests__/youch.test.ts create mode 100644 packages/youch/src/index.ts create mode 100644 packages/youch/src/parser.ts create mode 100644 packages/youch/src/renderer.ts create mode 100644 packages/youch/src/types.ts create mode 100644 packages/youch/tsconfig.json create mode 100644 packages/youch/tsup.config.ts create mode 100644 packages/youch/vitest.config.ts diff --git a/apps/ottabase-template-app-tanstack/cloudflare-worker.ts b/apps/ottabase-template-app-tanstack/cloudflare-worker.ts index cb205b13d..a2f824769 100644 --- a/apps/ottabase-template-app-tanstack/cloudflare-worker.ts +++ b/apps/ottabase-template-app-tanstack/cloudflare-worker.ts @@ -1,5 +1,6 @@ import { RealtimeActor } from '@ottabase/cf-realtime/server'; import { errorResponse, ServiceError } from '@ottabase/utils/http-errors'; +import { Youch } from '@ottabase/youch'; import type { CloudflareEnv } from './cloudflare-env'; import { queueHandler } from './ottabase/queue'; import { handleBootstrapRoute, interceptIfNotReady, resolvePlatformState } from './worker/bootstrap'; @@ -140,6 +141,22 @@ export default { } catch (err) { console.error('Worker unhandled error:', err); + // In dev mode, return a pretty HTML error page via Youch + const isDev = + !(env as Record).ENVIRONMENT || + (env as Record).ENVIRONMENT === 'development' || + (env as Record).ENVIRONMENT === 'dev'; + + if (isDev && isHtmlRequest(request)) { + const youch = new Youch(); + youch.addRequestMetadata(request); + const html = youch.toHTML(err, { title: 'Worker Error' }); + return new Response(html, { + status: err instanceof ServiceError ? err.status : 500, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + if (err instanceof ServiceError) { return errorResponse(err.message, err.status, err.toApiResponse()); } diff --git a/apps/ottabase-template-app-tanstack/package.json b/apps/ottabase-template-app-tanstack/package.json index 2e3ea254e..f7d686f9e 100644 --- a/apps/ottabase-template-app-tanstack/package.json +++ b/apps/ottabase-template-app-tanstack/package.json @@ -61,6 +61,7 @@ "@ottabase/ui-shadcn": "workspace:*", "@ottabase/ui-split-pane": "workspace:*", "@ottabase/utils": "workspace:*", + "@ottabase/youch": "workspace:*", "@tabler/icons-react": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", diff --git a/packages/youch/.gitignore b/packages/youch/.gitignore new file mode 100644 index 000000000..9f6d627ea --- /dev/null +++ b/packages/youch/.gitignore @@ -0,0 +1,2 @@ +dist/ +.turbo/ diff --git a/packages/youch/README.md b/packages/youch/README.md new file mode 100644 index 000000000..51167db54 --- /dev/null +++ b/packages/youch/README.md @@ -0,0 +1,117 @@ +# @ottabase/youch + +Pretty print JavaScript errors as self-contained HTML pages — edge-runtime compatible. + +Inspired by [poppinss/youch](https://github.com/poppinss/youch), built for Cloudflare Workers and other edge runtimes +where Node.js `fs` is unavailable. + +## Usage + +```ts +import { Youch } from '@ottabase/youch'; + +try { + await handleRequest(); +} catch (error) { + const youch = new Youch(); + youch.addRequestMetadata(request); + + const html = youch.toHTML(error, { title: 'Worker Error' }); + return new Response(html, { + status: 500, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); +} +``` + +## API + +### `new Youch()` + +Create a new Youch instance. + +### `youch.toHTML(error, options?)` + +Render an error to a self-contained HTML page with: + +- Error type badge and message +- Expandable stack trace frames (app frames highlighted) +- Raw JSON error view +- Error cause chain +- Metadata sections +- Dark/light theme toggle +- "Open in editor" links + +**Options:** + +| Option | Type | Default | Description | +| ---------- | -------- | ------------------------- | -------------------------------------- | +| `title` | `string` | `"An error has occurred"` | Page title / subtitle | +| `ide` | `string` | `"vscode"` | Code editor for file links | +| `offset` | `number` | `0` | Stack frames to skip | +| `cspNonce` | `string` | — | CSP nonce for inline style/script tags | + +Supported editors: `vscode`, `sublime`, `atom`, `phpstorm`, `textmate`, `emacs`, `macvim`, or a custom URL template with +`%f`, `%l`, `%c` placeholders. + +### `youch.group(name, sections)` + +Add metadata sections (e.g., request info, environment). + +```ts +youch.group('Request', { + info: [ + { key: 'Method', value: 'POST' }, + { key: 'URL', value: '/api/users' }, + ], + headers: [ + { key: 'content-type', value: 'application/json' }, + { key: 'user-agent', value: request.headers.get('user-agent') }, + ], +}); +``` + +### `youch.addRequestMetadata(request)` + +Automatically extract method, URL, and common headers from a `Request` object. Sensitive headers (`authorization`, +`cookie`) are automatically masked. + +### `youch.parse(error, offset?)` + +Parse an error into a structured `ParsedError` object without rendering HTML. + +### `parseError(error, offset?)` + +Standalone function to parse errors into structured objects. + +### `renderHTML(parsedError, metadata?, options?)` + +Standalone function to render a `ParsedError` to HTML. + +## Integration with Cloudflare Worker + +```ts +import { Youch } from '@ottabase/youch'; + +export default { + async fetch(request: Request, env: Env): Promise { + try { + return await handleRequest(request, env); + } catch (err) { + const isDev = env.ENVIRONMENT === 'development'; + + if (isDev) { + const youch = new Youch(); + youch.addRequestMetadata(request); + const html = youch.toHTML(err, { title: 'Worker Error' }); + return new Response(html, { + status: 500, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + + return new Response('Internal Server Error', { status: 500 }); + } + }, +}; +``` diff --git a/packages/youch/package.json b/packages/youch/package.json new file mode 100644 index 000000000..e2ed8ed98 --- /dev/null +++ b/packages/youch/package.json @@ -0,0 +1,52 @@ +{ + "name": "@ottabase/youch", + "version": "0.0.1", + "description": "Pretty print JavaScript errors as HTML — edge-runtime compatible, inspired by poppinss/youch", + "author": "Ottabase", + "license": "MIT", + "type": "module", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "README.md" + ], + "keywords": [ + "error", + "youch", + "pretty-error", + "error-page", + "edge-runtime", + "cloudflare-workers", + "ottabase" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src --ext .ts", + "type-check": "tsc --noEmit", + "test": "vitest", + "test:coverage": "vitest --coverage", + "clean": "rimraf dist" + }, + "devDependencies": { + "@types/node": "catalog:", + "eslint": "catalog:", + "rimraf": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/youch/src/__tests__/parser.test.ts b/packages/youch/src/__tests__/parser.test.ts new file mode 100644 index 000000000..6a046ed3d --- /dev/null +++ b/packages/youch/src/__tests__/parser.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import { parseError } from '../parser'; + +describe('parseError', () => { + it('should parse a standard Error', () => { + const error = new Error('Test error message'); + const parsed = parseError(error); + + expect(parsed.type).toBe('Error'); + expect(parsed.message).toBe('Test error message'); + expect(parsed.frames.length).toBeGreaterThan(0); + expect(parsed.rawStack).toBeDefined(); + expect(parsed.cause).toBeUndefined(); + }); + + it('should parse a TypeError', () => { + const error = new TypeError('Cannot read property'); + const parsed = parseError(error); + + expect(parsed.type).toBe('TypeError'); + expect(parsed.message).toBe('Cannot read property'); + }); + + it('should parse error with cause', () => { + const cause = new Error('Root cause'); + const error = new Error('Wrapper error', { cause }); + const parsed = parseError(error); + + expect(parsed.cause).toBeDefined(); + expect(parsed.cause!.type).toBe('Error'); + expect(parsed.cause!.message).toBe('Root cause'); + }); + + it('should handle non-Error values (string)', () => { + const parsed = parseError('something went wrong'); + + expect(parsed.type).toBe('string'); + expect(parsed.message).toBe('something went wrong'); + expect(parsed.frames).toHaveLength(0); + }); + + it('should handle non-Error values (number)', () => { + const parsed = parseError(42); + + expect(parsed.type).toBe('number'); + expect(parsed.message).toBe('42'); + }); + + it('should handle non-Error values (object)', () => { + const parsed = parseError({ code: 'FAIL', detail: 'bad request' }); + + expect(parsed.type).toBe('Object'); + expect(parsed.properties).toHaveProperty('code', 'FAIL'); + }); + + it('should handle null/undefined', () => { + expect(parseError(null).message).toBe('null'); + expect(parseError(undefined).message).toBe('undefined'); + }); + + it('should apply offset to skip frames', () => { + const error = new Error('Test'); + const allFrames = parseError(error, 0); + const offsetFrames = parseError(error, 2); + + expect(offsetFrames.frames.length).toBe(Math.max(0, allFrames.frames.length - 2)); + }); + + it('should extract extra properties from error objects', () => { + const error = new Error('Custom error'); + (error as Record).code = 'CUSTOM_CODE'; + (error as Record).status = 422; + + const parsed = parseError(error); + + expect(parsed.properties).toHaveProperty('code', 'CUSTOM_CODE'); + expect(parsed.properties).toHaveProperty('status', 422); + }); + + it('should mark node_modules frames as non-app', () => { + const error = new Error('Test'); + const parsed = parseError(error); + + for (const frame of parsed.frames) { + if (frame.file?.includes('node_modules')) { + expect(frame.isApp).toBe(false); + } + } + }); + + it('should parse frames with file, line, and column', () => { + const error = new Error('Test'); + const parsed = parseError(error); + + // At least the first frame should have file/line/column info + const framesWithInfo = parsed.frames.filter((f) => f.file && f.line && f.column); + expect(framesWithInfo.length).toBeGreaterThan(0); + }); + + it('should handle deeply nested causes', () => { + const deepCause = new Error('Level 3'); + const midCause = new Error('Level 2', { cause: deepCause }); + const error = new Error('Level 1', { cause: midCause }); + + const parsed = parseError(error); + + expect(parsed.cause).toBeDefined(); + expect(parsed.cause!.cause).toBeDefined(); + expect(parsed.cause!.cause!.message).toBe('Level 3'); + }); +}); diff --git a/packages/youch/src/__tests__/renderer.test.ts b/packages/youch/src/__tests__/renderer.test.ts new file mode 100644 index 000000000..bfdf2f5a9 --- /dev/null +++ b/packages/youch/src/__tests__/renderer.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { renderHTML } from '../renderer'; +import type { ParsedError, MetadataGroup } from '../types'; + +describe('renderHTML', () => { + const makeParsedError = (overrides: Partial = {}): ParsedError => ({ + type: 'Error', + message: 'Something went wrong', + frames: [ + { + raw: ' at doSomething (/app/src/handler.ts:42:10)', + file: '/app/src/handler.ts', + line: 42, + column: 10, + function: 'doSomething', + isApp: true, + }, + { + raw: ' at Module._compile (node:internal/modules/cjs/loader:1241:14)', + file: 'node:internal/modules/cjs/loader', + line: 1241, + column: 14, + function: 'Module._compile', + isApp: false, + }, + ], + properties: {}, + ...overrides, + }); + + it('should return a valid HTML document', () => { + const html = renderHTML(makeParsedError()); + + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('should include error type and message', () => { + const html = renderHTML(makeParsedError({ type: 'TypeError', message: 'null is not an object' })); + + expect(html).toContain('TypeError'); + expect(html).toContain('null is not an object'); + }); + + it('should include stack frames', () => { + const html = renderHTML(makeParsedError()); + + expect(html).toContain('doSomething'); + expect(html).toContain('/app/src/handler.ts'); + expect(html).toContain('42:10'); + }); + + it('should mark app frames with badge', () => { + const html = renderHTML(makeParsedError()); + + expect(html).toContain('is-app'); + expect(html).toContain('youch-app-badge'); + }); + + it('should include editor links with vscode by default', () => { + const html = renderHTML(makeParsedError()); + + expect(html).toContain('vscode://file/'); + }); + + it('should use custom IDE when specified', () => { + const html = renderHTML(makeParsedError(), [], { ide: 'sublime' }); + + expect(html).toContain('subl://open'); + }); + + it('should include dark/light theme toggle', () => { + const html = renderHTML(makeParsedError()); + + expect(html).toContain('youch-theme-toggle'); + expect(html).toContain('html.dark'); + }); + + it('should include Stack Trace and Raw tabs', () => { + const html = renderHTML(makeParsedError()); + + expect(html).toContain('Stack Trace'); + expect(html).toContain('Raw'); + }); + + it('should include raw JSON output with error data', () => { + const html = renderHTML(makeParsedError({ type: 'RangeError', message: 'out of bounds' })); + + expect(html).toContain('RangeError'); + expect(html).toContain('out of bounds'); + }); + + it('should render error cause when present', () => { + const parsed = makeParsedError({ + cause: { + type: 'DatabaseError', + message: 'Connection refused', + frames: [], + properties: {}, + }, + }); + const html = renderHTML(parsed); + + expect(html).toContain('Error Cause'); + expect(html).toContain('DatabaseError'); + expect(html).toContain('Connection refused'); + }); + + it('should render metadata groups', () => { + const metadata: MetadataGroup[] = [ + { + name: 'Request', + sections: { + info: [ + { key: 'Method', value: 'GET' }, + { key: 'URL', value: 'https://example.com/api/test' }, + ], + headers: [{ key: 'user-agent', value: 'Mozilla/5.0' }], + }, + }, + ]; + const html = renderHTML(makeParsedError(), metadata); + + expect(html).toContain('Request'); + expect(html).toContain('Method'); + expect(html).toContain('GET'); + expect(html).toContain('user-agent'); + expect(html).toContain('Mozilla/5.0'); + }); + + it('should render error properties as badges', () => { + const parsed = makeParsedError({ + properties: { code: 'NOT_FOUND', status: 404 }, + }); + const html = renderHTML(parsed); + + expect(html).toContain('code'); + expect(html).toContain('NOT_FOUND'); + expect(html).toContain('status'); + expect(html).toContain('404'); + }); + + it('should include custom title', () => { + const html = renderHTML(makeParsedError(), [], { title: 'Server Error' }); + + expect(html).toContain('Server Error'); + }); + + it('should add CSP nonce to style and script tags', () => { + const html = renderHTML(makeParsedError(), [], { cspNonce: 'abc123' }); + + expect(html).toContain('nonce="abc123"'); + }); + + it('should handle empty frames gracefully', () => { + const html = renderHTML(makeParsedError({ frames: [] })); + + expect(html).toContain('No stack frames available'); + }); + + it('should escape HTML in error messages', () => { + const html = renderHTML(makeParsedError({ message: '' })); + + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + }); +}); diff --git a/packages/youch/src/__tests__/youch.test.ts b/packages/youch/src/__tests__/youch.test.ts new file mode 100644 index 000000000..7b3ee0aa9 --- /dev/null +++ b/packages/youch/src/__tests__/youch.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import { Youch } from '../index'; + +describe('Youch', () => { + it('should create an instance', () => { + const youch = new Youch(); + expect(youch).toBeInstanceOf(Youch); + }); + + it('should render error to HTML', () => { + const youch = new Youch(); + const html = youch.toHTML(new Error('Test error')); + + expect(html).toContain(''); + expect(html).toContain('Test error'); + expect(html).toContain('Error'); + }); + + it('should accept metadata groups', () => { + const youch = new Youch(); + youch.group('Request', { + info: [ + { key: 'Method', value: 'POST' }, + { key: 'URL', value: '/api/users' }, + ], + }); + + const html = youch.toHTML(new Error('Not found')); + + expect(html).toContain('Request'); + expect(html).toContain('POST'); + expect(html).toContain('/api/users'); + }); + + it('should merge metadata groups with the same name', () => { + const youch = new Youch(); + youch.group('Request', { + info: [{ key: 'Method', value: 'GET' }], + }); + youch.group('Request', { + headers: [{ key: 'Host', value: 'example.com' }], + }); + + const html = youch.toHTML(new Error('Test')); + + expect(html).toContain('Method'); + expect(html).toContain('Host'); + expect(html).toContain('example.com'); + }); + + it('should support chaining on group()', () => { + const youch = new Youch(); + const result = youch.group('Test', { a: [{ key: 'k', value: 'v' }] }); + + expect(result).toBe(youch); + }); + + it('should parse errors independently via parse()', () => { + const youch = new Youch(); + const parsed = youch.parse(new TypeError('Cannot read')); + + expect(parsed.type).toBe('TypeError'); + expect(parsed.message).toBe('Cannot read'); + expect(parsed.frames.length).toBeGreaterThan(0); + }); + + it('should pass HTML options through', () => { + const youch = new Youch(); + const html = youch.toHTML(new Error('Test'), { + title: 'Custom Title', + ide: 'sublime', + cspNonce: 'test-nonce', + }); + + expect(html).toContain('Custom Title'); + expect(html).toContain('subl://open'); + expect(html).toContain('nonce="test-nonce"'); + }); + + it('should handle non-Error values', () => { + const youch = new Youch(); + const html = youch.toHTML('string error'); + + expect(html).toContain('string error'); + }); + + it('should handle error with cause', () => { + const youch = new Youch(); + const cause = new Error('Database connection failed'); + const error = new Error('Service unavailable', { cause }); + const html = youch.toHTML(error); + + expect(html).toContain('Service unavailable'); + expect(html).toContain('Database connection failed'); + expect(html).toContain('Error Cause'); + }); + + it('should handle error with extra properties', () => { + const youch = new Youch(); + const error = new Error('Bad request'); + (error as Record).status = 400; + (error as Record).code = 'VALIDATION_ERROR'; + + const html = youch.toHTML(error); + + expect(html).toContain('400'); + expect(html).toContain('VALIDATION_ERROR'); + }); + + it('should add request metadata from Request object', () => { + const youch = new Youch(); + const request = new Request('https://example.com/api/test?q=1', { + method: 'POST', + headers: { + 'user-agent': 'TestAgent/1.0', + 'content-type': 'application/json', + }, + }); + + youch.addRequestMetadata(request); + const html = youch.toHTML(new Error('Test')); + + expect(html).toContain('POST'); + expect(html).toContain('/api/test'); + expect(html).toContain('TestAgent/1.0'); + }); + + it('should mask sensitive headers in request metadata', () => { + const youch = new Youch(); + const request = new Request('https://example.com/', { + headers: { + authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.secret', + }, + }); + + youch.addRequestMetadata(request); + const html = youch.toHTML(new Error('Test')); + + // Should mask the middle of the token + expect(html).toContain('Bear'); + expect(html).toContain('****'); + expect(html).not.toContain('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.secret'); + }); + + it('should support offset option to skip frames', () => { + const youch = new Youch(); + + const htmlNoOffset = youch.toHTML(new Error('Test'), { offset: 0 }); + const htmlWithOffset = youch.toHTML(new Error('Test'), { offset: 5 }); + + // Both should be valid HTML, but offset version may have fewer frames + expect(htmlNoOffset).toContain(''); + expect(htmlWithOffset).toContain(''); + }); +}); diff --git a/packages/youch/src/index.ts b/packages/youch/src/index.ts new file mode 100644 index 000000000..218e1e2cf --- /dev/null +++ b/packages/youch/src/index.ts @@ -0,0 +1,140 @@ +import type { MetadataGroup, MetadataSection, YouchHTMLOptions, ParsedError, StackFrame } from './types.js'; +import { parseError } from './parser.js'; +import { renderHTML } from './renderer.js'; + +/** + * Youch — Pretty print JavaScript errors as self-contained HTML pages. + * + * Edge-runtime compatible (no Node.js `fs`). Inspired by poppinss/youch. + * + * @example + * ```ts + * import { Youch } from '@ottabase/youch'; + * + * const youch = new Youch(); + * + * // Add request metadata + * youch.group('Request', { + * headers: [ + * { key: 'host', value: request.headers.get('host') }, + * { key: 'user-agent', value: request.headers.get('user-agent') }, + * ], + * }); + * + * // Render error to HTML + * const html = youch.toHTML(error); + * return new Response(html, { + * status: 500, + * headers: { 'Content-Type': 'text/html' }, + * }); + * ``` + */ +export class Youch { + #metadata: MetadataGroup[] = []; + + /** + * Add a metadata group (e.g., "Request", "Environment"). + * Calling with the same group name merges sections. + * + * @param name - Group name + * @param sections - Record of section name → array of key/value rows + */ + group(name: string, sections: Record): this { + const existing = this.#metadata.find((g) => g.name === name); + if (existing) { + Object.assign(existing.sections, sections); + } else { + this.#metadata.push({ name, sections }); + } + return this; + } + + /** + * Add request metadata from a standard Request object. + * Extracts method, URL, and common headers. + */ + addRequestMetadata(request: Request): this { + const url = new URL(request.url); + const headerRows: { key: string; value: unknown }[] = []; + + // Collect important headers + const importantHeaders = [ + 'host', + 'user-agent', + 'accept', + 'content-type', + 'authorization', + 'cookie', + 'referer', + 'x-forwarded-for', + 'x-real-ip', + 'cf-connecting-ip', + 'cf-ray', + ]; + + for (const name of importantHeaders) { + const value = request.headers.get(name); + if (value) { + // Mask sensitive headers + const masked = name === 'authorization' || name === 'cookie' ? maskValue(value) : value; + headerRows.push({ key: name, value: masked }); + } + } + + return this.group('Request', { + info: [ + { key: 'Method', value: request.method }, + { key: 'URL', value: request.url }, + { key: 'Pathname', value: url.pathname }, + ...(url.search ? [{ key: 'Query', value: url.search }] : []), + ], + ...(headerRows.length > 0 ? { headers: headerRows } : {}), + }); + } + + /** + * Parse an error into a structured ParsedError object. + * + * @param error - The error to parse (Error instance, string, or any thrown value) + * @param offset - Number of stack frames to skip + */ + parse(error: unknown, offset: number = 0): ParsedError { + return parseError(error, offset); + } + + /** + * Render an error as a self-contained HTML page. + * + * @param error - The error to render + * @param options - HTML rendering options + * @returns Complete HTML document string + */ + toHTML(error: unknown, options?: YouchHTMLOptions): string { + const parsed = parseError(error, options?.offset); + return renderHTML(parsed, this.#metadata, { + title: options?.title, + ide: options?.ide, + cspNonce: options?.cspNonce, + }); + } +} + +/** + * Mask a sensitive string, showing only the first and last 4 characters. + */ +function maskValue(value: string): string { + if (value.length <= 8) return '****'; + return value.slice(0, 4) + '****' + value.slice(-4); +} + +// ─── Re-exports ────────────────────────────────────────────────────────── +export { parseError } from './parser.js'; +export { renderHTML } from './renderer.js'; +export type { + ParsedError, + StackFrame, + MetadataGroup, + MetadataSection, + MetadataRow, + YouchHTMLOptions, +} from './types.js'; diff --git a/packages/youch/src/parser.ts b/packages/youch/src/parser.ts new file mode 100644 index 000000000..7583cf603 --- /dev/null +++ b/packages/youch/src/parser.ts @@ -0,0 +1,164 @@ +import type { StackFrame, ParsedError } from './types.js'; + +/** + * Regex patterns for parsing stack trace lines. + * Handles V8 (Chrome/Node/Workers), SpiderMonkey (Firefox), and JavaScriptCore (Safari). + */ +const V8_FRAME_RE = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/; +const V8_NATIVE_RE = /^\s*at\s+(.+?)\s+\(\)$/; +const V8_EVAL_RE = /^\s*at\s+(?:(.+?)\s+\()?eval\s+at\s+.+?,\s*(.+?):(\d+):(\d+)\)?$/; + +/** + * Parse a single stack trace line into a StackFrame. + */ +function parseFrame(line: string): StackFrame | null { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'Error' || !trimmed.startsWith('at ')) { + // Try non-V8 format (Firefox/Safari): "functionName@file:line:col" + const atIdx = trimmed.indexOf('@'); + if (atIdx > -1) { + const fnName = trimmed.slice(0, atIdx); + const rest = trimmed.slice(atIdx + 1); + const match = rest.match(/^(.+?):(\d+):(\d+)$/); + if (match) { + const file = match[1]; + return { + raw: line, + file, + line: parseInt(match[2], 10), + column: parseInt(match[3], 10), + function: fnName || undefined, + isApp: isAppFrame(file), + }; + } + } + if (!trimmed.startsWith('at ')) return null; + } + + // Try eval frame + let match = trimmed.match(V8_EVAL_RE); + if (match) { + return { + raw: line, + file: match[2], + line: parseInt(match[3], 10), + column: parseInt(match[4], 10), + function: match[1] || 'eval', + isApp: isAppFrame(match[2]), + }; + } + + // Try native/anonymous frame + match = trimmed.match(V8_NATIVE_RE); + if (match) { + return { + raw: line, + function: match[1], + isApp: false, + }; + } + + // Try standard V8 frame + match = trimmed.match(V8_FRAME_RE); + if (match) { + const file = match[2]; + return { + raw: line, + file, + line: parseInt(match[3], 10), + column: parseInt(match[4], 10), + function: match[1] || undefined, + isApp: isAppFrame(file), + }; + } + + // Fallback: plain "at something" line + return { + raw: line, + function: trimmed.replace(/^\s*at\s+/, ''), + isApp: false, + }; +} + +/** + * Determine whether a file path is application code (not node_modules or internal). + */ +function isAppFrame(file: string): boolean { + if (!file) return false; + if (file.includes('node_modules')) return false; + if (file.startsWith('node:') || file.startsWith('internal/')) return false; + if (file === '' || file.includes('wrangler')) return false; + return true; +} + +/** + * Safely extract extra properties from an error object (beyond the standard fields). + */ +function extractProperties(error: unknown): Record { + if (!(error instanceof Error)) { + return typeof error === 'object' && error !== null ? { ...(error as object) } : {}; + } + + const props: Record = {}; + const skip = new Set(['name', 'message', 'stack', 'cause']); + + for (const key of Object.getOwnPropertyNames(error)) { + if (!skip.has(key)) { + try { + props[key] = (error as unknown as Record)[key]; + } catch { + // skip non-readable properties + } + } + } + + return props; +} + +/** + * Parse an error (or unknown thrown value) into a structured ParsedError. + * + * @param error - The error to parse + * @param offset - Number of frames to skip from the top of the stack + */ +export function parseError(error: unknown, offset: number = 0): ParsedError { + // Handle non-Error values + if (!(error instanceof Error)) { + return { + type: typeof error === 'object' && error !== null ? error.constructor?.name || 'Object' : typeof error, + message: String(error), + frames: [], + properties: extractProperties(error), + }; + } + + const type = error.constructor?.name || 'Error'; + const message = error.message || ''; + const rawStack = error.stack || ''; + + // Parse stack frames + const lines = rawStack.split('\n'); + const frames: StackFrame[] = []; + for (const line of lines) { + const frame = parseFrame(line); + if (frame) { + frames.push(frame); + } + } + + // Apply offset + const offsetFrames = offset > 0 ? frames.slice(offset) : frames; + + // Parse cause recursively + const errorCause = (error as unknown as Record).cause; + const cause = errorCause ? parseError(errorCause) : undefined; + + return { + type, + message, + frames: offsetFrames, + rawStack, + cause, + properties: extractProperties(error), + }; +} diff --git a/packages/youch/src/renderer.ts b/packages/youch/src/renderer.ts new file mode 100644 index 000000000..2d02e645d --- /dev/null +++ b/packages/youch/src/renderer.ts @@ -0,0 +1,643 @@ +import type { ParsedError, MetadataGroup, StackFrame } from './types.js'; + +// ─── IDE URL Templates ──────────────────────────────────────────────────── +const IDE_URLS: Record = { + vscode: 'vscode://file/%f:%l:%c', + textmate: 'txmt://open?url=file://%f&line=%l&column=%c', + sublime: 'subl://open?url=file://%f&line=%l&column=%c', + phpstorm: 'phpstorm://open?file=%f&line=%l&column=%c', + atom: 'atom://core/open/file?filename=%f&line=%l&column=%c', + emacs: 'emacs://open?url=file://%f&line=%l&column=%c', + macvim: 'mvim://open?url=file://%f&line=%l&column=%c', +}; + +function editorUrl(ide: string, file: string, line?: number, column?: number): string { + const template = IDE_URLS[ide] || ide; + return template + .replace(/%f/g, encodeURIComponent(file)) + .replace(/%l/g, String(line ?? 1)) + .replace(/%c/g, String(column ?? 1)); +} + +// ─── HTML Escaping ──────────────────────────────────────────────────────── +function esc(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// ─── JSON Serializer (safe, handles circular refs) ──────────────────────── +function safeStringify(value: unknown, indent: number = 2): string { + const seen = new WeakSet(); + return JSON.stringify( + value, + (_key, val) => { + if (typeof val === 'object' && val !== null) { + if (seen.has(val)) return '[Circular]'; + seen.add(val); + } + if (typeof val === 'bigint') return val.toString(); + if (typeof val === 'function') return `[Function: ${val.name || 'anonymous'}]`; + if (typeof val === 'symbol') return val.toString(); + if (val === undefined) return '[undefined]'; + return val; + }, + indent, + ); +} + +// ─── CSS ────────────────────────────────────────────────────────────────── +function getStyles(): string { + return ` + :root { + --youch-bg: #fafafa; + --youch-fg: #1a1a1a; + --youch-muted: #6b7280; + --youch-border: #e5e7eb; + --youch-accent: #dc2626; + --youch-accent-bg: #fef2f2; + --youch-app-bg: #eff6ff; + --youch-app-border: #3b82f6; + --youch-frame-bg: #ffffff; + --youch-frame-hover: #f9fafb; + --youch-code-bg: #f3f4f6; + --youch-code-fg: #374151; + --youch-badge-bg: #e5e7eb; + --youch-badge-fg: #374151; + --youch-cause-bg: #fffbeb; + --youch-cause-border: #f59e0b; + --youch-meta-bg: #f0fdf4; + --youch-meta-border: #22c55e; + --youch-link: #2563eb; + --youch-raw-bg: #f8fafc; + --youch-tab-active: #2563eb; + --youch-tab-inactive: #9ca3af; + } + html.dark { + --youch-bg: #0f0f0f; + --youch-fg: #e5e5e5; + --youch-muted: #9ca3af; + --youch-border: #2d2d2d; + --youch-accent: #ef4444; + --youch-accent-bg: #1c0d0d; + --youch-app-bg: #0c1929; + --youch-app-border: #3b82f6; + --youch-frame-bg: #161616; + --youch-frame-hover: #1e1e1e; + --youch-code-bg: #1e1e1e; + --youch-code-fg: #d1d5db; + --youch-badge-bg: #2d2d2d; + --youch-badge-fg: #d1d5db; + --youch-cause-bg: #1a1400; + --youch-cause-border: #d97706; + --youch-meta-bg: #0a1a0a; + --youch-meta-border: #16a34a; + --youch-link: #60a5fa; + --youch-raw-bg: #111111; + --youch-tab-active: #60a5fa; + --youch-tab-inactive: #6b7280; + } + * { margin: 0; padding: 0; box-sizing: border-box; } + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--youch-bg); + color: var(--youch-fg); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + } + .youch-container { + max-width: 960px; + margin: 0 auto; + padding: 32px 24px; + } + /* ─── Header ───────────────────────────────── */ + .youch-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + } + .youch-logo { + font-size: 13px; + font-weight: 600; + color: var(--youch-muted); + letter-spacing: 0.5px; + text-transform: uppercase; + } + .youch-theme-toggle { + background: var(--youch-badge-bg); + border: 1px solid var(--youch-border); + color: var(--youch-fg); + border-radius: 6px; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + transition: background 0.15s; + } + .youch-theme-toggle:hover { opacity: 0.8; } + /* ─── Error Info ───────────────────────────── */ + .youch-error-info { + background: var(--youch-accent-bg); + border: 1px solid var(--youch-accent); + border-radius: 8px; + padding: 20px 24px; + margin-bottom: 24px; + } + .youch-error-type { + display: inline-block; + background: var(--youch-accent); + color: #fff; + font-size: 12px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + margin-bottom: 8px; + } + .youch-error-message { + font-size: 18px; + font-weight: 600; + color: var(--youch-accent); + word-break: break-word; + } + .youch-error-title { + font-size: 13px; + color: var(--youch-muted); + margin-top: 4px; + } + /* ─── Properties ───────────────────────────── */ + .youch-props { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .youch-prop-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + background: var(--youch-badge-bg); + color: var(--youch-badge-fg); + padding: 2px 8px; + border-radius: 4px; + font-family: 'SF Mono', Monaco, Consolas, monospace; + } + .youch-prop-key { font-weight: 600; } + /* ─── Tabs ─────────────────────────────────── */ + .youch-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--youch-border); + margin-bottom: 16px; + } + .youch-tab { + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + color: var(--youch-tab-inactive); + cursor: pointer; + border-bottom: 2px solid transparent; + background: none; + border-top: none; + border-left: none; + border-right: none; + transition: color 0.15s, border-color 0.15s; + } + .youch-tab:hover { color: var(--youch-fg); } + .youch-tab.active { + color: var(--youch-tab-active); + border-bottom-color: var(--youch-tab-active); + } + .youch-tab-panel { display: none; } + .youch-tab-panel.active { display: block; } + /* ─── Stack Frames ─────────────────────────── */ + .youch-frames { display: flex; flex-direction: column; gap: 2px; } + .youch-frame { + border: 1px solid var(--youch-border); + border-radius: 6px; + overflow: hidden; + transition: border-color 0.15s; + } + .youch-frame.is-app { + border-left: 3px solid var(--youch-app-border); + } + .youch-frame-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: var(--youch-frame-bg); + cursor: pointer; + user-select: none; + transition: background 0.15s; + font-size: 13px; + } + .youch-frame-header:hover { background: var(--youch-frame-hover); } + .youch-frame-chevron { + width: 16px; + height: 16px; + flex-shrink: 0; + transition: transform 0.15s; + color: var(--youch-muted); + } + .youch-frame.open .youch-frame-chevron { transform: rotate(90deg); } + .youch-frame-fn { + font-weight: 600; + color: var(--youch-fg); + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 12.5px; + } + .youch-frame-file { + color: var(--youch-muted); + font-size: 12px; + margin-left: auto; + text-align: right; + flex-shrink: 0; + } + .youch-frame-file a { + color: var(--youch-link); + text-decoration: none; + } + .youch-frame-file a:hover { text-decoration: underline; } + .youch-frame-body { + display: none; + padding: 0; + background: var(--youch-code-bg); + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 12px; + overflow-x: auto; + border-top: 1px solid var(--youch-border); + } + .youch-frame.open .youch-frame-body { display: block; } + .youch-frame-raw { + padding: 12px 16px; + color: var(--youch-code-fg); + white-space: pre-wrap; + word-break: break-all; + } + .youch-app-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + background: var(--youch-app-border); + color: #fff; + padding: 1px 5px; + border-radius: 3px; + flex-shrink: 0; + } + /* ─── Raw Output ───────────────────────────── */ + .youch-raw { + background: var(--youch-raw-bg); + border: 1px solid var(--youch-border); + border-radius: 6px; + padding: 16px; + overflow-x: auto; + } + .youch-raw pre { + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.5; + color: var(--youch-code-fg); + white-space: pre-wrap; + word-break: break-word; + } + /* ─── Error Cause ──────────────────────────── */ + .youch-cause { + background: var(--youch-cause-bg); + border: 1px solid var(--youch-cause-border); + border-radius: 8px; + padding: 16px 20px; + margin-top: 24px; + } + .youch-cause-title { + font-size: 13px; + font-weight: 600; + color: var(--youch-cause-border); + margin-bottom: 8px; + } + .youch-cause-message { + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; + } + .youch-cause-type { + display: inline-block; + background: var(--youch-cause-border); + color: #fff; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + margin-right: 6px; + } + .youch-cause-frames { + margin-top: 8px; + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 11px; + color: var(--youch-muted); + max-height: 150px; + overflow-y: auto; + } + .youch-cause-frames div { padding: 1px 0; } + /* ─── Metadata ─────────────────────────────── */ + .youch-metadata { + margin-top: 24px; + } + .youch-meta-group { + background: var(--youch-meta-bg); + border: 1px solid var(--youch-meta-border); + border-radius: 8px; + margin-bottom: 12px; + overflow: hidden; + } + .youch-meta-group-title { + font-size: 13px; + font-weight: 600; + color: var(--youch-meta-border); + padding: 10px 16px; + border-bottom: 1px solid var(--youch-meta-border); + cursor: pointer; + user-select: none; + } + .youch-meta-group-title:hover { opacity: 0.8; } + .youch-meta-section { + padding: 8px 16px; + } + .youch-meta-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: var(--youch-muted); + letter-spacing: 0.5px; + padding: 4px 0; + } + .youch-meta-row { + display: flex; + gap: 12px; + padding: 3px 0; + font-size: 12.5px; + border-bottom: 1px solid var(--youch-border); + } + .youch-meta-row:last-child { border-bottom: none; } + .youch-meta-key { + font-weight: 600; + min-width: 120px; + flex-shrink: 0; + color: var(--youch-muted); + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 11.5px; + } + .youch-meta-value { + color: var(--youch-fg); + word-break: break-all; + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 11.5px; + } + /* ─── Footer ───────────────────────────────── */ + .youch-footer { + margin-top: 32px; + padding-top: 16px; + border-top: 1px solid var(--youch-border); + font-size: 12px; + color: var(--youch-muted); + text-align: center; + } + `; +} + +// ─── JavaScript ─────────────────────────────────────────────────────────── +function getScript(): string { + return ` + (function() { + // Theme toggle + var html = document.documentElement; + var toggle = document.getElementById('youch-theme-toggle'); + var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + if (prefersDark) html.classList.add('dark'); + if (toggle) { + toggle.addEventListener('click', function() { + html.classList.toggle('dark'); + toggle.textContent = html.classList.contains('dark') ? '☀ Light' : '● Dark'; + }); + toggle.textContent = html.classList.contains('dark') ? '☀ Light' : '● Dark'; + } + + // Frame accordion + document.querySelectorAll('.youch-frame-header').forEach(function(header) { + header.addEventListener('click', function() { + var frame = header.parentElement; + frame.classList.toggle('open'); + }); + }); + + // Tabs + document.querySelectorAll('.youch-tab').forEach(function(tab) { + tab.addEventListener('click', function() { + var target = tab.getAttribute('data-tab'); + var container = tab.closest('.youch-container'); + container.querySelectorAll('.youch-tab').forEach(function(t) { t.classList.remove('active'); }); + container.querySelectorAll('.youch-tab-panel').forEach(function(p) { p.classList.remove('active'); }); + tab.classList.add('active'); + var panel = container.querySelector('[data-panel="' + target + '"]'); + if (panel) panel.classList.add('active'); + }); + }); + + // Auto-open first app frame + var firstAppFrame = document.querySelector('.youch-frame.is-app'); + if (firstAppFrame) firstAppFrame.classList.add('open'); + })(); + `; +} + +// ─── Chevron SVG ────────────────────────────────────────────────────────── +const CHEVRON_SVG = ``; + +// ─── Frame Rendering ────────────────────────────────────────────────────── +function renderFrame(frame: StackFrame, ide: string): string { + const fnName = esc(frame.function || '(anonymous)'); + const appClass = frame.isApp ? ' is-app' : ''; + const appBadge = frame.isApp ? `app` : ''; + + let fileInfo = ''; + if (frame.file) { + const loc = `${esc(frame.file)}:${frame.line ?? '?'}:${frame.column ?? '?'}`; + const url = editorUrl(ide, frame.file, frame.line, frame.column); + fileInfo = `${loc}`; + } + + const rawContent = esc(frame.raw.trim()); + + return ` +
+
+ ${CHEVRON_SVG} + ${appBadge} + ${fnName} + ${fileInfo} +
+
+
${rawContent}
+
+
`; +} + +// ─── Cause Chain Rendering ──────────────────────────────────────────────── +function renderCause(cause: ParsedError): string { + let html = ` +
+
⚡ Error Cause
+
+ ${esc(cause.type)} + ${esc(cause.message)} +
`; + + if (cause.frames.length > 0) { + html += `
`; + for (const f of cause.frames.slice(0, 10)) { + html += `
${esc(f.raw.trim())}
`; + } + if (cause.frames.length > 10) { + html += `
... ${cause.frames.length - 10} more frames
`; + } + html += `
`; + } + + // Recursive cause + if (cause.cause) { + html += renderCause(cause.cause); + } + + html += `
`; + return html; +} + +// ─── Properties Rendering ───────────────────────────────────────────────── +function renderProperties(properties: Record): string { + const keys = Object.keys(properties); + if (keys.length === 0) return ''; + + let html = `
`; + for (const key of keys) { + const val = properties[key]; + const display = typeof val === 'object' ? JSON.stringify(val) : String(val); + html += `${esc(key)}: ${esc(display)}`; + } + html += `
`; + return html; +} + +// ─── Metadata Rendering ────────────────────────────────────────────────── +function renderMetadata(groups: MetadataGroup[]): string { + if (groups.length === 0) return ''; + + let html = ``; + return html; +} + +// ─── Main Renderer ──────────────────────────────────────────────────────── + +/** + * Render a ParsedError and metadata groups into a self-contained HTML page. + * + * @param error - The parsed error to render + * @param metadata - Optional metadata groups to display + * @param options - Rendering options (title, ide, cspNonce) + */ +export function renderHTML( + error: ParsedError, + metadata: MetadataGroup[] = [], + options: { + title?: string; + ide?: string; + cspNonce?: string; + } = {}, +): string { + const title = options.title ?? 'An error has occurred'; + const ide = options.ide ?? 'vscode'; + const nonce = options.cspNonce ? ` nonce="${esc(options.cspNonce)}"` : ''; + + // Error info section + const errorInfo = ` +
+ ${esc(error.type)} +
${esc(error.message)}
+
${esc(title)}
+ ${renderProperties(error.properties)} +
`; + + // Stack tab + const stackFrames = error.frames.map((f) => renderFrame(f, ide)).join(''); + const stackTab = `
${stackFrames || '

No stack frames available.

'}
`; + + // Raw tab + const rawContent = safeStringify( + { + type: error.type, + message: error.message, + properties: error.properties, + stack: error.rawStack, + }, + 2, + ); + const rawTab = `
${esc(rawContent)}
`; + + // Cause section + const causeSection = error.cause ? renderCause(error.cause) : ''; + + // Metadata section + const metadataSection = renderMetadata(metadata); + + return ` + + + + + ${esc(error.type)}: ${esc(error.message)} — ${esc(title)} + ${getStyles()} + + +
+
+ + +
+ + ${errorInfo} + +
+ + +
+
${stackTab}
+
${rawTab}
+ + ${causeSection} + ${metadataSection} + + +
+${getScript()} + +`; +} diff --git a/packages/youch/src/types.ts b/packages/youch/src/types.ts new file mode 100644 index 000000000..05b88137e --- /dev/null +++ b/packages/youch/src/types.ts @@ -0,0 +1,62 @@ +/** + * Types for the @ottabase/youch error renderer. + */ + +/** A single parsed stack frame */ +export interface StackFrame { + /** The raw line from the stack trace */ + raw: string; + /** File path (if parsed) */ + file?: string; + /** Line number (if parsed) */ + line?: number; + /** Column number (if parsed) */ + column?: number; + /** Function/method name (if parsed) */ + function?: string; + /** Whether this frame is from application code (not node_modules/internal) */ + isApp: boolean; +} + +/** Structured representation of a parsed error */ +export interface ParsedError { + /** Error class/constructor name (e.g., "TypeError", "ServiceError") */ + type: string; + /** Error message */ + message: string; + /** Parsed stack frames */ + frames: StackFrame[]; + /** Raw stack trace string */ + rawStack?: string; + /** Error cause (chained error) */ + cause?: ParsedError; + /** Additional properties from the error object */ + properties: Record; +} + +/** A metadata key-value row */ +export interface MetadataRow { + key: string; + value: unknown; +} + +/** A section within a metadata group */ +export type MetadataSection = MetadataRow[]; + +/** A named group of metadata sections */ +export interface MetadataGroup { + name: string; + sections: Record; +} + +/** Options for HTML rendering */ +export interface YouchHTMLOptions { + /** Page title (default: "An error has occurred") */ + title?: string; + /** Number of stack frames to skip from the top */ + offset?: number; + /** Code editor for "open in editor" links (default: "vscode") */ + ide?: string; + /** CSP nonce for inline style/script tags */ + cspNonce?: string; +} diff --git a/packages/youch/tsconfig.json b/packages/youch/tsconfig.json new file mode 100644 index 000000000..f7bf841b1 --- /dev/null +++ b/packages/youch/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/__tests__/**"] +} diff --git a/packages/youch/tsup.config.ts b/packages/youch/tsup.config.ts new file mode 100644 index 000000000..e0e2d2a2e --- /dev/null +++ b/packages/youch/tsup.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + clean: true, +}); diff --git a/packages/youch/vitest.config.ts b/packages/youch/vitest.config.ts new file mode 100644 index 000000000..8d4ad08ba --- /dev/null +++ b/packages/youch/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + thresholds: { + lines: 70, + functions: 70, + branches: 65, + statements: 70, + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97de99fc1..75f687a93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2170,6 +2170,27 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/youch: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.28 + eslint: + specifier: 'catalog:' + version: 9.39.2(jiti@2.6.1) + rimraf: + specifier: 'catalog:' + version: 6.1.2 + tsup: + specifier: 'catalog:' + version: 8.5.1(@swc/core@1.13.5)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.28)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages: '@acemir/cssom@0.9.31': @@ -8685,7 +8706,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} From 5c9b3f5faa0318357a37270096492c01d36c1fb0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 08:03:05 +0000 Subject: [PATCH 3/4] rename @ottabase/youch to @ottabase/error-page, remove all Youch references, add enhancements Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- .../cloudflare-worker.ts | 10 +- .../package.json | 2 +- packages/{youch => error-page}/.gitignore | 0 packages/{youch => error-page}/README.md | 50 ++- packages/{youch => error-page}/package.json | 5 +- .../src/__tests__/error-page.test.ts} | 70 +-- .../src/__tests__/parser.test.ts | 0 .../src/__tests__/renderer.test.ts | 4 +- packages/{youch => error-page}/src/index.ts | 20 +- packages/{youch => error-page}/src/parser.ts | 0 .../{youch => error-page}/src/renderer.ts | 421 ++++++++++-------- packages/{youch => error-page}/src/types.ts | 4 +- packages/{youch => error-page}/tsconfig.json | 0 packages/{youch => error-page}/tsup.config.ts | 0 .../{youch => error-page}/vitest.config.ts | 0 pnpm-lock.yaml | 51 ++- 16 files changed, 347 insertions(+), 290 deletions(-) rename packages/{youch => error-page}/.gitignore (100%) rename packages/{youch => error-page}/README.md (64%) rename packages/{youch => error-page}/package.json (87%) rename packages/{youch/src/__tests__/youch.test.ts => error-page/src/__tests__/error-page.test.ts} (67%) rename packages/{youch => error-page}/src/__tests__/parser.test.ts (100%) rename packages/{youch => error-page}/src/__tests__/renderer.test.ts (98%) rename packages/{youch => error-page}/src/index.ts (88%) rename packages/{youch => error-page}/src/parser.ts (100%) rename packages/{youch => error-page}/src/renderer.ts (60%) rename packages/{youch => error-page}/src/types.ts (94%) rename packages/{youch => error-page}/tsconfig.json (100%) rename packages/{youch => error-page}/tsup.config.ts (100%) rename packages/{youch => error-page}/vitest.config.ts (100%) diff --git a/apps/ottabase-template-app-tanstack/cloudflare-worker.ts b/apps/ottabase-template-app-tanstack/cloudflare-worker.ts index a2f824769..857340d09 100644 --- a/apps/ottabase-template-app-tanstack/cloudflare-worker.ts +++ b/apps/ottabase-template-app-tanstack/cloudflare-worker.ts @@ -1,6 +1,6 @@ import { RealtimeActor } from '@ottabase/cf-realtime/server'; import { errorResponse, ServiceError } from '@ottabase/utils/http-errors'; -import { Youch } from '@ottabase/youch'; +import { ErrorPage } from '@ottabase/error-page'; import type { CloudflareEnv } from './cloudflare-env'; import { queueHandler } from './ottabase/queue'; import { handleBootstrapRoute, interceptIfNotReady, resolvePlatformState } from './worker/bootstrap'; @@ -141,16 +141,16 @@ export default { } catch (err) { console.error('Worker unhandled error:', err); - // In dev mode, return a pretty HTML error page via Youch + // In dev mode, return a pretty HTML error page const isDev = !(env as Record).ENVIRONMENT || (env as Record).ENVIRONMENT === 'development' || (env as Record).ENVIRONMENT === 'dev'; if (isDev && isHtmlRequest(request)) { - const youch = new Youch(); - youch.addRequestMetadata(request); - const html = youch.toHTML(err, { title: 'Worker Error' }); + const errorPage = new ErrorPage(); + errorPage.addRequestMetadata(request); + const html = errorPage.toHTML(err, { title: 'Worker Error' }); return new Response(html, { status: err instanceof ServiceError ? err.status : 500, headers: { 'Content-Type': 'text/html; charset=utf-8' }, diff --git a/apps/ottabase-template-app-tanstack/package.json b/apps/ottabase-template-app-tanstack/package.json index f7d686f9e..5e4e44682 100644 --- a/apps/ottabase-template-app-tanstack/package.json +++ b/apps/ottabase-template-app-tanstack/package.json @@ -61,7 +61,7 @@ "@ottabase/ui-shadcn": "workspace:*", "@ottabase/ui-split-pane": "workspace:*", "@ottabase/utils": "workspace:*", - "@ottabase/youch": "workspace:*", + "@ottabase/error-page": "workspace:*", "@tabler/icons-react": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", diff --git a/packages/youch/.gitignore b/packages/error-page/.gitignore similarity index 100% rename from packages/youch/.gitignore rename to packages/error-page/.gitignore diff --git a/packages/youch/README.md b/packages/error-page/README.md similarity index 64% rename from packages/youch/README.md rename to packages/error-page/README.md index 51167db54..c846352d6 100644 --- a/packages/youch/README.md +++ b/packages/error-page/README.md @@ -1,22 +1,33 @@ -# @ottabase/youch +# @ottabase/error-page Pretty print JavaScript errors as self-contained HTML pages — edge-runtime compatible. -Inspired by [poppinss/youch](https://github.com/poppinss/youch), built for Cloudflare Workers and other edge runtimes -where Node.js `fs` is unavailable. +Built for Cloudflare Workers and other edge runtimes where Node.js `fs` is unavailable. + +## Features + +- 🔥 Beautiful error pages with error type badges, expandable stack frames, and "Open in editor" links +- 🌗 Dark/light theme toggle (respects `prefers-color-scheme`) +- 📋 Request metadata display (method, URL, headers with sensitive masking) +- ⛓️ Error cause chain rendering (recursive) +- 🏷️ Extra error properties shown as inline badges +- 📄 Stack Trace / Raw JSON tab switching with copy-to-clipboard +- 🕒 Timestamp display for when the error occurred +- 🛡️ XSS-safe HTML escaping, circular reference handling +- ☁️ Zero Node.js dependencies — works in Cloudflare Workers and other edge runtimes ## Usage ```ts -import { Youch } from '@ottabase/youch'; +import { ErrorPage } from '@ottabase/error-page'; try { await handleRequest(); } catch (error) { - const youch = new Youch(); - youch.addRequestMetadata(request); + const errorPage = new ErrorPage(); + errorPage.addRequestMetadata(request); - const html = youch.toHTML(error, { title: 'Worker Error' }); + const html = errorPage.toHTML(error, { title: 'Worker Error' }); return new Response(html, { status: 500, headers: { 'Content-Type': 'text/html; charset=utf-8' }, @@ -26,21 +37,22 @@ try { ## API -### `new Youch()` +### `new ErrorPage()` -Create a new Youch instance. +Create a new ErrorPage instance. -### `youch.toHTML(error, options?)` +### `errorPage.toHTML(error, options?)` Render an error to a self-contained HTML page with: - Error type badge and message - Expandable stack trace frames (app frames highlighted) -- Raw JSON error view +- Raw JSON error view with copy-to-clipboard - Error cause chain - Metadata sections - Dark/light theme toggle - "Open in editor" links +- Timestamp **Options:** @@ -54,12 +66,12 @@ Render an error to a self-contained HTML page with: Supported editors: `vscode`, `sublime`, `atom`, `phpstorm`, `textmate`, `emacs`, `macvim`, or a custom URL template with `%f`, `%l`, `%c` placeholders. -### `youch.group(name, sections)` +### `errorPage.group(name, sections)` Add metadata sections (e.g., request info, environment). ```ts -youch.group('Request', { +errorPage.group('Request', { info: [ { key: 'Method', value: 'POST' }, { key: 'URL', value: '/api/users' }, @@ -71,12 +83,12 @@ youch.group('Request', { }); ``` -### `youch.addRequestMetadata(request)` +### `errorPage.addRequestMetadata(request)` Automatically extract method, URL, and common headers from a `Request` object. Sensitive headers (`authorization`, `cookie`) are automatically masked. -### `youch.parse(error, offset?)` +### `errorPage.parse(error, offset?)` Parse an error into a structured `ParsedError` object without rendering HTML. @@ -91,7 +103,7 @@ Standalone function to render a `ParsedError` to HTML. ## Integration with Cloudflare Worker ```ts -import { Youch } from '@ottabase/youch'; +import { ErrorPage } from '@ottabase/error-page'; export default { async fetch(request: Request, env: Env): Promise { @@ -101,9 +113,9 @@ export default { const isDev = env.ENVIRONMENT === 'development'; if (isDev) { - const youch = new Youch(); - youch.addRequestMetadata(request); - const html = youch.toHTML(err, { title: 'Worker Error' }); + const errorPage = new ErrorPage(); + errorPage.addRequestMetadata(request); + const html = errorPage.toHTML(err, { title: 'Worker Error' }); return new Response(html, { status: 500, headers: { 'Content-Type': 'text/html; charset=utf-8' }, diff --git a/packages/youch/package.json b/packages/error-page/package.json similarity index 87% rename from packages/youch/package.json rename to packages/error-page/package.json index e2ed8ed98..33d0963f4 100644 --- a/packages/youch/package.json +++ b/packages/error-page/package.json @@ -1,7 +1,7 @@ { - "name": "@ottabase/youch", + "name": "@ottabase/error-page", "version": "0.0.1", - "description": "Pretty print JavaScript errors as HTML — edge-runtime compatible, inspired by poppinss/youch", + "description": "Pretty print JavaScript errors as self-contained HTML pages — edge-runtime compatible", "author": "Ottabase", "license": "MIT", "type": "module", @@ -22,7 +22,6 @@ ], "keywords": [ "error", - "youch", "pretty-error", "error-page", "edge-runtime", diff --git a/packages/youch/src/__tests__/youch.test.ts b/packages/error-page/src/__tests__/error-page.test.ts similarity index 67% rename from packages/youch/src/__tests__/youch.test.ts rename to packages/error-page/src/__tests__/error-page.test.ts index 7b3ee0aa9..3a746ee6d 100644 --- a/packages/youch/src/__tests__/youch.test.ts +++ b/packages/error-page/src/__tests__/error-page.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect } from 'vitest'; -import { Youch } from '../index'; +import { ErrorPage } from '../index'; -describe('Youch', () => { +describe('ErrorPage', () => { it('should create an instance', () => { - const youch = new Youch(); - expect(youch).toBeInstanceOf(Youch); + const errorPage = new ErrorPage(); + expect(errorPage).toBeInstanceOf(ErrorPage); }); it('should render error to HTML', () => { - const youch = new Youch(); - const html = youch.toHTML(new Error('Test error')); + const errorPage = new ErrorPage(); + const html = errorPage.toHTML(new Error('Test error')); expect(html).toContain(''); expect(html).toContain('Test error'); @@ -17,15 +17,15 @@ describe('Youch', () => { }); it('should accept metadata groups', () => { - const youch = new Youch(); - youch.group('Request', { + const errorPage = new ErrorPage(); + errorPage.group('Request', { info: [ { key: 'Method', value: 'POST' }, { key: 'URL', value: '/api/users' }, ], }); - const html = youch.toHTML(new Error('Not found')); + const html = errorPage.toHTML(new Error('Not found')); expect(html).toContain('Request'); expect(html).toContain('POST'); @@ -33,15 +33,15 @@ describe('Youch', () => { }); it('should merge metadata groups with the same name', () => { - const youch = new Youch(); - youch.group('Request', { + const errorPage = new ErrorPage(); + errorPage.group('Request', { info: [{ key: 'Method', value: 'GET' }], }); - youch.group('Request', { + errorPage.group('Request', { headers: [{ key: 'Host', value: 'example.com' }], }); - const html = youch.toHTML(new Error('Test')); + const html = errorPage.toHTML(new Error('Test')); expect(html).toContain('Method'); expect(html).toContain('Host'); @@ -49,15 +49,15 @@ describe('Youch', () => { }); it('should support chaining on group()', () => { - const youch = new Youch(); - const result = youch.group('Test', { a: [{ key: 'k', value: 'v' }] }); + const errorPage = new ErrorPage(); + const result = errorPage.group('Test', { a: [{ key: 'k', value: 'v' }] }); - expect(result).toBe(youch); + expect(result).toBe(errorPage); }); it('should parse errors independently via parse()', () => { - const youch = new Youch(); - const parsed = youch.parse(new TypeError('Cannot read')); + const errorPage = new ErrorPage(); + const parsed = errorPage.parse(new TypeError('Cannot read')); expect(parsed.type).toBe('TypeError'); expect(parsed.message).toBe('Cannot read'); @@ -65,8 +65,8 @@ describe('Youch', () => { }); it('should pass HTML options through', () => { - const youch = new Youch(); - const html = youch.toHTML(new Error('Test'), { + const errorPage = new ErrorPage(); + const html = errorPage.toHTML(new Error('Test'), { title: 'Custom Title', ide: 'sublime', cspNonce: 'test-nonce', @@ -78,17 +78,17 @@ describe('Youch', () => { }); it('should handle non-Error values', () => { - const youch = new Youch(); - const html = youch.toHTML('string error'); + const errorPage = new ErrorPage(); + const html = errorPage.toHTML('string error'); expect(html).toContain('string error'); }); it('should handle error with cause', () => { - const youch = new Youch(); + const errorPage = new ErrorPage(); const cause = new Error('Database connection failed'); const error = new Error('Service unavailable', { cause }); - const html = youch.toHTML(error); + const html = errorPage.toHTML(error); expect(html).toContain('Service unavailable'); expect(html).toContain('Database connection failed'); @@ -96,19 +96,19 @@ describe('Youch', () => { }); it('should handle error with extra properties', () => { - const youch = new Youch(); + const errorPage = new ErrorPage(); const error = new Error('Bad request'); (error as Record).status = 400; (error as Record).code = 'VALIDATION_ERROR'; - const html = youch.toHTML(error); + const html = errorPage.toHTML(error); expect(html).toContain('400'); expect(html).toContain('VALIDATION_ERROR'); }); it('should add request metadata from Request object', () => { - const youch = new Youch(); + const errorPage = new ErrorPage(); const request = new Request('https://example.com/api/test?q=1', { method: 'POST', headers: { @@ -117,8 +117,8 @@ describe('Youch', () => { }, }); - youch.addRequestMetadata(request); - const html = youch.toHTML(new Error('Test')); + errorPage.addRequestMetadata(request); + const html = errorPage.toHTML(new Error('Test')); expect(html).toContain('POST'); expect(html).toContain('/api/test'); @@ -126,15 +126,15 @@ describe('Youch', () => { }); it('should mask sensitive headers in request metadata', () => { - const youch = new Youch(); + const errorPage = new ErrorPage(); const request = new Request('https://example.com/', { headers: { authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.secret', }, }); - youch.addRequestMetadata(request); - const html = youch.toHTML(new Error('Test')); + errorPage.addRequestMetadata(request); + const html = errorPage.toHTML(new Error('Test')); // Should mask the middle of the token expect(html).toContain('Bear'); @@ -143,10 +143,10 @@ describe('Youch', () => { }); it('should support offset option to skip frames', () => { - const youch = new Youch(); + const errorPage = new ErrorPage(); - const htmlNoOffset = youch.toHTML(new Error('Test'), { offset: 0 }); - const htmlWithOffset = youch.toHTML(new Error('Test'), { offset: 5 }); + const htmlNoOffset = errorPage.toHTML(new Error('Test'), { offset: 0 }); + const htmlWithOffset = errorPage.toHTML(new Error('Test'), { offset: 5 }); // Both should be valid HTML, but offset version may have fewer frames expect(htmlNoOffset).toContain(''); diff --git a/packages/youch/src/__tests__/parser.test.ts b/packages/error-page/src/__tests__/parser.test.ts similarity index 100% rename from packages/youch/src/__tests__/parser.test.ts rename to packages/error-page/src/__tests__/parser.test.ts diff --git a/packages/youch/src/__tests__/renderer.test.ts b/packages/error-page/src/__tests__/renderer.test.ts similarity index 98% rename from packages/youch/src/__tests__/renderer.test.ts rename to packages/error-page/src/__tests__/renderer.test.ts index bfdf2f5a9..5eb4904d2 100644 --- a/packages/youch/src/__tests__/renderer.test.ts +++ b/packages/error-page/src/__tests__/renderer.test.ts @@ -55,7 +55,7 @@ describe('renderHTML', () => { const html = renderHTML(makeParsedError()); expect(html).toContain('is-app'); - expect(html).toContain('youch-app-badge'); + expect(html).toContain('ep-app-badge'); }); it('should include editor links with vscode by default', () => { @@ -73,7 +73,7 @@ describe('renderHTML', () => { it('should include dark/light theme toggle', () => { const html = renderHTML(makeParsedError()); - expect(html).toContain('youch-theme-toggle'); + expect(html).toContain('ep-theme-toggle'); expect(html).toContain('html.dark'); }); diff --git a/packages/youch/src/index.ts b/packages/error-page/src/index.ts similarity index 88% rename from packages/youch/src/index.ts rename to packages/error-page/src/index.ts index 218e1e2cf..364daad7b 100644 --- a/packages/youch/src/index.ts +++ b/packages/error-page/src/index.ts @@ -1,20 +1,20 @@ -import type { MetadataGroup, MetadataSection, YouchHTMLOptions, ParsedError, StackFrame } from './types.js'; +import type { MetadataGroup, MetadataSection, ErrorPageHTMLOptions, ParsedError, StackFrame } from './types.js'; import { parseError } from './parser.js'; import { renderHTML } from './renderer.js'; /** - * Youch — Pretty print JavaScript errors as self-contained HTML pages. + * ErrorPage — Pretty print JavaScript errors as self-contained HTML pages. * - * Edge-runtime compatible (no Node.js `fs`). Inspired by poppinss/youch. + * Edge-runtime compatible (no Node.js `fs`). * * @example * ```ts - * import { Youch } from '@ottabase/youch'; + * import { ErrorPage } from '@ottabase/error-page'; * - * const youch = new Youch(); + * const errorPage = new ErrorPage(); * * // Add request metadata - * youch.group('Request', { + * errorPage.group('Request', { * headers: [ * { key: 'host', value: request.headers.get('host') }, * { key: 'user-agent', value: request.headers.get('user-agent') }, @@ -22,14 +22,14 @@ import { renderHTML } from './renderer.js'; * }); * * // Render error to HTML - * const html = youch.toHTML(error); + * const html = errorPage.toHTML(error); * return new Response(html, { * status: 500, * headers: { 'Content-Type': 'text/html' }, * }); * ``` */ -export class Youch { +export class ErrorPage { #metadata: MetadataGroup[] = []; /** @@ -109,7 +109,7 @@ export class Youch { * @param options - HTML rendering options * @returns Complete HTML document string */ - toHTML(error: unknown, options?: YouchHTMLOptions): string { + toHTML(error: unknown, options?: ErrorPageHTMLOptions): string { const parsed = parseError(error, options?.offset); return renderHTML(parsed, this.#metadata, { title: options?.title, @@ -136,5 +136,5 @@ export type { MetadataGroup, MetadataSection, MetadataRow, - YouchHTMLOptions, + ErrorPageHTMLOptions, } from './types.js'; diff --git a/packages/youch/src/parser.ts b/packages/error-page/src/parser.ts similarity index 100% rename from packages/youch/src/parser.ts rename to packages/error-page/src/parser.ts diff --git a/packages/youch/src/renderer.ts b/packages/error-page/src/renderer.ts similarity index 60% rename from packages/youch/src/renderer.ts rename to packages/error-page/src/renderer.ts index 2d02e645d..a3ff6f29e 100644 --- a/packages/youch/src/renderer.ts +++ b/packages/error-page/src/renderer.ts @@ -48,102 +48,102 @@ function safeStringify(value: unknown, indent: number = 2): string { function getStyles(): string { return ` :root { - --youch-bg: #fafafa; - --youch-fg: #1a1a1a; - --youch-muted: #6b7280; - --youch-border: #e5e7eb; - --youch-accent: #dc2626; - --youch-accent-bg: #fef2f2; - --youch-app-bg: #eff6ff; - --youch-app-border: #3b82f6; - --youch-frame-bg: #ffffff; - --youch-frame-hover: #f9fafb; - --youch-code-bg: #f3f4f6; - --youch-code-fg: #374151; - --youch-badge-bg: #e5e7eb; - --youch-badge-fg: #374151; - --youch-cause-bg: #fffbeb; - --youch-cause-border: #f59e0b; - --youch-meta-bg: #f0fdf4; - --youch-meta-border: #22c55e; - --youch-link: #2563eb; - --youch-raw-bg: #f8fafc; - --youch-tab-active: #2563eb; - --youch-tab-inactive: #9ca3af; + --ep-bg: #fafafa; + --ep-fg: #1a1a1a; + --ep-muted: #6b7280; + --ep-border: #e5e7eb; + --ep-accent: #dc2626; + --ep-accent-bg: #fef2f2; + --ep-app-bg: #eff6ff; + --ep-app-border: #3b82f6; + --ep-frame-bg: #ffffff; + --ep-frame-hover: #f9fafb; + --ep-code-bg: #f3f4f6; + --ep-code-fg: #374151; + --ep-badge-bg: #e5e7eb; + --ep-badge-fg: #374151; + --ep-cause-bg: #fffbeb; + --ep-cause-border: #f59e0b; + --ep-meta-bg: #f0fdf4; + --ep-meta-border: #22c55e; + --ep-link: #2563eb; + --ep-raw-bg: #f8fafc; + --ep-tab-active: #2563eb; + --ep-tab-inactive: #9ca3af; } html.dark { - --youch-bg: #0f0f0f; - --youch-fg: #e5e5e5; - --youch-muted: #9ca3af; - --youch-border: #2d2d2d; - --youch-accent: #ef4444; - --youch-accent-bg: #1c0d0d; - --youch-app-bg: #0c1929; - --youch-app-border: #3b82f6; - --youch-frame-bg: #161616; - --youch-frame-hover: #1e1e1e; - --youch-code-bg: #1e1e1e; - --youch-code-fg: #d1d5db; - --youch-badge-bg: #2d2d2d; - --youch-badge-fg: #d1d5db; - --youch-cause-bg: #1a1400; - --youch-cause-border: #d97706; - --youch-meta-bg: #0a1a0a; - --youch-meta-border: #16a34a; - --youch-link: #60a5fa; - --youch-raw-bg: #111111; - --youch-tab-active: #60a5fa; - --youch-tab-inactive: #6b7280; + --ep-bg: #0f0f0f; + --ep-fg: #e5e5e5; + --ep-muted: #9ca3af; + --ep-border: #2d2d2d; + --ep-accent: #ef4444; + --ep-accent-bg: #1c0d0d; + --ep-app-bg: #0c1929; + --ep-app-border: #3b82f6; + --ep-frame-bg: #161616; + --ep-frame-hover: #1e1e1e; + --ep-code-bg: #1e1e1e; + --ep-code-fg: #d1d5db; + --ep-badge-bg: #2d2d2d; + --ep-badge-fg: #d1d5db; + --ep-cause-bg: #1a1400; + --ep-cause-border: #d97706; + --ep-meta-bg: #0a1a0a; + --ep-meta-border: #16a34a; + --ep-link: #60a5fa; + --ep-raw-bg: #111111; + --ep-tab-active: #60a5fa; + --ep-tab-inactive: #6b7280; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background: var(--youch-bg); - color: var(--youch-fg); + background: var(--ep-bg); + color: var(--ep-fg); line-height: 1.6; -webkit-font-smoothing: antialiased; } - .youch-container { + .ep-container { max-width: 960px; margin: 0 auto; padding: 32px 24px; } /* ─── Header ───────────────────────────────── */ - .youch-header { + .ep-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; } - .youch-logo { + .ep-logo { font-size: 13px; font-weight: 600; - color: var(--youch-muted); + color: var(--ep-muted); letter-spacing: 0.5px; text-transform: uppercase; } - .youch-theme-toggle { - background: var(--youch-badge-bg); - border: 1px solid var(--youch-border); - color: var(--youch-fg); + .ep-theme-toggle { + background: var(--ep-badge-bg); + border: 1px solid var(--ep-border); + color: var(--ep-fg); border-radius: 6px; padding: 4px 10px; font-size: 12px; cursor: pointer; transition: background 0.15s; } - .youch-theme-toggle:hover { opacity: 0.8; } + .ep-theme-toggle:hover { opacity: 0.8; } /* ─── Error Info ───────────────────────────── */ - .youch-error-info { - background: var(--youch-accent-bg); - border: 1px solid var(--youch-accent); + .ep-error-info { + background: var(--ep-accent-bg); + border: 1px solid var(--ep-accent); border-radius: 8px; padding: 20px 24px; margin-bottom: 24px; } - .youch-error-type { + .ep-error-type { display: inline-block; - background: var(--youch-accent); + background: var(--ep-accent); color: #fff; font-size: 12px; font-weight: 600; @@ -151,48 +151,48 @@ function getStyles(): string { border-radius: 4px; margin-bottom: 8px; } - .youch-error-message { + .ep-error-message { font-size: 18px; font-weight: 600; - color: var(--youch-accent); + color: var(--ep-accent); word-break: break-word; } - .youch-error-title { + .ep-error-title { font-size: 13px; - color: var(--youch-muted); + color: var(--ep-muted); margin-top: 4px; } /* ─── Properties ───────────────────────────── */ - .youch-props { + .ep-props { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 8px; } - .youch-prop-badge { + .ep-prop-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; - background: var(--youch-badge-bg); - color: var(--youch-badge-fg); + background: var(--ep-badge-bg); + color: var(--ep-badge-fg); padding: 2px 8px; border-radius: 4px; font-family: 'SF Mono', Monaco, Consolas, monospace; } - .youch-prop-key { font-weight: 600; } + .ep-prop-key { font-weight: 600; } /* ─── Tabs ─────────────────────────────────── */ - .youch-tabs { + .ep-tabs { display: flex; gap: 0; - border-bottom: 1px solid var(--youch-border); + border-bottom: 1px solid var(--ep-border); margin-bottom: 16px; } - .youch-tab { + .ep-tab { padding: 8px 16px; font-size: 13px; font-weight: 500; - color: var(--youch-tab-inactive); + color: var(--ep-tab-inactive); cursor: pointer; border-bottom: 2px solid transparent; background: none; @@ -201,126 +201,126 @@ function getStyles(): string { border-right: none; transition: color 0.15s, border-color 0.15s; } - .youch-tab:hover { color: var(--youch-fg); } - .youch-tab.active { - color: var(--youch-tab-active); - border-bottom-color: var(--youch-tab-active); + .ep-tab:hover { color: var(--ep-fg); } + .ep-tab.active { + color: var(--ep-tab-active); + border-bottom-color: var(--ep-tab-active); } - .youch-tab-panel { display: none; } - .youch-tab-panel.active { display: block; } + .ep-tab-panel { display: none; } + .ep-tab-panel.active { display: block; } /* ─── Stack Frames ─────────────────────────── */ - .youch-frames { display: flex; flex-direction: column; gap: 2px; } - .youch-frame { - border: 1px solid var(--youch-border); + .ep-frames { display: flex; flex-direction: column; gap: 2px; } + .ep-frame { + border: 1px solid var(--ep-border); border-radius: 6px; overflow: hidden; transition: border-color 0.15s; } - .youch-frame.is-app { - border-left: 3px solid var(--youch-app-border); + .ep-frame.is-app { + border-left: 3px solid var(--ep-app-border); } - .youch-frame-header { + .ep-frame-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; - background: var(--youch-frame-bg); + background: var(--ep-frame-bg); cursor: pointer; user-select: none; transition: background 0.15s; font-size: 13px; } - .youch-frame-header:hover { background: var(--youch-frame-hover); } - .youch-frame-chevron { + .ep-frame-header:hover { background: var(--ep-frame-hover); } + .ep-frame-chevron { width: 16px; height: 16px; flex-shrink: 0; transition: transform 0.15s; - color: var(--youch-muted); + color: var(--ep-muted); } - .youch-frame.open .youch-frame-chevron { transform: rotate(90deg); } - .youch-frame-fn { + .ep-frame.open .ep-frame-chevron { transform: rotate(90deg); } + .ep-frame-fn { font-weight: 600; - color: var(--youch-fg); + color: var(--ep-fg); font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 12.5px; } - .youch-frame-file { - color: var(--youch-muted); + .ep-frame-file { + color: var(--ep-muted); font-size: 12px; margin-left: auto; text-align: right; flex-shrink: 0; } - .youch-frame-file a { - color: var(--youch-link); + .ep-frame-file a { + color: var(--ep-link); text-decoration: none; } - .youch-frame-file a:hover { text-decoration: underline; } - .youch-frame-body { + .ep-frame-file a:hover { text-decoration: underline; } + .ep-frame-body { display: none; padding: 0; - background: var(--youch-code-bg); + background: var(--ep-code-bg); font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 12px; overflow-x: auto; - border-top: 1px solid var(--youch-border); + border-top: 1px solid var(--ep-border); } - .youch-frame.open .youch-frame-body { display: block; } - .youch-frame-raw { + .ep-frame.open .ep-frame-body { display: block; } + .ep-frame-raw { padding: 12px 16px; - color: var(--youch-code-fg); + color: var(--ep-code-fg); white-space: pre-wrap; word-break: break-all; } - .youch-app-badge { + .ep-app-badge { font-size: 10px; font-weight: 600; text-transform: uppercase; - background: var(--youch-app-border); + background: var(--ep-app-border); color: #fff; padding: 1px 5px; border-radius: 3px; flex-shrink: 0; } /* ─── Raw Output ───────────────────────────── */ - .youch-raw { - background: var(--youch-raw-bg); - border: 1px solid var(--youch-border); + .ep-raw { + background: var(--ep-raw-bg); + border: 1px solid var(--ep-border); border-radius: 6px; padding: 16px; overflow-x: auto; } - .youch-raw pre { + .ep-raw pre { font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 12px; line-height: 1.5; - color: var(--youch-code-fg); + color: var(--ep-code-fg); white-space: pre-wrap; word-break: break-word; } /* ─── Error Cause ──────────────────────────── */ - .youch-cause { - background: var(--youch-cause-bg); - border: 1px solid var(--youch-cause-border); + .ep-cause { + background: var(--ep-cause-bg); + border: 1px solid var(--ep-cause-border); border-radius: 8px; padding: 16px 20px; margin-top: 24px; } - .youch-cause-title { + .ep-cause-title { font-size: 13px; font-weight: 600; - color: var(--youch-cause-border); + color: var(--ep-cause-border); margin-bottom: 8px; } - .youch-cause-message { + .ep-cause-message { font-size: 14px; font-weight: 500; margin-bottom: 8px; } - .youch-cause-type { + .ep-cause-type { display: inline-block; - background: var(--youch-cause-border); + background: var(--ep-cause-border); color: #fff; font-size: 11px; font-weight: 600; @@ -328,78 +328,100 @@ function getStyles(): string { border-radius: 3px; margin-right: 6px; } - .youch-cause-frames { + .ep-cause-frames { margin-top: 8px; font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 11px; - color: var(--youch-muted); + color: var(--ep-muted); max-height: 150px; overflow-y: auto; } - .youch-cause-frames div { padding: 1px 0; } + .ep-cause-frames div { padding: 1px 0; } /* ─── Metadata ─────────────────────────────── */ - .youch-metadata { + .ep-metadata { margin-top: 24px; } - .youch-meta-group { - background: var(--youch-meta-bg); - border: 1px solid var(--youch-meta-border); + .ep-meta-group { + background: var(--ep-meta-bg); + border: 1px solid var(--ep-meta-border); border-radius: 8px; margin-bottom: 12px; overflow: hidden; } - .youch-meta-group-title { + .ep-meta-group-title { font-size: 13px; font-weight: 600; - color: var(--youch-meta-border); + color: var(--ep-meta-border); padding: 10px 16px; - border-bottom: 1px solid var(--youch-meta-border); + border-bottom: 1px solid var(--ep-meta-border); cursor: pointer; user-select: none; } - .youch-meta-group-title:hover { opacity: 0.8; } - .youch-meta-section { + .ep-meta-group-title:hover { opacity: 0.8; } + .ep-meta-section { padding: 8px 16px; } - .youch-meta-section-title { + .ep-meta-section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; - color: var(--youch-muted); + color: var(--ep-muted); letter-spacing: 0.5px; padding: 4px 0; } - .youch-meta-row { + .ep-meta-row { display: flex; gap: 12px; padding: 3px 0; font-size: 12.5px; - border-bottom: 1px solid var(--youch-border); + border-bottom: 1px solid var(--ep-border); } - .youch-meta-row:last-child { border-bottom: none; } - .youch-meta-key { + .ep-meta-row:last-child { border-bottom: none; } + .ep-meta-key { font-weight: 600; min-width: 120px; flex-shrink: 0; - color: var(--youch-muted); + color: var(--ep-muted); font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 11.5px; } - .youch-meta-value { - color: var(--youch-fg); + .ep-meta-value { + color: var(--ep-fg); word-break: break-all; font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 11.5px; } /* ─── Footer ───────────────────────────────── */ - .youch-footer { + .ep-footer { margin-top: 32px; padding-top: 16px; - border-top: 1px solid var(--youch-border); + border-top: 1px solid var(--ep-border); font-size: 12px; - color: var(--youch-muted); + color: var(--ep-muted); text-align: center; } + .ep-timestamp { + font-size: 12px; + color: var(--ep-muted); + font-family: 'SF Mono', Monaco, Consolas, monospace; + } + /* ─── Copy Button ──────────────────────────── */ + .ep-copy-btn { + background: var(--ep-badge-bg); + border: 1px solid var(--ep-border); + color: var(--ep-fg); + border-radius: 6px; + padding: 4px 12px; + font-size: 12px; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; + margin-bottom: 8px; + display: inline-flex; + align-items: center; + gap: 4px; + } + .ep-copy-btn:hover { opacity: 0.8; } + .ep-copy-btn.copied { background: var(--ep-meta-border); color: #fff; border-color: var(--ep-meta-border); } `; } @@ -409,7 +431,7 @@ function getScript(): string { (function() { // Theme toggle var html = document.documentElement; - var toggle = document.getElementById('youch-theme-toggle'); + var toggle = document.getElementById('ep-theme-toggle'); var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; if (prefersDark) html.classList.add('dark'); if (toggle) { @@ -421,7 +443,7 @@ function getScript(): string { } // Frame accordion - document.querySelectorAll('.youch-frame-header').forEach(function(header) { + document.querySelectorAll('.ep-frame-header').forEach(function(header) { header.addEventListener('click', function() { var frame = header.parentElement; frame.classList.toggle('open'); @@ -429,12 +451,12 @@ function getScript(): string { }); // Tabs - document.querySelectorAll('.youch-tab').forEach(function(tab) { + document.querySelectorAll('.ep-tab').forEach(function(tab) { tab.addEventListener('click', function() { var target = tab.getAttribute('data-tab'); - var container = tab.closest('.youch-container'); - container.querySelectorAll('.youch-tab').forEach(function(t) { t.classList.remove('active'); }); - container.querySelectorAll('.youch-tab-panel').forEach(function(p) { p.classList.remove('active'); }); + var container = tab.closest('.ep-container'); + container.querySelectorAll('.ep-tab').forEach(function(t) { t.classList.remove('active'); }); + container.querySelectorAll('.ep-tab-panel').forEach(function(p) { p.classList.remove('active'); }); tab.classList.add('active'); var panel = container.querySelector('[data-panel="' + target + '"]'); if (panel) panel.classList.add('active'); @@ -442,40 +464,58 @@ function getScript(): string { }); // Auto-open first app frame - var firstAppFrame = document.querySelector('.youch-frame.is-app'); + var firstAppFrame = document.querySelector('.ep-frame.is-app'); if (firstAppFrame) firstAppFrame.classList.add('open'); + + // Copy-to-clipboard for raw view + var copyBtn = document.getElementById('ep-copy-raw'); + if (copyBtn) { + copyBtn.addEventListener('click', function() { + var raw = document.querySelector('.ep-raw pre'); + if (raw && navigator.clipboard) { + navigator.clipboard.writeText(raw.textContent || '').then(function() { + copyBtn.textContent = '✓ Copied'; + copyBtn.classList.add('copied'); + setTimeout(function() { + copyBtn.textContent = '📋 Copy'; + copyBtn.classList.remove('copied'); + }, 2000); + }); + } + }); + } })(); `; } // ─── Chevron SVG ────────────────────────────────────────────────────────── -const CHEVRON_SVG = ``; +const CHEVRON_SVG = ``; // ─── Frame Rendering ────────────────────────────────────────────────────── function renderFrame(frame: StackFrame, ide: string): string { const fnName = esc(frame.function || '(anonymous)'); const appClass = frame.isApp ? ' is-app' : ''; - const appBadge = frame.isApp ? `app` : ''; + const appBadge = frame.isApp ? `app` : ''; let fileInfo = ''; if (frame.file) { const loc = `${esc(frame.file)}:${frame.line ?? '?'}:${frame.column ?? '?'}`; const url = editorUrl(ide, frame.file, frame.line, frame.column); - fileInfo = `${loc}`; + fileInfo = `${loc}`; } const rawContent = esc(frame.raw.trim()); return ` -
-
+
+
${CHEVRON_SVG} ${appBadge} - ${fnName} + ${fnName} ${fileInfo}
-
-
${rawContent}
+
+
${rawContent}
`; } @@ -483,15 +523,15 @@ function renderFrame(frame: StackFrame, ide: string): string { // ─── Cause Chain Rendering ──────────────────────────────────────────────── function renderCause(cause: ParsedError): string { let html = ` -
-
⚡ Error Cause
+
+
⚡ Error Cause
- ${esc(cause.type)} - ${esc(cause.message)} + ${esc(cause.type)} + ${esc(cause.message)}
`; if (cause.frames.length > 0) { - html += `
`; + html += `
`; for (const f of cause.frames.slice(0, 10)) { html += `
${esc(f.raw.trim())}
`; } @@ -515,11 +555,11 @@ function renderProperties(properties: Record): string { const keys = Object.keys(properties); if (keys.length === 0) return ''; - let html = `
`; + let html = `
`; for (const key of keys) { const val = properties[key]; const display = typeof val === 'object' ? JSON.stringify(val) : String(val); - html += `${esc(key)}: ${esc(display)}`; + html += `${esc(key)}: ${esc(display)}`; } html += `
`; return html; @@ -529,19 +569,19 @@ function renderProperties(properties: Record): string { function renderMetadata(groups: MetadataGroup[]): string { if (groups.length === 0) return ''; - let html = `