UNPKG

astro

Version:

Astro is a modern site builder with web best practices, performance, and DX front-of-mind.

520 lines (519 loc) • 19.7 kB
import nodeFs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { bold } from "kleur/colors"; import pLimit from "p-limit"; import { injectImageEndpoint } from "../../../assets/endpoint/config.js"; import { toRoutingStrategy } from "../../../i18n/utils.js"; import { runHookRoutesResolved } from "../../../integrations/hooks.js"; import { getPrerenderDefault } from "../../../prerender/utils.js"; import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from "../../constants.js"; import { MissingIndexForInternationalization, UnsupportedExternalRedirect } from "../../errors/errors-data.js"; import { AstroError } from "../../errors/index.js"; import { hasFileExtension, removeLeadingForwardSlash, slash } from "../../path.js"; import { injectServerIslandRoute } from "../../server-islands/endpoint.js"; import { resolvePages } from "../../util.js"; import { ensure404Route } from "../astro-designed-error-pages.js"; import { routeComparator } from "../priority.js"; import { getRouteGenerator } from "./generator.js"; import { getPattern } from "./pattern.js"; import { getRoutePrerenderOption } from "./prerender.js"; import { validateSegment } from "./segment.js"; const require2 = createRequire(import.meta.url); const ROUTE_DYNAMIC_SPLIT = /\[(.+?\(.+?\)|.+?)\]/; const ROUTE_SPREAD = /^\.{3}.+$/; function getParts(part, file) { const result = []; part.split(ROUTE_DYNAMIC_SPLIT).map((str, i) => { if (!str) return; const dynamic = i % 2 === 1; const [, content] = dynamic ? /([^(]+)$/.exec(str) || [null, null] : [null, str]; if (!content || dynamic && !/^(?:\.\.\.)?[\w$]+$/.test(content)) { throw new Error(`Invalid route ${file} \u2014 parameter name must match /^[a-zA-Z0-9_$]+$/`); } result.push({ content, dynamic, spread: dynamic && ROUTE_SPREAD.test(content) }); }); return result; } 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 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.substring(0, basename.lastIndexOf(".")) === "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 pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join("/")}` : null; const trailingSlash = trailingSlashForPath(pathname, settings.config); const pattern = getPattern(segments, settings.config.base, trailingSlash); const generate = getRouteGenerator(segments, trailingSlash); const route = joinSegments(segments); routes.push({ route, isIndex: item.isIndex, type: item.isPage ? "page" : "endpoint", pattern, segments, params, component, generate, pathname: pathname || void 0, prerender, fallbackRoutes: [], distURL: [], origin: "project" }); } } } 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; } const trailingSlashForPath = (pathname, config) => pathname && hasFileExtension(pathname) ? "ignore" : config.trailingSlash; function createInjectedRoutes({ settings, cwd }) { const { config } = settings; const prerender = getPrerenderDefault(config); const routes = []; for (const injectedRoute of settings.injectedRoutes) { const { pattern: name, entrypoint, prerender: prerenderInjected, origin } = injectedRoute; const { resolved, component } = resolveInjectedRoute(entrypoint.toString(), config.root, cwd); 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 pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join("/")}` : null; const trailingSlash = trailingSlashForPath(pathname, config); const pattern = getPattern(segments, settings.config.base, trailingSlash); const generate = getRouteGenerator(segments, trailingSlash); const params = segments.flat().filter((p) => p.dynamic).map((p) => p.content); const route = joinSegments(segments); routes.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: [], distURL: [], origin }); } return routes; } function createRedirectRoutes({ settings }, routeMap) { const { config } = settings; const trailingSlash = config.trailingSlash; const routes = []; 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.base, 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 = joinSegments(segments); let destination; if (typeof to === "string") { destination = to; } else { destination = to.destination; } if (URL.canParse(destination) && !/^https?:\/\//.test(destination)) { throw new AstroError({ ...UnsupportedExternalRedirect, message: UnsupportedExternalRedirect.message(from, destination) }); } routes.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: [], distURL: [], origin: "project" }); } 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."); } async function createRoutesList(params, logger, { dev = false } = {}) { 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 route of injectedRoutes) { routeMap.set(route.route, route); } const redirectRoutes = createRedirectRoutes(params, routeMap); const filteredFiledBasedRoutes = fileBasedRoutes.filter((fileBasedRoute) => { const isRedirect = redirectRoutes.findIndex((rd) => rd.route === fileBasedRoute.route); return isRedirect < 0; }); const routes = [ ...[...filteredFiledBasedRoutes, ...injectedRoutes, ...redirectRoutes].sort(routeComparator) ]; settings.buildOutput = getPrerenderDefault(config) ? "static" : "server"; const limit = pLimit(10); let promises = []; for (const route of routes) { promises.push( limit(async () => { if (route.type !== "page" && route.type !== "endpoint") return; const localFs = params.fsMod ?? nodeFs; const content = await localFs.promises.readFile( fileURLToPath(new URL(route.component, settings.config.root)), "utf-8" ); await getRoutePrerenderOption(content, route, settings, logger); }) ); } await Promise.all(promises); 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) { const strategy = toRoutingStrategy(i18n.routing, i18n.domains); if (strategy === "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(i18n.defaultLocale), hint: MissingIndexForInternationalization.hint(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 (strategy === "pathname-prefix-always") { const defaultLocaleRoutes = routesByLocale.get(i18n.defaultLocale); if (defaultLocaleRoutes) { const indexDefaultRoute = defaultLocaleRoutes.find(({ route }) => route === "/") ?? defaultLocaleRoutes.find(({ route }) => route === `/${i18n.defaultLocale}`); 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.base, 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 && strategy === "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.base, config.trailingSlash), type: "fallback", fallbackRoutes: [] }; const routeData = routes[index]; routeData.fallbackRoutes.push(fallbackRoute); } } } } } } } if (dev) { ensure404Route({ routes }); } if (dev || settings.buildOutput === "server") { injectImageEndpoint(settings, { routes }, dev ? "dev" : "build"); } if (dev || settings.config.adapter) { injectServerIslandRoute(settings.config, { routes }); } await runHookRoutesResolved({ routes, settings, logger }); return { routes }; } function resolveInjectedRoute(entrypoint, root, cwd) { let resolved; try { resolved = require2.resolve(entrypoint, { paths: [cwd || fileURLToPath(root)] }); } catch { resolved = fileURLToPath(new URL(entrypoint, root)); } return { resolved, component: slash(path.relative(cwd || fileURLToPath(root), resolved)) }; } function joinSegments(segments) { const arr = segments.map((segment) => { return segment.map((rp) => rp.dynamic ? `[${rp.content}]` : rp.content).join(""); }); return `/${arr.join("/")}`.toLowerCase(); } export { createRoutesList, resolveInjectedRoute };