Include xll-miniapp prototype, PRD resources, annotation directory sync, agent skills, and cloud publishing setup. Co-authored-by: Cursor <cursoragent@cursor.com>
306 lines
10 KiB
JavaScript
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();
|