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:
824
scripts/capture-theme-source.mjs
Normal file
824
scripts/capture-theme-source.mjs
Normal 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;
|
||||
});
|
||||
}
|
||||
105
scripts/capture-theme-source.test.mjs
Normal file
105
scripts/capture-theme-source.test.mjs
Normal file
@@ -0,0 +1,105 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
inferThemeFromUrl,
|
||||
parseResponsiveViewports,
|
||||
parseSourceCaptureArgs,
|
||||
resolveSourceCaptureJob,
|
||||
summarizeTheme,
|
||||
} from './capture-theme-source.mjs';
|
||||
|
||||
describe('capture-theme-source CLI planning', () => {
|
||||
it('parses source capture options for screenshot and token evidence', () => {
|
||||
const args = parseSourceCaptureArgs([
|
||||
'node',
|
||||
'capture-theme-source.mjs',
|
||||
'https://www.trae.ai/',
|
||||
'--theme',
|
||||
'trae-ai',
|
||||
'--output',
|
||||
'.local/theme-capture-trae-ai',
|
||||
'--viewport',
|
||||
'1440x900',
|
||||
'--responsive-viewports',
|
||||
'desktop:1440x900,tablet:768x1024,mobile:390x844',
|
||||
'--wait-for-selector',
|
||||
'main',
|
||||
'--dismiss-selector',
|
||||
'button:has-text("Accept")',
|
||||
'--remove-selector',
|
||||
'.cookie-banner',
|
||||
'--header',
|
||||
'Authorization=Bearer token',
|
||||
'--connect-cdp',
|
||||
'http://localhost:9222',
|
||||
'--no-scroll-warmup',
|
||||
]);
|
||||
|
||||
expect(args.url).toBe('https://www.trae.ai/');
|
||||
expect(args.theme).toBe('trae-ai');
|
||||
expect(args.output).toBe('.local/theme-capture-trae-ai');
|
||||
expect(args.viewport).toEqual({ width: 1440, height: 900 });
|
||||
expect(args.responsiveViewports).toEqual([
|
||||
{ name: 'desktop', width: 1440, height: 900 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'mobile', width: 390, height: 844 },
|
||||
]);
|
||||
expect(args.waitForSelectors).toEqual(['main']);
|
||||
expect(args.dismissSelectors).toEqual(['button:has-text("Accept")']);
|
||||
expect(args.removeSelectors).toEqual(['.cookie-banner']);
|
||||
expect(args.headers).toEqual({ Authorization: 'Bearer token' });
|
||||
expect(args.connectCdp).toBe('http://localhost:9222');
|
||||
expect(args.scrollWarmup).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves default output directory from a URL-derived theme key', () => {
|
||||
const appRoot = '/repo/client';
|
||||
const workspaceRoot = '/repo';
|
||||
const args = parseSourceCaptureArgs([
|
||||
'node',
|
||||
'capture-theme-source.mjs',
|
||||
'https://www.trae.ai/',
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
const job = resolveSourceCaptureJob(args, { appRoot, workspaceRoot });
|
||||
|
||||
expect(job.theme).toBe('trae');
|
||||
expect(job.outputDir).toBe(path.join(appRoot, '.local/theme-capture-trae'));
|
||||
expect(job.viewport).toEqual({ width: 1440, height: 900 });
|
||||
expect(job.responsive).toBe(true);
|
||||
expect(job.screenshot).toBe(true);
|
||||
expect(job.tokens).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes responsive viewport lists and URL theme inference', () => {
|
||||
expect(parseResponsiveViewports('wide:1600x900,narrow:375x812')).toEqual([
|
||||
{ name: 'wide', width: 1600, height: 900 },
|
||||
{ name: 'narrow', width: 375, height: 812 },
|
||||
]);
|
||||
expect(inferThemeFromUrl('https://www.example.co.uk/path')).toBe('example');
|
||||
});
|
||||
|
||||
it('keeps computed token summaries focused on theme evidence', () => {
|
||||
const summary = summarizeTheme({
|
||||
colors: { text: [{ value: 'rgb(255, 255, 255)', count: 2 }] },
|
||||
typography: { families: [{ value: 'Inter', count: 3 }] },
|
||||
spacing: [{ value: '16px', count: 4 }],
|
||||
ignored: 'not included',
|
||||
});
|
||||
|
||||
expect(summary).toEqual({
|
||||
colors: { text: [{ value: 'rgb(255, 255, 255)', count: 2 }] },
|
||||
typography: { families: [{ value: 'Inter', count: 3 }] },
|
||||
spacing: [{ value: '16px', count: 4 }],
|
||||
radius: [],
|
||||
lineWidth: [],
|
||||
shadow: {},
|
||||
transitions: [],
|
||||
animations: [],
|
||||
cssVariables: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
256
scripts/convert-word-doc.py
Normal file
256
scripts/convert-word-doc.py
Normal file
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert textutil Word HTML export to ct-word-doc seed JS module."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from bs4 import BeautifulSoup, NavigableString, Tag
|
||||
|
||||
DEFAULT_SEMANTIC = {
|
||||
"p1": "ct-word-title",
|
||||
"p2": "ct-word-contract-no",
|
||||
"p3": "ct-word-contract-no",
|
||||
"p5": "ct-word-party-line",
|
||||
"p6": "ct-word-party-line",
|
||||
"p7": "ct-word-party-line",
|
||||
"p8": "ct-word-party-line",
|
||||
}
|
||||
|
||||
|
||||
def parse_css_rules(css_text: str) -> Dict[str, str]:
|
||||
rules: Dict[str, str] = {}
|
||||
for block in re.findall(r"([^{]+)\{([^}]+)\}", css_text):
|
||||
selector = block[0].strip()
|
||||
props = block[1].strip()
|
||||
if "," in selector:
|
||||
continue
|
||||
selector = selector.lstrip(".")
|
||||
if re.match(r"^(p|span|td|table)\.", selector):
|
||||
rules[selector] = props
|
||||
return rules
|
||||
|
||||
|
||||
def is_red_style(style: str) -> bool:
|
||||
return bool(re.search(r"color\s*:\s*#ff0000", style, re.I))
|
||||
|
||||
|
||||
def build_red_classes(rules: Dict[str, str]) -> Set[str]:
|
||||
red = set()
|
||||
for cls, props in rules.items():
|
||||
if is_red_style(props):
|
||||
red.add(cls.split(".")[-1])
|
||||
return red
|
||||
|
||||
|
||||
def class_list(tag: Tag) -> List[str]:
|
||||
raw = tag.get("class") or []
|
||||
if isinstance(raw, str):
|
||||
return raw.split()
|
||||
return list(raw)
|
||||
|
||||
|
||||
def primary_class(tag: Tag) -> Optional[str]:
|
||||
classes = class_list(tag)
|
||||
for c in classes:
|
||||
if c in DEFAULT_SEMANTIC or re.match(r"^[pst]\d+$", c) or re.match(r"^td\d+$", c):
|
||||
return c
|
||||
return classes[0] if classes else None
|
||||
|
||||
|
||||
def merge_style(tag: Tag, rules: Dict[str, str]) -> None:
|
||||
parts: List[str] = []
|
||||
for c in class_list(tag):
|
||||
key_p = f"p.{c}" if c.startswith("p") else None
|
||||
key_s = f"span.{c}" if c.startswith("s") else None
|
||||
key_td = f"td.{c}" if c.startswith("td") else None
|
||||
key_t = f"table.{c}" if c.startswith("t") else None
|
||||
for key in (key_p, key_s, key_td, key_t):
|
||||
if key and key in rules:
|
||||
parts.append(rules[key])
|
||||
if parts:
|
||||
existing = tag.get("style", "")
|
||||
merged = ";".join([existing] + parts) if existing else ";".join(parts)
|
||||
tag["style"] = merged
|
||||
|
||||
|
||||
def strip_apple_noise(soup: BeautifulSoup) -> None:
|
||||
for el in soup.find_all(class_="Apple-converted-space"):
|
||||
el.replace_with("\u00a0" * max(1, len(el.get_text())))
|
||||
for el in soup.find_all(class_="Apple-tab-span"):
|
||||
el.replace_with("\t")
|
||||
|
||||
|
||||
def wrap_risk_redlines(soup: BeautifulSoup, red_classes: Set[str]) -> None:
|
||||
risk_seq = 0
|
||||
|
||||
def next_id() -> str:
|
||||
nonlocal risk_seq
|
||||
risk_seq += 1
|
||||
return f"risk-{risk_seq}"
|
||||
|
||||
def is_red_tag(tag: Tag) -> bool:
|
||||
pc = primary_class(tag)
|
||||
return pc in red_classes if pc else False
|
||||
|
||||
for p in list(soup.find_all("p")):
|
||||
if not is_red_tag(p):
|
||||
continue
|
||||
if p.find_parent(class_=lambda x: x and "ct-risk-redline" in x):
|
||||
continue
|
||||
inner = list(p.contents)
|
||||
if not inner:
|
||||
continue
|
||||
wrapper = soup.new_tag(
|
||||
"span",
|
||||
attrs={
|
||||
"class": "ct-risk-redline",
|
||||
"data-risk-redline": "1",
|
||||
"data-risk-id": next_id(),
|
||||
},
|
||||
)
|
||||
for child in inner:
|
||||
wrapper.append(child.extract() if isinstance(child, Tag) else child)
|
||||
p.clear()
|
||||
p.append(wrapper)
|
||||
|
||||
for span in list(soup.find_all("span")):
|
||||
if not is_red_tag(span):
|
||||
continue
|
||||
if span.find_parent(class_=lambda x: x and "ct-risk-redline" in x):
|
||||
continue
|
||||
parent_p = span.find_parent("p")
|
||||
if parent_p and is_red_tag(parent_p):
|
||||
continue
|
||||
wrapper = soup.new_tag(
|
||||
"span",
|
||||
attrs={
|
||||
"class": "ct-risk-redline",
|
||||
"data-risk-redline": "1",
|
||||
"data-risk-id": next_id(),
|
||||
},
|
||||
)
|
||||
span.wrap(wrapper)
|
||||
|
||||
|
||||
def apply_semantic_classes(root: Tag, semantic: Dict[str, str]) -> None:
|
||||
for tag in root.find_all(True):
|
||||
pc = primary_class(tag)
|
||||
if pc and pc in semantic:
|
||||
classes = class_list(tag)
|
||||
extra = semantic[pc]
|
||||
if extra not in classes:
|
||||
tag["class"] = classes + [extra]
|
||||
|
||||
|
||||
def map_tables(root: Tag) -> None:
|
||||
for i, table in enumerate(root.find_all("table")):
|
||||
classes = class_list(table)
|
||||
if "ct-word-table" not in classes:
|
||||
table["class"] = classes + ["ct-doc-table", "ct-word-table"]
|
||||
for td in table.find_all("td"):
|
||||
td["class"] = class_list(td) + ["ct-word-td"]
|
||||
|
||||
|
||||
def apply_template_vars(html: str) -> str:
|
||||
regex_replacements = [
|
||||
(r"合同编号<span[^>]*>:</span>【LNZLHT\s*<span[^>]*>[\s\S]*?</span>\s*】",
|
||||
"合同编号:【LNZLHT {{contractCode}} 】"),
|
||||
(r"合同编号:【LNZLHT[^】]*】", "合同编号:【LNZLHT {{contractCode}} 】"),
|
||||
(r"协议编号:【LNZLHT[^】]*】", "协议编号:【LNZLHT {{contractCode}} 】"),
|
||||
]
|
||||
literal_replacements = [
|
||||
("甲方(出租方):羚牛氢能科技(广东)有限公司", "甲方(出租方):{{lessorName}}"),
|
||||
("甲方(出租方): 羚牛氢能科技(广东)有限公司", "甲方(出租方): {{lessorName}}"),
|
||||
("甲方:羚牛氢能科技(广东)有限公司", "甲方:{{lessorName}}"),
|
||||
("致:羚牛氢能科技(广东)有限公司", "致:{{lessorName}}"),
|
||||
("甲方(车辆提供方):", "甲方(车辆提供方):{{lessorName}}"),
|
||||
("乙方(车辆使用方):", "乙方(车辆使用方):{{customerName}}"),
|
||||
("乙方(承租方):</b>", "乙方(承租方): {{customerName}}</b>"),
|
||||
("乙方(承租方):</b>", "乙方(承租方):{{customerName}}</b>"),
|
||||
]
|
||||
out = html
|
||||
for pattern, repl in regex_replacements:
|
||||
out = re.sub(pattern, repl, out)
|
||||
for old, new in literal_replacements:
|
||||
out = out.replace(old, new)
|
||||
return out
|
||||
|
||||
|
||||
def scope_css(css_text: str) -> str:
|
||||
scoped = []
|
||||
for block in re.findall(r"([^{]+)\{([^}]+)\}", css_text):
|
||||
selector = block[0].strip()
|
||||
props = block[1].strip()
|
||||
if selector.startswith("@") or "," in selector:
|
||||
continue
|
||||
scoped.append(f".ct-word-doc--v266 {selector}{{{props}}}")
|
||||
return "\n".join(scoped)
|
||||
|
||||
|
||||
def convert_html(raw: str) -> str:
|
||||
css_match = re.search(r"<style[^>]*>([\s\S]*?)</style>", raw)
|
||||
body_match = re.search(r"<body>([\s\S]*?)</body>", raw)
|
||||
if not css_match or not body_match:
|
||||
raise SystemExit("Invalid source HTML")
|
||||
|
||||
rules = parse_css_rules(css_match.group(1))
|
||||
red_classes = build_red_classes(rules)
|
||||
scoped = scope_css(css_match.group(1))
|
||||
|
||||
soup = BeautifulSoup(body_match.group(1), "lxml")
|
||||
strip_apple_noise(soup)
|
||||
|
||||
for tag in soup.find_all(True):
|
||||
merge_style(tag, rules)
|
||||
|
||||
wrap_risk_redlines(soup, red_classes)
|
||||
apply_semantic_classes(soup, DEFAULT_SEMANTIC)
|
||||
map_tables(soup)
|
||||
|
||||
body_html = "".join(str(c) for c in (soup.body or soup).contents)
|
||||
body_html = apply_template_vars(body_html)
|
||||
body_html = re.sub(r"<p class=\"p\d+\"><br\s*/?></p>\s*", "", body_html, count=3)
|
||||
|
||||
return (
|
||||
'<div class="ct-word-doc ct-word-doc--v266">'
|
||||
f"<style>{scoped}</style>"
|
||||
f"{body_html}"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
def export_docx(docx: Path, out: Path, export_name: str) -> None:
|
||||
with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
subprocess.run(
|
||||
["textutil", "-convert", "html", "-output", str(tmp_path), str(docx)],
|
||||
check=True,
|
||||
)
|
||||
raw = tmp_path.read_text(encoding="utf-8")
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
html = convert_html(raw)
|
||||
escaped = json.dumps(html, ensure_ascii=False)
|
||||
out.write_text(
|
||||
f"// AUTO-GENERATED from {docx.name} — do not edit by hand\n"
|
||||
f"export var {export_name} = {escaped};\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"Wrote {out} ({out.stat().st_size} bytes), redlines={html.count('data-risk-redline')}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("docx", type=Path)
|
||||
parser.add_argument("out", type=Path)
|
||||
parser.add_argument("export_name")
|
||||
args = parser.parse_args()
|
||||
export_docx(args.docx, args.out, args.export_name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,9 +4,9 @@ const baseUrl = process.argv[2] || 'http://localhost:51720';
|
||||
const targets = process.argv.slice(3).length > 0
|
||||
? process.argv.slice(3)
|
||||
: [
|
||||
'/prototypes/ref-antd',
|
||||
'/prototypes/ref-app-home',
|
||||
'/themes/antd-new',
|
||||
'/prototypes/annotation-demo',
|
||||
'/prototypes/beginner-guide',
|
||||
'/themes/apple',
|
||||
];
|
||||
|
||||
let hasFailure = false;
|
||||
@@ -23,20 +23,21 @@ for (const target of targets) {
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
const htmlProxyMatches = Array.from(
|
||||
html.matchAll(/src="([^"]*html-proxy[^"]*)"/g),
|
||||
const previewLoaderMatches = Array.from(
|
||||
html.matchAll(/src="([^"]*__axhub-preview-loader\.js[^"]*)"/g),
|
||||
(match) => match[1],
|
||||
);
|
||||
const loaderProxy = htmlProxyMatches.find((value) => value.includes('index=0.js')) || htmlProxyMatches[0] || null;
|
||||
const previewLoader = previewLoaderMatches[0] || null;
|
||||
|
||||
let loaderScript = '';
|
||||
if (loaderProxy) {
|
||||
loaderScript = await fetch(new URL(loaderProxy, baseUrl)).then((res) => res.text());
|
||||
if (previewLoader) {
|
||||
loaderScript = await fetch(new URL(previewLoader, baseUrl)).then((res) => res.text());
|
||||
}
|
||||
|
||||
const ok = response.ok
|
||||
&& html.includes('<div id="root"></div>')
|
||||
&& htmlProxyMatches.length >= 1
|
||||
&& previewLoaderMatches.length === 1
|
||||
&& !html.includes('html-proxy')
|
||||
&& !html.includes('waitForBootstrap')
|
||||
&& loaderScript.includes('import PreviewComponent from')
|
||||
&& loaderScript.includes('import.meta.hot.accept(')
|
||||
@@ -47,9 +48,10 @@ for (const target of targets) {
|
||||
console.error(`[preview-smoke] FAIL ${requestUrl}`);
|
||||
console.error(` status=${response.status}`);
|
||||
console.error(` containsRoot=${html.includes('<div id="root"></div>')}`);
|
||||
console.error(` htmlProxyCount=${htmlProxyMatches.length}`);
|
||||
console.error(` previewLoaderCount=${previewLoaderMatches.length}`);
|
||||
console.error(` removedHtmlProxy=${!html.includes('html-proxy')}`);
|
||||
console.error(` removedLegacyLoader=${!html.includes('waitForBootstrap')}`);
|
||||
console.error(` loaderProxy=${Boolean(loaderProxy)}`);
|
||||
console.error(` previewLoader=${Boolean(previewLoader)}`);
|
||||
console.error(` loaderImportsEntry=${loaderScript.includes('import PreviewComponent from')}`);
|
||||
console.error(` loaderHasAcceptBoundary=${loaderScript.includes('import.meta.hot.accept(')}`);
|
||||
continue;
|
||||
|
||||
7
scripts/sync-project-metadata.d.ts
vendored
7
scripts/sync-project-metadata.d.ts
vendored
@@ -4,6 +4,13 @@ export const PRODUCT_NAME: string;
|
||||
export const DEFAULT_CLIENT_ORIGIN: string;
|
||||
export const DETERMINISTIC_UPDATED_AT: string;
|
||||
export const resourceLayout: Record<string, string[]>;
|
||||
export const PROTOTYPE_PLACEHOLDER_GUIDE: {
|
||||
kind: string;
|
||||
title: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
tips: string[];
|
||||
};
|
||||
|
||||
export function normalizeMakeClientProjectIdentity(project: unknown): {
|
||||
id: string;
|
||||
|
||||
@@ -18,14 +18,12 @@ export const DETERMINISTIC_UPDATED_AT = '2026-05-03T00:00:00.000Z';
|
||||
|
||||
export const resourceLayout = {
|
||||
prototypes: ['src/prototypes'],
|
||||
docs: ['src/resources'],
|
||||
themes: ['src/themes'],
|
||||
media: ['src/resources/assets'],
|
||||
};
|
||||
|
||||
export const resourceWriteTargets = {
|
||||
prototypes: { type: 'project-relative-path', path: resourceLayout.prototypes[0] },
|
||||
docs: { type: 'project-relative-path', path: resourceLayout.docs[0] },
|
||||
themes: { type: 'project-relative-path', path: resourceLayout.themes[0] },
|
||||
media: { type: 'project-relative-path', path: resourceLayout.media[0] },
|
||||
};
|
||||
@@ -35,6 +33,22 @@ export const localExportCapabilities = {
|
||||
make: false,
|
||||
};
|
||||
|
||||
export const PROTOTYPE_PLACEHOLDER_GUIDE = {
|
||||
kind: 'prototype-empty',
|
||||
title: '这个原型还没有开始创建',
|
||||
description: '告诉 AI 你想做什么:目标用户、使用场景、页面内容和参考风格。',
|
||||
steps: [
|
||||
'在本地 AI 软件中打开本页面',
|
||||
'打开草稿创作原型',
|
||||
],
|
||||
tips: [
|
||||
'模型不要用 auto,推荐:Claude Opus 4.8、Gemini 3.1 Pro、GPT-5.5、Kimi K2.7、GLM-5.2。',
|
||||
'一个任务开一个新对话,避免多个需求互相干扰。',
|
||||
'多用图片和语音描述,截图、草图和参考页面通常比长文字更清楚。',
|
||||
'如果已有视觉规范,建议先创建设计系统。',
|
||||
],
|
||||
};
|
||||
|
||||
// Snapshot from https://getdesign.md/api/cli/downloads?brands=...
|
||||
const GETDESIGN_DOWNLOAD_SNAPSHOT_DATE = '2026-05-15';
|
||||
const GETDESIGN_THEME_STATS_BY_ID = {
|
||||
@@ -228,6 +242,37 @@ function readDisplayName(indexFilePath, fallback) {
|
||||
return displayName || fallback;
|
||||
}
|
||||
|
||||
function hasGeneratedPlaceholderSource(indexFilePath) {
|
||||
if (!fs.existsSync(indexFilePath)) return false;
|
||||
const source = fs.readFileSync(indexFilePath, 'utf8');
|
||||
const hasGeneratedShell = source.includes('placeholder-empty-page')
|
||||
&& source.includes('打开左侧默认引导页继续创建')
|
||||
&& source.includes('export default function Placeholder');
|
||||
return hasGeneratedShell && (
|
||||
source.includes('@axhub-placeholder prototype-empty')
|
||||
|| source.includes('className="placeholder-empty-page"')
|
||||
);
|
||||
}
|
||||
|
||||
function hasEmptyCanvasFile(prototypeDir) {
|
||||
const canvasPath = path.join(prototypeDir, 'canvas.excalidraw');
|
||||
if (!fs.existsSync(canvasPath)) return true;
|
||||
try {
|
||||
const canvas = JSON.parse(fs.readFileSync(canvasPath, 'utf8'));
|
||||
const elements = Array.isArray(canvas?.elements) ? canvas.elements : [];
|
||||
const files = canvas?.files && typeof canvas.files === 'object' && !Array.isArray(canvas.files)
|
||||
? canvas.files
|
||||
: {};
|
||||
return elements.length === 0 && Object.keys(files).length === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isGeneratedEmptyPrototypePlaceholder(prototypeDir, indexFilePath) {
|
||||
return hasGeneratedPlaceholderSource(indexFilePath) && hasEmptyCanvasFile(prototypeDir);
|
||||
}
|
||||
|
||||
function getLiteralPropertyValue(objectLiteral, propertyName) {
|
||||
const property = objectLiteral.properties.find((candidate) => (
|
||||
ts.isPropertyAssignment(candidate)
|
||||
@@ -484,6 +529,7 @@ function collectPrototypes(projectRoot, clientOrigin, options = {}) {
|
||||
if (!fs.existsSync(indexFile)) continue;
|
||||
const filePath = toPosix(path.relative(projectRoot, indexFile));
|
||||
const route = extractHashRouteMetadata(path.join(root, entry.name));
|
||||
const placeholder = isGeneratedEmptyPrototypePlaceholder(path.join(root, entry.name), indexFile);
|
||||
const item = {
|
||||
id: entry.name,
|
||||
name: entry.name,
|
||||
@@ -495,6 +541,7 @@ function collectPrototypes(projectRoot, clientOrigin, options = {}) {
|
||||
filePath,
|
||||
...(options.includeAbsoluteFilePaths === false ? {} : { absoluteFilePath: path.resolve(indexFile) }),
|
||||
...(route ? { pages: route.pages, defaultPageId: route.defaultPageId } : {}),
|
||||
...(placeholder ? { placeholder: true, placeholderGuide: PROTOTYPE_PLACEHOLDER_GUIDE } : {}),
|
||||
};
|
||||
const artifacts = {
|
||||
...createFigmaArtifactMetadata(projectRoot, entry.name),
|
||||
@@ -510,32 +557,6 @@ function collectPrototypes(projectRoot, clientOrigin, options = {}) {
|
||||
return items.sort(sortById);
|
||||
}
|
||||
|
||||
function collectDocs(projectRoot, options = {}) {
|
||||
const docs = [];
|
||||
for (const root of resourceLayout.docs.map((dir) => path.resolve(projectRoot, dir))) {
|
||||
for (const filePath of listFiles(root, () => true)) {
|
||||
const relativePath = toPosix(path.relative(root, filePath));
|
||||
if (isIgnoredResourceRelativePath(relativePath)) continue;
|
||||
const isMarkdown = path.extname(filePath).toLowerCase() === '.md';
|
||||
const id = isMarkdown ? relativePath.replace(/\.md$/iu, '') : relativePath;
|
||||
docs.push({
|
||||
id,
|
||||
name: id,
|
||||
title: isMarkdown
|
||||
? titleFromMarkdown(filePath, path.basename(filePath, '.md'))
|
||||
: relativePath.replace(/\.[^.]+$/u, ''),
|
||||
path: options.includeAbsoluteFilePaths === false
|
||||
? toPosix(path.relative(projectRoot, filePath))
|
||||
: path.resolve(filePath),
|
||||
description: '',
|
||||
updatedAt: DETERMINISTIC_UPDATED_AT,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return docs.sort(sortById);
|
||||
}
|
||||
|
||||
function collectThemes(projectRoot, clientOrigin) {
|
||||
const items = [];
|
||||
for (const root of resourceLayout.themes.map((dir) => path.resolve(projectRoot, dir))) {
|
||||
@@ -576,7 +597,6 @@ export function buildMakeProjectMetadata(projectRoot, options = {}) {
|
||||
const clientOrigin = String(options.clientOrigin ?? DEFAULT_CLIENT_ORIGIN).replace(/\/+$/u, '');
|
||||
const projectIdentity = readMakeClientProjectIdentity(projectRoot);
|
||||
const prototypes = collectPrototypes(projectRoot, clientOrigin, options);
|
||||
const docs = collectDocs(projectRoot, options);
|
||||
const themes = collectThemes(projectRoot, clientOrigin);
|
||||
|
||||
return {
|
||||
@@ -587,12 +607,10 @@ export function buildMakeProjectMetadata(projectRoot, options = {}) {
|
||||
},
|
||||
resources: {
|
||||
prototypes,
|
||||
docs,
|
||||
themes,
|
||||
},
|
||||
navigation: {
|
||||
prototypes: prototypes.map((item) => item.id),
|
||||
docs: docs.map((item) => item.id),
|
||||
},
|
||||
orders: {
|
||||
themes: themes.map((item) => item.id),
|
||||
|
||||
7
scripts/sync-project-metadata.mjs.d.ts
vendored
7
scripts/sync-project-metadata.mjs.d.ts
vendored
@@ -4,6 +4,13 @@ export const PRODUCT_NAME: string;
|
||||
export const DEFAULT_CLIENT_ORIGIN: string;
|
||||
export const DETERMINISTIC_UPDATED_AT: string;
|
||||
export const resourceLayout: Record<string, string[]>;
|
||||
export const PROTOTYPE_PLACEHOLDER_GUIDE: {
|
||||
kind: string;
|
||||
title: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
tips: string[];
|
||||
};
|
||||
|
||||
export function normalizeMakeClientProjectIdentity(project: unknown): {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user