/** * Workspace Scanner * Scans workspace files for: * - i18n locale JSON files → extract translation keys * - pages/ directory → extract route paths * - store declarations in HTML → extract store property paths * - NoJS.directive() calls in JS → extract custom directive names */ import { Connection } from 'vscode-languageserver/node'; import { TextDocuments } from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { URI } from 'vscode-uri'; import * as fs from 'fs'; import * as path from 'path'; // ─── i18n Key Scanning ─── export interface I18nKeyInfo { key: string; value: string; locale: string; filePath: string; } /** * Recursively flatten a nested JSON object into dot-notation keys. * e.g., { nav: { home: "Home" } } → [{ key: "nav.home", value: "Home" }] */ function flattenKeys(obj: Record, prefix = ''): { key: string; value: string }[] { const results: { key: string; value: string }[] = []; for (const [k, v] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${k}` : k; if (typeof v === 'object' && v !== null && !Array.isArray(v)) { results.push(...flattenKeys(v, fullKey)); } else { results.push({ key: fullKey, value: String(v) }); } } return results; } /** * Scan workspace for locale JSON files and extract translation keys. * Looks for patterns like: * locales/{locale}.json (flat mode) * locales/{locale}/*.json (namespace mode) */ export function scanI18nKeys(workspaceRoot: string): I18nKeyInfo[] { const results: I18nKeyInfo[] = []; const localesDirs = findDirectories(workspaceRoot, 'locales'); for (const localesDir of localesDirs) { const entries = safeReaddir(localesDir); for (const entry of entries) { const fullPath = path.join(localesDir, entry); const stat = safeStat(fullPath); if (!stat) continue; if (stat.isFile() && entry.endsWith('.json')) { // Flat mode: locales/en.json const locale = entry.replace('.json', ''); const keys = parseLocaleFile(fullPath, locale); results.push(...keys); } else if (stat.isDirectory()) { // Namespace mode: locales/en/*.json const locale = entry; const nsFiles = safeReaddir(fullPath).filter(f => f.endsWith('.json')); for (const nsFile of nsFiles) { const nsPath = path.join(fullPath, nsFile); const ns = nsFile.replace('.json', ''); const keys = parseLocaleFile(nsPath, locale, ns); results.push(...keys); } } } } return results; } function parseLocaleFile(filePath: string, locale: string, namespace?: string): I18nKeyInfo[] { try { const content = fs.readFileSync(filePath, 'utf-8'); const json = JSON.parse(content); const flat = flattenKeys(json); return flat.map(({ key, value }) => ({ key: namespace ? `${namespace}.${key}` : key, value, locale, filePath, })); } catch { return []; } } // ─── Route Path Scanning ─── export interface RouteInfo { path: string; filePath: string; fileName: string; } /** * Scan a pages/ directory for file-based routes. * Convention: pages/about.html → /about, pages/index.html → / */ export function scanRoutes(workspaceRoot: string, pagesDir = 'pages', ext = '.html'): RouteInfo[] { const results: RouteInfo[] = []; const fullPagesDir = path.join(workspaceRoot, pagesDir); if (!safeExists(fullPagesDir)) { // Also check for common alternative extensions const altExts = ['.html', '.tpl', '.htm']; for (const altExt of altExts) { scanRoutesRecursive(fullPagesDir, fullPagesDir, altExt, results); } return results; } scanRoutesRecursive(fullPagesDir, fullPagesDir, ext, results); // Try other extensions too if (results.length === 0) { for (const altExt of ['.tpl', '.htm']) { if (altExt !== ext) { scanRoutesRecursive(fullPagesDir, fullPagesDir, altExt, results); } } } return results; } function scanRoutesRecursive(baseDir: string, dir: string, ext: string, results: RouteInfo[]): void { const entries = safeReaddir(dir); for (const entry of entries) { const fullPath = path.join(dir, entry); const stat = safeStat(fullPath); if (!stat) continue; if (stat.isFile() && entry.endsWith(ext)) { const relativePath = path.relative(baseDir, fullPath); const routeName = relativePath.replace(new RegExp(`\\${ext}$`), '').replace(/\\/g, '/'); let routePath: string; if (routeName === 'index') { routePath = '/'; } else if (routeName.endsWith('/index')) { routePath = '/' + routeName.slice(0, -6); } else { routePath = `/${routeName}`; } results.push({ path: routePath, filePath: fullPath, fileName: entry, }); } else if (stat.isDirectory() && !entry.startsWith('.')) { scanRoutesRecursive(baseDir, fullPath, ext, results); } } } // ─── Store Property Scanning ─── export interface StorePropertyInfo { storeName: string; properties: string[]; } /** * Parse store declarations from open documents to extract property paths. * Looks for store="name" value="{ prop1, prop2 }" patterns. */ export function scanStoreProperties(documents: TextDocuments): StorePropertyInfo[] { const stores: StorePropertyInfo[] = []; const seen = new Set(); for (const doc of documents.all()) { const text = doc.getText(); // Match store="name" + value/state attributes const storeRegex = /store="([^"]+)"[^>]*(?:value|state)="(\{[^"]*\})"/g; let match; while ((match = storeRegex.exec(text)) !== null) { const storeName = match[1]; if (seen.has(storeName)) continue; seen.add(storeName); const valueExpr = match[2]; const props = extractObjectKeys(valueExpr); stores.push({ storeName, properties: props }); } // Also match state="{ ... }" store="name" (reversed order) const reverseRegex = /(?:value|state)="(\{[^"]*\})"[^>]*store="([^"]+)"/g; while ((match = reverseRegex.exec(text)) !== null) { const storeName = match[2]; if (seen.has(storeName)) continue; seen.add(storeName); const valueExpr = match[1]; const props = extractObjectKeys(valueExpr); stores.push({ storeName, properties: props }); } // Match NoJS.config({ stores: { name: { ... }, ... } }) in