diff --git a/packages/void/src/pages/scan.ts b/packages/void/src/pages/scan.ts index 200cfa064..1f6cdf744 100644 --- a/packages/void/src/pages/scan.ts +++ b/packages/void/src/pages/scan.ts @@ -1,8 +1,18 @@ import { existsSync, readFileSync } from 'node:fs'; import { join, dirname, basename, extname } from 'pathslash'; import { glob } from 'tinyglobby'; +import { parseSync } from 'vite'; import { readProjectPaths, type ProjectPaths } from '../project-paths.ts'; import { parseRouteFilename } from '../router/parse-filename.ts'; +import { + isIdentifier, + isLiteral, + type AstExportNamedDeclaration, + type AstFunctionDeclaration, + type AstNode, + type AstProgram, + type AstVariableDeclaration, +} from '../shared/ast.ts'; import { resolveNamedLayout } from './layout-resolution.ts'; import type { ExplicitBoolean, @@ -17,13 +27,7 @@ import type { const COMPONENT_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte', 'md']; const SERVER_EXTENSIONS = ['ts', 'tsx', 'js', 'mjs', 'mts']; -const PAGES_EXPORT_RE = /export\s+(?:const|function|async\s+function)\s+(loader|actions?)\b/g; -const REVALIDATE_RE = /export\s+const\s+revalidate\s*=\s*(\d+)\s*;?/; -const PRERENDER_RE = /export\s+const\s+prerender\s*=\s*true\s*;?/; -const PRERENDER_FALSE_RE = /export\s+const\s+prerender\s*=\s*false\s*;?/; -const SSR_TRUE_RE = /export\s+const\s+ssr\s*=\s*true\s*;?/; -const SSR_FALSE_RE = /export\s+const\s+ssr\s*=\s*false\s*;?/; -const GET_PRERENDER_PATHS_RE = /export\s+(?:async\s+)?function\s+getPrerenderPaths\b/; +const PAGE_METHODS = new Set(['loader', 'action', 'actions']); const LAYOUT_STRING_RE = /export\s+const\s+layout\s*=\s*["']([^"']+)["']\s*;?/; const LAYOUT_FALSE_RE = /export\s+const\s+layout\s*=\s*false\s*;?/; export const MAX_REVALIDATE = 31536000; // 1 year in seconds @@ -48,6 +52,125 @@ type ResolvedPageRendering = { shouldPrerender: boolean; }; +type PageServerMetadata = { + methods: Array; + revalidate?: number; + prerender?: ExplicitBoolean; + ssr?: ExplicitBoolean; + hasGetPrerenderPaths?: boolean; +}; + +const TRANSPARENT_EXPRESSION_TYPES = new Set([ + 'ParenthesizedExpression', + 'TSAsExpression', + 'TSNonNullExpression', + 'TSSatisfiesExpression', + 'TSTypeAssertion', +]); + +function asAstNode(value: unknown): AstNode | undefined { + if ( + typeof value === 'object' && + value !== null && + typeof (value as { type?: unknown }).type === 'string' + ) { + return value as AstNode; + } + return undefined; +} + +function unwrapTransparentExpression(node: AstNode | null | undefined): AstNode | undefined { + let current = node ?? undefined; + while (current && TRANSPARENT_EXPRESSION_TYPES.has(current.type)) { + current = asAstNode(current.expression); + } + return current; +} + +function extractPageServerMetadata(filename: string, code: string): PageServerMetadata { + let parsed: ReturnType; + try { + parsed = parseSync(filename, code); + } catch { + return { methods: [] }; + } + + const metadata: PageServerMetadata = { methods: [] }; + const methodNames = new Set(); + + const recordExport = (name: string, init?: AstNode): void => { + if (PAGE_METHODS.has(name)) { + methodNames.add(name); + return; + } + if (name === 'getPrerenderPaths') { + metadata.hasGetPrerenderPaths = true; + return; + } + const literal = unwrapTransparentExpression(init); + if (!isLiteral(literal)) { + return; + } + if ( + name === 'revalidate' && + typeof literal.value === 'number' && + Number.isInteger(literal.value) && + literal.value >= 0 + ) { + metadata.revalidate = literal.value; + } else if (name === 'prerender' && typeof literal.value === 'boolean') { + metadata.prerender = literal.value; + } else if (name === 'ssr' && typeof literal.value === 'boolean') { + metadata.ssr = literal.value; + } + }; + + const program = parsed.program as unknown as AstProgram; + for (const node of program.body ?? []) { + if (node.type !== 'ExportNamedDeclaration') { + continue; + } + const exported = node as AstExportNamedDeclaration; + if (exported.exportKind === 'type') { + continue; + } + + const declaration = exported.declaration; + if (declaration?.type === 'VariableDeclaration') { + const variableDeclaration = declaration as AstVariableDeclaration; + if (variableDeclaration.kind === 'const') { + for (const declarator of variableDeclaration.declarations ?? []) { + if (isIdentifier(declarator.id)) { + recordExport(declarator.id.name, declarator.init); + } + } + } + } else if (declaration?.type === 'FunctionDeclaration') { + const functionDeclaration = declaration as AstFunctionDeclaration; + if (isIdentifier(functionDeclaration.id)) { + recordExport(functionDeclaration.id.name); + } + } + + for (const specifier of exported.specifiers ?? []) { + if (specifier.exportKind === 'type') { + continue; + } + const name = isIdentifier(specifier.exported) + ? specifier.exported.name + : isIdentifier(specifier.local) + ? specifier.local.name + : undefined; + if (name) { + recordExport(name); + } + } + } + + metadata.methods = [...methodNames]; + return metadata; +} + function inferAutoPrerender(page: PageRenderingSource, options?: PageScanOptions): boolean { const isDynamic = page.params.length > 0 || page.catchAll; @@ -167,26 +290,12 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis if (existsSync(join(pagesDir, candidate))) { serverPath = candidate; const content = readFileSync(join(pagesDir, candidate), 'utf-8'); - for (const m of content.matchAll(PAGES_EXPORT_RE)) { - methods.push(m[1]); - } - const revalidateMatch = content.match(REVALIDATE_RE); - if (revalidateMatch) { - revalidate = Number(revalidateMatch[1]); - } - if (PRERENDER_RE.test(content)) { - prerender = true; - } else if (PRERENDER_FALSE_RE.test(content)) { - prerender = false; - } - if (SSR_TRUE_RE.test(content)) { - ssr = true; - } else if (SSR_FALSE_RE.test(content)) { - ssr = false; - } - if (GET_PRERENDER_PATHS_RE.test(content)) { - hasGetPrerenderPaths = true; - } + const metadata = extractPageServerMetadata(candidate, content); + methods.push(...metadata.methods); + revalidate = metadata.revalidate; + prerender = metadata.prerender; + ssr = metadata.ssr; + hasGetPrerenderPaths = metadata.hasGetPrerenderPaths; break; } } diff --git a/packages/void/src/shared/ast.ts b/packages/void/src/shared/ast.ts index bdbc80ab3..8beb762bc 100644 --- a/packages/void/src/shared/ast.ts +++ b/packages/void/src/shared/ast.ts @@ -50,6 +50,7 @@ export type AstVariableDeclarator = AstNode & { export type AstVariableDeclaration = AstNode & { type: 'VariableDeclaration'; + kind?: string; declarations?: Array; }; diff --git a/packages/void/test/unit/pages-scan.test.ts b/packages/void/test/unit/pages-scan.test.ts index 3a5d152d2..b2c305c41 100644 --- a/packages/void/test/unit/pages-scan.test.ts +++ b/packages/void/test/unit/pages-scan.test.ts @@ -102,6 +102,70 @@ describe('scanPages', () => { expect(page!.methods).toEqual(['loader', 'actions']); }); + it('ignores commented-out and string-contained server exports', async () => { + writeFile('comments.vue', ''); + writeFile( + 'comments.server.ts', + ` + export const action = () => ({}); + const example = "export const loader = () => ({})"; + // export const actions = {}; + /* + export const loader = () => ({}); + export const revalidate = 60; + export const prerender = true; + export const ssr = false; + export function getPrerenderPaths() {} + */ + `, + ); + + const result = await scanPages(TMP); + const page = result.pages.find((p) => p.componentId === 'comments'); + expect(page).toBeDefined(); + expect(page!.methods).toEqual(['action']); + expect(page!.revalidate).toBeUndefined(); + expect(page!.prerender).toBeUndefined(); + expect(page!.ssr).toBeUndefined(); + expect(page!.hasGetPrerenderPaths).toBeUndefined(); + }); + + it.each([ + ['ts', 'export const action: () => void = () => {};'], + ['tsx', 'export const action = () =>
;'], + ['js', 'export const action = () => {};'], + ['mjs', 'export const action = () => {};'], + ['mts', 'export const action: () => void = () => {};'], + ])('parses .server.%s exports', async (extension, source) => { + writeFile('extension.vue', ''); + writeFile(`extension.server.${extension}`, source); + + const result = await scanPages(TMP); + const page = result.pages.find((p) => p.componentId === 'extension'); + expect(page).toBeDefined(); + expect(page!.serverPath).toBe(`extension.server.${extension}`); + expect(page!.methods).toEqual(['action']); + }); + + it('unwraps TypeScript expressions around rendering metadata literals', async () => { + writeFile('typed.vue', ''); + writeFile( + 'typed.server.ts', + ` + export const prerender = true as const; + export const ssr = false satisfies boolean; + export const revalidate = (60 as number)!; + `, + ); + + const result = await scanPages(TMP); + const page = result.pages.find((p) => p.componentId === 'typed'); + expect(page).toBeDefined(); + expect(page!.prerender).toBe(true); + expect(page!.ssr).toBe(false); + expect(page!.revalidate).toBe(60); + }); + it('identifies layout.vue files separately', async () => { writeFile('layout.vue', ''); writeFile('users/layout.vue', '');