vl-metadata-extractor.js 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486
  1. /**
  2. * VL Metadata Extractor v1.0
  3. *
  4. * Extracts structured metadata directly from VL source files (.vx, .sc, .cp, .vs, .vdb, .vth)
  5. * Zero dependencies, browser-compatible ES Module.
  6. *
  7. * Usage:
  8. * const metadata = VLMetadataExtractor.extract(fileTree);
  9. * const { uiMap, serviceMap } = VLMetadataExtractor.toProcessEditorFormat(metadata);
  10. * const graph = VLMetadataExtractor.buildDependencyGraph(metadata);
  11. *
  12. * fileTree: { 'Apps/PlayerDesktop.vx': '...content...', ... }
  13. */
  14. const VLMetadataExtractor = (() => {
  15. // ─── Core Utilities ────────────────────────────────────────────────
  16. const FILE_TYPES = {
  17. '.vx': 'app',
  18. '.sc': 'section',
  19. '.cp': 'component',
  20. '.vs': 'service',
  21. '.vdb': 'database',
  22. '.vth': 'theme'
  23. };
  24. function classifyFile(path) {
  25. for (const [ext, type] of Object.entries(FILE_TYPES)) {
  26. if (path.endsWith(ext)) return { type, ext };
  27. }
  28. return null;
  29. }
  30. function getDepth(line) {
  31. const m = line.match(/^(-*)/);
  32. return m ? m[1].length : 0;
  33. }
  34. function stripDepth(line) {
  35. return line.replace(/^-+/, '');
  36. }
  37. function splitSections(content) {
  38. const lines = content.split('\n');
  39. const sections = {};
  40. let currentSection = '__header__';
  41. sections[currentSection] = [];
  42. for (const line of lines) {
  43. const trimmed = line.trim();
  44. if (/^#\s+/.test(trimmed)) {
  45. currentSection = trimmed.replace(/^#\s+/, '').trim();
  46. sections[currentSection] = [];
  47. } else {
  48. sections[currentSection].push(line);
  49. }
  50. }
  51. return sections;
  52. }
  53. // Extract VL version from file content
  54. function extractVersion(content) {
  55. const m = content.match(/\/\/\s*VL_VERSION:(\d+\.\d+)/);
  56. return m ? m[1] : null;
  57. }
  58. // Extract preview annotation
  59. function extractPreview(content) {
  60. const m = content.match(/\/\/\s*Preview:\s*(.+)/);
  61. if (!m) return null;
  62. const props = {};
  63. const pairs = m[1].match(/[\w-]+:[^\s]+/g) || [];
  64. for (const pair of pairs) {
  65. const [k, ...v] = pair.split(':');
  66. props[k] = v.join(':');
  67. }
  68. return props;
  69. }
  70. // Extract root component: <Type-Name "id"> ...props
  71. function extractRoot(content) {
  72. const m = content.match(/<(App|Section|Component|ServiceDomain|Database|Theme)-(\w+)(?:\s+"(\w+)")?/);
  73. if (!m) return null;
  74. return { type: m[1], name: m[2], id: m[3] || null };
  75. }
  76. // Find the index of the matching closing paren/bracket for the opener at pos
  77. function findMatchingClose(str, pos) {
  78. const open = str[pos];
  79. const close = open === '(' ? ')' : open === '[' ? ']' : open === '{' ? '}' : null;
  80. if (!close) return -1;
  81. let depth = 1;
  82. for (let i = pos + 1; i < str.length; i++) {
  83. if (str[i] === open) depth++;
  84. else if (str[i] === close) { depth--; if (depth === 0) return i; }
  85. }
  86. return -1;
  87. }
  88. // Extract the balanced content inside the outermost parens starting at pos
  89. function extractBalancedParens(str, startPos) {
  90. const openIdx = str.indexOf('(', startPos);
  91. if (openIdx < 0) return { content: '', endIdx: -1 };
  92. const closeIdx = findMatchingClose(str, openIdx);
  93. if (closeIdx < 0) return { content: str.slice(openIdx + 1), endIdx: str.length };
  94. return { content: str.slice(openIdx + 1, closeIdx), endIdx: closeIdx };
  95. }
  96. // Parse typed parameter list: "param1(TYPE), param2(TYPE)"
  97. // Handles nested parens: legs([{matchId:INT}])
  98. function parseParams(str) {
  99. if (!str || !str.trim()) return [];
  100. const params = [];
  101. let i = 0;
  102. while (i < str.length) {
  103. // Skip whitespace and commas
  104. while (i < str.length && (str[i] === ' ' || str[i] === ',' || str[i] === '\t')) i++;
  105. if (i >= str.length) break;
  106. // Read param name
  107. let name = '';
  108. while (i < str.length && /\w/.test(str[i])) { name += str[i]; i++; }
  109. if (!name) { i++; continue; }
  110. // Expect (TYPE)
  111. if (i < str.length && str[i] === '(') {
  112. const closeIdx = findMatchingClose(str, i);
  113. if (closeIdx > 0) {
  114. const type = str.slice(i + 1, closeIdx);
  115. params.push({ name, type });
  116. i = closeIdx + 1;
  117. } else {
  118. params.push({ name, type: str.slice(i + 1) });
  119. break;
  120. }
  121. }
  122. }
  123. return params;
  124. }
  125. // Parse return type: "{field:TYPE, field:TYPE}" or complex types
  126. function parseReturnFields(str) {
  127. if (!str || !str.trim()) return [];
  128. const trimmed = str.trim();
  129. // Handle {field:TYPE, ...} format
  130. if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
  131. const inner = trimmed.slice(1, -1);
  132. return parseFieldList(inner);
  133. }
  134. // Otherwise it's a simple type
  135. return [{ name: '_value', type: trimmed }];
  136. }
  137. // Parse "field:TYPE, field:TYPE" or "field:TYPE, field:[{...}]"
  138. function parseFieldList(str) {
  139. const fields = [];
  140. let depth = 0;
  141. let current = '';
  142. for (let i = 0; i < str.length; i++) {
  143. const ch = str[i];
  144. if (ch === '{' || ch === '[' || ch === '(') depth++;
  145. else if (ch === '}' || ch === ']' || ch === ')') depth--;
  146. if (ch === ',' && depth === 0) {
  147. const f = parseOneField(current.trim());
  148. if (f) fields.push(f);
  149. current = '';
  150. } else {
  151. current += ch;
  152. }
  153. }
  154. if (current.trim()) {
  155. const f = parseOneField(current.trim());
  156. if (f) fields.push(f);
  157. }
  158. return fields;
  159. }
  160. function parseOneField(str) {
  161. const idx = str.indexOf(':');
  162. if (idx < 0) return null;
  163. return { name: str.slice(0, idx).trim(), type: str.slice(idx + 1).trim() };
  164. }
  165. // Parse inline props from element line: key:value key:"quoted" key:(expr)
  166. function parseInlineProps(propsStr) {
  167. const props = {};
  168. if (!propsStr) return props;
  169. const regex = /(\w[\w-]*):(?:"([^"]*)"|\(([^)]*)\)|(\[[^\]]*\])|(\{[^}]*\})|(\S+))/g;
  170. let m;
  171. while ((m = regex.exec(propsStr)) !== null) {
  172. const key = m[1];
  173. const val = m[2] !== undefined ? m[2] : (m[3] || m[4] || m[5] || m[6]);
  174. props[key] = val;
  175. }
  176. return props;
  177. }
  178. // ─── Variable Declaration Parser ──────────────────────────────────
  179. function parseVarDecl(line) {
  180. // $name(TYPE) = default or $name(complex type) = value
  181. const m = line.match(/^\$(\w+)\(([^)]*(?:\([^)]*\)[^)]*)*(?:\[[^\]]*(?:\{[^}]*\}[^\]]*)*\][^)]*)*)\)\s*=\s*(.+)$/);
  182. if (!m) {
  183. // Try simpler pattern
  184. const m2 = line.match(/^\$(\w+)\((.+?)\)\s*=\s*(.+)$/);
  185. if (m2) return { name: m2[1], type: m2[2], default: m2[3].trim() };
  186. return null;
  187. }
  188. return { name: m[1], type: m[2], default: m[3].trim() };
  189. }
  190. // Parse variables from a section block (lines)
  191. function parseVarsFromLines(lines) {
  192. const vars = [];
  193. for (const line of lines) {
  194. const trimmed = line.trim();
  195. if (trimmed.startsWith('$')) {
  196. const v = parseVarDecl(trimmed);
  197. if (v) vars.push(v);
  198. }
  199. }
  200. return vars;
  201. }
  202. // ─── Event Declaration Parser ─────────────────────────────────────
  203. function parseEventDecl(line) {
  204. // EVENT @name(param1(TYPE), param2(TYPE))
  205. const m = line.match(/^EVENT\s+@(\w+)/);
  206. if (!m) return null;
  207. const name = m[1];
  208. const parenStart = line.indexOf('(', m.index + m[0].length);
  209. if (parenStart < 0) return { name, params: [] };
  210. const parenEnd = findMatchingClose(line, parenStart);
  211. if (parenEnd < 0) return { name, params: [] };
  212. const paramStr = line.slice(parenStart + 1, parenEnd);
  213. return { name, params: parseParams(paramStr) };
  214. }
  215. function parseEventsFromLines(lines) {
  216. const events = [];
  217. for (const line of lines) {
  218. const trimmed = line.trim();
  219. if (trimmed.startsWith('EVENT')) {
  220. const e = parseEventDecl(trimmed);
  221. if (e) events.push(e);
  222. }
  223. }
  224. return events;
  225. }
  226. // ─── Method/Service Declaration Parser ────────────────────────────
  227. function parseMethodDecl(line) {
  228. // Handles: METHOD, METHOD_PUB, SERVICE, PUBLIC_SERVICE, TRANSACTION, PIPE
  229. // Format: KEYWORD name(params); RETURN {fields}
  230. const kwMatch = line.match(/^(METHOD_PUB|METHOD|PUBLIC_SERVICE|SERVICE|TRANSACTION|PIPE)\s+(\w+)/);
  231. if (!kwMatch) return null;
  232. const kind = kwMatch[1];
  233. const name = kwMatch[2];
  234. const afterName = line.slice(kwMatch.index + kwMatch[0].length);
  235. // Find params inside balanced parens
  236. const parenStart = afterName.indexOf('(');
  237. if (parenStart < 0) return { kind, name, params: [], returns: null };
  238. const parenEnd = findMatchingClose(afterName, parenStart);
  239. if (parenEnd < 0) return { kind, name, params: [], returns: null };
  240. const paramStr = afterName.slice(parenStart + 1, parenEnd);
  241. const params = parseParams(paramStr);
  242. // Find RETURN part after the closing paren
  243. const afterParams = afterName.slice(parenEnd + 1).trim().replace(/^;\s*/, '');
  244. let returns = null;
  245. const retMatch = afterParams.match(/^RETURN\s+(.+)$/);
  246. if (retMatch) {
  247. returns = retMatch[1].trim();
  248. }
  249. const result = { kind, name, params, returns };
  250. parseExposeIfPresent(result);
  251. return result;
  252. }
  253. function parseExposeIfPresent(result) {
  254. if (result.returns && result.returns.includes('EXPOSE')) {
  255. const parts = result.returns.split(/;\s*EXPOSE\s+/);
  256. result.returns = parts[0].trim();
  257. if (parts[1]) {
  258. result.expose = parseInlineProps(parts[1].replace(/^\{|\}$/g, ''));
  259. }
  260. }
  261. }
  262. function parseMethodsFromLines(lines, kinds) {
  263. const methods = [];
  264. for (const line of lines) {
  265. const stripped = stripDepth(line.trim()).trim();
  266. for (const kind of kinds) {
  267. if (stripped.startsWith(kind + ' ')) {
  268. const m = parseMethodDecl(stripped);
  269. if (m) methods.push(m);
  270. break;
  271. }
  272. }
  273. }
  274. return methods;
  275. }
  276. // ─── Element Reference Parser ─────────────────────────────────────
  277. function findElementRefs(lines, category) {
  278. // Find <Category-Name "instanceId"> in lines
  279. const refs = [];
  280. const seen = new Set();
  281. const regex = new RegExp(`<${category}-(\\w+)(?:\\s+"(\\w+)")?`, 'g');
  282. for (const line of lines) {
  283. let m;
  284. while ((m = regex.exec(line)) !== null) {
  285. const key = `${m[1]}:${m[2] || ''}`;
  286. if (!seen.has(key)) {
  287. seen.add(key);
  288. refs.push({ name: m[1], instanceId: m[2] || null });
  289. }
  290. }
  291. }
  292. return refs;
  293. }
  294. // ─── Interactive Element Parser ──────────────────────────────────
  295. const INTERACTIVE_CATEGORIES = [
  296. 'Button', 'Input', 'Textarea', 'Select',
  297. 'SingleSelect_Dropdown', 'MultiSelect_Dropdown',
  298. 'Modal', 'Image', 'Chart', 'Icon'
  299. ];
  300. const INTERACTIVE_REGEX = new RegExp(
  301. '<(' + INTERACTIVE_CATEGORIES.join('|') + ')-(\\w+)(?:\\s+"(\\w+)")?>\\s*(.*)',
  302. 'g'
  303. );
  304. function parseInteractiveElements(treeLines) {
  305. const elements = [];
  306. for (const line of treeLines) {
  307. const depth = getDepth(line.trim());
  308. const stripped = stripDepth(line.trim()).trim();
  309. if (!stripped) continue;
  310. INTERACTIVE_REGEX.lastIndex = 0;
  311. const m = INTERACTIVE_REGEX.exec(stripped);
  312. if (!m) continue;
  313. const category = m[1];
  314. const name = m[2];
  315. const instanceId = m[3] || null;
  316. const propsStr = m[4] || '';
  317. const props = parseInlineProps(propsStr);
  318. // Determine element type for test generation
  319. let elType = 'other';
  320. if (category === 'Button') elType = 'button';
  321. else if (category === 'Input') elType = 'input';
  322. else if (category === 'Textarea') elType = 'textarea';
  323. else if (category === 'Select' || category.includes('Select_Dropdown')) elType = 'select';
  324. else if (category === 'Modal') elType = 'modal';
  325. const el = { category, name, instanceId, type: elType, depth };
  326. if (props.value !== undefined) el.label = props.value;
  327. if (props.placeholder) el.placeholder = props.placeholder;
  328. if (props.type) el.inputType = props.type;
  329. if (props.disabled) el.disabled = props.disabled;
  330. if (props.StyleClass) el.styleClass = props.StyleClass;
  331. elements.push(el);
  332. }
  333. return elements;
  334. }
  335. // ─── Event Binding Parser (for .vx files) ─────────────────────────
  336. function parseEventBindings(lines) {
  337. const bindings = [];
  338. let current = null;
  339. for (const line of lines) {
  340. const trimmed = line.trim();
  341. if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) continue;
  342. // Match: <Section-Name "id">.@eventName(params)
  343. // Or: <Component-Name "id">.@eventName(params)
  344. const m = trimmed.match(/^<(Section|Component)-(\w+)\s+"(\w+)">\s*\.@(\w+)\(([^)]*)\)$/);
  345. if (m) {
  346. if (current) bindings.push(current);
  347. current = {
  348. source: { type: m[1].toLowerCase(), id: m[2], instanceId: m[3], event: m[4], params: m[5] ? m[5].split(',').map(s => s.trim()).filter(Boolean) : [] },
  349. actions: []
  350. };
  351. continue;
  352. }
  353. // Collect handler body lines
  354. if (current && trimmed.startsWith('-')) {
  355. current.actions.push(stripDepth(trimmed).trim());
  356. } else if (current && trimmed === '') {
  357. // Empty line may end the handler
  358. } else if (current && !trimmed.startsWith('-') && !trimmed.startsWith('<')) {
  359. // Non-dash non-element line in handler
  360. // Still part of the handler if we're at depth
  361. }
  362. }
  363. if (current) bindings.push(current);
  364. return bindings;
  365. }
  366. // ─── .vdb Parser ──────────────────────────────────────────────────
  367. function parseVDB(content, filePath) {
  368. const version = extractVersion(content);
  369. const root = extractRoot(content);
  370. const lines = content.split('\n');
  371. const tables = [];
  372. const relations = [];
  373. let currentTable = null;
  374. for (const line of lines) {
  375. const trimmed = line.trim();
  376. if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*')) continue;
  377. // Table start: <Table-Name> data:[...]
  378. const tableMatch = trimmed.match(/^<Table-(\w+)>(?:\s+data:(.+))?$/);
  379. if (tableMatch) {
  380. currentTable = {
  381. name: tableMatch[1],
  382. fields: [],
  383. indexes: [],
  384. seedData: tableMatch[2] || null
  385. };
  386. tables.push(currentTable);
  387. continue;
  388. }
  389. // Close table
  390. if (trimmed.match(/^<\/Table-/)) {
  391. currentTable = null;
  392. continue;
  393. }
  394. // Field in table
  395. const fieldMatch = stripDepth(trimmed).match(/^<Field-(\w+)>\s*(.*)$/);
  396. if (fieldMatch && currentTable) {
  397. const props = parseInlineProps(fieldMatch[2]);
  398. currentTable.fields.push({
  399. name: fieldMatch[1],
  400. type: props.type || 'STRING',
  401. notNull: props.notNull === 'true',
  402. default: props.default || null
  403. });
  404. continue;
  405. }
  406. // Index in table
  407. const indexMatch = stripDepth(trimmed).match(/^<Index-(\w+)>\s*(.*)$/);
  408. if (indexMatch && currentTable) {
  409. const props = parseInlineProps(indexMatch[2]);
  410. let fields = [];
  411. if (props.fields) {
  412. try { fields = JSON.parse(props.fields); } catch(e) { fields = [props.fields]; }
  413. }
  414. currentTable.indexes.push({
  415. name: indexMatch[1],
  416. type: props.type || 'NORMAL',
  417. fields
  418. });
  419. continue;
  420. }
  421. // Relation: <Relation-T1&T2> T1.field<<T2.field
  422. const relMatch = trimmed.match(/^<Relation-(\w+&\w+)>\s+(\w+)\.([(\w,)]+)(<<|>>|--)(\w+)\.([(\w,)]+)$/);
  423. if (relMatch) {
  424. const cardMap = { '<<': 'oneToMany', '>>': 'manyToOne', '--': 'oneToOne' };
  425. relations.push({
  426. name: relMatch[1],
  427. from: { table: relMatch[2], field: relMatch[3] },
  428. to: { table: relMatch[5], field: relMatch[6] },
  429. cardinality: cardMap[relMatch[4]] || relMatch[4]
  430. });
  431. }
  432. }
  433. return {
  434. name: root ? root.name : filePath.split('/').pop().replace('.vdb', ''),
  435. vlVersion: version,
  436. tables,
  437. relations
  438. };
  439. }
  440. // ─── .vs Parser ───────────────────────────────────────────────────
  441. function parseVS(content, filePath) {
  442. const version = extractVersion(content);
  443. const root = extractRoot(content);
  444. const sections = splitSections(content);
  445. const domainId = root ? root.name : filePath.split('/').pop().replace('.vs', '');
  446. const fileName = filePath.split('/').pop();
  447. // Parse environment vars
  448. const envVars = [];
  449. const envLines = sections['Backend Environment Vars'] || [];
  450. for (const line of envLines) {
  451. const trimmed = line.trim();
  452. const m = trimmed.match(/^ENV\s+(\w+)\((\w+)\)\s+"([^"]*)"$/);
  453. if (m) {
  454. envVars.push({ name: m[1], type: m[2], description: m[3] });
  455. }
  456. }
  457. // Parse Backend Tree: VirtualTables and backend components
  458. const virtualTables = [];
  459. const backendComponents = [];
  460. const treeLines = sections['Backend Tree'] || [];
  461. let currentVT = null;
  462. for (const line of treeLines) {
  463. const trimmed = line.trim();
  464. const stripped = stripDepth(trimmed);
  465. if (!stripped) continue;
  466. // VirtualTable: <VirtualTable-Name "id"> sourceTable:X
  467. const vtMatch = stripped.match(/^<VirtualTable-(\w+)\s+"(\w+)">\s*(.*)$/);
  468. if (vtMatch) {
  469. const props = parseInlineProps(vtMatch[3]);
  470. currentVT = {
  471. name: vtMatch[1],
  472. instanceId: vtMatch[2],
  473. sourceTable: props.sourceTable || '',
  474. fields: [],
  475. extraSpecs: {}
  476. };
  477. // Parse extraSpecs if present
  478. if (props.extraSpecs) {
  479. try { currentVT.extraSpecs = JSON.parse(props.extraSpecs); } catch(e) {}
  480. }
  481. virtualTables.push(currentVT);
  482. continue;
  483. }
  484. // Field in VT
  485. const fieldMatch = stripped.match(/^<Field-(\w+)>\s*(.*)$/);
  486. if (fieldMatch && currentVT) {
  487. const props = parseInlineProps(fieldMatch[2]);
  488. currentVT.fields.push({
  489. name: fieldMatch[1],
  490. type: props.type || 'STRING',
  491. sourceField: props.sourceField || null
  492. });
  493. continue;
  494. }
  495. // Backend components: <ServerApi-X "id">, <MQ-X "id">, etc.
  496. const compMatch = stripped.match(/^<(ServerApi|MQ|TokenIssuer|Encryption|Email|ServerCache)-(\w+)(?:\s+"(\w+)")?/);
  497. if (compMatch) {
  498. backendComponents.push({
  499. type: compMatch[1],
  500. name: compMatch[2],
  501. instanceId: compMatch[3] || null
  502. });
  503. currentVT = null;
  504. }
  505. }
  506. // Parse Services
  507. const services = [];
  508. const serviceLines = sections['Services'] || [];
  509. for (const line of serviceLines) {
  510. const stripped = stripDepth(line.trim()).trim();
  511. if (stripped.startsWith('SERVICE ') || stripped.startsWith('PUBLIC_SERVICE ')) {
  512. const m = parseMethodDecl(stripped);
  513. if (m) {
  514. services.push({
  515. serviceId: `${domainId}.${m.name}`,
  516. name: m.name,
  517. type: m.kind === 'PUBLIC_SERVICE' ? 'PUBLIC_SERVICE' : 'SERVICE',
  518. params: m.params,
  519. returns: { raw: m.returns, fields: parseReturnFields(m.returns) },
  520. expose: m.expose || null
  521. });
  522. }
  523. }
  524. }
  525. // Parse Transactions
  526. const transactions = [];
  527. const txLines = sections['Transactions'] || [];
  528. for (const line of txLines) {
  529. const stripped = stripDepth(line.trim()).trim();
  530. if (stripped.startsWith('TRANSACTION ')) {
  531. const m = parseMethodDecl(stripped);
  532. if (m) {
  533. transactions.push({
  534. name: m.name,
  535. params: m.params,
  536. returns: { raw: m.returns, fields: parseReturnFields(m.returns) }
  537. });
  538. }
  539. }
  540. }
  541. return {
  542. domainId,
  543. fileName,
  544. filePath,
  545. vlVersion: version,
  546. envVars,
  547. virtualTables,
  548. services,
  549. transactions,
  550. backendComponents
  551. };
  552. }
  553. // ─── .cp Parser ───────────────────────────────────────────────────
  554. function parseCP(content, filePath) {
  555. const version = extractVersion(content);
  556. const preview = extractPreview(content);
  557. const root = extractRoot(content);
  558. const sections = splitSections(content);
  559. const componentId = root ? root.name : filePath.split('/').pop().replace('.cp', '');
  560. const fileName = filePath.split('/').pop();
  561. // Parse interactive elements from Frontend Tree
  562. const treeLines = sections['Frontend Tree'] || [];
  563. const interactiveElements = parseInteractiveElements(treeLines);
  564. return {
  565. componentId,
  566. fileName,
  567. filePath,
  568. vlVersion: version,
  569. previewSize: preview,
  570. publicProps: parseVarsFromLines(sections['Frontend Public Props'] || []),
  571. publicEvents: parseEventsFromLines(sections['Frontend Public Events'] || []),
  572. derivedVars: parseVarsFromLines(sections['Frontend Derived Vars'] || []),
  573. interactiveElements,
  574. internalMethods: parseMethodsFromLines(
  575. sections['Frontend Internal Methods'] || [],
  576. ['METHOD']
  577. ),
  578. pipeFuncs: parseMethodsFromLines(
  579. sections['Frontend Pipeline Funcs'] || [],
  580. ['PIPE']
  581. )
  582. };
  583. }
  584. // ─── .sc Parser ───────────────────────────────────────────────────
  585. function parseSC(content, filePath) {
  586. const version = extractVersion(content);
  587. const preview = extractPreview(content);
  588. const root = extractRoot(content);
  589. const sections = splitSections(content);
  590. const sectionId = root ? root.name : filePath.split('/').pop().replace('.sc', '');
  591. const fileName = filePath.split('/').pop();
  592. // Parse ServiceDomain references from Frontend Tree
  593. const treeLines = sections['Frontend Tree'] || [];
  594. const serviceDomainRefs = [];
  595. const sdMap = new Map(); // domainId -> { domainId, instanceId, services }
  596. let currentSD = null;
  597. for (const line of treeLines) {
  598. const stripped = stripDepth(line.trim()).trim();
  599. if (!stripped) continue;
  600. // <ServiceDomain-Name "instanceId">
  601. const sdMatch = stripped.match(/^<ServiceDomain-(\w+)\s+"(\w+)">$/);
  602. if (sdMatch) {
  603. currentSD = { domainId: sdMatch[1], domainInstanceId: sdMatch[2], services: [] };
  604. sdMap.set(sdMatch[1], currentSD);
  605. continue;
  606. }
  607. // <Service-Name> params:(...) returns:(...)
  608. const svcMatch = stripped.match(/^<Service-(\w+)>\s*(.*)$/);
  609. if (svcMatch && currentSD) {
  610. currentSD.services.push({
  611. serviceId: `${currentSD.domainId}.${svcMatch[1]}`,
  612. serviceName: svcMatch[1]
  613. });
  614. continue;
  615. }
  616. // If we encounter a non-service child, reset currentSD context
  617. if (!stripped.startsWith('<Service-') && !stripped.startsWith('<Field-')) {
  618. if (currentSD && !stripped.startsWith('<ServiceDomain-')) {
  619. // Check if the line is still under a ServiceDomain (by depth)
  620. const depth = getDepth(line.trim());
  621. if (depth === 0) currentSD = null;
  622. }
  623. }
  624. }
  625. const servicesUsed = Array.from(sdMap.values());
  626. // Parse Component references from Frontend Tree
  627. const componentRefs = findElementRefs(treeLines, 'Component').map(r => ({
  628. componentId: r.name,
  629. instanceId: r.instanceId
  630. }));
  631. // Parse interactive elements (buttons, inputs, etc.) from Frontend Tree
  632. const interactiveElements = parseInteractiveElements(treeLines);
  633. // Parse methods sections
  634. const pubMethodLines = sections['Frontend Public Methods'] || [];
  635. const publicMethods = [];
  636. for (const line of pubMethodLines) {
  637. const stripped = stripDepth(line.trim()).trim();
  638. if (stripped.startsWith('METHOD_PUB ')) {
  639. const m = parseMethodDecl(stripped);
  640. if (m) publicMethods.push({
  641. name: m.name,
  642. params: m.params,
  643. returns: { raw: m.returns, fields: m.returns ? parseReturnFields(m.returns) : [] }
  644. });
  645. }
  646. }
  647. // ── Nav section detection ──────────────────────────────────────
  648. // A nav section has: PUBLIC EVENT @someEvent(routePath(STRING))
  649. // and renders items with data-key attribute via a For loop
  650. // e.g. SidebarNav.sc has $menuItems with key/label pairs
  651. const publicEvents = parseEventsFromLines(sections['Frontend Public Events'] || []);
  652. const globalVarsAll = parseVarsFromLines(sections['Frontend Global Vars'] || []);
  653. let isNavSection = false;
  654. let navMenuItems = []; // [{ key: '/campaigns', label: 'Campaigns' }, ...]
  655. let navItemInstanceId = null; // e.g. 'menuItem'
  656. // Detect nav event: EVENT @xxx(routePath(STRING)) or similar
  657. const hasNavEvent = publicEvents.some(e =>
  658. e.params?.some(p => p.name === 'routePath' || p.name?.toLowerCase().includes('route'))
  659. );
  660. if (hasNavEvent) {
  661. isNavSection = true;
  662. // Parse $menuItems variable: [{key:STRING,label:STRING,...}] = [{key:"/",label:"Dashboard",...},...]
  663. const menuVar = globalVarsAll.find(v =>
  664. v.name?.toLowerCase().includes('menu') || v.name?.toLowerCase().includes('nav')
  665. );
  666. if (menuVar?.default) {
  667. // Extract key/label pairs from default value string
  668. const pairRegex = /\{[^}]*key:"([^"]+)"[^}]*label:"([^"]+)"[^}]*\}/g;
  669. let m;
  670. while ((m = pairRegex.exec(menuVar.default)) !== null) {
  671. navMenuItems.push({ key: m[1], label: m[2] });
  672. }
  673. // Also try label before key order
  674. if (navMenuItems.length === 0) {
  675. const pairRegex2 = /\{[^}]*label:"([^"]+)"[^}]*key:"([^"]+)"[^}]*\}/g;
  676. while ((m = pairRegex2.exec(menuVar.default)) !== null) {
  677. navMenuItems.push({ key: m[2], label: m[1] });
  678. }
  679. }
  680. }
  681. // Find the For loop's rendered item instanceId (the element with data-key binding)
  682. for (const line of treeLines) {
  683. const stripped = stripDepth(line.trim()).trim();
  684. // Look for <Row-ItemName "instanceId"> ... data-key:...
  685. const rowMatch = stripped.match(/^<(?:Row|Col|Div)-(\w+)\s+"(\w+)">[^>]*data-key:/);
  686. if (rowMatch) {
  687. navItemInstanceId = rowMatch[2];
  688. break;
  689. }
  690. }
  691. }
  692. return {
  693. sectionId,
  694. fileName,
  695. filePath,
  696. vlVersion: version,
  697. previewSize: preview,
  698. publicProps: parseVarsFromLines(sections['Frontend Public Props'] || []),
  699. publicEvents,
  700. publicMethods,
  701. globalVars: globalVarsAll,
  702. derivedVars: parseVarsFromLines(sections['Frontend Derived Vars'] || []),
  703. servicesUsed,
  704. componentRefs,
  705. interactiveElements,
  706. internalMethods: parseMethodsFromLines(
  707. sections['Frontend Internal Methods'] || [],
  708. ['METHOD']
  709. ),
  710. pipeFuncs: parseMethodsFromLines(
  711. sections['Frontend Pipeline Funcs'] || [],
  712. ['PIPE']
  713. ),
  714. // Nav section info (set if this section provides app-level navigation)
  715. isNavSection,
  716. navMenuItems, // [{ key: '/campaigns', label: 'Campaigns' }, ...]
  717. navItemInstanceId, // 'menuItem' — the instance-id of rendered nav items
  718. };
  719. }
  720. // ─── .vx Parser ───────────────────────────────────────────────────
  721. function parseVX(content, filePath) {
  722. const version = extractVersion(content);
  723. const root = extractRoot(content);
  724. const sections = splitSections(content);
  725. const appId = root ? root.name : filePath.split('/').pop().replace('.vx', '');
  726. const fileName = filePath.split('/').pop();
  727. // Parse SysConfig
  728. const sysConfig = { deviceTarget: 'PC', screenResolution: '1920x1080' };
  729. const sysLines = sections['SysConfig'] || [];
  730. for (const line of sysLines) {
  731. const trimmed = line.trim();
  732. const dtMatch = trimmed.match(/^DEVICE_TARGET:"([^"]+)"$/);
  733. if (dtMatch) sysConfig.deviceTarget = dtMatch[1];
  734. const srMatch = trimmed.match(/^SCREEN_RESOLUTION:"([^"]+)"$/);
  735. if (srMatch) sysConfig.screenResolution = srMatch[1];
  736. }
  737. // Parse Global Vars
  738. const globalVars = parseVarsFromLines(sections['Frontend Global Vars'] || []);
  739. // Parse Frontend Tree: Pages with Sections and Components
  740. const treeLines = sections['Frontend Tree'] || [];
  741. const pages = [];
  742. let currentPage = null;
  743. // Route map: extract <If-X> conditions:($routeVar == "/path") → sectionId
  744. // Pattern used in VL SPA apps with route-based navigation ($activeRoute variable)
  745. const routeMap = {}; // { '/campaigns': 'CampaignManagement', ... }
  746. let pendingIfRoute = null; // set when we see an <If-...> with a route condition
  747. // Also detect the routing variable name (e.g., $activeRoute)
  748. let routeVarName = null;
  749. // Detect nav section: which section receives menuSelect event (sets $routeVar)
  750. const navSectionRefs = []; // instanceIds of sections that emit route-changing events
  751. for (const line of treeLines) {
  752. const stripped = stripDepth(line.trim()).trim();
  753. if (!stripped) continue;
  754. // <Page-Name "id"> path:"route"
  755. const pageMatch = stripped.match(/^<Page-(\w+)\s+"(\w+)">\s*(.*)$/);
  756. if (pageMatch) {
  757. const props = parseInlineProps(pageMatch[3]);
  758. currentPage = {
  759. pageId: pageMatch[1],
  760. instanceId: pageMatch[2],
  761. route: props.path || '',
  762. sectionRefs: [],
  763. componentRefs: []
  764. };
  765. pages.push(currentPage);
  766. pendingIfRoute = null;
  767. continue;
  768. }
  769. // <If-Name> conditions:($routeVar == "/path") or conditions:($routeVar == "path")
  770. const ifMatch = stripped.match(/^<If-\w+>\s*conditions?:\((\$\w+)\s*==\s*"([^"]+)"\)/);
  771. if (ifMatch) {
  772. routeVarName = routeVarName || ifMatch[1]; // record routing variable
  773. pendingIfRoute = ifMatch[2]; // e.g. "/campaigns"
  774. continue;
  775. }
  776. // <Section-Name "instanceId"> immediately following an <If> with route condition
  777. if (pendingIfRoute !== null) {
  778. const secInIfMatch = stripped.match(/^<Section-(\w+)\s+"(\w+)">/);
  779. if (secInIfMatch) {
  780. routeMap[pendingIfRoute] = secInIfMatch[1]; // route → sectionId
  781. pendingIfRoute = null;
  782. if (currentPage) {
  783. currentPage.sectionRefs.push({
  784. sectionId: secInIfMatch[1],
  785. instanceId: secInIfMatch[2],
  786. layoutProps: {}
  787. });
  788. }
  789. continue;
  790. }
  791. // Non-section line after If → clear pending
  792. pendingIfRoute = null;
  793. }
  794. if (currentPage) {
  795. // <Section-Name "instanceId"> ...layoutProps (direct section ref, no If)
  796. const secMatch = stripped.match(/^<Section-(\w+)\s+"(\w+)">?\s*(.*)$/);
  797. if (secMatch) {
  798. // Avoid duplicates (might have been added via If route above)
  799. if (!currentPage.sectionRefs.some(r => r.sectionId === secMatch[1])) {
  800. currentPage.sectionRefs.push({
  801. sectionId: secMatch[1],
  802. instanceId: secMatch[2],
  803. layoutProps: parseInlineProps(secMatch[3])
  804. });
  805. }
  806. continue;
  807. }
  808. // <Component-Name "instanceId"> ...props
  809. const compMatch = stripped.match(/^<Component-(\w+)\s+"(\w+)">?\s*(.*)$/);
  810. if (compMatch) {
  811. currentPage.componentRefs.push({
  812. componentId: compMatch[1],
  813. instanceId: compMatch[2]
  814. });
  815. }
  816. }
  817. }
  818. // Parse Event Handlers — detect nav section by pattern:
  819. // <Section-SidebarNav "sidebarNav">.@menuSelect(routePath)
  820. // followed by: -$activeRoute = routePath
  821. const handlerLines = sections['Frontend Event Handlers'] || [];
  822. const eventBindings = parseEventBindings(handlerLines);
  823. // Find which section instanceId provides navigation via @menuSelect → $routeVar assignment
  824. let navSectionInstanceId = null;
  825. let navEventName = null;
  826. for (let i = 0; i < handlerLines.length; i++) {
  827. const line = handlerLines[i].trim();
  828. // Match: <Section-XXX "instanceId">.@eventName(params)
  829. const m = line.match(/^<Section-(\w+)\s+"(\w+)">\s*\.@(\w+)\(([^)]*)\)$/);
  830. if (m) {
  831. // Look at the handler body lines for "$routeVar = <param>"
  832. const param = m[4] ? m[4].split(',')[0].trim() : '';
  833. for (let j = i + 1; j < handlerLines.length && j < i + 5; j++) {
  834. const bodyLine = handlerLines[j].trim().replace(/^-+/, '').trim();
  835. if (routeVarName && bodyLine.startsWith(routeVarName + ' = ') && param && bodyLine.includes(param)) {
  836. navSectionInstanceId = m[2];
  837. navEventName = m[3];
  838. break;
  839. }
  840. }
  841. if (navSectionInstanceId) break;
  842. }
  843. }
  844. // Build home route (route that appears first, or '/')
  845. const homeRoute = Object.keys(routeMap).find(r => r === '/') || Object.keys(routeMap)[0] || '/';
  846. // Parse interactive elements from Frontend Tree
  847. const interactiveElements = parseInteractiveElements(treeLines);
  848. // Parse Internal Methods
  849. const internalMethods = parseMethodsFromLines(
  850. sections['Frontend Internal Methods'] || [],
  851. ['METHOD']
  852. );
  853. return {
  854. appId,
  855. fileName,
  856. filePath,
  857. vlVersion: version,
  858. sysConfig,
  859. globalVars,
  860. pages,
  861. interactiveElements,
  862. eventBindings,
  863. internalMethods,
  864. // Route-based navigation info (key addition for SPA test generation)
  865. routeMap, // { '/': 'DashboardOverview', '/campaigns': 'CampaignManagement', ... }
  866. routeVarName, // '$activeRoute'
  867. homeRoute, // '/'
  868. navSectionInstanceId, // 'sidebarNav' — the section that emits route changes
  869. navEventName, // 'menuSelect'
  870. };
  871. }
  872. // ─── .vth Parser ──────────────────────────────────────────────────
  873. function parseVTH(content, filePath) {
  874. const version = extractVersion(content);
  875. const root = extractRoot(content);
  876. const sections = splitSections(content);
  877. const themeName = root ? root.name : filePath.split('/').pop().replace('.vth', '');
  878. const meta = {};
  879. const metaLines = sections['Meta'] || [];
  880. for (const line of metaLines) {
  881. const trimmed = line.trim();
  882. if (!trimmed || trimmed.startsWith('//')) continue;
  883. const m = trimmed.match(/^([A-Za-z_][\w-]*):(.+)$/);
  884. if (!m) continue;
  885. const [, key, value] = m;
  886. meta[key] = value.trim().replace(/^"(.*)"$/, '$1');
  887. }
  888. const slots = {};
  889. const pointSlotLines = sections['Point Slot Values'] || sections['Coordinate Values'] || [];
  890. for (const line of pointSlotLines) {
  891. const trimmed = line.trim();
  892. if (!trimmed || trimmed.startsWith('//')) continue;
  893. const sep = trimmed.indexOf(':');
  894. if (sep <= 0) continue;
  895. const key = trimmed.slice(0, sep).trim();
  896. const value = trimmed.slice(sep + 1).trim();
  897. if (!key || !value) continue;
  898. slots[key] = value;
  899. }
  900. // Parse Design Tokens
  901. const designTokens = [];
  902. const tokenLines = sections['Design Tokens'] || [];
  903. for (const line of tokenLines) {
  904. const trimmed = line.trim();
  905. if (!trimmed || trimmed.startsWith('//')) continue;
  906. // <TokenGroup-Name> key:value key:value ...
  907. const m = trimmed.match(/^<(\w+)-(\w+)>\s+(.+)$/);
  908. if (m) {
  909. const group = `${m[1]}-${m[2]}`;
  910. const tokensStr = m[3];
  911. const tokens = [];
  912. // Parse key:value pairs - tokens can have complex values like rgba(...)
  913. const tokenRegex = /(\w+):([^\s]+(?:\s+[^\s:]+)*?)(?=\s+\w+:|$)/g;
  914. let tm;
  915. // Simpler approach: split by camelCase boundaries after first match
  916. const pairs = [];
  917. let remaining = tokensStr;
  918. // Use a smarter regex that matches tokenName:value
  919. const smartRegex = /([a-z]\w*):(.+?)(?=\s+[a-z]\w*:|$)/gi;
  920. while ((tm = smartRegex.exec(remaining)) !== null) {
  921. pairs.push({ name: tm[1], value: tm[2].trim() });
  922. }
  923. designTokens.push({ group, tokens: pairs });
  924. }
  925. }
  926. // Parse Component Styles / Component Variants
  927. const componentVariants = [];
  928. const styleLines = sections['Component Styles'] || sections['Component Variants'] || [];
  929. let currentComp = null;
  930. let currentVariant = null;
  931. for (const line of styleLines) {
  932. const trimmed = line.trim();
  933. const stripped = stripDepth(trimmed);
  934. if (!stripped || stripped.startsWith('//')) continue;
  935. const depth = getDepth(trimmed);
  936. // Top-level element: <CompType-VariantName> styles...
  937. if (depth === 0) {
  938. const m = stripped.match(/^<(\w+)-(\w+)>?\s*(.*)$/);
  939. if (m) {
  940. const compType = m[1];
  941. const variantName = m[2];
  942. const styles = m[3] ? parseInlineProps(m[3]) : {};
  943. currentVariant = { name: variantName, styles, stateStyles: [] };
  944. // Find or create component entry
  945. let comp = componentVariants.find(c => c.component === compType);
  946. if (!comp) {
  947. comp = { component: compType, default: null, variants: [], bindingRules: [] };
  948. componentVariants.push(comp);
  949. }
  950. comp.variants.push(currentVariant);
  951. }
  952. continue;
  953. }
  954. // StateStyle nested under variant
  955. if (depth > 0 && currentVariant) {
  956. const ssMatch = stripped.match(/^<StateStyle-(\w+)(?:\s+trigger:(\w+))?>\s*(.*)$/);
  957. if (ssMatch) {
  958. currentVariant.stateStyles.push({
  959. name: ssMatch[1],
  960. trigger: ssMatch[2] || null,
  961. styles: parseInlineProps(ssMatch[3])
  962. });
  963. }
  964. }
  965. }
  966. // Parse Overrides
  967. const overrides = [];
  968. const overrideLines = sections['Overrides'] || [];
  969. for (const line of overrideLines) {
  970. const trimmed = line.trim();
  971. if (!trimmed || trimmed.startsWith('//')) continue;
  972. // <Section-X>.#elementId prop:value
  973. const m = trimmed.match(/^<(\w+-\w+)>\.#(\w+)\s+(.+)$/);
  974. if (m) {
  975. overrides.push({
  976. target: `${m[1]}.#${m[2]}`,
  977. properties: parseInlineProps(m[3])
  978. });
  979. }
  980. }
  981. return {
  982. name: themeName,
  983. vlVersion: version,
  984. rootTag: root ? root.type : null,
  985. meta,
  986. mode: meta.mode || null,
  987. version: meta.version || null,
  988. styleSpaceVersion: meta.styleSpaceVersion || null,
  989. base_theme: meta.base_theme || null,
  990. profile: meta.profile || null,
  991. slots,
  992. pointSlotValues: slots,
  993. designTokens,
  994. componentVariants,
  995. bindingRules: [], // Would need more parsing for binding rules
  996. overrides
  997. };
  998. }
  999. // ─── Main Extract Function ────────────────────────────────────────
  1000. function extract(fileTree) {
  1001. const metadata = {
  1002. project: {
  1003. name: '',
  1004. vlVersion: '',
  1005. fileManifest: []
  1006. },
  1007. database: null,
  1008. serviceDomains: [],
  1009. components: [],
  1010. sections: [],
  1011. apps: [],
  1012. theme: null,
  1013. dependencyGraph: { nodes: [], edges: [] }
  1014. };
  1015. // Normalize paths: strip leading ./ or /
  1016. const normalized = {};
  1017. for (const [path, content] of Object.entries(fileTree)) {
  1018. const normPath = path.replace(/^\.?\//, '');
  1019. normalized[normPath] = content;
  1020. }
  1021. // Classify and parse each file
  1022. for (const [path, content] of Object.entries(normalized)) {
  1023. const info = classifyFile(path);
  1024. if (!info) continue;
  1025. const { type } = info;
  1026. metadata.project.fileManifest.push({
  1027. path,
  1028. type,
  1029. id: null // filled below
  1030. });
  1031. try {
  1032. switch (type) {
  1033. case 'database': {
  1034. const db = parseVDB(content, path);
  1035. metadata.database = db;
  1036. metadata.project.name = db.name;
  1037. metadata.project.vlVersion = db.vlVersion || metadata.project.vlVersion;
  1038. metadata.project.fileManifest[metadata.project.fileManifest.length - 1].id = db.name;
  1039. break;
  1040. }
  1041. case 'service': {
  1042. const sd = parseVS(content, path);
  1043. metadata.serviceDomains.push(sd);
  1044. metadata.project.vlVersion = sd.vlVersion || metadata.project.vlVersion;
  1045. metadata.project.fileManifest[metadata.project.fileManifest.length - 1].id = sd.domainId;
  1046. break;
  1047. }
  1048. case 'component': {
  1049. const cp = parseCP(content, path);
  1050. metadata.components.push(cp);
  1051. metadata.project.vlVersion = cp.vlVersion || metadata.project.vlVersion;
  1052. metadata.project.fileManifest[metadata.project.fileManifest.length - 1].id = cp.componentId;
  1053. break;
  1054. }
  1055. case 'section': {
  1056. const sc = parseSC(content, path);
  1057. metadata.sections.push(sc);
  1058. metadata.project.vlVersion = sc.vlVersion || metadata.project.vlVersion;
  1059. metadata.project.fileManifest[metadata.project.fileManifest.length - 1].id = sc.sectionId;
  1060. break;
  1061. }
  1062. case 'app': {
  1063. const vx = parseVX(content, path);
  1064. metadata.apps.push(vx);
  1065. metadata.project.vlVersion = vx.vlVersion || metadata.project.vlVersion;
  1066. metadata.project.fileManifest[metadata.project.fileManifest.length - 1].id = vx.appId;
  1067. break;
  1068. }
  1069. case 'theme': {
  1070. const th = parseVTH(content, path);
  1071. metadata.theme = th;
  1072. metadata.project.vlVersion = th.vlVersion || metadata.project.vlVersion;
  1073. metadata.project.fileManifest[metadata.project.fileManifest.length - 1].id = th.name;
  1074. break;
  1075. }
  1076. }
  1077. } catch (err) {
  1078. console.warn(`[VLMetadataExtractor] Error parsing ${path}:`, err);
  1079. }
  1080. }
  1081. // Build dependency graph
  1082. metadata.dependencyGraph = buildDependencyGraph(metadata);
  1083. return metadata;
  1084. }
  1085. // ─── Dependency Graph Builder ─────────────────────────────────────
  1086. function buildDependencyGraph(meta) {
  1087. const nodes = [];
  1088. const edges = [];
  1089. const nodeSet = new Set();
  1090. function addNode(id, type, label, sub) {
  1091. if (!nodeSet.has(id)) {
  1092. nodeSet.add(id);
  1093. nodes.push({ id, type, label, sub: sub || '' });
  1094. }
  1095. }
  1096. // Add database/theme nodes
  1097. if (meta.database) {
  1098. const dbPath = meta.project.fileManifest.find(f => f.type === 'database')?.path;
  1099. addNode(dbPath || `Database/${meta.database.name}.vdb`, 'database', meta.database.name, '.vdb');
  1100. }
  1101. if (meta.theme) {
  1102. const thPath = meta.project.fileManifest.find(f => f.type === 'theme')?.path;
  1103. addNode(thPath || `Theme/${meta.theme.name}.vth`, 'theme', meta.theme.name, '.vth');
  1104. }
  1105. // Add service domain nodes
  1106. for (const sd of meta.serviceDomains) {
  1107. addNode(sd.filePath, 'service', sd.domainId, sd.fileName);
  1108. }
  1109. // Add component nodes
  1110. for (const cp of meta.components) {
  1111. addNode(cp.filePath, 'component', cp.componentId, cp.fileName);
  1112. }
  1113. // Add section nodes
  1114. for (const sc of meta.sections) {
  1115. addNode(sc.filePath, 'section', sc.sectionId, sc.fileName);
  1116. }
  1117. // Add app nodes
  1118. for (const app of meta.apps) {
  1119. addNode(app.filePath, 'app', app.appId, app.fileName);
  1120. }
  1121. // Build edges
  1122. // 1. Section -> ServiceDomain (calls)
  1123. for (const sc of meta.sections) {
  1124. for (const svcRef of sc.servicesUsed) {
  1125. const sdMeta = meta.serviceDomains.find(sd => sd.domainId === svcRef.domainId);
  1126. if (sdMeta) {
  1127. const edgeKey = `${sc.filePath}|${sdMeta.filePath}|calls`;
  1128. if (!edges.find(e => `${e.from}|${e.to}|${e.type}` === edgeKey)) {
  1129. edges.push({ from: sc.filePath, to: sdMeta.filePath, type: 'calls' });
  1130. }
  1131. }
  1132. }
  1133. }
  1134. // 2. Section -> Component (uses)
  1135. for (const sc of meta.sections) {
  1136. for (const compRef of sc.componentRefs) {
  1137. const cpMeta = meta.components.find(cp => cp.componentId === compRef.componentId);
  1138. if (cpMeta) {
  1139. edges.push({ from: sc.filePath, to: cpMeta.filePath, type: 'uses' });
  1140. }
  1141. }
  1142. }
  1143. // 3. App -> Section (hosts)
  1144. for (const app of meta.apps) {
  1145. const hostedSections = new Set();
  1146. for (const page of app.pages) {
  1147. for (const secRef of page.sectionRefs) {
  1148. hostedSections.add(secRef.sectionId);
  1149. }
  1150. }
  1151. // Also check event bindings for section references
  1152. for (const binding of app.eventBindings) {
  1153. if (binding.source.type === 'section') {
  1154. hostedSections.add(binding.source.id);
  1155. }
  1156. }
  1157. for (const secId of hostedSections) {
  1158. const scMeta = meta.sections.find(sc => sc.sectionId === secId);
  1159. if (scMeta) {
  1160. edges.push({ from: app.filePath, to: scMeta.filePath, type: 'hosts' });
  1161. }
  1162. }
  1163. }
  1164. // 4. App -> Component (uses)
  1165. for (const app of meta.apps) {
  1166. const usedComps = new Set();
  1167. for (const page of app.pages) {
  1168. for (const compRef of page.componentRefs) {
  1169. usedComps.add(compRef.componentId);
  1170. }
  1171. }
  1172. // Also check event bindings
  1173. for (const binding of app.eventBindings) {
  1174. if (binding.source.type === 'component') {
  1175. usedComps.add(binding.source.id);
  1176. }
  1177. }
  1178. for (const compId of usedComps) {
  1179. const cpMeta = meta.components.find(cp => cp.componentId === compId);
  1180. if (cpMeta) {
  1181. edges.push({ from: app.filePath, to: cpMeta.filePath, type: 'uses' });
  1182. }
  1183. }
  1184. }
  1185. return { nodes, edges };
  1186. }
  1187. // ─── Process Editor Format Converter ──────────────────────────────
  1188. function toProcessEditorFormat(meta) {
  1189. // Build UIMap
  1190. const uiMap = {
  1191. apps: meta.apps.map(a => ({ appId: a.appId })),
  1192. pages: [],
  1193. sections: [],
  1194. components: []
  1195. };
  1196. // Pages
  1197. for (const app of meta.apps) {
  1198. for (const page of app.pages) {
  1199. uiMap.pages.push({
  1200. pageId: page.pageId,
  1201. route: page.route,
  1202. rootSections: page.sectionRefs.map(s => s.sectionId),
  1203. sectionLayout: {}
  1204. });
  1205. }
  1206. }
  1207. // Build section->component reverse map for reusedIn
  1208. const compUsageMap = new Map(); // componentId -> [sectionId]
  1209. for (const sc of meta.sections) {
  1210. for (const ref of sc.componentRefs) {
  1211. if (!compUsageMap.has(ref.componentId)) compUsageMap.set(ref.componentId, []);
  1212. compUsageMap.get(ref.componentId).push(sc.sectionId);
  1213. }
  1214. }
  1215. // Sections
  1216. for (const sc of meta.sections) {
  1217. uiMap.sections.push({
  1218. sectionId: sc.sectionId,
  1219. fileName: sc.fileName,
  1220. servicesUsed: sc.servicesUsed.flatMap(d =>
  1221. d.services.map(svc => ({ serviceId: svc.serviceId }))
  1222. ),
  1223. componentRefs: sc.componentRefs.map(c => c.componentId),
  1224. publicEvents: sc.publicEvents,
  1225. publicMethods: sc.publicMethods,
  1226. keyStates: [...sc.globalVars, ...sc.derivedVars].map(v => ({
  1227. name: v.name,
  1228. type: v.type,
  1229. initialValue: v.default
  1230. }))
  1231. });
  1232. }
  1233. // Components
  1234. for (const cp of meta.components) {
  1235. uiMap.components.push({
  1236. componentId: cp.componentId,
  1237. fileName: cp.fileName,
  1238. props: cp.publicProps,
  1239. events: cp.publicEvents,
  1240. reusedIn: compUsageMap.get(cp.componentId) || []
  1241. });
  1242. }
  1243. // Build ServiceMap
  1244. const serviceMap = {
  1245. serviceDomains: meta.serviceDomains.map(sd => {
  1246. // Build service->vtable mapping
  1247. // We can't perfectly map which services use which VTables without deep analysis
  1248. // But we list all VTables under the domain
  1249. return {
  1250. domainId: sd.domainId,
  1251. fileName: sd.fileName,
  1252. description: '',
  1253. services: sd.services.map(svc => ({
  1254. serviceId: svc.serviceId,
  1255. inputModel: Object.fromEntries(svc.params.map(p => [p.name, p.type])),
  1256. outputModel: svc.returns && svc.returns.fields
  1257. ? Object.fromEntries(svc.returns.fields.map(f => [f.name, f.type]))
  1258. : {},
  1259. virtualTablesUsed: sd.virtualTables.map(vt => vt.instanceId || vt.name),
  1260. operations: []
  1261. })),
  1262. virtualTables: sd.virtualTables.map(vt => ({
  1263. vtId: vt.instanceId || vt.name,
  1264. sourceTable: vt.sourceTable,
  1265. fields: vt.fields
  1266. }))
  1267. };
  1268. })
  1269. };
  1270. // Build VDB text (for Process Editor compatibility)
  1271. let vdbText = '';
  1272. if (meta.database) {
  1273. vdbText = `// VL_VERSION:${meta.project.vlVersion || '2.8'}\n`;
  1274. vdbText += `<Database-${meta.database.name}>\n\n`;
  1275. for (const table of meta.database.tables) {
  1276. vdbText += `<Table-${table.name}>\n`;
  1277. for (const field of table.fields) {
  1278. let fieldLine = `-<Field-${field.name}> type:${field.type}`;
  1279. if (field.notNull) fieldLine += ' notNull:true';
  1280. if (field.default) fieldLine += ` default:${field.default}`;
  1281. vdbText += fieldLine + '\n';
  1282. }
  1283. for (const idx of table.indexes) {
  1284. vdbText += `-<Index-${idx.name}> type:${idx.type} fields:${JSON.stringify(idx.fields)}\n`;
  1285. }
  1286. vdbText += '\n';
  1287. }
  1288. for (const rel of meta.database.relations) {
  1289. const cardMap = { oneToMany: '<<', manyToOne: '>>', oneToOne: '--' };
  1290. vdbText += `<Relation-${rel.name}> ${rel.from.table}.${rel.from.field}${cardMap[rel.cardinality] || '<<'}${rel.to.table}.${rel.to.field}\n`;
  1291. }
  1292. vdbText += `\n</Database-${meta.database.name}>\n`;
  1293. }
  1294. return { uiMap, serviceMap, vdbText };
  1295. }
  1296. // ─── Public API ───────────────────────────────────────────────────
  1297. return {
  1298. extract,
  1299. buildDependencyGraph,
  1300. toProcessEditorFormat,
  1301. // Expose individual parsers for advanced usage
  1302. parsers: {
  1303. parseVDB,
  1304. parseVS,
  1305. parseCP,
  1306. parseSC,
  1307. parseVX,
  1308. parseVTH
  1309. },
  1310. // Expose utilities
  1311. utils: {
  1312. classifyFile,
  1313. splitSections,
  1314. extractVersion,
  1315. extractPreview,
  1316. extractRoot,
  1317. parseParams,
  1318. parseReturnFields,
  1319. parseInlineProps
  1320. }
  1321. };
  1322. })();
  1323. // Support both ES Module and global usage
  1324. if (typeof module !== 'undefined' && module.exports) {
  1325. module.exports = VLMetadataExtractor;
  1326. }