@trueclone/cli 0.0.0-stage → 0.2.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.
@@ -0,0 +1,532 @@
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', nomodule: 'noModule', formnovalidate: 'formNoValidate', itemscope: 'itemScope', 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
+ cellpadding: 'cellPadding', cellspacing: 'cellSpacing', charset: 'charSet', accesskey: 'accessKey', usemap: 'useMap',
56
+ formaction: 'formAction', marginwidth: 'marginWidth', marginheight: 'marginHeight', hreflang: 'hrefLang', srclang: 'srcLang',
57
+ srcdoc: 'srcDoc', referrerpolicy: 'referrerPolicy', fetchpriority: 'fetchPriority', enterkeyhint: 'enterKeyHint',
58
+ itemprop: 'itemProp', itemscope: 'itemScope', itemtype: 'itemType', 'accept-charset': 'acceptCharset', 'http-equiv': 'httpEquiv',
59
+ };
60
+ // Legacy presentational attributes (email-style tables) that React has no typed prop for: passed through a spread,
61
+ // which React renders as-is and TypeScript doesn't reject.
62
+ const LEGACY = new Set(['valign', 'bgcolor', 'background', 'nowrap', 'hspace', 'vspace', 'clear', 'char', 'charoff', 'compact', 'frame', 'rules', 'axis']);
63
+ const BOOL = new Set(['muted', 'loop', 'playsinline', 'autoplay', 'controls', 'disabled', 'checked', 'required', 'hidden', 'novalidate', 'readonly', 'multiple', 'selected', 'allowfullscreen', 'open', 'default', 'reversed', 'async', 'defer', 'nomodule', 'formnovalidate', 'inert', 'itemscope']);
64
+ const camel = (s) => s.replace(/[-:]([a-z])/g, (m, c) => c.toUpperCase());
65
+ const styleObject = (css) => {
66
+ const o = {};
67
+ for (const decl of css.split(';')) {
68
+ const i = decl.indexOf(':');
69
+ if (i < 0) continue;
70
+ const k = decl.slice(0, i).trim();
71
+ if (k) o[k.startsWith('--') ? k : camel(k)] = decl.slice(i + 1).trim();
72
+ }
73
+ return o;
74
+ };
75
+ const fixUrl = (v) => (v.startsWith('assets/') ? '/' + v : v);
76
+ const attrString = (n, v) => (/["\\]/.test(v) ? `${n}={${JSON.stringify(v)}}` : `${n}="${v}"`);
77
+
78
+ let usesStyle = false; // set while converting a component whose style objects need the CSSProperties type (custom properties like --i)
79
+ function jsxAttrs(node, inSvg) {
80
+ const parts = [];
81
+ for (const a of node.attrs || []) {
82
+ if (/^on[a-z]+$/.test(a.name)) continue; // inline handlers are strings; the motion script owns behaviour
83
+ // an input's value/checked are its starting state here; as plain props React would make the field read-only
84
+ if (node.tagName === 'input' && a.name === 'value') { parts.push(attrString('defaultValue', a.value)); continue; }
85
+ if (node.tagName === 'input' && a.name === 'checked') { parts.push('defaultChecked'); continue; }
86
+ if (LEGACY.has(a.name) && !inSvg) { parts.push(`{...{ ${JSON.stringify(a.name)}: ${JSON.stringify(a.value)} }}`); continue; }
87
+ if (a.name === 'style') { usesStyle = true; parts.push(`style={${JSON.stringify(styleObject(a.value))} as CSSProperties}`); continue; }
88
+ // SVG elements have no typed boolean props (hidden), so they go through the spread too
89
+ if (BOOL.has(a.name) && (a.value === '' || a.value === a.name)) { parts.push(inSvg ? `{...{ ${JSON.stringify(a.name)}: true }}` : RENAME[a.name] || a.name); continue; }
90
+ let n = RENAME[a.name] || a.name;
91
+ if (!RENAME[a.name] && inSvg && !n.startsWith('data-') && !n.startsWith('aria-')) n = camel(n);
92
+ if (n === 'xmlns:xlink') continue;
93
+ const v = ['src', 'href', 'poster', 'srcSet'].includes(n) ? fixUrl(a.value) : a.value;
94
+ // React types these as numbers: tabIndex="0" fails type-checking, tabIndex={0} is the same attribute
95
+ if (['tabIndex', 'colSpan', 'rowSpan', 'maxLength', 'minLength', 'span', 'size', 'cols', 'rows'].includes(n) && /^-?\d+$/.test(v)) { parts.push(`${n}={${v}}`); continue; }
96
+ parts.push(attrString(n, v));
97
+ }
98
+ return parts.length ? ' ' + parts.join(' ') : '';
99
+ }
100
+
101
+ // JSX trims and collapses whitespace that HTML renders, so text is written to keep exactly what the browser showed:
102
+ // verbatim where white-space is preserved (code samples), elsewhere with its edge spaces as {" "}
103
+ const BLOCK = new Set(['address', 'article', 'aside', 'blockquote', 'body', 'dd', 'details', 'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hr', 'html', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'summary', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul']);
104
+ const SPACE = '{" "}';
105
+ const keepsWhitespace = (node) => ['pre', 'textarea', 'listing', 'xmp'].includes(node.tagName)
106
+ || (node.attrs || []).some((a) => (a.name === 'class' && /(^|\s)whitespace-(pre|break-spaces)/.test(a.value)) || (a.name === 'style' && /white-space:\s*(pre|break-spaces)/.test(a.value)));
107
+ const textJsx = (s) => (/[{}<>&"']/.test(s) ? `{${JSON.stringify(s)}}` : s);
108
+
109
+ function toJsx(node, depth, inSvg = false, inPre = false) {
110
+ const pad = ' '.repeat(depth);
111
+ if (node.nodeName === '#comment') return '';
112
+ if (node.nodeName === '#text') {
113
+ if (inPre) return `{${JSON.stringify(node.value)}}`;
114
+ if (!node.value.trim()) {
115
+ // whitespace between two inline things renders as one space; next to a block, or at an edge, it renders nothing
116
+ const sibs = node.parentNode?.childNodes || [];
117
+ const i = sibs.indexOf(node);
118
+ const prev = sibs[i - 1], next = sibs[i + 1];
119
+ return prev && next && !BLOCK.has(prev.tagName) && !BLOCK.has(next.tagName) ? SPACE : '';
120
+ }
121
+ const text = node.value.replace(/\s+/g, ' ');
122
+ return `${/^\s/.test(text) ? SPACE : ''}${textJsx(text.trim())}${/\s$/.test(text) ? SPACE : ''}`;
123
+ }
124
+ if (['script', 'noscript', 'style'].includes(node.nodeName)) return '';
125
+ const tag = node.tagName;
126
+ const svg = inSvg || tag === 'svg';
127
+ const pre = inPre || keepsWhitespace(node);
128
+ const attrs = jsxAttrs(node, svg);
129
+ const kids = (node.childNodes || []).map((c) => toJsx(c, depth + 1, svg, pre)).filter((s) => s !== '');
130
+ if (VOID.has(tag) || !kids.length) return `${pad}<${tag}${attrs} />`;
131
+ const inline = kids.every((k) => !k.startsWith(' ')) && kids.join('').length < 120;
132
+ if (inline) return `${pad}<${tag}${attrs}>${kids.map((k) => k.trim() || ' ').join('')}</${tag}>`;
133
+ return `${pad}<${tag}${attrs}>\n${kids.map((k) => (k.startsWith(' ') && k.trim() ? k : pad + ' ' + k.trim())).join('\n')}\n${pad}</${tag}>`;
134
+ }
135
+
136
+ const pascal = (s) => s.split(/[^a-z0-9]+/i).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
137
+ const els = (n) => (n.childNodes || []).filter((c) => c.tagName);
138
+ const find = (n, tag) => els(n).find((c) => c.tagName === tag);
139
+
140
+ // ---------------- Generate ----------------
141
+ const html = await fs.readFile(path.join(cloneDir, 'index.html'), 'utf8');
142
+ const doc = parse(html);
143
+ const htmlEl = find(doc, 'html');
144
+ const head = find(htmlEl, 'head');
145
+ const body = find(htmlEl, 'body');
146
+ const title = (els(head).find((e) => e.tagName === 'title')?.childNodes[0]?.value || name).trim();
147
+ const fontLinks = els(head).filter((e) => e.tagName === 'link').map((e) => Object.fromEntries(e.attrs.map((a) => [a.name, a.value])))
148
+ .filter((l) => /fonts\.(googleapis|gstatic)\.com/.test(l.href || ''));
149
+
150
+ await fs.rm(path.join(out, 'app'), { recursive: true, force: true });
151
+ await fs.rm(path.join(out, 'components', 'site'), { recursive: true, force: true });
152
+ for (const d of ['app', 'components/site', 'lib', 'styles', 'public']) await fs.mkdir(path.join(out, d), { recursive: true });
153
+
154
+ // Two kinds of clone. A hand-built one (styles.css + one script.js using GSAP/Lenis/Swiper/three) is converted
155
+ // section by section below; a seeded one (trueclone seed: the original's own stylesheets, trueclone's players and
156
+ // a script.js) goes through seededApp(), further down.
157
+ const seeded = await fs.stat(path.join(cloneDir, 'css')).then(() => true, () => false);
158
+ let components = [];
159
+ if (seeded) components = await seededApp();
160
+ else {
161
+ // Sections: everything before <main> is "chrome" (nav, menu, intro, cursor); each child of <main> is a section.
162
+ const mainEl = find(body, 'main');
163
+ const chrome = els(body).filter((e) => e !== mainEl && e.tagName !== 'script');
164
+ const sections = mainEl ? els(mainEl) : [];
165
+ const used = new Set();
166
+ const writeComponent = async (compName, nodes, depth = 2) => {
167
+ usesStyle = false;
168
+ const jsx = nodes.map((n) => toJsx(n, depth)).filter(Boolean).join('\n');
169
+ const typeImport = usesStyle ? "import type { CSSProperties } from 'react';\n\n" : '';
170
+ 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`;
171
+ await fs.writeFile(path.join(out, 'components', 'site', `${compName}.tsx`), file);
172
+ };
173
+ await writeComponent('SiteChrome', chrome, 3);
174
+ for (const s of sections) {
175
+ const id = s.attrs.find((a) => a.name === 'id')?.value;
176
+ const cls = (s.attrs.find((a) => a.name === 'class')?.value || s.tagName).split(/\s+/)[0];
177
+ let compName = pascal(id || cls) || 'Section';
178
+ if (/^\d/.test(compName)) compName = 'S' + compName;
179
+ while (used.has(compName)) compName += 'X';
180
+ used.add(compName);
181
+ components.push(compName);
182
+ await writeComponent(compName, [s], 3);
183
+ }
184
+
185
+ // Motion: the clone's script, wrapped as initSite(); libraries come from npm instead of CDN script tags.
186
+ let script = await fs.readFile(path.join(cloneDir, 'script.js'), 'utf8');
187
+ const startIdx = script.indexOf('(() => {');
188
+ const endIdx = script.lastIndexOf('})();');
189
+ if (startIdx < 0 || endIdx < 0) throw new Error('clone/script.js must be a single (() => { ... })(); IIFE');
190
+ script = script.slice(0, startIdx) + 'export default function initSite() {' + script.slice(startIdx + '(() => {'.length, endIdx) + '}\n';
191
+ script = script.replace(/(['"`])assets\//g, (m, q) => `${q}/assets/`);
192
+ 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}`);
193
+ await fs.writeFile(path.join(out, 'components', 'site', 'SiteMotion.tsx'), `'use client';
194
+ // Generated by scripts/next.mjs: loads the motion libraries from npm (same versions the HTML clone uses),
195
+ // exposes them the way the clone's script expects, then runs it once after hydration.
196
+ import { useEffect } from 'react';
197
+
198
+ export default function SiteMotion() {
199
+ useEffect(() => {
200
+ const w = window as unknown as Record<string, unknown>;
201
+ if (w.__siteInit) return; // React strict mode runs effects twice in dev
202
+ w.__siteInit = true;
203
+ (async () => {
204
+ const [{ gsap }, { ScrollTrigger }, { SplitText }, lenis, swiper, three] = await Promise.all([
205
+ import('gsap'), import('gsap/ScrollTrigger'), import('gsap/SplitText'), import('lenis'), import('swiper/bundle'), import('three'),
206
+ ]);
207
+ Object.assign(w, { gsap, ScrollTrigger, SplitText, Lenis: lenis.default, Swiper: swiper.default, THREE: three });
208
+ const { default: initSite } = await import('@/lib/site-script.js');
209
+ initSite();
210
+ })();
211
+ }, []);
212
+ return null;
213
+ }
214
+ `);
215
+
216
+ // Styles: shadcn theme first (Tailwind + tokens as utilities), then the clone's own tokens and stylesheet.
217
+ const globals = await fs.readFile(path.join(root, 'globals.css'), 'utf8');
218
+ 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/`);
219
+ await fs.writeFile(path.join(out, 'styles', 'site.css'), siteCss);
220
+ await fs.copyFile(path.join(cloneDir, 'tokens.css'), path.join(out, 'styles', 'tokens.css'));
221
+ // Library stylesheets the HTML loads from an npm CDN become package imports, placed BEFORE the site
222
+ // stylesheet exactly like in the HTML <head>: same cascade order, same winners (e.g. .media-card vs .swiper-slide).
223
+ const libCss = els(head).filter((e) => e.tagName === 'link').map((e) => Object.fromEntries(e.attrs.map((a) => [a.name, a.value])))
224
+ .filter((l) => l.rel === 'stylesheet' && /cdn\.jsdelivr\.net\/npm\/|unpkg\.com\//.test(l.href || ''))
225
+ .map((l) => {
226
+ const m = /\/npm\/((?:@[^/]+\/)?[^@/]+)@[^/]+\/(.+)$/.exec(l.href) || /unpkg\.com\/((?:@[^/]+\/)?[^@/]+)@[^/]+\/(.+)$/.exec(l.href);
227
+ if (!m) return null;
228
+ const [, pkgName, file] = m;
229
+ if (pkgName === 'swiper') return 'swiper/css/bundle'; // swiper's package exports expose its CSS under css/
230
+ return `${pkgName}/${file}`;
231
+ }).filter(Boolean);
232
+ const importLines = libCss.map((p) => `@import "${p}";\n`).join('') + '@import "../styles/tokens.css";\n@import "../styles/site.css";\n';
233
+ // Preflight parity: Tailwind's reset gives media elements display:block, vertical-align:middle and
234
+ // (img/video) max-width:100% + height:auto. The HTML clone was verified without that reset, so restore the
235
+ // browser defaults where they differ; the site stylesheet (unlayered) still wins wherever it sets a value.
236
+ const parity = `
237
+ /* Preflight parity (scripts/next.mjs): render media like the verified HTML clone */
238
+ @layer base {
239
+ svg, video, canvas, audio, iframe, embed, object { display: revert; vertical-align: revert; }
240
+ video { max-width: revert; height: revert; }
241
+ }
242
+ `;
243
+ const globalsOut = globals.includes('@import "tw-animate-css";')
244
+ ? globals.replace('@import "tw-animate-css";', () => '@import "tw-animate-css";\n' + importLines)
245
+ : importLines + globals;
246
+ await fs.writeFile(path.join(out, 'app', 'globals.css'), globalsOut + parity);
247
+
248
+ // App shell
249
+ await fs.writeFile(path.join(out, 'app', 'layout.tsx'), `// Generated by scripts/next.mjs
250
+ import type { Metadata } from 'next';
251
+ import './globals.css';
252
+
253
+ export const metadata: Metadata = {
254
+ title: ${JSON.stringify(title)},
255
+ robots: { index: false, follow: false },
256
+ };
257
+
258
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
259
+ return (
260
+ <html lang="en" suppressHydrationWarning>
261
+ <head>
262
+ {/* the clone hides reveal targets and shows its intro only when JS runs: flag it before first paint */}
263
+ <script dangerouslySetInnerHTML={{ __html: "document.documentElement.classList.add('js')" }} />
264
+ ${fontLinks.map((l) => ` <link ${Object.entries(l).map(([k, v]) => (k === 'crossorigin' ? 'crossOrigin=""' : `${k}="${v}"`)).join(' ')} />`).join('\n')}
265
+ </head>
266
+ <body>{children}</body>
267
+ </html>
268
+ );
269
+ }
270
+ `);
271
+ await fs.writeFile(path.join(out, 'app', 'page.tsx'), `// Generated by scripts/next.mjs: one component per section of the verified HTML clone.
272
+ import SiteChrome from '@/components/site/SiteChrome';
273
+ ${components.map((c) => `import ${c} from '@/components/site/${c}';`).join('\n')}
274
+ import SiteMotion from '@/components/site/SiteMotion';
275
+
276
+ export default function Page() {
277
+ return (
278
+ <>
279
+ <SiteChrome />
280
+ <main>
281
+ ${components.map((c) => ` <${c} />`).join('\n')}
282
+ </main>
283
+ <SiteMotion />
284
+ </>
285
+ );
286
+ }
287
+ `);
288
+ }
289
+
290
+ // Assets, design system docs, config
291
+ await fs.rm(path.join(out, 'public', 'assets'), { recursive: true, force: true });
292
+ await fs.cp(path.join(cloneDir, 'assets'), path.join(out, 'public', 'assets'), { recursive: true });
293
+ for (const f of ['DESIGN.md', 'design-tokens.json']) await fs.copyFile(path.join(root, f), path.join(out, f)).catch(() => {});
294
+ await fs.writeFile(path.join(out, 'package.json'), JSON.stringify(PKG, null, 2) + '\n');
295
+ await fs.writeFile(path.join(out, 'postcss.config.mjs'), "export default { plugins: { '@tailwindcss/postcss': {} } };\n");
296
+ await fs.writeFile(path.join(out, 'next.config.mjs'), `import path from 'node:path';
297
+ import { fileURLToPath } from 'node:url';
298
+
299
+ const root = path.dirname(fileURLToPath(import.meta.url));
300
+ // Pin the project root: the lab folder above has its own lockfile.
301
+ export default { reactStrictMode: true, turbopack: { root }, outputFileTracingRoot: root };
302
+ `);
303
+ await fs.writeFile(path.join(out, 'tsconfig.json'), JSON.stringify({
304
+ compilerOptions: {
305
+ target: 'ES2017', lib: ['dom', 'dom.iterable', 'esnext'], allowJs: true, skipLibCheck: true, strict: false, noEmit: true,
306
+ esModuleInterop: true, module: 'esnext', moduleResolution: 'bundler', resolveJsonModule: true, isolatedModules: true,
307
+ jsx: 'react-jsx', incremental: true, plugins: [{ name: 'next' }], paths: { '@/*': ['./*'] },
308
+ },
309
+ include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '**/*.js', '.next/types/**/*.ts', '.next/dev/types/**/*.ts'],
310
+ exclude: ['node_modules'],
311
+ }, null, 2) + '\n');
312
+ await fs.writeFile(path.join(out, 'components.json'), JSON.stringify({
313
+ $schema: 'https://ui.shadcn.com/schema.json', style: 'new-york', rsc: true, tsx: true,
314
+ tailwind: { config: '', css: 'app/globals.css', baseColor: 'neutral', cssVariables: true, prefix: '' },
315
+ aliases: { components: '@/components', utils: '@/lib/utils', ui: '@/components/ui', lib: '@/lib', hooks: '@/hooks' },
316
+ iconLibrary: 'lucide',
317
+ }, null, 2) + '\n');
318
+ 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");
319
+ await fs.writeFile(path.join(out, '.gitignore'), 'node_modules/\n.next/\nnext-env.d.ts\n*.tsbuildinfo\n');
320
+ console.log(`generated ${path.relative(process.cwd(), out)} (${seeded ? 'seeded clone' : 'hand-built clone'}): ${components.length} components + ${seeded ? 'SiteScripts' : 'SiteChrome + SiteMotion'}`);
321
+
322
+ // ---------------- Seeded clones ----------------
323
+ // The page is the original's markup and stylesheets (trueclone seed), so the app keeps them as they are:
324
+ // - the stylesheets and inline <style> blocks of <head>, in their order, after the shadcn theme; Tailwind's own class
325
+ // scanning is limited to components/ui, because the original's compiled CSS already defines every class in the
326
+ // markup and a second build from a different theme would compete with it;
327
+ // - <html> and <body> attributes (font-variable classes, dark mode, color-scheme);
328
+ // - the largest body child is split at its first element with 3+ children: each of those becomes a component, the
329
+ // wrappers around them stay in the page with their classes; other body children are components of their own;
330
+ // - trueclone's players and script.js are served from public/ and run in order after hydration, with the data
331
+ // blocks they read (script type="application/json") rendered in the page.
332
+ async function seededApp() {
333
+ const attrsOf = (n) => Object.fromEntries((n.attrs || []).map((a) => [a.name, a.value]));
334
+ // styles
335
+ await fs.mkdir(path.join(out, 'styles', 'site'), { recursive: true });
336
+ const imports = [];
337
+ let inline = 0, usesTailwind = false;
338
+ for (const e of els(head)) {
339
+ const a = attrsOf(e);
340
+ let css = null, file = null;
341
+ if (e.tagName === 'link' && a.rel === 'stylesheet' && a.href && !/^(https?:)?\/\//.test(a.href)) {
342
+ css = await fs.readFile(path.join(cloneDir, a.href), 'utf8').catch(() => null);
343
+ file = path.basename(a.href);
344
+ } else if (e.tagName === 'style') {
345
+ css = (e.childNodes || []).map((c) => c.value || '').join('');
346
+ file = `inline-${++inline}.css`;
347
+ }
348
+ if (css === null) continue;
349
+ // stylesheets lived in clone/css/ (assets at ../assets/), inline blocks in the page (assets at assets/)
350
+ css = css.replace(/url\((['"]?)(?:\.\.\/)?assets\//g, (m, q) => `url(${q}/assets/`);
351
+ usesTailwind ||= /--tw-|tailwindcss v\d/.test(css);
352
+ await fs.writeFile(path.join(out, 'styles', 'site', file), css);
353
+ imports.push(`import '../styles/site/${file}';`);
354
+ }
355
+ // globals.css = the shadcn theme only; the original's stylesheets are imported by layout.tsx AFTER it, so they come
356
+ // later in the cascade and win every tie (same layer names, same variable names: the original is often Tailwind too)
357
+ const theme = await fs.readFile(path.join(root, 'globals.css'), 'utf8').catch(() => '@import "tailwindcss";\n');
358
+ // Tailwind's preflight resets what the browser draws by default (form controls, headings, lists); an original built
359
+ // without Tailwind relies on those defaults, so its clone gets Tailwind's theme and utilities but not the reset
360
+ const tailwind = usesTailwind ? '@import "tailwindcss" source(none);'
361
+ : '/* no preflight: the original was not built with Tailwind and relies on browser defaults */\n@layer theme, base, components, utilities;\n@import "tailwindcss/theme.css" layer(theme);\n@import "tailwindcss/utilities.css" layer(utilities) source(none);';
362
+ const scoped = theme.replace('@import "tailwindcss";', `${tailwind}\n@source "../components/ui";`);
363
+ await fs.writeFile(path.join(out, 'app', 'globals.css'), scoped);
364
+ const siteCssImports = imports;
365
+
366
+ // markup
367
+ const SKIP_BODY = new Set(['script', 'noscript', 'next-route-announcer', 'template']);
368
+ const kids = els(body).filter((e) => !SKIP_BODY.has(e.tagName));
369
+ const size = (n) => (n.childNodes || []).reduce((s, c) => s + 1 + size(c), 0);
370
+ const content = kids.reduce((x, y) => (size(y) > size(x) ? y : x), kids[0]);
371
+ let container = content;
372
+ while (els(container).length < 3) {
373
+ const inner = els(container);
374
+ if (!inner.length) break;
375
+ container = inner.reduce((x, y) => (size(y) > size(x) ? y : x));
376
+ }
377
+ const used = new Set();
378
+ const names = [];
379
+ // names: the element's id when it has one, otherwise its tag and position (utility classes such as "mx-auto" and
380
+ // placeholder headings say nothing about what a section is)
381
+ let counter = 0;
382
+ const nameFor = (node, fallback) => {
383
+ counter++;
384
+ const id = attrsOf(node).id;
385
+ let n = id ? pascal(id) : `${pascal(node.tagName) || fallback}${String(counter).padStart(2, '0')}`;
386
+ if (/^\d/.test(n)) n = 'S' + n;
387
+ n = n.slice(0, 40) || fallback;
388
+ let k = n, i = 2;
389
+ while (used.has(k)) k = `${n}${i++}`;
390
+ used.add(k);
391
+ return k;
392
+ };
393
+ const writeComp = async (compName, node) => {
394
+ usesStyle = false;
395
+ const jsx = toJsx(node, 3);
396
+ const typeImport = usesStyle ? "import type { CSSProperties } from 'react';\n\n" : '';
397
+ await fs.writeFile(path.join(out, 'components', 'site', `${compName}.tsx`), `// 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`);
398
+ names.push(compName);
399
+ };
400
+ // the page: wrappers kept inline down to the container, the container's children as components
401
+ let pageUsesStyle = false;
402
+ const wrap = async (node, depth) => {
403
+ const pad = ' '.repeat(depth);
404
+ if (node.nodeName === '#text' || node.nodeName === '#comment') return toJsx(node, depth);
405
+ usesStyle = false;
406
+ const attrs = jsxAttrs(node, false);
407
+ pageUsesStyle ||= usesStyle;
408
+ const children = [];
409
+ for (const c of node.childNodes || []) {
410
+ if (node === container && c.tagName) { const n = nameFor(c, 'Section'); await writeComp(n, c); children.push(`${pad} <${n} />`); }
411
+ else if (c.tagName && c !== container && !c.childNodes?.some(() => false) && !(contains(c, container))) { usesStyle = false; children.push(toJsx(c, depth + 1)); pageUsesStyle ||= usesStyle; }
412
+ else if (c.tagName) children.push(await wrap(c, depth + 1));
413
+ }
414
+ const body = children.filter((s) => s && s.trim()).join('\n');
415
+ return body ? `${pad}<${node.tagName}${attrs}>\n${body}\n${pad}</${node.tagName}>` : `${pad}<${node.tagName}${attrs} />`;
416
+ };
417
+ const contains = (a, b) => { for (let n = b; n; n = n.parentNode) if (n === a) return true; return false; };
418
+ const pageParts = [];
419
+ for (const k of kids) {
420
+ if (k === content) pageParts.push(await wrap(k, 3));
421
+ else { const n = nameFor(k, 'Part'); await writeComp(n, k); pageParts.push(` <${n} />`); }
422
+ }
423
+ // data blocks the players read, and the scripts themselves (copied to public/, run in order after hydration)
424
+ const scripts = els(body).filter((e) => e.tagName === 'script');
425
+ const dataBlocks = scripts.filter((s) => /json/.test(attrsOf(s).type || '') && attrsOf(s).id)
426
+ .map((s) => ` <script type="${attrsOf(s).type}" id="${attrsOf(s).id}" dangerouslySetInnerHTML={{ __html: ${JSON.stringify((s.childNodes || []).map((c) => c.value || '').join(''))} }} />`);
427
+ const srcs = scripts.map((s) => attrsOf(s).src).filter((s) => s && !/^(https?:)?\/\//.test(s));
428
+ for (const s of srcs) await fs.copyFile(path.join(cloneDir, s), path.join(out, 'public', path.basename(s)));
429
+ await fs.writeFile(path.join(out, 'components', 'site', 'SiteScripts.tsx'), `'use client';
430
+ // Generated by scripts/next.mjs: runs the clone's scripts (trueclone's reveal and widget players, script.js) in
431
+ // order, once, after hydration, exactly as the static clone does after parsing.
432
+ import { useEffect } from 'react';
433
+
434
+ const SCRIPTS = ${JSON.stringify(srcs.map((s) => '/' + path.basename(s)))};
435
+
436
+ export default function SiteScripts() {
437
+ useEffect(() => {
438
+ const w = window as unknown as Record<string, unknown>;
439
+ if (w.__siteScripts) return; // React strict mode runs effects twice in dev
440
+ w.__siteScripts = true;
441
+ (async () => {
442
+ for (const src of SCRIPTS) {
443
+ await new Promise((resolve) => {
444
+ const s = document.createElement('script');
445
+ s.src = src;
446
+ s.onload = s.onerror = () => resolve(null);
447
+ document.body.appendChild(s);
448
+ });
449
+ }
450
+ })();
451
+ }, []);
452
+ return null;
453
+ }
454
+ `);
455
+ // layout: the original's <html> and <body> attributes
456
+ usesStyle = false;
457
+ const htmlAttrs = jsxAttrs(htmlEl, false);
458
+ const bodyAttrs = jsxAttrs(body, false);
459
+ const layoutStyle = usesStyle;
460
+ await fs.writeFile(path.join(out, 'app', 'layout.tsx'), `// Generated by scripts/next.mjs
461
+ import type { Metadata } from 'next';
462
+ ${layoutStyle ? "import type { CSSProperties } from 'react';\n" : ''}import './globals.css';
463
+ // the original's stylesheets, in its <head> order, after the theme
464
+ ${siteCssImports.join('\n')}
465
+
466
+ export const metadata: Metadata = {
467
+ title: ${JSON.stringify(title)},
468
+ robots: { index: false, follow: false },
469
+ };
470
+
471
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
472
+ return (
473
+ <html${htmlAttrs} suppressHydrationWarning>
474
+ <body${bodyAttrs} suppressHydrationWarning>{children}</body>
475
+ </html>
476
+ );
477
+ }
478
+ `);
479
+ await fs.writeFile(path.join(out, 'app', 'page.tsx'), `// Generated by scripts/next.mjs from a seeded clone: the original's structure, one component per section.
480
+ ${pageUsesStyle ? "import type { CSSProperties } from 'react';\n" : ''}${names.map((c) => `import ${c} from '@/components/site/${c}';`).join('\n')}
481
+ import SiteScripts from '@/components/site/SiteScripts';
482
+
483
+ export default function Page() {
484
+ return (
485
+ <>
486
+ ${pageParts.join('\n')}
487
+ ${dataBlocks.join('\n')}
488
+ <SiteScripts />
489
+ </>
490
+ );
491
+ }
492
+ `);
493
+ return names;
494
+ }
495
+
496
+ // ---------------- Install / build / verify ----------------
497
+ // npm/npx are .cmd files on Windows and need a shell; Node warns (DEP0190) when a shell gets separate arguments, so
498
+ // build one quoted command line there instead
499
+ const spawnCmd = (cmd, args, opts = {}) => (process.platform === 'win32'
500
+ ? spawn([cmd, ...args].map((a) => (/[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(' '), { ...opts, shell: true })
501
+ : spawn(cmd, args, opts));
502
+ const run = (cmd, args, opts = {}) => new Promise((resolve, reject) => {
503
+ const p = spawnCmd(cmd, args, { cwd: out, stdio: 'inherit', ...opts });
504
+ p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(' ')} exited ${code}`))));
505
+ });
506
+ if (flags.install || flags.build || flags.verify) await run('npm', ['install', '--no-audit', '--no-fund']);
507
+ if (flags.build || flags.verify) await run('npx', ['next', 'build']);
508
+ // A server left running on the port would keep serving the OLD build (or a half-deleted one after a rebuild)
509
+ // and the scores would describe the wrong thing. Always verify on a port that is actually free.
510
+ const freePort = async (start) => {
511
+ for (let p = start; p < start + 50; p++) {
512
+ 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'); });
513
+ if (ok) return p;
514
+ }
515
+ throw new Error('no free port');
516
+ };
517
+ if (flags.verify) {
518
+ const verifyPort = await freePort(port);
519
+ if (verifyPort !== port) console.warn(`port ${port} is busy (another server?) - verifying on ${verifyPort}`);
520
+ const server = spawnCmd('npx', ['next', 'start', '-p', String(verifyPort)], { cwd: out, stdio: 'ignore' });
521
+ const url = `http://127.0.0.1:${verifyPort}/`;
522
+ for (let i = 0; i < 60; i++) { try { if ((await fetch(url)).ok) break; } catch { /* starting */ } await new Promise((r) => setTimeout(r, 1000)); }
523
+ const lab = path.resolve('.');
524
+ const score = (args) => run('node', args, { cwd: lab });
525
+ try {
526
+ // same scoring entry point as the HTML clone: time limit per step, summary at the end
527
+ await score([path.join(path.dirname(toPath(import.meta.url)), 'score.mjs'), name, '--url', url, ...(flags.quick ? ['--quick'] : [])]);
528
+ } finally {
529
+ if (process.platform === 'win32') spawn('taskkill', ['/pid', String(server.pid), '/T', '/F']);
530
+ else server.kill();
531
+ }
532
+ }