@trueclone/cli 0.1.0 → 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.
package/scripts/next.mjs CHANGED
@@ -48,12 +48,19 @@ const PKG = {
48
48
  // ---------------- HTML -> JSX ----------------
49
49
  const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
50
50
  const RENAME = {
51
- class: 'className', for: 'htmlFor', tabindex: 'tabIndex', readonly: 'readOnly', maxlength: 'maxLength', minlength: 'minLength',
51
+ class: 'className', for: 'htmlFor', tabindex: 'tabIndex', readonly: 'readOnly', nomodule: 'noModule', formnovalidate: 'formNoValidate', itemscope: 'itemScope', maxlength: 'maxLength', minlength: 'minLength',
52
52
  crossorigin: 'crossOrigin', autoplay: 'autoPlay', playsinline: 'playsInline', srcset: 'srcSet', autocomplete: 'autoComplete',
53
53
  enctype: 'encType', colspan: 'colSpan', rowspan: 'rowSpan', contenteditable: 'contentEditable', spellcheck: 'spellCheck',
54
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',
55
59
  };
56
- const BOOL = new Set(['muted', 'loop', 'playsinline', 'autoplay', 'controls', 'disabled', 'checked', 'required', 'hidden', 'novalidate', 'readonly', 'multiple', 'selected', 'allowfullscreen']);
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']);
57
64
  const camel = (s) => s.replace(/[-:]([a-z])/g, (m, c) => c.toUpperCase());
58
65
  const styleObject = (css) => {
59
66
  const o = {};
@@ -73,30 +80,53 @@ function jsxAttrs(node, inSvg) {
73
80
  const parts = [];
74
81
  for (const a of node.attrs || []) {
75
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; }
76
87
  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; }
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; }
78
90
  let n = RENAME[a.name] || a.name;
79
91
  if (!RENAME[a.name] && inSvg && !n.startsWith('data-') && !n.startsWith('aria-')) n = camel(n);
80
92
  if (n === 'xmlns:xlink') continue;
81
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; }
82
96
  parts.push(attrString(n, v));
83
97
  }
84
98
  return parts.length ? ' ' + parts.join(' ') : '';
85
99
  }
86
100
 
87
- function toJsx(node, depth, inSvg = false) {
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) {
88
110
  const pad = ' '.repeat(depth);
89
111
  if (node.nodeName === '#comment') return '';
90
112
  if (node.nodeName === '#text') {
91
- if (!node.value.trim()) return node.value.includes('\n') ? '' : ' ';
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
+ }
92
121
  const text = node.value.replace(/\s+/g, ' ');
93
- return /[{}<>&"']/.test(text) ? `{${JSON.stringify(text)}}` : text;
122
+ return `${/^\s/.test(text) ? SPACE : ''}${textJsx(text.trim())}${/\s$/.test(text) ? SPACE : ''}`;
94
123
  }
95
124
  if (['script', 'noscript', 'style'].includes(node.nodeName)) return '';
96
125
  const tag = node.tagName;
97
126
  const svg = inSvg || tag === 'svg';
127
+ const pre = inPre || keepsWhitespace(node);
98
128
  const attrs = jsxAttrs(node, svg);
99
- const kids = (node.childNodes || []).map((c) => toJsx(c, depth + 1, svg)).filter((s) => s !== '');
129
+ const kids = (node.childNodes || []).map((c) => toJsx(c, depth + 1, svg, pre)).filter((s) => s !== '');
100
130
  if (VOID.has(tag) || !kids.length) return `${pad}<${tag}${attrs} />`;
101
131
  const inline = kids.every((k) => !k.startsWith(' ')) && kids.join('').length < 120;
102
132
  if (inline) return `${pad}<${tag}${attrs}>${kids.map((k) => k.trim() || ' ').join('')}</${tag}>`;
@@ -121,12 +151,18 @@ await fs.rm(path.join(out, 'app'), { recursive: true, force: true });
121
151
  await fs.rm(path.join(out, 'components', 'site'), { recursive: true, force: true });
122
152
  for (const d of ['app', 'components/site', 'lib', 'styles', 'public']) await fs.mkdir(path.join(out, d), { recursive: true });
123
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 {
124
161
  // Sections: everything before <main> is "chrome" (nav, menu, intro, cursor); each child of <main> is a section.
125
162
  const mainEl = find(body, 'main');
126
163
  const chrome = els(body).filter((e) => e !== mainEl && e.tagName !== 'script');
127
164
  const sections = mainEl ? els(mainEl) : [];
128
165
  const used = new Set();
129
- const components = [];
130
166
  const writeComponent = async (compName, nodes, depth = 2) => {
131
167
  usesStyle = false;
132
168
  const jsx = nodes.map((n) => toJsx(n, depth)).filter(Boolean).join('\n');
@@ -249,6 +285,7 @@ ${components.map((c) => ` <${c} />`).join('\n')}
249
285
  );
250
286
  }
251
287
  `);
288
+ }
252
289
 
253
290
  // Assets, design system docs, config
254
291
  await fs.rm(path.join(out, 'public', 'assets'), { recursive: true, force: true });
@@ -280,11 +317,190 @@ await fs.writeFile(path.join(out, 'components.json'), JSON.stringify({
280
317
  }, null, 2) + '\n');
281
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");
282
319
  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`);
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
+ }
284
495
 
