Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 136 additions & 27 deletions packages/void/src/pages/scan.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -48,6 +52,125 @@ type ResolvedPageRendering = {
shouldPrerender: boolean;
};

type PageServerMetadata = {
methods: Array<string>;
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<typeof parseSync>;
try {
parsed = parseSync(filename, code);
} catch {
return { methods: [] };
}

const metadata: PageServerMetadata = { methods: [] };
const methodNames = new Set<string>();

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;

Expand Down Expand Up @@ -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;
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/void/src/shared/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type AstVariableDeclarator = AstNode & {

export type AstVariableDeclaration = AstNode & {
type: 'VariableDeclaration';
kind?: string;
declarations?: Array<AstVariableDeclarator>;
};

Expand Down
64 changes: 64 additions & 0 deletions packages/void/test/unit/pages-scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', '<template><div>Comments</div></template>');
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 = () => <div />;'],
['js', 'export const action = () => {};'],
['mjs', 'export const action = () => {};'],
['mts', 'export const action: () => void = () => {};'],
])('parses .server.%s exports', async (extension, source) => {
writeFile('extension.vue', '<template><div>Extension</div></template>');
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', '<template><div>Typed</div></template>');
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', '<template><slot /></template>');
writeFile('users/layout.vue', '<template><slot /></template>');
Expand Down