/** * 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: ...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 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: .@eventName(params) // Or: .@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: data:[...] const tableMatch = trimmed.match(/^(?:\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(/^\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(/^\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: T1.field<\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: sourceTable:X const vtMatch = stripped.match(/^\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(/^\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: , , 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; // const sdMatch = stripped.match(/^$/); if (sdMatch) { currentSD = { domainId: sdMatch[1], domainInstanceId: sdMatch[2], services: [] }; sdMap.set(sdMatch[1], currentSD); continue; } // params:(...) returns:(...) const svcMatch = stripped.match(/^\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(' ({ 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 ... 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 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 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; // path:"route" const pageMatch = stripped.match(/^\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; } // conditions:($routeVar == "/path") or conditions:($routeVar == "path") const ifMatch = stripped.match(/^\s*conditions?:\((\$\w+)\s*==\s*"([^"]+)"\)/); if (ifMatch) { routeVarName = routeVarName || ifMatch[1]; // record routing variable pendingIfRoute = ifMatch[2]; // e.g. "/campaigns" continue; } // immediately following an with route condition if (pendingIfRoute !== null) { const secInIfMatch = stripped.match(/^/); 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) { // ...layoutProps (direct section ref, no If) const secMatch = stripped.match(/^?\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; } // ...props const compMatch = stripped.match(/^?\s*(.*)$/); if (compMatch) { currentPage.componentRefs.push({ componentId: compMatch[1], instanceId: compMatch[2] }); } } } // Parse Event Handlers — detect nav section by pattern: // .@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: .@eventName(params) const m = line.match(/^\s*\.@(\w+)\(([^)]*)\)$/); if (m) { // Look at the handler body lines for "$routeVar = " 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; // 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: 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(/^\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; // .#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 += `\n\n`; for (const table of meta.database.tables) { vdbText += `\n`; for (const field of table.fields) { let fieldLine = `- 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 += `- type:${idx.type} fields:${JSON.stringify(idx.fields)}\n`; } vdbText += '\n'; } for (const rel of meta.database.relations) { const cardMap = { oneToMany: '<<', manyToOne: '>>', oneToOne: '--' }; vdbText += ` ${rel.from.table}.${rel.from.field}${cardMap[rel.cardinality] || '<<'}${rel.to.table}.${rel.to.field}\n`; } vdbText += `\n\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; }