285
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));
286
502
  const run = (cmd, args, opts = {}) => new Promise((resolve, reject) => {
287
- const p = spawn(cmd, args, { cwd: out, stdio: 'inherit', shell: process.platform === 'win32', ...opts });
503
+ const p = spawnCmd(cmd, args, { cwd: out, stdio: 'inherit', ...opts });
288
504
  p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(' ')} exited ${code}`))));
289
505
  });
290
506
  if (flags.install || flags.build || flags.verify) await run('npm', ['install', '--no-audit', '--no-fund']);
@@ -301,7 +517,7 @@ const freePort = async (start) => {
301
517
  if (flags.verify) {
302
518
  const verifyPort = await freePort(port);
303
519
  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' });
520
+ const server = spawnCmd('npx', ['next', 'start', '-p', String(verifyPort)], { cwd: out, stdio: 'ignore' });
305
521
  const url = `http://127.0.0.1:${verifyPort}/`;
306
522
  for (let i = 0; i < 60; i++) { try { if ((await fetch(url)).ok) break; } catch { /* starting */ } await new Promise((r) => setTimeout(r, 1000)); }
307
523
  const lab = path.resolve('.');
@@ -0,0 +1,191 @@
1
+ // Record one-shot scroll reveals on the reference, frame by frame, so the clone can replay them exactly.
2
+ // A fresh page is scrolled down like a visitor would; every element that sat below the fold in a hidden or offset
3
+ // state (opacity ~0, a translate, a scale, a blur) is watched with requestAnimationFrame, and when it starts to
4
+ // change its opacity, translate, scale and blur are sampled every frame until they settle. The result is the
5
+ // reveal's real curve (delay, duration, easing, stagger included), whatever library drew it: Framer Motion, GSAP,
6
+ // AOS, IntersectionObserver plus CSS transitions. `trueclone seed` marks these elements in the clone and adds a
7
+ // small player that replays the curves with the Web Animations API when they scroll into view.
8
+ //
9
+ // Elements are identified by their path of element children, skipping script/style/meta-like tags, which is
10
+ // the same in the live page and in the script-free mirror the clone is seeded from.
11
+ //
12
+ // usage: node scripts/reveals.mjs <name> [--viewport desktop] -> sites/<name>/reference/reveals.<vp>.json
13
+ import fs from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { VIEWPORTS, parseArgs, openBrowser, newPage, waitStill, wheelTo } from './lib.mjs';
16
+
17
+ const { pos, flags } = parseArgs(process.argv.slice(2));
18
+ const [name] = pos;
19
+ if (!name) {
20
+ console.error('usage: trueclone reveals <name> [--viewport desktop]');
21
+ process.exit(1);
22
+ }
23
+ const root = path.resolve('sites', name);
24
+ const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
25
+ const vp = VIEWPORTS.find((v) => v.name === (flags.viewport || 'desktop'));
26
+
27
+ // Runs in the page before any scroll: picks the candidates and starts the frame sampler.
28
+ function watchInPage() {
29
+ const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
30
+ const pathOf = (el) => {
31
+ const parts = [];
32
+ for (let n = el; n && n !== document.body; n = n.parentElement) {
33
+ const sibs = [...n.parentElement.children].filter((c) => !SKIP.has(c.tagName));
34
+ parts.unshift(`${n.tagName.toLowerCase()}:${sibs.indexOf(n) + 1}`);
35
+ }
36
+ return parts.join('>');
37
+ };
38
+ const read = (el) => {
39
+ const s = getComputedStyle(el);
40
+ const m = new DOMMatrixReadOnly(s.transform === 'none' ? undefined : s.transform);
41
+ const blur = /blur\(([\d.]+)px\)/.exec(s.filter);
42
+ return { o: +(+s.opacity).toFixed(3), tx: +m.m41.toFixed(2), ty: +m.m42.toFixed(2), s: +Math.hypot(m.m11, m.m12).toFixed(4), blur: blur ? +blur[1] : 0 };
43
+ };
44
+ const hiddenish = (st) => st.o <= 0.05 || Math.hypot(st.tx, st.ty) >= 8 || Math.abs(st.s - 1) >= 0.04 || st.blur > 0;
45
+ const candidates = [];
46
+ for (const el of document.body.querySelectorAll('*')) {
47
+ if (SKIP.has(el.tagName) || el.closest('svg') && el.tagName !== 'svg') continue;
48
+ const r = el.getBoundingClientRect();
49
+ if (r.top < innerHeight || r.width < 8 || r.height < 8) continue;
50
+ const st = read(el);
51
+ if (!hiddenish(st)) continue;
52
+ // continuous motion (marquees, loops) is not a reveal: skip anything running an endless animation
53
+ const endless = (e) => e.getAnimations().some((a) => a.effect?.getTiming().iterations === Infinity);
54
+ if (endless(el) || (el.parentElement && endless(el.parentElement))) continue;
55
+ // keep the outermost element of a revealing group (a hidden card hides its children too)
56
+ if (candidates.some((c) => c.el.contains(el))) continue;
57
+ candidates.push({ el, path: pathOf(el), from: st, frames: null, start: null, topAtStart: null, last: JSON.stringify(st), still: 0, done: false });
58
+ }
59
+ window.__tcReveals = candidates;
60
+ const tick = (now) => {
61
+ for (const c of candidates) {
62
+ if (c.done) continue;
63
+ const st = read(c.el);
64
+ const key = JSON.stringify(st);
65
+ if (c.frames === null) {
66
+ if (key !== c.last) {
67
+ c.frames = [{ t: 0, ...c.from }, { t: 16, ...st }];
68
+ c.start = now - 16;
69
+ const r = c.el.getBoundingClientRect();
70
+ c.topAtStart = +(r.top / innerHeight).toFixed(3);
71
+ c.last = key;
72
+ }
73
+ continue;
74
+ }
75
+ c.frames.push({ t: Math.round(now - c.start), ...st });
76
+ c.still = key === c.last ? c.still + 1 : 0;
77
+ c.last = key;
78
+ if (c.still >= 8 || now - c.start > 6000) {
79
+ c.frames = c.frames.slice(0, c.frames.length - c.still + 1); // drop the trailing identical frames
80
+ c.done = true;
81
+ }
82
+ }
83
+ if (candidates.some((c) => !c.done)) requestAnimationFrame(tick);
84
+ };
85
+ requestAnimationFrame(tick);
86
+ return candidates.length;
87
+ }
88
+
89
+ // Records the first class change of each element while the page scrolls, with where it happened.
90
+ function watchClassesInPage() {
91
+ const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
92
+ const pathOf = (el) => {
93
+ const parts = [];
94
+ for (let n = el; n && n !== document.body; n = n.parentElement) {
95
+ const sibs = [...n.parentElement.children].filter((c) => !SKIP.has(c.tagName));
96
+ parts.unshift(`${n.tagName.toLowerCase()}:${sibs.indexOf(n) + 1}`);
97
+ }
98
+ return parts.join('>');
99
+ };
100
+ const pinned = (el) => { for (let n = el; n && n !== document.body; n = n.parentElement) { const p = getComputedStyle(n).position; if (p === 'fixed' || p === 'sticky') return true; } return false; };
101
+ window.__tcClasses = [];
102
+ window.__tcClassIdle = true;
103
+ const idleChanged = new Set();
104
+ const seen = new Set();
105
+ new MutationObserver((records) => {
106
+ for (const r of records) {
107
+ const el = r.target;
108
+ if (!(el instanceof Element) || !el.isConnected || el === document.body || el === document.documentElement) continue;
109
+ if (window.__tcClassIdle) { idleChanged.add(el); continue; }
110
+ if (idleChanged.has(el) || seen.has(el)) continue;
111
+ const now = el.getAttribute('class');
112
+ if (now === r.oldValue) continue;
113
+ seen.add(el);
114
+ const rect = el.getBoundingClientRect();
115
+ window.__tcClasses.push({ el, path: pathOf(el), from: r.oldValue, to: now, scrollY: Math.round(scrollY), ratio: +(rect.top / innerHeight).toFixed(3), sticky: pinned(el) });
116
+ }
117
+ }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['class'], attributeOldValue: true });
118
+ }
119
+
120
+ const browser = await openBrowser(flags.cdp);
121
+ let result;
122
+ try {
123
+ const { page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
124
+ await page.goto(meta.url, { waitUntil: 'load', timeout: 60000 });
125
+ await page.mouse.move(5, vp.height - 5);
126
+ await waitStill(page, { timeout: 15000, interval: 200 });
127
+ const count = await page.evaluate(watchInPage);
128
+ // Class changes caused by scrolling (a header that gets its backdrop, an "is-visible" toggle): watched with a
129
+ // MutationObserver. Anything that changes while the page sits idle (timers, carousels) is ignored.
130
+ await page.evaluate(watchClassesInPage);
131
+ await page.waitForTimeout(1500);
132
+ await page.evaluate(() => { window.__tcClassIdle = false; });
133
+ // scroll down a third of a window at a time and give each reveal time to play
134
+ const max = await page.evaluate(() => document.documentElement.scrollHeight - innerHeight);
135
+ for (let y = 0; y <= max; y += Math.round(vp.height / 3)) {
136
+ await wheelTo(page, y, vp);
137
+ await page.waitForTimeout(450);
138
+ }
139
+ await page.waitForTimeout(2500);
140
+ // back to the top: does each class change revert (scroll state) or stay (one-shot)?
141
+ await page.evaluate(() => window.scrollTo(0, 0));
142
+ await page.waitForTimeout(1200);
143
+ // class order changes when a site removes and re-adds classes: compare the sets
144
+ // It reverts when the classes the scroll removed are back; classes added once (a one-time animation) may stay, so the
145
+ // state after returning ('back') is recorded too and used by the clone after the first scroll.
146
+ await page.evaluate(() => {
147
+ const toks = (v) => String(v || '').split(/\s+/).filter(Boolean);
148
+ for (const c of window.__tcClasses) {
149
+ c.back = c.el.getAttribute('class');
150
+ const removed = toks(c.from).filter((t) => !toks(c.to).includes(t));
151
+ c.reverts = removed.length ? removed.every((t) => toks(c.back).includes(t)) : !toks(c.back).some((t) => toks(c.to).includes(t) && !toks(c.from).includes(t));
152
+ c.marks = toks(c.to).filter((t) => !toks(c.back).includes(t)); // classes that only exist while scrolled
153
+ }
154
+ });
155
+ // sticky/fixed elements switch at a scroll position: find it precisely by bisection
156
+ const stickyCount = await page.evaluate(() => window.__tcClasses.filter((c) => c.sticky && c.reverts).length);
157
+ for (let i = 0; i < stickyCount; i++) {
158
+ let lo = 0, hi = await page.evaluate((k) => window.__tcClasses.filter((c) => c.sticky && c.reverts)[k].scrollY, i);
159
+ while (hi - lo > 2) {
160
+ const mid = Math.round((lo + hi) / 2);
161
+ await page.evaluate((y) => window.scrollTo(0, y), mid);
162
+ await page.waitForTimeout(160);
163
+ const on = await page.evaluate((k) => { const c = window.__tcClasses.filter((x) => x.sticky && x.reverts)[k]; const now = String(c.el.getAttribute('class') || '').split(/\s+/); return c.marks.length > 0 && c.marks.every((t) => now.includes(t)); }, i);
164
+ if (on) hi = mid; else lo = mid;
165
+ }
166
+ await page.evaluate(([k, y]) => { window.__tcClasses.filter((c) => c.sticky && c.reverts)[k].threshold = y; }, [i, hi]);
167
+ }
168
+ const classes = await page.evaluate(() => window.__tcClasses.map((c) => ({ path: c.path, from: c.from, to: c.to, back: c.back, sticky: c.sticky, reverts: c.reverts, ...(c.sticky ? { threshold: c.threshold ?? c.scrollY } : { trigger: c.ratio }) })));
169
+ // a reveal settles; anything still moving at the 6s cap is continuous motion driven some other way
170
+ result = await page.evaluate(() => window.__tcReveals.filter((c) => c.frames && c.frames.length > 2 && c.frames[c.frames.length - 1].t < 5900).map((c) => {
171
+ const last = c.frames[c.frames.length - 1];
172
+ // keep at most ~60 keyframes: enough for any easing, small enough for the clone
173
+ const step = Math.max(1, Math.ceil(c.frames.length / 60));
174
+ const frames = c.frames.filter((f, i) => i % step === 0 || i === c.frames.length - 1);
175
+ return { path: c.path, cls: String(c.el.getAttribute('class') || '').split(/\s+/).slice(0, 2).join('.'), from: c.from, to: { o: last.o, tx: last.tx, ty: last.ty, s: last.s, blur: last.blur }, trigger: c.topAtStart, duration: last.t, frames };
176
+ }));
177
+ console.log(`${count} candidates below the fold, ${result.length} revealed while scrolling`);
178
+ result.classes = classes;
179
+ console.log(`${classes.length} class change(s) caused by scrolling: ${classes.filter((c) => c.sticky).length} at a scroll position (sticky/fixed), ${classes.filter((c) => !c.sticky).length} when in view; ${classes.filter((c) => c.reverts).length} revert on the way back up`);
180
+ } finally {
181
+ if (!flags.cdp) await browser.close();
182
+ }
183
+ const out = path.join(root, 'reference', `reveals.${vp.name}.json`);
184
+ await fs.writeFile(out, JSON.stringify({ url: meta.url, viewport: vp, at: new Date().toISOString(), reveals: result, classes: result.classes }, null, 2));
185
+ const groups = new Map();
186
+ for (const r of result) {
187
+ const k = `${JSON.stringify(r.from)} -> ${Math.round(r.duration / 50) * 50}ms`;
188
+ groups.set(k, (groups.get(k) || 0) + 1);
189
+ }
190
+ for (const [k, n] of [...groups].sort((a, b) => b[1] - a[1]).slice(0, 8)) console.log(` ${String(n).padStart(3)}x from ${k}`);
191
+ console.log(`-> ${path.relative(process.cwd(), out)}`);