@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/README.md +20 -9
- package/bin/trueclone.mjs +8 -1
- package/package.json +1 -1
- package/scripts/capture.mjs +68 -9
- package/scripts/diff.mjs +16 -0
- package/scripts/interact.mjs +7 -3
- package/scripts/lib.mjs +35 -1
- package/scripts/next.mjs +227 -11
- package/scripts/reveals.mjs +191 -0
- package/scripts/seed.mjs +466 -21
- package/scripts/states.mjs +2 -1
- package/scripts/widgets.mjs +169 -0
- package/skill/SKILL.md +23 -3
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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}
|
|
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 =
|
|
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 =
|
|
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)}`);
|