| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486 |
- /**
- * VL Metadata Extractor v1.0
- *
- * Extracts structured metadata directly from VL source files (.vx, .sc, .cp, .vs, .vdb, .vth)
- * Zero dependencies, browser-compatible ES Module.
- *
- * Usage:
- * const metadata = VLMetadataExtractor.extract(fileTree);
- * const { uiMap, serviceMap } = VLMetadataExtractor.toProcessEditorFormat(metadata);
- * const graph = VLMetadataExtractor.buildDependencyGraph(metadata);
- *
- * fileTree: { 'Apps/PlayerDesktop.vx': '...content...', ... }
- */
- const VLMetadataExtractor = (() => {
- // ─── Core Utilities ────────────────────────────────────────────────
- const FILE_TYPES = {
- '.vx': 'app',
- '.sc': 'section',
- '.cp': 'component',
- '.vs': 'service',
- '.vdb': 'database',
- '.vth': 'theme'
- };
- function classifyFile(path) {
- for (const [ext, type] of Object.entries(FILE_TYPES)) {
- if (path.endsWith(ext)) return { type, ext };
- }
- return null;
- }
- function getDepth(line) {
- const m = line.match(/^(-*)/);
- return m ? m[1].length : 0;
- }
- function stripDepth(line) {
- return line.replace(/^-+/, '');
- }
- function splitSections(content) {
- const lines = content.split('\n');
- const sections = {};
- let currentSection = '__header__';
- sections[currentSection] = [];
- for (const line of lines) {
- const trimmed = line.trim();
- if (/^#\s+/.test(trimmed)) {
- currentSection = trimmed.replace(/^#\s+/, '').trim();
- sections[currentSection] = [];
- } else {
- sections[currentSection].push(line);
- }
- }
- return sections;
- }
- // Extract VL version from file content
- function extractVersion(content) {
- const m = content.match(/\/\/\s*VL_VERSION:(\d+\.\d+)/);
- return m ? m[1] : null;
- }
- // Extract preview annotation
- function extractPreview(content) {
- const m = content.match(/\/\/\s*Preview:\s*(.+)/);
- if (!m) return null;
- const props = {};
- const pairs = m[1].match(/[\w-]+:[^\s]+/g) || [];
- for (const pair of pairs) {
- const [k, ...v] = pair.split(':');
- props[k] = v.join(':');
- }
- return props;
- }
- // Extract root component: <Type-Name "id"> ...props
- function extractRoot(content) {
- const m = content.match(/<(App|Section|Component|ServiceDomain|Database|Theme)-(\w+)(?:\s+"(\w+)")?/);
- if (!m) return null;
- return { type: m[1], name: m[2], id: m[3] || null };
- }
- // Find the index of the matching closing paren/bracket for the opener at pos
- function findMatchingClose(str, pos) {
- const open = str[pos];
- const close = open === '(' ? ')' : open === '[' ? ']' : open === '{' ? '}' : null;
- if (!close) return -1;
- let depth = 1;
- for (let i = pos + 1; i < str.length; i++) {
- if (str[i] === open) depth++;
- else if (str[i] === close) { depth--; if (depth === 0) return i; }
- }
- return -1;
- }
- // Extract the balanced content inside the outermost parens starting at pos
- function extractBalancedParens(str, startPos) {
- const openIdx = str.indexOf('(', startPos);
- if (openIdx < 0) return { content: '', endIdx: -1 };
- const closeIdx = findMatchingClose(str, openIdx);
- if (closeIdx < 0) return { content: str.slice(openIdx + 1), endIdx: str.length };
- return { content: str.slice(openIdx + 1, closeIdx), endIdx: closeIdx };
- }
- // Parse typed parameter list: "param1(TYPE), param2(TYPE)"
- // Handles nested parens: legs([{matchId:INT}])
- function parseParams(str) {
- if (!str || !str.trim()) return [];
- const params = [];
- let i = 0;
- while (i < str.length) {
- // Skip whitespace and commas
- while (i < str.length && (str[i] === ' ' || str[i] === ',' || str[i] === '\t')) i++;
- if (i >= str.length) break;
- // Read param name
- let name = '';
- while (i < str.length && /\w/.test(str[i])) { name += str[i]; i++; }
- if (!name) { i++; continue; }
- // Expect (TYPE)
- if (i < str.length && str[i] === '(') {
- const closeIdx = findMatchingClose(str, i);
- if (closeIdx > 0) {
- const type = str.slice(i + 1, closeIdx);
- params.push({ name, type });
- i = closeIdx + 1;
- } else {
- params.push({ name, type: str.slice(i + 1) });
- break;
- }
- }
- }
- return params;
- }
- // Parse return type: "{field:TYPE, field:TYPE}" or complex types
- function parseReturnFields(str) {
- if (!str || !str.trim()) return [];
- const trimmed = str.trim();
- // Handle {field:TYPE, ...} format
- if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
- const inner = trimmed.slice(1, -1);
- return parseFieldList(inner);
- }
- // Otherwise it's a simple type
- return [{ name: '_value', type: trimmed }];
- }
- // Parse "field:TYPE, field:TYPE" or "field:TYPE, field:[{...}]"
- function parseFieldList(str) {
- const fields = [];
- let depth = 0;
- let current = '';
- for (let i = 0; i < str.length; i++) {
- const ch = str[i];
- if (ch === '{' || ch === '[' || ch === '(') depth++;
- else if (ch === '}' || ch === ']' || ch === ')') depth--;
- if (ch === ',' && depth === 0) {
- const f = parseOneField(current.trim());
- if (f) fields.push(f);
- current = '';
- } else {
- current += ch;
- }
- }
- if (current.trim()) {
- const f = parseOneField(current.trim());
- if (f) fields.push(f);
- }
- return fields;
- }
- function parseOneField(str) {
- const idx = str.indexOf(':');
- if (idx < 0) return null;
- return { name: str.slice(0, idx).trim(), type: str.slice(idx + 1).trim() };
- }
- // Parse inline props from element line: key:value key:"quoted" key:(expr)
- function parseInlineProps(propsStr) {
- const props = {};
- if (!propsStr) return props;
- const regex = /(\w[\w-]*):(?:"([^"]*)"|\(([^)]*)\)|(\[[^\]]*\])|(\{[^}]*\})|(\S+))/g;
- let m;
- while ((m = regex.exec(propsStr)) !== null) {
- const key = m[1];
- const val = m[2] !== undefined ? m[2] : (m[3] || m[4] || m[5] || m[6]);
- props[key] = val;
- }
- return props;
- }
- // ─── Variable Declaration Parser ──────────────────────────────────
- function parseVarDecl(line) {
- // $name(TYPE) = default or $name(complex type) = value
- const m = line.match(/^\$(\w+)\(([^)]*(?:\([^)]*\)[^)]*)*(?:\[[^\]]*(?:\{[^}]*\}[^\]]*)*\][^)]*)*)\)\s*=\s*(.+)$/);
- if (!m) {
- // Try simpler pattern
- const m2 = line.match(/^\$(\w+)\((.+?)\)\s*=\s*(.+)$/);
- if (m2) return { name: m2[1], type: m2[2], default: m2[3].trim() };
- return null;
- }
- return { name: m[1], type: m[2], default: m[3].trim() };
- }
- // Parse variables from a section block (lines)
- function parseVarsFromLines(lines) {
- const vars = [];
- for (const line of lines) {
- const trimmed = line.trim();
- if (trimmed.startsWith('$')) {
- const v = parseVarDecl(trimmed);
- if (v) vars.push(v);
- }
- }
- return vars;
- }
- // ─── Event Declaration Parser ─────────────────────────────────────
- function parseEventDecl(line) {
- // EVENT @name(param1(TYPE), param2(TYPE))
- const m = line.match(/^EVENT\s+@(\w+)/);
- if (!m) return null;
- const name = m[1];
- const parenStart = line.indexOf('(', m.index + m[0].length);
- if (parenStart < 0) return { name, params: [] };
- const parenEnd = findMatchingClose(line, parenStart);
- if (parenEnd < 0) return { name, params: [] };
- const paramStr = line.slice(parenStart + 1, parenEnd);
- return { name, params: parseParams(paramStr) };
- }
- function parseEventsFromLines(lines) {
- const events = [];
- for (const line of lines) {
- const trimmed = line.trim();
- if (trimmed.startsWith('EVENT')) {
- const e = parseEventDecl(trimmed);
- if (e) events.push(e);
- }
- }
- return events;
- }
- // ─── Method/Service Declaration Parser ────────────────────────────
- function parseMethodDecl(line) {
- // Handles: METHOD, METHOD_PUB, SERVICE, PUBLIC_SERVICE, TRANSACTION, PIPE
- // Format: KEYWORD name(params); RETURN {fields}
- const kwMatch = line.match(/^(METHOD_PUB|METHOD|PUBLIC_SERVICE|SERVICE|TRANSACTION|PIPE)\s+(\w+)/);
- if (!kwMatch) return null;
- const kind = kwMatch[1];
- const name = kwMatch[2];
- const afterName = line.slice(kwMatch.index + kwMatch[0].length);
- // Find params inside balanced parens
- const parenStart = afterName.indexOf('(');
- if (parenStart < 0) return { kind, name, params: [], returns: null };
- const parenEnd = findMatchingClose(afterName, parenStart);
- if (parenEnd < 0) return { kind, name, params: [], returns: null };
- const paramStr = afterName.slice(parenStart + 1, parenEnd);
- const params = parseParams(paramStr);
- // Find RETURN part after the closing paren
- const afterParams = afterName.slice(parenEnd + 1).trim().replace(/^;\s*/, '');
- let returns = null;
- const retMatch = afterParams.match(/^RETURN\s+(.+)$/);
- if (retMatch) {
- returns = retMatch[1].trim();
- }
- const result = { kind, name, params, returns };
- parseExposeIfPresent(result);
- return result;
- }
- function parseExposeIfPresent(result) {
- if (result.returns && result.returns.includes('EXPOSE')) {
- const parts = result.returns.split(/;\s*EXPOSE\s+/);
- result.returns = parts[0].trim();
- if (parts[1]) {
- result.expose = parseInlineProps(parts[1].replace(/^\{|\}$/g, ''));
- }
- }
- }
- function parseMethodsFromLines(lines, kinds) {
- const methods = [];
- for (const line of lines) {
- const stripped = stripDepth(line.trim()).trim();
- for (const kind of kinds) {
- if (stripped.startsWith(kind + ' ')) {
- const m = parseMethodDecl(stripped);
- if (m) methods.push(m);
- break;
- }
- }
- }
- return methods;
- }
- // ─── Element Reference Parser ─────────────────────────────────────
- function findElementRefs(lines, category) {
- // Find <Category-Name "instanceId"> in lines
- const refs = [];
- const seen = new Set();
- const regex = new RegExp(`<${category}-(\\w+)(?:\\s+"(\\w+)")?`, 'g');
- for (const line of lines) {
- let m;
- while ((m = regex.exec(line)) !== null) {
- const key = `${m[1]}:${m[2] || ''}`;
- if (!seen.has(key)) {
- seen.add(key);
- refs.push({ name: m[1], instanceId: m[2] || null });
- }
- }
- }
- return refs;
- }
- // ─── Interactive Element Parser ──────────────────────────────────
- const INTERACTIVE_CATEGORIES = [
- 'Button', 'Input', 'Textarea', 'Select',
- 'SingleSelect_Dropdown', 'MultiSelect_Dropdown',
- 'Modal', 'Image', 'Chart', 'Icon'
- ];
- const INTERACTIVE_REGEX = new RegExp(
- '<(' + INTERACTIVE_CATEGORIES.join('|') + ')-(\\w+)(?:\\s+"(\\w+)")?>\\s*(.*)',
- 'g'
- );
- function parseInteractiveElements(treeLines) {
- const elements = [];
- for (const line of treeLines) {
- const depth = getDepth(line.trim());
- const stripped = stripDepth(line.trim()).trim();
- if (!stripped) continue;
- INTERACTIVE_REGEX.lastIndex = 0;
- const m = INTERACTIVE_REGEX.exec(stripped);
- if (!m) continue;
- const category = m[1];
- const name = m[2];
- const instanceId = m[3] || null;
- const propsStr = m[4] || '';
- const props = parseInlineProps(propsStr);
- // Determine element type for test generation
- let elType = 'other';
- if (category === 'Button') elType = 'button';
- else if (category === 'Input') elType = 'input';
- else if (category === 'Textarea') elType = 'textarea';
- else if (category === 'Select' || category.includes('Select_Dropdown')) elType = 'select';
- else if (category === 'Modal') elType = 'modal';
- const el = { category, name, instanceId, type: elType, depth };
- if (props.value !== undefined) el.label = props.value;
- if (props.placeholder) el.placeholder = props.placeholder;
- if (props.type) el.inputType = props.type;
- if (props.disabled) el.disabled = props.disabled;
- if (props.StyleClass) el.styleClass = props.StyleClass;
- elements.push(el);
- }
- return elements;
- }
- // ─── Event Binding Parser (for .vx files) ─────────────────────────
- function parseEventBindings(lines) {
- const bindings = [];
- let current = null;
- for (const line of lines) {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) continue;
- // Match: <Section-Name "id">.@eventName(params)
- // Or: <Component-Name "id">.@eventName(params)
- const m = trimmed.match(/^<(Section|Component)-(\w+)\s+"(\w+)">\s*\.@(\w+)\(([^)]*)\)$/);
- if (m) {
- if (current) bindings.push(current);
- current = {
- 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) : [] },
- actions: []
- };
- continue;
- }
- // Collect handler body lines
- if (current && trimmed.startsWith('-')) {
- current.actions.push(stripDepth(trimmed).trim());
- } else if (current && trimmed === '') {
- // Empty line may end the handler
- } else if (current && !trimmed.startsWith('-') && !trimmed.startsWith('<')) {
- // Non-dash non-element line in handler
- // Still part of the handler if we're at depth
- }
- }
- if (current) bindings.push(current);
- return bindings;
- }
- // ─── .vdb Parser ──────────────────────────────────────────────────
- function parseVDB(content, filePath) {
- const version = extractVersion(content);
- const root = extractRoot(content);
- const lines = content.split('\n');
- const tables = [];
- const relations = [];
- let currentTable = null;
- for (const line of lines) {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*')) continue;
- // Table start: <Table-Name> data:[...]
- const tableMatch = trimmed.match(/^<Table-(\w+)>(?:\s+data:(.+))?$/);
- if (tableMatch) {
- currentTable = {
- name: tableMatch[1],
- fields: [],
- indexes: [],
- seedData: tableMatch[2] || null
- };
- tables.push(currentTable);
- continue;
- }
- // Close table
- if (trimmed.match(/^<\/Table-/)) {
- currentTable = null;
- continue;
- }
- // Field in table
- const fieldMatch = stripDepth(trimmed).match(/^<Field-(\w+)>\s*(.*)$/);
- if (fieldMatch && currentTable) {
- const props = parseInlineProps(fieldMatch[2]);
- currentTable.fields.push({
- name: fieldMatch[1],
- type: props.type || 'STRING',
- notNull: props.notNull === 'true',
- default: props.default || null
- });
- continue;
- }
- // Index in table
- const indexMatch = stripDepth(trimmed).match(/^<Index-(\w+)>\s*(.*)$/);
- if (indexMatch && currentTable) {
- const props = parseInlineProps(indexMatch[2]);
- let fields = [];
- if (props.fields) {
- try { fields = JSON.parse(props.fields); } catch(e) { fields = [props.fields]; }
- }
- currentTable.indexes.push({
- name: indexMatch[1],
- type: props.type || 'NORMAL',
- fields
- });
- continue;
- }
- // Relation: <Relation-T1&T2> T1.field<<T2.field
- const relMatch = trimmed.match(/^<Relation-(\w+&\w+)>\s+(\w+)\.([(\w,)]+)(<<|>>|--)(\w+)\.([(\w,)]+)$/);
- if (relMatch) {
- const cardMap = { '<<': 'oneToMany', '>>': 'manyToOne', '--': 'oneToOne' };
- relations.push({
- name: relMatch[1],
- from: { table: relMatch[2], field: relMatch[3] },
- to: { table: relMatch[5], field: relMatch[6] },
- cardinality: cardMap[relMatch[4]] || relMatch[4]
- });
- }
- }
- return {
- name: root ? root.name : filePath.split('/').pop().replace('.vdb', ''),
- vlVersion: version,
- tables,
- relations
- };
- }
- // ─── .vs Parser ───────────────────────────────────────────────────
- function parseVS(content, filePath) {
- const version = extractVersion(content);
- const root = extractRoot(content);
- const sections = splitSections(content);
- const domainId = root ? root.name : filePath.split('/').pop().replace('.vs', '');
- const fileName = filePath.split('/').pop();
- // Parse environment vars
- const envVars = [];
- const envLines = sections['Backend Environment Vars'] || [];
- for (const line of envLines) {
- const trimmed = line.trim();
- const m = trimmed.match(/^ENV\s+(\w+)\((\w+)\)\s+"([^"]*)"$/);
- if (m) {
- envVars.push({ name: m[1], type: m[2], description: m[3] });
- }
- }
- // Parse Backend Tree: VirtualTables and backend components
- const virtualTables = [];
- const backendComponents = [];
- const treeLines = sections['Backend Tree'] || [];
- let currentVT = null;
- for (const line of treeLines) {
- const trimmed = line.trim();
- const stripped = stripDepth(trimmed);
- if (!stripped) continue;
- // VirtualTable: <VirtualTable-Name "id"> sourceTable:X
- const vtMatch = stripped.match(/^<VirtualTable-(\w+)\s+"(\w+)">\s*(.*)$/);
- if (vtMatch) {
- const props = parseInlineProps(vtMatch[3]);
- currentVT = {
- name: vtMatch[1],
- instanceId: vtMatch[2],
- sourceTable: props.sourceTable || '',
- fields: [],
- extraSpecs: {}
- };
- // Parse extraSpecs if present
- if (props.extraSpecs) {
- try { currentVT.extraSpecs = JSON.parse(props.extraSpecs); } catch(e) {}
- }
- virtualTables.push(currentVT);
- continue;
- }
- // Field in VT
- const fieldMatch = stripped.match(/^<Field-(\w+)>\s*(.*)$/);
- if (fieldMatch && currentVT) {
- const props = parseInlineProps(fieldMatch[2]);
- currentVT.fields.push({
- name: fieldMatch[1],
- type: props.type || 'STRING',
- sourceField: props.sourceField || null
- });
- continue;
- }
- // Backend components: <ServerApi-X "id">, <MQ-X "id">, etc.
- const compMatch = stripped.match(/^<(ServerApi|MQ|TokenIssuer|Encryption|Email|ServerCache)-(\w+)(?:\s+"(\w+)")?/);
- if (compMatch) {
- backendComponents.push({
- type: compMatch[1],
- name: compMatch[2],
- instanceId: compMatch[3] || null
- });
- currentVT = null;
- }
- }
- // Parse Services
- const services = [];
- const serviceLines = sections['Services'] || [];
- for (const line of serviceLines) {
- const stripped = stripDepth(line.trim()).trim();
- if (stripped.startsWith('SERVICE ') || stripped.startsWith('PUBLIC_SERVICE ')) {
- const m = parseMethodDecl(stripped);
- if (m) {
- services.push({
- serviceId: `${domainId}.${m.name}`,
- name: m.name,
- type: m.kind === 'PUBLIC_SERVICE' ? 'PUBLIC_SERVICE' : 'SERVICE',
- params: m.params,
- returns: { raw: m.returns, fields: parseReturnFields(m.returns) },
- expose: m.expose || null
- });
- }
- }
- }
- // Parse Transactions
- const transactions = [];
- const txLines = sections['Transactions'] || [];
- for (const line of txLines) {
- const stripped = stripDepth(line.trim()).trim();
- if (stripped.startsWith('TRANSACTION ')) {
- const m = parseMethodDecl(stripped);
- if (m) {
- transactions.push({
- name: m.name,
- params: m.params,
- returns: { raw: m.returns, fields: parseReturnFields(m.returns) }
- });
- }
- }
- }
- return {
- domainId,
- fileName,
- filePath,
- vlVersion: version,
- envVars,
- virtualTables,
- services,
- transactions,
- backendComponents
- };
- }
- // ─── .cp Parser ───────────────────────────────────────────────────
- function parseCP(content, filePath) {
- const version = extractVersion(content);
- const preview = extractPreview(content);
- const root = extractRoot(content);
- const sections = splitSections(content);
- const componentId = root ? root.name : filePath.split('/').pop().replace('.cp', '');
- const fileName = filePath.split('/').pop();
- // Parse interactive elements from Frontend Tree
- const treeLines = sections['Frontend Tree'] || [];
- const interactiveElements = parseInteractiveElements(treeLines);
- return {
- componentId,
- fileName,
- filePath,
- vlVersion: version,
- previewSize: preview,
- publicProps: parseVarsFromLines(sections['Frontend Public Props'] || []),
- publicEvents: parseEventsFromLines(sections['Frontend Public Events'] || []),
- derivedVars: parseVarsFromLines(sections['Frontend Derived Vars'] || []),
- interactiveElements,
- internalMethods: parseMethodsFromLines(
- sections['Frontend Internal Methods'] || [],
- ['METHOD']
- ),
- pipeFuncs: parseMethodsFromLines(
- sections['Frontend Pipeline Funcs'] || [],
- ['PIPE']
- )
- };
- }
- // ─── .sc Parser ───────────────────────────────────────────────────
- function parseSC(content, filePath) {
- const version = extractVersion(content);
- const preview = extractPreview(content);
- const root = extractRoot(content);
- const sections = splitSections(content);
- const sectionId = root ? root.name : filePath.split('/').pop().replace('.sc', '');
- const fileName = filePath.split('/').pop();
- // Parse ServiceDomain references from Frontend Tree
- const treeLines = sections['Frontend Tree'] || [];
- const serviceDomainRefs = [];
- const sdMap = new Map(); // domainId -> { domainId, instanceId, services }
- let currentSD = null;
- for (const line of treeLines) {
- const stripped = stripDepth(line.trim()).trim();
- if (!stripped) continue;
- // <ServiceDomain-Name "instanceId">
- const sdMatch = stripped.match(/^<ServiceDomain-(\w+)\s+"(\w+)">$/);
- if (sdMatch) {
- currentSD = { domainId: sdMatch[1], domainInstanceId: sdMatch[2], services: [] };
- sdMap.set(sdMatch[1], currentSD);
- continue;
- }
- // <Service-Name> params:(...) returns:(...)
- const svcMatch = stripped.match(/^<Service-(\w+)>\s*(.*)$/);
- if (svcMatch && currentSD) {
- currentSD.services.push({
- serviceId: `${currentSD.domainId}.${svcMatch[1]}`,
- serviceName: svcMatch[1]
- });
- continue;
- }
- // If we encounter a non-service child, reset currentSD context
- if (!stripped.startsWith('<Service-') && !stripped.startsWith('<Field-')) {
- if (currentSD && !stripped.startsWith('<ServiceDomain-')) {
- // Check if the line is still under a ServiceDomain (by depth)
- const depth = getDepth(line.trim());
- if (depth === 0) currentSD = null;
- }
- }
- }
- const servicesUsed = Array.from(sdMap.values());
- // Parse Component references from Frontend Tree
- const componentRefs = findElementRefs(treeLines, 'Component').map(r => ({
- componentId: r.name,
- instanceId: r.instanceId
- }));
- // Parse interactive elements (buttons, inputs, etc.) from Frontend Tree
- const interactiveElements = parseInteractiveElements(treeLines);
- // Parse methods sections
- const pubMethodLines = sections['Frontend Public Methods'] || [];
- const publicMethods = [];
- for (const line of pubMethodLines) {
- const stripped = stripDepth(line.trim()).trim();
- if (stripped.startsWith('METHOD_PUB ')) {
- const m = parseMethodDecl(stripped);
- if (m) publicMethods.push({
- name: m.name,
- params: m.params,
- returns: { raw: m.returns, fields: m.returns ? parseReturnFields(m.returns) : [] }
- });
- }
- }
- // ── Nav section detection ──────────────────────────────────────
- // A nav section has: PUBLIC EVENT @someEvent(routePath(STRING))
- // and renders items with data-key attribute via a For loop
- // e.g. SidebarNav.sc has $menuItems with key/label pairs
- const publicEvents = parseEventsFromLines(sections['Frontend Public Events'] || []);
- const globalVarsAll = parseVarsFromLines(sections['Frontend Global Vars'] || []);
- let isNavSection = false;
- let navMenuItems = []; // [{ key: '/campaigns', label: 'Campaigns' }, ...]
- let navItemInstanceId = null; // e.g. 'menuItem'
- // Detect nav event: EVENT @xxx(routePath(STRING)) or similar
- const hasNavEvent = publicEvents.some(e =>
- e.params?.some(p => p.name === 'routePath' || p.name?.toLowerCase().includes('route'))
- );
- if (hasNavEvent) {
- isNavSection = true;
- // Parse $menuItems variable: [{key:STRING,label:STRING,...}] = [{key:"/",label:"Dashboard",...},...]
- const menuVar = globalVarsAll.find(v =>
- v.name?.toLowerCase().includes('menu') || v.name?.toLowerCase().includes('nav')
- );
- if (menuVar?.default) {
- // Extract key/label pairs from default value string
- const pairRegex = /\{[^}]*key:"([^"]+)"[^}]*label:"([^"]+)"[^}]*\}/g;
- let m;
- while ((m = pairRegex.exec(menuVar.default)) !== null) {
- navMenuItems.push({ key: m[1], label: m[2] });
- }
- // Also try label before key order
- if (navMenuItems.length === 0) {
- const pairRegex2 = /\{[^}]*label:"([^"]+)"[^}]*key:"([^"]+)"[^}]*\}/g;
- while ((m = pairRegex2.exec(menuVar.default)) !== null) {
- navMenuItems.push({ key: m[2], label: m[1] });
- }
- }
- }
- // Find the For loop's rendered item instanceId (the element with data-key binding)
- for (const line of treeLines) {
- const stripped = stripDepth(line.trim()).trim();
- // Look for <Row-ItemName "instanceId"> ... data-key:...
- const rowMatch = stripped.match(/^<(?:Row|Col|Div)-(\w+)\s+"(\w+)">[^>]*data-key:/);
- if (rowMatch) {
- navItemInstanceId = rowMatch[2];
- break;
- }
- }
- }
- return {
- sectionId,
- fileName,
- filePath,
- vlVersion: version,
- previewSize: preview,
- publicProps: parseVarsFromLines(sections['Frontend Public Props'] || []),
- publicEvents,
- publicMethods,
- globalVars: globalVarsAll,
- derivedVars: parseVarsFromLines(sections['Frontend Derived Vars'] || []),
- servicesUsed,
- componentRefs,
- interactiveElements,
- internalMethods: parseMethodsFromLines(
- sections['Frontend Internal Methods'] || [],
- ['METHOD']
- ),
- pipeFuncs: parseMethodsFromLines(
- sections['Frontend Pipeline Funcs'] || [],
- ['PIPE']
- ),
- // Nav section info (set if this section provides app-level navigation)
- isNavSection,
- navMenuItems, // [{ key: '/campaigns', label: 'Campaigns' }, ...]
- navItemInstanceId, // 'menuItem' — the instance-id of rendered nav items
- };
- }
- // ─── .vx Parser ───────────────────────────────────────────────────
- function parseVX(content, filePath) {
- const version = extractVersion(content);
- const root = extractRoot(content);
- const sections = splitSections(content);
- const appId = root ? root.name : filePath.split('/').pop().replace('.vx', '');
- const fileName = filePath.split('/').pop();
- // Parse SysConfig
- const sysConfig = { deviceTarget: 'PC', screenResolution: '1920x1080' };
- const sysLines = sections['SysConfig'] || [];
- for (const line of sysLines) {
- const trimmed = line.trim();
- const dtMatch = trimmed.match(/^DEVICE_TARGET:"([^"]+)"$/);
- if (dtMatch) sysConfig.deviceTarget = dtMatch[1];
- const srMatch = trimmed.match(/^SCREEN_RESOLUTION:"([^"]+)"$/);
- if (srMatch) sysConfig.screenResolution = srMatch[1];
- }
- // Parse Global Vars
- const globalVars = parseVarsFromLines(sections['Frontend Global Vars'] || []);
- // Parse Frontend Tree: Pages with Sections and Components
- const treeLines = sections['Frontend Tree'] || [];
- const pages = [];
- let currentPage = null;
- // Route map: extract <If-X> conditions:($routeVar == "/path") → sectionId
- // Pattern used in VL SPA apps with route-based navigation ($activeRoute variable)
- const routeMap = {}; // { '/campaigns': 'CampaignManagement', ... }
- let pendingIfRoute = null; // set when we see an <If-...> with a route condition
- // Also detect the routing variable name (e.g., $activeRoute)
- let routeVarName = null;
- // Detect nav section: which section receives menuSelect event (sets $routeVar)
- const navSectionRefs = []; // instanceIds of sections that emit route-changing events
- for (const line of treeLines) {
- const stripped = stripDepth(line.trim()).trim();
- if (!stripped) continue;
- // <Page-Name "id"> path:"route"
- const pageMatch = stripped.match(/^<Page-(\w+)\s+"(\w+)">\s*(.*)$/);
- if (pageMatch) {
- const props = parseInlineProps(pageMatch[3]);
- currentPage = {
- pageId: pageMatch[1],
- instanceId: pageMatch[2],
- route: props.path || '',
- sectionRefs: [],
- componentRefs: []
- };
- pages.push(currentPage);
- pendingIfRoute = null;
- continue;
- }
- // <If-Name> conditions:($routeVar == "/path") or conditions:($routeVar == "path")
- const ifMatch = stripped.match(/^<If-\w+>\s*conditions?:\((\$\w+)\s*==\s*"([^"]+)"\)/);
- if (ifMatch) {
- routeVarName = routeVarName || ifMatch[1]; // record routing variable
- pendingIfRoute = ifMatch[2]; // e.g. "/campaigns"
- continue;
- }
- // <Section-Name "instanceId"> immediately following an <If> with route condition
- if (pendingIfRoute !== null) {
- const secInIfMatch = stripped.match(/^<Section-(\w+)\s+"(\w+)">/);
- if (secInIfMatch) {
- routeMap[pendingIfRoute] = secInIfMatch[1]; // route → sectionId
- pendingIfRoute = null;
- if (currentPage) {
- currentPage.sectionRefs.push({
- sectionId: secInIfMatch[1],
- instanceId: secInIfMatch[2],
- layoutProps: {}
- });
- }
- continue;
- }
- // Non-section line after If → clear pending
- pendingIfRoute = null;
- }
- if (currentPage) {
- // <Section-Name "instanceId"> ...layoutProps (direct section ref, no If)
- const secMatch = stripped.match(/^<Section-(\w+)\s+"(\w+)">?\s*(.*)$/);
- if (secMatch) {
- // Avoid duplicates (might have been added via If route above)
- if (!currentPage.sectionRefs.some(r => r.sectionId === secMatch[1])) {
- currentPage.sectionRefs.push({
- sectionId: secMatch[1],
- instanceId: secMatch[2],
- layoutProps: parseInlineProps(secMatch[3])
- });
- }
- continue;
- }
- // <Component-Name "instanceId"> ...props
- const compMatch = stripped.match(/^<Component-(\w+)\s+"(\w+)">?\s*(.*)$/);
- if (compMatch) {
- currentPage.componentRefs.push({
- componentId: compMatch[1],
- instanceId: compMatch[2]
- });
- }
- }
- }
- // Parse Event Handlers — detect nav section by pattern:
- // <Section-SidebarNav "sidebarNav">.@menuSelect(routePath)
- // followed by: -$activeRoute = routePath
- const handlerLines = sections['Frontend Event Handlers'] || [];
- const eventBindings = parseEventBindings(handlerLines);
- // Find which section instanceId provides navigation via @menuSelect → $routeVar assignment
- let navSectionInstanceId = null;
- let navEventName = null;
- for (let i = 0; i < handlerLines.length; i++) {
- const line = handlerLines[i].trim();
- // Match: <Section-XXX "instanceId">.@eventName(params)
- const m = line.match(/^<Section-(\w+)\s+"(\w+)">\s*\.@(\w+)\(([^)]*)\)$/);
- if (m) {
- // Look at the handler body lines for "$routeVar = <param>"
- const param = m[4] ? m[4].split(',')[0].trim() : '';
- for (let j = i + 1; j < handlerLines.length && j < i + 5; j++) {
- const bodyLine = handlerLines[j].trim().replace(/^-+/, '').trim();
- if (routeVarName && bodyLine.startsWith(routeVarName + ' = ') && param && bodyLine.includes(param)) {
- navSectionInstanceId = m[2];
- navEventName = m[3];
- break;
- }
- }
- if (navSectionInstanceId) break;
- }
- }
- // Build home route (route that appears first, or '/')
- const homeRoute = Object.keys(routeMap).find(r => r === '/') || Object.keys(routeMap)[0] || '/';
- // Parse interactive elements from Frontend Tree
- const interactiveElements = parseInteractiveElements(treeLines);
- // Parse Internal Methods
- const internalMethods = parseMethodsFromLines(
- sections['Frontend Internal Methods'] || [],
- ['METHOD']
- );
- return {
- appId,
- fileName,
- filePath,
- vlVersion: version,
- sysConfig,
- globalVars,
- pages,
- interactiveElements,
- eventBindings,
- internalMethods,
- // Route-based navigation info (key addition for SPA test generation)
- routeMap, // { '/': 'DashboardOverview', '/campaigns': 'CampaignManagement', ... }
- routeVarName, // '$activeRoute'
- homeRoute, // '/'
- navSectionInstanceId, // 'sidebarNav' — the section that emits route changes
- navEventName, // 'menuSelect'
- };
- }
- // ─── .vth Parser ──────────────────────────────────────────────────
- function parseVTH(content, filePath) {
- const version = extractVersion(content);
- const root = extractRoot(content);
- const sections = splitSections(content);
- const themeName = root ? root.name : filePath.split('/').pop().replace('.vth', '');
- const meta = {};
- const metaLines = sections['Meta'] || [];
- for (const line of metaLines) {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith('//')) continue;
- const m = trimmed.match(/^([A-Za-z_][\w-]*):(.+)$/);
- if (!m) continue;
- const [, key, value] = m;
- meta[key] = value.trim().replace(/^"(.*)"$/, '$1');
- }
- const slots = {};
- const pointSlotLines = sections['Point Slot Values'] || sections['Coordinate Values'] || [];
- for (const line of pointSlotLines) {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith('//')) continue;
- const sep = trimmed.indexOf(':');
- if (sep <= 0) continue;
- const key = trimmed.slice(0, sep).trim();
- const value = trimmed.slice(sep + 1).trim();
- if (!key || !value) continue;
- slots[key] = value;
- }
- // Parse Design Tokens
- const designTokens = [];
- const tokenLines = sections['Design Tokens'] || [];
- for (const line of tokenLines) {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith('//')) continue;
- // <TokenGroup-Name> key:value key:value ...
- const m = trimmed.match(/^<(\w+)-(\w+)>\s+(.+)$/);
- if (m) {
- const group = `${m[1]}-${m[2]}`;
- const tokensStr = m[3];
- const tokens = [];
- // Parse key:value pairs - tokens can have complex values like rgba(...)
- const tokenRegex = /(\w+):([^\s]+(?:\s+[^\s:]+)*?)(?=\s+\w+:|$)/g;
- let tm;
- // Simpler approach: split by camelCase boundaries after first match
- const pairs = [];
- let remaining = tokensStr;
- // Use a smarter regex that matches tokenName:value
- const smartRegex = /([a-z]\w*):(.+?)(?=\s+[a-z]\w*:|$)/gi;
- while ((tm = smartRegex.exec(remaining)) !== null) {
- pairs.push({ name: tm[1], value: tm[2].trim() });
- }
- designTokens.push({ group, tokens: pairs });
- }
- }
- // Parse Component Styles / Component Variants
- const componentVariants = [];
- const styleLines = sections['Component Styles'] || sections['Component Variants'] || [];
- let currentComp = null;
- let currentVariant = null;
- for (const line of styleLines) {
- const trimmed = line.trim();
- const stripped = stripDepth(trimmed);
- if (!stripped || stripped.startsWith('//')) continue;
- const depth = getDepth(trimmed);
- // Top-level element: <CompType-VariantName> styles...
- if (depth === 0) {
- const m = stripped.match(/^<(\w+)-(\w+)>?\s*(.*)$/);
- if (m) {
- const compType = m[1];
- const variantName = m[2];
- const styles = m[3] ? parseInlineProps(m[3]) : {};
- currentVariant = { name: variantName, styles, stateStyles: [] };
- // Find or create component entry
- let comp = componentVariants.find(c => c.component === compType);
- if (!comp) {
- comp = { component: compType, default: null, variants: [], bindingRules: [] };
- componentVariants.push(comp);
- }
- comp.variants.push(currentVariant);
- }
- continue;
- }
- // StateStyle nested under variant
- if (depth > 0 && currentVariant) {
- const ssMatch = stripped.match(/^<StateStyle-(\w+)(?:\s+trigger:(\w+))?>\s*(.*)$/);
- if (ssMatch) {
- currentVariant.stateStyles.push({
- name: ssMatch[1],
- trigger: ssMatch[2] || null,
- styles: parseInlineProps(ssMatch[3])
- });
- }
- }
- }
- // Parse Overrides
- const overrides = [];
- const overrideLines = sections['Overrides'] || [];
- for (const line of overrideLines) {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith('//')) continue;
- // <Section-X>.#elementId prop:value
- const m = trimmed.match(/^<(\w+-\w+)>\.#(\w+)\s+(.+)$/);
- if (m) {
- overrides.push({
- target: `${m[1]}.#${m[2]}`,
- properties: parseInlineProps(m[3])
- });
- }
- }
- return {
- name: themeName,
- vlVersion: version,
- rootTag: root ? root.type : null,
- meta,
- mode: meta.mode || null,
- version: meta.version || null,
- styleSpaceVersion: meta.styleSpaceVersion || null,
- base_theme: meta.base_theme || null,
- profile: meta.profile || null,
- slots,
- pointSlotValues: slots,
- designTokens,
- componentVariants,
- bindingRules: [], // Would need more parsing for binding rules
- overrides
- };
- }
- // ─── Main Extract Function ────────────────────────────────────────
- function extract(fileTree) {
- const metadata = {
- project: {
- name: '',
- vlVersion: '',
- fileManifest: []
- },
- database: null,
- serviceDomains: [],
- components: [],
- sections: [],
- apps: [],
- theme: null,
- dependencyGraph: { nodes: [], edges: [] }
- };
- // Normalize paths: strip leading ./ or /
- const normalized = {};
- for (const [path, content] of Object.entries(fileTree)) {
- const normPath = path.replace(/^\.?\//, '');
- normalized[normPath] = content;
- }
- // Classify and parse each file
- for (const [path, content] of Object.entries(normalized)) {
- const info = classifyFile(path);
- if (!info) continue;
- const { type } = info;
- metadata.project.fileManifest.push({
- path,
- type,
- id: null // filled below
- });
- try {
- switch (type) {
- case 'database': {
- const db = parseVDB(content, path);
- metadata.database = db;
- metadata.project.name = db.name;
- metadata.project.vlVersion = db.vlVersion || metadata.project.vlVersion;
- metadata.project.fileManifest[metadata.project.fileManifest.length - 1].id = db.name;
- break;
- }
- case 'service': {
- const sd = parseVS(content, path);
- metadata.serviceDomains.push(sd);
- metadata.project.vlVersion = sd.vlVersion || metadata.project.vlVersion;
- metadata.project.fileManifest[metadata.project.fileManifest.length - 1].id = sd.domainId;
- break;
- }
- case 'component': {
- const cp = parseCP(content, path);
- metadata.components.push(cp);
- metadata.project.vlVersion = cp.vlVersion || metadata.project.vlVersion;
- metadata.project.fileManifest[metadata.project.fileManifest.length - 1].id = cp.componentId;
- break;
- }
- case 'section': {
- const sc = parseSC(content, path);
- metadata.sections.push(sc);
- metadata.project.vlVersion = sc.vlVersion || metadata.project.vlVersion;
- metadata.project.fileManifest[metadata.project.fileManifest.length - 1].id = sc.sectionId;
- break;
- }
- case 'app': {
- const vx = parseVX(content, path);
- metadata.apps.push(vx);
- metadata.project.vlVersion = vx.vlVersion || metadata.project.vlVersion;
- metadata.project.fileManifest[metadata.project.fileManifest.length - 1].id = vx.appId;
- break;
- }
- case 'theme': {
- const th = parseVTH(content, path);
- metadata.theme = th;
- metadata.project.vlVersion = th.vlVersion || metadata.project.vlVersion;
- metadata.project.fileManifest[metadata.project.fileManifest.length - 1].id = th.name;
- break;
- }
- }
- } catch (err) {
- console.warn(`[VLMetadataExtractor] Error parsing ${path}:`, err);
- }
- }
- // Build dependency graph
- metadata.dependencyGraph = buildDependencyGraph(metadata);
- return metadata;
- }
- // ─── Dependency Graph Builder ─────────────────────────────────────
- function buildDependencyGraph(meta) {
- const nodes = [];
- const edges = [];
- const nodeSet = new Set();
- function addNode(id, type, label, sub) {
- if (!nodeSet.has(id)) {
- nodeSet.add(id);
- nodes.push({ id, type, label, sub: sub || '' });
- }
- }
- // Add database/theme nodes
- if (meta.database) {
- const dbPath = meta.project.fileManifest.find(f => f.type === 'database')?.path;
- addNode(dbPath || `Database/${meta.database.name}.vdb`, 'database', meta.database.name, '.vdb');
- }
- if (meta.theme) {
- const thPath = meta.project.fileManifest.find(f => f.type === 'theme')?.path;
- addNode(thPath || `Theme/${meta.theme.name}.vth`, 'theme', meta.theme.name, '.vth');
- }
- // Add service domain nodes
- for (const sd of meta.serviceDomains) {
- addNode(sd.filePath, 'service', sd.domainId, sd.fileName);
- }
- // Add component nodes
- for (const cp of meta.components) {
- addNode(cp.filePath, 'component', cp.componentId, cp.fileName);
- }
- // Add section nodes
- for (const sc of meta.sections) {
- addNode(sc.filePath, 'section', sc.sectionId, sc.fileName);
- }
- // Add app nodes
- for (const app of meta.apps) {
- addNode(app.filePath, 'app', app.appId, app.fileName);
- }
- // Build edges
- // 1. Section -> ServiceDomain (calls)
- for (const sc of meta.sections) {
- for (const svcRef of sc.servicesUsed) {
- const sdMeta = meta.serviceDomains.find(sd => sd.domainId === svcRef.domainId);
- if (sdMeta) {
- const edgeKey = `${sc.filePath}|${sdMeta.filePath}|calls`;
- if (!edges.find(e => `${e.from}|${e.to}|${e.type}` === edgeKey)) {
- edges.push({ from: sc.filePath, to: sdMeta.filePath, type: 'calls' });
- }
- }
- }
- }
- // 2. Section -> Component (uses)
- for (const sc of meta.sections) {
- for (const compRef of sc.componentRefs) {
- const cpMeta = meta.components.find(cp => cp.componentId === compRef.componentId);
- if (cpMeta) {
- edges.push({ from: sc.filePath, to: cpMeta.filePath, type: 'uses' });
- }
- }
- }
- // 3. App -> Section (hosts)
- for (const app of meta.apps) {
- const hostedSections = new Set();
- for (const page of app.pages) {
- for (const secRef of page.sectionRefs) {
- hostedSections.add(secRef.sectionId);
- }
- }
- // Also check event bindings for section references
- for (const binding of app.eventBindings) {
- if (binding.source.type === 'section') {
- hostedSections.add(binding.source.id);
- }
- }
- for (const secId of hostedSections) {
- const scMeta = meta.sections.find(sc => sc.sectionId === secId);
- if (scMeta) {
- edges.push({ from: app.filePath, to: scMeta.filePath, type: 'hosts' });
- }
- }
- }
- // 4. App -> Component (uses)
- for (const app of meta.apps) {
- const usedComps = new Set();
- for (const page of app.pages) {
- for (const compRef of page.componentRefs) {
- usedComps.add(compRef.componentId);
- }
- }
- // Also check event bindings
- for (const binding of app.eventBindings) {
- if (binding.source.type === 'component') {
- usedComps.add(binding.source.id);
- }
- }
- for (const compId of usedComps) {
- const cpMeta = meta.components.find(cp => cp.componentId === compId);
- if (cpMeta) {
- edges.push({ from: app.filePath, to: cpMeta.filePath, type: 'uses' });
- }
- }
- }
- return { nodes, edges };
- }
- // ─── Process Editor Format Converter ──────────────────────────────
- function toProcessEditorFormat(meta) {
- // Build UIMap
- const uiMap = {
- apps: meta.apps.map(a => ({ appId: a.appId })),
- pages: [],
- sections: [],
- components: []
- };
- // Pages
- for (const app of meta.apps) {
- for (const page of app.pages) {
- uiMap.pages.push({
- pageId: page.pageId,
- route: page.route,
- rootSections: page.sectionRefs.map(s => s.sectionId),
- sectionLayout: {}
- });
- }
- }
- // Build section->component reverse map for reusedIn
- const compUsageMap = new Map(); // componentId -> [sectionId]
- for (const sc of meta.sections) {
- for (const ref of sc.componentRefs) {
- if (!compUsageMap.has(ref.componentId)) compUsageMap.set(ref.componentId, []);
- compUsageMap.get(ref.componentId).push(sc.sectionId);
- }
- }
- // Sections
- for (const sc of meta.sections) {
- uiMap.sections.push({
- sectionId: sc.sectionId,
- fileName: sc.fileName,
- servicesUsed: sc.servicesUsed.flatMap(d =>
- d.services.map(svc => ({ serviceId: svc.serviceId }))
- ),
- componentRefs: sc.componentRefs.map(c => c.componentId),
- publicEvents: sc.publicEvents,
- publicMethods: sc.publicMethods,
- keyStates: [...sc.globalVars, ...sc.derivedVars].map(v => ({
- name: v.name,
- type: v.type,
- initialValue: v.default
- }))
- });
- }
- // Components
- for (const cp of meta.components) {
- uiMap.components.push({
- componentId: cp.componentId,
- fileName: cp.fileName,
- props: cp.publicProps,
- events: cp.publicEvents,
- reusedIn: compUsageMap.get(cp.componentId) || []
- });
- }
- // Build ServiceMap
- const serviceMap = {
- serviceDomains: meta.serviceDomains.map(sd => {
- // Build service->vtable mapping
- // We can't perfectly map which services use which VTables without deep analysis
- // But we list all VTables under the domain
- return {
- domainId: sd.domainId,
- fileName: sd.fileName,
- description: '',
- services: sd.services.map(svc => ({
- serviceId: svc.serviceId,
- inputModel: Object.fromEntries(svc.params.map(p => [p.name, p.type])),
- outputModel: svc.returns && svc.returns.fields
- ? Object.fromEntries(svc.returns.fields.map(f => [f.name, f.type]))
- : {},
- virtualTablesUsed: sd.virtualTables.map(vt => vt.instanceId || vt.name),
- operations: []
- })),
- virtualTables: sd.virtualTables.map(vt => ({
- vtId: vt.instanceId || vt.name,
- sourceTable: vt.sourceTable,
- fields: vt.fields
- }))
- };
- })
- };
- // Build VDB text (for Process Editor compatibility)
- let vdbText = '';
- if (meta.database) {
- vdbText = `// VL_VERSION:${meta.project.vlVersion || '2.8'}\n`;
- vdbText += `<Database-${meta.database.name}>\n\n`;
- for (const table of meta.database.tables) {
- vdbText += `<Table-${table.name}>\n`;
- for (const field of table.fields) {
- let fieldLine = `-<Field-${field.name}> type:${field.type}`;
- if (field.notNull) fieldLine += ' notNull:true';
- if (field.default) fieldLine += ` default:${field.default}`;
- vdbText += fieldLine + '\n';
- }
- for (const idx of table.indexes) {
- vdbText += `-<Index-${idx.name}> type:${idx.type} fields:${JSON.stringify(idx.fields)}\n`;
- }
- vdbText += '\n';
- }
- for (const rel of meta.database.relations) {
- const cardMap = { oneToMany: '<<', manyToOne: '>>', oneToOne: '--' };
- vdbText += `<Relation-${rel.name}> ${rel.from.table}.${rel.from.field}${cardMap[rel.cardinality] || '<<'}${rel.to.table}.${rel.to.field}\n`;
- }
- vdbText += `\n</Database-${meta.database.name}>\n`;
- }
- return { uiMap, serviceMap, vdbText };
- }
- // ─── Public API ───────────────────────────────────────────────────
- return {
- extract,
- buildDependencyGraph,
- toProcessEditorFormat,
- // Expose individual parsers for advanced usage
- parsers: {
- parseVDB,
- parseVS,
- parseCP,
- parseSC,
- parseVX,
- parseVTH
- },
- // Expose utilities
- utils: {
- classifyFile,
- splitSections,
- extractVersion,
- extractPreview,
- extractRoot,
- parseParams,
- parseReturnFields,
- parseInlineProps
- }
- };
- })();
- // Support both ES Module and global usage
- if (typeof module !== 'undefined' && module.exports) {
- module.exports = VLMetadataExtractor;
- }
|