design-clone 1.1.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +42 -20
  2. package/SKILL.md +74 -0
  3. package/bin/commands/clone-site.js +75 -10
  4. package/bin/commands/init.js +33 -1
  5. package/bin/commands/verify.js +5 -3
  6. package/bin/utils/validate.js +24 -8
  7. package/docs/cli-reference.md +224 -2
  8. package/docs/codebase-summary.md +309 -0
  9. package/docs/design-clone-architecture.md +290 -45
  10. package/docs/pixel-perfect.md +35 -4
  11. package/docs/project-roadmap.md +382 -0
  12. package/docs/troubleshooting.md +5 -4
  13. package/package.json +12 -6
  14. package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
  15. package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
  16. package/src/ai/analyze-structure.py +73 -3
  17. package/src/ai/extract-design-tokens.py +356 -13
  18. package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  19. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  20. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  21. package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
  22. package/src/ai/prompts/design_tokens.py +133 -0
  23. package/src/ai/prompts/structure_analysis.py +329 -10
  24. package/src/ai/prompts/ux_audit.py +198 -0
  25. package/src/ai/ux-audit.js +596 -0
  26. package/src/core/animation-extractor.js +526 -0
  27. package/src/core/app-state-snapshot.js +511 -0
  28. package/src/core/content-counter.js +342 -0
  29. package/src/core/cookie-handler.js +1 -1
  30. package/src/core/css-extractor.js +4 -4
  31. package/src/core/dimension-extractor.js +93 -21
  32. package/src/core/dimension-output.js +103 -6
  33. package/src/core/discover-pages.js +242 -14
  34. package/src/core/dom-tree-analyzer.js +298 -0
  35. package/src/core/extract-assets.js +1 -1
  36. package/src/core/framework-detector.js +538 -0
  37. package/src/core/html-extractor.js +45 -4
  38. package/src/core/lazy-loader.js +7 -7
  39. package/src/core/multi-page-screenshot.js +9 -6
  40. package/src/core/page-readiness.js +8 -8
  41. package/src/core/screenshot.js +311 -7
  42. package/src/core/section-cropper.js +209 -0
  43. package/src/core/section-detector.js +386 -0
  44. package/src/core/semantic-enhancer.js +492 -0
  45. package/src/core/state-capture.js +598 -0
  46. package/src/core/tests/test-section-cropper.js +177 -0
  47. package/src/core/tests/test-section-detector.js +55 -0
  48. package/src/core/video-capture.js +546 -0
  49. package/src/route-discoverers/angular-discoverer.js +157 -0
  50. package/src/route-discoverers/astro-discoverer.js +123 -0
  51. package/src/route-discoverers/base-discoverer.js +242 -0
  52. package/src/route-discoverers/index.js +106 -0
  53. package/src/route-discoverers/next-discoverer.js +130 -0
  54. package/src/route-discoverers/nuxt-discoverer.js +138 -0
  55. package/src/route-discoverers/react-discoverer.js +139 -0
  56. package/src/route-discoverers/svelte-discoverer.js +109 -0
  57. package/src/route-discoverers/universal-discoverer.js +227 -0
  58. package/src/route-discoverers/vue-discoverer.js +118 -0
  59. package/src/utils/__init__.py +1 -1
  60. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  61. package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
  62. package/src/utils/browser.js +11 -37
  63. package/src/utils/playwright.js +213 -0
  64. package/src/verification/generate-audit-report.js +398 -0
  65. package/src/verification/verify-footer.js +493 -0
  66. package/src/verification/verify-header.js +486 -0
  67. package/src/verification/verify-layout.js +2 -2
  68. package/src/verification/verify-menu.js +4 -20
  69. package/src/verification/verify-slider.js +533 -0
  70. package/src/utils/puppeteer.js +0 -281
