/** * 将 index.tsx 中的 defineHashPageRoute 页面同步到 annotation-source.json 的 directory.route 节点。 * * 用法: * node scripts/sync-annotation-directory.mjs * node scripts/sync-annotation-directory.mjs --prototype xll-miniapp */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PAGE_ID_RE = /^[a-z0-9-]+$/u; function parseArgs(argv) { const args = { prototype: '', all: false, projectRoot: '' }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--prototype' && argv[i + 1]) { args.prototype = argv[++i].trim(); } else if (arg === '--project-root' && argv[i + 1]) { args.projectRoot = path.resolve(argv[++i].trim()); } else if (arg === '--all') { args.all = true; } } return args; } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function writeJson(filePath, value) { fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } function normalizePageId(value) { const id = typeof value === 'string' ? value.trim() : ''; return PAGE_ID_RE.test(id) ? id : ''; } 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 = 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 extractHashRouteFromPrototype(prototypeDir) { const indexFile = path.join(prototypeDir, 'index.tsx'); if (!fs.existsSync(indexFile)) return null; const sourceText = fs.readFileSync(indexFile, 'utf8'); const sourceFile = ts.createSourceFile(indexFile, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); 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 loadDirectoryPagesConfig(prototypeDir) { const configPath = path.join(prototypeDir, 'directory.pages.json'); if (!fs.existsSync(configPath)) return null; return readJson(configPath); } function routeNodeId(route) { return `route-${String(route).replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase()}`; } function createRouteNode(route, title) { return { type: 'route', id: routeNodeId(route), title, route, }; } function collectRouteNodes(nodes, result = []) { for (const node of nodes || []) { if (node?.type === 'route') result.push(node); if (node?.type === 'folder' && Array.isArray(node.children)) { collectRouteNodes(node.children, result); } } return result; } function resolveRouteToken(pageId, existingRoutes) { const byId = existingRoutes.find((node) => node.id === routeNodeId(pageId)); if (byId) return byId.route; const camel = pageId.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase()); const byCamel = existingRoutes.find((node) => node.route === camel); if (byCamel) return byCamel.route; return pageId; } function buildRouteFolders(pages, config, existingDirectory) { const existingRoutes = collectRouteNodes(existingDirectory?.nodes); const extraRoutes = Array.isArray(config?.extraRoutes) ? config.extraRoutes : []; const groups = Array.isArray(config?.groups) && config.groups.length ? config.groups : [{ folderId: 'directory-pages', folderTitle: '页面导航', defaultExpanded: true, pageIds: pages.map((page) => page.id), }]; const assigned = new Set(); const folders = []; for (const group of groups) { const pageIds = Array.isArray(group.pageIds) ? group.pageIds : []; const children = []; for (const pageId of pageIds) { const page = pages.find((item) => item.id === pageId); if (!page) continue; assigned.add(page.id); children.push(createRouteNode(resolveRouteToken(page.id, existingRoutes), page.title)); } for (const extra of extraRoutes) { if (extra.folderId !== group.folderId) continue; const route = String(extra.route || '').trim(); const title = String(extra.title || '').trim(); if (!route || !title) continue; children.push(createRouteNode(route, title)); assigned.add(`extra:${route}`); } if (!children.length) continue; folders.push({ type: 'folder', id: group.folderId || `directory-group-${folders.length + 1}`, title: group.folderTitle || '页面', defaultExpanded: group.defaultExpanded !== false, children, }); } const unassigned = pages.filter((page) => !assigned.has(page.id)); if (unassigned.length) { folders.push({ type: 'folder', id: 'directory-pages-extra', title: '其他页面', defaultExpanded: true, children: unassigned.map((page) => createRouteNode(resolveRouteToken(page.id, existingRoutes), page.title)), }); } const coveredExtras = new Set( extraRoutes.filter((extra) => groups.some((group) => group.folderId === extra.folderId)).map((extra) => extra.route), ); const orphanExtras = extraRoutes.filter((extra) => !coveredExtras.has(extra.route)); if (orphanExtras.length) { folders.push({ type: 'folder', id: 'directory-routes-extra', title: '补充入口', defaultExpanded: true, children: orphanExtras.map((extra) => createRouteNode(extra.route, extra.title)), }); } return folders; } function preserveFolders(directory, preserveFolderIds) { const ids = new Set(preserveFolderIds || []); return (directory?.nodes || []).filter((node) => node?.type === 'folder' && ids.has(node.id)); } function fixPublishedLinks(nodes, prototypeId) { const walk = (items) => { for (const node of items || []) { if (node?.type === 'link' && typeof node.href === 'string') { const href = node.href.trim(); const selfMatch = href.match(new RegExp(`^/prototypes/${prototypeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:[#?].*)?$`, 'u')); if (selfMatch) { node.href = './index.html'; node.target = node.target || 'self'; } else { const otherMatch = href.match(/^\/prototypes\/([^/?#]+)(?:[#?].*)?$/u); if (otherMatch) { node.href = `../${otherMatch[1]}/index.html`; node.target = node.target || 'self'; } } } if (node?.type === 'folder' && Array.isArray(node.children)) { walk(node.children); } } }; walk(nodes); } function syncPrototypeDirectory(prototypeId, projectRoot) { const prototypesRoot = path.join(projectRoot, 'src/prototypes'); const prototypeDir = path.join(prototypesRoot, prototypeId); const annotationPath = path.join(prototypeDir, 'annotation-source.json'); if (!fs.existsSync(annotationPath)) { console.log(`[skip] ${prototypeId}: 无 annotation-source.json`); return false; } const annotation = readJson(annotationPath); const route = extractHashRouteFromPrototype(prototypeDir); const config = loadDirectoryPagesConfig(prototypeDir); const preserveFolderIds = config?.preserveFolderIds || ['directory-docs']; const preserved = preserveFolders(annotation.directory, preserveFolderIds); if (route?.pages?.length) { const routeFolders = buildRouteFolders(route.pages, config, annotation.directory); annotation.directory = { nodes: [...routeFolders, ...preserved], }; annotation.data = annotation.data || {}; annotation.data.pageId = route.defaultPageId; annotation.data.updatedAt = Date.now(); console.log(`[sync] ${prototypeId}: 已同步 ${route.pages.length} 个页面到原型目录`); } else { console.log(`[keep] ${prototypeId}: 无 hash 路由,仅修复发布链接`); } fixPublishedLinks(annotation.directory?.nodes, prototypeId); writeJson(annotationPath, annotation); return true; } function listPrototypeIds(projectRoot) { const prototypesRoot = path.join(projectRoot, 'src/prototypes'); if (!fs.existsSync(prototypesRoot)) return []; return fs.readdirSync(prototypesRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(prototypesRoot, entry.name, 'annotation-source.json'))) .map((entry) => entry.name) .sort((a, b) => a.localeCompare(b)); } function main() { const args = parseArgs(process.argv.slice(2)); const projectRoot = args.projectRoot || path.resolve(__dirname, '..'); const targets = args.prototype ? [args.prototype] : listPrototypeIds(projectRoot); if (!targets.length) { console.log('未找到带 annotation-source.json 的原型。'); process.exit(0); } let changed = 0; for (const prototypeId of targets) { if (syncPrototypeDirectory(prototypeId, projectRoot)) changed += 1; } console.log(`完成:处理 ${targets.length} 个原型,更新 ${changed} 个 annotation-source.json`); } main();