mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
72a9346b12
- `app/blog/og-image/[post]+api.ts` was a screen named `/blog/og-image/[post]+api` (evanbacon.dev). An Expo API route is now one endpoint per exported HTTP method (`GET /blog/og-image/:post`), bound to its handler, the way Next.js `route.ts` files are. - The per-app framework gate (#2129) took ANY enclosing manifest's declaration: react-native-true-sheet's root declares expo-router for its example app, so its `docs/` Next.js app got 16 Expo screens (`/layout`, `/page`, `/api/search/route`, …). Walking up from a file, the first manifest that names any gated framework now decides. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
5.3 KiB
TypeScript
129 lines
5.3 KiB
TypeScript
/**
|
|
* In a monorepo, a file-based router reads only its own app's files.
|
|
*
|
|
* create-t3-turbo, tamagui's starter and react-native-reusables keep an Expo
|
|
* app beside a Next.js app. Expo Router read the Next app's `app/` folder as
|
|
* its own: `app/layout.tsx` became a `/layout` screen, `app/page.tsx` a
|
|
* `/page` one, a `_components/posts.tsx` a `/_components/posts` screen, and
|
|
* `app/api/auth/[...all]/route.ts` a screen named after the file.
|
|
*/
|
|
import { describe, it, expect, afterAll } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { CodeGraph } from '../src';
|
|
|
|
const roots: string[] = [];
|
|
afterAll(() => {
|
|
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
|
|
});
|
|
|
|
async function project(files: Record<string, string>): Promise<CodeGraph> {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-app-frameworks-'));
|
|
roots.push(root);
|
|
for (const [rel, content] of Object.entries(files)) {
|
|
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(root, rel), content);
|
|
}
|
|
return CodeGraph.init(root, { index: true });
|
|
}
|
|
|
|
const routesIn = (cg: CodeGraph, dir: string): string[] =>
|
|
cg
|
|
.getNodesByKind('route')
|
|
.filter((n) => n.filePath.startsWith(dir))
|
|
.map((n) => n.name)
|
|
.sort();
|
|
|
|
describe('file-based routers in a monorepo', () => {
|
|
it('Expo Router reads the Expo app, Next.js the Next app', async () => {
|
|
const cg = await project({
|
|
'package.json': JSON.stringify({ name: 'mono', private: true, workspaces: ['apps/*'] }),
|
|
'apps/mobile/package.json': JSON.stringify({ name: 'mobile', dependencies: { expo: '*', 'expo-router': '*', react: '*' } }),
|
|
'apps/mobile/app/_layout.tsx': `export default function Layout() { return null; }
|
|
`,
|
|
'apps/mobile/app/index.tsx': `export default function Home() { return null; }
|
|
`,
|
|
'apps/mobile/app/user/[id].tsx': `export default function User() { return null; }
|
|
`,
|
|
'apps/web/package.json': JSON.stringify({ name: 'web', dependencies: { next: '*', react: '*' } }),
|
|
'apps/web/app/layout.tsx': `export default function RootLayout({ children }: { children: unknown }) { return children; }
|
|
`,
|
|
'apps/web/app/page.tsx': `export default function Page() { return null; }
|
|
`,
|
|
'apps/web/app/user/[id]/page.tsx': `export default function UserPage() { return null; }
|
|
`,
|
|
'apps/web/app/_components/posts.tsx': `export default function Posts() { return null; }
|
|
`,
|
|
'apps/web/app/api/auth/[...all]/route.ts': `export async function GET() { return new Response('ok'); }
|
|
`,
|
|
});
|
|
try {
|
|
expect(routesIn(cg, 'apps/mobile/')).toEqual(['/', '/user/[id]']);
|
|
const web = routesIn(cg, 'apps/web/');
|
|
expect(web).toContain('/');
|
|
expect(web).toContain('/user/:id');
|
|
for (const bogus of ['/layout', '/page', '/_components/posts', '/api/auth/[...all]/route', '/user/[id]/page']) {
|
|
expect(web).not.toContain(bogus);
|
|
}
|
|
} finally {
|
|
cg.close();
|
|
}
|
|
});
|
|
|
|
it('a single Expo app at the repository root keeps its routes', async () => {
|
|
const cg = await project({
|
|
'package.json': JSON.stringify({ name: 'demo', dependencies: { expo: '*', 'expo-router': '*' } }),
|
|
'app/_layout.tsx': `export default function Layout() { return null; }
|
|
`,
|
|
'app/settings.tsx': `export default function Settings() { return null; }
|
|
`,
|
|
});
|
|
try {
|
|
expect(routesIn(cg, 'app/')).toEqual(['/settings']);
|
|
} finally {
|
|
cg.close();
|
|
}
|
|
});
|
|
|
|
it('the nearest manifest that names a router decides: a Next docs app under an Expo root', async () => {
|
|
const cg = await project({
|
|
'package.json': JSON.stringify({ name: 'true-sheet', devDependencies: { expo: '*', 'expo-router': '*', react: '*' } }),
|
|
'docs/package.json': JSON.stringify({ name: 'docs', dependencies: { next: '*', react: '*' } }),
|
|
'docs/app/layout.tsx': `export default function RootLayout({ children }: { children: unknown }) { return children; }
|
|
`,
|
|
'docs/app/(docs)/[...slug]/page.tsx': `export default function DocPage() { return null; }
|
|
`,
|
|
'example/app/_layout.tsx': `export default function Layout() { return null; }
|
|
`,
|
|
'example/app/sheet.tsx': `export default function Sheet() { return null; }
|
|
`,
|
|
});
|
|
try {
|
|
expect(routesIn(cg, 'docs/')).toEqual(['/:slug*']);
|
|
expect(routesIn(cg, 'example/')).toEqual(['/sheet']);
|
|
} finally {
|
|
cg.close();
|
|
}
|
|
});
|
|
|
|
it('an Expo API route is an endpoint per exported method, not a screen', async () => {
|
|
const cg = await project({
|
|
'package.json': JSON.stringify({ name: 'site', dependencies: { expo: '*', 'expo-router': '*' } }),
|
|
'app/index.tsx': `export default function Home() { return null; }
|
|
`,
|
|
'app/blog/og-image/[post]+api.ts': `export async function GET(request: Request) { return new Response('png'); }
|
|
export const POST = async () => new Response('ok');
|
|
`,
|
|
});
|
|
try {
|
|
expect(routesIn(cg, 'app/')).toEqual(['/', 'GET /blog/og-image/:post', 'POST /blog/og-image/:post']);
|
|
const get = cg.getNodesByKind('route').find((n) => n.name.startsWith('GET '))!;
|
|
const handler = cg.getOutgoingEdges(get.id).map((e) => cg.getNode(e.target)!).find((n) => n.name === 'GET');
|
|
expect(handler?.kind).toBe('function');
|
|
} finally {
|
|
cg.close();
|
|
}
|
|
});
|
|
});
|