From 7573ea766458f355f2d7a1b410a95a32c26c7d9f Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Mon, 7 Sep 2026 01:10:16 -0600 Subject: [PATCH] feat: discover registered Analog file pages --- CHANGELOG.md | 2 + __tests__/analog-routes.test.ts | 208 +++++++++++++++ .../PLAN-application-router-coverage.md | 4 +- docs/design/framework-coverage.md | 3 + .../content/docs/guides/framework-routes.md | 3 + src/extraction/index.ts | 33 ++- src/resolution/frameworks/analog.ts | 251 ++++++++++++++++++ src/resolution/frameworks/index.ts | 2 + 8 files changed, 496 insertions(+), 10 deletions(-) create mode 100644 __tests__/analog-routes.test.ts create mode 100644 src/resolution/frameworks/analog.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7402fd4f6..47f3e8d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- Analog default file pages now link to their component classes, preserving its directory layouts, dotted paths and dynamic segments when the platform plugin and file router are registered. + - Angular Router registered route arrays now link to exact component classes, including nested children and static lazy imports; imported route edits refresh their registrations during sync. - RedwoodSDK registered routes now link to their page or API handlers, preserving prefixes and method tables while excluding middleware from page roots. diff --git a/__tests__/analog-routes.test.ts b/__tests__/analog-routes.test.ts new file mode 100644 index 000000000..040e68f40 --- /dev/null +++ b/__tests__/analog-routes.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { CodeGraph } from '../src'; +import { routeRoots } from '../src/ui-server/api/route-roots'; + +describe('Analog default page routes', () => { + let cg: CodeGraph | undefined; + let dir: string; + const write = (file: string, source: string) => { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), source); + }; + const page = (file: string, name: string) => + write( + 'src/app/pages/' + file + '.page.ts', + `import {Component} from '@angular/core'; @Component({template:'page'}) export default class ${name} {}`, + ); + const setup = () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-analog-')); + write( + 'package.json', + JSON.stringify({ + dependencies: { '@analogjs/router': '2.7.1', '@analogjs/platform': '2.7.1' }, + }), + ); + write( + 'vite.config.ts', + `import {defineConfig} from 'vite'; import analog from '@analogjs/platform'; export default defineConfig({plugins:[analog()]});`, + ); + write( + 'src/app/app.config.ts', + `import {provideFileRouter} from '@analogjs/router'; export const appConfig = {providers:[provideFileRouter()]};`, + ); + }; + afterEach(() => { + cg?.close(); + cg = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + // analogjs/analog@0896a7eaaa2acf26443ca184bc1dd9aa1a06f4d6, + // apps/analog-app/src/app/pages/products.[productId].page.ts and (auth).page.ts. + it('composes pinned filename semantics and links exact page classes', async () => { + setup(); + for (const [file, name] of [ + ['(home)', 'Home'], + ['products', 'Products'], + ['products.[productId]', 'ProductDetailsComponent'], + ['(auth)', 'AuthLayoutPageComponent'], + ['(auth)/login', 'Login'], + ['admin', 'AdminLayout'], + ['admin/index', 'AdminIndex'], + ['admin/users.[id]', 'User'], + ['[...slug]', 'CatchAll'], + ['_private', 'Private'], + ]) + page(file!, name!); + write( + 'src/app/pages/(auth)/sign-up.page.ts', + "import { Component } from '@angular/core';\n\n@Component({\n template: `

SignUp

