@trueclone/cli 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +134 -2
- package/bin/trueclone.mjs +139 -0
- package/package.json +62 -4
- package/scripts/capture.mjs +189 -0
- package/scripts/curves.mjs +254 -0
- package/scripts/design.mjs +491 -0
- package/scripts/detail.mjs +225 -0
- package/scripts/diff.mjs +72 -0
- package/scripts/hovers.mjs +116 -0
- package/scripts/interact.mjs +184 -0
- package/scripts/lib.mjs +389 -0
- package/scripts/motion.mjs +239 -0
- package/scripts/next.mjs +316 -0
- package/scripts/report.mjs +114 -0
- package/scripts/rules.mjs +109 -0
- package/scripts/score.mjs +82 -0
- package/scripts/seed.mjs +68 -0
- package/scripts/spec.mjs +78 -0
- package/scripts/states.mjs +172 -0
- package/scripts/textshape.mjs +384 -0
- package/scripts/tiles.mjs +25 -0
- package/skill/SKILL.md +138 -0
package/scripts/next.mjs
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Next.js mode: turn a verified HTML clone into a Next.js (App Router) + Tailwind v4 + shadcn-ready project.
|
|
2
|
+
// sites/<name>/next/
|
|
3
|
+
// app/globals.css the generated shadcn theme (from design-tokens.json) + the clone's tokens.css + site.css
|
|
4
|
+
// app/layout.tsx fonts, metadata (noindex), early `js` class
|
|
5
|
+
// app/page.tsx composes one component per section
|
|
6
|
+
// components/site/* each section converted from the clone's HTML to JSX
|
|
7
|
+
// components/site/SiteMotion.tsx client component: npm GSAP/ScrollTrigger/SplitText, Lenis, Swiper, three.js
|
|
8
|
+
// lib/site-script.js the clone's motion/interaction script, unchanged logic, exported as initSite()
|
|
9
|
+
// components.json + lib/utils.ts so `npx shadcn add <component>` works out of the box
|
|
10
|
+
// The HTML clone stays the source of truth: regenerate after every change, then verify the build with the same
|
|
11
|
+
// four scores (`npm run next -- <name> --verify`).
|
|
12
|
+
//
|
|
13
|
+
// usage: node scripts/next.mjs <name> [--install] [--build] [--verify [--quick]] [--port 3100]
|
|
14
|
+
import fs from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import net from 'node:net';
|
|
18
|
+
import { parse } from 'parse5';
|
|
19
|
+
import { fileURLToPath as toPath } from 'node:url';
|
|
20
|
+
import { parseArgs } from './lib.mjs';
|
|
21
|
+
|
|
22
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
23
|
+
const [name] = pos;
|
|
24
|
+
if (!name) {
|
|
25
|
+
console.error('usage: npm run next -- <name> [--install] [--build] [--verify] [--port 3100]');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const root = path.resolve('sites', name);
|
|
29
|
+
const cloneDir = path.join(root, 'clone');
|
|
30
|
+
const out = path.join(root, 'next');
|
|
31
|
+
const port = Number(flags.port || 3100);
|
|
32
|
+
|
|
33
|
+
// Versions: motion libraries pinned to what the verified HTML clone loads, so behaviour is identical.
|
|
34
|
+
const PKG = {
|
|
35
|
+
name: `${name}-next`, private: true, version: '0.1.0',
|
|
36
|
+
scripts: { dev: 'next dev', build: 'next build', start: `next start -p ${port}` },
|
|
37
|
+
dependencies: {
|
|
38
|
+
next: '16.4.0', react: '19.3.0', 'react-dom': '19.3.0',
|
|
39
|
+
gsap: '3.13.0', lenis: '1.1.18', swiper: '11.2.6', three: '0.128.0',
|
|
40
|
+
clsx: '2.1.1', 'tailwind-merge': '3.7.0',
|
|
41
|
+
},
|
|
42
|
+
devDependencies: {
|
|
43
|
+
tailwindcss: '4.3.3', '@tailwindcss/postcss': '4.3.3', 'tw-animate-css': '1.4.0',
|
|
44
|
+
typescript: '5.9.3', '@types/react': '19.3.0', '@types/node': '22.18.0',
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ---------------- HTML -> JSX ----------------
|
|
49
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
|
|
50
|
+
const RENAME = {
|
|
51
|
+
class: 'className', for: 'htmlFor', tabindex: 'tabIndex', readonly: 'readOnly', maxlength: 'maxLength', minlength: 'minLength',
|
|
52
|
+
crossorigin: 'crossOrigin', autoplay: 'autoPlay', playsinline: 'playsInline', srcset: 'srcSet', autocomplete: 'autoComplete',
|
|
53
|
+
enctype: 'encType', colspan: 'colSpan', rowspan: 'rowSpan', contenteditable: 'contentEditable', spellcheck: 'spellCheck',
|
|
54
|
+
novalidate: 'noValidate', allowfullscreen: 'allowFullScreen', frameborder: 'frameBorder', inputmode: 'inputMode', datetime: 'dateTime',
|
|
55
|
+
};
|
|
56
|
+
const BOOL = new Set(['muted', 'loop', 'playsinline', 'autoplay', 'controls', 'disabled', 'checked', 'required', 'hidden', 'novalidate', 'readonly', 'multiple', 'selected', 'allowfullscreen']);
|
|
57
|
+
const camel = (s) => s.replace(/[-:]([a-z])/g, (m, c) => c.toUpperCase());
|
|
58
|
+
const styleObject = (css) => {
|
|
59
|
+
const o = {};
|
|
60
|
+
for (const decl of css.split(';')) {
|
|
61
|
+
const i = decl.indexOf(':');
|
|
62
|
+
if (i < 0) continue;
|
|
63
|
+
const k = decl.slice(0, i).trim();
|
|
64
|
+
if (k) o[k.startsWith('--') ? k : camel(k)] = decl.slice(i + 1).trim();
|
|
65
|
+
}
|
|
66
|
+
return o;
|
|
67
|
+
};
|
|
68
|
+
const fixUrl = (v) => (v.startsWith('assets/') ? '/' + v : v);
|
|
69
|
+
const attrString = (n, v) => (/["\\]/.test(v) ? `${n}={${JSON.stringify(v)}}` : `${n}="${v}"`);
|
|
70
|
+
|
|
71
|
+
let usesStyle = false; // set while converting a component whose style objects need the CSSProperties type (custom properties like --i)
|
|
72
|
+
function jsxAttrs(node, inSvg) {
|
|
73
|
+
const parts = [];
|
|
74
|
+
for (const a of node.attrs || []) {
|
|
75
|
+
if (/^on[a-z]+$/.test(a.name)) continue; // inline handlers are strings; the motion script owns behaviour
|
|
76
|
+
if (a.name === 'style') { usesStyle = true; parts.push(`style={${JSON.stringify(styleObject(a.value))} as CSSProperties}`); continue; }
|
|
77
|
+
if (BOOL.has(a.name) && (a.value === '' || a.value === a.name)) { parts.push(RENAME[a.name] || a.name); continue; }
|
|
78
|
+
let n = RENAME[a.name] || a.name;
|
|
79
|
+
if (!RENAME[a.name] && inSvg && !n.startsWith('data-') && !n.startsWith('aria-')) n = camel(n);
|
|
80
|
+
if (n === 'xmlns:xlink') continue;
|
|
81
|
+
const v = ['src', 'href', 'poster', 'srcSet'].includes(n) ? fixUrl(a.value) : a.value;
|
|
82
|
+
parts.push(attrString(n, v));
|
|
83
|
+
}
|
|
84
|
+
return parts.length ? ' ' + parts.join(' ') : '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toJsx(node, depth, inSvg = false) {
|
|
88
|
+
const pad = ' '.repeat(depth);
|
|
89
|
+
if (node.nodeName === '#comment') return '';
|
|
90
|
+
if (node.nodeName === '#text') {
|
|
91
|
+
if (!node.value.trim()) return node.value.includes('\n') ? '' : ' ';
|
|
92
|
+
const text = node.value.replace(/\s+/g, ' ');
|
|
93
|
+
return /[{}<>&"']/.test(text) ? `{${JSON.stringify(text)}}` : text;
|
|
94
|
+
}
|
|
95
|
+
if (['script', 'noscript', 'style'].includes(node.nodeName)) return '';
|
|
96
|
+
const tag = node.tagName;
|
|
97
|
+
const svg = inSvg || tag === 'svg';
|
|
98
|
+
const attrs = jsxAttrs(node, svg);
|
|
99
|
+
const kids = (node.childNodes || []).map((c) => toJsx(c, depth + 1, svg)).filter((s) => s !== '');
|
|
100
|
+
if (VOID.has(tag) || !kids.length) return `${pad}<${tag}${attrs} />`;
|
|
101
|
+
const inline = kids.every((k) => !k.startsWith(' ')) && kids.join('').length < 120;
|
|
102
|
+
if (inline) return `${pad}<${tag}${attrs}>${kids.map((k) => k.trim() || ' ').join('')}</${tag}>`;
|
|
103
|
+
return `${pad}<${tag}${attrs}>\n${kids.map((k) => (k.startsWith(' ') && k.trim() ? k : pad + ' ' + k.trim())).join('\n')}\n${pad}</${tag}>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const pascal = (s) => s.split(/[^a-z0-9]+/i).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
|
|
107
|
+
const els = (n) => (n.childNodes || []).filter((c) => c.tagName);
|
|
108
|
+
const find = (n, tag) => els(n).find((c) => c.tagName === tag);
|
|
109
|
+
|
|
110
|
+
// ---------------- Generate ----------------
|
|
111
|
+
const html = await fs.readFile(path.join(cloneDir, 'index.html'), 'utf8');
|
|
112
|
+
const doc = parse(html);
|
|
113
|
+
const htmlEl = find(doc, 'html');
|
|
114
|
+
const head = find(htmlEl, 'head');
|
|
115
|
+
const body = find(htmlEl, 'body');
|
|
116
|
+
const title = (els(head).find((e) => e.tagName === 'title')?.childNodes[0]?.value || name).trim();
|
|
117
|
+
const fontLinks = els(head).filter((e) => e.tagName === 'link').map((e) => Object.fromEntries(e.attrs.map((a) => [a.name, a.value])))
|
|
118
|
+
.filter((l) => /fonts\.(googleapis|gstatic)\.com/.test(l.href || ''));
|
|
119
|
+
|
|
120
|
+
await fs.rm(path.join(out, 'app'), { recursive: true, force: true });
|
|
121
|
+
await fs.rm(path.join(out, 'components', 'site'), { recursive: true, force: true });
|
|
122
|
+
for (const d of ['app', 'components/site', 'lib', 'styles', 'public']) await fs.mkdir(path.join(out, d), { recursive: true });
|
|
123
|
+
|
|
124
|
+
// Sections: everything before <main> is "chrome" (nav, menu, intro, cursor); each child of <main> is a section.
|
|
125
|
+
const mainEl = find(body, 'main');
|
|
126
|
+
const chrome = els(body).filter((e) => e !== mainEl && e.tagName !== 'script');
|
|
127
|
+
const sections = mainEl ? els(mainEl) : [];
|
|
128
|
+
const used = new Set();
|
|
129
|
+
const components = [];
|
|
130
|
+
const writeComponent = async (compName, nodes, depth = 2) => {
|
|
131
|
+
usesStyle = false;
|
|
132
|
+
const jsx = nodes.map((n) => toJsx(n, depth)).filter(Boolean).join('\n');
|
|
133
|
+
const typeImport = usesStyle ? "import type { CSSProperties } from 'react';\n\n" : '';
|
|
134
|
+
const file = `// Generated from clone/index.html by scripts/next.mjs. Edit the HTML clone and regenerate.\n${typeImport}export default function ${compName}() {\n return (\n <>\n${jsx}\n </>\n );\n}\n`;
|
|
135
|
+
await fs.writeFile(path.join(out, 'components', 'site', `${compName}.tsx`), file);
|
|
136
|
+
};
|
|
137
|
+
await writeComponent('SiteChrome', chrome, 3);
|
|
138
|
+
for (const s of sections) {
|
|
139
|
+
const id = s.attrs.find((a) => a.name === 'id')?.value;
|
|
140
|
+
const cls = (s.attrs.find((a) => a.name === 'class')?.value || s.tagName).split(/\s+/)[0];
|
|
141
|
+
let compName = pascal(id || cls) || 'Section';
|
|
142
|
+
if (/^\d/.test(compName)) compName = 'S' + compName;
|
|
143
|
+
while (used.has(compName)) compName += 'X';
|
|
144
|
+
used.add(compName);
|
|
145
|
+
components.push(compName);
|
|
146
|
+
await writeComponent(compName, [s], 3);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Motion: the clone's script, wrapped as initSite(); libraries come from npm instead of CDN script tags.
|
|
150
|
+
let script = await fs.readFile(path.join(cloneDir, 'script.js'), 'utf8');
|
|
151
|
+
const startIdx = script.indexOf('(() => {');
|
|
152
|
+
const endIdx = script.lastIndexOf('})();');
|
|
153
|
+
if (startIdx < 0 || endIdx < 0) throw new Error('clone/script.js must be a single (() => { ... })(); IIFE');
|
|
154
|
+
script = script.slice(0, startIdx) + 'export default function initSite() {' + script.slice(startIdx + '(() => {'.length, endIdx) + '}\n';
|
|
155
|
+
script = script.replace(/(['"`])assets\//g, (m, q) => `${q}/assets/`);
|
|
156
|
+
await fs.writeFile(path.join(out, 'lib', 'site-script.js'), `// Generated from clone/script.js by scripts/next.mjs. Edit the HTML clone and regenerate.\n${script}`);
|
|
157
|
+
await fs.writeFile(path.join(out, 'components', 'site', 'SiteMotion.tsx'), `'use client';
|
|
158
|
+
// Generated by scripts/next.mjs: loads the motion libraries from npm (same versions the HTML clone uses),
|
|
159
|
+
// exposes them the way the clone's script expects, then runs it once after hydration.
|
|
160
|
+
import { useEffect } from 'react';
|
|
161
|
+
|
|
162
|
+
export default function SiteMotion() {
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
const w = window as unknown as Record<string, unknown>;
|
|
165
|
+
if (w.__siteInit) return; // React strict mode runs effects twice in dev
|
|
166
|
+
w.__siteInit = true;
|
|
167
|
+
(async () => {
|
|
168
|
+
const [{ gsap }, { ScrollTrigger }, { SplitText }, lenis, swiper, three] = await Promise.all([
|
|
169
|
+
import('gsap'), import('gsap/ScrollTrigger'), import('gsap/SplitText'), import('lenis'), import('swiper/bundle'), import('three'),
|
|
170
|
+
]);
|
|
171
|
+
Object.assign(w, { gsap, ScrollTrigger, SplitText, Lenis: lenis.default, Swiper: swiper.default, THREE: three });
|
|
172
|
+
const { default: initSite } = await import('@/lib/site-script.js');
|
|
173
|
+
initSite();
|
|
174
|
+
})();
|
|
175
|
+
}, []);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
`);
|
|
179
|
+
|
|
180
|
+
// Styles: shadcn theme first (Tailwind + tokens as utilities), then the clone's own tokens and stylesheet.
|
|
181
|
+
const globals = await fs.readFile(path.join(root, 'globals.css'), 'utf8');
|
|
182
|
+
const siteCss = (await fs.readFile(path.join(cloneDir, 'styles.css'), 'utf8')).replace(/@import url\(["']?tokens\.css["']?\);\s*/, '').replace(/url\((['"]?)assets\//g, (m, q) => `url(${q}/assets/`);
|
|
183
|
+
await fs.writeFile(path.join(out, 'styles', 'site.css'), siteCss);
|
|
184
|
+
await fs.copyFile(path.join(cloneDir, 'tokens.css'), path.join(out, 'styles', 'tokens.css'));
|
|
185
|
+
// Library stylesheets the HTML loads from an npm CDN become package imports, placed BEFORE the site
|
|
186
|
+
// stylesheet exactly like in the HTML <head>: same cascade order, same winners (e.g. .media-card vs .swiper-slide).
|
|
187
|
+
const libCss = els(head).filter((e) => e.tagName === 'link').map((e) => Object.fromEntries(e.attrs.map((a) => [a.name, a.value])))
|
|
188
|
+
.filter((l) => l.rel === 'stylesheet' && /cdn\.jsdelivr\.net\/npm\/|unpkg\.com\//.test(l.href || ''))
|
|
189
|
+
.map((l) => {
|
|
190
|
+
const m = /\/npm\/((?:@[^/]+\/)?[^@/]+)@[^/]+\/(.+)$/.exec(l.href) || /unpkg\.com\/((?:@[^/]+\/)?[^@/]+)@[^/]+\/(.+)$/.exec(l.href);
|
|
191
|
+
if (!m) return null;
|
|
192
|
+
const [, pkgName, file] = m;
|
|
193
|
+
if (pkgName === 'swiper') return 'swiper/css/bundle'; // swiper's package exports expose its CSS under css/
|
|
194
|
+
return `${pkgName}/${file}`;
|
|
195
|
+
}).filter(Boolean);
|
|
196
|
+
const importLines = libCss.map((p) => `@import "${p}";\n`).join('') + '@import "../styles/tokens.css";\n@import "../styles/site.css";\n';
|
|
197
|
+
// Preflight parity: Tailwind's reset gives media elements display:block, vertical-align:middle and
|
|
198
|
+
// (img/video) max-width:100% + height:auto. The HTML clone was verified without that reset, so restore the
|
|
199
|
+
// browser defaults where they differ; the site stylesheet (unlayered) still wins wherever it sets a value.
|
|
200
|
+
const parity = `
|
|
201
|
+
/* Preflight parity (scripts/next.mjs): render media like the verified HTML clone */
|
|
202
|
+
@layer base {
|
|
203
|
+
svg, video, canvas, audio, iframe, embed, object { display: revert; vertical-align: revert; }
|
|
204
|
+
video { max-width: revert; height: revert; }
|
|
205
|
+
}
|
|
206
|
+
`;
|
|
207
|
+
const globalsOut = globals.includes('@import "tw-animate-css";')
|
|
208
|
+
? globals.replace('@import "tw-animate-css";', () => '@import "tw-animate-css";\n' + importLines)
|
|
209
|
+
: importLines + globals;
|
|
210
|
+
await fs.writeFile(path.join(out, 'app', 'globals.css'), globalsOut + parity);
|
|
211
|
+
|
|
212
|
+
// App shell
|
|
213
|
+
await fs.writeFile(path.join(out, 'app', 'layout.tsx'), `// Generated by scripts/next.mjs
|
|
214
|
+
import type { Metadata } from 'next';
|
|
215
|
+
import './globals.css';
|
|
216
|
+
|
|
217
|
+
export const metadata: Metadata = {
|
|
218
|
+
title: ${JSON.stringify(title)},
|
|
219
|
+
robots: { index: false, follow: false },
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
223
|
+
return (
|
|
224
|
+
<html lang="en" suppressHydrationWarning>
|
|
225
|
+
<head>
|
|
226
|
+
{/* the clone hides reveal targets and shows its intro only when JS runs: flag it before first paint */}
|
|
227
|
+
<script dangerouslySetInnerHTML={{ __html: "document.documentElement.classList.add('js')" }} />
|
|
228
|
+
${fontLinks.map((l) => ` <link ${Object.entries(l).map(([k, v]) => (k === 'crossorigin' ? 'crossOrigin=""' : `${k}="${v}"`)).join(' ')} />`).join('\n')}
|
|
229
|
+
</head>
|
|
230
|
+
<body>{children}</body>
|
|
231
|
+
</html>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
`);
|
|
235
|
+
await fs.writeFile(path.join(out, 'app', 'page.tsx'), `// Generated by scripts/next.mjs: one component per section of the verified HTML clone.
|
|
236
|
+
import SiteChrome from '@/components/site/SiteChrome';
|
|
237
|
+
${components.map((c) => `import ${c} from '@/components/site/${c}';`).join('\n')}
|
|
238
|
+
import SiteMotion from '@/components/site/SiteMotion';
|
|
239
|
+
|
|
240
|
+
export default function Page() {
|
|
241
|
+
return (
|
|
242
|
+
<>
|
|
243
|
+
<SiteChrome />
|
|
244
|
+
<main>
|
|
245
|
+
${components.map((c) => ` <${c} />`).join('\n')}
|
|
246
|
+
</main>
|
|
247
|
+
<SiteMotion />
|
|
248
|
+
</>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
`);
|
|
252
|
+
|
|
253
|
+
// Assets, design system docs, config
|
|
254
|
+
await fs.rm(path.join(out, 'public', 'assets'), { recursive: true, force: true });
|
|
255
|
+
await fs.cp(path.join(cloneDir, 'assets'), path.join(out, 'public', 'assets'), { recursive: true });
|
|
256
|
+
for (const f of ['DESIGN.md', 'design-tokens.json']) await fs.copyFile(path.join(root, f), path.join(out, f)).catch(() => {});
|
|
257
|
+
await fs.writeFile(path.join(out, 'package.json'), JSON.stringify(PKG, null, 2) + '\n');
|
|
258
|
+
await fs.writeFile(path.join(out, 'postcss.config.mjs'), "export default { plugins: { '@tailwindcss/postcss': {} } };\n");
|
|
259
|
+
await fs.writeFile(path.join(out, 'next.config.mjs'), `import path from 'node:path';
|
|
260
|
+
import { fileURLToPath } from 'node:url';
|
|
261
|
+
|
|
262
|
+
const root = path.dirname(fileURLToPath(import.meta.url));
|
|
263
|
+
// Pin the project root: the lab folder above has its own lockfile.
|
|
264
|
+
export default { reactStrictMode: true, turbopack: { root }, outputFileTracingRoot: root };
|
|
265
|
+
`);
|
|
266
|
+
await fs.writeFile(path.join(out, 'tsconfig.json'), JSON.stringify({
|
|
267
|
+
compilerOptions: {
|
|
268
|
+
target: 'ES2017', lib: ['dom', 'dom.iterable', 'esnext'], allowJs: true, skipLibCheck: true, strict: false, noEmit: true,
|
|
269
|
+
esModuleInterop: true, module: 'esnext', moduleResolution: 'bundler', resolveJsonModule: true, isolatedModules: true,
|
|
270
|
+
jsx: 'react-jsx', incremental: true, plugins: [{ name: 'next' }], paths: { '@/*': ['./*'] },
|
|
271
|
+
},
|
|
272
|
+
include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '**/*.js', '.next/types/**/*.ts', '.next/dev/types/**/*.ts'],
|
|
273
|
+
exclude: ['node_modules'],
|
|
274
|
+
}, null, 2) + '\n');
|
|
275
|
+
await fs.writeFile(path.join(out, 'components.json'), JSON.stringify({
|
|
276
|
+
$schema: 'https://ui.shadcn.com/schema.json', style: 'new-york', rsc: true, tsx: true,
|
|
277
|
+
tailwind: { config: '', css: 'app/globals.css', baseColor: 'neutral', cssVariables: true, prefix: '' },
|
|
278
|
+
aliases: { components: '@/components', utils: '@/lib/utils', ui: '@/components/ui', lib: '@/lib', hooks: '@/hooks' },
|
|
279
|
+
iconLibrary: 'lucide',
|
|
280
|
+
}, null, 2) + '\n');
|
|
281
|
+
await fs.writeFile(path.join(out, 'lib', 'utils.ts'), "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n");
|
|
282
|
+
await fs.writeFile(path.join(out, '.gitignore'), 'node_modules/\n.next/\nnext-env.d.ts\n*.tsbuildinfo\n');
|
|
283
|
+
console.log(`generated ${path.relative(process.cwd(), out)}: ${components.length} section components + SiteChrome + SiteMotion`);
|
|
284
|
+
|
|
285
|
+
// ---------------- Install / build / verify ----------------
|
|
286
|
+
const run = (cmd, args, opts = {}) => new Promise((resolve, reject) => {
|
|
287
|
+
const p = spawn(cmd, args, { cwd: out, stdio: 'inherit', shell: process.platform === 'win32', ...opts });
|
|
288
|
+
p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(' ')} exited ${code}`))));
|
|
289
|
+
});
|
|
290
|
+
if (flags.install || flags.build || flags.verify) await run('npm', ['install', '--no-audit', '--no-fund']);
|
|
291
|
+
if (flags.build || flags.verify) await run('npx', ['next', 'build']);
|
|
292
|
+
// A server left running on the port would keep serving the OLD build (or a half-deleted one after a rebuild)
|
|
293
|
+
// and the scores would describe the wrong thing. Always verify on a port that is actually free.
|
|
294
|
+
const freePort = async (start) => {
|
|
295
|
+
for (let p = start; p < start + 50; p++) {
|
|
296
|
+
const ok = await new Promise((resolve) => { const t = net.createServer().once('error', () => resolve(false)).once('listening', () => t.close(() => resolve(true))).listen(p, '127.0.0.1'); });
|
|
297
|
+
if (ok) return p;
|
|
298
|
+
}
|
|
299
|
+
throw new Error('no free port');
|
|
300
|
+
};
|
|
301
|
+
if (flags.verify) {
|
|
302
|
+
const verifyPort = await freePort(port);
|
|
303
|
+
if (verifyPort !== port) console.warn(`port ${port} is busy (another server?) - verifying on ${verifyPort}`);
|
|
304
|
+
const server = spawn('npx', ['next', 'start', '-p', String(verifyPort)], { cwd: out, shell: process.platform === 'win32', stdio: 'ignore' });
|
|
305
|
+
const url = `http://127.0.0.1:${verifyPort}/`;
|
|
306
|
+
for (let i = 0; i < 60; i++) { try { if ((await fetch(url)).ok) break; } catch { /* starting */ } await new Promise((r) => setTimeout(r, 1000)); }
|
|
307
|
+
const lab = path.resolve('.');
|
|
308
|
+
const score = (args) => run('node', args, { cwd: lab });
|
|
309
|
+
try {
|
|
310
|
+
// same scoring entry point as the HTML clone: time limit per step, summary at the end
|
|
311
|
+
await score([path.join(path.dirname(toPath(import.meta.url)), 'score.mjs'), name, '--url', url, ...(flags.quick ? ['--quick'] : [])]);
|
|
312
|
+
} finally {
|
|
313
|
+
if (process.platform === 'win32') spawn('taskkill', ['/pid', String(server.pid), '/T', '/F']);
|
|
314
|
+
else server.kill();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// REPORT.md for a clone: every score against its target, the weakest spots, and what was not (or cannot be)
|
|
2
|
+
// copied. Reads the latest score files under sites/<name>/compare and the capture under sites/<name>/reference;
|
|
3
|
+
// `trueclone score` writes it at the end of every run.
|
|
4
|
+
//
|
|
5
|
+
// usage: node scripts/report.mjs <name>
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { parseArgs } from './lib.mjs';
|
|
9
|
+
|
|
10
|
+
const { pos } = parseArgs(process.argv.slice(2));
|
|
11
|
+
const [name] = pos;
|
|
12
|
+
if (!name) {
|
|
13
|
+
console.error('usage: trueclone report <name>');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
const root = path.resolve('sites', name);
|
|
17
|
+
const read = (rel) => fs.readFile(path.join(root, rel), 'utf8').then(JSON.parse, () => null);
|
|
18
|
+
const meta = await read('reference/meta.json');
|
|
19
|
+
if (!meta) {
|
|
20
|
+
console.error(`no capture for ${name}: run trueclone capture <url> ${name} first`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Targets a clone has to meet (the same ones the skill gives the agent).
|
|
25
|
+
const T = { layout: 98, statesGap: 5, motion: 95, detail: 95, hovers: 90 };
|
|
26
|
+
const mark = (ok) => (ok === null ? '–' : ok ? 'pass' : '**below target**');
|
|
27
|
+
const pct = (v) => (v === undefined || v === null ? '–' : `${v}%`);
|
|
28
|
+
const VPS = ['desktop', 'tablet', 'mobile'];
|
|
29
|
+
|
|
30
|
+
const layout = await read('compare/report.mask.json');
|
|
31
|
+
const visual = await read('compare/report.json');
|
|
32
|
+
const states = await read('compare/states/report.mask.json');
|
|
33
|
+
const curves = Object.fromEntries(await Promise.all(VPS.map(async (v) => [v, await read(`compare/curves.${v}.json`)])));
|
|
34
|
+
const interact = await read('compare/interact/report.json');
|
|
35
|
+
const hovers = await read('compare/hovers.desktop.json');
|
|
36
|
+
const detail = await read('compare/detail.json');
|
|
37
|
+
const motionInv = await read('reference/motion.desktop.json');
|
|
38
|
+
|
|
39
|
+
const byVp = (rep, key = 'match') => Object.fromEntries((rep?.viewports || []).map((v) => [v.viewport, v[key] ?? v.score]));
|
|
40
|
+
const L = byVp(layout), V = byVp(visual), S = byVp(states), D = byVp(detail, 'score');
|
|
41
|
+
const C = Object.fromEntries(VPS.map((v) => [v, curves[v]?.score]));
|
|
42
|
+
|
|
43
|
+
const rows = [];
|
|
44
|
+
const row = (check, values, ok, note = '') => rows.push(`| ${check} | ${VPS.map((v) => pct(values[v])).join(' | ')} | ${mark(ok)} | ${note} |`);
|
|
45
|
+
const present = (vals) => VPS.filter((v) => vals[v] !== undefined && vals[v] !== null);
|
|
46
|
+
const all = (vals, test) => (present(vals).length ? present(vals).every((v) => test(vals[v], v)) : null);
|
|
47
|
+
row(`Layout (structure)`, L, all(L, (x) => x >= T.layout), `target ≥ ${T.layout}%; text and media masked`);
|
|
48
|
+
if (meta.mode === 'exact') row('Layout (real pixels)', V, all(V, (x) => x >= T.layout), 'exact mode: same text and media');
|
|
49
|
+
row('Scroll states', S, all(S, (x, v) => L[v] === undefined || x >= L[v] - T.statesGap), `within ${T.statesGap} points of layout`);
|
|
50
|
+
row('Motion curves', C, all(C, (x) => x >= T.motion), present(C).length ? `target ≥ ${T.motion}%` : 'no motion map (no scroll or time animation to score)');
|
|
51
|
+
row('Detail (styles)', D, all(D, (x) => x >= T.detail), `target ≥ ${T.detail}%; font, size, weight, line height, colour, boxes`);
|
|
52
|
+
row('Hovers', { desktop: hovers?.score }, hovers ? hovers.score >= T.hovers : null, `target ≥ ${T.hovers}% of the reference hover effects`);
|
|
53
|
+
const scenarios = interact?.scenarios || [];
|
|
54
|
+
const failed = scenarios.filter((s) => s.expect && !s.expect.pass);
|
|
55
|
+
row('Interactions', { desktop: scenarios.length ? +(scenarios.reduce((n, s) => n + (s.score || 0), 0) / scenarios.length).toFixed(2) : undefined }, scenarios.length ? failed.length === 0 : null,
|
|
56
|
+
scenarios.length ? `${scenarios.length - failed.length}/${scenarios.length} scenarios change something on both sides` : 'no interactions.json');
|
|
57
|
+
|
|
58
|
+
const failing = rows.filter((r) => r.includes('below target')).length;
|
|
59
|
+
const verdict = failing ? `${failing} check(s) below target` : 'every scored check meets its target';
|
|
60
|
+
|
|
61
|
+
// Weakest spots, to fix first.
|
|
62
|
+
const weak = [];
|
|
63
|
+
for (const v of VPS) for (const s of (layout?.viewports.find((x) => x.viewport === v)?.worstSections || []).slice(0, 2)) if (s.match < 99) weak.push(`- Layout ${v}: section "${String(s.label).slice(0, 50)}" at y ${s.y} is ${s.match}%`);
|
|
64
|
+
for (const v of VPS) for (const e of (curves[v]?.entries || []).filter((x) => x.score < 90).slice(0, 3)) weak.push(`- Motion ${v}: "${e.name}" ${e.score}%${e.note ? ` (${e.note})` : ''}`);
|
|
65
|
+
for (const s of failed) weak.push(`- Interaction "${s.name}": the expected change happened on ${s.expect.ref ? 'the reference' : 'neither side'}${s.expect.clone ? '' : ', not on the clone'}`);
|
|
66
|
+
for (const v of detail?.viewports || []) {
|
|
67
|
+
const groups = new Map();
|
|
68
|
+
for (const it of v.issues || []) { const k = it.props[0].split(' ').slice(0, 1)[0]; groups.set(k, (groups.get(k) || 0) + 1); }
|
|
69
|
+
const top = [...groups].sort((a, b) => b[1] - a[1]).slice(0, 3);
|
|
70
|
+
if (top.length) weak.push(`- Detail ${v.viewport}: ${top.map(([k, n]) => `${n}× ${k === 'no' ? 'missing element' : k}`).join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// What the clone does not copy: server-side behaviour, tracking, and the original's data/API calls.
|
|
74
|
+
let calls = [];
|
|
75
|
+
try {
|
|
76
|
+
const har = JSON.parse(await fs.readFile(path.join(root, 'reference', 'desktop.har'), 'utf8'));
|
|
77
|
+
calls = har.log.entries.filter((e) => ['xhr', 'fetch'].includes(e._resourceType)).map((e) => ({ method: e.request.method, url: e.request.url, status: e.response.status, type: e.response.content.mimeType || '' }));
|
|
78
|
+
} catch { /* no HAR */ }
|
|
79
|
+
const TRACKING = /google-analytics|googletagmanager|doubleclick|facebook\.(com|net)|hotjar|segment\.(io|com)|plausible|cloudflareinsights|clarity\.ms|sentry|datadog|mixpanel|amplitude|posthog|intercom|hubspot|linkedin\.com\/px|tiktok|challenges\.cloudflare|cdn-cgi\/rum|recaptcha|hcaptcha|turnstile/i;
|
|
80
|
+
const tracking = calls.filter((c) => TRACKING.test(c.url));
|
|
81
|
+
const data = calls.filter((c) => !TRACKING.test(c.url));
|
|
82
|
+
const host = (u) => { try { return new URL(u).host; } catch { return u; } };
|
|
83
|
+
const uniq = (list) => [...new Map(list.map((c) => [`${c.method} ${c.url.split('?')[0]}`, c])).values()];
|
|
84
|
+
|
|
85
|
+
const libs = motionInv ? Object.entries(motionInv.libs || {}).filter(([, v]) => v).map(([k]) => k) : [];
|
|
86
|
+
const md = `# trueclone report: ${name}
|
|
87
|
+
|
|
88
|
+
| | |
|
|
89
|
+
|---|---|
|
|
90
|
+
| Reference | ${meta.url} |
|
|
91
|
+
| Mode | ${meta.mode === 'exact' ? `exact (the site's own text and media). Permission: ${meta.permission}` : 'template (structure, motion and behaviour; placeholder text and media)'} |
|
|
92
|
+
| Captured | ${meta.capturedAt} |
|
|
93
|
+
| Scored | ${layout?.at || '–'}${layout?.clone && !/127\.0\.0\.1:\d+\/$/.test(layout.clone) ? ` against ${layout.clone}` : ''} |
|
|
94
|
+
| Verdict | **${verdict}** |
|
|
95
|
+
|
|
96
|
+
## Scores
|
|
97
|
+
|
|
98
|
+
| Check | Desktop | Tablet | Mobile | Status | Notes |
|
|
99
|
+
|---|---|---|---|---|---|
|
|
100
|
+
${rows.join('\n')}
|
|
101
|
+
|
|
102
|
+
${weak.length ? `## Fix first\n\n${weak.join('\n')}\n` : '## Fix first\n\nNothing below 99% on layout, 90% on motion or failing.\n'}
|
|
103
|
+
## Motion found on the reference
|
|
104
|
+
|
|
105
|
+
${motionInv ? `Libraries: ${libs.length ? libs.join(', ') : 'none (no GSAP, Lenis, Swiper or similar)'}. ScrollTriggers: ${motionInv.triggers?.length ?? 0}. Tweens created while scrolling: ${motionInv.onScroll?.length ?? 0}. CSS animations: ${(motionInv.css || []).length}.` : 'Not inventoried yet (trueclone motion <name>).'}
|
|
106
|
+
|
|
107
|
+
## Not copied
|
|
108
|
+
|
|
109
|
+
- **Server-side behaviour** is never visible from the outside: databases, accounts, payments, search indexes. The clone reproduces the client.
|
|
110
|
+
- **Tracking, analytics and bot protection** are left out on purpose${tracking.length ? `: ${[...new Set(tracking.map((c) => { try { const u = new URL(c.url); return `${u.host}${u.pathname.split('/').slice(0, 3).join('/')}`; } catch { return host(c.url); } }))].join(', ')}` : ''}.
|
|
111
|
+
${data.length ? `- **Data and API calls** the reference makes (${uniq(data).length}), which the clone does not call; their captured responses are in reference/desktop.har:\n${uniq(data).slice(0, 15).map((c) => ` - \`${c.method} ${c.url.split('?')[0].slice(0, 110)}\` → ${c.status} ${c.type.split(';')[0]}`).join('\n')}` : '- **Data and API calls**: none seen during the capture (static content).'}
|
|
112
|
+
`;
|
|
113
|
+
await fs.writeFile(path.join(root, 'REPORT.md'), md);
|
|
114
|
+
console.log(`${verdict} -> ${path.relative(process.cwd(), path.join(root, 'REPORT.md'))}`);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// CSS rules for one element, like the DevTools Styles pane: every rule that matches the element and its
|
|
2
|
+
// ::before / ::after, in source order, with the @media / @supports / @layer it sits in and where it came from.
|
|
3
|
+
// Build from the original's real CSS instead of approximating what the computed values look like
|
|
4
|
+
// (e.g. a "capsize" ::before/::after text trim is not the same as text-box-trim inside flex and grid).
|
|
5
|
+
// Cross-origin stylesheets (CDNs) are fetched outside the page and parsed, so they are included too.
|
|
6
|
+
//
|
|
7
|
+
// usage: node scripts/rules.mjs <name> "<selector>[@n]" [--viewport desktop] [--clone] [--url <page>]
|
|
8
|
+
// node scripts/rules.mjs <name> --text "Car number" (the element whose own text starts with this)
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { VIEWPORTS, parseArgs, openBrowser, newPage, settle, serve, waitStill } from './lib.mjs';
|
|
12
|
+
|
|
13
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
14
|
+
const [name, target] = pos;
|
|
15
|
+
if (!name || (!target && typeof flags.text !== 'string')) {
|
|
16
|
+
console.error('usage: npm run rules -- <name> "<selector>[@n]" [--viewport desktop] [--clone] | <name> --text "label"');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
const root = path.resolve('sites', name);
|
|
20
|
+
const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
|
|
21
|
+
const vp = VIEWPORTS.find((v) => v.name === (flags.viewport || 'desktop'));
|
|
22
|
+
const server = flags.clone && typeof flags.url !== 'string' ? await serve(path.join(root, 'clone')) : null;
|
|
23
|
+
const url = typeof flags.url === 'string' ? flags.url : server ? server.url : meta.url;
|
|
24
|
+
|
|
25
|
+
const browser = await openBrowser(flags.cdp);
|
|
26
|
+
try {
|
|
27
|
+
const { page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
|
|
28
|
+
await page.goto(url, { waitUntil: 'load', timeout: 60000 });
|
|
29
|
+
await waitStill(page, { timeout: 15000, interval: 200 });
|
|
30
|
+
await settle(page);
|
|
31
|
+
|
|
32
|
+
// Sheets the page cannot read (cross-origin without CORS): fetch them here, parse them in the page.
|
|
33
|
+
const blocked = await page.evaluate(() => [...document.styleSheets].map((s, i) => {
|
|
34
|
+
try { void s.cssRules; return null; } catch { return { i, href: s.href, media: s.media.mediaText }; }
|
|
35
|
+
}).filter(Boolean));
|
|
36
|
+
for (const b of blocked) {
|
|
37
|
+
const text = await page.context().request.get(b.href).then((r) => r.text()).catch(() => '');
|
|
38
|
+
await page.evaluate(([b, text]) => {
|
|
39
|
+
const sheet = new CSSStyleSheet({ media: b.media || undefined });
|
|
40
|
+
sheet.replaceSync(text.replace(/@import[^;]+;/g, ''));
|
|
41
|
+
(window.__labSheets ||= []).push({ i: b.i, href: b.href, sheet });
|
|
42
|
+
}, [b, text]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const result = await page.evaluate(([target, text]) => {
|
|
46
|
+
let el;
|
|
47
|
+
if (text) {
|
|
48
|
+
el = [...document.body.querySelectorAll('*')].find((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim().startsWith(text)));
|
|
49
|
+
} else {
|
|
50
|
+
const [sel, idx] = target.split('@');
|
|
51
|
+
el = document.querySelectorAll(sel)[Number(idx || 0)];
|
|
52
|
+
}
|
|
53
|
+
if (!el) return null;
|
|
54
|
+
// split a selector list on top-level commas only (":is(a, b)" stays whole)
|
|
55
|
+
const split = (s) => { const out = []; let depth = 0, cur = ''; for (const ch of s) { if (ch === '(' || ch === '[') depth++; if (ch === ')' || ch === ']') depth--; if (ch === ',' && !depth) { out.push(cur.trim()); cur = ''; } else cur += ch; } if (cur.trim()) out.push(cur.trim()); return out; };
|
|
56
|
+
const PSEUDO = /::?(before|after)\b/;
|
|
57
|
+
const found = { element: [], before: [], after: [] };
|
|
58
|
+
const sheets = [...document.styleSheets].map((s, i) => {
|
|
59
|
+
if (s.ownerNode?.hasAttribute?.('data-lab-freeze')) return { rules: [], source: 'lab' }; // the lab's own freeze styles
|
|
60
|
+
const own = (() => { try { return s.cssRules; } catch { return null; } })();
|
|
61
|
+
const parsed = (window.__labSheets || []).find((x) => x.i === i);
|
|
62
|
+
return { rules: own || parsed?.sheet.cssRules || [], source: s.href ? s.href.split('/').pop().split('?')[0] : `inline <style> #${i}` };
|
|
63
|
+
});
|
|
64
|
+
const walk = (rules, source, ctx) => {
|
|
65
|
+
for (const r of rules) {
|
|
66
|
+
if (r instanceof CSSMediaRule) { if (matchMedia(r.conditionText).matches) walk(r.cssRules, source, [...ctx, `@media ${r.conditionText}`]); continue; }
|
|
67
|
+
if (r instanceof CSSSupportsRule) { if (CSS.supports(r.conditionText)) walk(r.cssRules, source, [...ctx, `@supports ${r.conditionText}`]); continue; }
|
|
68
|
+
if (window.CSSLayerBlockRule && r instanceof CSSLayerBlockRule) { walk(r.cssRules, source, [...ctx, `@layer ${r.name}`]); continue; }
|
|
69
|
+
if (!(r instanceof CSSStyleRule)) continue;
|
|
70
|
+
for (const part of split(r.selectorText)) {
|
|
71
|
+
const m = part.match(PSEUDO);
|
|
72
|
+
if (!m && /::?(marker|placeholder|selection|first-line|first-letter|-webkit-|-moz-)/.test(part)) continue; // other pseudo-elements
|
|
73
|
+
const base = part.replace(/::?(before|after|marker|placeholder|selection|first-line|first-letter)\b/g, '').trim() || '*';
|
|
74
|
+
let hit = false;
|
|
75
|
+
try { hit = el.matches(base); } catch { /* selector the engine does not support */ }
|
|
76
|
+
if (!hit) continue;
|
|
77
|
+
(found[m ? m[1] : 'element']).push({ selector: part, where: [source, ...ctx].join(' '), css: r.style.cssText });
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
sheets.forEach((s) => walk(s.rules, s.source, []));
|
|
83
|
+
const inline = el.getAttribute('style');
|
|
84
|
+
if (inline) found.element.push({ selector: 'style=""', where: 'inline attribute', css: inline });
|
|
85
|
+
const pick = (s) => ({ display: s.display, size: `${s.width} x ${s.height}`, margin: s.margin, padding: s.padding, font: `${s.fontWeight} ${s.fontSize}/${s.lineHeight} ${s.fontFamily.split(',')[0]}`, color: s.color, background: s.backgroundColor, transform: s.transform, content: s.content });
|
|
86
|
+
const r = el.getBoundingClientRect();
|
|
87
|
+
return {
|
|
88
|
+
el: `${el.tagName.toLowerCase()}.${String(el.getAttribute('class') || '').split(/\s+/).join('.')}`,
|
|
89
|
+
box: `${Math.round(r.width)} x ${Math.round(r.height)} at page y ${Math.round(r.top + scrollY)}`,
|
|
90
|
+
computed: { element: pick(getComputedStyle(el)), before: pick(getComputedStyle(el, '::before')), after: pick(getComputedStyle(el, '::after')) },
|
|
91
|
+
found,
|
|
92
|
+
};
|
|
93
|
+
}, [target || null, typeof flags.text === 'string' ? flags.text : null]);
|
|
94
|
+
|
|
95
|
+
if (!result) { console.error(`nothing matched ${target || flags.text} on ${url}`); process.exitCode = 1; }
|
|
96
|
+
else {
|
|
97
|
+
console.log(`${result.el} (${result.box}) on ${flags.clone ? 'the clone' : url}, ${vp.name}`);
|
|
98
|
+
for (const part of ['element', 'before', 'after']) {
|
|
99
|
+
const rules = result.found[part];
|
|
100
|
+
const c = result.computed[part];
|
|
101
|
+
if (part !== 'element' && !rules.length && c.content === 'none') continue;
|
|
102
|
+
console.log(`\n${part === 'element' ? 'element' : `::${part}`} computed: ${Object.entries(c).filter(([k, v]) => v && v !== 'none' && v !== 'normal' && !(k === 'content' && part === 'element')).map(([k, v]) => `${k} ${v}`).join(' | ')}`);
|
|
103
|
+
for (const r of rules) console.log(` ${r.selector} { ${r.css} }\n ${r.where}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
if (!flags.cdp) await browser.close();
|
|
108
|
+
server?.close();
|
|
109
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// One command for all scores, with a time limit per step and a summary at the end.
|
|
2
|
+
// --quick layout (all viewports) + live states (desktop) + interactions: the fast loop while building
|
|
3
|
+
// (full) layout + states (all) + motion curves (desktop/tablet/mobile) + interactions + hovers + detail
|
|
4
|
+
// --url <clone url> score another build (e.g. the Next.js app); --refresh-ref re-measures the reference
|
|
5
|
+
// A step that runs past its limit is killed and reported as TIMEOUT; the rest still run.
|
|
6
|
+
// Steps run in three parallel groups (layout+states, the three curve viewports, interact+hovers+detail): each
|
|
7
|
+
// tool mostly waits on smooth scroll and settling, so running them side by side costs little. --serial runs
|
|
8
|
+
// one at a time (for debugging a flaky score).
|
|
9
|
+
//
|
|
10
|
+
// usage: node scripts/score.mjs <name> [--quick] [--url <clone url>] [--refresh-ref] [--step-minutes 20] [--serial]
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { parseArgs } from './lib.mjs';
|
|
15
|
+
|
|
16
|
+
// steps are the sibling scripts of this file, wherever the package is installed; sites/ is in the current folder
|
|
17
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
20
|
+
const [name] = pos;
|
|
21
|
+
if (!name) {
|
|
22
|
+
console.error('usage: npm run score -- <name> [--quick] [--url <clone url>] [--refresh-ref] [--serial]');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const url = typeof flags.url === 'string' ? flags.url : null;
|
|
26
|
+
const refresh = flags['refresh-ref'] ? ['--refresh-ref'] : [];
|
|
27
|
+
const limitMs = Number(flags['step-minutes'] || 20) * 60 * 1000;
|
|
28
|
+
const withUrl = (args) => (url ? [...args, '--url', url] : args);
|
|
29
|
+
|
|
30
|
+
const steps = [
|
|
31
|
+
{ group: 1, label: 'layout', args: withUrl(['scripts/diff.mjs', name, '--structure']), pick: /│ \d+\s+│ '(\w+)'\s+│ ([\d.]+)/g, unit: 'layout %' },
|
|
32
|
+
{ group: 1, label: 'states', args: ['scripts/states.mjs', name, '--clone', ...(url ? [url] : []), '--structure', ...(flags.quick ? ['--viewports', 'desktop'] : [])], pick: /│ \d+\s+│ '(\w+)'\s+│ ([\d.]+)/g, unit: 'states %' },
|
|
33
|
+
...(flags.quick ? [] : ['desktop', 'tablet', 'mobile'].map((v) => ({ group: 2, label: `curves ${v}`, args: withUrl(['scripts/curves.mjs', name, '--viewport', v, ...refresh]), pick: /motion score \((\w+)\): ([\d.]+)%/g, unit: 'motion %' }))),
|
|
34
|
+
{ group: 3, label: 'interact', args: withUrl(['scripts/interact.mjs', name, ...refresh]), pick: /│ \d+\s+│ '([^']+)'\s+│ ([\d.]+)/g, unit: 'interaction %', fail: /FAIL/g },
|
|
35
|
+
...(flags.quick ? [] : [
|
|
36
|
+
{ group: 3, label: 'hovers', args: withUrl(['scripts/hovers.mjs', name, ...refresh]), pick: /hover match \((\w+)\): ([\d.]+)%/g, unit: 'hover %' },
|
|
37
|
+
{ group: 3, label: 'detail', args: withUrl(['scripts/detail.mjs', name, '--viewport', 'all', ...refresh]), pick: /(\w+): detail match ([\d.]+)%/g, unit: 'detail %' },
|
|
38
|
+
]),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const runStep = (step) => new Promise((resolve) => {
|
|
42
|
+
const started = Date.now();
|
|
43
|
+
const [file, ...args] = step.args;
|
|
44
|
+
const child = spawn(process.execPath, [path.join(HERE, path.basename(file)), ...args], { cwd: process.cwd() });
|
|
45
|
+
let out = '';
|
|
46
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
47
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
out += '\nTIMEOUT';
|
|
50
|
+
if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F']);
|
|
51
|
+
else child.kill('SIGKILL');
|
|
52
|
+
}, limitMs);
|
|
53
|
+
child.on('close', (code) => { clearTimeout(timer); resolve({ code, out, ms: Date.now() - started }); });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const summary = [];
|
|
57
|
+
const t0 = Date.now();
|
|
58
|
+
const finish = (step, { code, out, ms }) => {
|
|
59
|
+
const values = [...out.matchAll(step.pick)].map((m) => `${m[1]} ${m[2]}`);
|
|
60
|
+
const fails = step.fail ? (out.match(step.fail) || []).length : 0;
|
|
61
|
+
const timedOut = out.includes('\nTIMEOUT');
|
|
62
|
+
const errors = (out.match(/PAGE ERROR[^\n]*/g) || []).slice(0, 2);
|
|
63
|
+
const warnings = (out.match(/WARNING[^\n]*/g) || []).slice(0, 2);
|
|
64
|
+
const status = timedOut ? 'TIMEOUT' : code !== 0 ? `EXIT ${code}` : fails ? `${fails} FAIL` : 'ok';
|
|
65
|
+
console.log(` ${step.label.padEnd(16)} ${status} in ${(ms / 1000).toFixed(0)}s ${values.join(' ')}`);
|
|
66
|
+
if (code !== 0 && !timedOut) console.log(out.split('\n').filter(Boolean).slice(-4).map((l) => ` ${l}`).join('\n'));
|
|
67
|
+
[...errors, ...warnings].forEach((l) => console.log(` ${l}`));
|
|
68
|
+
summary.push({ step: step.label, status, seconds: Math.round(ms / 1000), [step.unit]: values.join(' ') || '-' });
|
|
69
|
+
};
|
|
70
|
+
const groups = flags.serial
|
|
71
|
+
? steps.map((st) => [st])
|
|
72
|
+
: [...new Set(steps.map((st) => st.group))].map((g) => steps.filter((st) => st.group === g));
|
|
73
|
+
for (const group of groups) {
|
|
74
|
+
console.log(`running: ${group.map((st) => st.label).join(', ')}`);
|
|
75
|
+
await Promise.all(group.map(async (step) => finish(step, await runStep(step))));
|
|
76
|
+
}
|
|
77
|
+
summary.sort((x, y) => steps.findIndex((st) => st.label === x.step) - steps.findIndex((st) => st.label === y.step));
|
|
78
|
+
console.log(`\n${flags.quick ? 'quick' : 'full'} score for ${name}${url ? ` (${url})` : ''} in ${((Date.now() - t0) / 60000).toFixed(1)} min`);
|
|
79
|
+
console.table(summary.map((s) => ({ step: s.step, status: s.status, 's': s.seconds, result: Object.entries(s).find(([k]) => k.endsWith('%'))?.[1] })));
|
|
80
|
+
// REPORT.md: every score against its target, what to fix first, and what was not copied
|
|
81
|
+
const report = await runStep({ args: ['report.mjs', name] });
|
|
82
|
+
console.log(report.out.trim());
|