@elitedcs/ghl-mcp 3.55.0 → 3.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * extract-design.mjs — the STYLE LANE (rights = none, or "I just want the look").
4
+ *
5
+ * Pulls the page's real design system — palette, type scale, spacing rhythm,
6
+ * radii, shadows, breakpoints, section skeleton — so a rebuild starts from the
7
+ * actual design instead of a from-memory redraw (the failure that produced a
8
+ * result "not even close" to the original).
9
+ *
10
+ * It deliberately copies NO page bytes, NO images, NO video and NO copy. Section
11
+ * structure is recorded as shape only (role, heading word count, CTA count,
12
+ * media count) — never verbatim text.
13
+ *
14
+ * Usage:
15
+ * node extract-design.mjs --url <url> --out <dir>
16
+ */
17
+
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { parseArgs, fetchText, mapLimit, ext, writeJson, nowIso } from "./lib.mjs";
21
+
22
+ const args = parseArgs(process.argv.slice(2));
23
+ if (!args.url || !args.out) {
24
+ console.error("usage: extract-design.mjs --url <url> --out <dir>");
25
+ process.exit(1);
26
+ }
27
+ const SRC = String(args.url);
28
+ const OUT = path.resolve(String(args.out));
29
+ fs.mkdirSync(OUT, { recursive: true });
30
+
31
+ const rank = (arr) => {
32
+ const m = new Map();
33
+ for (const v of arr) m.set(v, (m.get(v) || 0) + 1);
34
+ return [...m.entries()].sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
35
+ };
36
+
37
+ function normalizeColor(c) {
38
+ const s = c.trim().toLowerCase();
39
+ if (/^#[0-9a-f]{3}$/.test(s)) return "#" + s.slice(1).split("").map((ch) => ch + ch).join("");
40
+ return s;
41
+ }
42
+
43
+ async function main() {
44
+ const page = await fetchText(SRC);
45
+ if (!page.ok || !page.text) {
46
+ console.error(`FAILED to fetch ${SRC} (status ${page.status}).`);
47
+ process.exit(4);
48
+ }
49
+ const html = page.text;
50
+ const origin = new URL(SRC).origin;
51
+
52
+ // ---- gather stylesheets (linked + inline) --------------------------------
53
+ const cssUrls = [];
54
+ for (const m of html.matchAll(/<link[^>]+rel=["']stylesheet["'][^>]*>/gi)) {
55
+ const href = (m[0].match(/href=["']([^"']+)["']/i) || [])[1];
56
+ if (href) {
57
+ try { cssUrls.push(new URL(href, SRC).toString()); } catch {}
58
+ }
59
+ }
60
+ const fetched = await mapLimit(cssUrls, 6, async (u) => ({ u, r: await fetchText(u) }));
61
+
62
+ /**
63
+ * Framework CSS drowns out the brand. On a WordPress/Elementor page the
64
+ * plugin stylesheets repeat neutral greys hundreds of times while the actual
65
+ * brand colours sit in the theme or per-page CSS and appear two or three
66
+ * times. Ranking by raw frequency therefore returns the FRAMEWORK's palette,
67
+ * which is how a "design-matched" rebuild ends up looking nothing like the
68
+ * original. Live-caught 2026-07-30. Split the sources and rank them apart.
69
+ */
70
+ const isVendor = (u) =>
71
+ /\/(?:plugins|node_modules|vendor|libs?)\//i.test(u) ||
72
+ /(?:bootstrap|swiper|normalize|reset|frontend(?:\.min)?\.css|widget-[\w-]*\.min|apple-webkit|e-animation|animations?\.min|font-?awesome|slick|owl\.carousel)/i.test(u);
73
+
74
+ let css = ""; // everything, for structural/type extraction
75
+ let brandCss = ""; // the site's own CSS — where the brand actually lives
76
+ for (const { u, r } of fetched) {
77
+ if (!r.ok) continue;
78
+ css += "\n" + r.text;
79
+ if (!isVendor(u)) brandCss += "\n" + r.text;
80
+ }
81
+ for (const m of html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) {
82
+ css += "\n" + m[1];
83
+ brandCss += "\n" + m[1]; // inline <style> is authored for THIS page
84
+ }
85
+ // Inline style="" attributes are deliberate per-element design choices.
86
+ let inlineCss = "";
87
+ for (const m of html.matchAll(/style=["']([^"']+)["']/gi)) inlineCss += ";" + m[1];
88
+ brandCss += "\n" + inlineCss;
89
+
90
+ // Vite/Next builds sometimes inline tokens in the JS bundle.
91
+ const jsUrls = [];
92
+ for (const m of html.matchAll(/<script[^>]+src=["']([^"']+)["']/gi)) {
93
+ try {
94
+ const u = new URL(m[1], SRC).toString();
95
+ if (new URL(u).origin === origin && [".js", ".mjs"].includes(ext(u))) jsUrls.push(u);
96
+ } catch {}
97
+ }
98
+ const jsBodies = await mapLimit(jsUrls.slice(0, 6), 4, async (u) => (await fetchText(u)).text || "");
99
+ const jsText = jsBodies.join("\n");
100
+
101
+ const all = css + "\n" + jsText;
102
+
103
+ // ---- tokens ---------------------------------------------------------------
104
+ const cssVars = {};
105
+ const USELESS_VAR = /^(?:initial|inherit|unset|auto|none|normal|block|flex|0|0px|0%)$/i;
106
+ for (const m of brandCss.matchAll(/(--[A-Za-z0-9_-]+)\s*:\s*([^;{}]+)[;}]/g)) {
107
+ const k = m[1], v = m[2].trim();
108
+ if (USELESS_VAR.test(v)) continue; // framework placeholders, not design tokens
109
+ if (!cssVars[k]) cssVars[k] = v;
110
+ }
111
+
112
+ const colorsIn = (src) => {
113
+ const hex = [...src.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].map((m) => normalizeColor(m[0]))
114
+ .filter((c) => c.length === 7 || c.length === 9);
115
+ const rgb = [...src.matchAll(/rgba?\(\s*\d{1,3}\s*[, ]\s*\d{1,3}\s*[, ]\s*\d{1,3}[^)]*\)/g)].map((m) => m[0].replace(/\s+/g, " "));
116
+ const hsl = [...src.matchAll(/hsla?\([^)]+\)/g)].map((m) => m[0].replace(/\s+/g, " "));
117
+ return [...hex, ...rgb, ...hsl];
118
+ };
119
+ const NEUTRAL = /^#(?:0{6}|f{6}|(?:([0-9a-f])\1){3})$/i; // pure black/white/grey ramps
120
+ const brandRanked = rank(colorsIn(brandCss));
121
+ const colors = brandRanked.slice(0, 24);
122
+ const frameworkColors = rank(colorsIn(all)).slice(0, 12);
123
+
124
+ /**
125
+ * Frequency alone is a poor brand signal: a page-builder's per-page CSS may
126
+ * name the brand colour ONCE while the theme repeats greys. So attribute each
127
+ * accent to the file it came from and weight page-specific CSS highest — then
128
+ * show the source, rather than pretending a rank order is authoritative.
129
+ */
130
+ const sourceWeight = (u) =>
131
+ /uploads\/[^?]*css\/post-\d+|\/post-\d+\.css|custom|brand|theme/i.test(u) ? 3 : 1;
132
+ const accentBySource = [];
133
+ const sources = [
134
+ ...fetched.filter(({ u, r }) => r.ok && !isVendor(u)).map(({ u, r }) => ({ label: u.split("/").pop().split("?")[0], text: r.text, weight: sourceWeight(u) })),
135
+ { label: "inline style attributes", text: inlineCss, weight: 3 },
136
+ ];
137
+ for (const src of sources) {
138
+ const accents = rank(colorsIn(src.text).filter((c) => c.startsWith("#") && !NEUTRAL.test(c)));
139
+ for (const a of accents.slice(0, 6)) accentBySource.push({ value: a.value, count: a.count, source: src.label, weight: src.weight });
140
+ }
141
+ const accentScore = new Map();
142
+ for (const a of accentBySource) {
143
+ const cur = accentScore.get(a.value) || { value: a.value, score: 0, sources: [] };
144
+ cur.score += a.count * a.weight;
145
+ if (!cur.sources.includes(a.source)) cur.sources.push(a.source);
146
+ accentScore.set(a.value, cur);
147
+ }
148
+ const brandAccents = [...accentScore.values()].sort((x, y) => y.score - x.score).slice(0, 10);
149
+
150
+ /**
151
+ * Best signal of all when it exists: the site's DECLARED colour tokens.
152
+ * Elementor (`--e-global-color-*`), WordPress theme.json
153
+ * (`--wp--preset--color--*`) and hand-authored `--brand-*` variables name the
154
+ * palette outright, so no frequency guessing is needed.
155
+ */
156
+ const COLOR_VALUE = /^(#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\()/;
157
+ const declaredPalette = Object.entries(cssVars)
158
+ .filter(([k, v]) => COLOR_VALUE.test(v.trim()) &&
159
+ /(?:global-color|preset--color|brand|palette|--color-|primary|secondary|accent)/i.test(k))
160
+ .map(([name, value]) => ({ name, value: value.trim() }));
161
+
162
+ /**
163
+ * Page-builders express type as CSS variables (`var(--e-global-typography-x-font-size)`).
164
+ * Reporting those verbatim is useless for a rebuild, so resolve them against
165
+ * the variables we collected and drop anything still unresolved.
166
+ */
167
+ const resolveVar = (v) => {
168
+ let out = String(v);
169
+ for (let i = 0; i < 4 && out.includes("var("); i++) {
170
+ out = out.replace(/var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,([^)]*))?\)/g, (m, name, fallback) =>
171
+ cssVars[name] !== undefined ? cssVars[name] : (fallback !== undefined ? fallback.trim() : m)
172
+ );
173
+ }
174
+ return out.trim();
175
+ };
176
+ const resolved = (list) =>
177
+ rank(list.map(resolveVar).filter((v) => v && !v.includes("var(") && !/^(?:inherit|initial|unset)$/i.test(v)));
178
+
179
+ const families = resolved(
180
+ [...css.matchAll(/font-family\s*:\s*([^;{}]+)/gi)].map((m) => m[1].trim().replace(/["']/g, ""))
181
+ ).slice(0, 8);
182
+ const faceFamilies = rank(
183
+ [...css.matchAll(/@font-face\s*{[^}]*font-family\s*:\s*["']?([^;"'}]+)["']?/gi)].map((m) => m[1].trim())
184
+ ).map((f) => f.value);
185
+ const googleFonts = [...html.matchAll(/https:\/\/fonts\.googleapis\.com\/css2?\?[^"']+/g)].map((m) => m[0]);
186
+
187
+ const fontSizes = resolved([...css.matchAll(/font-size\s*:\s*([^;{}]+)/gi)].map((m) => m[1].trim())).slice(0, 16);
188
+ const weights = resolved([...css.matchAll(/font-weight\s*:\s*([^;{}]+)/gi)].map((m) => m[1].trim())).slice(0, 8);
189
+ const lineHeights = resolved([...css.matchAll(/line-height\s*:\s*([^;{}]+)/gi)].map((m) => m[1].trim())).slice(0, 8);
190
+ const spacing = resolved(
191
+ [...css.matchAll(/(?:padding|margin|gap)(?:-(?:top|right|bottom|left|block|inline))?\s*:\s*([^;{}]+)/gi)].map((m) => m[1].trim())
192
+ ).filter((v) => !/^0(?:px)?(?:\s+0(?:px)?)*$/.test(v.value)).slice(0, 20);
193
+ const radii = rank([...css.matchAll(/border-radius\s*:\s*([^;{}]+)/gi)].map((m) => m[1].trim())).slice(0, 10);
194
+ const shadows = rank([...css.matchAll(/box-shadow\s*:\s*([^;{}]+)/gi)].map((m) => m[1].trim())).slice(0, 10);
195
+ const breakpoints = rank([...css.matchAll(/@media[^{]*?\(([^)]*(?:min|max)-width[^)]*)\)/gi)].map((m) => m[1].trim())).slice(0, 10);
196
+ const containers = rank([...css.matchAll(/max-width\s*:\s*([^;{}]+)/gi)].map((m) => m[1].trim())).slice(0, 10);
197
+ const gridCols = rank([...css.matchAll(/grid-template-columns\s*:\s*([^;{}]+)/gi)].map((m) => m[1].trim())).slice(0, 10);
198
+ const usesTailwind = /tailwind|\bbg-\w+-\d{2,3}\b|\btext-\w+-\d{2,3}\b/.test(all);
199
+
200
+ // ---- section skeleton (shape only, never verbatim copy) -------------------
201
+ const body = html
202
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
203
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
204
+ .replace(/<!--[\s\S]*?-->/g, "");
205
+ const strip = (s) => s.replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim();
206
+ const words = (s) => (strip(s) ? strip(s).split(/\s+/).length : 0);
207
+
208
+ const landmarks = [...body.matchAll(/<(section|header|footer|nav|main|article)\b([^>]*)>/gi)];
209
+ const sections = [];
210
+ for (let i = 0; i < landmarks.length; i++) {
211
+ const start = landmarks[i].index;
212
+ const end = i + 1 < landmarks.length ? landmarks[i + 1].index : body.length;
213
+ const chunk = body.slice(start, end);
214
+ const attrs = landmarks[i][2] || "";
215
+ const cls = ((attrs.match(/class=["']([^"']*)["']/i) || [])[1] || "").toLowerCase();
216
+ const id = ((attrs.match(/id=["']([^"']*)["']/i) || [])[1] || "").toLowerCase();
217
+ const hay = `${landmarks[i][1]} ${cls} ${id} ${chunk.slice(0, 4000).toLowerCase()}`;
218
+ const role =
219
+ /hero|banner|jumbotron/.test(`${cls} ${id}`) ? "hero" :
220
+ /testimonial|review|rating|what .* say/.test(hay) ? "testimonials" :
221
+ /pricing|package|plan|tier|\$\d/.test(hay) ? "pricing" :
222
+ /faq|frequently asked|accordion/.test(hay) ? "faq" :
223
+ /footer/.test(landmarks[i][1] + cls + id) ? "footer" :
224
+ /nav|menu/.test(landmarks[i][1] + cls + id) ? "nav" :
225
+ /form|book|schedule|apply|contact/.test(hay) ? "capture/CTA" :
226
+ /benefit|feature|why|how it works|step/.test(hay) ? "benefits/features" :
227
+ i === 0 ? "hero" : "content";
228
+ const headings = [...chunk.matchAll(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi)].map((m) => ({
229
+ level: Number(m[1]),
230
+ words: words(m[2]),
231
+ }));
232
+ sections.push({
233
+ order: sections.length + 1,
234
+ tag: landmarks[i][1].toLowerCase(),
235
+ role,
236
+ headings,
237
+ paragraphs: (chunk.match(/<p\b/gi) || []).length,
238
+ listItems: (chunk.match(/<li\b/gi) || []).length,
239
+ images: (chunk.match(/<img\b/gi) || []).length,
240
+ videos: (chunk.match(/<video\b|<iframe[^>]+(youtube|vimeo|wistia)/gi) || []).length,
241
+ buttons: (chunk.match(/<button\b|class=["'][^"']*\bbtn\b|class=["'][^"']*button/gi) || []).length,
242
+ forms: (chunk.match(/<form\b/gi) || []).length,
243
+ approxWords: words(chunk),
244
+ });
245
+ }
246
+ // Page-builders nest landmark elements; the empty wrappers are noise.
247
+ const meaningful = sections.filter((x) => x.approxWords > 0 || x.images > 0 || x.videos > 0 || x.buttons > 0 || x.forms > 0);
248
+ const emptyWrappers = sections.length - meaningful.length;
249
+ sections.length = 0;
250
+ meaningful.forEach((x, i) => { x.order = i + 1; sections.push(x); });
251
+ const spaShell = sections.length <= 1 && words(body) < 120;
252
+
253
+ const tokens = {
254
+ tool: "clone-site/extract-design.mjs",
255
+ lane: "style — design system only; no bytes, images, video or copy were copied",
256
+ generatedAt: nowIso(),
257
+ source: SRC,
258
+ declaredPalette,
259
+ palette: colors,
260
+ brandAccents,
261
+ frameworkPalette: frameworkColors,
262
+ cssVariables: cssVars,
263
+ typography: { families, fontFaceFamilies: faceFamilies, googleFontsLinks: googleFonts, sizes: fontSizes, weights, lineHeights },
264
+ spacingScale: spacing,
265
+ radii,
266
+ shadows,
267
+ breakpoints,
268
+ containers,
269
+ gridColumns: gridCols,
270
+ utilityFramework: usesTailwind ? "tailwind-like utility classes detected" : null,
271
+ structure: { spaShell, sections, emptyWrappersDropped: emptyWrappers },
272
+ };
273
+ writeJson(path.join(OUT, "design-tokens.json"), tokens);
274
+
275
+ // ---- human-readable brief -------------------------------------------------
276
+ const md = [];
277
+ md.push("# Design system extracted (style lane)");
278
+ md.push("");
279
+ md.push(`Source: ${SRC}`);
280
+ md.push(`Generated: ${nowIso()}`);
281
+ md.push("");
282
+ md.push("**No page bytes, images, video or copy were copied.** This is the design system only: rebuild on it with original copy and licensed or original media.");
283
+ md.push("");
284
+ md.push("## Palette");
285
+ md.push("");
286
+ if (declaredPalette.length) {
287
+ md.push("**Declared colour tokens** — the site names its own palette. Use these; everything below is inference by comparison:");
288
+ md.push("");
289
+ for (const d of declaredPalette) md.push(`- \`${d.name}: ${d.value}\``);
290
+ md.push("");
291
+ }
292
+ if (brandAccents.length) {
293
+ md.push("**Brand colours** — non-neutral values from the site's OWN stylesheets and inline styles. These are the ones to reuse:");
294
+ md.push("");
295
+ for (const c of brandAccents) md.push(`- \`${c.value}\` — from ${c.sources.join(", ")}`);
296
+ md.push("");
297
+ }
298
+ md.push("Full palette from the site's own CSS (includes neutrals):");
299
+ md.push("");
300
+ for (const c of colors.slice(0, 14)) md.push(`- \`${c.value}\` ×${c.count}`);
301
+ md.push("");
302
+ md.push("_Framework/plugin CSS was ranked separately and excluded above — on a page built with a page-builder it repeats neutral greys far more often than the brand's own colours, so ranking everything together returns the framework's palette instead of the brand's._");
303
+ md.push("");
304
+ if (Object.keys(cssVars).length) {
305
+ md.push("## CSS custom properties (the author's own token names)");
306
+ md.push("");
307
+ for (const [k, v] of Object.entries(cssVars).slice(0, 40)) md.push(`- \`${k}: ${v}\``);
308
+ md.push("");
309
+ }
310
+ md.push("## Typography");
311
+ md.push("");
312
+ for (const f of families) md.push(`- family: \`${f.value}\` ×${f.count}`);
313
+ if (googleFonts.length) for (const g of googleFonts) md.push(`- Google Fonts: ${g}`);
314
+ md.push("");
315
+ md.push(`- size scale: ${fontSizes.map((s) => s.value).join(", ") || "(none found)"}`);
316
+ md.push(`- weights: ${weights.map((s) => s.value).join(", ") || "(none found)"}`);
317
+ md.push(`- line-heights: ${lineHeights.map((s) => s.value).join(", ") || "(none found)"}`);
318
+ md.push("");
319
+ md.push("## Rhythm and shape");
320
+ md.push("");
321
+ md.push(`- spacing values: ${spacing.slice(0, 12).map((s) => s.value).join(", ") || "(none)"}`);
322
+ md.push(`- radii: ${radii.map((s) => s.value).join(", ") || "(none)"}`);
323
+ md.push(`- shadows: ${shadows.slice(0, 4).map((s) => s.value).join(" | ") || "(none)"}`);
324
+ md.push(`- containers (max-width): ${containers.map((s) => s.value).join(", ") || "(none)"}`);
325
+ md.push(`- breakpoints: ${breakpoints.map((s) => s.value).join(", ") || "(none)"}`);
326
+ md.push(`- grid columns: ${gridCols.slice(0, 5).map((s) => s.value).join(" | ") || "(none)"}`);
327
+ if (usesTailwind) md.push("- utility framework: Tailwind-like classes detected — match the scale, not the class names");
328
+ md.push("");
329
+ md.push("## Page skeleton (shape only)");
330
+ md.push("");
331
+ if (spaShell) {
332
+ md.push("⚠ The HTML is a client-rendered shell — the section structure is not in the source.");
333
+ md.push("Open the page in a browser and record the section order visually before rebuilding.");
334
+ } else {
335
+ if (emptyWrappers) md.push(`_${emptyWrappers} empty layout wrappers dropped (page-builder nesting)._`);
336
+ md.push("");
337
+ md.push("| # | role | headings (level×words) | ¶ | list | img | video | btn | form | ~words |");
338
+ md.push("|---|---|---|---|---|---|---|---|---|---|");
339
+ for (const s of sections) {
340
+ const h = s.headings.map((x) => `h${x.level}×${x.words}w`).join(" ") || "—";
341
+ md.push(`| ${s.order} | ${s.role} | ${h} | ${s.paragraphs} | ${s.listItems} | ${s.images} | ${s.videos} | ${s.buttons} | ${s.forms} | ${s.approxWords} |`);
342
+ }
343
+ }
344
+ md.push("");
345
+ md.push("Heading **word counts** are given instead of the words themselves on purpose: match the rhythm and the information hierarchy, write your own lines.");
346
+ md.push("");
347
+ md.push("## Rebuild rules");
348
+ md.push("");
349
+ md.push("1. Use the palette, type scale, spacing and radii above verbatim — that is what makes it look as good as the original.");
350
+ md.push("2. Write every headline, paragraph and CTA fresh for the new business.");
351
+ md.push("3. Source media the client owns or licenses. Never hot-link the original site's images.");
352
+ md.push("4. Match the section ORDER and density, not the sentences.");
353
+ md.push("5. Run the pre-launch audit on the finished page too — a fresh build can still inherit a claim you typed from memory.");
354
+ md.push("");
355
+ fs.writeFileSync(path.join(OUT, "DESIGN-SYSTEM.md"), md.join("\n"));
356
+
357
+ const out = [];
358
+ out.push("");
359
+ out.push(`STYLE LANE — design system extracted from ${SRC}`);
360
+ out.push(` palette: ${colors.length} colors fonts: ${families.length} sections: ${sections.length}${spaShell ? " (SPA shell — read structure in a browser)" : ""}`);
361
+ out.push(` ${path.join(OUT, "DESIGN-SYSTEM.md")}`);
362
+ out.push(` ${path.join(OUT, "design-tokens.json")}`);
363
+ out.push("");
364
+ out.push(" No bytes, images, video or copy were copied. Rebuild with original copy and licensed media.");
365
+ out.push("");
366
+ console.log(out.join("\n"));
367
+ }
368
+
369
+ main().catch((e) => {
370
+ console.error("extract-design failed:", e);
371
+ process.exit(1);
372
+ });
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Shared helpers for the clone-site scripts.
3
+ * Dependency-free: Node 20+ global fetch only. These scripts are installed to
4
+ * ~/.claude/skills/clone-site/scripts/ where no node_modules is available.
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+
10
+ export const ASSET_EXT = [
11
+ ".css", ".js", ".mjs",
12
+ ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".ico", ".avif", ".bmp",
13
+ ".mp4", ".webm", ".mov", ".m4v", ".ogg", ".mp3", ".wav",
14
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
15
+ ".pdf", ".json",
16
+ ];
17
+
18
+ const CONTENT_TYPE_FAMILY = {
19
+ ".css": "text/css",
20
+ ".js": "javascript",
21
+ ".mjs": "javascript",
22
+ ".png": "image/",
23
+ ".jpg": "image/",
24
+ ".jpeg": "image/",
25
+ ".webp": "image/",
26
+ ".gif": "image/",
27
+ ".svg": "image/",
28
+ ".ico": "image/",
29
+ ".avif": "image/",
30
+ ".bmp": "image/",
31
+ ".mp4": "video/",
32
+ ".webm": "video/",
33
+ ".mov": "video/",
34
+ ".m4v": "video/",
35
+ ".mp3": "audio/",
36
+ ".wav": "audio/",
37
+ ".ogg": "audio/",
38
+ ".woff": "font",
39
+ ".woff2": "font",
40
+ ".ttf": "font",
41
+ ".otf": "font",
42
+ ".eot": "font",
43
+ ".pdf": "application/pdf",
44
+ ".json": "json",
45
+ };
46
+
47
+ if (typeof fetch !== "function") {
48
+ console.error("clone-site needs Node 18 or newer (global fetch). Run `node --version` and upgrade.");
49
+ process.exit(1);
50
+ }
51
+
52
+ export const UA =
53
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
54
+
55
+ export function ext(urlOrPath) {
56
+ const clean = String(urlOrPath).split("?")[0].split("#")[0];
57
+ const dot = clean.lastIndexOf(".");
58
+ const slash = clean.lastIndexOf("/");
59
+ if (dot === -1 || dot < slash) return "";
60
+ return clean.slice(dot).toLowerCase();
61
+ }
62
+
63
+ export function isAssetUrl(u) {
64
+ return ASSET_EXT.includes(ext(u));
65
+ }
66
+
67
+ /**
68
+ * Does the served content-type match what the extension promises?
69
+ * This is the soft-404 catcher: static hosts return 200 + HTML for a missing
70
+ * asset, so a status code proves nothing.
71
+ */
72
+ export function contentTypeMatches(extension, contentType) {
73
+ const want = CONTENT_TYPE_FAMILY[extension];
74
+ if (!want) return true; // unknown extension — no opinion
75
+ const got = String(contentType || "").toLowerCase();
76
+ if (!got) return false;
77
+ if (want === "javascript") return got.includes("javascript") || got.includes("ecmascript");
78
+ if (want === "font") return got.includes("font") || got.includes("octet-stream");
79
+ if (want === "json") return got.includes("json");
80
+ return got.includes(want);
81
+ }
82
+
83
+ export async function fetchText(url, timeoutMs = 30000) {
84
+ const res = await fetchRaw(url, timeoutMs);
85
+ if (!res.ok) return { ok: false, status: res.status, text: "", contentType: res.contentType };
86
+ return { ok: true, status: res.status, text: res.buf.toString("utf8"), contentType: res.contentType };
87
+ }
88
+
89
+ export async function fetchRaw(url, timeoutMs = 60000) {
90
+ const ctrl = new AbortController();
91
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
92
+ try {
93
+ const res = await fetch(url, {
94
+ redirect: "follow",
95
+ signal: ctrl.signal,
96
+ headers: { "user-agent": UA, accept: "*/*" },
97
+ });
98
+ const buf = Buffer.from(await res.arrayBuffer());
99
+ return {
100
+ ok: res.ok,
101
+ status: res.status,
102
+ buf,
103
+ contentType: res.headers.get("content-type") || "",
104
+ finalUrl: res.url || url,
105
+ };
106
+ } catch (e) {
107
+ return { ok: false, status: 0, buf: Buffer.alloc(0), contentType: "", error: String(e && e.message ? e.message : e) };
108
+ } finally {
109
+ clearTimeout(timer);
110
+ }
111
+ }
112
+
113
+ export async function headSize(url, timeoutMs = 20000) {
114
+ const ctrl = new AbortController();
115
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
116
+ try {
117
+ const res = await fetch(url, {
118
+ method: "HEAD",
119
+ redirect: "follow",
120
+ signal: ctrl.signal,
121
+ headers: { "user-agent": UA },
122
+ });
123
+ const len = res.headers.get("content-length");
124
+ return { ok: res.ok, status: res.status, bytes: len ? Number(len) : null, contentType: res.headers.get("content-type") || "" };
125
+ } catch {
126
+ return { ok: false, status: 0, bytes: null, contentType: "" };
127
+ } finally {
128
+ clearTimeout(timer);
129
+ }
130
+ }
131
+
132
+ /** Run tasks with a small concurrency cap; order of results matches input. */
133
+ export async function mapLimit(items, limit, fn) {
134
+ const out = new Array(items.length);
135
+ let next = 0;
136
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
137
+ for (;;) {
138
+ const i = next++;
139
+ if (i >= items.length) return;
140
+ out[i] = await fn(items[i], i);
141
+ }
142
+ });
143
+ await Promise.all(workers);
144
+ return out;
145
+ }
146
+
147
+ export function ensureDir(file) {
148
+ fs.mkdirSync(path.dirname(file), { recursive: true });
149
+ }
150
+
151
+ export function walk(root) {
152
+ const out = [];
153
+ const rec = (dir) => {
154
+ let entries;
155
+ try {
156
+ entries = fs.readdirSync(dir, { withFileTypes: true });
157
+ } catch {
158
+ return;
159
+ }
160
+ for (const e of entries) {
161
+ const full = path.join(dir, e.name);
162
+ if (e.isDirectory()) rec(full);
163
+ else if (e.isFile()) out.push(full);
164
+ }
165
+ };
166
+ rec(root);
167
+ return out.sort();
168
+ }
169
+
170
+ export const TEXT_EXT = [".html", ".htm", ".css", ".js", ".mjs", ".json", ".txt", ".svg", ".xml", ".webmanifest"];
171
+
172
+ export function isTextFile(file) {
173
+ return TEXT_EXT.includes(ext(file));
174
+ }
175
+
176
+ export function humanBytes(n) {
177
+ if (n === null || n === undefined) return "?";
178
+ const units = ["B", "KB", "MB", "GB"];
179
+ let v = Number(n);
180
+ let i = 0;
181
+ while (v >= 1024 && i < units.length - 1) {
182
+ v /= 1024;
183
+ i++;
184
+ }
185
+ return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)}${units[i]}`;
186
+ }
187
+
188
+ /** Minimal flag parser: --key value, --flag (boolean). */
189
+ export function parseArgs(argv) {
190
+ const args = {};
191
+ for (let i = 0; i < argv.length; i++) {
192
+ const a = argv[i];
193
+ if (!a.startsWith("--")) continue;
194
+ const key = a.slice(2);
195
+ const nextArg = argv[i + 1];
196
+ if (nextArg === undefined || nextArg.startsWith("--")) args[key] = true;
197
+ else {
198
+ args[key] = nextArg;
199
+ i++;
200
+ }
201
+ }
202
+ return args;
203
+ }
204
+
205
+ export function readJson(file) {
206
+ return JSON.parse(fs.readFileSync(file, "utf8"));
207
+ }
208
+
209
+ export function writeJson(file, data) {
210
+ ensureDir(file);
211
+ fs.writeFileSync(file, JSON.stringify(data, null, 2));
212
+ }
213
+
214
+ /** Timestamp with no dependency on the caller's locale. */
215
+ export function nowIso() {
216
+ return new Date().toISOString();
217
+ }
218
+
219
+ /**
220
+ * Run-directory layout. Reports NEVER live inside the deployable folder — a
221
+ * REVIEW-REQUIRED.md published on the live site would hand the world a list of
222
+ * the original owner's liabilities plus the user's rights declaration.
223
+ *
224
+ * <run-dir>/site/ ← deploy THIS
225
+ * <run-dir>/reports/ ← run-report.json, substitution-report.json, REVIEW-REQUIRED.md
226
+ */
227
+ export function siteDir(runDir) {
228
+ return path.join(runDir, "site");
229
+ }
230
+ export function reportsDir(runDir) {
231
+ return path.join(runDir, "reports");
232
+ }
233
+ /** Accept either a run dir (has site/) or, for older runs, a bare site dir. */
234
+ export function resolveRun(dir) {
235
+ const abs = path.resolve(dir);
236
+ if (fs.existsSync(path.join(abs, "site"))) return { runDir: abs, site: siteDir(abs), reports: reportsDir(abs) };
237
+ return { runDir: abs, site: abs, reports: abs, legacy: true };
238
+ }
239
+
240
+ export const RIGHTS_CHOICES = ["own", "client-authorized", "written-permission", "none"];
241
+
242
+ export const RIGHTS_LABEL = {
243
+ own: "I own this site",
244
+ "client-authorized": "My client owns it and authorized the migration",
245
+ "written-permission": "I have written permission from the owner",
246
+ none: "None of these",
247
+ };
248
+
249
+ export const RIGHTS_QUESTION = `
250
+ STEP 0 — rights declaration (required before any bytes are copied)
251
+
252
+ Who owns this page, or what permission do you have to copy it?
253
+
254
+ own ${RIGHTS_LABEL.own}
255
+ client-authorized ${RIGHTS_LABEL["client-authorized"]}
256
+ written-permission ${RIGHTS_LABEL["written-permission"]}
257
+ none ${RIGHTS_LABEL.none}
258
+
259
+ Pass it as --rights <value>. Your answer is recorded in the run report.
260
+ "none" is not a dead end — it routes to the style lane (extract-design.mjs),
261
+ which rebuilds on the real design system with your own copy and media.
262
+ `.trim();