Sync OneOS workspace with new prototypes, annotations, and Gitea remote fix.

Add vehicle-h2-fee-ledger, customer-management, lease and self-operated ledgers, annotation sources, agent skills, and vite annotation runtime support. Update vehicle management, contract templates, and lease contract flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-06-30 15:27:23 +08:00
parent 00ca1846af
commit af29b26fe8
309 changed files with 73875 additions and 3838 deletions

View File

@@ -0,0 +1,824 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
findWorkspaceRoot,
normalizeViewport,
resolveChromiumLaunchOptions,
} from './capture-theme-homepage.mjs';
const PLAYWRIGHT_CACHE_DIR = path.join(os.homedir(), '.cache', 'axure-extractor');
const DEFAULT_SOURCE_CAPTURE_OPTIONS = {
viewport: { width: 1440, height: 900 },
responsiveViewports: [
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'mobile', width: 390, height: 844 },
],
deviceScaleFactor: 1,
waitUntil: 'domcontentloaded',
timeoutMs: 60000,
waitAfterLoadMs: 3000,
settleTimeoutMs: 8000,
scrollWarmup: true,
maxScrollSteps: 60,
headless: true,
responsive: true,
screenshot: true,
tokens: true,
hideScrollbar: true,
};
function readRequiredValue(argv, index, flag) {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${flag} requires a value`);
}
return value;
}
function parseInteger(value, name) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
throw new Error(`Invalid ${name}: ${value}`);
}
return parsed;
}
function normalizeSelectorList(value) {
if (!value) return [];
if (Array.isArray(value)) return value.filter(Boolean).map(String);
return [String(value)];
}
function parseHeader(value) {
const separatorIndex = value.includes('=') ? value.indexOf('=') : value.indexOf(':');
if (separatorIndex <= 0) {
throw new Error(`Invalid header, expected Name=Value: ${value}`);
}
const name = value.slice(0, separatorIndex).trim();
const headerValue = value.slice(separatorIndex + 1).trim();
if (!name) throw new Error(`Invalid header, expected Name=Value: ${value}`);
return [name, headerValue];
}
function parseNamedViewport(value, fallbackName) {
const [maybeName, maybeViewport] = value.includes(':')
? value.split(':', 2)
: [fallbackName, value];
const viewport = normalizeViewport(maybeViewport);
return { name: maybeName || fallbackName, ...viewport };
}
export function parseResponsiveViewports(value) {
if (!value) return DEFAULT_SOURCE_CAPTURE_OPTIONS.responsiveViewports;
return String(value)
.split(',')
.map((item, index) => parseNamedViewport(item.trim(), `viewport-${index + 1}`));
}
export function inferThemeFromUrl(url) {
try {
return new URL(url).hostname.replace(/^www\./, '').split('.')[0] || 'homepage';
} catch {
return 'homepage';
}
}
export function parseSourceCaptureArgs(argv = process.argv) {
const args = {
headers: {},
waitForSelectors: [],
dismissSelectors: [],
removeSelectors: [],
help: false,
dryRun: false,
};
const rawArgs = argv.slice(2);
for (let i = 0; i < rawArgs.length; i += 1) {
const flag = rawArgs[i];
if (flag === '--') {
continue;
} else if (flag === '--help' || flag === '-h') {
args.help = true;
} else if (flag === '--dry-run') {
args.dryRun = true;
} else if (flag === '--theme') {
args.theme = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--url') {
args.url = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--output' || flag === '-o') {
args.output = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--viewport') {
args.viewport = normalizeViewport(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag === '--responsive-viewports') {
args.responsiveViewports = parseResponsiveViewports(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag === '--wait-until') {
args.waitUntil = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--timeout') {
args.timeoutMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--wait') {
args.waitAfterLoadMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--settle-timeout') {
args.settleTimeoutMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--max-scroll-steps') {
args.maxScrollSteps = parseInteger(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--selector') {
args.selector = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--wait-for-selector') {
args.waitForSelectors.push(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag === '--dismiss-selector') {
args.dismissSelectors.push(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag === '--remove-selector') {
args.removeSelectors.push(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag === '--header') {
const [name, value] = parseHeader(readRequiredValue(rawArgs, i, flag));
args.headers[name] = value;
i += 1;
} else if (flag === '--connect-cdp') {
args.connectCdp = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--browser-executable') {
args.browserExecutable = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--storage-state') {
args.storageState = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--headless') {
args.headless = true;
} else if (flag === '--no-headless') {
args.headless = false;
} else if (flag === '--scroll-warmup') {
args.scrollWarmup = true;
} else if (flag === '--no-scroll-warmup') {
args.scrollWarmup = false;
} else if (flag === '--responsive') {
args.responsive = true;
} else if (flag === '--no-responsive') {
args.responsive = false;
} else if (flag === '--screenshot') {
args.screenshot = true;
} else if (flag === '--no-screenshot') {
args.screenshot = false;
} else if (flag === '--tokens') {
args.tokens = true;
} else if (flag === '--no-tokens') {
args.tokens = false;
} else if (flag === '--hide-scrollbar') {
args.hideScrollbar = true;
} else if (flag === '--show-scrollbar') {
args.hideScrollbar = false;
} else if (flag.startsWith('--')) {
throw new Error(`Unknown option: ${flag}`);
} else if (!args.url) {
args.url = flag;
} else if (!args.theme) {
args.theme = flag;
} else {
throw new Error(`Unexpected argument: ${flag}`);
}
}
return args;
}
function resolveMaybePath(value, baseDir) {
if (!value) return value;
return path.isAbsolute(value) ? value : path.resolve(baseDir, value);
}
export function resolveSourceCaptureJob(args, options = {}) {
const appRoot = path.resolve(options.appRoot || process.cwd());
const workspaceRoot = path.resolve(options.workspaceRoot || findWorkspaceRoot(appRoot));
const url = args.url;
if (!url) throw new Error('A URL is required. Pass <url> or --url <url>.');
const theme = args.theme || inferThemeFromUrl(url);
const outputDir = resolveMaybePath(
args.output || path.join(appRoot, '.local', `theme-capture-${theme}`),
appRoot,
);
const storageState = resolveMaybePath(args.storageState, appRoot);
return {
...DEFAULT_SOURCE_CAPTURE_OPTIONS,
...Object.fromEntries(Object.entries(args).filter(([, value]) => value !== undefined)),
theme,
url,
appRoot,
workspaceRoot,
outputDir,
storageState,
waitForSelectors: normalizeSelectorList(args.waitForSelectors),
dismissSelectors: normalizeSelectorList(args.dismissSelectors),
removeSelectors: normalizeSelectorList(args.removeSelectors),
headers: args.headers || {},
};
}
async function loadPlaywright() {
try {
return await import('playwright');
} catch {
const cachedPath = path.join(PLAYWRIGHT_CACHE_DIR, 'node_modules', 'playwright', 'index.mjs');
if (fs.existsSync(cachedPath)) {
return import(pathToFileURL(cachedPath).href);
}
}
throw new Error(
'Playwright is not available. Install dependencies with pnpm install, or run an existing capture tool once to populate the shared cache.',
);
}
async function openPage(playwright, job, viewport) {
let browser;
let context;
let page;
let ownsContext = true;
if (job.connectCdp) {
browser = await playwright.chromium.connectOverCDP(job.connectCdp);
context = browser.contexts()[0] || await browser.newContext();
ownsContext = browser.contexts()[0] !== context;
page = await context.newPage();
await page.setViewportSize(viewport);
await context.setExtraHTTPHeaders(job.headers || {});
} else {
browser = await playwright.chromium.launch(resolveChromiumLaunchOptions(job));
context = await browser.newContext({
viewport,
deviceScaleFactor: job.deviceScaleFactor,
storageState: job.storageState && fs.existsSync(job.storageState) ? job.storageState : undefined,
extraHTTPHeaders: job.headers,
ignoreHTTPSErrors: true,
});
page = await context.newPage();
}
return { browser, context, page, ownsContext };
}
async function closePageSession(session, job) {
await session.page.close().catch(() => {});
if (!job.connectCdp && session.ownsContext) {
await session.context.close().catch(() => {});
}
if (!job.connectCdp) {
await session.browser.close().catch(() => {});
}
}
async function injectCaptureCss(page, job) {
const scrollbarCss = job.hideScrollbar
? `
html, body {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
*::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
}
`
: '';
const removeCss = (job.removeSelectors || [])
.map((selector) => `${selector} { display: none !important; visibility: hidden !important; }`)
.join('\n');
await page.addStyleTag({
content: `
${scrollbarCss}
* {
caret-color: transparent !important;
}
${removeCss}
`,
});
}
async function dismissOverlays(page, selectors) {
for (const selector of selectors || []) {
try {
await page.locator(selector).first().click({ timeout: 1500 });
await page.waitForTimeout(300);
} catch {
// Optional dismiss selectors should never fail a source capture.
}
}
}
async function waitForSelectors(page, selectors, timeoutMs) {
for (const selector of selectors || []) {
await page.waitForSelector(selector, { timeout: timeoutMs, state: 'attached' });
}
}
async function waitForFontsAndImages(page, timeoutMs) {
await page.evaluate(async (timeout) => {
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const withTimeout = (promise) => Promise.race([promise, delay(timeout)]);
if (document.fonts?.ready) {
await withTimeout(document.fonts.ready);
}
const pendingImages = Array.from(document.images).filter((image) => !image.complete);
await withTimeout(Promise.all(pendingImages.map((image) => new Promise((resolve) => {
image.addEventListener('load', resolve, { once: true });
image.addEventListener('error', resolve, { once: true });
}))));
}, Math.min(timeoutMs, 8000));
}
async function scrollWarmup(page, job) {
return page.evaluate(async ({ maxScrollSteps }) => {
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const scrollingElement = document.scrollingElement || document.documentElement;
const step = Math.max(600, Math.floor(window.innerHeight * 0.85));
let previousHeight = 0;
let stableHeightPasses = 0;
let steps = 0;
while (steps < maxScrollSteps) {
const scrollHeight = scrollingElement.scrollHeight;
if (scrollHeight === previousHeight) stableHeightPasses += 1;
else stableHeightPasses = 0;
previousHeight = scrollHeight;
const nextY = Math.min(window.scrollY + step, scrollHeight - window.innerHeight);
window.scrollTo(0, nextY);
steps += 1;
await delay(220);
if (nextY >= scrollHeight - window.innerHeight && stableHeightPasses >= 2) {
break;
}
}
window.scrollTo(0, 0);
await delay(500);
return { steps, scrollHeight: scrollingElement.scrollHeight };
}, { maxScrollSteps: job.maxScrollSteps });
}
async function preparePage(page, job) {
await page.goto(job.url, { waitUntil: job.waitUntil, timeout: job.timeoutMs });
await injectCaptureCss(page, job);
await waitForSelectors(page, job.waitForSelectors, job.timeoutMs);
await dismissOverlays(page, job.dismissSelectors);
if (job.waitAfterLoadMs > 0) {
await page.waitForTimeout(job.waitAfterLoadMs);
}
await waitForFontsAndImages(page, job.settleTimeoutMs);
const warmup = job.scrollWarmup ? await scrollWarmup(page, job) : null;
await waitForFontsAndImages(page, job.settleTimeoutMs);
await injectCaptureCss(page, job);
return warmup;
}
export function summarizeTheme(theme) {
const summary = {
colors: theme.colors || {},
typography: theme.typography || {},
spacing: theme.spacing || [],
radius: theme.radius || [],
lineWidth: theme.lineWidth || [],
shadow: theme.shadow || {},
transitions: theme.transitions || [],
animations: theme.animations || [],
cssVariables: theme.cssVariables || {},
};
return summary;
}
async function extractThemeTokens(page, job) {
return page.evaluate((selector) => {
const makeBucket = () => new Map();
const add = (bucket, value, tag) => {
if (!value) return;
const trimmed = String(value).trim();
if (!trimmed) return;
if (!bucket.has(trimmed)) bucket.set(trimmed, { count: 0, tags: new Set() });
const item = bucket.get(trimmed);
item.count += 1;
if (tag) item.tags.add(tag);
};
const isZeroish = (value) => {
if (!value) return false;
const trimmed = value.trim();
if (trimmed === '0' || trimmed === '0px' || trimmed === '0%') return true;
const parts = trimmed.split(/\s+/);
return parts.length > 1 && parts.every((part) => part === '0' || part === '0px' || part === '0%');
};
const isTransparentColor = (value) => {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === 'transparent'
|| normalized === 'rgba(0, 0, 0, 0)'
|| normalized === 'rgba(0,0,0,0)';
};
const normalizeTags = (tags) => (tags.has('body') ? ['body'] : [...tags]);
const sortTopInPage = (bucket, limit = 10) =>
[...bucket.entries()]
.sort((a, b) => b[1].count - a[1].count)
.slice(0, limit)
.map(([value, obj]) => ({ value, count: obj.count, tags: normalizeTags(obj.tags) }));
const sortTextStyles = (bucket, limit = 10) =>
[...bucket.entries()]
.sort((a, b) => b[1].count - a[1].count)
.slice(0, limit)
.map(([value, obj]) => {
const [size, lineHeight, weight, letterSpacing] = value.split('||');
return { size, lineHeight, weight, letterSpacing, count: obj.count, tags: normalizeTags(obj.tags) };
});
const root = selector ? document.querySelector(selector) : document.documentElement;
if (!root) throw new Error(`Selector "${selector}" not found`);
const walkRoot = root.tagName === 'HTML' ? document.body : root;
const walker = document.createTreeWalker(walkRoot, NodeFilter.SHOW_ELEMENT);
const spacingBucket = makeBucket();
const colorBuckets = { background: makeBucket(), text: makeBucket(), border: makeBucket() };
const typographyBuckets = { family: makeBucket(), textStyle: makeBucket() };
const radiusBucket = makeBucket();
const lineWidthBucket = makeBucket();
const shadowBuckets = { box: makeBucket(), text: makeBucket() };
const animationBucket = makeBucket();
const transitionBucket = makeBucket();
const bgImageBucket = makeBucket();
let el = walker.currentNode;
while (el) {
const tag = el.tagName?.toLowerCase() || '';
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
const isVisible = rect.width > 0
&& rect.height > 0
&& style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(style.opacity || 1) !== 0;
if (isVisible) {
if (style.backgroundColor && !isTransparentColor(style.backgroundColor)) {
add(colorBuckets.background, style.backgroundColor, tag);
}
if (style.color && !isTransparentColor(style.color)) {
add(colorBuckets.text, style.color, tag);
}
[style.borderColor, style.borderTopColor, style.borderRightColor, style.borderBottomColor, style.borderLeftColor]
.forEach((color) => {
if (color && !isTransparentColor(color)) add(colorBuckets.border, color, tag);
});
add(typographyBuckets.family, style.fontFamily, tag);
if (style.fontSize && style.fontWeight && style.lineHeight) {
add(typographyBuckets.textStyle, `${style.fontSize}||${style.lineHeight}||${style.fontWeight}||${style.letterSpacing}`, tag);
}
const addSpacingParts = (value) => {
if (!value) return;
value.trim().split(/\s+/).filter(Boolean).forEach((part) => {
if (!isZeroish(part)) add(spacingBucket, part, tag);
});
};
addSpacingParts(style.margin);
addSpacingParts(style.padding);
if (style.gap && style.gap !== 'normal') addSpacingParts(style.gap);
if (style.borderRadius && !isZeroish(style.borderRadius)) add(radiusBucket, style.borderRadius, tag);
['borderWidth', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'].forEach((widthProp) => {
const value = style[widthProp];
if (value && !isZeroish(value)) add(lineWidthBucket, value, tag);
});
if (style.boxShadow && style.boxShadow !== 'none') add(shadowBuckets.box, style.boxShadow, tag);
if (style.textShadow && style.textShadow !== 'none') add(shadowBuckets.text, style.textShadow, tag);
if (style.animationName && style.animationName !== 'none') {
add(animationBucket, `${style.animationName}|${style.animationDuration}|${style.animationTimingFunction}`, tag);
}
if (style.transition && style.transition !== 'none' && !style.transition.startsWith('all 0s')) {
add(transitionBucket, style.transition, tag);
}
if (style.backgroundImage && style.backgroundImage !== 'none') add(bgImageBucket, style.backgroundImage, tag);
}
el = walker.nextNode();
}
const cssVariables = {};
const rootStyle = getComputedStyle(document.documentElement);
for (const sheet of [...document.styleSheets]) {
try {
for (const rule of [...(sheet.cssRules || [])]) {
if (rule instanceof CSSStyleRule && rule.selectorText === ':root') {
for (const prop of [...rule.style]) {
if (prop.startsWith('--')) cssVariables[prop] = rootStyle.getPropertyValue(prop).trim();
}
}
}
} catch {
// Ignore cross-origin stylesheets.
}
}
const images = [...document.querySelectorAll('img')].map((img) => {
const imgStyle = getComputedStyle(img);
return {
src: img.currentSrc || img.src,
alt: img.alt,
width: img.naturalWidth || null,
height: img.naturalHeight || null,
position: imgStyle.position,
zIndex: imgStyle.zIndex,
siblingImgCount: img.parentElement ? img.parentElement.querySelectorAll('img').length : 0,
};
});
return {
colors: {
background: sortTopInPage(colorBuckets.background, 10),
text: sortTopInPage(colorBuckets.text, 10),
border: sortTopInPage(colorBuckets.border, 8),
},
typography: {
families: sortTopInPage(typographyBuckets.family, 8),
textStyles: sortTextStyles(typographyBuckets.textStyle, 12),
},
spacing: sortTopInPage(spacingBucket, 18),
radius: sortTopInPage(radiusBucket, 8),
lineWidth: sortTopInPage(lineWidthBucket, 6),
shadow: {
box: sortTopInPage(shadowBuckets.box, 8),
text: sortTopInPage(shadowBuckets.text, 4),
},
animations: sortTopInPage(animationBucket, 8),
transitions: sortTopInPage(transitionBucket, 8),
cssVariables,
assets: {
backgroundImages: sortTopInPage(bgImageBucket, 12),
images,
svgCount: document.querySelectorAll('svg').length,
},
};
}, job.selector || null);
}
async function extractSamples(page) {
return page.evaluate(() => {
const rgbaToHex = (value) => {
const match = String(value || '').match(/rgba?\(([^)]+)\)/);
if (!match) return value;
const parts = match[1].split(',').map((part) => part.trim());
const [r, g, b] = parts.slice(0, 3).map(Number);
const alpha = parts[3] === undefined ? 1 : Number(parts[3]);
const hex = [r, g, b]
.map((num) => Math.max(0, Math.min(255, Math.round(num))).toString(16).padStart(2, '0'))
.join('');
return alpha === 1 ? `#${hex}` : `#${hex}${Math.round(alpha * 255).toString(16).padStart(2, '0')}`;
};
const sampleSelectors = ['header', 'nav', 'h1', 'h2', 'h3', 'button', 'a[href]', 'input', 'textarea', 'main section', 'footer'];
return sampleSelectors.flatMap((selector) => [...document.querySelectorAll(selector)].slice(0, 10).map((el) => {
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect();
return {
selector,
tag: el.tagName.toLowerCase(),
text: (el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 180),
color: rgbaToHex(style.color),
background: rgbaToHex(style.backgroundColor),
border: `${style.borderTopWidth} ${style.borderTopStyle} ${rgbaToHex(style.borderTopColor)}`,
radius: style.borderTopLeftRadius,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
fontWeight: style.fontWeight,
letterSpacing: style.letterSpacing,
padding: `${style.paddingTop} ${style.paddingRight} ${style.paddingBottom} ${style.paddingLeft}`,
margin: `${style.marginTop} ${style.marginRight} ${style.marginBottom} ${style.marginLeft}`,
width: Math.round(rect.width),
height: Math.round(rect.height),
};
}));
});
}
async function captureViewport(playwright, job, viewport, outputPath, { includeTokens = false } = {}) {
const session = await openPage(playwright, job, { width: viewport.width, height: viewport.height });
const startedAt = Date.now();
try {
const warmup = await preparePage(session.page, job);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
if (job.screenshot) {
await session.page.screenshot({
path: outputPath,
fullPage: true,
type: 'png',
animations: 'disabled',
caret: 'hide',
timeout: job.timeoutMs,
});
}
const state = await session.page.evaluate(() => {
const scrollingElement = document.scrollingElement || document.documentElement;
return {
url: window.location.href,
title: document.title,
viewport: { width: window.innerWidth, height: window.innerHeight },
scrollHeight: scrollingElement.scrollHeight,
nodeCount: document.querySelectorAll('*').length,
};
});
let theme = null;
let samples = [];
if (includeTokens && job.tokens) {
theme = await extractThemeTokens(session.page, job);
samples = await extractSamples(session.page);
}
return {
...state,
name: viewport.name,
screenshot: job.screenshot ? outputPath : null,
warmup,
durationMs: Date.now() - startedAt,
theme,
samples,
};
} finally {
await closePageSession(session, job);
}
}
async function captureSourceJob(playwright, job) {
fs.mkdirSync(job.outputDir, { recursive: true });
const responsiveDir = path.join(job.outputDir, 'responsive');
const viewport = { name: 'desktop', ...job.viewport };
const mainScreenshotPath = path.join(job.outputDir, 'screenshot.png');
const main = await captureViewport(playwright, job, viewport, mainScreenshotPath, { includeTokens: true });
const responsive = {};
if (job.responsive) {
fs.mkdirSync(responsiveDir, { recursive: true });
for (const item of job.responsiveViewports) {
const screenshotPath = path.join(responsiveDir, `${item.name}.png`);
responsive[item.name] = await captureViewport(playwright, job, item, screenshotPath, {
includeTokens: false,
});
}
}
const metadata = {
tool: 'capture-theme-source',
theme: job.theme,
requestedUrl: job.url,
finalUrl: main.url,
title: main.title,
viewport: job.viewport,
responsiveViewports: job.responsiveViewports,
outputDir: job.outputDir,
files: {
screenshot: job.screenshot ? 'screenshot.png' : null,
responsive: job.responsive ? Object.fromEntries(Object.keys(responsive).map((name) => [name, `responsive/${name}.png`])) : {},
theme: job.tokens ? 'theme.json' : null,
computedTokens: job.tokens ? 'computed-tokens.json' : null,
},
page: {
scrollHeight: main.scrollHeight,
nodeCount: main.nodeCount,
},
responsive: Object.fromEntries(Object.entries(responsive).map(([name, value]) => [
name,
{
url: value.url,
title: value.title,
viewport: value.viewport,
scrollHeight: value.scrollHeight,
nodeCount: value.nodeCount,
screenshot: path.relative(job.outputDir, value.screenshot),
},
])),
captureOptions: {
waitUntil: job.waitUntil,
waitAfterLoadMs: job.waitAfterLoadMs,
scrollWarmup: job.scrollWarmup,
selector: job.selector || null,
waitForSelectors: job.waitForSelectors,
dismissSelectors: job.dismissSelectors,
removeSelectors: job.removeSelectors,
connectCdp: Boolean(job.connectCdp),
browserExecutable: Boolean(job.browserExecutable),
storageState: Boolean(job.storageState),
},
capturedAt: new Date().toISOString(),
};
if (job.tokens && main.theme) {
fs.writeFileSync(path.join(job.outputDir, 'theme.json'), JSON.stringify(main.theme, null, 2));
fs.writeFileSync(path.join(job.outputDir, 'computed-tokens.json'), JSON.stringify({
page: {
url: main.url,
title: main.title,
viewport: main.viewport,
scrollHeight: main.scrollHeight,
nodeCount: main.nodeCount,
},
responsive: metadata.responsive,
tokens: summarizeTheme(main.theme),
samples: main.samples,
}, null, 2));
}
fs.writeFileSync(path.join(job.outputDir, 'meta.json'), JSON.stringify(metadata, null, 2));
return metadata;
}
function showHelp() {
console.log(`
Capture source evidence for building an Axhub Make theme.
Usage:
pnpm exec node scripts/capture-theme-source.mjs https://example.com --theme example
pnpm exec node scripts/capture-theme-source.mjs --url https://example.com --theme example -o .local/theme-capture-example
Outputs:
.local/theme-capture-<theme>/
screenshot.png
responsive/desktop.png
responsive/tablet.png
responsive/mobile.png
theme.json
computed-tokens.json
meta.json
Options:
--theme NAME Theme key used for the default output path
--url URL Source URL
-o, --output DIR Output directory. Default: .local/theme-capture-<theme>
--viewport WxH Desktop viewport. Default: 1440x900
--responsive-viewports LIST Comma list: desktop:1440x900,tablet:768x1024,mobile:390x844
--selector SEL Scope token extraction to a selector
--wait MS Extra wait after load. Default: 3000
--wait-until STATE load, domcontentloaded, or networkidle. Default: domcontentloaded
--wait-for-selector SEL Wait for selector before capture. Repeatable
--dismiss-selector SEL Click optional overlay accept/close selector. Repeatable
--remove-selector SEL Hide noisy selector before capture. Repeatable
--connect-cdp URL Reuse a running Chrome, for example http://localhost:9222
--browser-executable PATH Use a specific Chrome/Chromium executable
--storage-state FILE Playwright storageState JSON
--no-responsive Skip responsive screenshots
--no-screenshot Skip screenshot files
--no-tokens Skip theme.json and computed-tokens.json
--dry-run Print resolved job without opening a browser
`);
}
async function runCli() {
const args = parseSourceCaptureArgs(process.argv);
if (args.help) {
showHelp();
return;
}
const job = resolveSourceCaptureJob(args);
if (args.dryRun) {
console.log(JSON.stringify(job, null, 2));
return;
}
const playwright = await loadPlaywright();
console.log(`[theme-source] ${job.theme} ${job.url}`);
const metadata = await captureSourceJob(playwright, job);
console.log(`[theme-source] wrote ${metadata.outputDir}`);
console.log('[theme-source] files: screenshot.png, responsive/, theme.json, computed-tokens.json, meta.json');
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
runCli().catch((error) => {
console.error(`[theme-source] ${error.stack || error.message || error}`);
process.exitCode = 1;
});
}