@trueclone/cli 0.1.0 → 0.3.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 +28 -14
- package/bin/trueclone.mjs +14 -2
- package/package.json +1 -1
- package/scripts/capture.mjs +192 -15
- package/scripts/detail.mjs +19 -6
- package/scripts/diff.mjs +16 -0
- package/scripts/drift.mjs +48 -0
- package/scripts/explain.mjs +87 -0
- package/scripts/hovers.mjs +17 -8
- package/scripts/interact.mjs +7 -3
- package/scripts/lib.mjs +38 -20
- package/scripts/load.mjs +102 -0
- package/scripts/next.mjs +296 -15
- package/scripts/owned.mjs +66 -0
- package/scripts/report.mjs +43 -11
- package/scripts/reveals.mjs +285 -0
- package/scripts/score.mjs +71 -9
- package/scripts/seed.mjs +758 -22
- package/scripts/states.mjs +2 -1
- package/scripts/understand.mjs +231 -0
- package/scripts/widgets.mjs +169 -0
- package/skill/SKILL.md +62 -13
package/scripts/seed.mjs
CHANGED
|
@@ -1,29 +1,193 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Start the clone from the reference mirror instead of an empty folder. The mirror is the rendered page with every
|
|
2
|
+
// script and inline handler removed and its stylesheets and media saved locally, so the seeded clone has the site's
|
|
3
|
+
// structure and styles at the right paths. What it lacks is behaviour: write that in clone/script.js (your own
|
|
4
|
+
// code, using the parameters you read from the site), then verify with the usual scores.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
6
|
+
// exact mode (the site's own content, with recorded permission): markup, text, styles and media as they are.
|
|
7
|
+
// template mode: the same markup and styles, but every text becomes lorem words of the same rendered width (so lines
|
|
8
|
+
// break where the reference's break), every image, video, large SVG and iframe a same-size placeholder, every link
|
|
9
|
+
// "#", and brand metadata is dropped. No text or media of the site ends up in clone/. Fonts stay for local study:
|
|
10
|
+
// swap licensed fonts before reusing the result.
|
|
7
11
|
//
|
|
8
12
|
// usage: node scripts/seed.mjs <name> [--force] (--force overwrites an existing clone/)
|
|
9
13
|
import fs from 'node:fs/promises';
|
|
10
14
|
import path from 'node:path';
|
|
11
|
-
import { parseArgs } from './lib.mjs';
|
|
15
|
+
import { parseArgs, openBrowser, newPage, serve, VIEWPORTS } from './lib.mjs';
|
|
16
|
+
|
|
17
|
+
// placeholder vocabulary, wide enough to match most word widths
|
|
18
|
+
const LOREM_TEXT = `lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore
|
|
19
|
+
magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute
|
|
20
|
+
irure in reprehenderit voluptate velit esse cillum fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt
|
|
21
|
+
culpa qui officia deserunt mollit anim id est laborum curabitur pretium tincidunt lacus nunc vitae faucibus porta felis
|
|
22
|
+
mauris viverra diam vel augue blandit nibh sapien rhoncus massa feugiat tellus integer vulputate morbi risus quam varius
|
|
23
|
+
orci nam libero justo laoreet sem lectus mattis praesent semper ante gravida eget fermentum turpis sodales neque arcu
|
|
24
|
+
egestas phasellus volutpat donec pharetra aenean placerat lobortis primis cras tortor iaculis lacinia suspendisse potenti
|
|
25
|
+
vivamus ultricies posuere cubilia curae maecenas ligula proin accumsan quisque ornare urna pellentesque habitant tristique
|
|
26
|
+
senectus netus malesuada fames ac a at nec mi ne te se eu per
|
|
27
|
+
abeo absum accedo accipio acer acies actus addo adeo adhuc aditus adsum aequor aequus aer aetas ager agito agmen ago
|
|
28
|
+
aio alacer albus alias alius alo alter altus amicus amo amor amplus anima animus annus ante antea aperio apud aqua arbor
|
|
29
|
+
arcus ardeo argentum arma ars artus arx asper astrum atque atrox audax audio aureus auris aurum aut autem auxilium avis
|
|
30
|
+
bellum bene bonus bos brevis cado caecus caelum calamus campus candidus canis cano capio caput carmen carus castra causa
|
|
31
|
+
cedo celer cena certus cervus ceteri cibus cinis circa cito civis clamo clarus claudo clavis coepi cogito cognosco cohors
|
|
32
|
+
colo color comes communis concilium condo confero coniunx consilium constat contra copia cor corpus cras credo creo cresco
|
|
33
|
+
crimen cubo culpa cum cunctus cupio cur cura curro cursus custos damnum de debeo decet decus deinde denique dens desino
|
|
34
|
+
deus dexter dico dies dignus diligo discipulus diu diversus divus do doceo dolor domus donum dorsum dubius duco dulcis dum
|
|
35
|
+
durus dux ecce edo effero ego emo eo epistula equus ergo erro et etiam excito exemplum exeo exerceo exitus expedio facies
|
|
36
|
+
facilis facio factum fallo fama fames fatum faveo felix femina fere fero ferrum fessus fides filius finis fio flamma flecto
|
|
37
|
+
fleo flos flumen fluo foedus folium fons fores forma fortis fortuna forum frango frater frons fructus frustra fuga fugio
|
|
38
|
+
fundo fundus genus gens gero gladius gloria gradus gratia gravis habeo haud herba heri hic hiems hodie homo honor hora
|
|
39
|
+
hortus hostis humus iaceo iam ianua ibi idem ignis ille imago imber imperium impero incipio inde ingens inimicus initium
|
|
40
|
+
iniuria inquam insula inter interea ipse ira iter iubeo iungo ius iuvenis labor lacrima laetus lapis late latus laudo laus
|
|
41
|
+
lectus legio lego lenis levis lex liber libertas licet limen lingua littera litus locus longus loquor luceo ludus lumen
|
|
42
|
+
luna lux magister maior malus maneo manus mare mater medius memoria mens mensa mereo metus meus miles mirus miser mitto
|
|
43
|
+
modo modus moenia moneo mons mora morbus morior mors mos moveo mox mulier multus munus murus muto narro nascor natura navis
|
|
44
|
+
nemo nemus nescio niger nihil nimis nisi nobilis noceo nomen nos noster novus nox nullus numen numerus nunquam nuntius
|
|
45
|
+
oculus odium officium olim omnis onus opera oppidum ops optimus opus ora orbis ordo orior os otium pando par parens paro
|
|
46
|
+
pars parvus pater patior patria pauci pax pectus pecunia pello pendeo pereo periculum pes peto pietas placeo plenus
|
|
47
|
+
plurimus poena pono pons populus porta posco possum post potens praeda praemium premo pretium primus princeps prior pro
|
|
48
|
+
procul prope propter proximus puella puer pugna pulcher puto qualis quando quantus quare quasi quidem quies quinque quoque
|
|
49
|
+
rapio ratio recipio reddo redeo refero regio regnum rego relinquo res respondeo rex rideo ripa rogo ruo rursus sacer
|
|
50
|
+
saepe saevus salus sanguis sapiens satis saxum scelus scio scribo secundus securus sedeo semel senex sensus sentio sermo
|
|
51
|
+
servo servus sic sidus signum silva similis simul sine socius sol soleo solus somnus sonus soror spatium species spero
|
|
52
|
+
spes spiritus statim statuo sto studium sub subito summus super surgo suus tacitus talis tam tamen tandem tango tantus
|
|
53
|
+
tectum tellus telum templum tempus teneo tener terra terreo tertius timeo tollo totus traho trans tristis tu tum turba
|
|
54
|
+
tutus tuus ubi ullus umbra umquam unda unde unus urbs usus uxor vaco valeo vallis varius vates veho vel venio ventus
|
|
55
|
+
verbum vereor vero verto verus vester veto vetus via video vinco vinum vir virgo virtus vis vita vito vivo vix voco volo
|
|
56
|
+
voluntas vos votum vox vulnus vultus`;
|
|
57
|
+
const LOREM = LOREM_TEXT.split(/\s+/).filter(Boolean);
|
|
58
|
+
|
|
59
|
+
// The player's width classes (see PLAYER) as media queries: the same three widths the reference was captured at.
|
|
60
|
+
const WIDTH_MEDIA = { desktop: '(min-width: 1100px)', tablet: '(min-width: 600px) and (max-width: 1099px)', mobile: '(max-width: 599px)' };
|
|
61
|
+
|
|
62
|
+
// Reveal player shipped with the clone (trueclone's own code): replays each recorded curve with the Web Animations
|
|
63
|
+
// API once the element's top crosses the recorded trigger line; with reduced motion it shows the end state.
|
|
64
|
+
const PLAYER = `// trueclone reveal player: replays what was recorded on the reference while scrolling (trueclone reveals): one-shot
|
|
65
|
+
// reveals frame by frame, scroll-linked curves (parallax, scrubbed fades) by scroll position, and class changes (a
|
|
66
|
+
// header that gets its backdrop past 12px, an "is-visible" toggle). Records exist per width; the one for the
|
|
67
|
+
// visitor's width is used (desktop from 1100px, tablet from 600px, otherwise mobile).
|
|
68
|
+
(() => {
|
|
69
|
+
const raw = JSON.parse(document.getElementById('tc-reveals')?.textContent || '{}');
|
|
70
|
+
const data = Array.isArray(raw) ? raw : raw.reveals || [];
|
|
71
|
+
const classData = Array.isArray(raw) ? [] : raw.classes || [];
|
|
72
|
+
const scrubData = Array.isArray(raw) ? [] : raw.scrubs || [];
|
|
73
|
+
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
74
|
+
const vpName = innerWidth >= 1100 ? 'desktop' : innerWidth >= 600 ? 'tablet' : 'mobile';
|
|
75
|
+
const pick = (entry) => (entry && (entry.desktop || entry.tablet || entry.mobile) ? entry[vpName] || entry.desktop || entry.tablet || entry.mobile : entry);
|
|
76
|
+
|
|
77
|
+
// class changes are state, not motion: they apply with reduced motion too
|
|
78
|
+
let scrolled = false;
|
|
79
|
+
const pinned = [];
|
|
80
|
+
for (const el of document.querySelectorAll('[data-tc-class]')) {
|
|
81
|
+
const c = classData[Number(el.dataset.tcClass)];
|
|
82
|
+
if (!c) continue;
|
|
83
|
+
if (c.sticky) { pinned.push([el, c]); continue; }
|
|
84
|
+
const line = Math.max(0, Math.min(1, c.trigger ?? 1));
|
|
85
|
+
// an element without a box (display: contents) never intersects: watch its first descendant that has one
|
|
86
|
+
const watched = el.getBoundingClientRect().height ? el : [...el.querySelectorAll('*')].find((d) => d.getBoundingClientRect().height) || el;
|
|
87
|
+
new IntersectionObserver(([e], io) => {
|
|
88
|
+
if (e.isIntersecting) { el.setAttribute('class', c.to); if (!c.reverts) io.disconnect(); }
|
|
89
|
+
else if (c.reverts && el.getAttribute('class') === c.to) el.setAttribute('class', c.back ?? c.from);
|
|
90
|
+
}, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` }).observe(watched);
|
|
91
|
+
}
|
|
92
|
+
const applyPinned = () => {
|
|
93
|
+
if (scrollY > 0) scrolled = true;
|
|
94
|
+
for (const [el, c] of pinned) {
|
|
95
|
+
const past = scrollY >= (c.threshold ?? 1);
|
|
96
|
+
if (past) el.setAttribute('class', c.to);
|
|
97
|
+
else if (c.reverts && scrolled) el.setAttribute('class', c.back ?? c.from);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
if (pinned.length) {
|
|
101
|
+
let queued = false;
|
|
102
|
+
addEventListener('scroll', () => { if (!queued) { queued = true; requestAnimationFrame(() => { queued = false; applyPinned(); }); } }, { passive: true });
|
|
103
|
+
applyPinned();
|
|
104
|
+
}
|
|
105
|
+
const style = (f) => ({ opacity: f.o, transform: \`translate(\${f.tx}px, \${f.ty}px) scale(\${f.s})\`, filter: f.blur ? \`blur(\${f.blur}px)\` : 'none' });
|
|
106
|
+
// scroll-linked curves: the recorded state at the two nearest scroll positions, interpolated. An element that also
|
|
107
|
+
// plays a one-shot reveal follows its curve once the reveal has finished.
|
|
108
|
+
// A curve is keyed by the element's own position in the viewport (key "v": its layout box top, translate taken
|
|
109
|
+
// out, the way a ScrollTrigger on the element sees it, so any viewport height gives the same curve) or, for an
|
|
110
|
+
// element inside a pinned stage, by the scroll position (key "y").
|
|
111
|
+
const scrubs = [];
|
|
112
|
+
for (const el of document.querySelectorAll('[data-tc-scrub]')) {
|
|
113
|
+
const rec = pick(scrubData[Number(el.dataset.tcScrub)]);
|
|
114
|
+
const s = Array.isArray(rec) ? rec : rec && rec.samples;
|
|
115
|
+
if (!s || s.length < 2) continue;
|
|
116
|
+
const key = !Array.isArray(rec) && rec.key === 'v' && s[0].v !== undefined ? 'v' : 'y';
|
|
117
|
+
scrubs.push({ el, key, s: key === 'v' ? [...s].sort((p, q) => p.v - q.v) : s, ty: 0, ready: !el.hasAttribute('data-tc-reveal') });
|
|
118
|
+
}
|
|
119
|
+
const at = (s, key, pos) => {
|
|
120
|
+
if (pos <= s[0][key]) return s[0];
|
|
121
|
+
for (let i = 1; i < s.length; i++) {
|
|
122
|
+
if (pos > s[i][key]) continue;
|
|
123
|
+
const a = s[i - 1], b = s[i], k = (pos - a[key]) / ((b[key] - a[key]) || 1);
|
|
124
|
+
const L = (p) => a[p] + (b[p] - a[p]) * k;
|
|
125
|
+
return { o: L('o'), tx: L('tx'), ty: L('ty'), s: L('s'), blur: L('blur') };
|
|
126
|
+
}
|
|
127
|
+
return s[s.length - 1];
|
|
128
|
+
};
|
|
129
|
+
// script.js takes an element over by removing its data-tc-scrub / data-tc-reveal mark: the player then leaves it alone
|
|
130
|
+
const applyScrubs = () => {
|
|
131
|
+
for (const x of scrubs) {
|
|
132
|
+
if (!x.ready || !x.el.hasAttribute('data-tc-scrub')) continue;
|
|
133
|
+
const pos = x.key === 'v' ? x.el.getBoundingClientRect().top - x.ty : scrollY;
|
|
134
|
+
const f = at(x.s, x.key, pos);
|
|
135
|
+
x.ty = f.ty;
|
|
136
|
+
Object.assign(x.el.style, style(f));
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
if (scrubs.length) {
|
|
140
|
+
let queued = false;
|
|
141
|
+
addEventListener('scroll', () => { if (!queued) { queued = true; requestAnimationFrame(() => { queued = false; applyScrubs(); }); } }, { passive: true });
|
|
142
|
+
applyScrubs();
|
|
143
|
+
}
|
|
144
|
+
const release = (el) => { const x = scrubs.find((q) => q.el === el); if (x) { x.ready = true; applyScrubs(); } };
|
|
145
|
+
const settle = (el, r) => { Object.assign(el.style, style(r.to)); release(el); };
|
|
146
|
+
const play = (el, r) => {
|
|
147
|
+
const frames = r.frames.map((f) => ({ ...style(f), offset: r.duration ? Math.min(1, f.t / r.duration) : 1 }));
|
|
148
|
+
const anim = el.animate(frames, { duration: r.duration, fill: 'forwards', easing: 'linear' });
|
|
149
|
+
anim.onfinish = () => { settle(el, r); anim.cancel(); };
|
|
150
|
+
};
|
|
151
|
+
for (const el of document.querySelectorAll('[data-tc-reveal]')) {
|
|
152
|
+
const r = pick(data[Number(el.dataset.tcReveal)]);
|
|
153
|
+
if (!r) { release(el); continue; }
|
|
154
|
+
if (r.from) Object.assign(el.style, style(r.from)); // this width's starting state (the seed inlined the desktop one)
|
|
155
|
+
if (reduce) { settle(el, r); continue; }
|
|
156
|
+
// the recorded trigger is where the element's top was (as a share of the window height) when it started
|
|
157
|
+
const line = Math.max(0, Math.min(1, r.trigger ?? 1));
|
|
158
|
+
const io = new IntersectionObserver((entries) => {
|
|
159
|
+
for (const e of entries) {
|
|
160
|
+
if (!e.isIntersecting) continue;
|
|
161
|
+
io.disconnect();
|
|
162
|
+
if (el.hasAttribute('data-tc-reveal')) play(el, r);
|
|
163
|
+
}
|
|
164
|
+
}, { rootMargin: \`0px 0px -\${((1 - line) * 100).toFixed(1)}% 0px\` });
|
|
165
|
+
io.observe(el);
|
|
166
|
+
}
|
|
167
|
+
// boot contract: the page shows once every clone script has run (DOMContentLoaded comes after the deferred ones;
|
|
168
|
+
// the Next app's injector releases after its last script) and every hold is released. script.js holds the boot
|
|
169
|
+
// while it prepares what a visitor must not see half-built (an intro waiting for its image): tcBoot.hold() at the
|
|
170
|
+
// top, tcBoot.release() when ready.
|
|
171
|
+
const boot = window.tcBoot = { holds: 1, hold() { this.holds++; }, release() { if (--this.holds <= 0) requestAnimationFrame(() => document.documentElement.classList.remove('tc-boot')); } };
|
|
172
|
+
if (document.readyState === 'loading') addEventListener('DOMContentLoaded', () => boot.release()); else if (!window.__tcInjected) boot.release();
|
|
173
|
+
})();
|
|
174
|
+
`;
|
|
12
175
|
|
|
13
176
|
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
14
177
|
const [name] = pos;
|
|
15
178
|
if (!name) {
|
|
16
|
-
console.error('usage:
|
|
179
|
+
console.error('usage: trueclone seed <name> [--force] [--hide-until-boot "<selectors a script rebuilds at boot>"]');
|
|
17
180
|
process.exit(1);
|
|
18
181
|
}
|
|
19
182
|
const root = path.resolve('sites', name);
|
|
20
183
|
const ref = path.join(root, 'reference');
|
|
21
184
|
const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
|
|
22
|
-
|
|
23
|
-
|
|
185
|
+
const exact = meta.mode === 'exact' && Boolean(meta.permission);
|
|
186
|
+
const mirror = path.join(ref, 'mirror');
|
|
187
|
+
if (!(await fs.stat(path.join(mirror, 'index.html')).then(() => true, () => false))) {
|
|
188
|
+
console.error(`no mirror yet: run trueclone capture ${meta.url} ${name} --mirror-only`);
|
|
24
189
|
process.exit(1);
|
|
25
190
|
}
|
|
26
|
-
const mirror = path.join(ref, 'mirror');
|
|
27
191
|
const clone = path.join(root, 'clone');
|
|
28
192
|
const exists = await fs.stat(path.join(clone, 'index.html')).then(() => true, () => false);
|
|
29
193
|
if (exists && !flags.force) {
|
|
@@ -33,36 +197,608 @@ if (exists && !flags.force) {
|
|
|
33
197
|
await fs.mkdir(path.join(clone, 'css'), { recursive: true });
|
|
34
198
|
|
|
35
199
|
// Assets the mirror points at (../assets/... from the page, ../../assets/... from mirror/css) are copied into
|
|
36
|
-
// clone/assets and the paths rewritten, so clone/ is self-contained.
|
|
200
|
+
// clone/assets and the paths rewritten, so clone/ is self-contained. Template mode only takes fonts; every other
|
|
201
|
+
// asset reference becomes the placeholder.
|
|
202
|
+
const PLACEHOLDER = 'assets/placeholder.svg';
|
|
203
|
+
const PLACEHOLDER_BG = 'assets/placeholder-bg.svg'; // transparent: for CSS backgrounds
|
|
37
204
|
const copied = new Set();
|
|
205
|
+
const isFont = (rel) => /\.(woff2?|ttf|otf|eot)([?#]|$)/i.test(rel) || /^font\//.test(rel);
|
|
38
206
|
const pull = async (rel) => {
|
|
39
207
|
const clean = decodeURIComponent(rel.split(/[?#]/)[0]);
|
|
40
208
|
if (copied.has(clean)) return;
|
|
41
209
|
copied.add(clean);
|
|
42
|
-
const from = path.join(ref, 'assets', clean);
|
|
43
210
|
const to = path.join(clone, 'assets', clean);
|
|
44
211
|
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
45
|
-
await fs.copyFile(
|
|
212
|
+
await fs.copyFile(path.join(ref, 'assets', clean), to).catch(() => console.warn(`WARNING missing asset ${clean}`));
|
|
46
213
|
};
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
214
|
+
// `forPass`: the page's media point at the original files in reference/ for the browser pass only, so images keep
|
|
215
|
+
// their intrinsic size (an image laid out by its aspect ratio needs it); the pass replaces them with same-size
|
|
216
|
+
// placeholders and a final sweep guarantees no reference path survives.
|
|
217
|
+
const REF_ASSETS = '../reference/assets/';
|
|
218
|
+
const rewrite = async (text, prefix, replacement, forPass = false) => {
|
|
219
|
+
// saved asset names are [a-z0-9._-]; `&` ends a path inside a style attribute (url("../assets/x.webp"))
|
|
220
|
+
const re = new RegExp(prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + 'assets/([^"\')\\s&]+)', 'g');
|
|
221
|
+
for (const m of text.matchAll(re)) if (exact || isFont(m[1])) await pull(m[1]);
|
|
222
|
+
return text.replace(re, (all, rel) => (exact || isFont(rel) ? `${replacement}assets/${rel}` : forPass ? `${REF_ASSETS}${rel}` : `${replacement}${PLACEHOLDER}`));
|
|
51
223
|
};
|
|
52
224
|
|
|
53
225
|
for (const file of await fs.readdir(path.join(mirror, 'css')).catch(() => [])) {
|
|
54
226
|
const css = await fs.readFile(path.join(mirror, 'css', file), 'utf8');
|
|
55
|
-
|
|
227
|
+
let out = await rewrite(css, '../../', '../');
|
|
228
|
+
// template mode: images the capture never downloaded still point at the site (url("https://…/bg.avif"));
|
|
229
|
+
// every non-font url() becomes a transparent placeholder (CSS backgrounds are mostly decorative: noise, grids,
|
|
230
|
+
// glows), so the clone never loads the site's media. url(#id) points inside the page (SVG paint) and stays.
|
|
231
|
+
if (!exact) out = out.replace(/url\(\s*(['"]?)(?!data:)([^'")]+)\1\s*\)/g, (all, q, u) => (isFont(u) || u.trim().startsWith('#') ? all : `url("../${PLACEHOLDER_BG}")`));
|
|
232
|
+
await fs.writeFile(path.join(clone, 'css', file), out);
|
|
56
233
|
}
|
|
57
234
|
let html = await fs.readFile(path.join(mirror, 'index.html'), 'utf8');
|
|
58
|
-
html = await rewrite(html, '../', '');
|
|
235
|
+
html = await rewrite(html, '../', '', !exact);
|
|
236
|
+
await fs.writeFile(path.join(clone, 'index.html'), html);
|
|
237
|
+
|
|
238
|
+
if (!exact) {
|
|
239
|
+
// a neutral box that reads on light and dark pages
|
|
240
|
+
await fs.mkdir(path.join(clone, 'assets'), { recursive: true });
|
|
241
|
+
await fs.writeFile(path.join(clone, PLACEHOLDER), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" preserveAspectRatio="none"><rect width="16" height="16" fill="#8884"/></svg>\n');
|
|
242
|
+
await fs.writeFile(path.join(clone, PLACEHOLDER_BG), '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"/>\n');
|
|
243
|
+
}
|
|
244
|
+
// Scroll reveals recorded on the reference (trueclone reveals): mark the elements, start them in their recorded
|
|
245
|
+
// starting state, and replay the recorded curves with a small player when they scroll into view.
|
|
246
|
+
// One file per width when `reveals` ran at every width: elements are matched by path (the same DOM at every width)
|
|
247
|
+
// and the player uses the record for the visitor's width, so phone offsets are phone offsets, not desktop pixels.
|
|
248
|
+
const recs = {};
|
|
249
|
+
for (const v of ['desktop', 'tablet', 'mobile']) {
|
|
250
|
+
const r = JSON.parse(await fs.readFile(path.join(ref, `reveals.${v}.json`), 'utf8').catch(() => 'null'));
|
|
251
|
+
if (r) recs[v] = r;
|
|
252
|
+
}
|
|
253
|
+
const primary = recs.desktop || recs.tablet || recs.mobile || null;
|
|
254
|
+
const byPath = (key) => {
|
|
255
|
+
const m = new Map();
|
|
256
|
+
for (const [v, r] of Object.entries(recs)) for (const x of r[key] || []) {
|
|
257
|
+
if (!m.has(x.path)) m.set(x.path, { path: x.path, cls: x.cls });
|
|
258
|
+
m.get(x.path)[v] = x;
|
|
259
|
+
}
|
|
260
|
+
return [...m.values()];
|
|
261
|
+
};
|
|
262
|
+
const recorded = byPath('reveals');
|
|
263
|
+
const scrubbed = byPath('scrubs');
|
|
264
|
+
const classChanges = primary?.classes || [];
|
|
265
|
+
// Widget states recorded on the reference (trueclone widgets): tabs, menus. Template mode only for now: the snapshots
|
|
266
|
+
// are placeholdered like the page; exact-mode snapshots would still point at the site's media.
|
|
267
|
+
const widgetRec = exact ? null : JSON.parse(await fs.readFile(path.join(ref, 'widgets.desktop.json'), 'utf8').catch(() => 'null'));
|
|
268
|
+
const widgetGroups = widgetRec?.groups || [];
|
|
269
|
+
let marked = 0, classMarked = 0, scrubMarked = 0, widgets = null, runtime = null;
|
|
270
|
+
({ html, marked, classMarked, scrubMarked, widgets, runtime } = await browserPass(!exact, recorded, classChanges, widgetGroups, scrubbed));
|
|
271
|
+
if (!exact) html = html.split(REF_ASSETS).join('__tc_ref__').replace(/__tc_ref__[^"')\s]+/g, PLACEHOLDER);
|
|
272
|
+
if (widgets?.groups.length) {
|
|
273
|
+
// a plain script (not fetched JSON), so the clone also works opened from disk
|
|
274
|
+
await fs.writeFile(path.join(clone, 'trueclone-widgets.js'), `// trueclone widget player: recorded states of the reference's tabs and menus (trueclone widgets), placeholdered.\nwindow.__tcWidgetData = ${JSON.stringify(widgets)};\n(${widgetPlayer.toString()})();\n`);
|
|
275
|
+
html = html.replace(/<\/body>/i, '<script src="trueclone-widgets.js" defer></script>\n</body>');
|
|
276
|
+
}
|
|
277
|
+
if (marked || classMarked || scrubMarked) {
|
|
278
|
+
await fs.writeFile(path.join(clone, 'trueclone-reveals.js'), PLAYER);
|
|
279
|
+
const perWidth = (r, shape) => Object.fromEntries(['desktop', 'tablet', 'mobile'].filter((v) => r[v]).map((v) => [v, shape(r[v])]));
|
|
280
|
+
const data = JSON.stringify({
|
|
281
|
+
reveals: recorded.map((r) => perWidth(r, ({ trigger, duration, frames, to, from }) => ({ trigger, duration, frames, to, from }))),
|
|
282
|
+
scrubs: scrubbed.map((r) => perWidth(r, (x) => ({ key: x.key, samples: x.samples }))),
|
|
283
|
+
classes: classChanges.map(({ from, to, back, sticky, reverts, threshold, trigger }) => ({ from, to, back, sticky, reverts, threshold, trigger })),
|
|
284
|
+
});
|
|
285
|
+
html = html.replace(/<\/body>/i, `<script type="application/json" id="tc-reveals">${data}</script>\n<script src="trueclone-reveals.js" defer></script>\n</body>`);
|
|
286
|
+
}
|
|
287
|
+
// Boot contract: <html class="tc-boot"> from the first byte until every clone script has run (the reveal player drops
|
|
288
|
+
// it after DOMContentLoaded; the Next app after its last script). Regions a script builds or re-lays out at boot
|
|
289
|
+
// (--hide-until-boot "<selectors>") stay invisible meanwhile, so a visitor never sees their captured, pre-script state;
|
|
290
|
+
// a 3 s failsafe shows everything if no script ever runs.
|
|
291
|
+
html = html.replace(/<html\b([^>]*)>/i, (all, attrs) => (/\bclass=/.test(attrs) ? `<html${attrs.replace(/\bclass=(["'])([^"']*)\1/, (m, q, v) => `class=${q}${v.trim() ? `${v.trim()} ` : ''}tc-boot${q}`)}>` : `<html${attrs} class="tc-boot">`));
|
|
292
|
+
html = html.replace(/<\/head>/i, '<style id="tc-boot-styles">/* trueclone seed: boot contract */@keyframes tc-boot-failsafe{to{visibility:visible}}html.tc-boot [data-tc-boot-hide]{visibility:hidden;animation:tc-boot-failsafe 0s 3s forwards}</style>\n</head>');
|
|
59
293
|
// the clone's own behaviour script, loaded once the DOM is parsed
|
|
60
294
|
if (!/<script[^>]+src="script\.js"/.test(html)) html = html.replace(/<\/body>/i, '<script src="script.js" defer></script>\n</body>');
|
|
295
|
+
// Patches: what a clone needs on top of the seed lives in clone/patches/ and is applied here on every seed, so the
|
|
296
|
+
// generated index.html is never edited by hand and a re-seed never loses a fix. In name order:
|
|
297
|
+
// head-*.html inserted before </head> (meta, extra stylesheet links)
|
|
298
|
+
// *.css linked before </head> (after the site's stylesheets, so they win ties)
|
|
299
|
+
// body-*.html inserted before the clone's own <script src="script.js"> (vendor libraries, a hand-over prelude)
|
|
300
|
+
// *.js <script src defer> before script.js (runs after the players, before script.js)
|
|
301
|
+
const patchDir = path.join(clone, 'patches');
|
|
302
|
+
const patches = (await fs.readdir(patchDir).catch(() => [])).filter((f) => /\.(html|css|js)$/i.test(f)).sort();
|
|
303
|
+
const beforeScript = (s) => (/<script[^>]+src="script\.js"/.test(html) ? html.replace(/<script[^>]+src="script\.js"[^>]*><\/script>/i, (tag) => `${s}\n${tag}`) : html.replace(/<\/body>/i, `${s}\n</body>`));
|
|
304
|
+
for (const f of patches) {
|
|
305
|
+
if (/^head-.*\.html$/i.test(f)) html = html.replace(/<\/head>/i, `${await fs.readFile(path.join(patchDir, f), 'utf8')}\n</head>`);
|
|
306
|
+
else if (/\.css$/i.test(f)) html = html.replace(/<\/head>/i, `<link rel="stylesheet" href="patches/${f}">\n</head>`);
|
|
307
|
+
else if (/\.html$/i.test(f)) html = beforeScript(await fs.readFile(path.join(patchDir, f), 'utf8'));
|
|
308
|
+
else if (/\.js$/i.test(f)) html = beforeScript(`<script src="patches/${f}" defer></script>`);
|
|
309
|
+
}
|
|
61
310
|
await fs.writeFile(path.join(clone, 'index.html'), html);
|
|
62
311
|
|
|
312
|
+
// notes.md: the agent's record, with the sections the report reads back (Findings, Accepted differences). Written once.
|
|
313
|
+
const notesFile = path.join(root, 'notes.md');
|
|
314
|
+
if (!(await fs.stat(notesFile).then(() => true, () => false))) {
|
|
315
|
+
await fs.writeFile(notesFile, `# ${name}: clone notes
|
|
316
|
+
|
|
317
|
+
Reference: ${meta.url}. Mode: ${exact ? `exact (permission: ${meta.permission})` : 'template'}. Captured ${meta.capturedAt}.
|
|
318
|
+
Keep every section; the report reads "Findings" and "Accepted differences" back. One line per entry, no prose.
|
|
319
|
+
|
|
320
|
+
## Stack
|
|
321
|
+
|
|
322
|
+
<!-- what the reference is built with: platform, libraries and versions, fonts, breakpoints (from motion.*.md, reference/code.md, the HAR) -->
|
|
323
|
+
|
|
324
|
+
## Behaviours
|
|
325
|
+
|
|
326
|
+
<!-- the motion inventory and the interactions as a list: element, trigger, property, range, timing; one line each -->
|
|
327
|
+
|
|
328
|
+
## Own-code map
|
|
329
|
+
|
|
330
|
+
<!-- file -> behaviours it implements -> where its parameters came from (a motion entry, code.md, a measured value) -->
|
|
331
|
+
|
|
332
|
+
## Findings
|
|
333
|
+
|
|
334
|
+
<!-- defects in the tool, not in the site: symptom, cause if known, the workaround used. Listed in REPORT.md -->
|
|
335
|
+
|
|
336
|
+
## Accepted differences
|
|
337
|
+
|
|
338
|
+
<!-- what cannot or should not match, one per line: "<check> <width> <where>: <why>" (random motion, a live feed, a
|
|
339
|
+
hover that lands by timing, a third-party embed). Listed first in REPORT.md; a check below target that is covered
|
|
340
|
+
here is marked "accepted" there. Every entry needs the why. -->
|
|
341
|
+
|
|
342
|
+
## Unknowns
|
|
343
|
+
|
|
344
|
+
<!-- what you could not determine: a rule you could not read, a value you measured instead of derived -->
|
|
345
|
+
`);
|
|
346
|
+
}
|
|
347
|
+
|
|
63
348
|
const scriptFile = path.join(clone, 'script.js');
|
|
64
349
|
if (!(await fs.stat(scriptFile).then(() => true, () => false))) {
|
|
65
|
-
await fs.writeFile(scriptFile, `// ${meta.url} - clone behaviour (your own code).
|
|
350
|
+
await fs.writeFile(scriptFile, `// ${meta.url} - clone behaviour (your own code). Markup and styles come from the mirror${exact ? '' : ' (placeholder content)'}.\n// Rebuild every interaction and reveal from the reference's parameters; verify with npx trueclone score ${name}.\n`);
|
|
351
|
+
}
|
|
352
|
+
console.log(`seeded ${path.relative(process.cwd(), clone)} from the mirror (${exact ? 'exact: the site\'s own content' : 'template: placeholder text and media'}): index.html, ${(await fs.readdir(path.join(clone, 'css'))).length} stylesheet(s), ${copied.size} asset(s)${exact ? '' : ' (fonts only)'}`);
|
|
353
|
+
if (recorded.length || classChanges.length || scrubbed.length) console.log(`replayed by trueclone-reveals.js: ${marked}/${recorded.length} scroll reveals, ${scrubMarked}/${scrubbed.length} scroll-linked curves, ${classMarked}/${classChanges.length} scroll class changes (${Object.keys(recs).join(', ') || 'no'} widths recorded)${marked < recorded.length || classMarked < classChanges.length || scrubMarked < scrubbed.length ? ' (the rest did not resolve to an element in the mirror)' : ''}`);
|
|
354
|
+
if (runtime) {
|
|
355
|
+
const pins = runtime.pins.map((p) => `${p.el} pinned for ${p.distance}px`).join(', ');
|
|
356
|
+
console.log(`runtime artefacts cleaned: ${runtime.pins.length} GSAP pin(s) made sticky${pins ? ` (${pins}; data-tc-pin on each)` : ''}, ${runtime.canvases} canvas(es) marked data-tc-runtime="canvas"`);
|
|
357
|
+
if (runtime.widthStyled) console.log(`per-width inline styles: ${runtime.widthStyled} element(s) sized or placed differently by the site's scripts at tablet/mobile, now media-query rules (data-tc-w)`);
|
|
358
|
+
if (runtime.hidden) console.log(`boot contract: ${runtime.hidden} element(s) hidden until the clone's scripts have run (data-tc-boot-hide)`);
|
|
359
|
+
}
|
|
360
|
+
if (widgetGroups.length) console.log(`replayed by trueclone-widgets.js: ${widgets?.groups.length || 0}/${widgetGroups.length} widgets (${widgets?.states || 0} recorded states)${widgetRec?.portals?.length ? `; not replayed (portal popovers): ${widgetRec.portals.join(', ')}` : ''}`);
|
|
361
|
+
if (patches.length) console.log(`patches applied from clone/patches/: ${patches.join(', ')}`);
|
|
362
|
+
console.log(`next: write clone/script.js (behaviour), then npx trueclone score ${name}`);
|
|
363
|
+
|
|
364
|
+
// ---------- One browser pass over the seeded page: placeholders (template mode) and reveal marks ----------
|
|
365
|
+
async function browserPass(template, reveals, classes, groups = [], scrubs = []) {
|
|
366
|
+
// served from sites/<name>/ so the pass can reach reference/assets (see `forPass`)
|
|
367
|
+
const server = await serve(root);
|
|
368
|
+
const browser = await openBrowser(flags.cdp);
|
|
369
|
+
try {
|
|
370
|
+
const { page } = await newPage(browser, VIEWPORTS.find((v) => v.name === 'desktop'), {});
|
|
371
|
+
await page.goto(`${server.url}clone/index.html`, { waitUntil: 'load', timeout: 60000 });
|
|
372
|
+
await page.evaluate(() => document.fonts.ready);
|
|
373
|
+
const counts = reveals.length || classes.length || scrubs.length ? await page.evaluate(markRevealsInPage, [reveals, classes, scrubs]) : [0, 0, 0];
|
|
374
|
+
// widgets: mark regions and triggers, then give every recorded state the same placeholders as the page by
|
|
375
|
+
// placing it into its region (so it is laid out and measured for real) and reading it back
|
|
376
|
+
let widgets = null;
|
|
377
|
+
if (groups.length) {
|
|
378
|
+
const ok = await page.evaluate(markWidgetsInPage, groups);
|
|
379
|
+
await page.evaluate(`window.__tcPlaceholders = ${placeholdersInPage.toString()}`);
|
|
380
|
+
widgets = { groups: [], states: 0 };
|
|
381
|
+
for (const g of groups) {
|
|
382
|
+
if (!ok[g.i]) continue;
|
|
383
|
+
const shape = (stateHtml) => (stateHtml == null ? null : page.evaluate(([i, h, lorem, t]) => {
|
|
384
|
+
const scope = document.querySelector(`[data-tc-scope~="${i}"]`);
|
|
385
|
+
if (!scope) return null;
|
|
386
|
+
const before = scope.innerHTML;
|
|
387
|
+
scope.innerHTML = h;
|
|
388
|
+
const out = t ? window.__tcPlaceholders([lorem, `[data-tc-scope~="${i}"]`]) : scope.innerHTML;
|
|
389
|
+
scope.innerHTML = before;
|
|
390
|
+
return out;
|
|
391
|
+
}, [g.i, stateHtml, LOREM, template]));
|
|
392
|
+
const states = [];
|
|
393
|
+
for (const st of g.states) states.push(await shape(st));
|
|
394
|
+
const closed = g.kind === 'expand' ? await shape(g.closed) : undefined;
|
|
395
|
+
widgets.states += states.filter(Boolean).length;
|
|
396
|
+
widgets.groups[g.i] = { kind: g.kind, states, closed };
|
|
397
|
+
}
|
|
398
|
+
widgets.groups = Array.from(widgets.groups, (x) => x || null);
|
|
399
|
+
}
|
|
400
|
+
if (template) await page.evaluate(placeholdersInPage, [LOREM, null]);
|
|
401
|
+
// last, because the recorded paths above were taken on the live page, pin-spacers included
|
|
402
|
+
const runtime = await page.evaluate(cleanRuntimeInPage);
|
|
403
|
+
// regions the clone's script builds at boot, hidden until it has run (see the boot contract below)
|
|
404
|
+
runtime.hidden = typeof flags['hide-until-boot'] === 'string'
|
|
405
|
+
? await page.evaluate((sels) => { let n = 0; for (const s of sels) for (const el of document.querySelectorAll(s)) { el.setAttribute('data-tc-boot-hide', ''); n++; } return n; }, flags['hide-until-boot'].split(',').map((s) => s.trim()).filter(Boolean))
|
|
406
|
+
: 0;
|
|
407
|
+
// inline styles that differ between widths (script-computed sizes and offsets) become media-query rules
|
|
408
|
+
const inlineStyles = JSON.parse(await fs.readFile(path.join(ref, 'inline-styles.json'), 'utf8').catch(() => 'null'));
|
|
409
|
+
runtime.widthStyled = inlineStyles ? await page.evaluate(widthStylesInPage, [inlineStyles, WIDTH_MEDIA]) : 0;
|
|
410
|
+
// getHTML keeps the declarative shadow roots the capture serialised (outerHTML would drop them); it is the inner
|
|
411
|
+
// HTML, so the <html> tag and its attributes (font classes, dark mode, lang) are written back around it
|
|
412
|
+
const out = await page.evaluate(() => {
|
|
413
|
+
const el = document.documentElement;
|
|
414
|
+
const inner = el.getHTML ? el.getHTML({ serializableShadowRoots: true }) : el.innerHTML;
|
|
415
|
+
const attrs = [...el.attributes].map((a) => ` ${a.name}="${a.value.replace(/&/g, '&').replace(/"/g, '"')}"`).join('');
|
|
416
|
+
return `<html${attrs}>${inner}</html>`;
|
|
417
|
+
});
|
|
418
|
+
return { html: `<!DOCTYPE html>\n${out}`, marked: counts[0], classMarked: counts[1], scrubMarked: counts[2], widgets, runtime };
|
|
419
|
+
} finally {
|
|
420
|
+
if (!flags.cdp) await browser.close();
|
|
421
|
+
server.close();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// The path of element children recorded by reveals.mjs (script/style/meta-like tags skipped) resolves to the same
|
|
426
|
+
// element in the script-free mirror. Each reveal gets data-tc-reveal and its recorded starting state inline; each
|
|
427
|
+
// scroll class change gets data-tc-class and its starting classes (the page as it is before any scroll).
|
|
428
|
+
// Widget regions and triggers, resolved by the recorded paths in the mirror (which shows each widget's initial
|
|
429
|
+
// state). A region can serve several widgets (every menu of a nav): data-tc-scope holds a space-separated list.
|
|
430
|
+
function markWidgetsInPage(groups) {
|
|
431
|
+
const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
432
|
+
const resolve = (p) => {
|
|
433
|
+
let el = document.body;
|
|
434
|
+
for (const part of p.split('>')) {
|
|
435
|
+
const [tag, n] = part.split(':');
|
|
436
|
+
el = [...el.children].filter((c) => !SKIP.has(c.tagName))[Number(n) - 1];
|
|
437
|
+
if (!el || el.tagName.toLowerCase() !== tag) return null;
|
|
438
|
+
}
|
|
439
|
+
return el;
|
|
440
|
+
};
|
|
441
|
+
const ok = {};
|
|
442
|
+
for (const g of groups) {
|
|
443
|
+
const scope = resolve(g.scopePath);
|
|
444
|
+
const triggers = g.triggers.map((t) => resolve(t.path));
|
|
445
|
+
if (!scope || triggers.some((t) => !t)) continue;
|
|
446
|
+
const ids = (scope.getAttribute('data-tc-scope') || '').split(' ').filter(Boolean);
|
|
447
|
+
ids.push(String(g.i));
|
|
448
|
+
scope.setAttribute('data-tc-scope', ids.join(' '));
|
|
449
|
+
triggers.forEach((t, j) => t.setAttribute('data-tc-trigger', `${g.i}:${j}`));
|
|
450
|
+
ok[g.i] = true;
|
|
451
|
+
}
|
|
452
|
+
return ok;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Widget player shipped with the clone (trueclone's own code, serialised into clone/trueclone-widgets.js): a click on
|
|
456
|
+
// a trigger swaps its region to the recorded state; an expandable trigger toggles between its open and closed state;
|
|
457
|
+
// menus in a header or nav also open on hover and close when the pointer leaves their region, like the reference.
|
|
458
|
+
function widgetPlayer() {
|
|
459
|
+
const data = window.__tcWidgetData || { groups: [] };
|
|
460
|
+
const regionOf = (i) => document.querySelector(`[data-tc-scope~="${i}"]`);
|
|
461
|
+
const show = (i, html) => { const r = regionOf(i); if (r && html != null && r.innerHTML !== html) r.innerHTML = html; };
|
|
462
|
+
const parse = (t) => t.dataset.tcTrigger.split(':').map(Number);
|
|
463
|
+
document.addEventListener('click', (e) => {
|
|
464
|
+
const t = e.target.closest('[data-tc-trigger]');
|
|
465
|
+
if (!t) return;
|
|
466
|
+
const [i, j] = parse(t);
|
|
467
|
+
const g = data.groups[i];
|
|
468
|
+
if (!g) return;
|
|
469
|
+
e.preventDefault();
|
|
470
|
+
if (g.kind === 'tabs') show(i, g.states[j]);
|
|
471
|
+
else show(i, t.getAttribute('aria-expanded') === 'true' ? g.closed : g.states[j]);
|
|
472
|
+
});
|
|
473
|
+
const hoverMenu = (t) => t.closest('header, nav') && data.groups[parse(t)[0]]?.kind === 'expand';
|
|
474
|
+
document.addEventListener('pointerover', (e) => {
|
|
475
|
+
const t = e.target.closest('[data-tc-trigger]');
|
|
476
|
+
if (!t || !hoverMenu(t) || t.getAttribute('aria-expanded') === 'true') return;
|
|
477
|
+
const [i, j] = parse(t);
|
|
478
|
+
show(i, data.groups[i].states[j]);
|
|
479
|
+
});
|
|
480
|
+
document.addEventListener('pointerout', (e) => {
|
|
481
|
+
for (const [i, g] of data.groups.entries()) {
|
|
482
|
+
if (!g || g.kind !== 'expand') continue;
|
|
483
|
+
const r = regionOf(i);
|
|
484
|
+
if (!r || !r.contains(e.target) || r.contains(e.relatedTarget)) continue;
|
|
485
|
+
if (r.querySelector(`[data-tc-trigger^="${i}:"][aria-expanded="true"]`)) show(i, g.closed);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function markRevealsInPage([reveals, classes, scrubs = []]) {
|
|
491
|
+
const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
492
|
+
const resolve = (p) => {
|
|
493
|
+
let el = document.body;
|
|
494
|
+
for (const part of p.split('>')) {
|
|
495
|
+
const [tag, n] = part.split(':');
|
|
496
|
+
const kids = [...el.children].filter((c) => !SKIP.has(c.tagName));
|
|
497
|
+
el = kids[Number(n) - 1];
|
|
498
|
+
if (!el || el.tagName.toLowerCase() !== tag) return null;
|
|
499
|
+
}
|
|
500
|
+
return el;
|
|
501
|
+
};
|
|
502
|
+
// each reveal's starting state is a rule on its mark (no flash before the player runs; the player then applies the
|
|
503
|
+
// visitor's width); script.js hands an element over by removing the mark, which releases the state with it
|
|
504
|
+
const rules = [];
|
|
505
|
+
let count = 0;
|
|
506
|
+
reveals.forEach((r, i) => {
|
|
507
|
+
const el = resolve(r.path);
|
|
508
|
+
if (!el) return;
|
|
509
|
+
const rec = r.desktop || r.tablet || r.mobile || r;
|
|
510
|
+
el.setAttribute('data-tc-reveal', String(i));
|
|
511
|
+
rules.push(`[data-tc-reveal="${i}"]{opacity:${rec.from.o};transform:translate(${rec.from.tx}px, ${rec.from.ty}px) scale(${rec.from.s})${rec.from.blur ? `;filter:blur(${rec.from.blur}px)` : ''}}`);
|
|
512
|
+
count++;
|
|
513
|
+
});
|
|
514
|
+
if (rules.length) {
|
|
515
|
+
document.getElementById('tc-reveal-styles')?.remove();
|
|
516
|
+
const style = document.createElement('style');
|
|
517
|
+
style.id = 'tc-reveal-styles';
|
|
518
|
+
style.textContent = `/* trueclone seed: starting states of recorded scroll reveals (desktop); trueclone-reveals.js plays them */\n${rules.join('\n')}\n`;
|
|
519
|
+
document.head.appendChild(style);
|
|
520
|
+
}
|
|
521
|
+
let classCount = 0;
|
|
522
|
+
classes.forEach((c, i) => {
|
|
523
|
+
const el = resolve(c.path);
|
|
524
|
+
if (!el) return;
|
|
525
|
+
el.setAttribute('data-tc-class', String(i));
|
|
526
|
+
if (c.from !== null && c.from !== undefined) el.setAttribute('class', c.from);
|
|
527
|
+
classCount++;
|
|
528
|
+
});
|
|
529
|
+
let scrubCount = 0;
|
|
530
|
+
scrubs.forEach((s, i) => {
|
|
531
|
+
const el = resolve(s.path);
|
|
532
|
+
if (!el) return;
|
|
533
|
+
el.setAttribute('data-tc-scrub', String(i));
|
|
534
|
+
scrubCount++;
|
|
535
|
+
});
|
|
536
|
+
return [count, classCount, scrubCount];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Inline styles the capture read at every width, by element path. A declaration with the same value at every width
|
|
540
|
+
// stays inline; one that differs (a height a script computed from the container, an offset, a custom property)
|
|
541
|
+
// moves into a media-query rule per width, so each width gets exactly what the site showed there. The rule's
|
|
542
|
+
// selector is stacked to the precedence an inline style had over the stylesheets, without !important, so a value
|
|
543
|
+
// script.js writes inline later still wins. Motion properties of elements the reveal player drives, and the pin
|
|
544
|
+
// geometry the seed removed, are left out.
|
|
545
|
+
function widthStylesInPage([styles, media]) {
|
|
546
|
+
const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
547
|
+
const resolve = (p) => {
|
|
548
|
+
let el = document.body;
|
|
549
|
+
for (const part of p.split('>')) {
|
|
550
|
+
const [tag, n] = part.split(':');
|
|
551
|
+
const kids = [...el.children].filter((c) => !SKIP.has(c.tagName));
|
|
552
|
+
el = kids[Number(n) - 1];
|
|
553
|
+
if (!el || el.tagName.toLowerCase() !== tag) return null;
|
|
554
|
+
}
|
|
555
|
+
return el;
|
|
556
|
+
};
|
|
557
|
+
const decls = (text) => {
|
|
558
|
+
const map = new Map();
|
|
559
|
+
for (const d of String(text || '').split(/;(?![^(]*\))/)) {
|
|
560
|
+
const i = d.indexOf(':');
|
|
561
|
+
if (i < 0) continue;
|
|
562
|
+
const k = d.slice(0, i).trim(), v = d.slice(i + 1).trim().replace(/\s*!important$/i, '');
|
|
563
|
+
if (k) map.set(k, v);
|
|
564
|
+
}
|
|
565
|
+
return map;
|
|
566
|
+
};
|
|
567
|
+
const MOTION = new Set(['transform', 'translate', 'rotate', 'scale', 'opacity', 'filter']);
|
|
568
|
+
const PIN = new Set(['translate', 'rotate', 'scale', 'inset', 'top', 'left', 'right', 'bottom', 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'max-width', 'width', 'max-height', 'height', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'box-sizing', 'position', 'transform', 'flex-basis']);
|
|
569
|
+
const widths = Object.keys(styles).filter((w) => media[w]);
|
|
570
|
+
const paths = new Set(widths.flatMap((w) => Object.keys(styles[w])));
|
|
571
|
+
const rules = [];
|
|
572
|
+
const sel = (i) => `[data-tc-w="${i}"]`.repeat(8); // (0,8,0): above any ordinary author rule, below inline
|
|
573
|
+
let count = 0;
|
|
574
|
+
for (const p of paths) {
|
|
575
|
+
const per = Object.fromEntries(widths.map((w) => [w, decls(styles[w][p])]));
|
|
576
|
+
const all = new Set(widths.flatMap((w) => [...per[w].keys()]));
|
|
577
|
+
const differs = [...all].filter((k) => widths.some((w) => per[w].get(k) !== per[widths[0]].get(k)));
|
|
578
|
+
if (!differs.length) continue;
|
|
579
|
+
const el = resolve(p);
|
|
580
|
+
if (!el) continue;
|
|
581
|
+
// a pin spacer keeps only its pin distance per width (the site computes it from the viewport height); GSAP writes
|
|
582
|
+
// it as the padding shorthand (padding: 0px 0px 5600px)
|
|
583
|
+
const spacer = el.hasAttribute('data-tc-pin-spacer');
|
|
584
|
+
if (spacer) {
|
|
585
|
+
const horizontal = /^padding-right|^padding:\s*0(px)?\s+[1-9]/.test(styles[widths[0]][p] || '');
|
|
586
|
+
const distance = (m) => { if (m.has(horizontal ? 'padding-right' : 'padding-bottom')) return parseFloat(m.get(horizontal ? 'padding-right' : 'padding-bottom')); const v = (m.get('padding') || '').trim().split(/\s+/); return parseFloat(horizontal ? v[1] ?? v[0] : v[2] ?? v[0]) || 0; };
|
|
587
|
+
const d = Object.fromEntries(widths.map((w) => [w, Math.round(distance(per[w]))]));
|
|
588
|
+
if (widths.every((w) => d[w] === d[widths[0]])) continue;
|
|
589
|
+
const i = count++;
|
|
590
|
+
el.setAttribute('data-tc-w', String(i));
|
|
591
|
+
for (const w of widths) {
|
|
592
|
+
el.firstElementChild?.setAttribute(`data-tc-pin-${w}`, String(d[w]));
|
|
593
|
+
rules.push(`@media ${media[w]} { ${sel(i)} { ${horizontal ? 'padding-right' : 'padding-bottom'}: ${d[w]}px } }`);
|
|
594
|
+
}
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const skip = (k) => (el.hasAttribute('data-tc-runtime') && (PIN.has(k) || k === 'width' || k === 'height')) || ((el.hasAttribute('data-tc-reveal') || el.hasAttribute('data-tc-scrub')) && MOTION.has(k));
|
|
598
|
+
const moved = differs.filter((k) => !skip(k));
|
|
599
|
+
if (!moved.length) continue;
|
|
600
|
+
const i = count++;
|
|
601
|
+
el.setAttribute('data-tc-w', String(i));
|
|
602
|
+
// the inline attribute keeps what is the same everywhere (as the desktop mirror had it)
|
|
603
|
+
const current = decls(el.getAttribute('style'));
|
|
604
|
+
for (const k of moved) current.delete(k);
|
|
605
|
+
const inline = [...current].map(([k, v]) => `${k}: ${v}`).join('; ');
|
|
606
|
+
if (inline) el.setAttribute('style', inline); else el.removeAttribute('style');
|
|
607
|
+
for (const w of widths) {
|
|
608
|
+
const body = moved.filter((k) => per[w].has(k)).map((k) => `${k}: ${per[w].get(k)}`).join('; ');
|
|
609
|
+
if (body) rules.push(`@media ${media[w]} { ${sel(i)} { ${body} } }`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (rules.length) {
|
|
613
|
+
const style = document.createElement('style');
|
|
614
|
+
style.id = 'tc-width-styles';
|
|
615
|
+
style.textContent = `/* trueclone seed: inline styles the site's scripts set per width (captured at desktop, tablet and mobile) */\n${rules.join('\n')}\n`;
|
|
616
|
+
document.head.appendChild(style);
|
|
617
|
+
}
|
|
618
|
+
return count;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// The mirror is a snapshot taken after the site's scripts ran, so it carries what they left in the DOM: GSAP's
|
|
622
|
+
// pin-spacer wrappers and the pin's inline geometry (fixed desktop pixels that break every other width), and canvas
|
|
623
|
+
// buffer sizes set for one viewport. A pin is a sticky element inside a spacer, which CSS does natively: the seed
|
|
624
|
+
// keeps the spacer as a plain block with the pin distance as padding (`data-tc-pin-spacer`), makes the element
|
|
625
|
+
// sticky, and records the distance on it (`data-tc-pin`), so layout and scroll states hold without a script. The
|
|
626
|
+
// clone's script may take the pin over (unwrap the spacer, drop the sticky style) and draws the marked canvases.
|
|
627
|
+
function cleanRuntimeInPage() {
|
|
628
|
+
const log = { pins: [], canvases: 0 };
|
|
629
|
+
const label = (el) => `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${el.classList.length ? `.${[...el.classList].slice(0, 2).join('.')}` : ''}`;
|
|
630
|
+
// what ScrollTrigger writes on the pinned element while pinning (its own values live in the stylesheet)
|
|
631
|
+
const PIN_PROPS = ['translate', 'rotate', 'scale', 'inset', 'top', 'left', 'right', 'bottom', 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'max-width', 'width', 'max-height', 'height', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'box-sizing', 'position', 'transform', 'flex-basis'];
|
|
632
|
+
// what the spacer copied from the element so the parent's flex/grid layout holds: kept; its geometry: dropped
|
|
633
|
+
const SPACER_KEEP = new Set(['order', 'place-self', 'grid-area', 'grid-column', 'grid-row', 'float', 'flex-shrink', 'flex-grow', 'flex-basis', 'display', 'align-self', 'justify-self']);
|
|
634
|
+
for (const spacer of document.querySelectorAll('.pin-spacer')) {
|
|
635
|
+
const pinned = spacer.firstElementChild;
|
|
636
|
+
const horizontal = !spacer.style.paddingBottom && !!spacer.style.paddingRight;
|
|
637
|
+
const distance = Math.round(parseFloat(horizontal ? spacer.style.paddingRight : spacer.style.paddingBottom) || 0);
|
|
638
|
+
if (!pinned) { spacer.replaceWith(...spacer.childNodes); continue; }
|
|
639
|
+
for (const p of PIN_PROPS) pinned.style.removeProperty(p);
|
|
640
|
+
pinned.style.position = 'sticky';
|
|
641
|
+
pinned.style.top = '0px';
|
|
642
|
+
pinned.setAttribute('data-tc-runtime', 'pin');
|
|
643
|
+
pinned.setAttribute('data-tc-pin', String(distance));
|
|
644
|
+
const keep = [...spacer.style].filter((p) => SPACER_KEEP.has(p)).map((p) => `${p}: ${spacer.style.getPropertyValue(p)}`);
|
|
645
|
+
spacer.removeAttribute('class');
|
|
646
|
+
spacer.setAttribute('style', `${keep.join('; ')}${keep.length ? '; ' : ''}${horizontal ? 'padding-right' : 'padding-bottom'}: ${distance}px`);
|
|
647
|
+
spacer.setAttribute('data-tc-pin-spacer', String(distance));
|
|
648
|
+
log.pins.push({ el: label(pinned), distance });
|
|
649
|
+
}
|
|
650
|
+
// a canvas keeps its buffer size: with only a CSS width, the height follows the buffer's aspect ratio, and the
|
|
651
|
+
// clone's own scene sets the buffer it needs; the mark says a script draws here
|
|
652
|
+
for (const c of document.querySelectorAll('canvas')) {
|
|
653
|
+
c.setAttribute('data-tc-runtime', 'canvas');
|
|
654
|
+
log.canvases++;
|
|
655
|
+
}
|
|
656
|
+
return log;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
function placeholdersInPage([lorem, rootSel]) {
|
|
661
|
+
// the whole page, or one region (a widget's recorded state placed into the page to be measured)
|
|
662
|
+
const root = rootSel ? document.querySelector(rootSel) : document.body;
|
|
663
|
+
if (!root) return null;
|
|
664
|
+
// Each letter run becomes a lorem word of (nearly) the same laid-out width in the element's own font, keeping its
|
|
665
|
+
// capitalisation; the running width is tracked across an element's text so line breaks land where the reference's
|
|
666
|
+
// do (a 2px drift is enough to move a break on a full line). Digits keep their count (years stay), punctuation and
|
|
667
|
+
// whitespace stay exactly as they were.
|
|
668
|
+
//
|
|
669
|
+
// Widths come from layout, not canvas: the reference words are measured with Ranges on the live text, candidates
|
|
670
|
+
// in an isolated box that copies the text style (contain: layout, so measuring never re-lays out the page). All
|
|
671
|
+
// reads happen before any text is written.
|
|
672
|
+
const meter = document.createElement('span');
|
|
673
|
+
meter.style.cssText = 'position:absolute;left:-99999px;top:0;white-space:pre;contain:layout style paint;visibility:hidden';
|
|
674
|
+
document.body.appendChild(meter);
|
|
675
|
+
const PROPS = ['fontStyle', 'fontWeight', 'fontSize', 'fontFamily', 'fontStretch', 'fontVariant', 'fontFeatureSettings', 'fontKerning', 'letterSpacing', 'wordSpacing', 'textTransform'];
|
|
676
|
+
let current = null;
|
|
677
|
+
const cache = new Map();
|
|
678
|
+
const widthIn = (s, word) => {
|
|
679
|
+
const key = PROPS.map((p) => s[p]).join('|');
|
|
680
|
+
let words = cache.get(key);
|
|
681
|
+
if (!words) cache.set(key, (words = new Map()));
|
|
682
|
+
if (!words.has(word)) {
|
|
683
|
+
if (current !== key) { for (const p of PROPS) meter.style[p] = s[p]; current = key; }
|
|
684
|
+
meter.textContent = word;
|
|
685
|
+
words.set(word, meter.getBoundingClientRect().width);
|
|
686
|
+
}
|
|
687
|
+
return words.get(word);
|
|
688
|
+
};
|
|
689
|
+
const RUN = /([\p{L}\p{N}]+)/u;
|
|
690
|
+
const pick = (s, run, refWidth, state) => {
|
|
691
|
+
if (/^\p{N}+$/u.test(run)) return /^(19|20)\d\d$/.test(run) ? run : run.replace(/\d/g, (d) => String((Number(d) + 3) % 10));
|
|
692
|
+
const caps = run.length > 1 && run === run.toUpperCase() && /\p{L}/u.test(run);
|
|
693
|
+
const cap = !caps && /\p{Lu}/u.test(run[0]);
|
|
694
|
+
const cased = (w) => (caps ? w.toUpperCase() : cap ? w[0].toUpperCase() + w.slice(1) : w);
|
|
695
|
+
const target = refWidth + state.drift;
|
|
696
|
+
let best = null, bestGap = Infinity;
|
|
697
|
+
state.seed = (state.seed * 31 + run.length) % 9973;
|
|
698
|
+
for (let i = 0; i < lorem.length; i++) {
|
|
699
|
+
const w = cased(lorem[(i + state.seed) % lorem.length]);
|
|
700
|
+
const gap = Math.abs(widthIn(s, w) - target);
|
|
701
|
+
if (gap < bestGap) { best = w; bestGap = gap; }
|
|
702
|
+
}
|
|
703
|
+
// No single word close enough (a 2px miss moves a line break): try two lorem words joined into one. Still one
|
|
704
|
+
// word, so the break opportunities stay the reference's, but there are ~10^5 widths to choose from.
|
|
705
|
+
if (bestGap > 0.75 && target > 30) {
|
|
706
|
+
const sorted = lorem.map((w) => [widthIn(s, cased(w)), w]).sort((a, b) => a[0] - b[0]);
|
|
707
|
+
const tail = (w) => (caps ? w.toUpperCase() : w); // only the first part carries the capital
|
|
708
|
+
const tries = [];
|
|
709
|
+
for (const [wa, a] of sorted) {
|
|
710
|
+
const need = target - wa;
|
|
711
|
+
if (need < 6) break;
|
|
712
|
+
let lo = 0, hi = sorted.length - 1;
|
|
713
|
+
while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid][0] < need) lo = mid + 1; else hi = mid; }
|
|
714
|
+
for (const k of [lo - 1, lo]) if (sorted[k]) tries.push([Math.abs(wa + sorted[k][0] - target), cased(a) + tail(sorted[k][1])]);
|
|
715
|
+
}
|
|
716
|
+
tries.sort((x, y) => x[0] - y[0]);
|
|
717
|
+
for (const [, word] of tries.slice(0, 12)) { // estimates; measure the real compound (kerning at the join)
|
|
718
|
+
const gap = Math.abs(widthIn(s, word) - target);
|
|
719
|
+
if (gap < bestGap) { best = word; bestGap = gap; }
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
state.drift = target - widthIn(s, best);
|
|
723
|
+
return best;
|
|
724
|
+
};
|
|
725
|
+
// for attributes (alt, aria-label...): no layout to match, the element's own font is enough
|
|
726
|
+
const shaper = (el) => {
|
|
727
|
+
const s = getComputedStyle(el || document.body);
|
|
728
|
+
const state = { drift: 0, seed: 7 };
|
|
729
|
+
return (text) => text.split(RUN).map((run) => (RUN.test(run) && !/^[^\p{L}\p{N}]*$/u.test(run) ? pick(s, run, widthIn(s, run), state) : run)).join('');
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
// text nodes: measure every original letter run first (reads only)
|
|
733
|
+
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
|
|
734
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
735
|
+
const plan = [];
|
|
736
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
737
|
+
if (!/[\p{L}\p{N}]/u.test(n.textContent) || SKIP.has(n.parentElement?.tagName) || n.parentElement === meter) continue;
|
|
738
|
+
const parts = n.textContent.split(RUN);
|
|
739
|
+
let at = 0;
|
|
740
|
+
const widths = parts.map((part) => {
|
|
741
|
+
const start = at;
|
|
742
|
+
at += part.length;
|
|
743
|
+
if (!/[\p{L}\p{N}]/u.test(part)) return null;
|
|
744
|
+
const r = document.createRange();
|
|
745
|
+
r.setStart(n, start);
|
|
746
|
+
r.setEnd(n, start + part.length);
|
|
747
|
+
const rects = [...r.getClientRects()];
|
|
748
|
+
return rects.reduce((sum, x) => sum + x.width, 0) || null; // a run hidden by display:none measures 0
|
|
749
|
+
});
|
|
750
|
+
plan.push({ n, parts, widths, style: getComputedStyle(n.parentElement) });
|
|
751
|
+
}
|
|
752
|
+
// then choose the words (writes go to the isolated box only), then write all the text
|
|
753
|
+
const states = new Map();
|
|
754
|
+
for (const item of plan) {
|
|
755
|
+
const state = states.get(item.n.parentElement) || { drift: 0, seed: 7 };
|
|
756
|
+
states.set(item.n.parentElement, state);
|
|
757
|
+
item.text = item.parts.map((part, i) => {
|
|
758
|
+
if (!/[\p{L}\p{N}]/u.test(part)) return part;
|
|
759
|
+
return pick(item.style, part, item.widths[i] ?? widthIn(item.style, part), state);
|
|
760
|
+
}).join('');
|
|
761
|
+
}
|
|
762
|
+
for (const item of plan) item.n.textContent = item.text;
|
|
763
|
+
meter.remove();
|
|
764
|
+
// text in attributes people read or hear
|
|
765
|
+
for (const el of root.querySelectorAll('[alt], [title], [aria-label], [placeholder], input[value], [data-text]')) {
|
|
766
|
+
for (const a of ['alt', 'title', 'aria-label', 'placeholder', 'data-text']) if (el.hasAttribute(a) && el.getAttribute(a).trim()) el.setAttribute(a, shaper(el)(el.getAttribute(a)));
|
|
767
|
+
if (el.tagName === 'INPUT' && ['text', 'email', 'search', 'url', 'tel', ''].includes(el.type) && el.value) el.setAttribute('value', shaper(el)(el.value));
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// media: same box, placeholder content
|
|
771
|
+
const box = (w, h) => `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(w) || 16}" height="${Math.round(h) || 16}" viewBox="0 0 16 16" preserveAspectRatio="none"><rect width="16" height="16" fill="#8884"/></svg>`)}`;
|
|
772
|
+
for (const img of root.querySelectorAll('img')) {
|
|
773
|
+
const r = img.getBoundingClientRect();
|
|
774
|
+
img.setAttribute('src', box(img.naturalWidth || r.width, img.naturalHeight || r.height));
|
|
775
|
+
for (const a of ['srcset', 'sizes', 'data-src', 'data-srcset']) img.removeAttribute(a);
|
|
776
|
+
}
|
|
777
|
+
root.querySelectorAll('picture source, video source, audio source, track').forEach((s) => s.remove());
|
|
778
|
+
for (const v of root.querySelectorAll('video')) {
|
|
779
|
+
const r = v.getBoundingClientRect();
|
|
780
|
+
v.removeAttribute('src');
|
|
781
|
+
v.setAttribute('poster', box(v.videoWidth || r.width, v.videoHeight || r.height));
|
|
782
|
+
v.setAttribute('preload', 'none');
|
|
783
|
+
}
|
|
784
|
+
for (const f of root.querySelectorAll('iframe, embed, object')) f.setAttribute(f.tagName === 'OBJECT' ? 'data' : 'src', 'about:blank');
|
|
785
|
+
// inline SVG: small ones are interface icons and stay; large ones (logos, illustrations) become a same-size box
|
|
786
|
+
for (const svg of root.querySelectorAll('svg')) {
|
|
787
|
+
if (svg.parentElement?.closest('svg')) continue;
|
|
788
|
+
const r = svg.getBoundingClientRect();
|
|
789
|
+
if (r.width <= 48 && r.height <= 48) continue;
|
|
790
|
+
if (!svg.getAttribute('viewBox')) svg.setAttribute('viewBox', `0 0 ${Math.round(r.width) || 16} ${Math.round(r.height) || 16}`);
|
|
791
|
+
svg.setAttribute('preserveAspectRatio', 'none');
|
|
792
|
+
const [, , w, h] = svg.getAttribute('viewBox').split(/[\s,]+/).map(Number);
|
|
793
|
+
svg.innerHTML = `<rect width="${w || 16}" height="${h || 16}" fill="#8884"/>`;
|
|
794
|
+
}
|
|
795
|
+
for (const el of root.querySelectorAll('[style*="url("]')) el.setAttribute('style', el.getAttribute('style').replace(/url\((['"]?)(?!data:)[^)'"]+\1\)/g, 'url("assets/placeholder.svg")'));
|
|
796
|
+
|
|
797
|
+
// links, forms and brand metadata
|
|
798
|
+
root.querySelectorAll('a[href]').forEach((a) => { if (!a.getAttribute('href').startsWith('#')) a.setAttribute('href', '#'); });
|
|
799
|
+
root.querySelectorAll('form').forEach((f) => f.setAttribute('action', '#'));
|
|
800
|
+
if (root !== document.body) return root.innerHTML;
|
|
801
|
+
document.querySelectorAll('meta[property^="og:"], meta[name^="twitter:"], meta[name="description"], meta[name="keywords"], meta[name="author"], meta[name="application-name"], meta[name="generator"], meta[name="apple-mobile-web-app-title"], link[rel="author"], link[rel="preconnect"], link[rel="dns-prefetch"], link[rel="preload"][as="image"], link[rel="prefetch"], link[rel~="icon"], link[rel="apple-touch-icon"], link[rel="manifest"], link[rel="canonical"], link[rel="alternate"], script[type="application/ld+json"]').forEach((m) => m.remove());
|
|
802
|
+
document.title = shaper(null)(document.title);
|
|
803
|
+
return document.documentElement.outerHTML;
|
|
66
804
|
}
|
|
67
|
-
console.log(`seeded ${path.relative(process.cwd(), clone)} from the mirror: index.html, ${(await fs.readdir(path.join(clone, 'css'))).length} stylesheet(s), ${copied.size} asset(s)`);
|
|
68
|
-
console.log('next: write clone/script.js (behaviour), then npm run score -- ' + name);
|