@@ -0,0 +1,546 @@
1
+ /**
2
+ * Video Capture Module
3
+ *
4
+ * Record scrolling interactions and CSS animations using Playwright's
5
+ * context-level video recording. Optionally convert WebM to MP4/GIF using ffmpeg.
6
+ *
7
+ * Usage:
8
+ * import { captureVideo, hasFfmpeg } from './video-capture.js';
9
+ * const result = await captureVideo(page, outputDir, { format: 'webm' });
10
+ *
11
+ * @module video-capture
12
+ */
13
+
14
+ import path from 'path';
15
+ import fs from 'fs/promises';
16
+
17
+ // ============================================================================
18
+ // Constants
19
+ // ============================================================================
20
+
21
+ /** Default recording duration in milliseconds */
22
+ const DEFAULT_DURATION = 12000;
23
+
24
+ /** Default hold time at top/bottom of scroll */
25
+ const DEFAULT_HOLD_MS = 500;
26
+
27
+ /** Formats requiring ffmpeg for conversion */
28
+ const FFMPEG_REQUIRED_FORMATS = ['mp4', 'gif'];
29
+
30
+ /** GIF output settings */
31
+ const GIF_DEFAULT_FPS = 10;
32
+ const GIF_DEFAULT_WIDTH = 640;
33
+
34
+ /** Maximum scroll steps to prevent memory exhaustion on very large pages */
35
+ const MAX_SCROLL_STEPS = 100;
36
+
37
+ /** Viewport overlap fraction for scroll step calculation */
38
+ const VIEWPORT_OVERLAP_FRACTION = 0.5;
39
+
40
+ /** Default viewport for video recording */
41
+ const DEFAULT_VIDEO_VIEWPORT = { width: 1440, height: 900 };
42
+
43
+ // ============================================================================
44
+ // Type Definitions (JSDoc)
45
+ // ============================================================================
46
+
47
+ /**
48
+ * @typedef {Object} RecordOptions
49
+ * @property {number} [duration=12000] - Total recording duration in ms
50
+ * @property {number} [scrollPauseMs=50] - Pause between scroll steps for smoothness
51
+ * @property {number} [holdTopMs=500] - Hold time at page top
52
+ * @property {number} [holdBottomMs=500] - Hold time at page bottom
53
+ * @property {{width: number, height: number}} [viewport] - Viewport dimensions
54
+ */
55
+
56
+ /**
57
+ * @typedef {Object} RecordResult
58
+ * @property {string} path - Output file path
59
+ * @property {string} format - Output format ('webm')
60
+ * @property {number} duration - Actual recording duration in ms
61
+ * @property {number} scrollSteps - Number of scroll steps taken
62
+ * @property {number} pageHeight - Total page height in pixels
63
+ */
64
+
65
+ /**
66
+ * @typedef {Object} ConvertResult
67
+ * @property {string} path - Output file path
68
+ * @property {string} format - Output format ('mp4' | 'gif')
69
+ */
70
+
71
+ /**
72
+ * @typedef {Object} CaptureOptions
73
+ * @property {'webm'|'mp4'|'gif'} [format='webm'] - Output format
74
+ * @property {number} [duration=12000] - Recording duration in ms
75
+ * @property {string} [filename='preview'] - Output filename (without extension)
76
+ */
77
+
78
+ /**
79
+ * @typedef {Object} CaptureResult
80
+ * @property {string} webm - Path to WebM file (always created)
81
+ * @property {string} [mp4] - Path to MP4 file (if format='mp4')
82
+ * @property {string} [gif] - Path to GIF file (if format='gif')
83
+ * @property {string} output - Path to final output file
84
+ * @property {number} duration - Recording duration in ms
85
+ * @property {number} pageHeight - Total page height in pixels
86
+ * @property {string} [conversionError] - Error message if conversion failed
87
+ */
88
+
89
+ // ============================================================================
90
+ // ffmpeg Dependency Management
91
+ // ============================================================================
92
+
93
+ /**
94
+ * ffmpeg module references
95
+ * Loaded dynamically to handle missing optional dependency gracefully
96
+ */
97
+ let ffmpeg = null;
98
+ let ffmpegPath = null;
99
+ let ffmpegInitialized = false;
100
+
101
+ /**
102
+ * Initialize ffmpeg dependencies.
103
+ * Lazy-loads fluent-ffmpeg and @ffmpeg-installer/ffmpeg.
104
+ *
105
+ * @returns {Promise<boolean>} True if ffmpeg is available
106
+ */
107
+ async function initFfmpeg() {
108
+ if (ffmpegInitialized) {
109
+ return ffmpeg !== false;
110
+ }
111
+
112
+ ffmpegInitialized = true;
113
+
114
+ try {
115
+ const [fluentFfmpeg, installer] = await Promise.all([
116
+ import('fluent-ffmpeg'),
117
+ import('@ffmpeg-installer/ffmpeg')
118
+ ]);
119
+
120
+ ffmpeg = fluentFfmpeg.default;
121
+ ffmpegPath = installer.path;
122
+ ffmpeg.setFfmpegPath(ffmpegPath);
123
+
124
+ return true;
125
+ } catch (importError) {
126
+ // Mark as unavailable
127
+ ffmpeg = false;
128
+
129
+ const isModuleNotFound = importError.code === 'ERR_MODULE_NOT_FOUND';
130
+ if (!isModuleNotFound) {
131
+ // Unexpected error
132
+ console.error(
133
+ '[video-capture] ffmpeg initialization error:',
134
+ importError.message
135
+ );
136
+ }
137
+
138
+ return false;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Check if ffmpeg is available for video conversion.
144
+ *
145
+ * @returns {Promise<boolean>} True if ffmpeg dependencies are available
146
+ */
147
+ export async function hasFfmpeg() {
148
+ return await initFfmpeg();
149
+ }
150
+
151
+ // ============================================================================
152
+ // Logging Helper
153
+ // ============================================================================
154
+
155
+ /**
156
+ * Log message to stderr if running in TTY
157
+ * @param {string} message - Message to log
158
+ */
159
+ function log(message) {
160
+ if (process.stderr.isTTY) {
161
+ console.error(message);
162
+ }
163
+ }
164
+
165
+ // ============================================================================
166
+ // Input Validation
167
+ // ============================================================================
168
+
169
+ /**
170
+ * Validate page object (Playwright page)
171
+ * @param {import('playwright').Page} page - Playwright page object
172
+ * @throws {TypeError} If page is invalid
173
+ */
174
+ function validatePage(page) {
175
+ if (!page || typeof page.evaluate !== 'function') {
176
+ throw new TypeError('Invalid page object: must be a Playwright page');
177
+ }
178
+ // Playwright-specific check
179
+ if (typeof page.context !== 'function') {
180
+ throw new TypeError('Invalid page object: missing context() method');
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Validate output path
186
+ * @param {string} outputPath - Output file/directory path
187
+ * @throws {TypeError} If path is invalid
188
+ */
189
+ function validatePath(outputPath) {
190
+ if (!outputPath || typeof outputPath !== 'string') {
191
+ throw new TypeError('Invalid output path: must be a non-empty string');
192
+ }
193
+ }
194
+
195
+ // ============================================================================
196
+ // Scroll Recording (Playwright Context-Level Video)
197
+ // ============================================================================
198
+
199
+ /**
200
+ * Record page scroll interaction using Playwright context-level video.
201
+ *
202
+ * Creates a new browser context with video recording enabled, navigates to
203
+ * the page URL, performs scroll animation, then closes to finalize video.
204
+ *
205
+ * @param {import('playwright').Browser} browser - Playwright browser instance
206
+ * @param {string} pageUrl - URL to navigate and record
207
+ * @param {string} outputDir - Directory for video output
208
+ * @param {RecordOptions} [options={}] - Recording options
209
+ * @returns {Promise<RecordResult>} Recording result with metadata
210
+ */
211
+ export async function recordScroll(browser, pageUrl, outputDir, options = {}) {
212
+ if (!browser || typeof browser.newContext !== 'function') {
213
+ throw new TypeError('Invalid browser: must be a Playwright browser instance');
214
+ }
215
+ validatePath(outputDir);
216
+
217
+ const {
218
+ duration = DEFAULT_DURATION,
219
+ scrollPauseMs = 50,
220
+ holdTopMs = DEFAULT_HOLD_MS,
221
+ holdBottomMs = DEFAULT_HOLD_MS,
222
+ viewport = DEFAULT_VIDEO_VIEWPORT
223
+ } = options;
224
+
225
+ // Create context with video recording enabled
226
+ const context = await browser.newContext({
227
+ recordVideo: {
228
+ dir: outputDir,
229
+ size: viewport
230
+ },
231
+ viewport
232
+ });
233
+
234
+ const page = await context.newPage();
235
+ const startTime = Date.now();
236
+
237
+ try {
238
+ // Navigate to page
239
+ await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30000 });
240
+
241
+ // Get page dimensions
242
+ const totalHeight = await page.evaluate(() =>
243
+ Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
244
+ );
245
+
246
+ const viewportHeight = viewport.height;
247
+ const scrollDistance = Math.max(0, totalHeight - viewportHeight);
248
+ const isScrollable = scrollDistance > 0;
249
+
250
+ const rawScrollSteps = isScrollable
251
+ ? Math.ceil(scrollDistance / (viewportHeight * VIEWPORT_OVERLAP_FRACTION))
252
+ : 0;
253
+ const scrollSteps = Math.min(rawScrollSteps, MAX_SCROLL_STEPS);
254
+
255
+ const scrollTime = duration - holdTopMs - holdBottomMs;
256
+ const scrollDelay = scrollSteps > 0
257
+ ? Math.max(scrollPauseMs, Math.floor(scrollTime / (scrollSteps * 2)))
258
+ : 0;
259
+
260
+ // Ensure at top
261
+ await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
262
+ await new Promise(r => setTimeout(r, 200));
263
+
264
+ // Hold at top
265
+ await new Promise(r => setTimeout(r, holdTopMs));
266
+
267
+ if (isScrollable && scrollSteps > 0) {
268
+ // Scroll down
269
+ for (let i = 1; i <= scrollSteps; i++) {
270
+ const y = (i / scrollSteps) * scrollDistance;
271
+ await page.evaluate(scrollY => window.scrollTo({ top: scrollY, behavior: 'instant' }), y);
272
+ await new Promise(r => setTimeout(r, scrollDelay));
273
+ }
274
+
275
+ // Hold at bottom
276
+ await new Promise(r => setTimeout(r, holdBottomMs));
277
+
278
+ // Scroll back up
279
+ for (let i = scrollSteps - 1; i >= 0; i--) {
280
+ const y = (i / scrollSteps) * scrollDistance;
281
+ await page.evaluate(scrollY => window.scrollTo({ top: scrollY, behavior: 'instant' }), y);
282
+ await new Promise(r => setTimeout(r, scrollDelay));
283
+ }
284
+
285
+ // Hold at top
286
+ await new Promise(r => setTimeout(r, holdTopMs));
287
+ } else {
288
+ // Single-screen: hold for duration
289
+ await new Promise(r => setTimeout(r, scrollTime + holdBottomMs));
290
+ }
291
+
292
+ const actualDuration = Date.now() - startTime;
293
+
294
+ // IMPORTANT: Close page before getting video path (Playwright requirement)
295
+ await page.close();
296
+
297
+ // Get video path (only available after page close)
298
+ const video = page.video();
299
+ const videoPath = video ? await video.path() : null;
300
+
301
+ // Cleanup context
302
+ await context.close();
303
+
304
+ if (!videoPath) {
305
+ throw new Error('Video recording failed - no path returned');
306
+ }
307
+
308
+ return {
309
+ path: videoPath,
310
+ format: 'webm',
311
+ duration: actualDuration,
312
+ scrollSteps,
313
+ pageHeight: totalHeight
314
+ };
315
+
316
+ } catch (error) {
317
+ // Cleanup on error
318
+ try {
319
+ await page.close().catch(() => {});
320
+ await context.close().catch(() => {});
321
+ } catch { /* ignore cleanup errors */ }
322
+ throw error;
323
+ }
324
+ }
325
+
326
+ // ============================================================================
327
+ // Format Conversion
328
+ // ============================================================================
329
+
330
+ /**
331
+ * Convert WebM to MP4 using ffmpeg.
332
+ *
333
+ * Uses H.264 codec with settings optimized for web playback:
334
+ * - libx264 encoder with fast preset
335
+ * - CRF 23 for good quality/size balance
336
+ * - yuv420p pixel format for iOS/Safari compatibility
337
+ * - faststart flag for progressive playback
338
+ *
339
+ * @param {string} inputPath - Path to WebM file
340
+ * @param {string} outputPath - Path for MP4 output
341
+ * @returns {Promise<ConvertResult>} Conversion result
342
+ * @throws {Error} If ffmpeg is not available or conversion fails
343
+ */
344
+ export async function convertToMp4(inputPath, outputPath) {
345
+ validatePath(inputPath);
346
+ validatePath(outputPath);
347
+
348
+ const hasFf = await initFfmpeg();
349
+ if (!hasFf) {
350
+ throw new Error(
351
+ 'ffmpeg not available. Install: npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg'
352
+ );
353
+ }
354
+
355
+ return new Promise((resolve, reject) => {
356
+ ffmpeg(inputPath)
357
+ .outputOptions([
358
+ '-c:v libx264',
359
+ '-preset fast',
360
+ '-crf 23',
361
+ '-pix_fmt yuv420p',
362
+ '-movflags +faststart'
363
+ ])
364
+ .output(outputPath)
365
+ .on('end', () => resolve({ path: outputPath, format: 'mp4' }))
366
+ .on('error', (err) => reject(new Error(`MP4 conversion failed: ${err.message}`)))
367
+ .run();
368
+ });
369
+ }
370
+
371
+ /**
372
+ * Convert WebM to GIF using ffmpeg.
373
+ *
374
+ * Uses two-pass conversion with palette generation for high-quality output:
375
+ * 1. Generate optimized palette from video
376
+ * 2. Create GIF using palette with dithering
377
+ *
378
+ * @param {string} inputPath - Path to WebM file
379
+ * @param {string} outputPath - Path for GIF output
380
+ * @param {Object} [options={}] - GIF options
381
+ * @param {number} [options.fps=10] - Output frame rate
382
+ * @param {number} [options.width=640] - Output width (height auto-calculated)
383
+ * @returns {Promise<ConvertResult>} Conversion result
384
+ * @throws {Error} If ffmpeg is not available or conversion fails
385
+ */
386
+ export async function convertToGif(inputPath, outputPath, options = {}) {
387
+ validatePath(inputPath);
388
+ validatePath(outputPath);
389
+
390
+ const { fps = GIF_DEFAULT_FPS, width = GIF_DEFAULT_WIDTH } = options;
391
+
392
+ const hasFf = await initFfmpeg();
393
+ if (!hasFf) {
394
+ throw new Error(
395
+ 'ffmpeg not available. Install: npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg'
396
+ );
397
+ }
398
+
399
+ // Palette path for two-pass conversion
400
+ const palettePath = inputPath.replace(/\.webm$/i, '-palette.png');
401
+
402
+ try {
403
+ // Pass 1: Generate palette
404
+ await new Promise((resolve, reject) => {
405
+ ffmpeg(inputPath)
406
+ .outputOptions([
407
+ '-vf',
408
+ `fps=${fps},scale=${width}:-1:flags=lanczos,palettegen=stats_mode=diff`
409
+ ])
410
+ .output(palettePath)
411
+ .on('end', resolve)
412
+ .on('error', (err) => reject(new Error(`Palette generation failed: ${err.message}`)))
413
+ .run();
414
+ });
415
+
416
+ // Pass 2: Create GIF with palette
417
+ await new Promise((resolve, reject) => {
418
+ ffmpeg(inputPath)
419
+ .input(palettePath)
420
+ .complexFilter([
421
+ `fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5`
422
+ ])
423
+ .output(outputPath)
424
+ .on('end', resolve)
425
+ .on('error', (err) => reject(new Error(`GIF creation failed: ${err.message}`)))
426
+ .run();
427
+ });
428
+
429
+ return { path: outputPath, format: 'gif' };
430
+ } finally {
431
+ // Cleanup palette file
432
+ try {
433
+ await fs.unlink(palettePath);
434
+ } catch (cleanupErr) {
435
+ if (process.env.DEBUG) {
436
+ console.error(`[video-capture] Palette cleanup failed: ${cleanupErr.message}`);
437
+ }
438
+ }
439
+ }
440
+ }
441
+
442
+ // ============================================================================
443
+ // Main Capture Function
444
+ // ============================================================================
445
+
446
+ /**
447
+ * Capture video of page scroll interaction.
448
+ *
449
+ * Creates a new browser context for recording (Playwright requirement),
450
+ * records page scrolling, and optionally converts to MP4 or GIF.
451
+ * WebM is always created first (native Playwright format).
452
+ *
453
+ * @param {import('playwright').Page} page - Playwright page (used for browser reference and URL)
454
+ * @param {string} outputDir - Directory for output files
455
+ * @param {CaptureOptions} [options={}] - Capture options
456
+ * @returns {Promise<CaptureResult>} Capture result with file paths
457
+ */
458
+ export async function captureVideo(page, outputDir, options = {}) {
459
+ validatePage(page);
460
+ validatePath(outputDir);
461
+
462
+ const {
463
+ format = 'webm',
464
+ duration = DEFAULT_DURATION,
465
+ filename = 'preview'
466
+ } = options;
467
+
468
+ // Get browser and current URL from page
469
+ const browser = page.context().browser();
470
+ const pageUrl = page.url();
471
+ const viewport = page.viewportSize() || DEFAULT_VIDEO_VIEWPORT;
472
+
473
+ if (!browser) {
474
+ throw new Error('Cannot get browser from page. Ensure page has browser context.');
475
+ }
476
+
477
+ // Record using new context (Playwright context-level video)
478
+ log('[video] Recording scroll...');
479
+ const recordResult = await recordScroll(browser, pageUrl, outputDir, {
480
+ duration,
481
+ viewport
482
+ });
483
+ log(`[video] Recorded ${(recordResult.duration / 1000).toFixed(1)}s`);
484
+
485
+ // Rename video file to expected name (Playwright auto-generates random name)
486
+ const expectedPath = path.join(outputDir, `${filename}.webm`);
487
+ if (recordResult.path !== expectedPath) {
488
+ try {
489
+ await fs.rename(recordResult.path, expectedPath);
490
+ recordResult.path = expectedPath;
491
+ } catch (renameErr) {
492
+ // If rename fails, keep original path
493
+ log(`[video] Could not rename video: ${renameErr.message}`);
494
+ }
495
+ }
496
+
497
+ /** @type {CaptureResult} */
498
+ const result = {
499
+ webm: recordResult.path,
500
+ duration: recordResult.duration,
501
+ pageHeight: recordResult.pageHeight,
502
+ output: recordResult.path
503
+ };
504
+
505
+ // Convert if needed (ffmpeg logic unchanged)
506
+ if (format === 'mp4') {
507
+ const mp4Path = path.join(outputDir, `${filename}.mp4`);
508
+ log('[video] Converting to MP4...');
509
+
510
+ try {
511
+ await convertToMp4(recordResult.path, mp4Path);
512
+ result.mp4 = mp4Path;
513
+ result.output = mp4Path;
514
+ log('[video] MP4 conversion complete');
515
+ } catch (e) {
516
+ log(`[video] MP4 conversion failed: ${e.message}`);
517
+ result.conversionError = e.message;
518
+ }
519
+ } else if (format === 'gif') {
520
+ const gifPath = path.join(outputDir, `${filename}.gif`);
521
+ log('[video] Converting to GIF...');
522
+
523
+ try {
524
+ await convertToGif(recordResult.path, gifPath);
525
+ result.gif = gifPath;
526
+ result.output = gifPath;
527
+ log('[video] GIF conversion complete');
528
+ } catch (e) {
529
+ log(`[video] GIF conversion failed: ${e.message}`);
530
+ result.conversionError = e.message;
531
+ }
532
+ }
533
+
534
+ return result;
535
+ }
536
+
537
+ // ============================================================================
538
+ // Exports
539
+ // ============================================================================
540
+
541
+ export {
542
+ DEFAULT_DURATION,
543
+ FFMPEG_REQUIRED_FORMATS,
544
+ MAX_SCROLL_STEPS,
545
+ VIEWPORT_OVERLAP_FRACTION
546
+ };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Angular Route Discoverer
3
+ *
4
+ * Extracts routes from Angular applications using:
5
+ * - ng.probe() for component inspection
6
+ * - routerLink attributes in DOM
7
+ * - app-root element analysis
8
+ */
9
+
10
+ import { BaseDiscoverer } from './base-discoverer.js';
11
+
12
+ export class AngularDiscoverer extends BaseDiscoverer {
13
+ /**
14
+ * Discover routes from an Angular application
15
+ * @returns {Promise<import('./base-discoverer.js').DiscoveredRoute[]>}
16
+ */
17
+ async discover() {
18
+ const rawRoutes = await this.page.evaluate(() => {
19
+ const routes = [];
20
+
21
+ // Method 1: ng.probe() to access Router
22
+ const appRoot = document.querySelector('app-root');
23
+ if (appRoot && window.ng?.probe) {
24
+ try {
25
+ const debugElement = window.ng.probe(appRoot);
26
+ if (debugElement?.injector) {
27
+ // Try to get Router from injector
28
+ // Note: This may not work in production builds
29
+ const injector = debugElement.injector;
30
+
31
+ // Look for router in provider tree
32
+ const getAllProviders = (inj) => {
33
+ const providers = [];
34
+ if (inj._records) {
35
+ inj._records.forEach((v, k) => {
36
+ if (k.toString().includes('Router')) {
37
+ providers.push(v);
38
+ }
39
+ });
40
+ }
41
+ return providers;
42
+ };
43
+
44
+ const routerProviders = getAllProviders(injector);
45
+ routerProviders.forEach(provider => {
46
+ if (provider?.config) {
47
+ extractAngularRoutes(provider.config, routes);
48
+ }
49
+ });
50
+ }
51
+ } catch (e) {
52
+ // ng.probe may not be available in production
53
+ }
54
+ }
55
+
56
+ // Method 2: routerLink attributes (most reliable)
57
+ document.querySelectorAll('[routerLink], [routerlink]').forEach(el => {
58
+ const path = el.getAttribute('routerLink') || el.getAttribute('routerlink');
59
+ if (path) {
60
+ const text = el.textContent?.trim();
61
+ routes.push({
62
+ path: path.startsWith('/') ? path : '/' + path,
63
+ name: text || '',
64
+ source: 'framework'
65
+ });
66
+ }
67
+ });
68
+
69
+ // Method 3: [routerLink] with binding syntax
70
+ document.querySelectorAll('a[href]').forEach(link => {
71
+ const href = link.getAttribute('href');
72
+ if (href && href.startsWith('/')) {
73
+ // Check if it's inside Angular app
74
+ const isAngularLink = link.closest('app-root') ||
75
+ link.hasAttribute('routerLinkActive') ||
76
+ link.classList.contains('active');
77
+
78
+ if (isAngularLink || link.closest('nav, header, [role="navigation"]')) {
79
+ const text = link.textContent?.trim();
80
+ if (!routes.some(r => r.path === href)) {
81
+ routes.push({
82
+ path: href,
83
+ name: text || '',
84
+ source: isAngularLink ? 'framework' : 'link-scrape'
85
+ });
86
+ }
87
+ }
88
+ }
89
+ });
90
+
91
+ // Method 4: routerLinkActive elements
92
+ document.querySelectorAll('[routerLinkActive], [routerlinkactive]').forEach(el => {
93
+ const link = el.tagName === 'A' ? el : el.querySelector('a');
94
+ if (link) {
95
+ const href = link.getAttribute('href') ||
96
+ link.getAttribute('routerLink') ||
97
+ link.getAttribute('routerlink');
98
+ if (href && !routes.some(r => r.path === href)) {
99
+ routes.push({
100
+ path: href.startsWith('/') ? href : '/' + href,
101
+ name: link.textContent?.trim() || '',
102
+ source: 'framework'
103
+ });
104
+ }
105
+ }
106
+ });
107
+
108
+ /**
109
+ * Extract routes from Angular Router config
110
+ */
111
+ function extractAngularRoutes(config, output, prefix = '') {
112
+ if (!Array.isArray(config)) return;
113
+
114
+ config.forEach(route => {
115
+ if (!route.path && route.path !== '') return;
116
+
117
+ let path = route.path;
118
+ if (prefix && !path.startsWith('/')) {
119
+ path = prefix + '/' + path;
120
+ }
121
+ if (!path.startsWith('/')) {
122
+ path = '/' + path;
123
+ }
124
+
125
+ // Skip wildcard and redirect-only routes
126
+ if (path === '/**' || path === '**' || (!route.component && route.redirectTo)) {
127
+ return;
128
+ }
129
+
130
+ output.push({
131
+ path,
132
+ name: route.data?.title || route.title || '',
133
+ component: route.component?.name || '',
134
+ source: 'framework'
135
+ });
136
+
137
+ // Process child routes
138
+ if (route.children) {
139
+ extractAngularRoutes(route.children, output, path);
140
+ }
141
+ });
142
+ }
143
+
144
+ return routes;
145
+ });
146
+
147
+ const processedRoutes = rawRoutes.map(route => ({
148
+ ...route,
149
+ name: route.name || this.extractPageName(route.path, route.component),
150
+ path: this.normalizeRoute(route.path)
151
+ }));
152
+
153
+ return this.deduplicateRoutes(processedRoutes);
154
+ }
155
+ }
156
+
157
+ export default AngularDiscoverer;