import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; import { readServerInfo } from './utils/serverInfo.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export const PROJECT_ID = 'make-project'; export const PROJECT_NAME = ''; export const PRODUCT_NAME = 'Axhub Make'; export const MAKE_CLIENT_MARKER_KIND = 'axhub-make-client'; export const MAKE_CLIENT_MARKER_RELATIVE_PATH = '.axhub/make/client.json'; export const DEFAULT_CLIENT_ORIGIN = 'http://localhost:51720'; export const DETERMINISTIC_UPDATED_AT = '2026-05-03T00:00:00.000Z'; export const resourceLayout = { prototypes: ['src/prototypes'], themes: ['src/themes'], media: ['src/resources/assets'], }; export const resourceWriteTargets = { prototypes: { type: 'project-relative-path', path: resourceLayout.prototypes[0] }, themes: { type: 'project-relative-path', path: resourceLayout.themes[0] }, media: { type: 'project-relative-path', path: resourceLayout.media[0] }, }; export const localExportCapabilities = { html: false, 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 = { 'airbnb': { sourceSlug: 'airbnb', downloads: 7111 }, 'airtable': { sourceSlug: 'airtable', downloads: 3446 }, 'apple': { sourceSlug: 'apple', downloads: 13995 }, 'binance': { sourceSlug: 'binance', downloads: 2179 }, 'bmw': { sourceSlug: 'bmw', downloads: 2917 }, 'bmw-m': { sourceSlug: 'bmw-m', downloads: 555 }, 'bugatti': { sourceSlug: 'bugatti', downloads: 1473 }, 'cal-com': { sourceSlug: 'cal', downloads: 3691 }, 'claude': { sourceSlug: 'claude', downloads: 10192 }, 'clay': { sourceSlug: 'clay', downloads: 3383 }, 'clickhouse': { sourceSlug: 'clickhouse', downloads: 2495 }, 'cohere': { sourceSlug: 'cohere', downloads: 3083 }, 'coinbase': { sourceSlug: 'coinbase', downloads: 3399 }, 'composio': { sourceSlug: 'composio', downloads: 2346 }, 'cursor': { sourceSlug: 'cursor', downloads: 4650 }, 'elevenlabs': { sourceSlug: 'elevenlabs', downloads: 3438 }, 'expo': { sourceSlug: 'expo', downloads: 2457 }, 'ferrari': { sourceSlug: 'ferrari', downloads: 3158 }, 'figma': { sourceSlug: 'figma', downloads: 4351 }, 'framer': { sourceSlug: 'framer', downloads: 3781 }, 'hashicorp': { sourceSlug: 'hashicorp', downloads: 2498 }, 'ibm': { sourceSlug: 'ibm', downloads: 3093 }, 'intercom': { sourceSlug: 'intercom', downloads: 3033 }, 'june': { sourceSlug: 'june', downloads: 0 }, 'kraken': { sourceSlug: 'kraken', downloads: 2738 }, 'lamborghini': { sourceSlug: 'lamborghini', downloads: 2632 }, 'linear': { sourceSlug: 'linear.app', downloads: 12010 }, 'lovable': { sourceSlug: 'lovable', downloads: 2907 }, 'mastercard': { sourceSlug: 'mastercard', downloads: 1275 }, 'meta': { sourceSlug: 'meta', downloads: 1927 }, 'minimax': { sourceSlug: 'minimax', downloads: 2742 }, 'mintlify': { sourceSlug: 'mintlify', downloads: 3271 }, 'miro': { sourceSlug: 'miro', downloads: 2587 }, 'mistral-ai': { sourceSlug: 'mistral.ai', downloads: 2481 }, 'mongodb': { sourceSlug: 'mongodb', downloads: 2399 }, 'nike': { sourceSlug: 'nike', downloads: 2158 }, 'notion': { sourceSlug: 'notion', downloads: 11442 }, 'nvidia': { sourceSlug: 'nvidia', downloads: 2610 }, 'ollama': { sourceSlug: 'ollama', downloads: 2602 }, 'opencode': { sourceSlug: 'opencode.ai', downloads: 3033 }, 'pinterest': { sourceSlug: 'pinterest', downloads: 2837 }, 'playstation': { sourceSlug: 'playstation', downloads: 1507 }, 'posthog': { sourceSlug: 'posthog', downloads: 2949 }, 'renault': { sourceSlug: 'renault', downloads: 2186 }, 'replicate': { sourceSlug: 'replicate', downloads: 2267 }, 'revolut': { sourceSlug: 'revolut', downloads: 3289 }, 'runway': { sourceSlug: 'runwayml', downloads: 2496 }, 'sanity': { sourceSlug: 'sanity', downloads: 2322 }, 'sentry': { sourceSlug: 'sentry', downloads: 2949 }, 'shopify': { sourceSlug: 'shopify', downloads: 2063 }, 'slack': { sourceSlug: 'slack', downloads: 0 }, 'spacex': { sourceSlug: 'spacex', downloads: 3053 }, 'spotify': { sourceSlug: 'spotify', downloads: 4283 }, 'starbucks': { sourceSlug: 'starbucks', downloads: 1100 }, 'stripe': { sourceSlug: 'stripe', downloads: 9401 }, 'supabase': { sourceSlug: 'supabase', downloads: 4044 }, 'superhuman': { sourceSlug: 'superhuman', downloads: 3186 }, 'tesla': { sourceSlug: 'tesla', downloads: 3228 }, 'the-verge': { sourceSlug: 'theverge', downloads: 1508 }, 'together-ai': { sourceSlug: 'together.ai', downloads: 2352 }, 'uber': { sourceSlug: 'uber', downloads: 2933 }, 'vercel': { sourceSlug: 'vercel', downloads: 10946 }, 'vodafone': { sourceSlug: 'vodafone', downloads: 750 }, 'voltagent': { sourceSlug: 'voltagent', downloads: 2847 }, 'warp': { sourceSlug: 'warp', downloads: 2541 }, 'webflow': { sourceSlug: 'webflow', downloads: 2596 }, 'wired': { sourceSlug: 'wired', downloads: 1488 }, 'wise': { sourceSlug: 'wise', downloads: 3274 }, 'xai': { sourceSlug: 'x.ai', downloads: 2599 }, 'zapier': { sourceSlug: 'zapier', downloads: 2597 }, }; function toPosix(input) { return String(input || '').replace(/\\/g, '/'); } function sortById(left, right) { return left.id.localeCompare(right.id); } function getThemeStats(themeId) { return GETDESIGN_THEME_STATS_BY_ID[themeId] || null; } function sortThemesByGetDesignDownloads(left, right) { const leftStats = getThemeStats(left.id); const rightStats = getThemeStats(right.id); if (leftStats && rightStats) { return rightStats.downloads - leftStats.downloads || sortById(left, right); } if (leftStats) return -1; if (rightStats) return 1; return sortById(left, right); } function readJson(filePath) { if (!fs.existsSync(filePath)) return null; try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; } } function stringValue(value) { return typeof value === 'string' ? value.trim() : ''; } function isLegacyOfficialProjectName(projectId, name) { return projectId === PROJECT_ID && (name === 'Axhub Make' || name === 'Axhub-Make'); } export function normalizeMakeClientProjectIdentity(project) { const rawProject = project && typeof project === 'object' && !Array.isArray(project) ? project : {}; const id = stringValue(rawProject.id) || PROJECT_ID; const name = typeof rawProject.name === 'string' ? rawProject.name.trim() : ''; return { id, name: isLegacyOfficialProjectName(id, name) ? '' : name, }; } const PAGE_ID_RE = /^[a-z0-9-]+$/u; function normalizePageId(value) { const id = stringValue(value); return PAGE_ID_RE.test(id) ? id : ''; } export function readMakeClientProjectIdentity(projectRoot) { const marker = readJson(path.join(projectRoot, MAKE_CLIENT_MARKER_RELATIVE_PATH)); const project = marker?.project && typeof marker.project === 'object' && !Array.isArray(marker.project) ? marker.project : {}; const id = stringValue(project.id); if (marker?.schemaVersion !== 1 || marker?.kind !== MAKE_CLIENT_MARKER_KIND || !id) { return { id: PROJECT_ID, name: PROJECT_NAME, }; } return normalizeMakeClientProjectIdentity(project); } function writeJsonAtomic(filePath, value) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; try { fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); fs.renameSync(tempPath, filePath); } finally { if (fs.existsSync(tempPath)) { fs.unlinkSync(tempPath); } } } function listFiles(rootDir, predicate) { if (!fs.existsSync(rootDir)) return []; const result = []; for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) { if (entry.name.startsWith('.')) continue; const fullPath = path.join(rootDir, entry.name); if (entry.isDirectory()) { result.push(...listFiles(fullPath, predicate)); continue; } if (entry.isFile() && predicate(fullPath)) { result.push(fullPath); } } return result.sort((a, b) => toPosix(a).localeCompare(toPosix(b))); } function isIgnoredResourceRelativePath(relativePath) { const normalized = toPosix(relativePath).replace(/^\/+|\/+$/g, ''); if (!normalized) return true; if (normalized.toLowerCase() === 'readme.md') return true; return normalized.split('/').some((segment) => segment.startsWith('.')); } function readDisplayName(indexFilePath, fallback) { if (!fs.existsSync(indexFilePath)) return fallback; const source = fs.readFileSync(indexFilePath, 'utf8'); const displayName = source.match(/@name\s+([^\n]+)/)?.[1]?.replace(/\*\/\s*$/u, '').trim(); 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) && ( (ts.isIdentifier(candidate.name) && candidate.name.text === propertyName) || (ts.isStringLiteral(candidate.name) && candidate.name.text === propertyName) ) )); if (!property || !ts.isPropertyAssignment(property)) { return null; } const initializer = property.initializer; return ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer) ? initializer.text.trim() : null; } function extractHashRouteFromCall(callExpression) { const expression = callExpression.expression; if (!ts.isIdentifier(expression) || expression.text !== 'defineHashPageRoute') { return null; } const pagesArg = callExpression.arguments[0]; if (!pagesArg || !ts.isArrayLiteralExpression(pagesArg)) { return null; } const pages = []; for (const element of pagesArg.elements) { if (!ts.isObjectLiteralExpression(element)) { continue; } const id = normalizePageId(getLiteralPropertyValue(element, 'id')); const title = stringValue(getLiteralPropertyValue(element, 'title')); if (id && title) { pages.push({ id, title }); } } if (!pages.length) { return null; } const optionsArg = callExpression.arguments[1]; const requestedDefaultPageId = optionsArg && ts.isObjectLiteralExpression(optionsArg) ? normalizePageId(getLiteralPropertyValue(optionsArg, 'defaultPageId')) : ''; const defaultPageId = pages.some((page) => page.id === requestedDefaultPageId) ? requestedDefaultPageId : pages[0].id; return { pages, defaultPageId, }; } function extractHashRouteFromFile(filePath) { const sourceText = fs.readFileSync(filePath, 'utf8'); const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS); let route = null; const visit = (node) => { if (route) { return; } if (ts.isCallExpression(node)) { route = extractHashRouteFromCall(node); if (route) { return; } } ts.forEachChild(node, visit); }; visit(sourceFile); return route; } function extractHashRouteMetadata(prototypeDir) { const files = listFiles(prototypeDir, (filePath) => /\.(tsx?|mts|cts)$/iu.test(filePath)); for (const filePath of files) { try { const route = extractHashRouteFromFile(filePath); if (route) { return route; } } catch { // Ignore malformed source and leave the prototype without page metadata. } } return null; } function titleFromMarkdown(markdownPath, fallback) { if (!fs.existsSync(markdownPath)) return fallback; const source = fs.readFileSync(markdownPath, 'utf8'); const heading = source.match(/^#\s+(.+)$/mu)?.[1]?.trim(); return heading || fallback; } function titleFromTokenFile(tokenPath, fallback) { const token = readJson(tokenPath); if (token && typeof token === 'object') { const title = typeof token.title === 'string' ? token.title.trim() : typeof token.name === 'string' ? token.name.trim() : ''; if (title) return title; } return fallback; } function normalizeThemeResourceTitle(title, fallback) { const rawTitle = typeof title === 'string' ? title.trim() : ''; const fallbackTitle = typeof fallback === 'string' ? fallback.trim() : ''; if (!rawTitle) return fallbackTitle; const repeatedThemeTitle = rawTitle.match(/^(.+?)\s+主题\s*-\s*(.+)$/u); if (repeatedThemeTitle) { const before = repeatedThemeTitle[1]?.trim(); const after = repeatedThemeTitle[2]?.trim(); if (before && after && before.toLowerCase() === after.toLowerCase()) { return after; } } return rawTitle; } function createAxureArtifactMetadata(projectRoot, prototypeName) { const artifactRoot = path.join(projectRoot, '.axhub/make/artifacts/axure', prototypeName); const files = { manifestPath: '.axhub/make/artifacts/axure/{name}/manifest.json', indexBundlePath: '.axhub/make/artifacts/axure/{name}/index-bundle.json', axureJsonPath: '.axhub/make/artifacts/axure/{name}/axure-json.json', coverSvgPath: '.axhub/make/artifacts/axure/{name}/cover.svg', }; const existing = {}; for (const [key, template] of Object.entries(files)) { const relativePath = template.replace('{name}', prototypeName); if (fs.existsSync(path.join(projectRoot, relativePath))) { existing[key] = relativePath; } } if (!Object.keys(existing).length && !fs.existsSync(artifactRoot)) { return undefined; } return { axure: { caseId: prototypeName, ...existing, }, }; } function createFigmaArtifactMetadata(projectRoot, prototypeName) { const artifactRoot = path.join(projectRoot, '.axhub/make/artifacts/figma', prototypeName); const files = { canvasFigPath: '.axhub/make/artifacts/figma/{name}/canvas.fig', metaPath: '.axhub/make/artifacts/figma/{name}/meta.json', aiChatPath: '.axhub/make/artifacts/figma/{name}/ai_chat.json', codeManifestPath: '.axhub/make/artifacts/figma/{name}/canvas.code-manifest.json', manifestPath: '.axhub/make/artifacts/figma/{name}/manifest.json', thumbnailPath: '.axhub/make/artifacts/figma/{name}/thumbnail.png', }; const existing = {}; for (const [key, template] of Object.entries(files)) { const relativePath = template.replace('{name}', prototypeName); if (fs.existsSync(path.join(projectRoot, relativePath))) { existing[key] = relativePath; } } const imagesRelativePath = `.axhub/make/artifacts/figma/${prototypeName}/images`; if (fs.existsSync(path.join(projectRoot, imagesRelativePath))) { existing.imagesDir = imagesRelativePath; } if (!Object.keys(existing).length && !fs.existsSync(artifactRoot)) { return undefined; } return { figma: { resourceId: prototypeName, ...existing, }, }; } function readRuntimeEntryKeys(projectRoot) { const manifest = readJson(path.join(projectRoot, '.axhub/make/entries.json')); const items = manifest && typeof manifest === 'object' && manifest.items && typeof manifest.items === 'object' ? manifest.items : {}; const legacyJs = manifest && typeof manifest === 'object' && manifest.js && typeof manifest.js === 'object' ? manifest.js : {}; const keys = new Set([ ...Object.keys(items), ...Object.keys(legacyJs), ]); return Array.from(keys) .map((key) => { const normalizedKey = toPosix(key).replace(/^\/+/, '').replace(/\/+$/, ''); const item = items[key]; const group = stringValue(item?.group) || normalizedKey.split('/')[0] || ''; const name = stringValue(item?.name) || normalizedKey.split('/').slice(1).join('/') || ''; if (!normalizedKey || group !== 'prototypes' || !name) { return null; } return { key: normalizedKey, name }; }) .filter(Boolean); } function createRuntimeArtifactMetadata(projectRoot, prototypeName, runtimeEntryKeys, options = {}) { if (options.includeRuntimeArtifacts === false) { return undefined; } const entry = runtimeEntryKeys.find((item) => item.name === prototypeName || item.key === `prototypes/${prototypeName}`); if (!entry) { return undefined; } const builtJsPath = `dist/${entry.key}.js`; if (!fs.existsSync(path.join(projectRoot, builtJsPath))) { return undefined; } return { runtime: { builtJsPath, }, }; } function createResourceClientUrl(clientOrigin, resourceKind, resourceName) { const pathname = `/${resourceKind}/${encodeURIComponent(resourceName)}`; const normalizedOrigin = String(clientOrigin || '').trim().replace(/\/+$/u, ''); if (!normalizedOrigin) { return pathname; } return `${normalizedOrigin}${pathname}`; } function collectPrototypes(projectRoot, clientOrigin, options = {}) { const roots = resourceLayout.prototypes.map((dir) => path.join(projectRoot, dir)); const runtimeEntryKeys = readRuntimeEntryKeys(projectRoot); const items = []; for (const root of roots) { if (!fs.existsSync(root)) continue; for (const entry of fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { if (!entry.isDirectory()) continue; const indexFile = path.join(root, entry.name, 'index.tsx'); 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, title: readDisplayName(indexFile, entry.name), clientUrl: createResourceClientUrl(clientOrigin, 'prototypes', entry.name), previewMode: 'clientRuntime', description: '', updatedAt: DETERMINISTIC_UPDATED_AT, 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), ...createAxureArtifactMetadata(projectRoot, entry.name), ...createRuntimeArtifactMetadata(projectRoot, entry.name, runtimeEntryKeys, options), }; if (Object.keys(artifacts).length > 0) { item.artifacts = artifacts; } items.push(item); } } return items.sort(sortById); } function collectThemes(projectRoot, clientOrigin) { const items = []; for (const root of resourceLayout.themes.map((dir) => path.resolve(projectRoot, dir))) { if (!fs.existsSync(root)) continue; for (const entry of fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { if (!entry.isDirectory()) continue; const indexFile = path.join(root, entry.name, 'index.tsx'); if (!fs.existsSync(indexFile)) continue; const tokenPath = path.join(root, entry.name, 'designToken.json'); const getdesignStats = getThemeStats(entry.name); const rawTitle = readDisplayName(indexFile, titleFromTokenFile(tokenPath, entry.name)); items.push({ id: entry.name, name: entry.name, title: normalizeThemeResourceTitle(rawTitle, entry.name), clientUrl: createResourceClientUrl(clientOrigin, 'themes', entry.name), sourcePath: toPosix(path.relative(projectRoot, path.join(root, entry.name))), updatedAt: DETERMINISTIC_UPDATED_AT, ...(getdesignStats ? { getdesign: { source: 'getdesign.md', sourceSlug: getdesignStats.sourceSlug, downloads: getdesignStats.downloads, snapshotDate: GETDESIGN_DOWNLOAD_SNAPSHOT_DATE, }, } : {}), }); } } return items .sort(sortThemesByGetDesignDownloads) .map((item) => Object.fromEntries(Object.entries(item).filter(([, value]) => value !== undefined))); } 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 themes = collectThemes(projectRoot, clientOrigin); return { schemaVersion: 1, project: { id: projectIdentity.id, name: projectIdentity.name, }, resources: { prototypes, themes, }, navigation: { prototypes: prototypes.map((item) => item.id), }, orders: { themes: themes.map((item) => item.id), }, capabilities: { quickEdit: true, quickEditMode: 'clientRuntime', figmaExport: true, axureExport: true, localExports: localExportCapabilities, }, resourceWriteTargets, }; } export function resolveClientOrigin(projectRoot, fallbackOrigin = DEFAULT_CLIENT_ORIGIN) { const runtime = readServerInfo(projectRoot, 'runtime'); return runtime?.origin || fallbackOrigin; } export function syncMakeProjectMetadata(projectRoot, options = {}) { const metadata = buildMakeProjectMetadata(projectRoot, { clientOrigin: options.includeRuntimeUrls === true ? options.clientOrigin ?? resolveClientOrigin(projectRoot) : '', includeAbsoluteFilePaths: options.includeAbsoluteFilePaths === true, includeRuntimeArtifacts: options.includeRuntimeArtifacts === true, }); const metadataPath = path.join(projectRoot, '.axhub/make/project.json'); writeJsonAtomic(metadataPath, metadata); return { metadata, metadataPath }; } if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { const appRoot = path.resolve(__dirname, '..'); const { metadata, metadataPath } = syncMakeProjectMetadata(appRoot); console.log(`Synced ${metadata.project.name || 'unnamed project'} metadata: ${metadataPath}`); }