Files
xll-mini-program/scripts/sync-annotation-directory.mjs
王冕 a619938e0c Initial commit: 小羚羚小程序 Axhub Make workspace.
Include xll-miniapp prototype, PRD resources, annotation directory sync, agent skills, and cloud publishing setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 15:32:30 +08:00

306 lines
10 KiB
JavaScript

/**
* 将 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();