UNPKG

astro

Version:

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

761 lines (760 loc) • 29 kB
import nodeFs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import pLimit from "p-limit"; import colors from "piccolore"; import { injectImageEndpoint } from "../../assets/endpoint/config.js"; import { runHookRoutesResolved } from "../../integrations/hooks.js"; import { getPrerenderDefault } from "../../prerender/utils.js"; import { toRoutingStrategy } from "../app/entrypoints/index.js"; import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from "../constants.js"; import { InvalidRedirectDestination, 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 { isRoute3xx } from "./internal/route-errors.js"; import { routeComparator } from "./priority.js"; import { getPattern } from "./pattern.js"; import { getRoutePrerenderOption } from "./prerender.js"; import { validateSegment } from "./segment.js"; const require = 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} — parameter name must match /^[a-zA-Z0-9_$]+$/`); } result.push({ content, dynamic, spread: dynamic && ROUTE_SPREAD.test(content) }); }); return result; } /** * Checks whether two route segments are semantically equivalent. * * Two segments are equivalent if they would match the same paths. This happens when: * - They have the same length. * - Each part in the same position is either: * - Both static and with the same content (e.g. `/foo` and `/foo`). * - Both dynamic, regardless of the content (e.g. `/[bar]` and `/[baz]`). * - Both rest parameters, regardless of the content (e.g. `/[...bar]` and `/[...baz]`). */ function isSemanticallyEqualSegment(segmentA, segmentB) { if (segmentA.length !== segmentB.length) { return false; } for (const [index, partA] of segmentA.entries()) { // Safe to use the index of one segment for the other because the segments have the same length const partB = segmentB[index]; if (partA.dynamic !== partB.dynamic || partA.spread !== partB.spread) { return false; } // Only compare the content on non-dynamic segments // `/[bar]` and `/[baz]` are effectively the same route, // only bound to a different path parameter. if (!partA.dynamic && partA.content !== partB.content) { return false; } } return true; } function createFileBasedRoutes({ settings, cwd, fsMod }, logger) { const { config } = settings; const pages = resolvePages(config); const localFs = fsMod ?? nodeFs; const rootPath = fileURLToPath(config.root); const pagesPath = fileURLToPath(pages); 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 routes = []; const validPageExtensions = new Set([ ".astro", ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS, ...settings.pageExtensions ]); const invalidPotentialPages = new Set([ ".tsx", ".jsx", ".vue", ".svelte" ]); const validEndpointExtensions = new Set([".js", ".ts"]); const prerender = getPrerenderDefault(settings.config); function walk(fs, dir, parentSegments, parentParams) { const items = []; const files = fs.readdirSync(dir); for (const basename of files) { const resolved = path.join(dir, basename); const file = slash(path.relative(cwd || rootPath, 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)) { if (invalidPotentialPages.has(ext)) { logger.warn(null, `Unsupported file type ${colors.bold(resolved)} 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); 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 { const component = item.file; const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join("/")}` : null; const route = joinSegments(segments); const trailingSlash = trailingSlashForPath(pathname, settings.config, item.isPage ? "page" : "endpoint", route); const pattern = getPattern(segments, settings.config.base, trailingSlash); routes.push({ route, isIndex: item.isIndex, type: item.isPage ? "page" : "endpoint", pattern, segments, params, component, pathname: pathname || undefined, prerender, fallbackRoutes: [], distURL: [], origin: "project" }); } } } walk(localFs, pagesPath, [], []); return routes; } export function createRoutesFromEntries(entries, settings, logger, pagesDirRelative = "src/pages") { const entriesByDir = groupEntriesByDir(entries); return createRoutesFromEntriesByDir(entriesByDir, settings, logger, pagesDirRelative); } function createRoutesFromEntriesByDir(entriesByDir, settings, logger, pagesDirRelative) { const routes = []; const validPageExtensions = new Set([ ".astro", ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS, ...settings.pageExtensions ]); const invalidPotentialPages = new Set([ ".tsx", ".jsx", ".vue", ".svelte" ]); const validEndpointExtensions = new Set([".js", ".ts"]); const prerender = getPrerenderDefault(settings.config); function walk(dir, parentSegments, parentParams) { const items = []; 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; } if (!isDir && !validPageExtensions.has(ext) && !validEndpointExtensions.has(ext)) { if (invalidPotentialPages.has(ext)) { logger.warn(null, `Unsupported file type ${colors.bold(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); 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, 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(path.posix.join(dir, item.basename), segments, params); } else { const component = item.file; const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join("/")}` : null; const route = joinSegments(segments); const trailingSlash = trailingSlashForPath(pathname, settings.config, item.isPage ? "page" : "endpoint", route); const pattern = getPattern(segments, settings.config.base, trailingSlash); routes.push({ route, isIndex: item.isIndex, type: item.isPage ? "page" : "endpoint", pattern, segments, params, component, pathname: pathname || undefined, prerender, fallbackRoutes: [], distURL: [], origin: "project" }); } } } walk("", [], []); return routes; } function groupEntriesByDir(entries) { const entriesByDir = new Map(); for (const entry of entries) { const normalizedPath = slash(entry.path); const dir = path.posix.dirname(normalizedPath); const key = dir === "." ? "" : dir; const list = entriesByDir.get(key); const normalizedEntry = normalizedPath === entry.path ? entry : { ...entry, path: normalizedPath }; if (list) { list.push(normalizedEntry); } else { entriesByDir.set(key, [normalizedEntry]); } } return entriesByDir; } // Get trailing slash rule for a path, based on the config and whether the path has an extension. // Only endpoints with file extensions (like /feed.xml) should force 'never' for trailing slashes. // Pages with dots in their names (like /hello.world) should respect the user's trailingSlash config. const trailingSlashForPath = (pathname, config, type, route) => type === "endpoint" && hasFileExtension(pathname ?? route ?? "") ? "never" : 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 route = joinSegments(segments); const trailingSlash = trailingSlashForPath(pathname, config, type, route); const pattern = getPattern(segments, settings.config.base, trailingSlash); const params = segments.flat().filter((p) => p.dynamic).map((p) => p.content); routes.push({ type, // For backwards compatibility, an injected route is never considered an index route. isIndex: false, route, pattern, segments, params, component, pathname: pathname || void 0, prerender: prerenderInjected ?? prerender, fallbackRoutes: [], distURL: [], origin }); } return routes; } /** * Create route data for all configured redirects. */ 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 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; } // check if the link starts with http or https; if not, throw an error if (URL.canParse(destination) && !/^https?:\/\//.test(destination)) { throw new AstroError({ ...UnsupportedExternalRedirect, message: UnsupportedExternalRedirect.message(from, destination) }); } const redirectRoute = routeMap.get(destination); // If the source has dynamic params and the redirect will be prerendered, // we need a valid redirectRoute to map them. Without it, the build will fail // later with a misleading error. Catch this early and provide a clear error message. if (params.length > 0 && !redirectRoute && !URL.canParse(destination) && getPrerenderDefault(config)) { throw new AstroError({ ...InvalidRedirectDestination, message: InvalidRedirectDestination.message(from, destination) }); } // If the source has dynamic params and the destination route exists but has // fewer params, the build will fail later with a misleading error (e.g., // "getStaticPaths required" pointing at the wrong file). Catch this early. if (params.length > 0 && redirectRoute && !URL.canParse(destination) && getPrerenderDefault(config)) { const destParams = redirectRoute.params ?? []; const missingParams = params.filter((p) => !destParams.includes(p)); if (missingParams.length > 0) { throw new AstroError({ ...InvalidRedirectDestination, message: InvalidRedirectDestination.message(from, destination, missingParams) }); } } routes.push({ type: "redirect", // For backwards compatibility, a redirect is never considered an index route. isIndex: false, route, pattern, segments, params, component: from, pathname: pathname || void 0, prerender: getPrerenderDefault(config), redirect: to, redirectRoute, fallbackRoutes: [], distURL: [], origin: "project" }); } return routes; } /** * Checks whether a route segment is static. */ function isStaticSegment(segment) { return segment.every((part) => !part.dynamic && !part.spread); } /** * Check whether two are sure to collide in clearly unintended ways report appropriately. * * Fallback routes are never considered to collide with any other route. * Routes that may collide depending on the parameters returned by their `getStaticPaths` * are not reported as collisions at this stage. * * Two routes are guaranteed to collide in the following scenarios: * - Both are the exact same static route. * For example, `/foo` from an injected route and `/foo` from a file in the project. * - Both are non-prerendered dynamic routes with equal static parts in matching positions * and dynamic parts of same type in the same positions. * For example, `/foo/[bar]` and `/foo/[baz]` or `/foo/[...bar]` and `/foo/[...baz]` * but not `/foo/[bar]` and `/foo/[...baz]`. */ function detectRouteCollision(a, b, _config, logger) { if (a.type === "fallback" || b.type === "fallback") { // If either route is a fallback route, they don't collide. // Fallbacks are always added below other routes exactly to avoid collisions. return; } if (a.route === b.route && a.segments.every(isStaticSegment) && b.segments.every(isStaticSegment)) { // If both routes are the same and completely static they are guaranteed to collide // such that one of them will never be matched. 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 a hard error in following versions of Astro."); return; } if (a.prerender || b.prerender) { // If either route is prerendered, it is impossible to know if they collide // at this stage because it depends on the parameters returned by `getStaticPaths`. return; } if (a.segments.length !== b.segments.length) { // If the routes have different number of segments, they cannot perfectly overlap // each other, so a collision is either not guaranteed or may be intentional. return; } // Routes have the same number of segments, can use either. 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)) { // If any segment is not semantically equal between the routes // it is not certain that the routes collide. return; } } // Both routes are guaranteed to collide such that one will never be matched. 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 a hard error in following versions of Astro."); } /** * Create a full route manifest from filesystem and injected routes. */ export async function createRoutesList(params, logger, { dev = false } = {}) { const { settings } = params; const { config } = settings; // Create a map of all routes so redirects can refer to any route const routeMap = 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); // we remove the file based routes that were deemed redirects const filteredFiledBasedRoutes = fileBasedRoutes.filter((fileBasedRoute) => { const isRedirect = redirectRoutes.findIndex((rd) => rd.route === fileBasedRoute.route); return isRedirect < 0; }); const routes = [...[ ...filteredFiledBasedRoutes, ...injectedRoutes, ...redirectRoutes ].sort(routeComparator)]; // Check the prerender option for each route const limit = pLimit(10); let promises = []; for (const route of routes) { promises.push(limit(async () => { if (route.type !== "page" && route.type !== "endpoint" && route.type !== "redirect") return; // External redirects aren't taken into account if (route.type === "redirect" && !route.redirectRoute) return; const localFs = params.fsMod ?? nodeFs; const componentUrl = new URL( // The destination redirect might be a prerendered route.type === "redirect" && route.redirectRoute ? route.redirectRoute.component : route.component, settings.config.root ); const content = await localFs.promises.readFile(componentUrl, "utf-8"); await getRoutePrerenderOption(content, route, settings, logger); })); } await Promise.all(promises); // Report route collisions 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); // First we check if the user doesn't have an index page. 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) }); } } createI18nFallbackRoutes(routes, i18n, config); } if (dev) { // In SSR, a 404 route is injected in the App directly for some special handling, // it must not appear in the manifest ensure404Route({ routes }); } if (dev || settings.buildOutput === "server") { injectImageEndpoint(settings, { routes }, dev ? "dev" : "build"); } // If an adapter is added, we unconditionally inject the server islands route. // Ideally we would only inject the server islands route if server islands are used in the project. // Unfortunately, there is a "circular dependency": to know if server islands are used, we need to run // the build but the build relies on the routes manifest. if (dev || settings.config.adapter) { injectServerIslandRoute(settings.config, { routes }); } await runHookRoutesResolved({ routes, settings, logger }); return { routes }; } /** * Generates i18n fallback routes and attaches them to their source routes. * * For each locale that has a fallback configured (e.g. `{ es: 'en' }`), this * function inspects the existing route list and creates `type: 'fallback'` * entries for any paths that the source locale does not already have. The * fallback routes are pushed onto `route.fallbackRoutes` of their source route * so that the build pipeline can serve the fallback content. * * @param routes The full route list — mutated in-place. * @param i18n The resolved `config.i18n` object. * @param config The resolved Astro config (needs `base` and `trailingSlash`). */ export function createI18nFallbackRoutes(routes, i18n, config) { const strategy = toRoutingStrategy(i18n.routing, i18n.domains); // Group page routes by locale const routesByLocale = new Map(); const setRoutes = new Set(routes.filter((route) => route.type === "page" && !isRoute3xx(route.route))); 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) { const hasLocaleInRoute = route.route.split("/").includes(locale); if (!hasLocaleInRoute) { 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) { const 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 && fallbackFromRoutes.some((route) => { if (fallbackToLocale === i18n.defaultLocale) { return route.route === `/${fallbackFromLocale}${fallbackToRoute.route}` || route.route.replace(`/${fallbackFromLocale}`, "") === fallbackToRoute.route; } else { const expectedRoute = replaceOrKeep(fallbackToRoute.route, fallbackToLocale, fallbackFromLocale); return route.route === expectedRoute; } }); 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 ? replaceOrKeep(fallbackToRoute.pathname, fallbackToLocale, fallbackFromLocale) : undefined; route = replaceOrKeep(fallbackToRoute.route, fallbackToLocale, fallbackFromLocale); } const segments = removeLeadingForwardSlash(route).split(path.posix.sep).filter(Boolean).map((s) => { validateSegment(s); return getParts(s, route); }); const index = routes.findIndex((r) => r === fallbackToRoute); if (index >= 0) { const fallbackRoute = { ...fallbackToRoute, pathname, route, segments, pattern: getPattern(segments, config.base, config.trailingSlash), type: "fallback", fallbackRoutes: [] }; const routeData = routes[index]; routeData.fallbackRoutes.push(fallbackRoute); } } } } } } } /** * Resolve a route entrypoint to an absolute component path. */ export function resolveInjectedRoute(entrypoint, root, cwd) { let resolved; try { resolved = require.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("/")}`; } /** * Swaps the first `from` locale segment of a route for `to`. Routes that already carry the * `to` locale, and routes with no `from` segment, are returned unchanged. Matching is done on * whole segments — the same way routes are grouped by locale above — so a locale code that is * merely a prefix of another segment (`/en/enterprise`) is left alone. */ function replaceOrKeep(original, from, to) { if (original.startsWith(`/${to}/`) || original === `/${to}`) return original; const segments = original.split("/"); const index = segments.indexOf(from); if (index === -1) return original; segments[index] = to; return segments.join("/"); }