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,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCRIPT 17 — R3F Fiber Serializer
|
|
3
|
+
* webgl-forensics skill | Enhancement #6
|
|
4
|
+
*
|
|
5
|
+
* PURPOSE:
|
|
6
|
+
* Convert the live React Fiber tree (extracted by script 13) into
|
|
7
|
+
* copy-pastable JSX code that reconstructs the R3F scene.
|
|
8
|
+
*
|
|
9
|
+
* Script 13 gives you DATA. This gives you CODE.
|
|
10
|
+
*
|
|
11
|
+
* OUTPUT:
|
|
12
|
+
* - Valid JSX for the full R3F scene graph
|
|
13
|
+
* - Component imports needed
|
|
14
|
+
* - Material definitions
|
|
15
|
+
* - Camera + renderer config
|
|
16
|
+
* - Recommended file structure for clone
|
|
17
|
+
*
|
|
18
|
+
* RUN VIA:
|
|
19
|
+
* evaluate_script — run AFTER script 13 has been run
|
|
20
|
+
* (or run standalone — it re-walks the fiber tree internally)
|
|
21
|
+
*
|
|
22
|
+
* REQUIRES:
|
|
23
|
+
* React + R3F must be on the page (same as script 13)
|
|
24
|
+
*/
|
|
25
|
+
(() => {
|
|
26
|
+
// ═══════════════════════════════════════════════
|
|
27
|
+
// FIBER UTILITIES (duplicated from 13 for standalone use)
|
|
28
|
+
// ═══════════════════════════════════════════════
|
|
29
|
+
function findFiber(el) {
|
|
30
|
+
const key = Object.keys(el || {}).find(k =>
|
|
31
|
+
k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance')
|
|
32
|
+
);
|
|
33
|
+
return key ? el[key] : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const R3F_TAGS = new Set([
|
|
37
|
+
'mesh', 'group', 'points', 'line', 'lineSegments', 'instancedMesh',
|
|
38
|
+
'skinnedMesh', 'sprite', 'bone', 'lod', 'object3d',
|
|
39
|
+
'meshStandardMaterial', 'meshPhysicalMaterial', 'meshBasicMaterial',
|
|
40
|
+
'meshLambertMaterial', 'meshNormalMaterial', 'meshMatcapMaterial',
|
|
41
|
+
'shaderMaterial', 'rawShaderMaterial', 'spriteMaterial',
|
|
42
|
+
'boxGeometry', 'sphereGeometry', 'planeGeometry', 'cylinderGeometry',
|
|
43
|
+
'torusGeometry', 'torusKnotGeometry', 'bufferGeometry', 'coneGeometry',
|
|
44
|
+
'tetrahedronGeometry', 'icosahedronGeometry', 'octahedronGeometry',
|
|
45
|
+
'ambientLight', 'directionalLight', 'pointLight', 'spotLight',
|
|
46
|
+
'rectAreaLight', 'hemisphereLight',
|
|
47
|
+
'perspectiveCamera', 'orthographicCamera',
|
|
48
|
+
'primitive', 'canvas',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const DREI_COMPONENTS = new Set([
|
|
52
|
+
'OrbitControls', 'TrackballControls', 'TransformControls',
|
|
53
|
+
'Environment', 'Sky', 'Stars', 'Sparkles', 'Cloud',
|
|
54
|
+
'MeshTransmissionMaterial', 'MeshReflectorMaterial', 'MeshWobbleMaterial',
|
|
55
|
+
'Html', 'Billboard', 'Text', 'Text3D',
|
|
56
|
+
'useGLTF', 'useLoader', 'useFBX', 'useAnimations',
|
|
57
|
+
'ScrollControls', 'Scroll',
|
|
58
|
+
'Preload', 'BakeShadows', 'AdaptiveDpr', 'AdaptiveEvents',
|
|
59
|
+
'Bounds', 'Center', 'Resize', 'Float', 'Sampler',
|
|
60
|
+
'Instances', 'Merged', 'Clone',
|
|
61
|
+
'Image', 'Video', 'Texture',
|
|
62
|
+
'Decal', 'Shadow', 'ContactShadows', 'AccumulativeShadows',
|
|
63
|
+
'Lightformer', 'Caustics',
|
|
64
|
+
'GizmoHelper', 'GizmoViewport',
|
|
65
|
+
'Loader', 'Hud',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
// ═══════════════════════════════════════════════
|
|
69
|
+
// VALUE SERIALIZER — props → JSX string
|
|
70
|
+
// ═══════════════════════════════════════════════
|
|
71
|
+
function serializeValue(val, key) {
|
|
72
|
+
if (val === null || val === undefined) return null;
|
|
73
|
+
if (typeof val === 'boolean') return val ? key : `${key}={false}`;
|
|
74
|
+
if (typeof val === 'string') return `${key}="${val}"`;
|
|
75
|
+
if (typeof val === 'number') return `${key}={${val}}`;
|
|
76
|
+
if (typeof val === 'function') return `${key}={${val.name ? val.name : '() => {}'}}`;
|
|
77
|
+
if (Array.isArray(val)) {
|
|
78
|
+
if (val.length <= 4 && val.every(v => typeof v === 'number')) {
|
|
79
|
+
return `${key}={[${val.map(v => parseFloat(v.toFixed(4))).join(', ')}]}`;
|
|
80
|
+
}
|
|
81
|
+
return `${key}={/* array[${val.length}] */}`;
|
|
82
|
+
}
|
|
83
|
+
if (val && typeof val === 'object') {
|
|
84
|
+
if (val.isColor) return `${key}="${val.getHexString ? '#' + val.getHexString() : val}"`;
|
|
85
|
+
if (val.isVector3) return `${key}={[${val.x.toFixed(3)}, ${val.y.toFixed(3)}, ${val.z.toFixed(3)}]}`;
|
|
86
|
+
if (val.isEuler) return `${key}={[${val.x.toFixed(3)}, ${val.y.toFixed(3)}, ${val.z.toFixed(3)}]}`;
|
|
87
|
+
return `${key}={/* ${Object.keys(val).slice(0,3).join(', ')} */}`;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ═══════════════════════════════════════════════
|
|
93
|
+
// JSX COMPONENT NAME — map fiber type to JSX tag
|
|
94
|
+
// ═══════════════════════════════════════════════
|
|
95
|
+
function getFiberJSXName(fiber) {
|
|
96
|
+
const { type } = fiber;
|
|
97
|
+
if (typeof type === 'string') {
|
|
98
|
+
// R3F intrinsic elements: capitalize for JSX readability... actually they're lowercase
|
|
99
|
+
if (R3F_TAGS.has(type)) return type; // <mesh>, <group>, etc. are lowercase in R3F
|
|
100
|
+
return type;
|
|
101
|
+
}
|
|
102
|
+
if (typeof type === 'function') {
|
|
103
|
+
return type.displayName || type.name || 'Unknown';
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ═══════════════════════════════════════════════
|
|
109
|
+
// GENERATE JSX for a single fiber node
|
|
110
|
+
// ═══════════════════════════════════════════════
|
|
111
|
+
function serializeNode(fiber, depth = 0, imports = { r3f: new Set(), drei: new Set() }) {
|
|
112
|
+
if (!fiber || depth > 40) return '';
|
|
113
|
+
const indent = ' '.repeat(depth);
|
|
114
|
+
const name = getFiberJSXName(fiber);
|
|
115
|
+
if (!name) return '';
|
|
116
|
+
|
|
117
|
+
const isR3F = R3F_TAGS.has(name);
|
|
118
|
+
const isDrei = DREI_COMPONENTS.has(name);
|
|
119
|
+
const isCanvas = name === 'Canvas';
|
|
120
|
+
const isDOM = !isR3F && !isDrei && !isCanvas && typeof fiber.type === 'string';
|
|
121
|
+
|
|
122
|
+
// Skip most DOM elements — only keep Canvas wrapper divs
|
|
123
|
+
if (isDOM && !['div', 'section', 'main', 'canvas'].includes(name)) return '';
|
|
124
|
+
|
|
125
|
+
// Track imports
|
|
126
|
+
if (isDrei) imports.drei.add(name);
|
|
127
|
+
if (isCanvas) imports.r3f.add('Canvas');
|
|
128
|
+
|
|
129
|
+
// Serialize props
|
|
130
|
+
const props = fiber.memoizedProps || {};
|
|
131
|
+
const propStrings = [];
|
|
132
|
+
|
|
133
|
+
const SKIP_PROPS = new Set(['children', 'ref', 'key', '__self', '__source']);
|
|
134
|
+
|
|
135
|
+
for (const [key, val] of Object.entries(props)) {
|
|
136
|
+
if (SKIP_PROPS.has(key)) continue;
|
|
137
|
+
if (key.startsWith('on')) {
|
|
138
|
+
propStrings.push(`${key}={handleEvent}`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const serialized = serializeValue(val, key);
|
|
142
|
+
if (serialized) propStrings.push(serialized);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Serialize children recursively
|
|
146
|
+
let childrenJSX = '';
|
|
147
|
+
if (fiber.child) {
|
|
148
|
+
const childLines = [];
|
|
149
|
+
let child = fiber.child;
|
|
150
|
+
while (child) {
|
|
151
|
+
const childStr = serializeNode(child, depth + 1, imports);
|
|
152
|
+
if (childStr.trim()) childLines.push(childStr);
|
|
153
|
+
child = child.sibling;
|
|
154
|
+
}
|
|
155
|
+
childrenJSX = childLines.join('\n');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Build JSX
|
|
159
|
+
const propsStr = propStrings.length
|
|
160
|
+
? '\n' + propStrings.map(p => `${indent} ${p}`).join('\n') + '\n' + indent
|
|
161
|
+
: '';
|
|
162
|
+
|
|
163
|
+
if (!childrenJSX.trim()) {
|
|
164
|
+
return `${indent}<${name}${propsStr ? propsStr : ' '}/>`; // self-closing
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return `${indent}<${name}${propsStr}>\n${childrenJSX}\n${indent}</${name}>`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ═══════════════════════════════════════════════
|
|
171
|
+
// FIND R3F ENTRY POINT — the <Canvas> component
|
|
172
|
+
// ═══════════════════════════════════════════════
|
|
173
|
+
function findCanvasFiber() {
|
|
174
|
+
for (const canvas of document.querySelectorAll('canvas')) {
|
|
175
|
+
const fiber = findFiber(canvas);
|
|
176
|
+
if (!fiber) continue;
|
|
177
|
+
|
|
178
|
+
// Walk up to find Canvas component
|
|
179
|
+
let node = fiber;
|
|
180
|
+
let depth = 0;
|
|
181
|
+
while (node && depth < 60) {
|
|
182
|
+
if (typeof node.type === 'function') {
|
|
183
|
+
const name = node.type.displayName || node.type.name || '';
|
|
184
|
+
if (name === 'Canvas' || name.includes('Canvas')) {
|
|
185
|
+
return node;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
node = node.return;
|
|
189
|
+
depth++;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ═══════════════════════════════════════════════
|
|
196
|
+
// CANVAS CONFIG — extract R3F Canvas props
|
|
197
|
+
// ═══════════════════════════════════════════════
|
|
198
|
+
function extractCanvasProps(canvasFiber) {
|
|
199
|
+
if (!canvasFiber) return {};
|
|
200
|
+
const props = canvasFiber.memoizedProps || {};
|
|
201
|
+
return {
|
|
202
|
+
camera: props.camera,
|
|
203
|
+
frameloop: props.frameloop || 'always',
|
|
204
|
+
dpr: props.dpr,
|
|
205
|
+
shadows: props.shadows,
|
|
206
|
+
gl: props.gl ? Object.keys(props.gl) : undefined,
|
|
207
|
+
linear: props.linear,
|
|
208
|
+
flat: props.flat,
|
|
209
|
+
orthographic: props.orthographic,
|
|
210
|
+
eventSource: props.eventSource ? 'eventSource={ref}' : undefined,
|
|
211
|
+
style: props.style,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ═══════════════════════════════════════════════
|
|
216
|
+
// GENERATE COMPLETE FILE
|
|
217
|
+
// ═══════════════════════════════════════════════
|
|
218
|
+
const imports = { r3f: new Set(['Canvas']), drei: new Set() };
|
|
219
|
+
const canvasFiber = findCanvasFiber();
|
|
220
|
+
|
|
221
|
+
if (!canvasFiber) {
|
|
222
|
+
return {
|
|
223
|
+
error: 'No R3F Canvas found. Run script 13 first to confirm R3F is present.',
|
|
224
|
+
hint: 'Try scrolling to the 3D section of the page then re-running.',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const canvasProps = extractCanvasProps(canvasFiber);
|
|
229
|
+
const sceneJSX = canvasFiber.child ? serializeNode(canvasFiber.child, 3, imports) : '';
|
|
230
|
+
|
|
231
|
+
// Build import statements
|
|
232
|
+
const r3fImports = imports.r3f.size ? `import { ${[...imports.r3f].join(', ')} } from '@react-three/fiber'` : '';
|
|
233
|
+
const dreiImports = imports.drei.size ? `import { ${[...imports.drei].join(', ')} } from '@react-three/drei'` : '';
|
|
234
|
+
|
|
235
|
+
// Build Canvas prop string
|
|
236
|
+
const canvasPropStr = Object.entries(canvasProps)
|
|
237
|
+
.filter(([_, v]) => v !== undefined && v !== null)
|
|
238
|
+
.map(([k, v]) => {
|
|
239
|
+
if (typeof v === 'boolean') return v ? k : `${k}={false}`;
|
|
240
|
+
if (typeof v === 'string') return `${k}="${v}"`;
|
|
241
|
+
if (Array.isArray(v)) return `${k}={[${v.join(', ')}]}`;
|
|
242
|
+
if (typeof v === 'object') return `${k}={${JSON.stringify(v)}}`;
|
|
243
|
+
return `${k}={${v}}`;
|
|
244
|
+
})
|
|
245
|
+
.map(p => ` ${p}`)
|
|
246
|
+
.join('\n');
|
|
247
|
+
|
|
248
|
+
const output = `// ═══════════════════════════════════════════════════
|
|
249
|
+
// R3F Scene — Serialized by webgl-forensics v2.0
|
|
250
|
+
// Source: ${window.location.href}
|
|
251
|
+
// Generated: ${new Date().toISOString()}
|
|
252
|
+
// ═══════════════════════════════════════════════════
|
|
253
|
+
|
|
254
|
+
${r3fImports}
|
|
255
|
+
${dreiImports}
|
|
256
|
+
import * as THREE from 'three'
|
|
257
|
+
|
|
258
|
+
// TODO: Add your scene-specific imports (models, textures, etc.)
|
|
259
|
+
|
|
260
|
+
export default function Scene() {
|
|
261
|
+
return (
|
|
262
|
+
<Canvas
|
|
263
|
+
${canvasPropStr}
|
|
264
|
+
style={{ position: 'fixed', inset: 0 }}
|
|
265
|
+
>
|
|
266
|
+
${sceneJSX || ' {/* Scene content — check script 13 output for R3F nodes */}'}
|
|
267
|
+
</Canvas>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* ═══════════════════════════════════════════════════
|
|
272
|
+
USAGE NOTES:
|
|
273
|
+
|
|
274
|
+
1. Install deps:
|
|
275
|
+
pnpm add @react-three/fiber @react-three/drei three
|
|
276
|
+
pnpm add -D @types/three
|
|
277
|
+
|
|
278
|
+
2. Place this component in your layout or page
|
|
279
|
+
|
|
280
|
+
3. Required CSS on parent element:
|
|
281
|
+
position: relative;
|
|
282
|
+
width: 100%;
|
|
283
|
+
height: 100vh;
|
|
284
|
+
|
|
285
|
+
4. If using ScrollControls (Drei):
|
|
286
|
+
Wrap content in <Scroll> inside ScrollControls
|
|
287
|
+
|
|
288
|
+
5. Three.js object refs: use useRef() and attach via ref prop
|
|
289
|
+
═══════════════════════════════════════════════════ */
|
|
290
|
+
`;
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
meta: {
|
|
294
|
+
url: window.location.href,
|
|
295
|
+
timestamp: new Date().toISOString(),
|
|
296
|
+
r3fImports: [...imports.r3f],
|
|
297
|
+
dreiImports: [...imports.drei],
|
|
298
|
+
canvasProps,
|
|
299
|
+
sceneNodeCount: (sceneJSX.match(/<[a-z]/g) || []).length,
|
|
300
|
+
},
|
|
301
|
+
jsx: output,
|
|
302
|
+
fileStructure: {
|
|
303
|
+
recommended: [
|
|
304
|
+
'src/components/canvas/Scene.tsx ← paste jsx above here',
|
|
305
|
+
'src/components/canvas/index.ts ← re-export Scene',
|
|
306
|
+
'src/hooks/useScroll.ts ← R3F scroll hook',
|
|
307
|
+
'src/shaders/ ← GLSL from script 14',
|
|
308
|
+
'public/models/ ← GLB files',
|
|
309
|
+
],
|
|
310
|
+
install: [
|
|
311
|
+
'pnpm add @react-three/fiber @react-three/drei three',
|
|
312
|
+
'pnpm add -D @types/three',
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
})()
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCRIPT 18 — Font Extractor
|
|
3
|
+
* webgl-forensics skill | Enhancement #8
|
|
4
|
+
*
|
|
5
|
+
* PURPOSE:
|
|
6
|
+
* Extract every font used on the site:
|
|
7
|
+
* - Family names, weights, styles, formats
|
|
8
|
+
* - Source URLs (download-ready)
|
|
9
|
+
* - Unicode ranges and subsets
|
|
10
|
+
* - Variable font axes (weight, width, optical size, etc.)
|
|
11
|
+
* - Which elements use which fonts
|
|
12
|
+
* - Self-hosted vs. Google Fonts vs. Adobe Fonts vs. other CDN
|
|
13
|
+
* - CSS @font-face reconstruction for cloning
|
|
14
|
+
*
|
|
15
|
+
* RUN VIA:
|
|
16
|
+
* evaluate_script — run after page is fully loaded
|
|
17
|
+
*
|
|
18
|
+
* OUTPUT:
|
|
19
|
+
* - Complete @font-face CSS to paste into clone
|
|
20
|
+
* - Download URLs for self-hosted fonts
|
|
21
|
+
* - Google Fonts API URL if Google Fonts detected
|
|
22
|
+
* - Variable font axis ranges for dynamic typography
|
|
23
|
+
*/
|
|
24
|
+
(() => {
|
|
25
|
+
const result = {
|
|
26
|
+
meta: {
|
|
27
|
+
url: window.location.href,
|
|
28
|
+
timestamp: new Date().toISOString(),
|
|
29
|
+
totalFontsFound: 0,
|
|
30
|
+
},
|
|
31
|
+
fonts: [],
|
|
32
|
+
variableFonts: [],
|
|
33
|
+
usedComputedFonts: [],
|
|
34
|
+
sourceBreakdown: {
|
|
35
|
+
selfHosted: [],
|
|
36
|
+
googleFonts: [],
|
|
37
|
+
adobeFonts: [],
|
|
38
|
+
cdnFonts: [],
|
|
39
|
+
systemFonts: [],
|
|
40
|
+
},
|
|
41
|
+
reconstructionCSS: '',
|
|
42
|
+
downloadInstructions: [],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ═══════════════════════════════════════════════
|
|
46
|
+
// STEP 1 — Parse @font-face rules from all stylesheets
|
|
47
|
+
// ═══════════════════════════════════════════════
|
|
48
|
+
const fontFaceRules = [];
|
|
49
|
+
|
|
50
|
+
for (const sheet of document.styleSheets) {
|
|
51
|
+
try {
|
|
52
|
+
const rules = [...sheet.cssRules || []];
|
|
53
|
+
rules.forEach(rule => {
|
|
54
|
+
if (rule instanceof CSSFontFaceRule) {
|
|
55
|
+
fontFaceRules.push(rule);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
} catch(e) {
|
|
59
|
+
// Cross-origin stylesheet — can't read rules
|
|
60
|
+
if (sheet.href) {
|
|
61
|
+
result.fonts.push({
|
|
62
|
+
source: sheet.href,
|
|
63
|
+
accessible: false,
|
|
64
|
+
note: 'Cross-origin stylesheet — check Network tab for font requests',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ═══════════════════════════════════════════════
|
|
71
|
+
// STEP 2 — Extract font metadata from @font-face rules
|
|
72
|
+
// ═══════════════════════════════════════════════
|
|
73
|
+
const processedFamilies = new Map();
|
|
74
|
+
|
|
75
|
+
fontFaceRules.forEach((rule, idx) => {
|
|
76
|
+
const style = rule.style;
|
|
77
|
+
const family = style.getPropertyValue('font-family')?.replace(/['"]/g, '').trim();
|
|
78
|
+
const src = style.getPropertyValue('src') || '';
|
|
79
|
+
const weight = style.getPropertyValue('font-weight') || '400';
|
|
80
|
+
const fontStyle = style.getPropertyValue('font-style') || 'normal';
|
|
81
|
+
const display = style.getPropertyValue('font-display') || 'auto';
|
|
82
|
+
const unicodeRange = style.getPropertyValue('unicode-range') || '';
|
|
83
|
+
const variationSettings = style.getPropertyValue('font-variation-settings') || '';
|
|
84
|
+
|
|
85
|
+
// Extract URLs from src
|
|
86
|
+
const urlMatches = [...src.matchAll(/url\(['"]?([^'")\s]+)['"]?\)/g)];
|
|
87
|
+
const formatMatches = [...src.matchAll(/format\(['"]?([^'")\s]+)['"]?\)/g)];
|
|
88
|
+
|
|
89
|
+
const sources = urlMatches.map((match, i) => ({
|
|
90
|
+
url: match[1],
|
|
91
|
+
format: formatMatches[i]?.[1] || 'unknown',
|
|
92
|
+
isAbsolute: match[1].startsWith('http'),
|
|
93
|
+
fullUrl: match[1].startsWith('http')
|
|
94
|
+
? match[1]
|
|
95
|
+
: new URL(match[1], window.location.href).href,
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
// Classify source
|
|
99
|
+
let sourceType = 'self-hosted';
|
|
100
|
+
const allUrls = sources.map(s => s.url).join(' ');
|
|
101
|
+
if (/fonts\.googleapis\.com|fonts\.gstatic\.com/i.test(allUrls + src)) sourceType = 'google-fonts';
|
|
102
|
+
else if (/use\.typekit\.net|typekit\.com|adobe/i.test(allUrls + src)) sourceType = 'adobe-fonts';
|
|
103
|
+
else if (/fonts\.bunny\.net|bunnyfonts/i.test(allUrls + src)) sourceType = 'bunny-fonts';
|
|
104
|
+
else if (/cdnfonts\.com|fontawesome|fontcdn/i.test(allUrls + src)) sourceType = 'cdn';
|
|
105
|
+
|
|
106
|
+
// Check for variable font (weight range like "100 900")
|
|
107
|
+
const isVariable = /\d+\s+\d+/.test(weight) || variationSettings.includes('wght');
|
|
108
|
+
const weightRange = isVariable
|
|
109
|
+
? weight.split(/\s+/).map(Number).filter(Boolean)
|
|
110
|
+
: [parseInt(weight, 10)];
|
|
111
|
+
|
|
112
|
+
const fontEntry = {
|
|
113
|
+
id: idx,
|
|
114
|
+
family,
|
|
115
|
+
weight,
|
|
116
|
+
weightRange,
|
|
117
|
+
style: fontStyle,
|
|
118
|
+
display,
|
|
119
|
+
unicodeRange: unicodeRange || 'all',
|
|
120
|
+
isVariable,
|
|
121
|
+
variationSettings,
|
|
122
|
+
sourceType,
|
|
123
|
+
sources,
|
|
124
|
+
raw: rule.cssText.slice(0, 500),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
result.fonts.push(fontEntry);
|
|
128
|
+
|
|
129
|
+
// Group by family
|
|
130
|
+
if (!processedFamilies.has(family)) {
|
|
131
|
+
processedFamilies.set(family, { family, weights: [], styles: [], sourceType, sources: [], isVariable });
|
|
132
|
+
}
|
|
133
|
+
const fam = processedFamilies.get(family);
|
|
134
|
+
fam.weights.push(weight);
|
|
135
|
+
fam.styles.push(fontStyle);
|
|
136
|
+
fam.sources.push(...sources);
|
|
137
|
+
if (isVariable) fam.isVariable = true;
|
|
138
|
+
|
|
139
|
+
// Source breakdown
|
|
140
|
+
if (sourceType === 'google-fonts') result.sourceBreakdown.googleFonts.push(family);
|
|
141
|
+
else if (sourceType === 'adobe-fonts') result.sourceBreakdown.adobeFonts.push(family);
|
|
142
|
+
else if (sourceType === 'cdn' || sourceType === 'bunny-fonts') result.sourceBreakdown.cdnFonts.push(family);
|
|
143
|
+
else result.sourceBreakdown.selfHosted.push(family);
|
|
144
|
+
|
|
145
|
+
if (isVariable) result.variableFonts.push({ family, weightRange, variationSettings });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ═══════════════════════════════════════════════
|
|
149
|
+
// STEP 3 — Detect fonts via computed styles
|
|
150
|
+
// (catches system fonts and fonts not declared in @font-face)
|
|
151
|
+
// ═══════════════════════════════════════════════
|
|
152
|
+
const computedFontMap = new Map();
|
|
153
|
+
const sampleElements = [
|
|
154
|
+
document.body,
|
|
155
|
+
document.querySelector('h1'),
|
|
156
|
+
document.querySelector('h2'),
|
|
157
|
+
document.querySelector('p'),
|
|
158
|
+
document.querySelector('a'),
|
|
159
|
+
document.querySelector('button'),
|
|
160
|
+
document.querySelector('nav'),
|
|
161
|
+
document.querySelector('.hero, [class*="hero"]'),
|
|
162
|
+
document.querySelector('[class*="title"]'),
|
|
163
|
+
document.querySelector('[class*="heading"]'),
|
|
164
|
+
].filter(Boolean);
|
|
165
|
+
|
|
166
|
+
sampleElements.forEach(el => {
|
|
167
|
+
const computed = window.getComputedStyle(el);
|
|
168
|
+
const fontFamily = computed.fontFamily;
|
|
169
|
+
const fontSize = computed.fontSize;
|
|
170
|
+
const fontWeight = computed.fontWeight;
|
|
171
|
+
const letterSpacing = computed.letterSpacing;
|
|
172
|
+
const lineHeight = computed.lineHeight;
|
|
173
|
+
|
|
174
|
+
const tag = el.tagName.toLowerCase();
|
|
175
|
+
const cls = el.className && typeof el.className === 'string'
|
|
176
|
+
? '.' + el.className.trim().split(/\s+/)[0]
|
|
177
|
+
: '';
|
|
178
|
+
|
|
179
|
+
if (!computedFontMap.has(fontFamily)) {
|
|
180
|
+
computedFontMap.set(fontFamily, {
|
|
181
|
+
fontFamily,
|
|
182
|
+
elements: [],
|
|
183
|
+
sizes: new Set(),
|
|
184
|
+
weights: new Set(),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const entry = computedFontMap.get(fontFamily);
|
|
189
|
+
entry.elements.push(`${tag}${cls}`);
|
|
190
|
+
entry.sizes.add(fontSize);
|
|
191
|
+
entry.weights.add(fontWeight);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
result.usedComputedFonts = [...computedFontMap.values()].map(f => ({
|
|
195
|
+
...f,
|
|
196
|
+
sizes: [...f.sizes],
|
|
197
|
+
weights: [...f.weights],
|
|
198
|
+
isSystemFont: !result.fonts.some(rf => rf.family === f.fontFamily.replace(/['"]/g, '').trim()),
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
// System fonts
|
|
202
|
+
result.usedComputedFonts
|
|
203
|
+
.filter(f => f.isSystemFont)
|
|
204
|
+
.forEach(f => result.sourceBreakdown.systemFonts.push(f.fontFamily));
|
|
205
|
+
|
|
206
|
+
// ═══════════════════════════════════════════════
|
|
207
|
+
// STEP 4 — Font loading API status
|
|
208
|
+
// ═══════════════════════════════════════════════
|
|
209
|
+
const loadedFonts = [];
|
|
210
|
+
if (document.fonts) {
|
|
211
|
+
for (const font of document.fonts) {
|
|
212
|
+
loadedFonts.push({
|
|
213
|
+
family: font.family,
|
|
214
|
+
weight: font.weight,
|
|
215
|
+
style: font.style,
|
|
216
|
+
status: font.status, // 'loaded' | 'loading' | 'error'
|
|
217
|
+
unicodeRange: font.unicodeRange,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
result.fontFaceAPI = {
|
|
222
|
+
total: loadedFonts.length,
|
|
223
|
+
loaded: loadedFonts.filter(f => f.status === 'loaded').length,
|
|
224
|
+
loading: loadedFonts.filter(f => f.status === 'loading').length,
|
|
225
|
+
error: loadedFonts.filter(f => f.status === 'error').length,
|
|
226
|
+
fonts: loadedFonts,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// ═══════════════════════════════════════════════
|
|
230
|
+
// STEP 5 — Reconstruct @font-face CSS
|
|
231
|
+
// ═══════════════════════════════════════════════
|
|
232
|
+
const cssLines = [
|
|
233
|
+
'/* ══════════════════════════════════════════════',
|
|
234
|
+
` Fonts — webgl-forensics extraction`,
|
|
235
|
+
` Source: ${window.location.href}`,
|
|
236
|
+
'══════════════════════════════════════════════ */',
|
|
237
|
+
'',
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
result.fonts.forEach(font => {
|
|
241
|
+
if (font.sources.length === 0) return;
|
|
242
|
+
|
|
243
|
+
const srcParts = font.sources
|
|
244
|
+
.map(s => `url('${s.fullUrl}') format('${s.format}')`)
|
|
245
|
+
.join(',\n ');
|
|
246
|
+
|
|
247
|
+
cssLines.push('@font-face {');
|
|
248
|
+
cssLines.push(` font-family: '${font.family}';`);
|
|
249
|
+
cssLines.push(` font-weight: ${font.weight};`);
|
|
250
|
+
if (font.style !== 'normal') cssLines.push(` font-style: ${font.style};`);
|
|
251
|
+
cssLines.push(` font-display: ${font.display || 'swap'};`);
|
|
252
|
+
if (font.unicodeRange && font.unicodeRange !== 'all') cssLines.push(` unicode-range: ${font.unicodeRange};`);
|
|
253
|
+
cssLines.push(` src: ${srcParts};`);
|
|
254
|
+
cssLines.push('}');
|
|
255
|
+
cssLines.push('');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
result.reconstructionCSS = cssLines.join('\n');
|
|
259
|
+
|
|
260
|
+
// ═══════════════════════════════════════════════
|
|
261
|
+
// STEP 6 — Download instructions
|
|
262
|
+
// ═══════════════════════════════════════════════
|
|
263
|
+
const selfHostedFonts = result.fonts.filter(f => f.sourceType === 'self-hosted');
|
|
264
|
+
if (selfHostedFonts.length > 0) {
|
|
265
|
+
result.downloadInstructions.push('# Self-Hosted Fonts — Copy these files to /public/fonts/');
|
|
266
|
+
selfHostedFonts.forEach(font => {
|
|
267
|
+
font.sources.forEach(src => {
|
|
268
|
+
result.downloadInstructions.push(`curl -o "public/fonts/${src.url.split('/').pop()}" "${src.fullUrl}"`);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const googleFamilies = [...new Set(result.sourceBreakdown.googleFonts)];
|
|
274
|
+
if (googleFamilies.length > 0) {
|
|
275
|
+
result.downloadInstructions.push('');
|
|
276
|
+
result.downloadInstructions.push('# Google Fonts — Add to Next.js layout.tsx:');
|
|
277
|
+
result.downloadInstructions.push(`import { ${googleFamilies.map(f => f.replace(/\s+/g, '_')).join(', ')} } from 'next/font/google'`);
|
|
278
|
+
googleFamilies.forEach(family => {
|
|
279
|
+
const varName = family.replace(/\s+/g, '_').toLowerCase();
|
|
280
|
+
const weights = [...new Set(result.fonts.filter(f => f.family === family).map(f => f.weight))];
|
|
281
|
+
result.downloadInstructions.push(`const ${varName} = ${family.replace(/\s+/g, '_')}({ weight: [${weights.map(w => `'${w}'`).join(', ')}], subsets: ['latin'] })`);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
result.meta.totalFontsFound = result.fonts.length;
|
|
286
|
+
result.meta.familiesFound = processedFamilies.size;
|
|
287
|
+
result.meta.variableFontCount = result.variableFonts.length;
|
|
288
|
+
|
|
289
|
+
return result;
|
|
290
|
+
})()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const extractDesignTokens = () => {
|
|
2
|
+
const tokens = {
|
|
3
|
+
cssVariables: {},
|
|
4
|
+
sampledColors: [],
|
|
5
|
+
tailwindScaffold: { extend: { colors: {}, fontFamily: {}, spacing: {} } }
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const rootStyles = getComputedStyle(document.documentElement);
|
|
9
|
+
|
|
10
|
+
const extractFromRule = (rule) => {
|
|
11
|
+
for (let i = 0; i < rule.style.length; i++) {
|
|
12
|
+
const prop = rule.style[i];
|
|
13
|
+
if (prop.startsWith('--')) {
|
|
14
|
+
tokens.cssVariables[prop] = rootStyles.getPropertyValue(prop).trim();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const scanStyleSheets = () => {
|
|
20
|
+
try {
|
|
21
|
+
for (let i = 0; i < document.styleSheets.length; i++) {
|
|
22
|
+
const sheet = document.styleSheets[i];
|
|
23
|
+
if (!sheet.href || sheet.href.startsWith(window.location.origin)) {
|
|
24
|
+
for (let j = 0; j < sheet.cssRules.length; j++) {
|
|
25
|
+
const rule = sheet.cssRules[j];
|
|
26
|
+
if (rule.type === 1 && (rule.selectorText === ':root' || rule.selectorText === 'body' || rule.selectorText === 'html')) {
|
|
27
|
+
extractFromRule(rule);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} catch (e) {
|
|
33
|
+
// Ignore CORS errors
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
scanStyleSheets();
|
|
38
|
+
|
|
39
|
+
// If no variables found, fallback to inline styles on document body
|
|
40
|
+
if (Object.keys(tokens.cssVariables).length === 0 && document.body) {
|
|
41
|
+
if (document.body.style) {
|
|
42
|
+
extractFromRule({ style: document.body.style });
|
|
43
|
+
}
|
|
44
|
+
// Search inline style tags for :root
|
|
45
|
+
const inlineStyles = document.querySelectorAll('style');
|
|
46
|
+
inlineStyles.forEach(style => {
|
|
47
|
+
const text = style.textContent;
|
|
48
|
+
const matches = text.match(/--[\w-]+:[^;]+;/g);
|
|
49
|
+
if (matches) {
|
|
50
|
+
matches.forEach(m => {
|
|
51
|
+
const [key, val] = m.split(':');
|
|
52
|
+
if (key && val) tokens.cssVariables[key.trim()] = val.replace(';', '').trim();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Sample colors from DOM
|
|
59
|
+
const colorSet = new Set();
|
|
60
|
+
const els = document.querySelectorAll('h1, h2, p, a, button, div, section, header, footer');
|
|
61
|
+
els.forEach(el => {
|
|
62
|
+
const style = getComputedStyle(el);
|
|
63
|
+
if (style.color !== 'rgba(0, 0, 0, 0)') colorSet.add(style.color);
|
|
64
|
+
if (style.backgroundColor !== 'rgba(0, 0, 0, 0)' && !style.backgroundColor.includes('transparent')) colorSet.add(style.backgroundColor);
|
|
65
|
+
});
|
|
66
|
+
tokens.sampledColors = Array.from(colorSet);
|
|
67
|
+
|
|
68
|
+
// Generate Tailwind Scaffold
|
|
69
|
+
for (const [key, value] of Object.entries(tokens.cssVariables)) {
|
|
70
|
+
const cleanKey = key.replace('--', '');
|
|
71
|
+
if (cleanKey.includes('color') || cleanKey.includes('text') || cleanKey.includes('bg')) {
|
|
72
|
+
const name = cleanKey.replace('color-', '').replace('bg-', '').replace('text-', '');
|
|
73
|
+
tokens.tailwindScaffold.extend.colors[name] = `var(${key})`;
|
|
74
|
+
} else if (cleanKey.includes('font')) {
|
|
75
|
+
const name = cleanKey.replace('font-', '').replace('family-', '');
|
|
76
|
+
tokens.tailwindScaffold.extend.fontFamily[name] = `var(${key})`;
|
|
77
|
+
} else if (cleanKey.includes('space') || cleanKey.includes('gap') || cleanKey.includes('pad')) {
|
|
78
|
+
tokens.tailwindScaffold.extend.spacing[cleanKey] = `var(${key})`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return tokens;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return extractDesignTokens();
|