import { bold } from "kleur/colors"; import { createRequire } from "module"; import nodeFs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { getPrerenderDefault } from "../../../prerender/utils.js"; import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from "../../constants.js"; import { removeLeadingForwardSlash, slash } from "../../path.js"; import { resolvePages } from "../../util.js"; import { getRouteGenerator } from "./generator.js"; import { AstroError } from "../../errors/index.js"; import { MissingIndexForInternationalization } from "../../errors/errors-data.js"; const require2 = createRequire(import.meta.url); function countOccurrences(needle, haystack) { let count = 0; for (const hay of haystack) { if (hay === needle) count += 1; } return count; } function getParts(part, file) { const result = []; part.split(/\[(.+?\(.+?\)|.+?)\]/).map((str, i) => { if (!str) return; const dynamic = i % 2 === 1; const [, content] = dynamic ? /([^(]+)$/.exec(str) || [null, null] : [null, str]; if (!content || dynamic && !/^(\.\.\.)?[a-zA-Z0-9_$]+$/.test(content)) { throw new Error(`Invalid route ${file} \u2014 parameter name must match /^[a-zA-Z0-9_$]+$/`); } result.push({ content, dynamic, spread: dynamic && /^\.{3}.+$/.test(content) }); }); return result; } function getPattern(segments, config, addTrailingSlash) { const base = config.base; const pathname = segments.map((segment) => { if (segment.length === 1 && segment[0].spread) { return "(?:\\/(.*?))?"; } else { return "\\/" + segment.map((part) => { if (part.spread) { return "(.*?)"; } else if (part.dynamic) { return "([^/]+?)"; } else { return part.content.normalize().replace(/\?/g, "%3F").replace(/#/g, "%23").replace(/%5B/g, "[").replace(/%5D/g, "]").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } }).join(""); } }).join(""); const trailing = addTrailingSlash && segments.length ? getTrailingSlashPattern(addTrailingSlash) : "$"; let initial = "\\/"; if (addTrailingSlash === "never" && base !== "/") { initial = ""; } return new RegExp(`^${pathname || initial}${trailing}`); } function getTrailingSlashPattern(addTrailingSlash) { if (addTrailingSlash === "always") { return "\\/$"; } if (addTrailingSlash === "never") { return "$"; } return "\\/?$"; } function validateSegment(segment, file = "") { if (!file) file = segment; if (/\]\[/.test(segment)) { throw new Error(`Invalid route ${file} \u2014 parameters must be separated`); } if (countOccurrences("[", segment) !== countOccurrences("]", segment)) { throw new Error(`Invalid route ${file} \u2014 brackets are unbalanced`); } if ((/.+\[\.\.\.[^\]]+\]/.test(segment) || /\[\.\.\.[^\]]+\].+/.test(segment)) && file.endsWith(".astro")) { throw new Error(`Invalid route ${file} \u2014 rest parameter must be a standalone segment`); } } function isSemanticallyEqualSegment(segmentA, segmentB) { if (segmentA.length !== segmentB.length) { return false; } for (const [index, partA] of segmentA.entries()) { const partB = segmentB[index]; if (partA.dynamic !== partB.dynamic || partA.spread !== partB.spread) { return false; } if (!partA.dynamic && partA.content !== partB.content) { return false; } } return true; } function routeComparator(a, b) { const aLength = a.isIndex ? a.segments.length + 1 : a.segments.length; const bLength = b.isIndex ? b.segments.length + 1 : b.segments.length; if (aLength !== bLength) { return aLength > bLength ? -1 : 1; } const aIsStatic = a.segments.every( (segment) => segment.every((part) => !part.dynamic && !part.spread) ); const bIsStatic = b.segments.every( (segment) => segment.every((part) => !part.dynamic && !part.spread) ); if (aIsStatic !== bIsStatic) { return aIsStatic ? -1 : 1; } const aHasSpread = a.segments.some((segment) => segment.some((part) => part.spread)); const bHasSpread = b.segments.some((segment) => segment.some((part) => part.spread)); if (aHasSpread !== bHasSpread) { return aHasSpread ? 1 : -1; } if (a.prerender !== b.prerender) { return a.prerender ? -1 : 1; } if (a.type === "endpoint" !== (b.type === "endpoint")) { return a.type === "endpoint" ? -1 : 1; } return a.route.localeCompare(b.route); } function createFileBasedRoutes({ settings, cwd, fsMod }, logger) { const components = []; const routes = []; const validPageExtensions = /* @__PURE__ */ new Set([ ".astro", ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS, ...settings.pageExtensions ]); const validEndpointExtensions = /* @__PURE__ */ new Set([".js", ".ts"]); const localFs = fsMod ?? nodeFs; const prerender = getPrerenderDefault(settings.config); function walk(fs, dir, parentSegments, parentParams) { let items = []; 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 ext = path.extname(basename); const name = ext ? basename.slice(0, -ext.length) : basename; if (name[0] === "_") { continue; } if (basename[0] === "." && basename !== ".well-known") { continue; } if (!isDir && !validPageExtensions.has(ext) && !validEndpointExtensions.has(ext)) { logger.warn( null, `Unsupported file type ${bold( resolved )} found. Prefix filename with an underscore (\`_\`) to ignore.` ); continue; } const segment = isDir ? basename : name; validateSegment(segment, file); const parts = getParts(segment, file); const isIndex = isDir ? false : basename.startsWith("index."); const routeSuffix = basename.slice(basename.indexOf("."), -ext.length); const isPage = validPageExtensions.has(ext); items.push({ basename, ext, parts, file: file.replace(/\\/g, "/"), isDir, isIndex, isPage, routeSuffix }); } for (const item of items) { const segments = parentSegments.slice(); if (item.isIndex) { if (item.routeSuffix) { if (segments.length > 0) { const lastSegment = segments[segments.length - 1].slice(); const lastPart = lastSegment[lastSegment.length - 1]; if (lastPart.dynamic) { lastSegment.push({ dynamic: false, spread: false, content: item.routeSuffix }); } else { lastSegment[lastSegment.length - 1] = { dynamic: false, spread: false, content: `${lastPart.content}${item.routeSuffix}` }; } segments[segments.length - 1] = lastSegment; } else { segments.push(item.parts); } } } else { segments.push(item.parts); } const params = parentParams.slice(); 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); } else { components.push(item.file); const component = item.file; const { trailingSlash } = settings.config; const pattern = getPattern(segments, settings.config, trailingSlash); const generate = getRouteGenerator(segments, trailingSlash); const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join("/")}` : null; const route = `/${segments.map(([{ dynamic, content }]) => dynamic ? `[${content}]` : content).join("/")}`.toLowerCase(); routes.push({ route, isIndex: item.isIndex, type: item.isPage ? "page" : "endpoint", pattern, segments, params, component, generate, pathname: pathname || void 0, prerender, fallbackRoutes: [] }); } } } const { config } = settings; const pages = resolvePages(config); 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}`); } return routes; } function createInjectedRoutes({ settings, cwd }) { const { config } = settings; const prerender = getPrerenderDefault(config); const routes = { normal: [], legacy: [] }; const priority = computeRoutePriority(config); for (const injectedRoute of settings.injectedRoutes) { const { pattern: name, entrypoint, prerender: prerenderInjected } = injectedRoute; let resolved; try { resolved = require2.resolve(entrypoint, { paths: [cwd || fileURLToPath(config.root)] }); } catch (e) { resolved = fileURLToPath(new URL(entrypoint, config.root)); } const component = slash(path.relative(cwd || fileURLToPath(config.root), resolved)); const segments = removeLeadingForwardSlash(name).split(path.posix.sep).filter(Boolean).map((s) => { validateSegment(s); return getParts(s, component); }); const type = resolved.endsWith(".astro") ? "page" : "endpoint"; const isPage = type === "page"; const trailingSlash = isPage ? config.trailingSlash : "never"; const pattern = getPattern(segments, settings.config, trailingSlash); const generate = getRouteGenerator(segments, trailingSlash); const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join("/")}` : null; const params = segments.flat().filter((p) => p.dynamic).map((p) => p.content); const route = `/${segments.map(([{ dynamic, content }]) => dynamic ? `[${content}]` : content).join("/")}`.toLowerCase(); routes[priority].push({ type, // For backwards compatibility, an injected route is never considered an index route. isIndex: false, route, pattern, segments, params, component, generate, pathname: pathname || void 0, prerender: prerenderInjected ?? prerender, fallbackRoutes: [] }); } return routes; } function createRedirectRoutes({ settings }, routeMap, logger) { const { config } = settings; const trailingSlash = config.trailingSlash; const routes = { normal: [], legacy: [] }; const priority = computeRoutePriority(settings.config); for (const [from, to] of Object.entries(settings.config.redirects)) { const segments = removeLeadingForwardSlash(from).split(path.posix.sep).filter(Boolean).map((s) => { validateSegment(s); return getParts(s, from); }); const pattern = getPattern(segments, settings.config, trailingSlash); const generate = getRouteGenerator(segments, trailingSlash); const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join("/")}` : null; const params = segments.flat().filter((p) => p.dynamic).map((p) => p.content); const route = `/${segments.map(([{ dynamic, content }]) => dynamic ? `[${content}]` : content).join("/")}`.toLowerCase(); let destination; if (typeof to === "string") { destination = to; } else { destination = to.destination; } if (/^https?:\/\//.test(destination)) { logger.warn( "redirects", `Redirecting to an external URL is not officially supported: ${from} -> ${destination}` ); } routes[priority].push({ type: "redirect", // For backwards compatibility, a redirect is never considered an index route. isIndex: false, route, pattern, segments, params, component: from, generate, pathname: pathname || void 0, prerender: false, redirect: to, redirectRoute: routeMap.get(destination), fallbackRoutes: [] }); } return routes; } function isStaticSegment(segment) { return segment.every((part) => !part.dynamic && !part.spread); } function detectRouteCollision(a, b, config, logger) { if (a.type === "fallback" || b.type === "fallback") { return; } if (a.route === b.route && a.segments.every(isStaticSegment) && b.segments.every(isStaticSegment)) { logger.warn( "router", `The route "${a.route}" is defined in both "${a.component}" and "${b.component}". A static route cannot be defined more than once.` ); logger.warn( "router", "A collision will result in an hard error in following versions of Astro." ); return; } if (a.prerender || b.prerender) { return; } if (a.segments.length !== b.segments.length) { return; } const segmentCount = a.segments.length; for (let index = 0; index < segmentCount; index++) { const segmentA = a.segments[index]; const segmentB = b.segments[index]; if (!isSemanticallyEqualSegment(segmentA, segmentB)) { return; } } logger.warn( "router", `The route "${a.route}" is defined in both "${a.component}" and "${b.component}" using SSR mode. A dynamic SSR route cannot be defined more than once.` ); logger.warn("router", "A collision will result in an hard error in following versions of Astro."); } function createRouteManifest(params, logger) { const { settings } = params; const { config } = settings; const routeMap = /* @__PURE__ */ new Map(); const fileBasedRoutes = createFileBasedRoutes(params, logger); for (const route of fileBasedRoutes) { routeMap.set(route.route, route); } const injectedRoutes = createInjectedRoutes(params); for (const [, routes2] of Object.entries(injectedRoutes)) { for (const route of routes2) { routeMap.set(route.route, route); } } const redirectRoutes = createRedirectRoutes(params, routeMap, logger); const routes = [ ...injectedRoutes["legacy"].sort(routeComparator), ...[...fileBasedRoutes, ...injectedRoutes["normal"], ...redirectRoutes["normal"]].sort( routeComparator ), ...redirectRoutes["legacy"].sort(routeComparator) ]; if (config.experimental.globalRoutePriority) { for (const [index, higherRoute] of routes.entries()) { for (const lowerRoute of routes.slice(index + 1)) { detectRouteCollision(higherRoute, lowerRoute, config, logger); } } } const i18n = settings.config.i18n; if (i18n) { if (i18n.routing === "pathname-prefix-always") { let index = routes.find((route) => route.route === "/"); if (!index) { let relativePath = path.relative( fileURLToPath(settings.config.root), fileURLToPath(new URL("pages", settings.config.srcDir)) ); throw new AstroError({ ...MissingIndexForInternationalization, message: MissingIndexForInternationalization.message(relativePath) }); } } const routesByLocale = /* @__PURE__ */ new Map(); const setRoutes = new Set(routes.filter((route) => route.type === "page")); const filteredLocales = i18n.locales.filter((loc) => { if (typeof loc === "string") { return loc !== i18n.defaultLocale; } return loc.path !== i18n.defaultLocale; }).map((locale) => { if (typeof locale === "string") { return locale; } return locale.path; }); for (const locale of filteredLocales) { for (const route of setRoutes) { if (!route.route.includes(`/${locale}`)) { continue; } const currentRoutes = routesByLocale.get(locale); if (currentRoutes) { currentRoutes.push(route); routesByLocale.set(locale, currentRoutes); } else { routesByLocale.set(locale, [route]); } setRoutes.delete(route); } } for (const route of setRoutes) { const currentRoutes = routesByLocale.get(i18n.defaultLocale); if (currentRoutes) { currentRoutes.push(route); routesByLocale.set(i18n.defaultLocale, currentRoutes); } else { routesByLocale.set(i18n.defaultLocale, [route]); } setRoutes.delete(route); } if (i18n.routing === "pathname-prefix-always") { const defaultLocaleRoutes = routesByLocale.get(i18n.defaultLocale); if (defaultLocaleRoutes) { const indexDefaultRoute = defaultLocaleRoutes.find((routeData) => { return routeData.component.includes("index"); }); if (indexDefaultRoute) { const pathname = "/"; const route = "/"; const segments = removeLeadingForwardSlash(route).split(path.posix.sep).filter(Boolean).map((s) => { validateSegment(s); return getParts(s, route); }); routes.push({ ...indexDefaultRoute, pathname, route, segments, pattern: getPattern(segments, config, config.trailingSlash), type: "fallback" }); } } } if (i18n.fallback) { let fallback = Object.entries(i18n.fallback); if (fallback.length > 0) { for (const [fallbackFromLocale, fallbackToLocale] of fallback) { let fallbackToRoutes; if (fallbackToLocale === i18n.defaultLocale) { fallbackToRoutes = routesByLocale.get(i18n.defaultLocale); } else { fallbackToRoutes = routesByLocale.get(fallbackToLocale); } const fallbackFromRoutes = routesByLocale.get(fallbackFromLocale); if (!fallbackToRoutes) { continue; } for (const fallbackToRoute of fallbackToRoutes) { const hasRoute = fallbackFromRoutes && // we check if the fallback from locale (the origin) has already this route fallbackFromRoutes.some((route) => { if (fallbackToLocale === i18n.defaultLocale) { return route.route.replace(`/${fallbackFromLocale}`, "") === fallbackToRoute.route; } else { return route.route.replace(`/${fallbackToLocale}`, `/${fallbackFromLocale}`) === fallbackToRoute.route; } }); if (!hasRoute) { let pathname; let route; if (fallbackToLocale === i18n.defaultLocale && i18n.routing === "pathname-prefix-other-locales") { if (fallbackToRoute.pathname) { pathname = `/${fallbackFromLocale}${fallbackToRoute.pathname}`; } route = `/${fallbackFromLocale}${fallbackToRoute.route}`; } else { pathname = fallbackToRoute.pathname?.replace(`/${fallbackToLocale}/`, `/${fallbackFromLocale}/`).replace(`/${fallbackToLocale}`, `/${fallbackFromLocale}`); route = fallbackToRoute.route.replace(`/${fallbackToLocale}`, `/${fallbackFromLocale}`).replace(`/${fallbackToLocale}/`, `/${fallbackFromLocale}/`); } const segments = removeLeadingForwardSlash(route).split(path.posix.sep).filter(Boolean).map((s) => { validateSegment(s); return getParts(s, route); }); const generate = getRouteGenerator(segments, config.trailingSlash); const index = routes.findIndex((r) => r === fallbackToRoute); if (index >= 0) { const fallbackRoute = { ...fallbackToRoute, pathname, route, segments, generate, pattern: getPattern(segments, config, config.trailingSlash), type: "fallback", fallbackRoutes: [] }; const routeData = routes[index]; routeData.fallbackRoutes.push(fallbackRoute); } } } } } } } return { routes }; } function computeRoutePriority(config) { if (config.experimental.globalRoutePriority) { return "normal"; } return "legacy"; } export { createRouteManifest };