@trueclone/cli 0.2.0 → 0.4.0-rc.1
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 +27 -32
- package/bin/trueclone.mjs +28 -19
- package/package.json +2 -1
- package/scripts/capture.mjs +239 -9
- package/scripts/curves.mjs +44 -7
- package/scripts/detail.mjs +19 -6
- package/scripts/diff.mjs +4 -2
- package/scripts/drift.mjs +55 -0
- package/scripts/explain.mjs +87 -0
- package/scripts/hovers.mjs +144 -43
- package/scripts/interact.mjs +35 -5
- package/scripts/lib.mjs +77 -29
- package/scripts/load.mjs +106 -0
- package/scripts/next.mjs +158 -28
- package/scripts/owned.mjs +66 -0
- package/scripts/report.mjs +83 -17
- package/scripts/reveals.mjs +178 -80
- package/scripts/review.mjs +149 -0
- package/scripts/score.mjs +74 -9
- package/scripts/scripts-evidence.mjs +116 -0
- package/scripts/seed.mjs +420 -56
- package/scripts/states.mjs +38 -5
- package/scripts/tiles.mjs +33 -17
- package/scripts/understand.mjs +231 -0
- package/scripts/viewport-fit.mjs +198 -0
- package/skill/SKILL.md +119 -116
package/scripts/states.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// node scripts/states.mjs <name> --clone [url] [--structure]
|
|
8
8
|
import fs from 'node:fs/promises';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import { SCORED, parseArgs, openBrowser, newPage, serve, warmUp, waitStill, wheelTo, statesInPage, maskToggle, freezeLoops, matchPct, canvasSimilarity, PNG } from './lib.mjs';
|
|
11
11
|
|
|
12
12
|
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
13
13
|
const [name] = pos;
|
|
@@ -22,7 +22,13 @@ const cloneMode = Boolean(flags.clone);
|
|
|
22
22
|
const structure = Boolean(flags.structure);
|
|
23
23
|
const stepFrac = Number(flags.step || 0.5);
|
|
24
24
|
const wanted = typeof flags.viewports === 'string' ? flags.viewports.split(',') : null;
|
|
25
|
-
const
|
|
25
|
+
const viewportsAll = SCORED.filter((v) => !wanted || wanted.includes(v.name));
|
|
26
|
+
// the clone is only replayed where the reference has states (a capture older than the held-out sizes has none there)
|
|
27
|
+
const viewports = [];
|
|
28
|
+
for (const v of viewportsAll) {
|
|
29
|
+
if (flags.clone && !(await fs.stat(path.join(ref, 'states', `${v.name}.json`)).then(() => true, () => false))) { console.log(`${v.name}: no reference states (run trueclone states ${name}); skipped`); continue; }
|
|
30
|
+
viewports.push(v);
|
|
31
|
+
}
|
|
26
32
|
const outRoot = path.join(cloneMode ? path.join(root, 'compare') : ref, 'states');
|
|
27
33
|
|
|
28
34
|
const server = cloneMode && typeof flags.clone !== 'string' ? await serve(path.join(root, 'clone')) : null;
|
|
@@ -75,7 +81,13 @@ try {
|
|
|
75
81
|
await page.screenshot({ path: path.join(dir, `${file}.mask.png`) });
|
|
76
82
|
await maskToggle(page, false);
|
|
77
83
|
if (!cloneMode) samples.push(await page.evaluate(statesInPage));
|
|
78
|
-
|
|
84
|
+
// the canvases in view (scenes a script draws): masked out of the structure score, so compared on their own
|
|
85
|
+
const canvases = await page.evaluate(() => [...document.querySelectorAll('canvas')].map((c) => {
|
|
86
|
+
const r = c.getBoundingClientRect(), s = getComputedStyle(c);
|
|
87
|
+
if (r.width < 40 || r.height < 40 || r.bottom <= 0 || r.top >= innerHeight || s.visibility === 'hidden' || +s.opacity < 0.05) return null;
|
|
88
|
+
return [Math.max(0, r.left), Math.max(0, r.top), Math.min(innerWidth, r.right), Math.min(innerHeight, r.bottom)].map(Math.round);
|
|
89
|
+
}).filter((r) => r && r[2] - r[0] >= 40 && r[3] - r[1] >= 40));
|
|
90
|
+
steps.push({ i, y: actual, target: y, settled: still.settled, settleMs: still.ms, canvases });
|
|
79
91
|
}
|
|
80
92
|
await context.close();
|
|
81
93
|
|
|
@@ -89,7 +101,27 @@ try {
|
|
|
89
101
|
scored.push({ ...s, match: matchPct(a, b).pct });
|
|
90
102
|
}
|
|
91
103
|
const avg = +(scored.reduce((n, s) => n + s.match, 0) / scored.length).toFixed(2);
|
|
92
|
-
|
|
104
|
+
// canvases: the reference's canvas regions at each step, compared by what is drawn (real pixels, not the mask)
|
|
105
|
+
const refSteps = JSON.parse(await fs.readFile(path.join(ref, 'states', `${vp.name}.json`), 'utf8')).steps;
|
|
106
|
+
const canvasScores = [];
|
|
107
|
+
for (const s of steps) {
|
|
108
|
+
const regions = refSteps[s.i]?.canvases;
|
|
109
|
+
if (!regions?.length) continue;
|
|
110
|
+
const f = `${String(s.i).padStart(2, '0')}.png`;
|
|
111
|
+
const a = PNG.sync.read(await fs.readFile(path.join(ref, 'states', vp.name, f)));
|
|
112
|
+
const b = PNG.sync.read(await fs.readFile(path.join(dir, f)));
|
|
113
|
+
for (const [x0, y0, x1, y1] of regions) {
|
|
114
|
+
const w = Math.min(x1, a.width, b.width) - x0, h = Math.min(y1, a.height, b.height) - y0;
|
|
115
|
+
if (w < 40 || h < 40) continue;
|
|
116
|
+
const ca = new PNG({ width: w, height: h }), cb = new PNG({ width: w, height: h });
|
|
117
|
+
PNG.bitblt(a, ca, x0, y0, w, h, 0, 0);
|
|
118
|
+
PNG.bitblt(b, cb, x0, y0, w, h, 0, 0);
|
|
119
|
+
canvasScores.push({ i: s.i, y: s.y, region: [x0, y0, x1, y1], similarity: canvasSimilarity(ca, cb) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const hasCanvasData = refSteps.some((s) => Array.isArray(s.canvases));
|
|
123
|
+
const canvas = canvasScores.length ? +(canvasScores.reduce((n, c) => n + c.similarity, 0) / canvasScores.length).toFixed(2) : null;
|
|
124
|
+
return { viewport: vp.name, match: avg, worst: [...scored].sort((a, b) => a.match - b.match).slice(0, 5), steps: scored, canvas, canvasWorst: [...canvasScores].sort((a, b) => a.similarity - b.similarity).slice(0, 5), ...(hasCanvasData ? {} : { canvasNote: 'reference states predate the canvas check: re-run trueclone states' }) };
|
|
93
125
|
} else {
|
|
94
126
|
const summary = summarize(steps, samples, vp, initial);
|
|
95
127
|
await fs.writeFile(path.join(outRoot, `${vp.name}.json`), JSON.stringify({ viewport: vp, steps, samples }));
|
|
@@ -105,7 +137,8 @@ try {
|
|
|
105
137
|
await fs.writeFile(path.join(outRoot, cloneMode ? `report${structure ? '.mask' : ''}.json` : 'report.json'), JSON.stringify(report, null, 2));
|
|
106
138
|
if (cloneMode) {
|
|
107
139
|
console.log(`state match (${report.mode}) - average over scroll steps`);
|
|
108
|
-
console.table(report.viewports.map((v) => ({ viewport: v.viewport, 'match %': v.match, worst: v.worst.map((s) => `#${s.i} y${s.y} ${s.match}%`).join(' ') })));
|
|
140
|
+
console.table(report.viewports.map((v) => ({ viewport: v.viewport, 'match %': v.match, 'canvas %': v.canvas ?? '-', worst: v.worst.map((s) => `#${s.i} y${s.y} ${s.match}%`).join(' ') })));
|
|
141
|
+
for (const v of report.viewports) if (v.canvasNote) console.log(`${v.viewport}: ${v.canvasNote}`);
|
|
109
142
|
} else {
|
|
110
143
|
console.table(report.viewports);
|
|
111
144
|
}
|
package/scripts/tiles.mjs
CHANGED
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
// Cut a tall full-page screenshot into viewport-sized tiles you can actually read.
|
|
2
|
-
// usage: node scripts/tiles.mjs <
|
|
2
|
+
// usage: node scripts/tiles.mjs <name> [--size desktop] [--clone] -> sites/<name>/reference/tiles/<size>-01.png ...
|
|
3
|
+
// node scripts/tiles.mjs <png> [tileHeight=1200] -> <png-dir>/tiles/<png-name>-01.png ...
|
|
3
4
|
import fs from 'node:fs/promises';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { PNG } from 'pngjs';
|
|
7
|
+
import { SCORED, parseArgs } from './lib.mjs';
|
|
6
8
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
10
|
+
const [target, h] = pos;
|
|
11
|
+
if (!target) {
|
|
12
|
+
console.error('usage: trueclone tiles <name> [--size desktop|tablet|mobile|laptop|wide] [--clone] or trueclone tiles <file.png> [tileHeight]');
|
|
10
13
|
process.exit(1);
|
|
11
14
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
await fs.
|
|
15
|
+
let files;
|
|
16
|
+
let tileH = Number(h || 1200);
|
|
17
|
+
if (/\.png$/i.test(target)) files = [path.resolve(target)];
|
|
18
|
+
else {
|
|
19
|
+
// a site name: the reference's full-page shots (or the clone's last layout shots with --clone)
|
|
20
|
+
const root = path.resolve('sites', target);
|
|
21
|
+
const sizes = flags.size ? [flags.size] : SCORED.map((v) => v.name);
|
|
22
|
+
files = sizes.map((s) => (flags.clone ? path.join(root, 'compare', `${s}.mask.clone.png`) : path.join(root, 'reference', `${s}.png`)));
|
|
23
|
+
if (!h) tileH = 0; // one tile per screen of that size
|
|
24
|
+
}
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
const src = await fs.readFile(file).then((b) => PNG.sync.read(b), () => null);
|
|
27
|
+
if (!src) { console.log(`skipped (not found): ${path.relative(process.cwd(), file)}`); continue; }
|
|
28
|
+
const size = SCORED.find((v) => path.basename(file).startsWith(`${v.name}.`));
|
|
29
|
+
const th = tileH || size?.height || 1200;
|
|
30
|
+
const dir = path.join(path.dirname(file), 'tiles');
|
|
31
|
+
await fs.mkdir(dir, { recursive: true });
|
|
32
|
+
const base = path.basename(file, '.png');
|
|
33
|
+
let n = 0;
|
|
34
|
+
for (let y = 0; y < src.height; y += th) {
|
|
35
|
+
const hh = Math.min(th, src.height - y);
|
|
36
|
+
const out = new PNG({ width: src.width, height: hh });
|
|
37
|
+
PNG.bitblt(src, out, 0, y, src.width, hh, 0, 0);
|
|
38
|
+
await fs.writeFile(path.join(dir, `${base}-${String(++n).padStart(2, '0')}.png`), PNG.sync.write(out));
|
|
39
|
+
}
|
|
40
|
+
console.log(`${n} tiles of ${th}px -> ${path.relative(process.cwd(), dir)} (${base}-NN.png)`);
|
|
24
41
|
}
|
|
25
|
-
console.log(`${n} tiles -> ${dir}`);
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Code-level evidence about the reference, with REA (Reverse Engineer Anything, bundled) doing the reading and
|
|
2
|
+
// trueclone owning the browser: which scripts actually run while a visitor scrolls and hovers (V8 coverage), which
|
|
3
|
+
// handler each interactive element has and where its code is (CDP listeners, with the source around it), which
|
|
4
|
+
// endpoints exist in the code (fired during the capture or not), original sources when the site publishes source
|
|
5
|
+
// maps. Written to reference/code.md (for the agent) and reference/code.json. The capture already knows what the
|
|
6
|
+
// page looks like and does; this says which code does it, so script.js copies rules instead of guessing them.
|
|
7
|
+
//
|
|
8
|
+
// usage: node scripts/understand.mjs <name> [--elements 40] [--observe-ms 20000]
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import net from 'node:net';
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { chromium } from 'playwright';
|
|
15
|
+
import { VIEWPORTS, parseArgs, newPage, settle, wheelTo, withTimeout } 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 understand <name> [--elements 40] [--observe-ms 20000]');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const root = path.resolve('sites', name);
|
|
24
|
+
const ref = path.join(root, 'reference');
|
|
25
|
+
const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
|
|
26
|
+
const MAX_ELEMENTS = Number(flags.elements || 40);
|
|
27
|
+
const OBSERVE_MS = Number(flags['observe-ms'] || 20000);
|
|
28
|
+
const vp = VIEWPORTS.find((v) => v.name === 'desktop');
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const REA = path.join(path.dirname(require.resolve('rea-agents/package.json')), 'scripts', 'rea.mjs');
|
|
31
|
+
const reaVersion = JSON.parse(await fs.readFile(require.resolve('rea-agents/package.json'), 'utf8')).version;
|
|
32
|
+
|
|
33
|
+
// REA attaches to a loopback CDP endpoint it does not own: trueclone launches Chrome with a debugging port
|
|
34
|
+
const freePort = () => new Promise((resolve) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });
|
|
35
|
+
const port = await freePort();
|
|
36
|
+
const endpoint = `http://127.0.0.1:${port}`;
|
|
37
|
+
const launch = async () => {
|
|
38
|
+
const args = [`--remote-debugging-port=${port}`];
|
|
39
|
+
if (process.env.LAB_BROWSER !== 'chromium') { try { return await chromium.launch({ channel: 'chrome', args }); } catch { /* no Chrome */ } }
|
|
40
|
+
return chromium.launch({ args });
|
|
41
|
+
};
|
|
42
|
+
const rea = (args, { timeout = 90000, onProgress } = {}) => new Promise((resolve) => {
|
|
43
|
+
const child = spawn(process.execPath, [REA, ...args, '--format', 'json'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
44
|
+
let out = '', err = '';
|
|
45
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
46
|
+
child.stderr.on('data', (d) => { err += d; if (onProgress) for (const line of String(d).split('\n')) if (line.includes('rea_progress')) onProgress(line); });
|
|
47
|
+
const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } }, timeout);
|
|
48
|
+
child.on('close', (code) => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
let json = null;
|
|
51
|
+
try { json = JSON.parse(out); } catch { /* not JSON: an error */ }
|
|
52
|
+
const errText = err.split('\n').filter((l) => l && !l.includes('rea_progress')).join('\n').slice(0, 600);
|
|
53
|
+
resolve({ code, json, result: json?.normalized_result || null, err: errText || (json ? '' : `exit ${code}: ${out.slice(0, 400)}`) });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// origins whose scripts count: the page's own and every origin that served a script during the capture
|
|
58
|
+
const har = await fs.readFile(path.join(ref, 'desktop.har'), 'utf8').then(JSON.parse).catch(() => null);
|
|
59
|
+
const harEntries = har?.log?.entries || [];
|
|
60
|
+
const origins = new Set([new URL(meta.url).origin]);
|
|
61
|
+
for (const e of harEntries) if (e._resourceType === 'script') { try { origins.add(new URL(e.request.url).origin); } catch { /* data: */ } }
|
|
62
|
+
const originArgs = [...origins].flatMap((o) => ['--allowed-origins', o]); // one flag per origin
|
|
63
|
+
const fired = new Set(harEntries.map((e) => e.request.url.split('?')[0]));
|
|
64
|
+
|
|
65
|
+
const browser = await launch();
|
|
66
|
+
const out = { name, url: meta.url, at: new Date().toISOString(), rea: reaVersion, scripts: [], handlers: [], endpoints: [], sourceMaps: [], unknowns: [], notes: [] };
|
|
67
|
+
try {
|
|
68
|
+
const { page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
|
|
69
|
+
await page.goto(meta.url, { waitUntil: 'load', timeout: 60000 });
|
|
70
|
+
await settle(page, meta.hide || []);
|
|
71
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
72
|
+
await page.waitForTimeout(500);
|
|
73
|
+
|
|
74
|
+
const targets = await rea(['list-browser-targets', endpoint, ...originArgs]);
|
|
75
|
+
const target = (targets.result?.targets || []).find((t) => (t.url || '').startsWith(meta.url.replace(/\/$/, ''))) || (targets.result?.targets || [])[0];
|
|
76
|
+
if (!target) throw new Error(`REA found no page target at ${endpoint}: ${targets.err || 'no output'}`);
|
|
77
|
+
const tid = target.target_id;
|
|
78
|
+
|
|
79
|
+
// 1. the bundle: scripts, chunk edges, endpoints and routes in the code, source maps when published
|
|
80
|
+
const bundle = await rea(['analyze-web-bundle', endpoint, tid, '--fetch-source-maps', '--observation-ms', '1500', ...originArgs], { timeout: 180000 });
|
|
81
|
+
const obs = bundle.result?.observations || {};
|
|
82
|
+
const nodes = obs.chunks?.nodes || [];
|
|
83
|
+
const byKey = new Map(nodes.map((n) => [n.script_key, n]));
|
|
84
|
+
// an endpoint is a URL or a path; a `.get("EXT_blend_minmax")` on a WebGL context is not one
|
|
85
|
+
const TRACKING = /google-analytics|googletagmanager|doubleclick|facebook\.(com|net)|hotjar|segment\.(io|com)|plausible|cloudflareinsights|clarity\.ms|sentry|datadog|mixpanel|amplitude|posthog|intercom|hubspot|challenges\.cloudflare|recaptcha|hcaptcha|turnstile/i;
|
|
86
|
+
const urlish = (v) => /^(https?:)?\/\/[^\s]+$/.test(v) || /^\.{0,2}\/[\w./-]+/.test(v) || /^wss?:\/\//.test(v);
|
|
87
|
+
out.endpoints = (obs.endpoints || []).filter((e) => typeof e.value === 'string' && urlish(e.value.trim()) && !/^(call:.*\.get|call:.*\.request)$/.test(e.mechanism || '') || /fetch|xhr|websocket|axios|beacon/i.test(e.mechanism || '') && urlish(String(e.value)))
|
|
88
|
+
.map((e) => ({ value: e.value, mechanism: e.mechanism, script: byKey.get(e.location?.script_key)?.url || e.location?.script_key, line: e.location?.line, fired: fired.has(String(e.value).split('?')[0]), tracking: TRACKING.test(e.value) }));
|
|
89
|
+
out.routes = (obs.routes || []).map((r) => r.value || r.path || JSON.stringify(r)).slice(0, 40);
|
|
90
|
+
out.sourceMaps = (obs.source_maps?.items || []).map((m) => ({ script: byKey.get(m.script_key)?.url || m.script_key, status: m.status, sources: (m.sources || m.original_sources || []).slice(0, 20) }));
|
|
91
|
+
out.unknowns.push(...(bundle.result?.unknowns || []).map((u) => `${u.dimension}: ${u.reason}`));
|
|
92
|
+
if (!bundle.result) out.notes.push(`analyze-web-bundle gave no result: ${bundle.err}`);
|
|
93
|
+
|
|
94
|
+
// 2. what ran: coverage while trueclone scrolls through the page and hovers what a visitor hovers
|
|
95
|
+
const interactive = await page.evaluate((max) => {
|
|
96
|
+
const SEL = 'a[href], button, [role="button"], [role="tab"], summary, input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
97
|
+
const uniq = (el) => {
|
|
98
|
+
if (el.id && document.querySelectorAll(`#${CSS.escape(el.id)}`).length === 1) return `#${CSS.escape(el.id)}`;
|
|
99
|
+
const parts = [];
|
|
100
|
+
for (let n = el; n && n !== document.body; n = n.parentElement) {
|
|
101
|
+
const i = [...n.parentElement.children].indexOf(n) + 1;
|
|
102
|
+
parts.unshift(`${n.tagName.toLowerCase()}:nth-child(${i})`);
|
|
103
|
+
}
|
|
104
|
+
return `body > ${parts.join(' > ')}`;
|
|
105
|
+
};
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const list = [];
|
|
108
|
+
// a plain link navigates; it only has behaviour when it is wired to something (a hash, no real href, aria/data hooks)
|
|
109
|
+
const behaves = (el) => el.tagName !== 'A' || /^(#|javascript:|$)/.test((el.getAttribute('href') || '').trim()) || [...el.attributes].some((a) => /^(data-|aria-controls|aria-expanded|role)/.test(a.name));
|
|
110
|
+
for (const el of document.querySelectorAll(SEL)) {
|
|
111
|
+
const r = el.getBoundingClientRect();
|
|
112
|
+
if (r.width < 4 || r.height < 4 || r.top + scrollY < 0 || !el.checkVisibility?.() || !behaves(el)) continue;
|
|
113
|
+
const sig = `${el.tagName}.${el.className}`;
|
|
114
|
+
if (seen.has(sig) && list.length > max / 2) continue; // repeated rows share handlers: a few of each is enough
|
|
115
|
+
seen.add(sig);
|
|
116
|
+
list.push({ selector: uniq(el), label: `${el.tagName.toLowerCase()}${el.className ? `.${String(el.className).trim().split(/\s+/).slice(0, 2).join('.')}` : ''}`, text: (el.textContent || '').trim().slice(0, 40), x: Math.round(r.left + r.width / 2), y: Math.round(r.top + scrollY + r.height / 2) });
|
|
117
|
+
if (list.length >= max) break;
|
|
118
|
+
}
|
|
119
|
+
// delegated handlers live on the roots: a click on any row is handled here
|
|
120
|
+
for (const sel of ['body', 'html']) list.push({ selector: sel, label: sel, text: '', x: 2, y: 2, root: true });
|
|
121
|
+
return list;
|
|
122
|
+
}, MAX_ELEMENTS);
|
|
123
|
+
|
|
124
|
+
let armed = false;
|
|
125
|
+
const observing = rea(['observe-web-execution', endpoint, tid, '--observation-ms', String(OBSERVE_MS), ...originArgs], { timeout: OBSERVE_MS + 60000, onProgress: (l) => { if (l.includes('armed')) armed = true; } });
|
|
126
|
+
for (let i = 0; i < 50 && !armed; i++) await page.waitForTimeout(100);
|
|
127
|
+
const t0 = Date.now();
|
|
128
|
+
const max = await page.evaluate(() => document.documentElement.scrollHeight - innerHeight);
|
|
129
|
+
const step = Math.round(vp.height / 2);
|
|
130
|
+
for (let y = step; y <= max && Date.now() - t0 < OBSERVE_MS * 0.6; y += step) { await wheelTo(page, y, vp); await page.waitForTimeout(150); }
|
|
131
|
+
for (const el of interactive.filter((e) => !e.root).slice(0, 12)) {
|
|
132
|
+
if (Date.now() - t0 > OBSERVE_MS * 0.9) break;
|
|
133
|
+
await page.evaluate((y) => window.scrollTo(0, Math.max(0, y - innerHeight / 2)), el.y);
|
|
134
|
+
await page.waitForTimeout(120);
|
|
135
|
+
const vy = el.y - (await page.evaluate(() => scrollY));
|
|
136
|
+
await page.mouse.move(el.x, vy, { steps: 4 }).catch(() => {});
|
|
137
|
+
await page.waitForTimeout(250);
|
|
138
|
+
}
|
|
139
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
140
|
+
const exec = await observing;
|
|
141
|
+
const cov = exec.result?.coverage || {};
|
|
142
|
+
const sourceText = new Map();
|
|
143
|
+
for (const s of exec.result?.sources || []) if (s.source?.text) sourceText.set(String(s.script_id), { url: s.url, text: s.source.text });
|
|
144
|
+
// scripts without a URL are the page's inline <script> blocks, or the automation's own helpers: only the former count
|
|
145
|
+
const rendered = await fs.readFile(path.join(ref, 'rendered.html'), 'utf8').catch(() => '');
|
|
146
|
+
const inlineIndex = new Map();
|
|
147
|
+
const labelOf = (s) => {
|
|
148
|
+
if (s.reported_url) return s.reported_url;
|
|
149
|
+
const text = sourceText.get(String(s.script_id))?.text || '';
|
|
150
|
+
const head = text.trim().slice(0, 80);
|
|
151
|
+
if (!head || !rendered.includes(head)) return null;
|
|
152
|
+
if (!inlineIndex.has(head)) inlineIndex.set(head, inlineIndex.size + 1);
|
|
153
|
+
return `inline <script> #${inlineIndex.get(head)} ("${head.slice(0, 40).replace(/\s+/g, ' ')}…")`;
|
|
154
|
+
};
|
|
155
|
+
out.scripts = (cov.scripts || []).map((s) => {
|
|
156
|
+
const url = labelOf(s);
|
|
157
|
+
if (!url) return null;
|
|
158
|
+
const fns = s.functions || [];
|
|
159
|
+
const ran = fns.filter((f) => f.ranges?.some((r) => r.count > 0)).map((f) => f.name).filter(Boolean);
|
|
160
|
+
return { url, functionsRan: ran.length, functions: fns.length, bytes: sourceText.get(String(s.script_id))?.text.length || null, ranNames: ran.slice(0, 12) };
|
|
161
|
+
}).filter(Boolean).sort((a, b) => b.functionsRan - a.functionsRan);
|
|
162
|
+
if (!exec.result) out.notes.push(`observe-web-execution gave no result: ${exec.err}`);
|
|
163
|
+
|
|
164
|
+
// 3. handlers: the listener on each interactive element and the code around it
|
|
165
|
+
// readable source: lines around the handler; in a minified one-line bundle, a window of characters around the column
|
|
166
|
+
const excerpt = (text, line, column = 0, before = 3, after = 22) => {
|
|
167
|
+
const lines = text.split('\n');
|
|
168
|
+
const at = lines[line] || '';
|
|
169
|
+
if (at.length > 400) {
|
|
170
|
+
const from = Math.max(0, column - 500), to = Math.min(at.length, column + 900);
|
|
171
|
+
return `${line + 1}:${column} (minified, ${at.length} chars on this line; ${from}-${to} shown)\n${at.slice(from, to).replace(/;/g, ';\n')}`;
|
|
172
|
+
}
|
|
173
|
+
const from = Math.max(0, line - before);
|
|
174
|
+
return lines.slice(from, Math.min(lines.length, line + after)).map((l, i) => `${String(from + i + 1).padStart(5)} ${l.length > 160 ? `${l.slice(0, 160)}…` : l}`).join('\n');
|
|
175
|
+
};
|
|
176
|
+
const seenLoc = new Map();
|
|
177
|
+
for (const el of interactive) {
|
|
178
|
+
const r = await rea(['inspect-web-event-listeners', endpoint, tid, el.selector, ...originArgs], { timeout: 60000 });
|
|
179
|
+
const listeners = r.result?.listeners || [];
|
|
180
|
+
for (const s of r.result?.sources || []) if (s.source?.text && !sourceText.has(String(s.script_id))) sourceText.set(String(s.script_id), { url: s.url, text: s.source.text });
|
|
181
|
+
const entry = { selector: el.selector, label: el.label, text: el.text, y: el.y, listeners: [] };
|
|
182
|
+
for (const l of listeners) {
|
|
183
|
+
const loc = l.location || {};
|
|
184
|
+
const key = `${loc.url}:${loc.line_number}:${loc.column_number}`;
|
|
185
|
+
const src = sourceText.get(String(loc.script_id));
|
|
186
|
+
const listener = { type: l.type, url: loc.url, line: (loc.line_number ?? 0) + 1, column: loc.column_number, fn: loc.function_name || null, sharedWith: seenLoc.get(key) || null, code: !seenLoc.has(key) && src ? excerpt(src.text, loc.line_number ?? 0, loc.column_number ?? 0) : null };
|
|
187
|
+
if (!seenLoc.has(key)) seenLoc.set(key, el.label);
|
|
188
|
+
entry.listeners.push(listener);
|
|
189
|
+
}
|
|
190
|
+
if (!listeners.length && r.result?.selected_node?.match === 'found') entry.note = 'no direct listener: delegated (an ancestor, a framework root) or CSS-only';
|
|
191
|
+
if (!r.result) entry.note = `not inspected: ${r.err.split('\n')[0] || 'no output'}`;
|
|
192
|
+
out.handlers.push(entry);
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
await browser.close();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// code.md
|
|
199
|
+
const md = [];
|
|
200
|
+
md.push(`# ${name}: code evidence`, '', `Reference ${meta.url}, read ${out.at} with REA ${out.rea} through \`trueclone understand\` (trueclone owned the browser; REA observed, never clicked or evaluated). Observed facts only; what REA could not see is under Unknowns.`, '');
|
|
201
|
+
md.push('## Scripts, by how much of them ran while scrolling the page and hovering its controls', '', '| script | functions that ran | of | names |', '|---|---|---|---|');
|
|
202
|
+
for (const s of out.scripts) md.push(`| ${s.url} | ${s.functionsRan} | ${s.functions} | ${s.ranNames.join(', ')} |`);
|
|
203
|
+
if (!out.scripts.length) md.push('| (no coverage captured) | | | |');
|
|
204
|
+
md.push('', 'A script with nothing that ran is not needed for what the page does on a scroll-through; start from the ones at the top.', '');
|
|
205
|
+
md.push('## Handlers on interactive elements', '', 'What `script.js` has to reproduce, with the original code around each handler (read the rule, write your own). Elements that share one handler are listed once.', '');
|
|
206
|
+
for (const h of out.handlers) {
|
|
207
|
+
const direct = h.listeners.filter((l) => !l.sharedWith);
|
|
208
|
+
const shared = h.listeners.filter((l) => l.sharedWith);
|
|
209
|
+
if (!h.listeners.length && !h.note) continue;
|
|
210
|
+
md.push(`### \`${h.label}\`${h.text ? ` "${h.text}"` : ''} at y ${h.y}`, '', `selector: \`${h.selector}\``);
|
|
211
|
+
if (h.note) md.push(`- ${h.note}`);
|
|
212
|
+
for (const l of shared) md.push(`- ${l.type} → ${l.url}:${l.line} (same handler as \`${l.sharedWith}\`)`);
|
|
213
|
+
for (const l of direct) {
|
|
214
|
+
md.push(`- ${l.type} → ${l.url}:${l.line}${l.fn ? ` (${l.fn})` : ''}`);
|
|
215
|
+
if (l.code) md.push('', '```js', l.code, '```');
|
|
216
|
+
}
|
|
217
|
+
md.push('');
|
|
218
|
+
}
|
|
219
|
+
md.push('## Endpoints in the code', '', '| endpoint | how | where | fired during the capture |', '|---|---|---|---|');
|
|
220
|
+
for (const e of out.endpoints) md.push(`| ${e.value} | ${e.mechanism}${e.tracking ? ' (tracking: never copied)' : ''} | ${e.script}:${e.line ?? '?'} | ${e.fired ? 'yes (response in desktop.har)' : 'no: a form, a later state or a condition the capture did not reach'} |`);
|
|
221
|
+
if (!out.endpoints.length) md.push('| (none found in the scripts) | | | |');
|
|
222
|
+
if (out.routes?.length) md.push('', `Routes in the code: ${out.routes.join(', ')}`);
|
|
223
|
+
md.push('', '## Source maps', '');
|
|
224
|
+
md.push(out.sourceMaps.length ? out.sourceMaps.map((m) => `- ${m.script}: ${m.status}${m.sources.length ? `; originals: ${m.sources.join(', ')}` : ''}`).join('\n') : '- none published: the scripts are read as shipped (minified where minified)');
|
|
225
|
+
md.push('', '## Unknowns', '');
|
|
226
|
+
md.push([...out.unknowns, ...out.notes].length ? [...out.unknowns, ...out.notes].map((u) => `- ${u}`).join('\n') : '- none reported');
|
|
227
|
+
md.push('', 'Deeper, by hand: `npx trueclone rea <command>` (REA\'s own CLI; `npx trueclone rea --help`).', '');
|
|
228
|
+
await fs.writeFile(path.join(ref, 'code.md'), md.join('\n'));
|
|
229
|
+
await fs.writeFile(path.join(ref, 'code.json'), JSON.stringify(out, null, 2));
|
|
230
|
+
console.log(`understand: ${out.scripts.length} script(s) with coverage (${out.scripts.filter((s) => s.functionsRan).length} ran), ${out.handlers.filter((h) => h.listeners.length).length}/${out.handlers.length} elements with a direct handler, ${out.endpoints.length} endpoint(s) in code (${out.endpoints.filter((e) => e.fired).length} fired), ${out.sourceMaps.length} source map(s)`);
|
|
231
|
+
console.log(`-> ${path.relative(process.cwd(), path.join(ref, 'code.md'))}`);
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Inline styles a script set, read at many screen sizes (capture), turned into what the clone may carry:
|
|
2
|
+
// - a value that is the same at every size stays as it is;
|
|
3
|
+
// - a value that is a function of the viewport (a·width + b·height + c, in px) becomes that function as a CSS calc(),
|
|
4
|
+
// for all sizes when one function fits them all, or per range of the site's own breakpoints;
|
|
5
|
+
// - a value that fits no such function (it depends on content, time, a container, randomness) is NOT carried: the
|
|
6
|
+
// clone's script must compute it, the way the site does. It is listed as script-owned.
|
|
7
|
+
// Nothing here is ever exact at the captured sizes only: that is the point.
|
|
8
|
+
|
|
9
|
+
const MODELS = [
|
|
10
|
+
{ name: 'c', cols: () => [1] },
|
|
11
|
+
{ name: 'w', cols: (w) => [w] },
|
|
12
|
+
{ name: 'h', cols: (w, h) => [h] },
|
|
13
|
+
{ name: 'w+c', cols: (w) => [w, 1] },
|
|
14
|
+
{ name: 'h+c', cols: (w, h) => [h, 1] },
|
|
15
|
+
{ name: 'w+h', cols: (w, h) => [w, h] },
|
|
16
|
+
{ name: 'w+h+c', cols: (w, h) => [w, h, 1] },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function solve(A, b) {
|
|
20
|
+
const n = b.length;
|
|
21
|
+
const M = A.map((row, i) => [...row, b[i]]);
|
|
22
|
+
for (let c = 0; c < n; c++) {
|
|
23
|
+
let p = c;
|
|
24
|
+
for (let r = c + 1; r < n; r++) if (Math.abs(M[r][c]) > Math.abs(M[p][c])) p = r;
|
|
25
|
+
if (Math.abs(M[p][c]) < 1e-9) return null;
|
|
26
|
+
[M[c], M[p]] = [M[p], M[c]];
|
|
27
|
+
for (let r = 0; r < n; r++) {
|
|
28
|
+
if (r === c) continue;
|
|
29
|
+
const f = M[r][c] / M[c][c];
|
|
30
|
+
for (let k = c; k <= n; k++) M[r][k] -= f * M[c][k];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return M.map((row, i) => row[n] / row[i]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// points: [{ w, h, v }] -> { model, coef } or null. A model needs one point more than it has parameters, so every fit
|
|
37
|
+
// is checked on a point it was not determined by.
|
|
38
|
+
export function fitPoints(points, shaped = true) {
|
|
39
|
+
for (const m of MODELS) {
|
|
40
|
+
const k = m.cols(1, 1).length;
|
|
41
|
+
if (points.length < k + 1) continue;
|
|
42
|
+
const X = points.map((p) => m.cols(p.w, p.h));
|
|
43
|
+
const A = Array.from({ length: k }, () => new Array(k).fill(0));
|
|
44
|
+
const b = new Array(k).fill(0);
|
|
45
|
+
points.forEach((p, i) => { for (let r = 0; r < k; r++) { b[r] += X[i][r] * p.v; for (let c = 0; c < k; c++) A[r][c] += X[i][r] * X[i][c]; } });
|
|
46
|
+
const coef = solve(A, b);
|
|
47
|
+
if (!coef) continue;
|
|
48
|
+
const tol = Math.max(0.75, 0.003 * Math.max(...points.map((p) => Math.abs(p.v))));
|
|
49
|
+
if (points.every((p, i) => Math.abs(X[i].reduce((s, x, j) => s + x * coef[j], 0) - p.v) <= tol)) return { model: m.name, coef };
|
|
50
|
+
}
|
|
51
|
+
return shaped ? fitTwoLines(points) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The smaller or larger of two lines: min(L1, L2) / max(L1, L2), each L = a·w + b·h + c. This is what a script
|
|
55
|
+
// computes for "cover" (max(100vw, 177.8vh)), "contain", a cover image centred (min((w - 1.778·h) / 2, 0)), a tile
|
|
56
|
+
// of it, a size clamped at a limit. Which line applies switches at an aspect ratio, a width or a height: the points
|
|
57
|
+
// are split on each of those in turn, each side must fit one line on its own (checked as usual), and then every point
|
|
58
|
+
// must be the min (or max) of both lines. Eight points at least, so two short lines never fit noise.
|
|
59
|
+
function fitTwoLines(points) {
|
|
60
|
+
if (points.length < 8) return null;
|
|
61
|
+
const tol = Math.max(0.75, 0.003 * Math.max(...points.map((p) => Math.abs(p.v))));
|
|
62
|
+
for (const key of [(p) => p.w / p.h, (p) => p.w, (p) => p.h]) {
|
|
63
|
+
const sorted = [...points].sort((p, q) => key(p) - key(q));
|
|
64
|
+
for (let s = 2; s <= sorted.length - 2; s++) {
|
|
65
|
+
if (key(sorted[s - 1]) === key(sorted[s])) continue;
|
|
66
|
+
const lo = fitPoints(sorted.slice(0, s), false), hi = lo && fitPoints(sorted.slice(s), false);
|
|
67
|
+
if (!lo || !hi) continue;
|
|
68
|
+
for (const model of ['min', 'max']) {
|
|
69
|
+
const f = model === 'min' ? Math.min : Math.max;
|
|
70
|
+
if (points.every((p) => Math.abs(f(lineAt(lo, p.w, p.h), lineAt(hi, p.w, p.h)) - p.v) <= tol)) return { model, lines: [lo, hi] };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const lineAt = ({ model, coef }, w, h) => {
|
|
77
|
+
let i = 0, v = 0;
|
|
78
|
+
for (const t of model.split('+')) v += coef[i++] * (t === 'w' ? w : t === 'h' ? h : 1);
|
|
79
|
+
return v;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const r4 = (x) => +x.toFixed(4);
|
|
83
|
+
// a fitted px value as CSS: width terms use --tc-vw (1% of the layout width, scrollbar excluded, set by the seed)
|
|
84
|
+
export function cssOf({ model, coef, lines }) {
|
|
85
|
+
if (lines) return `${model}(${lines.map((l) => cssOf(l).replace(/^calc\((.*)\)$/, '$1')).join(', ')})`;
|
|
86
|
+
const parts = model.split('+');
|
|
87
|
+
let a = 0, b = 0, c = 0;
|
|
88
|
+
parts.forEach((p, i) => { if (p === 'w') a = coef[i]; else if (p === 'h') b = coef[i]; else c = coef[i]; });
|
|
89
|
+
const terms = [];
|
|
90
|
+
if (Math.abs(a) > 1e-6) terms.push([a * 100, (x) => `${r4(Math.abs(x))} * var(--tc-vw, 1vw)`]);
|
|
91
|
+
if (Math.abs(b) > 1e-6) terms.push([b * 100, (x) => `${r4(Math.abs(x))}vh`]);
|
|
92
|
+
if (Math.abs(c) > 0.005 || !terms.length) terms.push([c, (x) => `${+Math.abs(x).toFixed(2)}px`]);
|
|
93
|
+
if (terms.length === 1 && model === 'c') return `${+c.toFixed(2)}px`;
|
|
94
|
+
return `calc(${terms.map(([x, f], i) => `${i === 0 ? (x < 0 ? '-1 * ' : '') : x < 0 ? '- ' : '+ '}${f(x)}`).join(' ')})`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const PX = /-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?px/gi; // 5.68434e-14px is a number too (a script's float noise)
|
|
98
|
+
// one spelling per meaning, so a library that writes translate3d(0px, 0px, 0px) at one size and translate(0px, 0px)
|
|
99
|
+
// at another is not mistaken for a value that changes
|
|
100
|
+
const normalise = (v) => v
|
|
101
|
+
.replace(/translate3d\(\s*([^,()]+),\s*([^,()]+),\s*0(?:px)?\s*\)/g, 'translate($1, $2)')
|
|
102
|
+
.replace(/translateX\(\s*([^,()]+)\)/g, 'translate($1, 0px)')
|
|
103
|
+
.replace(/translateY\(\s*([^,()]+)\)/g, 'translate(0px, $1)')
|
|
104
|
+
.replace(/translate\(\s*([^,()]+)\)/g, 'translate($1, 0px)')
|
|
105
|
+
.replace(/(^|[\s(,])0(?=[\s),]|$)/g, '$10px');
|
|
106
|
+
const parseDecls = (text) => {
|
|
107
|
+
const map = new Map();
|
|
108
|
+
for (const d of String(text || '').split(/;(?![^(]*\))/)) {
|
|
109
|
+
const i = d.indexOf(':');
|
|
110
|
+
if (i < 0) continue;
|
|
111
|
+
const k = d.slice(0, i).trim(), v = d.slice(i + 1).trim().replace(/\s*!important$/i, '');
|
|
112
|
+
if (k) map.set(k, /transform|translate/.test(k) ? normalise(v) : v);
|
|
113
|
+
}
|
|
114
|
+
return map;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// values: [{ w, h, v: string }] -> CSS value string, or null when no viewport function fits
|
|
118
|
+
function fitValue(values) {
|
|
119
|
+
const tmpl = values[0].v.replace(PX, '§');
|
|
120
|
+
if (values.some((x) => x.v.replace(PX, '§') !== tmpl)) return null; // keywords or unitless numbers change: not a function of size
|
|
121
|
+
if (values.every((x) => x.v === values[0].v)) return values[0].v;
|
|
122
|
+
const nums = values.map((x) => (x.v.match(PX) || []).map(parseFloat));
|
|
123
|
+
const out = [];
|
|
124
|
+
for (let t = 0; t < nums[0].length; t++) {
|
|
125
|
+
const fit = fitPoints(values.map((x, i) => ({ w: x.w, h: x.h, v: nums[i][t] })));
|
|
126
|
+
if (!fit) return null;
|
|
127
|
+
out.push(cssOf(fit));
|
|
128
|
+
}
|
|
129
|
+
let k = 0;
|
|
130
|
+
return tmpl.replace(/§/g, () => out[k++]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// data: inline-styles.json ({ breakpoints, sizes, styles: { "WxH": { w, h, styles: { path: [style, class] } } } }), or
|
|
134
|
+
// the 0.3 shape ({ desktop: { path: style }, ... }). Returns, per element path: the declarations to set inline, the
|
|
135
|
+
// per-range rules, and the properties left to the clone's script.
|
|
136
|
+
export function planInlineStyles(data, viewports) {
|
|
137
|
+
let breakpoints = [], sizes = [];
|
|
138
|
+
if (data?.styles) {
|
|
139
|
+
breakpoints = data.breakpoints || [];
|
|
140
|
+
sizes = Object.values(data.styles).map((s) => ({ w: s.w, h: s.h, styles: Object.fromEntries(Object.entries(s.styles).map(([p, v]) => [p, Array.isArray(v) ? v[0] : v])) }));
|
|
141
|
+
} else if (data) {
|
|
142
|
+
sizes = Object.entries(data).map(([name, styles]) => { const vp = viewports.find((v) => v.name === name); return vp ? { w: vp.width, h: vp.height, styles } : null; }).filter(Boolean);
|
|
143
|
+
}
|
|
144
|
+
const edges = [0, ...breakpoints, 100000];
|
|
145
|
+
const ranges = edges.slice(0, -1).map((lo, i) => ({ lo: i === 0 ? 0 : lo + 1, hi: edges[i + 1] }));
|
|
146
|
+
const mediaOf = (r) => (r.lo === 0 ? `(max-width: ${r.hi}px)` : r.hi >= 100000 ? `(min-width: ${r.lo}px)` : `(min-width: ${r.lo}px) and (max-width: ${r.hi}px)`);
|
|
147
|
+
const plan = {};
|
|
148
|
+
const owned = [];
|
|
149
|
+
let fitted = 0, constant = 0;
|
|
150
|
+
const paths = new Set(sizes.flatMap((s) => Object.keys(s.styles)));
|
|
151
|
+
for (const p of paths) {
|
|
152
|
+
const per = sizes.map((s) => ({ w: s.w, h: s.h, d: parseDecls(s.styles[p]) }));
|
|
153
|
+
const props = new Set(per.flatMap((x) => [...x.d.keys()]));
|
|
154
|
+
const entry = { inline: {}, rules: [], dropped: [] };
|
|
155
|
+
for (const k of props) {
|
|
156
|
+
const vals = per.map((x) => ({ w: x.w, h: x.h, v: x.d.get(k) }));
|
|
157
|
+
if (vals.every((x) => x.v !== undefined && x.v === vals[0].v)) { constant++; continue; } // same everywhere: as captured
|
|
158
|
+
// one function for every size
|
|
159
|
+
if (vals.every((x) => x.v !== undefined)) {
|
|
160
|
+
const css = fitValue(vals);
|
|
161
|
+
if (css !== null) { entry.inline[k] = css; fitted++; continue; }
|
|
162
|
+
}
|
|
163
|
+
// per range of the site's breakpoints. Neighbouring ranges one function fits are one group (fewer rules, more
|
|
164
|
+
// points behind each fit); a range no captured size fell in joins its neighbour rather than losing the value.
|
|
165
|
+
const joins = (a, b) => {
|
|
166
|
+
if (!a.length || !b.length) return true;
|
|
167
|
+
const all = [...a, ...b];
|
|
168
|
+
const unset = all.filter((x) => x.v === undefined).length;
|
|
169
|
+
return unset === all.length || (!unset && fitValue(all) !== null);
|
|
170
|
+
};
|
|
171
|
+
const groups = [];
|
|
172
|
+
for (const r of ranges.length > 1 ? ranges : []) {
|
|
173
|
+
const inR = vals.filter((x) => x.w >= r.lo && x.w <= r.hi);
|
|
174
|
+
const last = groups[groups.length - 1];
|
|
175
|
+
if (last && joins(last.pts, inR)) { last.hi = r.hi; last.pts.push(...inR); } else groups.push({ lo: r.lo, hi: r.hi, pts: [...inR] });
|
|
176
|
+
}
|
|
177
|
+
let anyDrop = !groups.length;
|
|
178
|
+
const rules = [];
|
|
179
|
+
for (const g of groups) {
|
|
180
|
+
if (g.pts.every((x) => x.v === undefined)) continue; // the script doesn't set it at these sizes
|
|
181
|
+
// a fit needs a point it was not determined by; a group with unset and set sizes is not a function of size
|
|
182
|
+
if (g.pts.length < 2 || g.pts.some((x) => x.v === undefined)) { anyDrop = true; continue; }
|
|
183
|
+
const css = fitValue(g.pts);
|
|
184
|
+
if (css === null) { anyDrop = true; continue; }
|
|
185
|
+
rules.push({ media: mediaOf(g), prop: k, value: css });
|
|
186
|
+
}
|
|
187
|
+
// all or nothing: a property the clone's script must compute at some sizes is the script's at every size
|
|
188
|
+
// (half carried by the seed, half computed, the two would fight at the edges)
|
|
189
|
+
const anyRule = !anyDrop && rules.length > 0;
|
|
190
|
+
if (anyRule) entry.rules.push(...rules);
|
|
191
|
+
entry.inline[k] = null; // the captured constant no longer applies
|
|
192
|
+
if (anyRule) fitted++;
|
|
193
|
+
if (anyDrop || !anyRule) { entry.dropped.push(k); owned.push({ path: p, prop: k, samples: vals.slice(0, 5).map((x) => `${x.w}x${x.h}: ${x.v ?? '(unset)'}`) }); }
|
|
194
|
+
}
|
|
195
|
+
if (Object.keys(entry.inline).length || entry.rules.length || entry.dropped.length) plan[p] = entry;
|
|
196
|
+
}
|
|
197
|
+
return { plan, owned, fitted, constant, breakpoints, sizes: sizes.length };
|
|
198
|
+
}
|