webgl-forensics 3.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.
- package/.github/workflows/forensics.yml +44 -0
- package/CLAUDE_CODE.md +49 -0
- package/MASTER_PROMPT.md +202 -0
- package/README.md +579 -0
- package/SKILL.md +1256 -0
- package/package.json +53 -0
- package/project summary.md +33 -0
- package/puppeteer-runner.js +486 -0
- package/scripts/00-tech-stack-detect.js +185 -0
- package/scripts/01-source-map-extractor.js +73 -0
- package/scripts/02-interaction-model.js +129 -0
- package/scripts/03-responsive-analysis.js +115 -0
- package/scripts/04-page-transitions.js +128 -0
- package/scripts/05-loading-sequence.js +124 -0
- package/scripts/06-audio-extraction.js +115 -0
- package/scripts/07-accessibility-reduced-motion.js +133 -0
- package/scripts/08-complexity-scorer.js +138 -0
- package/scripts/09-visual-diff-validator.js +113 -0
- package/scripts/10-webgpu-extractor.js +248 -0
- package/scripts/11-scroll-screenshot-grid.js +76 -0
- package/scripts/12-network-waterfall.js +151 -0
- package/scripts/13-react-fiber-walker.js +285 -0
- package/scripts/14-shader-hotpatch.js +349 -0
- package/scripts/15-multipage-crawler.js +221 -0
- package/scripts/16-gsap-timeline-recorder.js +335 -0
- package/scripts/17-r3f-fiber-serializer.js +316 -0
- package/scripts/18-font-extractor.js +290 -0
- package/scripts/19-design-token-export.js +85 -0
- package/scripts/20-timeline-visualizer.js +61 -0
- package/scripts/21-lighthouse-audit.js +35 -0
- package/scripts/22-sitemap-crawler.js +128 -0
- package/scripts/23-scaffold-generator.js +451 -0
- package/scripts/24-ai-reconstruction.js +226 -0
- package/scripts/25-shader-annotator.js +226 -0
- package/tui.js +196 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
function generateTimelineHtml(timelineData) {
|
|
2
|
+
if (!timelineData || !Array.isArray(timelineData) || timelineData.length === 0) {
|
|
3
|
+
return '<div>No timeline data to visualize.</div>';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const itemsHtml = timelineData.map((item, idx) => {
|
|
7
|
+
let duration = item.duration || 1;
|
|
8
|
+
let delay = item.delay || 0;
|
|
9
|
+
let width = Math.max(10, duration * 100);
|
|
10
|
+
let left = delay * 100;
|
|
11
|
+
|
|
12
|
+
return `
|
|
13
|
+
<div class="timeline-row">
|
|
14
|
+
<div class="timeline-label">
|
|
15
|
+
<span class="element-target">${item.targets || 'Unknown'}</span>
|
|
16
|
+
<span class="element-props">${Object.keys(item.vars || {}).join(', ')}</span>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="timeline-track">
|
|
19
|
+
<div class="timeline-bar"
|
|
20
|
+
style="width: ${width}px; left: ${left}px;"
|
|
21
|
+
title="Target: ${item.targets}\nDuration: ${duration}s\nDelay: ${delay}s\nVars: ${JSON.stringify(item.vars)}">
|
|
22
|
+
${Object.keys(item.vars || {}).length > 0 ? '✨' : ''}
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
`;
|
|
27
|
+
}).join('');
|
|
28
|
+
|
|
29
|
+
return `
|
|
30
|
+
<!DOCTYPE html>
|
|
31
|
+
<html lang="en">
|
|
32
|
+
<head>
|
|
33
|
+
<meta charset="UTF-8">
|
|
34
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
35
|
+
<title>GSAP Timeline Visualizer</title>
|
|
36
|
+
<style>
|
|
37
|
+
body { background: #0f172a; color: #f8fafc; font-family: ui-sans-serif, system-ui, sans-serif; padding: 2rem; }
|
|
38
|
+
h1 { font-size: 1.5rem; margin-bottom: 1rem; border-bottom: 1px solid #334155; padding-bottom: 0.5rem; }
|
|
39
|
+
.timeline-container { border: 1px solid #334155; background: #1e293b; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); }
|
|
40
|
+
.timeline-row { display: flex; border-bottom: 1px solid #334155; }
|
|
41
|
+
.timeline-row:last-child { border-bottom: none; }
|
|
42
|
+
.timeline-label { width: 300px; padding: 0.75rem; border-right: 1px solid #334155; background: #0f172a; flex-shrink: 0; }
|
|
43
|
+
.element-target { display: block; font-weight: 600; font-size: 0.875rem; color: #38bdf8; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
44
|
+
.element-props { display: block; font-size: 0.75rem; color: #94a3b8; margin-top: 0.25rem; }
|
|
45
|
+
.timeline-track { flex-grow: 1; position: relative; padding: 0.75rem 0; overflow-x: auto; background: repeating-linear-gradient(90deg, #1e293b, #1e293b 99px, #334155 99px, #334155 100px); }
|
|
46
|
+
.timeline-bar { position: relative; height: 100%; background: linear-gradient(90deg, #8b5cf6, #d946ef); border-radius: 4px; box-shadow: 0 0 10px rgba(217, 70, 239, 0.4); min-height: 20px; transition: transform 0.2s; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 10px; }
|
|
47
|
+
.timeline-bar:hover { transform: scaleY(1.2); }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<h1>Animation Timeline Map</h1>
|
|
52
|
+
<div class="timeline-container">
|
|
53
|
+
${itemsHtml}
|
|
54
|
+
</div>
|
|
55
|
+
<p style="margin-top: 1rem; color: #64748b; font-size: 0.875rem;">Scale: 100px = 1 second. Hover over bars for exact property details.</p>
|
|
56
|
+
</body>
|
|
57
|
+
</html>
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = generateTimelineHtml;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const lighthouse = require('lighthouse');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runs a Lighthouse audit against the URL using the existing Puppeteer browser port.
|
|
5
|
+
*/
|
|
6
|
+
async function runAudit(url, port) {
|
|
7
|
+
const options = {
|
|
8
|
+
logLevel: 'error',
|
|
9
|
+
output: 'json',
|
|
10
|
+
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
|
|
11
|
+
port: port,
|
|
12
|
+
disableStorageReset: true // don't wipe out the cookies if we accumulated any
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const runnerResult = await lighthouse(url, options);
|
|
17
|
+
return {
|
|
18
|
+
scores: {
|
|
19
|
+
performance: Math.round(runnerResult.lhr.categories.performance.score * 100),
|
|
20
|
+
accessibility: Math.round(runnerResult.lhr.categories.accessibility.score * 100),
|
|
21
|
+
bestPractices: Math.round(runnerResult.lhr.categories['best-practices'].score * 100),
|
|
22
|
+
seo: Math.round(runnerResult.lhr.categories.seo.score * 100)
|
|
23
|
+
},
|
|
24
|
+
metrics: {
|
|
25
|
+
lcp: runnerResult.lhr.audits['largest-contentful-paint']?.displayValue || 'N/A',
|
|
26
|
+
cls: runnerResult.lhr.audits['cumulative-layout-shift']?.displayValue || 'N/A',
|
|
27
|
+
tti: runnerResult.lhr.audits['interactive']?.displayValue || 'N/A'
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return { error: e.message };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = runAudit;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* webgl-forensics — Sitemap-Driven Crawler
|
|
4
|
+
* Script 22 — Parses /sitemap.xml or /sitemap_index.xml and returns a
|
|
5
|
+
* prioritized list of internal URLs. Runs INSIDE the browser context.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
(() => {
|
|
9
|
+
// This script is designed to be eval'd by Puppeteer in the browser.
|
|
10
|
+
// It fetches the sitemap from the current origin and parses it.
|
|
11
|
+
|
|
12
|
+
const origin = window.location.origin;
|
|
13
|
+
|
|
14
|
+
async function fetchSitemap(url) {
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(url, { headers: { 'Accept': 'application/xml, text/xml, */*' } });
|
|
17
|
+
if (!res.ok) return null;
|
|
18
|
+
const text = await res.text();
|
|
19
|
+
return text;
|
|
20
|
+
} catch (e) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseXml(xmlString) {
|
|
26
|
+
const parser = new DOMParser();
|
|
27
|
+
const doc = parser.parseFromString(xmlString, 'application/xml');
|
|
28
|
+
return doc;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function extractUrls(doc) {
|
|
32
|
+
// Try sitemapindex first (index of sitemaps)
|
|
33
|
+
const sitemapIndexEntries = doc.querySelectorAll('sitemapindex sitemap loc');
|
|
34
|
+
if (sitemapIndexEntries.length > 0) {
|
|
35
|
+
return {
|
|
36
|
+
type: 'sitemapindex',
|
|
37
|
+
sitemaps: [...sitemapIndexEntries].map(el => el.textContent.trim()),
|
|
38
|
+
urls: [],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Regular URL set
|
|
43
|
+
const urlEntries = doc.querySelectorAll('urlset url');
|
|
44
|
+
const urls = [...urlEntries].map(el => ({
|
|
45
|
+
loc: el.querySelector('loc')?.textContent.trim() || '',
|
|
46
|
+
priority: parseFloat(el.querySelector('priority')?.textContent || '0.5'),
|
|
47
|
+
changefreq: el.querySelector('changefreq')?.textContent.trim() || 'monthly',
|
|
48
|
+
lastmod: el.querySelector('lastmod')?.textContent.trim() || null,
|
|
49
|
+
}))
|
|
50
|
+
.filter(u => u.loc.startsWith(origin))
|
|
51
|
+
.sort((a, b) => b.priority - a.priority);
|
|
52
|
+
|
|
53
|
+
// Categorize by path pattern
|
|
54
|
+
const categorized = {
|
|
55
|
+
homepage: urls.filter(u => new URL(u.loc).pathname === '/'),
|
|
56
|
+
work: urls.filter(u => /\/work|\/projects|\/portfolio|\/case/i.test(u.loc)),
|
|
57
|
+
about: urls.filter(u => /\/about|\/team|\/story/i.test(u.loc)),
|
|
58
|
+
contact: urls.filter(u => /\/contact|\/hire/i.test(u.loc)),
|
|
59
|
+
blog: urls.filter(u => /\/blog|\/posts|\/articles|\/news/i.test(u.loc)),
|
|
60
|
+
other: urls.filter(u =>
|
|
61
|
+
!/\/|\/work|\/projects|\/portfolio|\/case|\/about|\/team|\/story|\/contact|\/hire|\/blog|\/posts|\/articles|\/news/i.test(new URL(u.loc).pathname)
|
|
62
|
+
&& new URL(u.loc).pathname !== '/'
|
|
63
|
+
),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
type: 'urlset',
|
|
68
|
+
total: urls.length,
|
|
69
|
+
urls,
|
|
70
|
+
categorized,
|
|
71
|
+
topPriority: urls.slice(0, 10),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function crawl() {
|
|
76
|
+
const result = {
|
|
77
|
+
origin,
|
|
78
|
+
sitemapFound: false,
|
|
79
|
+
sitemapUrl: null,
|
|
80
|
+
data: null,
|
|
81
|
+
fallback: null,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Try common sitemap locations in order
|
|
85
|
+
const candidates = [
|
|
86
|
+
`${origin}/sitemap.xml`,
|
|
87
|
+
`${origin}/sitemap_index.xml`,
|
|
88
|
+
`${origin}/sitemap/sitemap.xml`,
|
|
89
|
+
`${origin}/page-sitemap.xml`,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
let rawXml = null;
|
|
93
|
+
for (const candidate of candidates) {
|
|
94
|
+
rawXml = await fetchSitemap(candidate);
|
|
95
|
+
if (rawXml && rawXml.includes('<urlset') || rawXml && rawXml.includes('<sitemapindex')) {
|
|
96
|
+
result.sitemapFound = true;
|
|
97
|
+
result.sitemapUrl = candidate;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!rawXml || !result.sitemapFound) {
|
|
103
|
+
// Fallback: DOM discovery
|
|
104
|
+
const links = [...document.querySelectorAll('a[href]')]
|
|
105
|
+
.map(a => a.href)
|
|
106
|
+
.filter(h => h.startsWith(origin) && !h.includes('#') && !h.includes('?'))
|
|
107
|
+
.filter((v, i, a) => a.indexOf(v) === i);
|
|
108
|
+
result.fallback = { type: 'dom-discovery', urls: links.slice(0, 50), count: links.length };
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const doc = parseXml(rawXml);
|
|
113
|
+
result.data = extractUrls(doc);
|
|
114
|
+
|
|
115
|
+
// If it's a sitemapindex, also fetch and parse the first child sitemap
|
|
116
|
+
if (result.data.type === 'sitemapindex' && result.data.sitemaps.length > 0) {
|
|
117
|
+
const childXml = await fetchSitemap(result.data.sitemaps[0]);
|
|
118
|
+
if (childXml) {
|
|
119
|
+
const childDoc = parseXml(childXml);
|
|
120
|
+
result.data.childSitemap = extractUrls(childDoc);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return crawl();
|
|
128
|
+
})()
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webgl-forensics — Next.js Scaffold Generator (Script 23)
|
|
3
|
+
*
|
|
4
|
+
* Node.js module. Takes a forensics report and generates a complete
|
|
5
|
+
* Next.js 15 App Router starter with:
|
|
6
|
+
* - Pre-wired design tokens (CSS variables)
|
|
7
|
+
* - Component stubs for detected R3F/Three.js objects
|
|
8
|
+
* - GSAP setup with detected plugins
|
|
9
|
+
* - Lenis/scroll setup
|
|
10
|
+
* - Font imports
|
|
11
|
+
* - tsconfig, tailwind.config.ts, package.json
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extract key data from forensics report
|
|
19
|
+
*/
|
|
20
|
+
function extractScaffoldData(report) {
|
|
21
|
+
const phases = report?.meta?.phases || {};
|
|
22
|
+
const tech = phases['tech-stack'] || {};
|
|
23
|
+
const tokens = phases['design-token-export'] || {};
|
|
24
|
+
const fonts = phases['font-extractor'] || {};
|
|
25
|
+
const gsap = phases['gsap-timeline'] || {};
|
|
26
|
+
const r3f = phases['r3f-serializer'] || {};
|
|
27
|
+
const fiber = phases['react-fiber-walker'] || {};
|
|
28
|
+
const scroll = phases['interaction-model'] || {};
|
|
29
|
+
const loading = phases['loading-sequence'] || {};
|
|
30
|
+
const sitemap = phases['sitemap-crawler'] || {};
|
|
31
|
+
const complexity = phases['complexity-scorer'] || {};
|
|
32
|
+
|
|
33
|
+
// Detect scroll library
|
|
34
|
+
const hasLenis = tech?.scroll?.lenis;
|
|
35
|
+
const hasLocomotive = tech?.scroll?.locomotive;
|
|
36
|
+
const hasGSAP = tech?.animations?.gsap;
|
|
37
|
+
const hasScrollTrigger = tech?.animations?.scrollTrigger;
|
|
38
|
+
const hasR3F = tech?.libraries?.r3f || r3f?.componentCount > 0;
|
|
39
|
+
const hasThree = tech?.libraries?.three;
|
|
40
|
+
const hasFramer = tech?.animations?.framerMotion;
|
|
41
|
+
const framework = tech?.framework?.name || 'Next.js';
|
|
42
|
+
|
|
43
|
+
// Extract site pages from sitemap
|
|
44
|
+
const pages = [];
|
|
45
|
+
if (sitemap?.data?.categorized) {
|
|
46
|
+
const cat = sitemap.data.categorized;
|
|
47
|
+
if (cat.homepage?.length) pages.push({ name: 'Home', path: '/', url: cat.homepage[0]?.loc });
|
|
48
|
+
if (cat.work?.length) pages.push({ name: 'Work', path: '/work', url: cat.work[0]?.loc });
|
|
49
|
+
if (cat.about?.length) pages.push({ name: 'About', path: '/about', url: cat.about[0]?.loc });
|
|
50
|
+
if (cat.contact?.length) pages.push({ name: 'Contact', path: '/contact', url: cat.contact[0]?.loc });
|
|
51
|
+
} else {
|
|
52
|
+
pages.push({ name: 'Home', path: '/' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Extract colors
|
|
56
|
+
const colors = (tokens?.colors || []).slice(0, 15);
|
|
57
|
+
const cssVars = (tokens?.cssVariables || []).slice(0, 40);
|
|
58
|
+
|
|
59
|
+
// Extract fonts
|
|
60
|
+
const webFonts = fonts?.webFonts || [];
|
|
61
|
+
|
|
62
|
+
// Extract R3F components
|
|
63
|
+
const r3fComponents = r3f?.components || [];
|
|
64
|
+
|
|
65
|
+
// GSAP plugins
|
|
66
|
+
const gsapPlugins = [];
|
|
67
|
+
if (hasScrollTrigger) gsapPlugins.push('ScrollTrigger');
|
|
68
|
+
if (tech?.animations?.scrollSmoother) gsapPlugins.push('ScrollSmoother');
|
|
69
|
+
if ((gsap?.tweens || []).some(t => t.vars?.includes('split') || t.vars?.includes('chars'))) gsapPlugins.push('SplitText');
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
url: report?.meta?.url,
|
|
73
|
+
domain: report?.meta?.domain,
|
|
74
|
+
complexity: complexity?.score,
|
|
75
|
+
hasLenis, hasLocomotive, hasGSAP, hasScrollTrigger, hasR3F, hasThree, hasFramer,
|
|
76
|
+
gsapPlugins, pages, colors, cssVars, webFonts, r3fComponents, loading,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Generate package.json for the scaffold
|
|
82
|
+
*/
|
|
83
|
+
function genPackageJson(data) {
|
|
84
|
+
const deps = {
|
|
85
|
+
'next': '15.0.0',
|
|
86
|
+
'react': '19.0.0',
|
|
87
|
+
'react-dom': '19.0.0',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (data.hasGSAP) deps['gsap'] = '^3.12.5';
|
|
91
|
+
if (data.hasLenis) deps['@studio-freight/lenis'] = '^1.0.42';
|
|
92
|
+
if (data.hasLocomotive) deps['locomotive-scroll'] = '^4.1.4';
|
|
93
|
+
if (data.hasR3F) {
|
|
94
|
+
deps['@react-three/fiber'] = '^8.17.0';
|
|
95
|
+
deps['@react-three/drei'] = '^9.111.0';
|
|
96
|
+
deps['three'] = '^0.167.0';
|
|
97
|
+
}
|
|
98
|
+
if (data.hasFramer) deps['framer-motion'] = '^11.0.0';
|
|
99
|
+
|
|
100
|
+
return JSON.stringify({
|
|
101
|
+
name: data.domain?.replace(/[^a-z0-9]/gi, '-').toLowerCase() || 'webgl-clone',
|
|
102
|
+
version: '0.1.0',
|
|
103
|
+
private: true,
|
|
104
|
+
scripts: {
|
|
105
|
+
dev: 'next dev',
|
|
106
|
+
build: 'next build',
|
|
107
|
+
start: 'next start',
|
|
108
|
+
lint: 'next lint',
|
|
109
|
+
},
|
|
110
|
+
dependencies: deps,
|
|
111
|
+
devDependencies: {
|
|
112
|
+
'@types/node': '^20',
|
|
113
|
+
'@types/react': '^19',
|
|
114
|
+
'@types/react-dom': '^19',
|
|
115
|
+
'@types/three': '^0.167.0',
|
|
116
|
+
'typescript': '^5',
|
|
117
|
+
'tailwindcss': '^4.0.0',
|
|
118
|
+
'@tailwindcss/typography': '^0.5.15',
|
|
119
|
+
'postcss': '^8',
|
|
120
|
+
},
|
|
121
|
+
}, null, 2);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Generate CSS variables from design tokens
|
|
126
|
+
*/
|
|
127
|
+
function genTokensCss(data) {
|
|
128
|
+
const lines = [':root {'];
|
|
129
|
+
|
|
130
|
+
for (const { name, value } of data.cssVars) {
|
|
131
|
+
lines.push(` ${name}: ${value};`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Add commonly needed fallbacks
|
|
135
|
+
const colorNames = data.cssVars.map(v => v.name);
|
|
136
|
+
if (!colorNames.includes('--background')) lines.push(' --background: #000000;');
|
|
137
|
+
if (!colorNames.includes('--foreground')) lines.push(' --foreground: #ffffff;');
|
|
138
|
+
if (!colorNames.includes('--accent')) lines.push(' --accent: #4f46e5;');
|
|
139
|
+
|
|
140
|
+
lines.push('}');
|
|
141
|
+
lines.push('');
|
|
142
|
+
lines.push('* { box-sizing: border-box; margin: 0; padding: 0; }');
|
|
143
|
+
lines.push('html { background: var(--background); color: var(--foreground); }');
|
|
144
|
+
lines.push('body { font-family: var(--font-sans, system-ui, sans-serif); }');
|
|
145
|
+
|
|
146
|
+
return lines.join('\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Generate GSAP setup file
|
|
151
|
+
*/
|
|
152
|
+
function genGsapSetup(data) {
|
|
153
|
+
const imports = [`import gsap from 'gsap';`];
|
|
154
|
+
const registers = [];
|
|
155
|
+
|
|
156
|
+
for (const plugin of data.gsapPlugins) {
|
|
157
|
+
imports.push(`import { ${plugin} } from 'gsap/dist/${plugin}';`);
|
|
158
|
+
registers.push(` gsap.registerPlugin(${plugin});`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return `${imports.join('\n')}
|
|
162
|
+
|
|
163
|
+
export function initGSAP() {
|
|
164
|
+
${registers.join('\n') || ' // No plugins detected — add as needed'}
|
|
165
|
+
|
|
166
|
+
// Global defaults
|
|
167
|
+
gsap.defaults({ ease: 'power2.out', duration: 0.8 });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export { gsap };
|
|
171
|
+
${data.hasScrollTrigger ? '\nexport { ScrollTrigger };' : ''}
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Generate Lenis smooth scroll setup
|
|
177
|
+
*/
|
|
178
|
+
function genLenisSetup() {
|
|
179
|
+
return `'use client';
|
|
180
|
+
import { useEffect } from 'react';
|
|
181
|
+
import Lenis from '@studio-freight/lenis';
|
|
182
|
+
import { gsap } from './gsap-setup';
|
|
183
|
+
import { ScrollTrigger } from 'gsap/dist/ScrollTrigger';
|
|
184
|
+
|
|
185
|
+
let lenis: Lenis | null = null;
|
|
186
|
+
|
|
187
|
+
export function initLenis() {
|
|
188
|
+
lenis = new Lenis({
|
|
189
|
+
duration: 1.2,
|
|
190
|
+
easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
|
191
|
+
orientation: 'vertical',
|
|
192
|
+
gestureOrientation: 'vertical',
|
|
193
|
+
smoothWheel: true,
|
|
194
|
+
wheelMultiplier: 1,
|
|
195
|
+
touchMultiplier: 2,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Sync with GSAP ticker
|
|
199
|
+
gsap.ticker.add((time) => lenis?.raf(time * 1000));
|
|
200
|
+
gsap.ticker.lagSmoothing(0);
|
|
201
|
+
|
|
202
|
+
// Sync with ScrollTrigger
|
|
203
|
+
lenis.on('scroll', ScrollTrigger.update);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function useLenis() {
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
initLenis();
|
|
209
|
+
return () => {
|
|
210
|
+
lenis?.destroy();
|
|
211
|
+
lenis = null;
|
|
212
|
+
};
|
|
213
|
+
}, []);
|
|
214
|
+
}
|
|
215
|
+
`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Generate a basic R3F canvas component
|
|
220
|
+
*/
|
|
221
|
+
function genCanvasComponent(data) {
|
|
222
|
+
const hasOrbit = data.r3fComponents?.some(c => /orbit|controls/i.test(c));
|
|
223
|
+
|
|
224
|
+
return `'use client';
|
|
225
|
+
import { Canvas } from '@react-three/fiber';
|
|
226
|
+
import { Suspense } from 'react';
|
|
227
|
+
${hasOrbit ? "import { OrbitControls } from '@react-three/drei';" : ''}
|
|
228
|
+
|
|
229
|
+
// TODO: Replace with actual detected scene components
|
|
230
|
+
function Scene() {
|
|
231
|
+
return (
|
|
232
|
+
<>
|
|
233
|
+
<ambientLight intensity={0.5} />
|
|
234
|
+
<directionalLight position={[10, 10, 5]} intensity={1} />
|
|
235
|
+
<mesh>
|
|
236
|
+
<torusKnotGeometry args={[1, 0.3, 128, 32]} />
|
|
237
|
+
<meshStandardMaterial color="#4f46e5" />
|
|
238
|
+
</mesh>
|
|
239
|
+
${hasOrbit ? '<OrbitControls />' : ''}
|
|
240
|
+
</>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function WebGLCanvas() {
|
|
245
|
+
return (
|
|
246
|
+
<Canvas
|
|
247
|
+
camera={{ position: [0, 0, 5], fov: 75 }}
|
|
248
|
+
gl={{ antialias: true, alpha: true }}
|
|
249
|
+
dpr={[1, 2]}
|
|
250
|
+
style={{ position: 'fixed', inset: 0, zIndex: 0 }}
|
|
251
|
+
>
|
|
252
|
+
<Suspense fallback={null}>
|
|
253
|
+
<Scene />
|
|
254
|
+
</Suspense>
|
|
255
|
+
</Canvas>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Generate the main layout.tsx
|
|
263
|
+
*/
|
|
264
|
+
function genLayout(data) {
|
|
265
|
+
const fontImports = data.webFonts
|
|
266
|
+
.filter(f => f.provider === 'google' || !f.provider)
|
|
267
|
+
.slice(0, 3)
|
|
268
|
+
.map(f => ` // ${f.family} — found on target site`);
|
|
269
|
+
|
|
270
|
+
return `import type { Metadata } from 'next';
|
|
271
|
+
import './globals.css';
|
|
272
|
+
|
|
273
|
+
export const metadata: Metadata = {
|
|
274
|
+
title: 'Clone — ${data.domain}',
|
|
275
|
+
description: 'Rebuilt from webgl-forensics analysis',
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
279
|
+
${fontImports.length ? fontImports.join('\n') + '\n' : ''} return (
|
|
280
|
+
<html lang="en">
|
|
281
|
+
<body>{children}</body>
|
|
282
|
+
</html>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Generate a page stub for each detected page
|
|
290
|
+
*/
|
|
291
|
+
function genPageStub(pageName) {
|
|
292
|
+
return `export default function ${pageName}Page() {
|
|
293
|
+
return (
|
|
294
|
+
<main>
|
|
295
|
+
<h1>${pageName}</h1>
|
|
296
|
+
{/* TODO: Reconstruct from forensics report */}
|
|
297
|
+
</main>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Generate the loading/preloader component
|
|
305
|
+
*/
|
|
306
|
+
function genLoader(data) {
|
|
307
|
+
return `'use client';
|
|
308
|
+
import { useEffect, useState } from 'react';
|
|
309
|
+
|
|
310
|
+
export function Preloader() {
|
|
311
|
+
const [visible, setVisible] = useState(true);
|
|
312
|
+
const [progress, setProgress] = useState(0);
|
|
313
|
+
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
// Simulate load progress
|
|
316
|
+
const interval = setInterval(() => {
|
|
317
|
+
setProgress(prev => {
|
|
318
|
+
if (prev >= 100) {
|
|
319
|
+
clearInterval(interval);
|
|
320
|
+
setTimeout(() => setVisible(false), 400);
|
|
321
|
+
return 100;
|
|
322
|
+
}
|
|
323
|
+
return prev + Math.random() * 15;
|
|
324
|
+
});
|
|
325
|
+
}, 80);
|
|
326
|
+
|
|
327
|
+
return () => clearInterval(interval);
|
|
328
|
+
}, []);
|
|
329
|
+
|
|
330
|
+
if (!visible) return null;
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<div style={{
|
|
334
|
+
position: 'fixed', inset: 0, zIndex: 9999,
|
|
335
|
+
background: 'var(--background, #000)',
|
|
336
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
337
|
+
transition: 'opacity 0.4s',
|
|
338
|
+
opacity: progress >= 100 ? 0 : 1,
|
|
339
|
+
}}>
|
|
340
|
+
<div style={{ textAlign: 'center' }}>
|
|
341
|
+
<div style={{ fontSize: '2rem', fontVariantNumeric: 'tabular-nums' }}>
|
|
342
|
+
{Math.round(Math.min(progress, 100))}%
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Write all scaffold files to disk
|
|
353
|
+
*/
|
|
354
|
+
function writeScaffold(scaffoldDir, data) {
|
|
355
|
+
const write = (filePath, content) => {
|
|
356
|
+
const fullPath = path.join(scaffoldDir, filePath);
|
|
357
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
358
|
+
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// Package and tsconfig
|
|
362
|
+
write('package.json', genPackageJson(data));
|
|
363
|
+
write('tsconfig.json', JSON.stringify({
|
|
364
|
+
compilerOptions: {
|
|
365
|
+
target: 'ES2017', lib: ['dom', 'dom.iterable', 'esnext'],
|
|
366
|
+
allowJs: true, skipLibCheck: true, strict: true, noEmit: true,
|
|
367
|
+
esModuleInterop: true, module: 'esnext', moduleResolution: 'bundler',
|
|
368
|
+
resolveJsonModule: true, isolatedModules: true, jsx: 'preserve',
|
|
369
|
+
incremental: true, plugins: [{ name: 'next' }],
|
|
370
|
+
paths: { '@/*': ['./src/*'] },
|
|
371
|
+
},
|
|
372
|
+
include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '.next/types/**/*.ts'],
|
|
373
|
+
exclude: ['node_modules'],
|
|
374
|
+
}, null, 2));
|
|
375
|
+
|
|
376
|
+
// next.config.ts
|
|
377
|
+
write('next.config.ts', `import type { NextConfig } from 'next';
|
|
378
|
+
const config: NextConfig = { reactStrictMode: true };
|
|
379
|
+
export default config;
|
|
380
|
+
`);
|
|
381
|
+
|
|
382
|
+
// Styles
|
|
383
|
+
write('src/app/globals.css', genTokensCss(data));
|
|
384
|
+
|
|
385
|
+
// Layout
|
|
386
|
+
write('src/app/layout.tsx', genLayout(data));
|
|
387
|
+
|
|
388
|
+
// Pages
|
|
389
|
+
for (const page of data.pages) {
|
|
390
|
+
const dir = page.path === '/' ? 'src/app' : `src/app${page.path}`;
|
|
391
|
+
write(`${dir}/page.tsx`, genPageStub(page.name));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Lib
|
|
395
|
+
if (data.hasGSAP) write('src/lib/gsap-setup.ts', genGsapSetup(data));
|
|
396
|
+
if (data.hasLenis) write('src/lib/lenis.ts', genLenisSetup());
|
|
397
|
+
if (data.hasR3F) write('src/components/canvas.tsx', genCanvasComponent(data));
|
|
398
|
+
if (data.loading?.hasPreloader) write('src/components/preloader.tsx', genLoader(data));
|
|
399
|
+
|
|
400
|
+
// README stub
|
|
401
|
+
write('README.md', `# ${data.domain} Clone
|
|
402
|
+
|
|
403
|
+
Generated by [webgl-forensics](https://github.com/404kidwiz/webgl-forensics) v3.1.
|
|
404
|
+
|
|
405
|
+
## Source
|
|
406
|
+
${data.url}
|
|
407
|
+
|
|
408
|
+
## Detected Stack
|
|
409
|
+
- Framework: ${data.hasR3F ? 'Next.js + R3F' : 'Next.js'}
|
|
410
|
+
- Animations: ${[data.hasGSAP && 'GSAP', data.hasFramer && 'Framer Motion'].filter(Boolean).join(', ') || 'None'}
|
|
411
|
+
- Scroll: ${[data.hasLenis && 'Lenis', data.hasLocomotive && 'Locomotive'].filter(Boolean).join(', ') || 'Native'}
|
|
412
|
+
- Complexity Score: ${data.complexity || 'N/A'}/100
|
|
413
|
+
|
|
414
|
+
## Setup
|
|
415
|
+
\`\`\`bash
|
|
416
|
+
npm install
|
|
417
|
+
npm run dev
|
|
418
|
+
\`\`\`
|
|
419
|
+
`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Main export
|
|
424
|
+
*/
|
|
425
|
+
function generateScaffold(report, outputDir) {
|
|
426
|
+
const data = extractScaffoldData(report);
|
|
427
|
+
const scaffoldDir = path.join(outputDir, 'scaffold');
|
|
428
|
+
fs.mkdirSync(scaffoldDir, { recursive: true });
|
|
429
|
+
|
|
430
|
+
writeScaffold(scaffoldDir, data);
|
|
431
|
+
|
|
432
|
+
const files = [];
|
|
433
|
+
function walk(dir) {
|
|
434
|
+
for (const f of fs.readdirSync(dir)) {
|
|
435
|
+
const full = path.join(dir, f);
|
|
436
|
+
if (fs.statSync(full).isDirectory()) walk(full);
|
|
437
|
+
else files.push(path.relative(scaffoldDir, full));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
walk(scaffoldDir);
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
scaffoldDir,
|
|
444
|
+
filesGenerated: files.length,
|
|
445
|
+
files,
|
|
446
|
+
pages: data.pages.map(p => p.path),
|
|
447
|
+
dependencies: Object.keys(JSON.parse(fs.readFileSync(path.join(scaffoldDir, 'package.json'))).dependencies),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
module.exports = generateScaffold;
|