generate-source-parity.mjs 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import { createRequire } from 'node:module';
  4. import { fileURLToPath } from 'node:url';
  5. const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
  6. const sourceRoot = path.resolve(process.env.XIAOSHU_UNIAPP_ROOT || path.join(projectRoot, '..', 'xiaoshu-uniapp'));
  7. const sourceRequire = createRequire(path.join(sourceRoot, 'package.json'));
  8. const JSON5 = sourceRequire('json5');
  9. const { parseComponent } = sourceRequire('vue-template-compiler');
  10. const parser = sourceRequire('@babel/parser');
  11. const pagesJson = JSON5.parse(fs.readFileSync(path.join(sourceRoot, 'pages.json'), 'utf8'));
  12. const pages = [
  13. ...(pagesJson.pages || []).map((page) => page.path),
  14. ...(pagesJson.subPackages || pagesJson.subpackages || []).flatMap((group) =>
  15. (group.pages || []).map((page) => `${group.root}/${page.path}`),
  16. ),
  17. ];
  18. const rows = pages.map((pagePath) => inspectPage(pagePath));
  19. const actions = [...new Set(rows.flatMap((page) => page.apiCalls.map((call) => call.action)).filter((action) => action !== '<dynamic>'))].sort();
  20. const externalCalls = [...new Set(rows.flatMap((page) => page.externalCalls.map((call) => `${call.service}.${call.operation}`)))].sort();
  21. const outputPath = path.join(projectRoot, 'projects/xiaoshu-mobile/src/app/core/source-parity.generated.ts');
  22. const output = `// Generated by npm run parity:generate. Do not edit manually.\n` +
  23. `export const SOURCE_PAGE_PARITY = ${JSON.stringify(rows, null, 2)} as const;\n\n` +
  24. `export const SOURCE_API_ACTIONS = ${JSON.stringify(actions, null, 2)} as const;\n\n` +
  25. `export const SOURCE_EXTERNAL_CALLS = ${JSON.stringify(externalCalls, null, 2)} as const;\n`;
  26. if (process.argv.includes('--check')) {
  27. const current = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : '';
  28. if (current !== output) {
  29. console.error('Source parity manifest is stale. Run npm run parity:generate.');
  30. process.exitCode = 1;
  31. }
  32. } else {
  33. fs.writeFileSync(outputPath, output);
  34. console.log(`Generated ${rows.length} pages and ${actions.length} API actions.`);
  35. }
  36. function inspectPage(pagePath) {
  37. const file = path.join(sourceRoot, `${pagePath}.vue`);
  38. const source = fs.readFileSync(file, 'utf8');
  39. const descriptor = parseComponent(source, { pad: 'line' });
  40. const script = descriptor.script?.content || '';
  41. const ast = parser.parse(script, { sourceType: 'module', plugins: ['optionalChaining', 'objectRestSpread'] });
  42. const apiCalls = [];
  43. const externalCalls = [];
  44. const methods = [];
  45. const navigations = [];
  46. walk(ast, (node) => {
  47. if (node.type === 'ObjectMethod' && node.key?.type === 'Identifier') methods.push(node.key.name);
  48. if (node.type !== 'CallExpression') return;
  49. const callee = memberName(node.callee);
  50. const first = node.arguments?.[0];
  51. const literal = first?.type === 'StringLiteral' ? first.value : '<dynamic>';
  52. if (/(?:uni\.\$u\.http|this\.\$u\.http)\.(get|post)$/.test(callee)) {
  53. apiCalls.push({ method: callee.endsWith('.post') ? 'post' : 'get', action: literal, line: node.loc?.start.line || 0 });
  54. }
  55. if (/^httpApi\.[A-Za-z0-9_]+$/.test(callee)) {
  56. externalCalls.push({ service: 'bytedesk', operation: callee.slice('httpApi.'.length), line: node.loc?.start.line || 0 });
  57. }
  58. if (/(?:navigateTo|redirectTo|switchTab|reLaunch|\.route)$/.test(callee)) {
  59. navigations.push({ call: callee, target: literal, line: node.loc?.start.line || 0 });
  60. }
  61. });
  62. return {
  63. path: pagePath,
  64. sourceFile: `${pagePath}.vue`,
  65. apiCalls: unique(apiCalls, (item) => `${item.method}:${item.action}:${item.line}`),
  66. externalCalls: unique(externalCalls, (item) => `${item.service}:${item.operation}:${item.line}`),
  67. methods: [...new Set(methods)],
  68. navigations,
  69. };
  70. }
  71. function memberName(node) {
  72. if (!node) return '';
  73. if (node.type === 'Identifier') return node.name;
  74. if (node.type === 'ThisExpression') return 'this';
  75. if (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') {
  76. const property = node.computed ? node.property?.value ?? memberName(node.property) : memberName(node.property);
  77. return `${memberName(node.object)}.${property}`;
  78. }
  79. return '';
  80. }
  81. function walk(node, visit) {
  82. if (!node || typeof node !== 'object') return;
  83. visit(node);
  84. for (const [key, value] of Object.entries(node)) {
  85. if (['loc', 'start', 'end', 'extra'].includes(key)) continue;
  86. if (Array.isArray(value)) value.forEach((entry) => walk(entry, visit));
  87. else if (value?.type) walk(value, visit);
  88. }
  89. }
  90. function unique(items, key) {
  91. return [...new Map(items.map((item) => [key(item), item])).values()];
  92. }