Skip to content
Draft
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
17 changes: 15 additions & 2 deletions packages/astro/src/core/app/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { type CreateRenderContext, RenderContext } from '../render-context.js';
import { redirectTemplate } from '../routing/3xx.js';
import { ensure404Route } from '../routing/astro-designed-error-pages.js';
import { matchRoute } from '../routing/match.js';
import { Router } from '../routing/router.js';
import { type AstroSession, PERSIST_SYMBOL } from '../session/runtime.js';
import type { AppPipeline } from './pipeline.js';
import type { SSRManifest } from './types.js';
Expand Down Expand Up @@ -114,6 +115,7 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
adapterLogger: AstroIntegrationLogger;
baseWithoutTrailingSlash: string;
logger: Logger;
#router: Router;
constructor(manifest: SSRManifest, streaming = true, ...args: any[]) {
this.manifest = manifest;
this.manifestData = { routes: manifest.routes.map((route) => route.routeData) };
Expand All @@ -124,6 +126,7 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
level: manifest.logLevel,
});
this.adapterLogger = new AstroIntegrationLogger(this.logger.options, manifest.adapterName);
this.#router = this.createRouter(this.manifestData);
// This is necessary to allow running middlewares for 404 in SSR. There's special handling
// to return the host 404 if the user doesn't provide a custom 404
ensure404Route(this.manifestData);
Expand Down Expand Up @@ -179,6 +182,7 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {

set setManifestData(newManifestData: RoutesList) {
this.manifestData = newManifestData;
this.#router = this.createRouter(this.manifestData);
}

public removeBase(pathname: string) {
Expand Down Expand Up @@ -221,8 +225,9 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
if (!pathname) {
pathname = prependForwardSlash(this.removeBase(url.pathname));
}
let routeData = matchRoute(decodeURI(pathname), this.manifestData);
if (!routeData) return undefined;
const match = this.#router.match(decodeURI(pathname));
if (match.type !== 'match') return undefined;
const routeData = match.route;
if (allowPrerenderedRoutes) {
return routeData;
}
Expand All @@ -233,6 +238,14 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
return routeData;
}

private createRouter(manifestData: RoutesList): Router {
return new Router(manifestData.routes, {
base: '/',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not correct

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be in the manifest I think.

trailingSlash: this.manifest.trailingSlash,
buildFormat: this.manifest.buildFormat,
});
}

/**
* A matching route function to use in the development server.
* Contrary to the `.match` function, this function resolves props and params, returning the correct
Expand Down
104 changes: 74 additions & 30 deletions packages/astro/src/core/routing/manifest/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ interface Item {
const ROUTE_DYNAMIC_SPLIT = /\[([^[\]()]+(?:\([^)]+\))?)\]/;
const ROUTE_SPREAD = /^\.{3}.+$/;

export interface RouteEntry {
path: string;
isDir: boolean;
}

function getParts(part: string, file: string) {
const result: RoutePart[] = [];
part.split(ROUTE_DYNAMIC_SPLIT).map((str, i) => {
Expand Down Expand Up @@ -112,7 +117,32 @@ function createFileBasedRoutes(
{ settings, cwd, fsMod }: CreateRouteManifestParams,
logger: Logger,
): RouteData[] {
const components: string[] = [];
const { config } = settings;
const pages = resolvePages(config);
const localFs = fsMod ?? nodeFs;
const pagesRoot = fileURLToPath(pages);
const rootPath = fileURLToPath(config.root);
const basePath = cwd ?? rootPath;
const pagesDirRelative = slash(path.relative(basePath, pagesRoot));

if (!localFs.existsSync(pages)) {
if (settings.injectedRoutes.length === 0) {
const pagesDirRootRelative = pages.href.slice(settings.config.root.href.length);
logger.warn(null, `Missing pages directory: ${pagesDirRootRelative}`);
}
return [];
}

const entries = collectRouteEntries(localFs, pagesRoot);
return createRoutesFromEntries(entries, settings, logger, pagesDirRelative);
}

export function createRoutesFromEntries(
entries: RouteEntry[],
settings: AstroSettings,
logger: Logger,
pagesDirRelative = 'src/pages',
): RouteData[] {
const routes: RouteData[] = [];
const validPageExtensions = new Set<string>([
'.astro',
Expand All @@ -121,45 +151,49 @@ function createFileBasedRoutes(
]);
const invalidPotentialPages = new Set<string>(['.tsx', '.jsx', '.vue', '.svelte']);
const validEndpointExtensions = new Set<string>(['.js', '.ts']);
const localFs = fsMod ?? nodeFs;
const prerender = getPrerenderDefault(settings.config);

function walk(
fs: typeof nodeFs,
dir: string,
parentSegments: RoutePart[][],
parentParams: string[],
) {
let items: Item[] = [];
const files = fs.readdirSync(dir);
for (const basename of files) {
const resolved = path.join(dir, basename);
const file = slash(path.relative(cwd || fileURLToPath(settings.config.root), resolved));
const isDir = fs.statSync(resolved).isDirectory();
const normalizedEntries = entries.map((entry) => ({
...entry,
path: slash(entry.path),
}));

const entriesByDir = new Map<string, RouteEntry[]>();
for (const entry of normalizedEntries) {
const dir = path.posix.dirname(entry.path) === '.' ? '' : path.posix.dirname(entry.path);
const list = entriesByDir.get(dir) ?? [];
list.push(entry);
entriesByDir.set(dir, list);
}

function walk(dir: string, parentSegments: RoutePart[][], parentParams: string[]) {
const items: Item[] = [];
const dirEntries = entriesByDir.get(dir) ?? [];
for (const entry of dirEntries) {
const basename = path.posix.basename(entry.path);
const ext = path.extname(basename);
const name = ext ? basename.slice(0, -ext.length) : basename;
const isDir = entry.isDir;
const file = slash(path.posix.join(pagesDirRelative, entry.path));

if (name[0] === '_') {
continue;
}
if (basename[0] === '.' && basename !== '.well-known') {
continue;
}
// filter out "foo.astro_tmp" files, etc
if (!isDir && !validPageExtensions.has(ext) && !validEndpointExtensions.has(ext)) {
// Only warn for files that could potentially be interpreted by users has being possible extensions for pages
// It's otherwise not a problem for users to have other files in their pages directory, for instance colocated images.
if (invalidPotentialPages.has(ext)) {
logger.warn(
null,
`Unsupported file type ${colors.bold(
resolved,
file,
)} found in pages directory. Only Astro files can be used as pages. Prefix filename with an underscore (\`_\`) to ignore this warning, or move the file outside of the pages directory.`,
);
}

continue;
}

const segment = isDir ? basename : name;
validateSegment(segment, file);

Expand All @@ -172,7 +206,7 @@ function createFileBasedRoutes(
basename,
ext,
parts,
file: file.replace(/\\/g, '/'),
file,
isDir,
isIndex,
isPage,
Expand Down Expand Up @@ -216,9 +250,8 @@ function createFileBasedRoutes(
params.push(...item.parts.filter((p) => p.dynamic).map((p) => p.content));

if (item.isDir) {
walk(fsMod ?? fs, path.join(dir, item.basename), segments, params);
walk(path.posix.join(dir, item.basename), segments, params);
} else {
components.push(item.file);
const component = item.file;
const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic)
? `/${segments.map((segment) => segment[0].content).join('/')}`
Expand All @@ -244,17 +277,28 @@ function createFileBasedRoutes(
}
}

const { config } = settings;
const pages = resolvePages(config);
walk('', [], []);
return routes;
}

if (localFs.existsSync(pages)) {
walk(localFs, fileURLToPath(pages), [], []);
} else if (settings.injectedRoutes.length === 0) {
const pagesDirRootRelative = pages.href.slice(settings.config.root.href.length);
logger.warn(null, `Missing pages directory: ${pagesDirRootRelative}`);
function collectRouteEntries(fs: typeof nodeFs, rootDir: string): RouteEntry[] {
const entries: RouteEntry[] = [];

function walk(dir: string) {
const files = fs.readdirSync(dir);
for (const basename of files) {
const resolved = path.join(dir, basename);
const isDir = fs.statSync(resolved).isDirectory();
const relative = slash(path.relative(rootDir, resolved));
entries.push({ path: relative, isDir });
if (isDir) {
walk(resolved);
}
}
}

return routes;
walk(rootDir);
return entries;
}

// Get trailing slash rule for a path, based on the config and whether the path has an extension.
Expand Down
142 changes: 142 additions & 0 deletions packages/astro/src/core/routing/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type { AstroConfig } from '../../types/public/config.js';
import type { Params } from '../../types/public/common.js';
import type { RouteData } from '../../types/public/internal.js';
import { getParams } from '../render/params-and-props.js';
import { routeComparator } from './priority.js';

export interface RouterOptions {
base: AstroConfig['base'];
trailingSlash: AstroConfig['trailingSlash'];
buildFormat: 'directory' | 'file' | 'preserve';
}

export interface RouterMatchRoute {
type: 'match';
route: RouteData;
params: Params;
pathname: string;
}

export interface RouterMatchRedirect {
type: 'redirect';
location: string;
status: 301 | 308;
}

export interface RouterMatchNone {
type: 'none';
reason: 'no-match' | 'outside-base';
}

export type RouterMatch = RouterMatchRoute | RouterMatchRedirect | RouterMatchNone;

export class Router {
#routes: RouteData[];
#base: string;
#baseWithoutTrailingSlash: string;
#buildFormat: RouterOptions['buildFormat'];
#trailingSlash: RouterOptions['trailingSlash'];

constructor(routes: RouteData[], options: RouterOptions) {
this.#routes = [...routes].sort(routeComparator);
this.#base = normalizeBase(options.base);
this.#baseWithoutTrailingSlash = removeTrailingSlash(this.#base);
this.#buildFormat = options.buildFormat;
this.#trailingSlash = options.trailingSlash;
}

public match(inputPathname: string): RouterMatch {
const normalized = normalizePathname(inputPathname);
if (normalized.redirect) {
return { type: 'redirect', location: normalized.redirect, status: 301 };
}

const baseResult = stripBase(
normalized.pathname,
this.#base,
this.#baseWithoutTrailingSlash,
this.#trailingSlash,
);
if (!baseResult) {
return { type: 'none', reason: 'outside-base' };
}

let pathname = baseResult;
if (this.#buildFormat === 'file') {
pathname = normalizeFileFormatPathname(pathname);
}

const route = this.#routes.find((candidate) => {
if (candidate.pattern.test(pathname)) return true;
return candidate.fallbackRoutes.some((fallbackRoute) => fallbackRoute.pattern.test(pathname));
});

if (!route) {
return { type: 'none', reason: 'no-match' };
}

const params = getParams(route, pathname);
return { type: 'match', route, params, pathname };
}
}

function normalizeBase(base: string): string {
if (!base) return '/';
if (base === '/') return '/';
if (!base.startsWith('/')) return `/${base}`;
return base;
}

function removeTrailingSlash(value: string): string {
if (value === '/') return '/';
return value.endsWith('/') ? value.slice(0, -1) : value;
}

function normalizePathname(pathname: string): { pathname: string; redirect?: string } {
let value = pathname;
if (!value.startsWith('/')) {
value = `/${value}`;
}

if (value.startsWith('//')) {
return { pathname: value, redirect: '/' };
}

return { pathname: value };
}

function stripBase(
pathname: string,
base: string,
baseWithoutTrailingSlash: string,
trailingSlash: RouterOptions['trailingSlash'],
): string | null {
if (base === '/') return pathname;
const baseWithSlash = `${baseWithoutTrailingSlash}/`;

if (pathname === baseWithoutTrailingSlash || pathname === base) {
return trailingSlash === 'always' ? null : '/';
}
if (pathname === baseWithSlash) {
return trailingSlash === 'never' ? null : '/';
}
if (pathname.startsWith(baseWithSlash)) {
return pathname.slice(baseWithoutTrailingSlash.length);
}

return null;
}

function normalizeFileFormatPathname(pathname: string): string {
if (pathname.endsWith('/index.html')) {
const trimmed = pathname.slice(0, -'/index.html'.length);
return trimmed === '' ? '/' : trimmed;
}

if (pathname.endsWith('.html')) {
const trimmed = pathname.slice(0, -'.html'.length);
return trimmed === '' ? '/' : trimmed;
}

return pathname;
}
Loading
Loading