`,\n})\nexport default class SignupPageComponent {}\n", + ); + write('src/other.ts', 'export class ProductDetailsComponent {}'); + cg = await CodeGraph.init(dir, { index: true }); + const routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name).sort()).toEqual([ + '/', + '/*', + '/_private', + '/admin', + '/admin/users/:id', + '/login', + '/products', + '/products/:productId', + '/sign-up', + ]); + const root = routeRoots(cg, routes).get( + routes.find((n) => n.name === '/products/:productId')!.id, + )!.node; + expect([root.name, root.filePath]).toEqual([ + 'ProductDetailsComponent', + 'src/app/pages/products.[productId].page.ts', + ]); + expect( + routeRoots(cg, routes).get(routes.find((n) => n.name === '/sign-up')!.id)!.node.name, + ).toBe('SignupPageComponent'); + }); + it('refreshes layouts and registration changes after reopening', async () => { + setup(); + page('parent', 'Parent'); + cg = await CodeGraph.init(dir, { index: true }); + expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/parent']); + cg.close(); + cg = await CodeGraph.open(dir); + page('parent/child', 'Child'); + await cg.sync({ paths: ['src/app/pages/parent/child.page.ts'] }); + expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/parent/child']); + fs.unlinkSync(path.join(dir, 'src/app/pages/parent/child.page.ts')); + await cg.sync(); + expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/parent']); + write('src/app/app.config.ts', 'export const appConfig = {providers:[]};'); + await cg.sync(); + expect(cg.getNodesByKind('route')).toEqual([]); + write( + 'src/app/app.config.ts', + `import {provideFileRouter as files} from '@analogjs/router'; export const providers=[files()];`, + ); + await cg.sync(); + expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/parent']); + write( + 'vite.config.ts', + `import analog from '@analogjs/platform'; export default {root:'custom',plugins:[analog()]};`, + ); + await cg.sync(); + expect(cg.getNodesByKind('route')).toEqual([]); + }); + it.each([ + [ + 'vite.config.ts', + `import analog from '@analogjs/platform'; export default {plugins:[analog()],plugins:[]};`, + ], + [ + 'src/app/app.config.ts', + `import {provideFileRouter} from '@analogjs/router'; for (const provideFileRouter of [()=>0]) provideFileRouter();`, + ], + ['vite.config.ts', `import analog from 'other'; export default {plugins:[analog()]};`], + [ + 'vite.config.ts', + `import analog from '@analogjs/platform'; export default {plugins:[analog({additionalPagesDirs:['custom']})]};`, + ], + [ + 'vite.config.ts', + `import analog from '@analogjs/platform'; export default makeConfig(analog());`, + ], + [ + 'src/app/app.config.ts', + `import {provideFileRouter} from 'other'; export const providers=[provideFileRouter()];`, + ], + [ + 'src/app/app.config.ts', + `import type {provideFileRouter} from '@analogjs/router'; export const providers=[provideFileRouter()];`, + ], + [ + 'src/app/app.config.ts', + `import {provideFileRouter} from '@analogjs/router'; function wrapper(provideFileRouter){return provideFileRouter()}`, + ], + [ + 'src/app/app.config.ts', + `import {provideFileRouter} from '@analogjs/router'; false && provideFileRouter();`, + ], + [ + 'src/app/app.config.ts', + `import {provideFileRouter,withExtraRoutes} from '@analogjs/router'; export const providers=[provideFileRouter(withExtraRoutes([{path:'other'}]))];`, + ], + ])('ignores unsupported activation: %s %s', async (file, source) => { + setup(); + page('index', 'Home'); + write(file!, source!); + cg = await CodeGraph.init(dir, { index: true }); + expect(cg.getNodesByKind('route')).toEqual([]); + }); + it('excludes metadata, optional catchalls, nondefault and anonymous pages', async () => { + setup(); + page('[[...slug]]', 'Optional'); + write( + 'src/app/pages/meta.page.ts', + `export const routeMeta={redirectTo:'/'}; export default class Meta {}`, + ); + write('src/app/pages/named.page.ts', `export class Named {}`); + write('src/app/pages/anonymous.page.ts', `export default class {}`); + cg = await CodeGraph.init(dir, { index: true }); + expect(cg.getNodesByKind('route')).toEqual([]); + }); + it.each([false, true])('discovers newly introduced Analog (scoped=%s)', async (scoped) => { + setup(); + write('package.json', '{}'); + page('index', 'Home'); + cg = await CodeGraph.init(dir, { index: true }); + expect(cg.getNodesByKind('route')).toEqual([]); + write('package.json', JSON.stringify({ dependencies: { '@analogjs/router': '2.0.0' } })); + write( + 'src/app/app.config.ts', + `import {provideFileRouter as routes} from '@analogjs/router'; export const providers=[routes()];`, + ); + await cg.sync(scoped ? { paths: ['src/app/app.config.ts'] } : undefined); + expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/']); + }); + it.runIf(fs.existsSync(path.resolve('dist/index.js')))( + 'uses fresh compiled parse and store workers', + () => { + setup(); + page('index', 'Home'); + const script = `const {CodeGraph}=require(${JSON.stringify(path.resolve('dist/index.js'))});(async()=>{const cg=await CodeGraph.init(${JSON.stringify(dir)},{index:true});const r=cg.getNodesByKind('route')[0];console.log(JSON.stringify([r.name,cg.getOutgoingEdges(r.id).filter(e=>e.kind==='references').map(e=>cg.getNode(e.target)?.name)]));cg.close();})().catch(e=>{console.error(e);process.exit(1)});`; + const output = execFileSync(process.execPath, ['-e', script], { + encoding: 'utf8', + timeout: 60000, + env: { + ...process.env, + CODEGRAPH_PARSE_WORKERS: '2', + CODEGRAPH_PARALLEL_RESOLVE_MIN: '1', + CODEGRAPH_RESOLVE_WORKERS: '2', + }, + }); + expect(JSON.parse(output.trim().split('\n').at(-1)!)).toEqual(['/', ['Home']]); + }, + ); +}); diff --git a/docs/design/PLAN-application-router-coverage.md b/docs/design/PLAN-application-router-coverage.md index 7ba85eb2a..53aa61e47 100644 --- a/docs/design/PLAN-application-router-coverage.md +++ b/docs/design/PLAN-application-router-coverage.md @@ -1,4 +1,4 @@ -Status: 6/13 — Angular Router verified; publishing step 6 before Analog +Status: 7/13 — Analog verified; publishing step 7 before Solid Router - [x] 1 React Router framework mode — seven official-fixture pages bind exact components; nested index/navigation, config/module sync and fresh compiled workers verified; build passes, 112 WASM focused/control tests pass, full native suite 4,353 pass / 46 skip; independent review clear. - [x] 2 TanStack Start server routes — literal method tables and `createHandlers` bind handlers/calls; page/API coexistence and full/scoped sync verified; build passes, 62 WASM focused/control tests pass, full native suite 4,375 pass / 46 skip; independent review clear. @@ -6,7 +6,7 @@ Status: 6/13 — Angular Router verified; publishing step 6 before Analog - [x] 4 Astro route completion — exact page components, declared method handlers and local source navigation; false-anchor/type/export controls and full/scoped/new-framework sync pass; build passes, 195 WASM tests pass, full native suite 4,435 pass / 46 skip; independent review clear. - [x] 5 RedwoodSDK — registered literal trees, prefixes and method tables bind exact handlers; JSX evidence classifies pages, interrupters/ambiguous declarations excluded; handler edit/delete and new-framework sync pass; build passes, 91 WASM tests pass, full native suite 4,459 pass / 46 skip; independent review clear. - [x] 6 Angular Router — registered arrays, nested children and static lazy imports bind exact classes; imported table add/edit/delete after reopening and fresh workers pass; build passes, 117 WASM focused/control tests pass, full native suite 4,484 pass / 46 skip; independent review clear. -- [ ] 7 Analog — default file routes and page components using Analog-specific index, dot, parameter, and layout conventions. Gate: shared proof and fixtures that distinguish parent layouts from matching pages. +- [x] 7 Analog — registered default pages bind exact classes with directory-based layouts and dot/parameter conventions; config/file and reopened/scoped sync plus fresh workers pass; build passes, 133 WASM focused/control tests pass, full native suite 4,500 pass / 46 skip; independent review clear. - [ ] 8 Solid Router — imported JSX/config declarations, nested path composition, and router base. Gate: shared proof, nested matching semantics, component links, and unrelated JSX negatives. - [ ] 9 SolidStart — pinned-version file routes, default page exports, and HTTP-method exports. Gate: shared proof, page/API coexistence, layouts, and dynamic parameters. - [ ] 10 Qwik City — default file routes, page components, and method-specific endpoint exports. Gate: shared proof, parameters, layouts, and `onRequest`/middleware exclusions. diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 9f2d0d8b6..aa38d2c5e 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -45,6 +45,9 @@ guessed. | Astro | `frameworks/astro.ts` | — | `astro-routes.test.ts` | pinned endpoint fixture; exact page components and source navigation | | RedwoodSDK | `frameworks/redwood.ts` | — | `redwood-routes.test.ts` | pinned 1.7.3 typed-routes worker; exact page and API roots | | Angular Router | `frameworks/angular.ts` | — | `angular-routes.test.ts` | pinned official tutorial registration; exact class roots and imported-array sync | +| Analog | `frameworks/analog.ts` | — | `analog-routes.test.ts` | pinned 2.7.1 sign-up page; filename/layout and registration sync controls | + +Analog recognizes `src/app/pages/**/*.page.ts` named default classes when a root Vite config registers the platform plugin and source registers option-free `provideFileRouter()`. Directory hierarchy determines layouts before dots become URL separators; index/pathless names, parameters and catchalls follow the pinned conventions. [Official page fixture](https://github.com/analogjs/analog/blob/0896a7eaaa2acf26443ca184bc1dd9aa1a06f4d6/apps/analog-app/src/app/pages/%28auth%29/sign-up.page.ts), [route construction](https://github.com/analogjs/analog/blob/0896a7eaaa2acf26443ca184bc1dd9aa1a06f4d6/packages/router/src/lib/routes.ts). Fresh workers and config/file add/edit/delete refresh existing pages, including after reopening. Custom roots, extra route directories, `app/routes`, metadata overrides, router options, optional catchalls, Markdown, anonymous defaults and re-exports remain unsupported. No navigation is inferred. Angular reads registered `provideRouter` / `RouterModule.forRoot` literal or constant arrays, nested children and relative imports. Static lazy imports can select component classes, route arrays or NgModules registering `forChild`. [Pinned tutorial](https://github.com/angular/angular/blob/9a58353b1b680f162a55969965ae6a90ae20316d/adev/src/content/tutorials/learn-angular/steps/14-routerLink/answer/src/app/app.routes.ts), [loading semantics](https://angular.dev/guide/routing/loading-strategies). Parent-side extraction enriches both ordinary and fresh worker storage without depending on database insertion order. Routes belong to their registration file; sync refreshes registrations after source changes, including after reopening. Redirects, named outlets, custom matchers, conditional registrations, dynamic loaders, path aliases, re-export modules and spread objects are excluded. No navigation is inferred. diff --git a/site/src/content/docs/guides/framework-routes.md b/site/src/content/docs/guides/framework-routes.md index 03e6d613c..cf6070ab9 100644 --- a/site/src/content/docs/guides/framework-routes.md +++ b/site/src/content/docs/guides/framework-routes.md @@ -38,9 +38,12 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **Astro** | `src/pages/` `.astro` pages linked to components; `.ts`/`.js` HTTP-method exports linked to handlers; anchors and `Astro.redirect` link to local pages | | **RedwoodSDK** | Registered `defineApp` trees with `route`, `index`, `render`, `layout`, `prefix` and standard method tables; exact handlers and JSX page classification | | **Angular Router** | `provideRouter` / `RouterModule.forRoot` arrays, nested children, relative component imports and static lazy components/route arrays/NgModules | +| **Analog** | Registered default `src/app/pages/**/*.page.ts` pages, linked to named default classes; directory layouts, dot paths, index/pathless segments and parameters | Route resolution is automatic — there's nothing to configure. If a framework file is recognized, its routes appear in the graph after the next index or sync. +Analog requires the platform plugin in a default-root Vite config and option-free `provideFileRouter()` registration. Custom roots, extra route directories, `app/routes`, route metadata overrides, router options, optional catchalls, Markdown and anonymous/re-exported defaults remain unsupported. A directory makes its corresponding page a layout; a dotted filename alone does not. + Angular follows literal or constant route arrays from runtime router imports. `forChild` contributes routes only through a statically imported lazy NgModule. Route nodes belong to the registration file, and imported table changes refresh that owner. Redirects, named outlets, custom matchers, dynamic factories, conditional registrations, spread objects, path aliases and re-export modules remain unsupported. No navigation is inferred. RedwoodSDK handler arrays use the final handler as the route root. A route stays `ANY /path` until its handler is shown to return JSX. Cross-file route arrays, custom methods, mutations, dynamic paths, ambiguous method tables and wrapped/anonymous exported components remain unsupported. diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 3b2e27cf1..03da9ea38 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -34,6 +34,7 @@ import { validatePathWithinRoot, normalizePath } from '../utils'; import ignore, { Ignore } from 'ignore'; import { detectFrameworks } from '../resolution/frameworks'; import { extractAngularRoutes, isAngularRegistrationFile } from '../resolution/frameworks/angular'; +import { extractAnalogRoutes, isAnalogPage } from '../resolution/frameworks/analog'; import type { ResolutionContext } from '../resolution/types'; import { createYielder, type MaybeYield } from '../resolution/cooperative-yield'; @@ -1449,6 +1450,7 @@ export class ExtractionOrchestrator { * hasn't run yet so single-file re-index paths can detect on the spot. */ private detectedFrameworkNames: string[] | null = null; + private frameworkSourceContext: ResolutionContext | null = null; private conventionInvalidatedFiles = new Set(); /** * Scope matcher for SCOPED syncs, memoized on the mtimes of the two root @@ -1558,6 +1560,7 @@ export class ExtractionOrchestrator { const fileList = files ?? scanDirectory(this.rootDir); const context = this.buildDetectionContext(fileList); this.detectedFrameworkNames = detectFrameworks(context).map((r) => r.name); + this.frameworkSourceContext = this.buildDetectionContext([...new Set([...this.queries.getAllFilePaths(), ...fileList])]); return this.detectedFrameworkNames; } @@ -1770,7 +1773,7 @@ export class ExtractionOrchestrator { const commitYield = createYielder(); const storeResult = async (filePath: string, content: string, stats: fs.Stats, result: ExtractionResult): Promise => { - result = await this.enrichAngularRoutes(filePath, content, result); + result = await this.enrichFrameworkRoutes(filePath, content, result); processed++; // WAL hard-cap backstop: between files (never mid-transaction), pause @@ -2367,13 +2370,17 @@ export class ExtractionOrchestrator { } } - private async enrichAngularRoutes(filePath: string, content: string, result: ExtractionResult): Promise { - if (!this.ensureDetectedFrameworks().includes('angular') || !isAngularRegistrationFile(content)) return result; + private async enrichFrameworkRoutes(filePath: string, content: string, result: ExtractionResult): Promise { + const frameworks = this.ensureDetectedFrameworks(); + const angular = frameworks.includes('angular') && isAngularRegistrationFile(content); + const analog = frameworks.includes('analog') && isAnalogPage(filePath); + if (!angular && !analog) return result; await loadGrammarsForLanguages(['typescript', 'javascript']); result = materializeKernelResult(result, filePath, detectLanguage(filePath)!); - const angular = extractAngularRoutes(filePath, content, this.buildDetectionContext([])); - result.nodes.push(...angular.nodes); - result.unresolvedReferences.push(...angular.references); + const context = this.frameworkSourceContext!; + const extracted = angular ? extractAngularRoutes(filePath, content, context) : extractAnalogRoutes(filePath, content, context); + result.nodes.push(...extracted.nodes); + result.unresolvedReferences.push(...extracted.references); return result; } @@ -2384,14 +2391,14 @@ export class ExtractionOrchestrator { stats: fs.Stats, result: ExtractionResult, onYield?: MaybeYield, - angularEnriched = false + frameworkEnriched = false ): Promise { // A kernel result can arrive as an undecoded buffer transport (empty // node/edge arrays, tables riding in kernelBuffers). Decode it before // storing — persisting the transport as-is records the file as having no // symbols at all (#1541). No-op for already-decoded results. result = materializeKernelResult(result, filePath, language); - if (!angularEnriched) result = await this.enrichAngularRoutes(filePath, content, result); + if (!frameworkEnriched) result = await this.enrichFrameworkRoutes(filePath, content, result); // Bulk inserts run in bounded sub-transactions with a yield between, so a // giant generated file (tens of thousands of symbols) can't block the @@ -2886,6 +2893,16 @@ export class ExtractionOrchestrator { : previous.includes('react-router-files'); this.detectedFrameworkNames = null; const detected = this.ensureDetectedFrameworks(currentFiles); + if (detected.includes('analog') || this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:analog:'))) { + const scope = this.scopedSyncMatcher(); + for (const filePath of new Set([...this.queries.getAllFilePaths(), ...currentFiles])) { + if (!isAnalogPage(filePath) || filesToIndex.includes(filePath) || scope.ignores(filePath) || !fs.existsSync(path.join(this.rootDir, filePath))) continue; + filesToIndex.push(filePath); + this.conventionInvalidatedFiles.add(filePath); + changedFilePaths.push(filePath); + filesModified++; + } + } if (detected.includes('angular') || this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:angular:'))) { const scope = this.scopedSyncMatcher(); for (const filePath of new Set([...this.queries.getAllFilePaths(), ...currentFiles])) { diff --git a/src/resolution/frameworks/analog.ts b/src/resolution/frameworks/analog.ts new file mode 100644 index 000000000..0611a7074 --- /dev/null +++ b/src/resolution/frameworks/analog.ts @@ -0,0 +1,251 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import type { FrameworkResolver, FrameworkExtractionResult, ResolutionContext } from '../types'; +import { detectLanguage, getParser } from '../../extraction/grammars'; +import { dependsOn } from './package-deps'; + +const ROOT = 'src/app/pages/'; +export const isAnalogPage = (file: string): boolean => + file.startsWith(ROOT) && file.endsWith('.page.ts'); +const literal = (node: SyntaxNode | null | undefined): string | null => + node?.type === 'string' && !node.text.includes('\\') ? node.text.slice(1, -1) : null; +const BODIES = new Set([ + 'function_declaration', + 'function_expression', + 'arrow_function', + 'method_definition', + 'statement_block', + 'binary_expression', + 'ternary_expression', + 'if_statement', + 'for_statement', + 'for_in_statement', + 'while_statement', + 'do_statement', + 'switch_statement', +]); +const states = new WeakMap(); + +function importedNames(root: SyntaxNode, source: string, name: string): Set { + const names = new Set(); + for (const statement of root.namedChildren) { + if ( + statement.type !== 'import_statement' || + statement.children.some((n) => n.type === 'type') || + literal(statement.childForFieldName('source')) !== source + ) + continue; + const clause = statement.namedChildren.find((n) => n.type === 'import_clause'); + if (name === 'default') + for (const child of clause?.namedChildren ?? []) + if (child.type === 'identifier') names.add(child.text); + for (const spec of statement.descendantsOfType('import_specifier')) + if ( + !spec.children.some((n) => n.type === 'type') && + spec.childForFieldName('name')?.text === name + ) + names.add(spec.childForFieldName('alias')?.text ?? name); + } + return names; +} + +/** Checks registration and default roots once per extraction batch. */ +function project(context: ResolutionContext): { active: boolean; files: string[] } { + const cached = states.get(context); + if (cached) return cached; + const all = context.getAllFiles().filter((file) => context.fileExists(file)); + const state = { active: false, files: all.filter(isAnalogPage) }; + states.set(context, state); + let plugin = false; + for (const extension of ['ts', 'js', 'mts', 'mjs']) { + const file = `vite.config.${extension}`; + const content = context.readFile(file); + if (content === null) continue; + const tree = getParser(detectLanguage(file)!)?.parse(content); + if (!tree) return state; + try { + const names = importedNames(tree.rootNode, '@analogjs/platform', 'default'); + const exported = tree.rootNode.namedChildren.find( + (n) => n.type === 'export_statement' && n.children.some((c) => c.type === 'default'), + ); + let config = exported?.childForFieldName('value'); + if (config?.type === 'call_expression') { + const defineConfig = importedNames(tree.rootNode, 'vite', 'defineConfig'); + if (!defineConfig.has(config.childForFieldName('function')?.text ?? '')) return state; + config = config.childForFieldName('arguments')?.namedChildren[0]; + } + if (config?.type !== 'object') return state; + const keys = config.namedChildren + .filter((n) => n.type !== 'comment') + .map((n) => + n.type === 'pair' + ? (literal(n.childForFieldName('key')) ?? n.childForFieldName('key')?.text) + : null, + ); + if ( + keys.some((key) => !key) || + new Set(keys).size !== keys.length || + config.namedChildren.some( + (n) => n.childForFieldName('key')?.type === 'computed_property_name', + ) + ) + return state; + if ( + config.descendantsOfType('spread_element').length || + config + .descendantsOfType('pair') + .some((n) => + ['root', 'workspaceRoot', 'additionalPagesDirs', 'routes'].includes( + n.childForFieldName('key')?.text.replace(/^['"]|['"]$/g, '') ?? '', + ), + ) + ) + return state; + const plugins = config.namedChildren + .find((n) => n.childForFieldName('key')?.text === 'plugins') + ?.childForFieldName('value'); + if (plugins?.type !== 'array') return state; + plugin = plugins.namedChildren.some( + (n) => + n.type === 'call_expression' && + names.has(n.childForFieldName('function')?.text ?? '') && + (n.childForFieldName('arguments')?.namedChildren.length === 0 || + (n.childForFieldName('arguments')?.namedChildren.length === 1 && + n.childForFieldName('arguments')?.namedChildren[0]?.type === 'object')), + ); + } finally { + tree.delete(); + } + } + if (!plugin) return state; + for (const file of all) { + if (!/\.[jt]s$/.test(file) || isAnalogPage(file)) continue; + const content = context.readFile(file); + if (!content?.includes('provideFileRouter')) continue; + const tree = getParser(detectLanguage(file)!)?.parse(content); + if (!tree) continue; + try { + const names = importedNames(tree.rootNode, '@analogjs/router', 'provideFileRouter'); + const visit = (node: SyntaxNode): boolean => { + if (BODIES.has(node.type)) return false; + if ( + node.type === 'call_expression' && + names.has(node.childForFieldName('function')?.text ?? '') + ) + return node.childForFieldName('arguments')?.namedChildren.length === 0; + return node.namedChildren.some(visit); + }; + if (visit(tree.rootNode)) state.active = true; + } finally { + tree.delete(); + } + } + return state; +} + +export function extractAnalogRoutes( + filePath: string, + content: string, + context: ResolutionContext, +): FrameworkExtractionResult { + const result: FrameworkExtractionResult = { nodes: [], references: [] }; + if (!isAnalogPage(filePath)) return result; + const state = project(context); + if (!state.active) return result; + const raw = filePath.slice(ROOT.length, -'.page.ts'.length); + // Hierarchy is computed from directories before dots become URL separators. + if (state.files.some((file) => file.startsWith(ROOT + raw + '/'))) return result; + if (raw.includes('[[...')) return result; + const routePath = + '/' + + raw + .split('/') + .map((segment) => + segment + .replace(/\[\.\.\.([^\]]+)\]/g, '*') + .replace(/\[([^\]]+)\]/g, ':$1') + .replace(/index|\(.*?\)/g, '') + .replace(/\./g, '/'), + ) + .join('/') + .split('/') + .filter(Boolean) + .join('/'); + const tree = getParser('typescript')?.parse(content); + if (!tree) return result; + try { + const exports = tree.rootNode.namedChildren.filter((n) => n.type === 'export_statement'); + if ( + exports.some((n) => + n + .descendantsOfType(['variable_declarator', 'export_specifier']) + .some( + (item) => + item.childForFieldName('name')?.text === 'routeMeta' || + item.childForFieldName('alias')?.text === 'routeMeta', + ), + ) + ) + return result; + const exported = exports.find((n) => n.children.some((c) => c.type === 'default')); + let declaration = exported?.childForFieldName('declaration'); + const value = exported?.childForFieldName('value'); + if (!declaration && value?.type === 'identifier') + declaration = + tree.rootNode.namedChildren.find( + (n) => n.type === 'class_declaration' && n.childForFieldName('name')?.text === value.text, + ) ?? null; + if (declaration?.type !== 'class_declaration') return result; + const name = declaration.childForFieldName('name')?.text; + if (!name) return result; + const id = `route:analog:${filePath}`; + result.nodes.push({ + id, + kind: 'route', + name: routePath, + qualifiedName: `${filePath}::${routePath}`, + filePath, + language: 'typescript', + startLine: declaration.startPosition.row + 1, + endLine: declaration.endPosition.row + 1, + startColumn: declaration.startPosition.column, + endColumn: declaration.endPosition.column, + updatedAt: Date.now(), + }); + result.references.push({ + fromNodeId: id, + referenceName: 'analog-component:' + name, + referenceKind: 'references', + filePath, + language: 'typescript', + line: declaration.startPosition.row + 1, + column: declaration.startPosition.column, + }); + return result; + } finally { + tree.delete(); + } +} + +export const analogResolver: FrameworkResolver = { + name: 'analog', + languages: ['typescript'], + detect: (context) => dependsOn(context, '@analogjs/router'), + claimsReference: (name) => name.startsWith('analog-component:'), + resolve(ref, context) { + if ( + !ref.fromNodeId.startsWith('route:analog:') || + !ref.referenceName.startsWith('analog-component:') + ) + return null; + const candidates = context + .getNodesInFile(ref.filePath) + .filter( + (n) => + n.name === ref.referenceName.slice('analog-component:'.length) && + ['class', 'component'].includes(n.kind), + ); + return candidates.length === 1 + ? { original: ref, targetNodeId: candidates[0]!.id, confidence: 1, resolvedBy: 'framework' } + : null; + }, +}; diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 26323270c..e788eaf34 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -22,6 +22,7 @@ import { vueResolver } from './vue'; import { astroResolver } from './astro'; import { redwoodResolver } from './redwood'; import { angularResolver } from './angular'; +import { analogResolver } from './analog'; import { djangoResolver, flaskResolver, fastapiResolver } from './python'; import { railsResolver } from './ruby'; import { springResolver } from './java'; @@ -67,6 +68,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ astroResolver, redwoodResolver, angularResolver, + analogResolver, // Python djangoResolver, flaskResolver,