@noego/forge 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/dist-ssr/test.cjs +50 -2
- package/dist-ssr/test.cjs.map +1 -1
- package/dist-ssr/test.d.ts +2 -0
- package/dist-ssr/test.js +50 -2
- package/dist-ssr/test.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -535,6 +535,92 @@ afterAll(() => {
|
|
|
535
535
|
- Always close the server after tests to release handles
|
|
536
536
|
- `buildServer()` doesn't call `listen()`, so you control the lifecycle
|
|
537
537
|
|
|
538
|
+
### Screenshot Testing with ImageRenderer
|
|
539
|
+
|
|
540
|
+
Forge includes an `ImageRenderer` for capturing screenshots of your Svelte components. This is useful for visual regression testing.
|
|
541
|
+
|
|
542
|
+
#### Prerequisites
|
|
543
|
+
|
|
544
|
+
Install Playwright as a dev dependency:
|
|
545
|
+
|
|
546
|
+
```bash
|
|
547
|
+
npm install --save-dev playwright
|
|
548
|
+
npx playwright install chromium
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
#### Basic Usage
|
|
552
|
+
|
|
553
|
+
```typescript
|
|
554
|
+
import { createImageRenderer } from '@noego/forge/test';
|
|
555
|
+
|
|
556
|
+
const renderer = await createImageRenderer({
|
|
557
|
+
outputDir: './screenshots',
|
|
558
|
+
stitchConfig: './stitch.yaml',
|
|
559
|
+
componentDir: './components',
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// Capture a route
|
|
563
|
+
await renderer.capture('homepage', '/');
|
|
564
|
+
|
|
565
|
+
// Capture with custom viewport
|
|
566
|
+
await renderer.capture('mobile-home', '/', {
|
|
567
|
+
width: 375,
|
|
568
|
+
height: 667,
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// Capture viewport only (not full page)
|
|
572
|
+
await renderer.capture('above-fold', '/', { fullPage: false });
|
|
573
|
+
|
|
574
|
+
// Don't forget to close when done
|
|
575
|
+
await renderer.close();
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
#### Loading Static Assets
|
|
579
|
+
|
|
580
|
+
By default, `ImageRenderer` cannot load static assets (images, fonts, etc.) because it renders HTML without a server. To enable asset loading, provide an `assets` mapping:
|
|
581
|
+
|
|
582
|
+
```typescript
|
|
583
|
+
const renderer = await createImageRenderer({
|
|
584
|
+
outputDir: './screenshots',
|
|
585
|
+
stitchConfig: './stitch.yaml',
|
|
586
|
+
componentDir: './components',
|
|
587
|
+
// Map URL paths to filesystem directories
|
|
588
|
+
assets: {
|
|
589
|
+
'/images': ['ui/resources/images'],
|
|
590
|
+
'/assets': ['public'],
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
This intercepts browser requests and serves files from your local filesystem. The format matches the server's asset configuration.
|
|
596
|
+
|
|
597
|
+
#### Configuration Options
|
|
598
|
+
|
|
599
|
+
| Option | Type | Default | Description |
|
|
600
|
+
|--------|------|---------|-------------|
|
|
601
|
+
| `outputDir` | `string` | required | Directory to save screenshots |
|
|
602
|
+
| `stitchConfig` | `string` | - | Path to stitch.yaml |
|
|
603
|
+
| `componentDir` | `string` | - | Path to Svelte components |
|
|
604
|
+
| `staticRenderer` | `StaticRenderer` | - | Pre-created StaticRenderer instance |
|
|
605
|
+
| `assets` | `Record<string, string[]>` | - | Asset path mappings |
|
|
606
|
+
| `width` | `number` | 1920 | Default viewport width |
|
|
607
|
+
| `height` | `number` | 1080 | Default viewport height |
|
|
608
|
+
| `deviceScaleFactor` | `number` | 1 | Device pixel ratio (2 for retina) |
|
|
609
|
+
| `format` | `'png' \| 'jpeg'` | `'png'` | Image format |
|
|
610
|
+
| `quality` | `number` | 80 | JPEG quality (0-100) |
|
|
611
|
+
| `waitUntil` | `string` | `'networkidle'` | When to consider page loaded |
|
|
612
|
+
| `timeoutMs` | `number` | 10000 | Render timeout |
|
|
613
|
+
|
|
614
|
+
#### Capture Options
|
|
615
|
+
|
|
616
|
+
| Option | Type | Default | Description |
|
|
617
|
+
|--------|------|---------|-------------|
|
|
618
|
+
| `view` | `any` | - | Data for view component |
|
|
619
|
+
| `layout` | `any[]` | - | Data for layout components |
|
|
620
|
+
| `width` | `number` | config.width | Override viewport width |
|
|
621
|
+
| `height` | `number` | config.height | Override viewport height |
|
|
622
|
+
| `fullPage` | `boolean` | `true` | Capture full page or viewport only |
|
|
623
|
+
|
|
538
624
|
### Server-Side Middleware
|
|
539
625
|
|
|
540
626
|
Define custom middleware in `x-middleware` arrays to run before rendering:
|
package/dist-ssr/test.cjs
CHANGED
|
@@ -91,6 +91,27 @@ async function loadPlaywright() {
|
|
|
91
91
|
throw new PlaywrightNotInstalledError();
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
function getContentType(filePath) {
|
|
95
|
+
const ext = path__namespace.extname(filePath).toLowerCase();
|
|
96
|
+
const types = {
|
|
97
|
+
// Images
|
|
98
|
+
".png": "image/png",
|
|
99
|
+
".jpg": "image/jpeg",
|
|
100
|
+
".jpeg": "image/jpeg",
|
|
101
|
+
".gif": "image/gif",
|
|
102
|
+
".svg": "image/svg+xml",
|
|
103
|
+
".webp": "image/webp",
|
|
104
|
+
// Stylesheets & Scripts
|
|
105
|
+
".css": "text/css",
|
|
106
|
+
".js": "application/javascript",
|
|
107
|
+
".json": "application/json",
|
|
108
|
+
// Fonts
|
|
109
|
+
".woff": "font/woff",
|
|
110
|
+
".woff2": "font/woff2",
|
|
111
|
+
".ttf": "font/ttf"
|
|
112
|
+
};
|
|
113
|
+
return types[ext] || "application/octet-stream";
|
|
114
|
+
}
|
|
94
115
|
class ImageRenderer {
|
|
95
116
|
constructor(browser, staticRenderer, config) {
|
|
96
117
|
__publicField(this, "browser");
|
|
@@ -107,7 +128,8 @@ class ImageRenderer {
|
|
|
107
128
|
quality: config.quality ?? 80,
|
|
108
129
|
waitUntil: config.waitUntil ?? "networkidle",
|
|
109
130
|
timeoutMs: config.timeoutMs ?? 1e4,
|
|
110
|
-
template: config.template
|
|
131
|
+
template: config.template,
|
|
132
|
+
assets: config.assets
|
|
111
133
|
};
|
|
112
134
|
}
|
|
113
135
|
/**
|
|
@@ -142,13 +164,39 @@ class ImageRenderer {
|
|
|
142
164
|
deviceScaleFactor: this.config.deviceScaleFactor
|
|
143
165
|
});
|
|
144
166
|
page = await context.newPage();
|
|
167
|
+
const FAKE_BASE_URL = "http://forge-renderer/";
|
|
168
|
+
await page.route(FAKE_BASE_URL, async (route) => {
|
|
169
|
+
await route.fulfill({
|
|
170
|
+
body: html,
|
|
171
|
+
contentType: "text/html"
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
if (this.config.assets) {
|
|
175
|
+
for (const [urlPath, fsPaths] of Object.entries(this.config.assets)) {
|
|
176
|
+
await page.route(`**${urlPath}/**`, async (route) => {
|
|
177
|
+
const url = new URL(route.request().url());
|
|
178
|
+
const relativePath = url.pathname.replace(urlPath, "");
|
|
179
|
+
for (const fsPath of fsPaths) {
|
|
180
|
+
const filePath = path__namespace.join(fsPath, relativePath);
|
|
181
|
+
try {
|
|
182
|
+
const body = await fs__namespace.promises.readFile(filePath);
|
|
183
|
+
const contentType = getContentType(filePath);
|
|
184
|
+
await route.fulfill({ body, contentType });
|
|
185
|
+
return;
|
|
186
|
+
} catch {
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
await route.abort("filenotfound");
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
145
193
|
const waitUntilMapping = {
|
|
146
194
|
"load": "load",
|
|
147
195
|
"domcontentloaded": "domcontentloaded",
|
|
148
196
|
"networkidle": "networkidle"
|
|
149
197
|
};
|
|
150
198
|
try {
|
|
151
|
-
await page.
|
|
199
|
+
await page.goto(FAKE_BASE_URL, {
|
|
152
200
|
waitUntil: waitUntilMapping[this.config.waitUntil],
|
|
153
201
|
timeout: this.config.timeoutMs
|
|
154
202
|
});
|
package/dist-ssr/test.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test.cjs","sources":["../src/test/allow_cli.ts","../src/test/index.ts"],"sourcesContent":["export class ForgeCLIRequiredError extends Error {\n name = 'ForgeCLIRequiredError' as const;\n constructor() {\n super(\n 'This file must be run with the forge CLI, not directly with tsx/node.\\n\\n' +\n 'Usage:\\n' +\n ' npx forge test/ui/your-test.ts\\n' +\n ' npx forge \"test/ui/**/*.test.ts\"\\n\\n' +\n 'The forge CLI provides the necessary Svelte loader for rendering components.'\n );\n }\n}\n\n//We need to throw on import so that this crashes right away\nif (!process.env.FORGE_CLI) {\n throw new ForgeCLIRequiredError();\n}","import './allow_cli';\n// Type-only import - erased at runtime, won't trigger module loading\nimport type { StaticRenderer } from '../static/index';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nexport type { StaticRenderer };\n\n\n\n// ─────────────────────────────────────────────────────────────\n// Error Classes\n// ─────────────────────────────────────────────────────────────\n\n\n\nexport class PlaywrightNotInstalledError extends Error {\n name = 'PlaywrightNotInstalledError' as const;\n constructor() {\n super(\n 'ImageRenderer requires Playwright. Install it with:\\n' +\n ' npm install playwright\\n' +\n ' npx playwright install chromium'\n );\n }\n}\n\nexport class BrowserLaunchError extends Error {\n name = 'BrowserLaunchError' as const;\n constructor(message: string, public cause?: Error) {\n super(message);\n }\n}\n\nexport class RenderTimeoutError extends Error {\n name = 'RenderTimeoutError' as const;\n constructor(timeoutMs: number) {\n super(`Render timed out after ${timeoutMs}ms`);\n }\n}\n\nexport class RenderError extends Error {\n name = 'RenderError' as const;\n constructor(message: string, public cause?: Error) {\n super(message);\n }\n}\n\n\n// ─────────────────────────────────────────────────────────────\n// Types\n// ─────────────────────────────────────────────────────────────\n\nexport interface ImageRendererConfig {\n /** Output directory for screenshots */\n outputDir: string;\n /** Default width (default: 1920) */\n width?: number;\n /** Default height (default: 1080) */\n height?: number;\n /** Device scale factor for retina (default: 1) */\n deviceScaleFactor?: number;\n /** Image format (default: 'png') */\n format?: 'png' | 'jpeg';\n /** JPEG quality 0-100 (default: 80) */\n quality?: number;\n /** Wait condition (default: 'networkidle') */\n waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';\n /** Timeout in ms (default: 10000) */\n timeoutMs?: number;\n /** Custom HTML template with {{{HEAD}}}, {{{CSS}}}, {{{APP}}} placeholders */\n template?: string;\n\n // Static renderer config (one of these)\n /** Pre-created StaticRenderer instance */\n staticRenderer?: StaticRenderer;\n /** OR: Path to stitch.yaml */\n stitchConfig?: string;\n /** Component directory (required if stitchConfig provided) */\n componentDir?: string;\n}\n\nexport interface CaptureOptions {\n /** View component data */\n view?: any;\n /** Layout data (one object per layout) */\n layout?: any[];\n /** Override width */\n width?: number;\n /** Override height */\n height?: number;\n /** Capture full page (default: true) */\n fullPage?: boolean;\n}\n\nexport interface CaptureHtmlOptions {\n /** Override width */\n width?: number;\n /** Override height */\n height?: number;\n /** Capture full page (default: true) */\n fullPage?: boolean;\n}\n\ninterface CaptureResult {\n /** Raw image buffer */\n buffer: Buffer;\n /** Path where image was saved */\n path: string;\n}\n\n// ─────────────────────────────────────────────────────────────\n// Playwright Loader\n// ─────────────────────────────────────────────────────────────\n\ntype PlaywrightModule = typeof import('playwright');\n\nasync function loadPlaywright(): Promise<PlaywrightModule> {\n try {\n const playwright = await import('playwright');\n return playwright;\n } catch {\n throw new PlaywrightNotInstalledError();\n }\n}\n\n// ─────────────────────────────────────────────────────────────\n// ImageRenderer Class\n// ─────────────────────────────────────────────────────────────\n\nexport class ImageRenderer {\n private browser: any;\n private staticRenderer: StaticRenderer;\n private config: Required<Omit<ImageRendererConfig, 'staticRenderer' | 'stitchConfig' | 'componentDir' | 'template'>> & {\n outputDir: string;\n template?: string;\n };\n\n constructor(\n browser: any,\n staticRenderer: StaticRenderer,\n config: ImageRendererConfig\n ) {\n this.browser = browser;\n this.staticRenderer = staticRenderer;\n this.config = {\n outputDir: config.outputDir,\n width: config.width ?? 1920,\n height: config.height ?? 1080,\n deviceScaleFactor: config.deviceScaleFactor ?? 1,\n format: config.format ?? 'png',\n quality: config.quality ?? 80,\n waitUntil: config.waitUntil ?? 'networkidle',\n timeoutMs: config.timeoutMs ?? 10000,\n template: config.template,\n };\n }\n\n /**\n * Capture a route and save to outputDir\n */\n async capture(\n name: string,\n route: string,\n options?: CaptureOptions\n ): Promise<CaptureResult> {\n // Render route to HTML using StaticRenderer\n const { view, layout } = options ?? {};\n const renderResult = await this.staticRenderer.renderToPage({\n route,\n data: { view, layout },\n template: this.config.template,\n });\n\n // Capture the HTML\n return this.captureHtmlInternal(name, renderResult, options);\n }\n\n /**\n * Capture raw HTML and save to outputDir\n */\n async captureHtml(\n name: string,\n html: string,\n options?: CaptureHtmlOptions\n ): Promise<CaptureResult> {\n return this.captureHtmlInternal(name, html, options);\n }\n\n /**\n * Internal method to capture HTML to image\n */\n private async captureHtmlInternal(\n name: string,\n html: string,\n options?: CaptureHtmlOptions\n ): Promise<CaptureResult> {\n const width = options?.width ?? this.config.width;\n const height = options?.height ?? this.config.height;\n\n let context: any;\n let page: any;\n\n try {\n // Create new context with viewport\n context = await this.browser.newContext({\n viewport: { width, height },\n deviceScaleFactor: this.config.deviceScaleFactor,\n });\n\n // Create page\n page = await context.newPage();\n\n // Set content with timeout\n const waitUntilMapping = {\n 'load': 'load',\n 'domcontentloaded': 'domcontentloaded',\n 'networkidle': 'networkidle',\n } as const;\n\n try {\n await page.setContent(html, {\n waitUntil: waitUntilMapping[this.config.waitUntil],\n timeout: this.config.timeoutMs,\n });\n } catch (error) {\n if (error instanceof Error && error.message.includes('timeout')) {\n throw new RenderTimeoutError(this.config.timeoutMs);\n }\n throw new RenderError(\n 'Failed to render HTML content',\n error instanceof Error ? error : undefined\n );\n }\n\n // Take screenshot\n const screenshotOptions: {\n type: 'png' | 'jpeg';\n quality?: number;\n fullPage: boolean;\n } = {\n type: this.config.format,\n fullPage: options?.fullPage ?? true,\n };\n\n // Quality only applies to JPEG\n if (this.config.format === 'jpeg') {\n screenshotOptions.quality = this.config.quality;\n }\n\n const screenshot = await page.screenshot(screenshotOptions);\n const buffer = Buffer.from(screenshot);\n\n // Generate filename: {name}@{width}x{height}.{format}\n const filename = `${name}@${width}x${height}.${this.config.format}`;\n const outputPath = path.join(this.config.outputDir, filename);\n\n // Ensure output directory exists\n await fs.promises.mkdir(this.config.outputDir, { recursive: true });\n\n // Write file\n await fs.promises.writeFile(outputPath, buffer);\n\n return { buffer, path: outputPath };\n\n } finally {\n // Cleanup context and page (browser stays open)\n if (page) {\n await page.close().catch(() => {});\n }\n if (context) {\n await context.close().catch(() => {});\n }\n }\n }\n\n /**\n * Close browser and clean up resources\n */\n async close(): Promise<void> {\n if (this.browser) {\n await this.browser.close().catch(() => {});\n }\n }\n}\n\n// ─────────────────────────────────────────────────────────────\n// Factory Function\n// ─────────────────────────────────────────────────────────────\n\nexport async function createImageRenderer(\n config: ImageRendererConfig\n): Promise<ImageRenderer> {\n\n // Load Playwright dynamically\n const playwright = await loadPlaywright();\n\n // Launch browser\n let browser: any;\n try {\n browser = await playwright.chromium.launch({\n headless: true,\n });\n } catch (error) {\n throw new BrowserLaunchError(\n 'Failed to launch Chromium. Ensure it is installed with: npx playwright install chromium',\n error instanceof Error ? error : undefined\n );\n }\n\n // Get or create StaticRenderer\n let staticRenderer: StaticRenderer;\n if (config.staticRenderer) {\n staticRenderer = config.staticRenderer;\n } else if (config.stitchConfig && config.componentDir) {\n // Dynamic import to avoid loading Svelte at module resolution time\n const { createStaticRenderer } = await import('../static/index');\n staticRenderer = await createStaticRenderer({\n stitchConfig: config.stitchConfig,\n componentDir: config.componentDir,\n });\n } else {\n throw new Error(\n 'ImageRendererConfig requires either staticRenderer or both stitchConfig and componentDir'\n );\n }\n\n return new ImageRenderer(browser, staticRenderer, config);\n}\n"],"names":["path","fs"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"test.cjs","sources":["../src/test/allow_cli.ts","../src/test/index.ts"],"sourcesContent":["export class ForgeCLIRequiredError extends Error {\n name = 'ForgeCLIRequiredError' as const;\n constructor() {\n super(\n 'This file must be run with the forge CLI, not directly with tsx/node.\\n\\n' +\n 'Usage:\\n' +\n ' npx forge test/ui/your-test.ts\\n' +\n ' npx forge \"test/ui/**/*.test.ts\"\\n\\n' +\n 'The forge CLI provides the necessary Svelte loader for rendering components.'\n );\n }\n}\n\n//We need to throw on import so that this crashes right away\nif (!process.env.FORGE_CLI) {\n throw new ForgeCLIRequiredError();\n}","import './allow_cli';\n// Type-only import - erased at runtime, won't trigger module loading\nimport type { StaticRenderer } from '../static/index';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nexport type { StaticRenderer };\n\n\n\n// ─────────────────────────────────────────────────────────────\n// Error Classes\n// ─────────────────────────────────────────────────────────────\n\n\n\nexport class PlaywrightNotInstalledError extends Error {\n name = 'PlaywrightNotInstalledError' as const;\n constructor() {\n super(\n 'ImageRenderer requires Playwright. Install it with:\\n' +\n ' npm install playwright\\n' +\n ' npx playwright install chromium'\n );\n }\n}\n\nexport class BrowserLaunchError extends Error {\n name = 'BrowserLaunchError' as const;\n constructor(message: string, public cause?: Error) {\n super(message);\n }\n}\n\nexport class RenderTimeoutError extends Error {\n name = 'RenderTimeoutError' as const;\n constructor(timeoutMs: number) {\n super(`Render timed out after ${timeoutMs}ms`);\n }\n}\n\nexport class RenderError extends Error {\n name = 'RenderError' as const;\n constructor(message: string, public cause?: Error) {\n super(message);\n }\n}\n\n\n// ─────────────────────────────────────────────────────────────\n// Types\n// ─────────────────────────────────────────────────────────────\n\nexport interface ImageRendererConfig {\n /** Output directory for screenshots */\n outputDir: string;\n /** Default width (default: 1920) */\n width?: number;\n /** Default height (default: 1080) */\n height?: number;\n /** Device scale factor for retina (default: 1) */\n deviceScaleFactor?: number;\n /** Image format (default: 'png') */\n format?: 'png' | 'jpeg';\n /** JPEG quality 0-100 (default: 80) */\n quality?: number;\n /** Wait condition (default: 'networkidle') */\n waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';\n /** Timeout in ms (default: 10000) */\n timeoutMs?: number;\n /** Custom HTML template with {{{HEAD}}}, {{{CSS}}}, {{{APP}}} placeholders */\n template?: string;\n\n // Static renderer config (one of these)\n /** Pre-created StaticRenderer instance */\n staticRenderer?: StaticRenderer;\n /** OR: Path to stitch.yaml */\n stitchConfig?: string;\n /** Component directory (required if stitchConfig provided) */\n componentDir?: string;\n /** Asset path mappings (e.g., { '/images': ['public/images'] }) */\n assets?: Record<string, string[]>;\n}\n\nexport interface CaptureOptions {\n /** View component data */\n view?: any;\n /** Layout data (one object per layout) */\n layout?: any[];\n /** Override width */\n width?: number;\n /** Override height */\n height?: number;\n /** Capture full page (default: true) */\n fullPage?: boolean;\n}\n\nexport interface CaptureHtmlOptions {\n /** Override width */\n width?: number;\n /** Override height */\n height?: number;\n /** Capture full page (default: true) */\n fullPage?: boolean;\n}\n\ninterface CaptureResult {\n /** Raw image buffer */\n buffer: Buffer;\n /** Path where image was saved */\n path: string;\n}\n\n// ─────────────────────────────────────────────────────────────\n// Playwright Loader\n// ─────────────────────────────────────────────────────────────\n\ntype PlaywrightModule = typeof import('playwright');\n\nasync function loadPlaywright(): Promise<PlaywrightModule> {\n try {\n const playwright = await import('playwright');\n return playwright;\n } catch {\n throw new PlaywrightNotInstalledError();\n }\n}\n\n// ─────────────────────────────────────────────────────────────\n// Content Type Helper\n// ─────────────────────────────────────────────────────────────\n\n/**\n * Determines the MIME content type for a file based on its extension.\n * The browser needs this to know how to handle the response\n * (e.g., render an image vs execute JavaScript).\n */\nfunction getContentType(filePath: string): string {\n const ext = path.extname(filePath).toLowerCase();\n const types: Record<string, string> = {\n // Images\n '.png': 'image/png',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.gif': 'image/gif',\n '.svg': 'image/svg+xml',\n '.webp': 'image/webp',\n // Stylesheets & Scripts\n '.css': 'text/css',\n '.js': 'application/javascript',\n '.json': 'application/json',\n // Fonts\n '.woff': 'font/woff',\n '.woff2': 'font/woff2',\n '.ttf': 'font/ttf',\n };\n // Fallback for unknown extensions\n return types[ext] || 'application/octet-stream';\n}\n\n// ─────────────────────────────────────────────────────────────\n// ImageRenderer Class\n// ─────────────────────────────────────────────────────────────\n\nexport class ImageRenderer {\n private browser: any;\n private staticRenderer: StaticRenderer;\n private config: Required<Omit<ImageRendererConfig, 'staticRenderer' | 'stitchConfig' | 'componentDir' | 'template' | 'assets'>> & {\n outputDir: string;\n template?: string;\n assets?: Record<string, string[]>;\n };\n\n constructor(\n browser: any,\n staticRenderer: StaticRenderer,\n config: ImageRendererConfig\n ) {\n this.browser = browser;\n this.staticRenderer = staticRenderer;\n this.config = {\n outputDir: config.outputDir,\n width: config.width ?? 1920,\n height: config.height ?? 1080,\n deviceScaleFactor: config.deviceScaleFactor ?? 1,\n format: config.format ?? 'png',\n quality: config.quality ?? 80,\n waitUntil: config.waitUntil ?? 'networkidle',\n timeoutMs: config.timeoutMs ?? 10000,\n template: config.template,\n assets: config.assets,\n };\n }\n\n /**\n * Capture a route and save to outputDir\n */\n async capture(\n name: string,\n route: string,\n options?: CaptureOptions\n ): Promise<CaptureResult> {\n // Render route to HTML using StaticRenderer\n const { view, layout } = options ?? {};\n const renderResult = await this.staticRenderer.renderToPage({\n route,\n data: { view, layout },\n template: this.config.template,\n });\n\n // Capture the HTML\n return this.captureHtmlInternal(name, renderResult, options);\n }\n\n /**\n * Capture raw HTML and save to outputDir\n */\n async captureHtml(\n name: string,\n html: string,\n options?: CaptureHtmlOptions\n ): Promise<CaptureResult> {\n return this.captureHtmlInternal(name, html, options);\n }\n\n /**\n * Internal method to capture HTML to image\n */\n private async captureHtmlInternal(\n name: string,\n html: string,\n options?: CaptureHtmlOptions\n ): Promise<CaptureResult> {\n const width = options?.width ?? this.config.width;\n const height = options?.height ?? this.config.height;\n\n let context: any;\n let page: any;\n\n try {\n // Create new context with viewport\n context = await this.browser.newContext({\n viewport: { width, height },\n deviceScaleFactor: this.config.deviceScaleFactor,\n });\n\n // Create page\n page = await context.newPage();\n\n // Route Interception Strategy\n // ===========================\n // We use page.goto() with a fake URL instead of page.setContent() because:\n // - setContent() has no base URL, so relative paths like \"/images/...\" can't resolve\n // - goto() with route interception allows all requests (HTML + assets) to be handled\n\n const FAKE_BASE_URL = 'http://forge-renderer/';\n\n // Route the base URL to return the HTML content\n await page.route(FAKE_BASE_URL, async (route: any) => {\n await route.fulfill({\n body: html,\n contentType: 'text/html',\n });\n });\n\n // Asset Route Interception\n // ========================\n // Intercept requests for assets and serve them from the filesystem\n if (this.config.assets) {\n // Loop through each URL path mapping (e.g., '/images' -> ['ui/resources/images'])\n for (const [urlPath, fsPaths] of Object.entries(this.config.assets)) {\n // Register a route handler for all requests matching this URL pattern\n await page.route(`**${urlPath}/**`, async (route: any) => {\n // Extract the relative path from the full URL\n const url = new URL(route.request().url());\n const relativePath = url.pathname.replace(urlPath, '');\n\n // Try each filesystem path in order (supports fallback directories)\n for (const fsPath of fsPaths) {\n // Build the full filesystem path\n const filePath = path.join(fsPath, relativePath);\n\n try {\n // Read the file from disk\n const body = await fs.promises.readFile(filePath);\n\n // Determine the MIME type based on file extension\n const contentType = getContentType(filePath);\n\n // Respond to the browser with the file contents\n await route.fulfill({ body, contentType });\n return; // File found, stop searching\n } catch {\n // File not found in this path, try the next one\n }\n }\n\n // No file found in any path - abort the request\n await route.abort('filenotfound');\n });\n }\n }\n\n // Navigate to fake URL (which returns our HTML via route interception)\n const waitUntilMapping = {\n 'load': 'load',\n 'domcontentloaded': 'domcontentloaded',\n 'networkidle': 'networkidle',\n } as const;\n\n try {\n await page.goto(FAKE_BASE_URL, {\n waitUntil: waitUntilMapping[this.config.waitUntil],\n timeout: this.config.timeoutMs,\n });\n } catch (error) {\n if (error instanceof Error && error.message.includes('timeout')) {\n throw new RenderTimeoutError(this.config.timeoutMs);\n }\n throw new RenderError(\n 'Failed to render HTML content',\n error instanceof Error ? error : undefined\n );\n }\n\n // Take screenshot\n const screenshotOptions: {\n type: 'png' | 'jpeg';\n quality?: number;\n fullPage: boolean;\n } = {\n type: this.config.format,\n fullPage: options?.fullPage ?? true,\n };\n\n // Quality only applies to JPEG\n if (this.config.format === 'jpeg') {\n screenshotOptions.quality = this.config.quality;\n }\n\n const screenshot = await page.screenshot(screenshotOptions);\n const buffer = Buffer.from(screenshot);\n\n // Generate filename: {name}@{width}x{height}.{format}\n const filename = `${name}@${width}x${height}.${this.config.format}`;\n const outputPath = path.join(this.config.outputDir, filename);\n\n // Ensure output directory exists\n await fs.promises.mkdir(this.config.outputDir, { recursive: true });\n\n // Write file\n await fs.promises.writeFile(outputPath, buffer);\n\n return { buffer, path: outputPath };\n\n } finally {\n // Cleanup context and page (browser stays open)\n if (page) {\n await page.close().catch(() => {});\n }\n if (context) {\n await context.close().catch(() => {});\n }\n }\n }\n\n /**\n * Close browser and clean up resources\n */\n async close(): Promise<void> {\n if (this.browser) {\n await this.browser.close().catch(() => {});\n }\n }\n}\n\n// ─────────────────────────────────────────────────────────────\n// Factory Function\n// ─────────────────────────────────────────────────────────────\n\nexport async function createImageRenderer(\n config: ImageRendererConfig\n): Promise<ImageRenderer> {\n\n // Load Playwright dynamically\n const playwright = await loadPlaywright();\n\n // Launch browser\n let browser: any;\n try {\n browser = await playwright.chromium.launch({\n headless: true,\n });\n } catch (error) {\n throw new BrowserLaunchError(\n 'Failed to launch Chromium. Ensure it is installed with: npx playwright install chromium',\n error instanceof Error ? error : undefined\n );\n }\n\n // Get or create StaticRenderer\n let staticRenderer: StaticRenderer;\n if (config.staticRenderer) {\n staticRenderer = config.staticRenderer;\n } else if (config.stitchConfig && config.componentDir) {\n // Dynamic import to avoid loading Svelte at module resolution time\n const { createStaticRenderer } = await import('../static/index');\n staticRenderer = await createStaticRenderer({\n stitchConfig: config.stitchConfig,\n componentDir: config.componentDir,\n });\n } else {\n throw new Error(\n 'ImageRendererConfig requires either staticRenderer or both stitchConfig and componentDir'\n );\n }\n\n return new ImageRenderer(browser, staticRenderer, config);\n}\n"],"names":["path","fs"],"mappings":"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"}
|
package/dist-ssr/test.d.ts
CHANGED
|
@@ -115,6 +115,8 @@ export declare interface ImageRendererConfig {
|
|
|
115
115
|
stitchConfig?: string;
|
|
116
116
|
/** Component directory (required if stitchConfig provided) */
|
|
117
117
|
componentDir?: string;
|
|
118
|
+
/** Asset path mappings (e.g., { '/images': ['public/images'] }) */
|
|
119
|
+
assets?: Record<string, string[]>;
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
declare interface IRoute {
|
package/dist-ssr/test.js
CHANGED
|
@@ -50,6 +50,27 @@ async function loadPlaywright() {
|
|
|
50
50
|
throw new PlaywrightNotInstalledError();
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
function getContentType(filePath) {
|
|
54
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
55
|
+
const types = {
|
|
56
|
+
// Images
|
|
57
|
+
".png": "image/png",
|
|
58
|
+
".jpg": "image/jpeg",
|
|
59
|
+
".jpeg": "image/jpeg",
|
|
60
|
+
".gif": "image/gif",
|
|
61
|
+
".svg": "image/svg+xml",
|
|
62
|
+
".webp": "image/webp",
|
|
63
|
+
// Stylesheets & Scripts
|
|
64
|
+
".css": "text/css",
|
|
65
|
+
".js": "application/javascript",
|
|
66
|
+
".json": "application/json",
|
|
67
|
+
// Fonts
|
|
68
|
+
".woff": "font/woff",
|
|
69
|
+
".woff2": "font/woff2",
|
|
70
|
+
".ttf": "font/ttf"
|
|
71
|
+
};
|
|
72
|
+
return types[ext] || "application/octet-stream";
|
|
73
|
+
}
|
|
53
74
|
class ImageRenderer {
|
|
54
75
|
constructor(browser, staticRenderer, config) {
|
|
55
76
|
__publicField(this, "browser");
|
|
@@ -66,7 +87,8 @@ class ImageRenderer {
|
|
|
66
87
|
quality: config.quality ?? 80,
|
|
67
88
|
waitUntil: config.waitUntil ?? "networkidle",
|
|
68
89
|
timeoutMs: config.timeoutMs ?? 1e4,
|
|
69
|
-
template: config.template
|
|
90
|
+
template: config.template,
|
|
91
|
+
assets: config.assets
|
|
70
92
|
};
|
|
71
93
|
}
|
|
72
94
|
/**
|
|
@@ -101,13 +123,39 @@ class ImageRenderer {
|
|
|
101
123
|
deviceScaleFactor: this.config.deviceScaleFactor
|
|
102
124
|
});
|
|
103
125
|
page = await context.newPage();
|
|
126
|
+
const FAKE_BASE_URL = "http://forge-renderer/";
|
|
127
|
+
await page.route(FAKE_BASE_URL, async (route) => {
|
|
128
|
+
await route.fulfill({
|
|
129
|
+
body: html,
|
|
130
|
+
contentType: "text/html"
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
if (this.config.assets) {
|
|
134
|
+
for (const [urlPath, fsPaths] of Object.entries(this.config.assets)) {
|
|
135
|
+
await page.route(`**${urlPath}/**`, async (route) => {
|
|
136
|
+
const url = new URL(route.request().url());
|
|
137
|
+
const relativePath = url.pathname.replace(urlPath, "");
|
|
138
|
+
for (const fsPath of fsPaths) {
|
|
139
|
+
const filePath = path.join(fsPath, relativePath);
|
|
140
|
+
try {
|
|
141
|
+
const body = await fs.promises.readFile(filePath);
|
|
142
|
+
const contentType = getContentType(filePath);
|
|
143
|
+
await route.fulfill({ body, contentType });
|
|
144
|
+
return;
|
|
145
|
+
} catch {
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
await route.abort("filenotfound");
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
104
152
|
const waitUntilMapping = {
|
|
105
153
|
"load": "load",
|
|
106
154
|
"domcontentloaded": "domcontentloaded",
|
|
107
155
|
"networkidle": "networkidle"
|
|
108
156
|
};
|
|
109
157
|
try {
|
|
110
|
-
await page.
|
|
158
|
+
await page.goto(FAKE_BASE_URL, {
|
|
111
159
|
waitUntil: waitUntilMapping[this.config.waitUntil],
|
|
112
160
|
timeout: this.config.timeoutMs
|
|
113
161
|
});
|
package/dist-ssr/test.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test.js","sources":["../src/test/allow_cli.ts","../src/test/index.ts"],"sourcesContent":["export class ForgeCLIRequiredError extends Error {\n name = 'ForgeCLIRequiredError' as const;\n constructor() {\n super(\n 'This file must be run with the forge CLI, not directly with tsx/node.\\n\\n' +\n 'Usage:\\n' +\n ' npx forge test/ui/your-test.ts\\n' +\n ' npx forge \"test/ui/**/*.test.ts\"\\n\\n' +\n 'The forge CLI provides the necessary Svelte loader for rendering components.'\n );\n }\n}\n\n//We need to throw on import so that this crashes right away\nif (!process.env.FORGE_CLI) {\n throw new ForgeCLIRequiredError();\n}","import './allow_cli';\n// Type-only import - erased at runtime, won't trigger module loading\nimport type { StaticRenderer } from '../static/index';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nexport type { StaticRenderer };\n\n\n\n// ─────────────────────────────────────────────────────────────\n// Error Classes\n// ─────────────────────────────────────────────────────────────\n\n\n\nexport class PlaywrightNotInstalledError extends Error {\n name = 'PlaywrightNotInstalledError' as const;\n constructor() {\n super(\n 'ImageRenderer requires Playwright. Install it with:\\n' +\n ' npm install playwright\\n' +\n ' npx playwright install chromium'\n );\n }\n}\n\nexport class BrowserLaunchError extends Error {\n name = 'BrowserLaunchError' as const;\n constructor(message: string, public cause?: Error) {\n super(message);\n }\n}\n\nexport class RenderTimeoutError extends Error {\n name = 'RenderTimeoutError' as const;\n constructor(timeoutMs: number) {\n super(`Render timed out after ${timeoutMs}ms`);\n }\n}\n\nexport class RenderError extends Error {\n name = 'RenderError' as const;\n constructor(message: string, public cause?: Error) {\n super(message);\n }\n}\n\n\n// ─────────────────────────────────────────────────────────────\n// Types\n// ─────────────────────────────────────────────────────────────\n\nexport interface ImageRendererConfig {\n /** Output directory for screenshots */\n outputDir: string;\n /** Default width (default: 1920) */\n width?: number;\n /** Default height (default: 1080) */\n height?: number;\n /** Device scale factor for retina (default: 1) */\n deviceScaleFactor?: number;\n /** Image format (default: 'png') */\n format?: 'png' | 'jpeg';\n /** JPEG quality 0-100 (default: 80) */\n quality?: number;\n /** Wait condition (default: 'networkidle') */\n waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';\n /** Timeout in ms (default: 10000) */\n timeoutMs?: number;\n /** Custom HTML template with {{{HEAD}}}, {{{CSS}}}, {{{APP}}} placeholders */\n template?: string;\n\n // Static renderer config (one of these)\n /** Pre-created StaticRenderer instance */\n staticRenderer?: StaticRenderer;\n /** OR: Path to stitch.yaml */\n stitchConfig?: string;\n /** Component directory (required if stitchConfig provided) */\n componentDir?: string;\n}\n\nexport interface CaptureOptions {\n /** View component data */\n view?: any;\n /** Layout data (one object per layout) */\n layout?: any[];\n /** Override width */\n width?: number;\n /** Override height */\n height?: number;\n /** Capture full page (default: true) */\n fullPage?: boolean;\n}\n\nexport interface CaptureHtmlOptions {\n /** Override width */\n width?: number;\n /** Override height */\n height?: number;\n /** Capture full page (default: true) */\n fullPage?: boolean;\n}\n\ninterface CaptureResult {\n /** Raw image buffer */\n buffer: Buffer;\n /** Path where image was saved */\n path: string;\n}\n\n// ─────────────────────────────────────────────────────────────\n// Playwright Loader\n// ─────────────────────────────────────────────────────────────\n\ntype PlaywrightModule = typeof import('playwright');\n\nasync function loadPlaywright(): Promise<PlaywrightModule> {\n try {\n const playwright = await import('playwright');\n return playwright;\n } catch {\n throw new PlaywrightNotInstalledError();\n }\n}\n\n// ─────────────────────────────────────────────────────────────\n// ImageRenderer Class\n// ─────────────────────────────────────────────────────────────\n\nexport class ImageRenderer {\n private browser: any;\n private staticRenderer: StaticRenderer;\n private config: Required<Omit<ImageRendererConfig, 'staticRenderer' | 'stitchConfig' | 'componentDir' | 'template'>> & {\n outputDir: string;\n template?: string;\n };\n\n constructor(\n browser: any,\n staticRenderer: StaticRenderer,\n config: ImageRendererConfig\n ) {\n this.browser = browser;\n this.staticRenderer = staticRenderer;\n this.config = {\n outputDir: config.outputDir,\n width: config.width ?? 1920,\n height: config.height ?? 1080,\n deviceScaleFactor: config.deviceScaleFactor ?? 1,\n format: config.format ?? 'png',\n quality: config.quality ?? 80,\n waitUntil: config.waitUntil ?? 'networkidle',\n timeoutMs: config.timeoutMs ?? 10000,\n template: config.template,\n };\n }\n\n /**\n * Capture a route and save to outputDir\n */\n async capture(\n name: string,\n route: string,\n options?: CaptureOptions\n ): Promise<CaptureResult> {\n // Render route to HTML using StaticRenderer\n const { view, layout } = options ?? {};\n const renderResult = await this.staticRenderer.renderToPage({\n route,\n data: { view, layout },\n template: this.config.template,\n });\n\n // Capture the HTML\n return this.captureHtmlInternal(name, renderResult, options);\n }\n\n /**\n * Capture raw HTML and save to outputDir\n */\n async captureHtml(\n name: string,\n html: string,\n options?: CaptureHtmlOptions\n ): Promise<CaptureResult> {\n return this.captureHtmlInternal(name, html, options);\n }\n\n /**\n * Internal method to capture HTML to image\n */\n private async captureHtmlInternal(\n name: string,\n html: string,\n options?: CaptureHtmlOptions\n ): Promise<CaptureResult> {\n const width = options?.width ?? this.config.width;\n const height = options?.height ?? this.config.height;\n\n let context: any;\n let page: any;\n\n try {\n // Create new context with viewport\n context = await this.browser.newContext({\n viewport: { width, height },\n deviceScaleFactor: this.config.deviceScaleFactor,\n });\n\n // Create page\n page = await context.newPage();\n\n // Set content with timeout\n const waitUntilMapping = {\n 'load': 'load',\n 'domcontentloaded': 'domcontentloaded',\n 'networkidle': 'networkidle',\n } as const;\n\n try {\n await page.setContent(html, {\n waitUntil: waitUntilMapping[this.config.waitUntil],\n timeout: this.config.timeoutMs,\n });\n } catch (error) {\n if (error instanceof Error && error.message.includes('timeout')) {\n throw new RenderTimeoutError(this.config.timeoutMs);\n }\n throw new RenderError(\n 'Failed to render HTML content',\n error instanceof Error ? error : undefined\n );\n }\n\n // Take screenshot\n const screenshotOptions: {\n type: 'png' | 'jpeg';\n quality?: number;\n fullPage: boolean;\n } = {\n type: this.config.format,\n fullPage: options?.fullPage ?? true,\n };\n\n // Quality only applies to JPEG\n if (this.config.format === 'jpeg') {\n screenshotOptions.quality = this.config.quality;\n }\n\n const screenshot = await page.screenshot(screenshotOptions);\n const buffer = Buffer.from(screenshot);\n\n // Generate filename: {name}@{width}x{height}.{format}\n const filename = `${name}@${width}x${height}.${this.config.format}`;\n const outputPath = path.join(this.config.outputDir, filename);\n\n // Ensure output directory exists\n await fs.promises.mkdir(this.config.outputDir, { recursive: true });\n\n // Write file\n await fs.promises.writeFile(outputPath, buffer);\n\n return { buffer, path: outputPath };\n\n } finally {\n // Cleanup context and page (browser stays open)\n if (page) {\n await page.close().catch(() => {});\n }\n if (context) {\n await context.close().catch(() => {});\n }\n }\n }\n\n /**\n * Close browser and clean up resources\n */\n async close(): Promise<void> {\n if (this.browser) {\n await this.browser.close().catch(() => {});\n }\n }\n}\n\n// ─────────────────────────────────────────────────────────────\n// Factory Function\n// ─────────────────────────────────────────────────────────────\n\nexport async function createImageRenderer(\n config: ImageRendererConfig\n): Promise<ImageRenderer> {\n\n // Load Playwright dynamically\n const playwright = await loadPlaywright();\n\n // Launch browser\n let browser: any;\n try {\n browser = await playwright.chromium.launch({\n headless: true,\n });\n } catch (error) {\n throw new BrowserLaunchError(\n 'Failed to launch Chromium. Ensure it is installed with: npx playwright install chromium',\n error instanceof Error ? error : undefined\n );\n }\n\n // Get or create StaticRenderer\n let staticRenderer: StaticRenderer;\n if (config.staticRenderer) {\n staticRenderer = config.staticRenderer;\n } else if (config.stitchConfig && config.componentDir) {\n // Dynamic import to avoid loading Svelte at module resolution time\n const { createStaticRenderer } = await import('../static/index');\n staticRenderer = await createStaticRenderer({\n stitchConfig: config.stitchConfig,\n componentDir: config.componentDir,\n });\n } else {\n throw new Error(\n 'ImageRendererConfig requires either staticRenderer or both stitchConfig and componentDir'\n );\n }\n\n return new ImageRenderer(browser, staticRenderer, config);\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"test.js","sources":["../src/test/allow_cli.ts","../src/test/index.ts"],"sourcesContent":["export class ForgeCLIRequiredError extends Error {\n name = 'ForgeCLIRequiredError' as const;\n constructor() {\n super(\n 'This file must be run with the forge CLI, not directly with tsx/node.\\n\\n' +\n 'Usage:\\n' +\n ' npx forge test/ui/your-test.ts\\n' +\n ' npx forge \"test/ui/**/*.test.ts\"\\n\\n' +\n 'The forge CLI provides the necessary Svelte loader for rendering components.'\n );\n }\n}\n\n//We need to throw on import so that this crashes right away\nif (!process.env.FORGE_CLI) {\n throw new ForgeCLIRequiredError();\n}","import './allow_cli';\n// Type-only import - erased at runtime, won't trigger module loading\nimport type { StaticRenderer } from '../static/index';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nexport type { StaticRenderer };\n\n\n\n// ─────────────────────────────────────────────────────────────\n// Error Classes\n// ─────────────────────────────────────────────────────────────\n\n\n\nexport class PlaywrightNotInstalledError extends Error {\n name = 'PlaywrightNotInstalledError' as const;\n constructor() {\n super(\n 'ImageRenderer requires Playwright. Install it with:\\n' +\n ' npm install playwright\\n' +\n ' npx playwright install chromium'\n );\n }\n}\n\nexport class BrowserLaunchError extends Error {\n name = 'BrowserLaunchError' as const;\n constructor(message: string, public cause?: Error) {\n super(message);\n }\n}\n\nexport class RenderTimeoutError extends Error {\n name = 'RenderTimeoutError' as const;\n constructor(timeoutMs: number) {\n super(`Render timed out after ${timeoutMs}ms`);\n }\n}\n\nexport class RenderError extends Error {\n name = 'RenderError' as const;\n constructor(message: string, public cause?: Error) {\n super(message);\n }\n}\n\n\n// ─────────────────────────────────────────────────────────────\n// Types\n// ─────────────────────────────────────────────────────────────\n\nexport interface ImageRendererConfig {\n /** Output directory for screenshots */\n outputDir: string;\n /** Default width (default: 1920) */\n width?: number;\n /** Default height (default: 1080) */\n height?: number;\n /** Device scale factor for retina (default: 1) */\n deviceScaleFactor?: number;\n /** Image format (default: 'png') */\n format?: 'png' | 'jpeg';\n /** JPEG quality 0-100 (default: 80) */\n quality?: number;\n /** Wait condition (default: 'networkidle') */\n waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';\n /** Timeout in ms (default: 10000) */\n timeoutMs?: number;\n /** Custom HTML template with {{{HEAD}}}, {{{CSS}}}, {{{APP}}} placeholders */\n template?: string;\n\n // Static renderer config (one of these)\n /** Pre-created StaticRenderer instance */\n staticRenderer?: StaticRenderer;\n /** OR: Path to stitch.yaml */\n stitchConfig?: string;\n /** Component directory (required if stitchConfig provided) */\n componentDir?: string;\n /** Asset path mappings (e.g., { '/images': ['public/images'] }) */\n assets?: Record<string, string[]>;\n}\n\nexport interface CaptureOptions {\n /** View component data */\n view?: any;\n /** Layout data (one object per layout) */\n layout?: any[];\n /** Override width */\n width?: number;\n /** Override height */\n height?: number;\n /** Capture full page (default: true) */\n fullPage?: boolean;\n}\n\nexport interface CaptureHtmlOptions {\n /** Override width */\n width?: number;\n /** Override height */\n height?: number;\n /** Capture full page (default: true) */\n fullPage?: boolean;\n}\n\ninterface CaptureResult {\n /** Raw image buffer */\n buffer: Buffer;\n /** Path where image was saved */\n path: string;\n}\n\n// ─────────────────────────────────────────────────────────────\n// Playwright Loader\n// ─────────────────────────────────────────────────────────────\n\ntype PlaywrightModule = typeof import('playwright');\n\nasync function loadPlaywright(): Promise<PlaywrightModule> {\n try {\n const playwright = await import('playwright');\n return playwright;\n } catch {\n throw new PlaywrightNotInstalledError();\n }\n}\n\n// ─────────────────────────────────────────────────────────────\n// Content Type Helper\n// ─────────────────────────────────────────────────────────────\n\n/**\n * Determines the MIME content type for a file based on its extension.\n * The browser needs this to know how to handle the response\n * (e.g., render an image vs execute JavaScript).\n */\nfunction getContentType(filePath: string): string {\n const ext = path.extname(filePath).toLowerCase();\n const types: Record<string, string> = {\n // Images\n '.png': 'image/png',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.gif': 'image/gif',\n '.svg': 'image/svg+xml',\n '.webp': 'image/webp',\n // Stylesheets & Scripts\n '.css': 'text/css',\n '.js': 'application/javascript',\n '.json': 'application/json',\n // Fonts\n '.woff': 'font/woff',\n '.woff2': 'font/woff2',\n '.ttf': 'font/ttf',\n };\n // Fallback for unknown extensions\n return types[ext] || 'application/octet-stream';\n}\n\n// ─────────────────────────────────────────────────────────────\n// ImageRenderer Class\n// ─────────────────────────────────────────────────────────────\n\nexport class ImageRenderer {\n private browser: any;\n private staticRenderer: StaticRenderer;\n private config: Required<Omit<ImageRendererConfig, 'staticRenderer' | 'stitchConfig' | 'componentDir' | 'template' | 'assets'>> & {\n outputDir: string;\n template?: string;\n assets?: Record<string, string[]>;\n };\n\n constructor(\n browser: any,\n staticRenderer: StaticRenderer,\n config: ImageRendererConfig\n ) {\n this.browser = browser;\n this.staticRenderer = staticRenderer;\n this.config = {\n outputDir: config.outputDir,\n width: config.width ?? 1920,\n height: config.height ?? 1080,\n deviceScaleFactor: config.deviceScaleFactor ?? 1,\n format: config.format ?? 'png',\n quality: config.quality ?? 80,\n waitUntil: config.waitUntil ?? 'networkidle',\n timeoutMs: config.timeoutMs ?? 10000,\n template: config.template,\n assets: config.assets,\n };\n }\n\n /**\n * Capture a route and save to outputDir\n */\n async capture(\n name: string,\n route: string,\n options?: CaptureOptions\n ): Promise<CaptureResult> {\n // Render route to HTML using StaticRenderer\n const { view, layout } = options ?? {};\n const renderResult = await this.staticRenderer.renderToPage({\n route,\n data: { view, layout },\n template: this.config.template,\n });\n\n // Capture the HTML\n return this.captureHtmlInternal(name, renderResult, options);\n }\n\n /**\n * Capture raw HTML and save to outputDir\n */\n async captureHtml(\n name: string,\n html: string,\n options?: CaptureHtmlOptions\n ): Promise<CaptureResult> {\n return this.captureHtmlInternal(name, html, options);\n }\n\n /**\n * Internal method to capture HTML to image\n */\n private async captureHtmlInternal(\n name: string,\n html: string,\n options?: CaptureHtmlOptions\n ): Promise<CaptureResult> {\n const width = options?.width ?? this.config.width;\n const height = options?.height ?? this.config.height;\n\n let context: any;\n let page: any;\n\n try {\n // Create new context with viewport\n context = await this.browser.newContext({\n viewport: { width, height },\n deviceScaleFactor: this.config.deviceScaleFactor,\n });\n\n // Create page\n page = await context.newPage();\n\n // Route Interception Strategy\n // ===========================\n // We use page.goto() with a fake URL instead of page.setContent() because:\n // - setContent() has no base URL, so relative paths like \"/images/...\" can't resolve\n // - goto() with route interception allows all requests (HTML + assets) to be handled\n\n const FAKE_BASE_URL = 'http://forge-renderer/';\n\n // Route the base URL to return the HTML content\n await page.route(FAKE_BASE_URL, async (route: any) => {\n await route.fulfill({\n body: html,\n contentType: 'text/html',\n });\n });\n\n // Asset Route Interception\n // ========================\n // Intercept requests for assets and serve them from the filesystem\n if (this.config.assets) {\n // Loop through each URL path mapping (e.g., '/images' -> ['ui/resources/images'])\n for (const [urlPath, fsPaths] of Object.entries(this.config.assets)) {\n // Register a route handler for all requests matching this URL pattern\n await page.route(`**${urlPath}/**`, async (route: any) => {\n // Extract the relative path from the full URL\n const url = new URL(route.request().url());\n const relativePath = url.pathname.replace(urlPath, '');\n\n // Try each filesystem path in order (supports fallback directories)\n for (const fsPath of fsPaths) {\n // Build the full filesystem path\n const filePath = path.join(fsPath, relativePath);\n\n try {\n // Read the file from disk\n const body = await fs.promises.readFile(filePath);\n\n // Determine the MIME type based on file extension\n const contentType = getContentType(filePath);\n\n // Respond to the browser with the file contents\n await route.fulfill({ body, contentType });\n return; // File found, stop searching\n } catch {\n // File not found in this path, try the next one\n }\n }\n\n // No file found in any path - abort the request\n await route.abort('filenotfound');\n });\n }\n }\n\n // Navigate to fake URL (which returns our HTML via route interception)\n const waitUntilMapping = {\n 'load': 'load',\n 'domcontentloaded': 'domcontentloaded',\n 'networkidle': 'networkidle',\n } as const;\n\n try {\n await page.goto(FAKE_BASE_URL, {\n waitUntil: waitUntilMapping[this.config.waitUntil],\n timeout: this.config.timeoutMs,\n });\n } catch (error) {\n if (error instanceof Error && error.message.includes('timeout')) {\n throw new RenderTimeoutError(this.config.timeoutMs);\n }\n throw new RenderError(\n 'Failed to render HTML content',\n error instanceof Error ? error : undefined\n );\n }\n\n // Take screenshot\n const screenshotOptions: {\n type: 'png' | 'jpeg';\n quality?: number;\n fullPage: boolean;\n } = {\n type: this.config.format,\n fullPage: options?.fullPage ?? true,\n };\n\n // Quality only applies to JPEG\n if (this.config.format === 'jpeg') {\n screenshotOptions.quality = this.config.quality;\n }\n\n const screenshot = await page.screenshot(screenshotOptions);\n const buffer = Buffer.from(screenshot);\n\n // Generate filename: {name}@{width}x{height}.{format}\n const filename = `${name}@${width}x${height}.${this.config.format}`;\n const outputPath = path.join(this.config.outputDir, filename);\n\n // Ensure output directory exists\n await fs.promises.mkdir(this.config.outputDir, { recursive: true });\n\n // Write file\n await fs.promises.writeFile(outputPath, buffer);\n\n return { buffer, path: outputPath };\n\n } finally {\n // Cleanup context and page (browser stays open)\n if (page) {\n await page.close().catch(() => {});\n }\n if (context) {\n await context.close().catch(() => {});\n }\n }\n }\n\n /**\n * Close browser and clean up resources\n */\n async close(): Promise<void> {\n if (this.browser) {\n await this.browser.close().catch(() => {});\n }\n }\n}\n\n// ─────────────────────────────────────────────────────────────\n// Factory Function\n// ─────────────────────────────────────────────────────────────\n\nexport async function createImageRenderer(\n config: ImageRendererConfig\n): Promise<ImageRenderer> {\n\n // Load Playwright dynamically\n const playwright = await loadPlaywright();\n\n // Launch browser\n let browser: any;\n try {\n browser = await playwright.chromium.launch({\n headless: true,\n });\n } catch (error) {\n throw new BrowserLaunchError(\n 'Failed to launch Chromium. Ensure it is installed with: npx playwright install chromium',\n error instanceof Error ? error : undefined\n );\n }\n\n // Get or create StaticRenderer\n let staticRenderer: StaticRenderer;\n if (config.staticRenderer) {\n staticRenderer = config.staticRenderer;\n } else if (config.stitchConfig && config.componentDir) {\n // Dynamic import to avoid loading Svelte at module resolution time\n const { createStaticRenderer } = await import('../static/index');\n staticRenderer = await createStaticRenderer({\n stitchConfig: config.stitchConfig,\n componentDir: config.componentDir,\n });\n } else {\n throw new Error(\n 'ImageRendererConfig requires either staticRenderer or both stitchConfig and componentDir'\n );\n }\n\n return new ImageRenderer(browser, staticRenderer, config);\n}\n"],"names":[],"mappings":"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"}
|