@duffcloudservices/cli 0.1.0 → 0.1.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/dist/index.js +581 -5
- package/dist/index.js.map +1 -1
- package/package.json +62 -62
package/dist/index.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import
|
|
5
|
+
import chalk8 from "chalk";
|
|
6
6
|
|
|
7
7
|
// package.json
|
|
8
|
-
var version = "0.1.
|
|
8
|
+
var version = "0.1.1";
|
|
9
9
|
|
|
10
10
|
// src/commands/auth.ts
|
|
11
11
|
import chalk2 from "chalk";
|
|
@@ -251,7 +251,7 @@ var PortalClient = class {
|
|
|
251
251
|
/**
|
|
252
252
|
* Make an authenticated API request.
|
|
253
253
|
*/
|
|
254
|
-
async request(
|
|
254
|
+
async request(path5, options = {}) {
|
|
255
255
|
let accessToken = getAccessToken();
|
|
256
256
|
if (!isAuthenticated() && getRefreshToken()) {
|
|
257
257
|
const refreshToken = getRefreshToken();
|
|
@@ -268,7 +268,7 @@ var PortalClient = class {
|
|
|
268
268
|
if (!accessToken) {
|
|
269
269
|
throw new Error("Not authenticated. Please login with `dcs login`.");
|
|
270
270
|
}
|
|
271
|
-
const response = await fetch(`${this.apiUrl}${
|
|
271
|
+
const response = await fetch(`${this.apiUrl}${path5}`, {
|
|
272
272
|
...options,
|
|
273
273
|
headers: {
|
|
274
274
|
...options.headers,
|
|
@@ -2082,6 +2082,572 @@ Once verified:
|
|
|
2082
2082
|
`;
|
|
2083
2083
|
}
|
|
2084
2084
|
|
|
2085
|
+
// src/commands/capture-snapshots.ts
|
|
2086
|
+
import fs4 from "fs";
|
|
2087
|
+
import path4 from "path";
|
|
2088
|
+
import yaml3 from "js-yaml";
|
|
2089
|
+
import chalk7 from "chalk";
|
|
2090
|
+
import ora3 from "ora";
|
|
2091
|
+
var SECTION_SELECTORS = {
|
|
2092
|
+
explicit: "[data-section]",
|
|
2093
|
+
header: 'header, [role="banner"], .header, #header',
|
|
2094
|
+
footer: 'footer, [role="contentinfo"], .footer, #footer',
|
|
2095
|
+
hero: '[class*="hero"], [data-section-type="hero"], .hero-section, #hero',
|
|
2096
|
+
content: 'main, [role="main"], article, .content, .main-content',
|
|
2097
|
+
gallery: '[class*="gallery"], [data-section-type="gallery"]',
|
|
2098
|
+
cta: '[class*="cta"], [data-section-type="cta"], .call-to-action'
|
|
2099
|
+
};
|
|
2100
|
+
function loadPagesConfig(targetDir) {
|
|
2101
|
+
const configPath = path4.join(targetDir, ".dcs", "pages.yaml");
|
|
2102
|
+
if (!fs4.existsSync(configPath)) {
|
|
2103
|
+
throw new Error(`Configuration file not found: ${configPath}`);
|
|
2104
|
+
}
|
|
2105
|
+
const content = fs4.readFileSync(configPath, "utf-8");
|
|
2106
|
+
return yaml3.load(content);
|
|
2107
|
+
}
|
|
2108
|
+
function loadContentConfig(targetDir) {
|
|
2109
|
+
const contentPath = path4.join(targetDir, ".dcs", "content.yaml");
|
|
2110
|
+
if (!fs4.existsSync(contentPath)) {
|
|
2111
|
+
return null;
|
|
2112
|
+
}
|
|
2113
|
+
const content = fs4.readFileSync(contentPath, "utf-8");
|
|
2114
|
+
return yaml3.load(content);
|
|
2115
|
+
}
|
|
2116
|
+
function loadTargetedSnapshotsConfig(targetDir) {
|
|
2117
|
+
const targetedPath = path4.join(targetDir, ".dcs", "targeted-snapshots.yaml");
|
|
2118
|
+
if (!fs4.existsSync(targetedPath)) {
|
|
2119
|
+
return null;
|
|
2120
|
+
}
|
|
2121
|
+
const content = fs4.readFileSync(targetedPath, "utf-8");
|
|
2122
|
+
return yaml3.load(content);
|
|
2123
|
+
}
|
|
2124
|
+
function getValidTextKeysForPage(contentConfig, pageSlug) {
|
|
2125
|
+
const keys = /* @__PURE__ */ new Map();
|
|
2126
|
+
if (!contentConfig) return keys;
|
|
2127
|
+
if (contentConfig.global) {
|
|
2128
|
+
for (const [key, value] of Object.entries(contentConfig.global)) {
|
|
2129
|
+
keys.set(key, typeof value === "string" ? value : JSON.stringify(value));
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
const pageContent = contentConfig.pages?.[pageSlug];
|
|
2133
|
+
if (pageContent) {
|
|
2134
|
+
for (const [key, value] of Object.entries(pageContent)) {
|
|
2135
|
+
const fullKey = `${pageSlug}.${key}`;
|
|
2136
|
+
keys.set(fullKey, typeof value === "string" ? value : JSON.stringify(value));
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
return keys;
|
|
2140
|
+
}
|
|
2141
|
+
async function resolveAllPages(config2) {
|
|
2142
|
+
const resolved = [];
|
|
2143
|
+
for (const page of config2.pages) {
|
|
2144
|
+
if (page.type === "dynamic" && page.instances) {
|
|
2145
|
+
for (const instance of page.instances) {
|
|
2146
|
+
const instancePath = page.pathTemplate ? page.pathTemplate.replace(/:[\w]+/, instance) : `${page.path}/${instance}`;
|
|
2147
|
+
resolved.push({
|
|
2148
|
+
slug: `${page.slug}-${instance}`,
|
|
2149
|
+
path: instancePath,
|
|
2150
|
+
config: { ...page, slug: `${page.slug}-${instance}` }
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
} else {
|
|
2154
|
+
resolved.push({
|
|
2155
|
+
slug: page.slug,
|
|
2156
|
+
path: page.path,
|
|
2157
|
+
config: page
|
|
2158
|
+
});
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
return resolved;
|
|
2162
|
+
}
|
|
2163
|
+
async function getDomPath(element) {
|
|
2164
|
+
return element.evaluate((el) => {
|
|
2165
|
+
const parts = [];
|
|
2166
|
+
let current = el;
|
|
2167
|
+
while (current && current !== document.body) {
|
|
2168
|
+
let selector = current.tagName.toLowerCase();
|
|
2169
|
+
if (current.id) {
|
|
2170
|
+
selector += `#${current.id}`;
|
|
2171
|
+
} else {
|
|
2172
|
+
const classes = Array.from(current.classList).filter((c) => !c.match(/^(v-|nuxt-|vue-|react-|ng-|_)/)).slice(0, 2).join(".");
|
|
2173
|
+
if (classes) {
|
|
2174
|
+
selector += `.${classes}`;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
parts.unshift(selector);
|
|
2178
|
+
current = current.parentElement;
|
|
2179
|
+
}
|
|
2180
|
+
return parts.join(" > ");
|
|
2181
|
+
});
|
|
2182
|
+
}
|
|
2183
|
+
async function generateSectionDisplayName(element, sectionType, index) {
|
|
2184
|
+
const headingText = await element.evaluate((el) => {
|
|
2185
|
+
const heading = el.querySelector('h1, h2, h3, h4, [class*="title"], [class*="heading"]');
|
|
2186
|
+
if (heading && heading.textContent) {
|
|
2187
|
+
return heading.textContent.trim().slice(0, 50);
|
|
2188
|
+
}
|
|
2189
|
+
return null;
|
|
2190
|
+
});
|
|
2191
|
+
if (headingText) return headingText;
|
|
2192
|
+
const typeLabels = {
|
|
2193
|
+
header: "Header",
|
|
2194
|
+
footer: "Footer",
|
|
2195
|
+
hero: "Hero Section",
|
|
2196
|
+
content: "Content Section",
|
|
2197
|
+
gallery: "Gallery",
|
|
2198
|
+
cta: "Call to Action"
|
|
2199
|
+
};
|
|
2200
|
+
return typeLabels[sectionType] || `Section ${index + 1}`;
|
|
2201
|
+
}
|
|
2202
|
+
async function detectTextKeys(_page, sectionElement, _pageSlug, _validTextKeys) {
|
|
2203
|
+
const textKeys = [];
|
|
2204
|
+
const textKeyElements = await sectionElement.$$("[data-text-key]");
|
|
2205
|
+
for (const element of textKeyElements) {
|
|
2206
|
+
const keyInfo = await element.evaluate((el) => {
|
|
2207
|
+
const rect = el.getBoundingClientRect();
|
|
2208
|
+
return {
|
|
2209
|
+
key: el.getAttribute("data-text-key") || "",
|
|
2210
|
+
elementType: el.tagName.toLowerCase(),
|
|
2211
|
+
currentValue: el.textContent?.trim().slice(0, 500) || "",
|
|
2212
|
+
bounds: {
|
|
2213
|
+
x: rect.left + window.scrollX,
|
|
2214
|
+
y: rect.top + window.scrollY,
|
|
2215
|
+
width: rect.width,
|
|
2216
|
+
height: rect.height
|
|
2217
|
+
}
|
|
2218
|
+
};
|
|
2219
|
+
});
|
|
2220
|
+
if (keyInfo.key) {
|
|
2221
|
+
const domPath = await getDomPath(element);
|
|
2222
|
+
textKeys.push({
|
|
2223
|
+
key: keyInfo.key,
|
|
2224
|
+
currentValue: keyInfo.currentValue,
|
|
2225
|
+
elementType: keyInfo.elementType,
|
|
2226
|
+
domPath,
|
|
2227
|
+
bounds: {
|
|
2228
|
+
x: Math.round(keyInfo.bounds.x),
|
|
2229
|
+
y: Math.round(keyInfo.bounds.y),
|
|
2230
|
+
width: Math.round(keyInfo.bounds.width),
|
|
2231
|
+
height: Math.round(keyInfo.bounds.height)
|
|
2232
|
+
}
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
return textKeys;
|
|
2237
|
+
}
|
|
2238
|
+
async function detectFallbackContentSections(page, existingSections) {
|
|
2239
|
+
const candidates = await page.$$('section, article, div[class*="section"], div[class*="container"]');
|
|
2240
|
+
const results = [];
|
|
2241
|
+
const existingBounds = existingSections.map((s) => s.bounds);
|
|
2242
|
+
for (const candidate of candidates) {
|
|
2243
|
+
const bounds = await candidate.evaluate((el) => {
|
|
2244
|
+
const rect = el.getBoundingClientRect();
|
|
2245
|
+
return {
|
|
2246
|
+
y: rect.top + window.scrollY,
|
|
2247
|
+
height: rect.height
|
|
2248
|
+
};
|
|
2249
|
+
});
|
|
2250
|
+
if (bounds.height < 100) continue;
|
|
2251
|
+
const overlaps = existingBounds.some(
|
|
2252
|
+
(eb) => bounds.y < eb.y + eb.height && bounds.y + bounds.height > eb.y
|
|
2253
|
+
);
|
|
2254
|
+
if (!overlaps) {
|
|
2255
|
+
results.push(candidate);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
return results;
|
|
2259
|
+
}
|
|
2260
|
+
async function capturePageSnapshot(browser, pageInfo, snapshotConfig, siteSlug, validTextKeys, outputDir, verbose) {
|
|
2261
|
+
const context = await browser.newContext({
|
|
2262
|
+
viewport: snapshotConfig.viewport
|
|
2263
|
+
});
|
|
2264
|
+
const page = await context.newPage();
|
|
2265
|
+
const url = pageInfo.path.startsWith("http") ? pageInfo.path : `${process.env.SITE_BASE_URL || "http://localhost:5173"}${pageInfo.path}`;
|
|
2266
|
+
if (verbose) {
|
|
2267
|
+
console.log(` Capturing: ${pageInfo.slug} (${url})`);
|
|
2268
|
+
}
|
|
2269
|
+
try {
|
|
2270
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
|
|
2271
|
+
await page.waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {
|
|
2272
|
+
if (verbose) console.log(` Note: networkidle timeout, continuing anyway`);
|
|
2273
|
+
});
|
|
2274
|
+
} catch (error) {
|
|
2275
|
+
console.error(` Failed to load: ${error}`);
|
|
2276
|
+
await context.close();
|
|
2277
|
+
throw error;
|
|
2278
|
+
}
|
|
2279
|
+
await page.waitForTimeout(snapshotConfig.waitAfterLoad);
|
|
2280
|
+
const pageOutputDir = path4.join(outputDir, siteSlug, pageInfo.slug);
|
|
2281
|
+
fs4.mkdirSync(path4.join(pageOutputDir, "sections"), { recursive: true });
|
|
2282
|
+
const title = await page.title();
|
|
2283
|
+
let pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
2284
|
+
const scrollStep = snapshotConfig.viewport.height;
|
|
2285
|
+
for (let scrollY2 = 0; scrollY2 < pageHeight; scrollY2 += scrollStep) {
|
|
2286
|
+
await page.evaluate((y) => window.scrollTo(0, y), scrollY2);
|
|
2287
|
+
await page.waitForTimeout(100);
|
|
2288
|
+
}
|
|
2289
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
2290
|
+
await page.waitForTimeout(200);
|
|
2291
|
+
pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
2292
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
2293
|
+
await page.waitForTimeout(100);
|
|
2294
|
+
const scrollY = await page.evaluate(() => window.scrollY);
|
|
2295
|
+
if (scrollY !== 0) {
|
|
2296
|
+
console.warn(` Warning: Could not scroll to top (scrollY=${scrollY})`);
|
|
2297
|
+
}
|
|
2298
|
+
const fullPagePath = path4.join(pageOutputDir, "full-page.png");
|
|
2299
|
+
await page.screenshot({
|
|
2300
|
+
path: fullPagePath,
|
|
2301
|
+
fullPage: snapshotConfig.captureFullPage
|
|
2302
|
+
});
|
|
2303
|
+
const sections = [];
|
|
2304
|
+
const seenDomPaths = /* @__PURE__ */ new Set();
|
|
2305
|
+
const getAccurateBounds = async (el, sectionType) => {
|
|
2306
|
+
return el.evaluate((element, type) => {
|
|
2307
|
+
const rect = element.getBoundingClientRect();
|
|
2308
|
+
const style = window.getComputedStyle(element);
|
|
2309
|
+
const position = style.position;
|
|
2310
|
+
const isFixed = position === "fixed" || position === "sticky";
|
|
2311
|
+
let y = rect.top + window.scrollY;
|
|
2312
|
+
if (isFixed && (type === "header" || element.tagName.toLowerCase() === "header")) {
|
|
2313
|
+
y = 0;
|
|
2314
|
+
} else if (isFixed && (type === "footer" || element.tagName.toLowerCase() === "footer")) {
|
|
2315
|
+
y = document.documentElement.scrollHeight - rect.height;
|
|
2316
|
+
}
|
|
2317
|
+
return {
|
|
2318
|
+
x: rect.left + window.scrollX,
|
|
2319
|
+
y,
|
|
2320
|
+
width: rect.width,
|
|
2321
|
+
height: rect.height,
|
|
2322
|
+
isFixed
|
|
2323
|
+
};
|
|
2324
|
+
}, sectionType);
|
|
2325
|
+
};
|
|
2326
|
+
const explicitElements = await page.$$(SECTION_SELECTORS.explicit);
|
|
2327
|
+
for (let i = 0; i < explicitElements.length; i++) {
|
|
2328
|
+
const element = explicitElements[i];
|
|
2329
|
+
const domPath = await getDomPath(element);
|
|
2330
|
+
if (seenDomPaths.has(domPath)) continue;
|
|
2331
|
+
seenDomPaths.add(domPath);
|
|
2332
|
+
const basicMetadata = await element.evaluate((el) => {
|
|
2333
|
+
return {
|
|
2334
|
+
sectionId: el.getAttribute("data-section") || "",
|
|
2335
|
+
sectionLabel: el.getAttribute("data-section-label") || "",
|
|
2336
|
+
sectionType: el.getAttribute("data-section-type") || "",
|
|
2337
|
+
isDynamic: el.hasAttribute("data-dynamic")
|
|
2338
|
+
};
|
|
2339
|
+
});
|
|
2340
|
+
let resolvedType = "content";
|
|
2341
|
+
if (basicMetadata.sectionType) {
|
|
2342
|
+
resolvedType = basicMetadata.sectionType;
|
|
2343
|
+
} else if (["header", "footer"].includes(basicMetadata.sectionId)) {
|
|
2344
|
+
resolvedType = basicMetadata.sectionId;
|
|
2345
|
+
} else if (basicMetadata.sectionId.includes("hero")) {
|
|
2346
|
+
resolvedType = "hero";
|
|
2347
|
+
} else if (basicMetadata.sectionId.includes("gallery")) {
|
|
2348
|
+
resolvedType = "gallery";
|
|
2349
|
+
}
|
|
2350
|
+
const bounds = await getAccurateBounds(element, resolvedType);
|
|
2351
|
+
if (!bounds || bounds.height < 10) continue;
|
|
2352
|
+
const sectionId = basicMetadata.sectionId || `section-${i}`;
|
|
2353
|
+
const sectionPath = path4.join(pageOutputDir, "sections", `${sectionId}.png`);
|
|
2354
|
+
try {
|
|
2355
|
+
await element.screenshot({ path: sectionPath });
|
|
2356
|
+
} catch {
|
|
2357
|
+
if (verbose) console.log(` Skipped section screenshot: ${sectionId}`);
|
|
2358
|
+
}
|
|
2359
|
+
const isStructuralOnly = ["header", "footer"].includes(resolvedType);
|
|
2360
|
+
let textKeys = [];
|
|
2361
|
+
if (!isStructuralOnly) {
|
|
2362
|
+
textKeys = await detectTextKeys(page, element, pageInfo.slug, validTextKeys);
|
|
2363
|
+
}
|
|
2364
|
+
const isEditable = !isStructuralOnly && textKeys.length > 0;
|
|
2365
|
+
let displayName = basicMetadata.sectionLabel;
|
|
2366
|
+
if (!displayName) {
|
|
2367
|
+
displayName = await generateSectionDisplayName(element, resolvedType, i);
|
|
2368
|
+
}
|
|
2369
|
+
if (verbose) {
|
|
2370
|
+
console.log(
|
|
2371
|
+
` Section: ${displayName} (${sectionId}) at y=${Math.round(bounds.y)}${basicMetadata.isDynamic ? " [dynamic]" : ""} - ${textKeys.length} text keys`
|
|
2372
|
+
);
|
|
2373
|
+
}
|
|
2374
|
+
sections.push({
|
|
2375
|
+
sectionId,
|
|
2376
|
+
sectionType: resolvedType,
|
|
2377
|
+
displayName,
|
|
2378
|
+
domPath,
|
|
2379
|
+
bounds: {
|
|
2380
|
+
x: Math.round(bounds.x),
|
|
2381
|
+
y: Math.round(bounds.y),
|
|
2382
|
+
width: Math.round(bounds.width),
|
|
2383
|
+
height: Math.round(bounds.height)
|
|
2384
|
+
},
|
|
2385
|
+
textKeys,
|
|
2386
|
+
isEditable,
|
|
2387
|
+
isStructuralOnly
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
for (const [sectionType, selector] of Object.entries(SECTION_SELECTORS)) {
|
|
2391
|
+
if (sectionType === "explicit") continue;
|
|
2392
|
+
const elements = await page.$$(selector);
|
|
2393
|
+
for (let i = 0; i < elements.length; i++) {
|
|
2394
|
+
const element = elements[i];
|
|
2395
|
+
const domPath = await getDomPath(element);
|
|
2396
|
+
if (seenDomPaths.has(domPath)) continue;
|
|
2397
|
+
const isInsideExplicitSection = await element.evaluate((el) => {
|
|
2398
|
+
let current = el.parentElement;
|
|
2399
|
+
while (current) {
|
|
2400
|
+
if (current.dataset?.section) {
|
|
2401
|
+
return true;
|
|
2402
|
+
}
|
|
2403
|
+
current = current.parentElement;
|
|
2404
|
+
}
|
|
2405
|
+
return false;
|
|
2406
|
+
});
|
|
2407
|
+
if (isInsideExplicitSection) {
|
|
2408
|
+
continue;
|
|
2409
|
+
}
|
|
2410
|
+
const isAncestorOfExplicitSection = await element.evaluate((el) => {
|
|
2411
|
+
const explicitSections = el.querySelectorAll("[data-section]");
|
|
2412
|
+
return explicitSections.length > 0;
|
|
2413
|
+
});
|
|
2414
|
+
if (isAncestorOfExplicitSection) {
|
|
2415
|
+
if (verbose) {
|
|
2416
|
+
console.log(` Skipping ${sectionType} container (contains explicit sections)`);
|
|
2417
|
+
}
|
|
2418
|
+
continue;
|
|
2419
|
+
}
|
|
2420
|
+
seenDomPaths.add(domPath);
|
|
2421
|
+
const bounds = await getAccurateBounds(element, sectionType);
|
|
2422
|
+
if (!bounds || bounds.height < 10) continue;
|
|
2423
|
+
const sectionId = `${sectionType}-${i}`;
|
|
2424
|
+
const sectionPath = path4.join(pageOutputDir, "sections", `${sectionId}.png`);
|
|
2425
|
+
try {
|
|
2426
|
+
await element.screenshot({ path: sectionPath });
|
|
2427
|
+
} catch {
|
|
2428
|
+
if (verbose) console.log(` Skipped section screenshot: ${sectionId}`);
|
|
2429
|
+
}
|
|
2430
|
+
const isStructuralOnly = ["header", "footer"].includes(sectionType);
|
|
2431
|
+
const textKeys = isStructuralOnly ? [] : await detectTextKeys(page, element, pageInfo.slug, validTextKeys);
|
|
2432
|
+
if (sectionType === "content" && textKeys.length === 0) {
|
|
2433
|
+
continue;
|
|
2434
|
+
}
|
|
2435
|
+
const isEditable = !isStructuralOnly && textKeys.length > 0;
|
|
2436
|
+
const displayName = await generateSectionDisplayName(element, sectionType, i);
|
|
2437
|
+
if (verbose) {
|
|
2438
|
+
console.log(` Section: ${displayName} at y=${Math.round(bounds.y)}${bounds.isFixed ? " [fixed]" : ""}`);
|
|
2439
|
+
}
|
|
2440
|
+
sections.push({
|
|
2441
|
+
sectionId,
|
|
2442
|
+
sectionType,
|
|
2443
|
+
displayName,
|
|
2444
|
+
domPath,
|
|
2445
|
+
bounds: {
|
|
2446
|
+
x: Math.round(bounds.x),
|
|
2447
|
+
y: Math.round(bounds.y),
|
|
2448
|
+
width: Math.round(bounds.width),
|
|
2449
|
+
height: Math.round(bounds.height)
|
|
2450
|
+
},
|
|
2451
|
+
textKeys,
|
|
2452
|
+
isEditable,
|
|
2453
|
+
isStructuralOnly
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
const hasContentSection = sections.some((s) => s.sectionType === "content");
|
|
2458
|
+
if (!hasContentSection) {
|
|
2459
|
+
if (verbose) console.log(` No content sections found, trying fallback detection...`);
|
|
2460
|
+
const fallbackElements = await detectFallbackContentSections(page, sections);
|
|
2461
|
+
for (let i = 0; i < fallbackElements.length; i++) {
|
|
2462
|
+
const element = fallbackElements[i];
|
|
2463
|
+
const domPath = await getDomPath(element);
|
|
2464
|
+
if (seenDomPaths.has(domPath)) continue;
|
|
2465
|
+
seenDomPaths.add(domPath);
|
|
2466
|
+
const bounds = await element.evaluate((el) => {
|
|
2467
|
+
const rect = el.getBoundingClientRect();
|
|
2468
|
+
return {
|
|
2469
|
+
x: rect.left + window.scrollX,
|
|
2470
|
+
y: rect.top + window.scrollY,
|
|
2471
|
+
width: rect.width,
|
|
2472
|
+
height: rect.height
|
|
2473
|
+
};
|
|
2474
|
+
});
|
|
2475
|
+
if (!bounds || bounds.height < 10) continue;
|
|
2476
|
+
const sectionId = `content-fallback-${i}`;
|
|
2477
|
+
const sectionPath = path4.join(pageOutputDir, "sections", `${sectionId}.png`);
|
|
2478
|
+
try {
|
|
2479
|
+
await element.screenshot({ path: sectionPath });
|
|
2480
|
+
} catch {
|
|
2481
|
+
if (verbose) console.log(` Skipped fallback section screenshot: ${sectionId}`);
|
|
2482
|
+
}
|
|
2483
|
+
const textKeys = await detectTextKeys(page, element, pageInfo.slug, validTextKeys);
|
|
2484
|
+
const displayName = await generateSectionDisplayName(element, "content", i);
|
|
2485
|
+
if (verbose) {
|
|
2486
|
+
console.log(
|
|
2487
|
+
` Fallback Section: ${displayName} at y=${Math.round(bounds.y)} (${textKeys.length} text keys)`
|
|
2488
|
+
);
|
|
2489
|
+
}
|
|
2490
|
+
sections.push({
|
|
2491
|
+
sectionId,
|
|
2492
|
+
sectionType: "content",
|
|
2493
|
+
displayName,
|
|
2494
|
+
domPath,
|
|
2495
|
+
bounds: {
|
|
2496
|
+
x: Math.round(bounds.x),
|
|
2497
|
+
y: Math.round(bounds.y),
|
|
2498
|
+
width: Math.round(bounds.width),
|
|
2499
|
+
height: Math.round(bounds.height)
|
|
2500
|
+
},
|
|
2501
|
+
textKeys,
|
|
2502
|
+
isEditable: textKeys.length > 0,
|
|
2503
|
+
isStructuralOnly: false
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
await context.close();
|
|
2508
|
+
sections.sort((a, b) => a.bounds.y - b.bounds.y);
|
|
2509
|
+
return {
|
|
2510
|
+
pageSlug: pageInfo.slug,
|
|
2511
|
+
pagePath: pageInfo.path,
|
|
2512
|
+
pageType: pageInfo.config.type,
|
|
2513
|
+
deletable: pageInfo.config.deletable,
|
|
2514
|
+
title: title || pageInfo.config.title,
|
|
2515
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2516
|
+
previewUrl: url,
|
|
2517
|
+
viewport: snapshotConfig.viewport,
|
|
2518
|
+
pageHeight,
|
|
2519
|
+
sections
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
async function captureSnapshotsCommand(options) {
|
|
2523
|
+
const { target, baseUrl, dryRun, verbose, pages: targetedPages } = options;
|
|
2524
|
+
const targetDir = path4.resolve(target);
|
|
2525
|
+
console.log(chalk7.blue("\u{1F4F8} Page Snapshot Capture"));
|
|
2526
|
+
console.log(chalk7.gray("========================"));
|
|
2527
|
+
console.log(chalk7.gray(`Target: ${targetDir}`));
|
|
2528
|
+
console.log(chalk7.gray(`Base URL: ${baseUrl}`));
|
|
2529
|
+
process.env.SITE_BASE_URL = baseUrl;
|
|
2530
|
+
let config2;
|
|
2531
|
+
try {
|
|
2532
|
+
config2 = loadPagesConfig(targetDir);
|
|
2533
|
+
} catch (error) {
|
|
2534
|
+
console.error(chalk7.red("Error:"), error.message);
|
|
2535
|
+
console.log(chalk7.yellow("\nMake sure .dcs/pages.yaml exists in the target directory."));
|
|
2536
|
+
process.exit(1);
|
|
2537
|
+
}
|
|
2538
|
+
console.log(chalk7.gray(`Site: ${config2.siteSlug}`));
|
|
2539
|
+
const outputDir = path4.join(targetDir, config2.snapshot.outputDir || ".dcs/snapshots");
|
|
2540
|
+
const contentConfig = loadContentConfig(targetDir);
|
|
2541
|
+
if (contentConfig) {
|
|
2542
|
+
const pageCount = Object.keys(contentConfig.pages || {}).length;
|
|
2543
|
+
const globalCount = Object.keys(contentConfig.global || {}).length;
|
|
2544
|
+
console.log(chalk7.gray(`Content: Loaded ${pageCount} pages, ${globalCount} global keys`));
|
|
2545
|
+
}
|
|
2546
|
+
const targetedConfig = loadTargetedSnapshotsConfig(targetDir);
|
|
2547
|
+
if (targetedConfig?.mode === "skip") {
|
|
2548
|
+
console.log("");
|
|
2549
|
+
console.log(chalk7.yellow("\u23ED\uFE0F Skip mode activated!"));
|
|
2550
|
+
console.log(chalk7.gray(` Reason: ${targetedConfig.reason}`));
|
|
2551
|
+
console.log(chalk7.gray(" No snapshots will be captured."));
|
|
2552
|
+
if (!dryRun) {
|
|
2553
|
+
fs4.mkdirSync(outputDir, { recursive: true });
|
|
2554
|
+
const skipMarker = {
|
|
2555
|
+
skipped: true,
|
|
2556
|
+
reason: targetedConfig.reason,
|
|
2557
|
+
triggeredBy: targetedConfig.triggeredBy,
|
|
2558
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2559
|
+
};
|
|
2560
|
+
fs4.writeFileSync(path4.join(outputDir, "skipped.json"), JSON.stringify(skipMarker, null, 2));
|
|
2561
|
+
}
|
|
2562
|
+
console.log(chalk7.green("\n\u2705 Snapshot capture skipped (as requested)"));
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
let pagesToCapture = await resolveAllPages(config2);
|
|
2566
|
+
console.log(chalk7.gray(`Found ${pagesToCapture.length} total pages in configuration`));
|
|
2567
|
+
if (targetedPages && targetedPages.length > 0) {
|
|
2568
|
+
const targetedSlugs = new Set(targetedPages);
|
|
2569
|
+
const originalCount = pagesToCapture.length;
|
|
2570
|
+
pagesToCapture = pagesToCapture.filter((p) => targetedSlugs.has(p.slug));
|
|
2571
|
+
console.log("");
|
|
2572
|
+
console.log(chalk7.cyan(`\u{1F3AF} Targeted mode: Filtering to ${pagesToCapture.length} of ${originalCount} pages`));
|
|
2573
|
+
} else if (targetedConfig?.mode === "targeted" && targetedConfig.pages.length > 0) {
|
|
2574
|
+
const targetedSlugs = new Set(targetedConfig.pages);
|
|
2575
|
+
const originalCount = pagesToCapture.length;
|
|
2576
|
+
pagesToCapture = pagesToCapture.filter((p) => targetedSlugs.has(p.slug));
|
|
2577
|
+
console.log("");
|
|
2578
|
+
console.log(chalk7.cyan(`\u{1F3AF} Targeted mode: Filtering to ${pagesToCapture.length} of ${originalCount} pages`));
|
|
2579
|
+
console.log(chalk7.gray(` Reason: ${targetedConfig.reason}`));
|
|
2580
|
+
}
|
|
2581
|
+
console.log("");
|
|
2582
|
+
console.log("Pages to capture:");
|
|
2583
|
+
for (const p of pagesToCapture) {
|
|
2584
|
+
console.log(chalk7.gray(` - ${p.slug} (${p.config.type}) ${p.path}`));
|
|
2585
|
+
}
|
|
2586
|
+
if (pagesToCapture.length === 0) {
|
|
2587
|
+
console.log(chalk7.yellow("\n\u26A0\uFE0F No pages to capture!"));
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
if (dryRun) {
|
|
2591
|
+
console.log(chalk7.yellow("\n--dry-run: Stopping before browser launch"));
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
const { chromium } = await import("playwright");
|
|
2595
|
+
if (fs4.existsSync(outputDir)) {
|
|
2596
|
+
fs4.rmSync(outputDir, { recursive: true });
|
|
2597
|
+
}
|
|
2598
|
+
fs4.mkdirSync(outputDir, { recursive: true });
|
|
2599
|
+
const spinner = ora3("Launching browser...").start();
|
|
2600
|
+
const browser = await chromium.launch();
|
|
2601
|
+
spinner.succeed("Browser launched");
|
|
2602
|
+
const snapshots = [];
|
|
2603
|
+
for (const pageInfo of pagesToCapture) {
|
|
2604
|
+
const pageSpinner = ora3(`Capturing ${pageInfo.slug}...`).start();
|
|
2605
|
+
try {
|
|
2606
|
+
const validTextKeys = getValidTextKeysForPage(contentConfig, pageInfo.slug);
|
|
2607
|
+
const snapshot = await capturePageSnapshot(
|
|
2608
|
+
browser,
|
|
2609
|
+
pageInfo,
|
|
2610
|
+
config2.snapshot,
|
|
2611
|
+
config2.siteSlug,
|
|
2612
|
+
validTextKeys,
|
|
2613
|
+
outputDir,
|
|
2614
|
+
verbose || false
|
|
2615
|
+
);
|
|
2616
|
+
snapshots.push(snapshot);
|
|
2617
|
+
const snapshotPath = path4.join(outputDir, config2.siteSlug, pageInfo.slug, "snapshot.json");
|
|
2618
|
+
fs4.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
|
|
2619
|
+
pageSpinner.succeed(`${pageInfo.slug}: ${snapshot.sections.length} sections captured`);
|
|
2620
|
+
} catch (error) {
|
|
2621
|
+
pageSpinner.fail(`${pageInfo.slug}: Failed - ${error.message}`);
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
await browser.close();
|
|
2625
|
+
const manifest = {
|
|
2626
|
+
siteSlug: config2.siteSlug,
|
|
2627
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2628
|
+
deploymentRef: process.env.GITHUB_REF || "local",
|
|
2629
|
+
pagesConfigVersion: config2.version,
|
|
2630
|
+
pages: snapshots.map((s) => ({
|
|
2631
|
+
pageSlug: s.pageSlug,
|
|
2632
|
+
pagePath: s.pagePath,
|
|
2633
|
+
pageType: s.pageType,
|
|
2634
|
+
deletable: s.deletable,
|
|
2635
|
+
title: s.title,
|
|
2636
|
+
capturedAt: s.capturedAt,
|
|
2637
|
+
sectionCount: s.sections.length
|
|
2638
|
+
}))
|
|
2639
|
+
};
|
|
2640
|
+
const manifestPath = path4.join(outputDir, config2.siteSlug, "manifest.json");
|
|
2641
|
+
fs4.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
2642
|
+
console.log("");
|
|
2643
|
+
console.log(chalk7.green("\u2705 Snapshot capture complete!"));
|
|
2644
|
+
console.log(chalk7.gray(` Captured ${snapshots.length} pages`));
|
|
2645
|
+
console.log(chalk7.gray(` - Static: ${snapshots.filter((s) => s.pageType === "static").length}`));
|
|
2646
|
+
console.log(chalk7.gray(` - Index: ${snapshots.filter((s) => s.pageType === "index").length}`));
|
|
2647
|
+
console.log(chalk7.gray(` - Dynamic: ${snapshots.filter((s) => s.pageType === "dynamic").length}`));
|
|
2648
|
+
console.log(chalk7.gray(` Output: ${outputDir}`));
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2085
2651
|
// src/index.ts
|
|
2086
2652
|
var program = new Command();
|
|
2087
2653
|
program.name("dcs").description("DCS (Duff Cloud Services) CLI for customer site management").version(version);
|
|
@@ -2124,6 +2690,16 @@ program.command("plans").description("Generate DCS integration plan files for AI
|
|
|
2124
2690
|
force: options.force
|
|
2125
2691
|
});
|
|
2126
2692
|
});
|
|
2693
|
+
program.command("capture-snapshots").description("Capture visual snapshots of site pages for the portal page editor").option("-t, --target <dir>", "Target directory containing .dcs/pages.yaml", ".").option("-u, --base-url <url>", "Base URL of the running site", "http://localhost:5173").option("-p, --pages <slugs>", "Comma-separated list of page slugs to capture").option("--dry-run", "Show what would be captured without launching browser").option("-v, --verbose", "Show detailed output").action(async (options) => {
|
|
2694
|
+
const pages = options.pages ? options.pages.split(",").map((s) => s.trim()) : void 0;
|
|
2695
|
+
await captureSnapshotsCommand({
|
|
2696
|
+
target: options.target,
|
|
2697
|
+
baseUrl: options.baseUrl,
|
|
2698
|
+
dryRun: options.dryRun,
|
|
2699
|
+
verbose: options.verbose,
|
|
2700
|
+
pages
|
|
2701
|
+
});
|
|
2702
|
+
});
|
|
2127
2703
|
program.exitOverride();
|
|
2128
2704
|
try {
|
|
2129
2705
|
await program.parseAsync(process.argv);
|
|
@@ -2132,7 +2708,7 @@ try {
|
|
|
2132
2708
|
if (error.code === "commander.helpDisplayed" || error.code === "commander.version") {
|
|
2133
2709
|
process.exit(0);
|
|
2134
2710
|
}
|
|
2135
|
-
console.error(
|
|
2711
|
+
console.error(chalk8.red("Error:"), error.message);
|
|
2136
2712
|
process.exit(1);
|
|
2137
2713
|
}
|
|
2138
2714
|
}
|