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;
});
}

View 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
View 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()

View File

@@ -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;

View File

@@ -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;

View File

@@ -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),

View File

@@ -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;