From 23fceb93ac14a34b3fabc3107922428e7de4b627 Mon Sep 17 00:00:00 2001 From: Pascal Schilp Date: Tue, 14 Jun 2022 22:21:23 +0200 Subject: [PATCH] feat: implement injectRoute (#3457) * feat: implement injectRoute * chore: make ts happy * feat: add route collision detection and error message * fix: case sensitivity in route collision detection * chore: ts * fix: improve route collision logic * chore: make ts happy * chore: update error message * refactor: lowercase route * fix: inject routes when no pages * Update packages/astro/src/integrations/index.ts Co-authored-by: Nate Moore Co-authored-by: Nate Moore --- packages/astro/src/@types/astro.ts | 8 ++ packages/astro/src/core/build/index.ts | 4 +- packages/astro/src/core/config.ts | 2 +- .../astro/src/core/routing/manifest/create.ts | 77 +++++++++++++++---- .../core/routing/manifest/serialization.ts | 1 + packages/astro/src/integrations/index.ts | 3 + 6 files changed, 78 insertions(+), 17 deletions(-) diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 7dd341d6ff..bc4f05a890 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -710,6 +710,11 @@ export type InjectedScriptStage = 'before-hydration' | 'head-inline' | 'page' | * Resolved Astro Config * Config with user settings along with all defaults filled in. */ + +export interface InjectedRoute { + pattern: string, + entryPoint: string +} export interface AstroConfig extends z.output { // Public: // This is a more detailed type than zod validation gives us. @@ -721,6 +726,7 @@ export interface AstroConfig extends z.output { // that is different from the user-exposed configuration. // TODO: Create an AstroConfig class to manage this, long-term. _ctx: { + injectedRoutes: InjectedRoute[], adapter: AstroAdapter | undefined; renderers: AstroRenderer[]; scripts: { stage: InjectedScriptStage; content: string }[]; @@ -929,6 +935,7 @@ export interface AstroIntegration { updateConfig: (newConfig: Record) => void; addRenderer: (renderer: AstroRenderer) => void; injectScript: (stage: InjectedScriptStage, content: string) => void; + injectRoute: (injectRoute: InjectedRoute) => void; // TODO: Add support for `injectElement()` for full HTML element injection, not just scripts. // This may require some refactoring of `scripts`, `styles`, and `links` into something // more generalized. Consider the SSR use-case as well. @@ -966,6 +973,7 @@ export interface RoutePart { } export interface RouteData { + route: string, component: string; generate: (data?: any) => string; params: string[]; diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index 357255f0ce..4f1e1c6438 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -55,7 +55,7 @@ class AstroBuilder { this.origin = config.site ? new URL(config.site).origin : `http://localhost:${config.server.port}`; - this.manifest = createRouteManifest({ config }, this.logging); + this.manifest = {routes: []}; this.timer = {}; } @@ -66,6 +66,8 @@ class AstroBuilder { this.timer.init = performance.now(); this.timer.viteStart = performance.now(); this.config = await runHookConfigSetup({ config: this.config, command: 'build' }); + this.manifest = createRouteManifest({ config: this.config }, this.logging); + const viteConfig = await createVite( { mode: this.mode, diff --git a/packages/astro/src/core/config.ts b/packages/astro/src/core/config.ts index 669e0bcd2c..5ef15a1af4 100644 --- a/packages/astro/src/core/config.ts +++ b/packages/astro/src/core/config.ts @@ -338,7 +338,7 @@ export async function validateConfig( // First-Pass Validation const result = { ...(await AstroConfigRelativeSchema.parseAsync(userConfig)), - _ctx: { scripts: [], renderers: [], adapter: undefined }, + _ctx: { scripts: [], renderers: [], injectedRoutes: [], adapter: undefined }, }; // Final-Pass Validation (perform checks that require the full config object) if ( diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index 4958e9abda..b68b690376 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -4,10 +4,12 @@ import type { LogOptions } from '../../logger/core'; import fs from 'fs'; import path from 'path'; import slash from 'slash'; +import { createRequire } from 'module'; import { fileURLToPath } from 'url'; import { warn } from '../../logger/core.js'; import { resolvePages } from '../../util.js'; import { getRouteGenerator } from './generator.js'; +const require = createRequire(import.meta.url); interface Item { basename: string; @@ -93,6 +95,23 @@ function isSpread(str: string) { return spreadPattern.test(str); } +function validateSegment(segment: string, file = '') { + if(!file) file = segment; + + if (/^\$/.test(segment)) { + throw new Error(`Invalid route ${file} \u2014 Astro's Collections API has been replaced by dynamic route params.`); + } + 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)) { + throw new Error(`Invalid route ${file} \u2014 rest parameter must be a standalone segment`); + } +} + function comparator(a: Item, b: Item) { if (a.isIndex !== b.isIndex) { if (a.isIndex) return isSpread(a.file) ? 1 : -1; @@ -168,20 +187,7 @@ export function createRouteManifest( return; } const segment = isDir ? basename : name; - if (/^\$/.test(segment)) { - throw new Error( - `Invalid route ${file} — Astro's Collections API has been replaced by dynamic route params.` - ); - } - if (/\]\[/.test(segment)) { - throw new Error(`Invalid route ${file} — parameters must be separated`); - } - if (countOccurrences('[', segment) !== countOccurrences(']', segment)) { - throw new Error(`Invalid route ${file} — brackets are unbalanced`); - } - if (/.+\[\.\.\.[^\]]+\]/.test(segment) || /\[\.\.\.[^\]]+\].+/.test(segment)) { - throw new Error(`Invalid route ${file} — rest parameter must be a standalone segment`); - } + validateSegment(segment, file); const parts = getParts(segment, file); const isIndex = isDir ? false : basename.startsWith('index.'); @@ -247,8 +253,10 @@ export function createRouteManifest( 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, type: item.isPage ? 'page' : 'endpoint', pattern, segments, @@ -265,12 +273,51 @@ export function createRouteManifest( if (fs.existsSync(pages)) { walk(fileURLToPath(pages), [], []); - } else { + } else if (config?._ctx?.injectedRoutes?.length === 0) { const pagesDirRootRelative = pages.href.slice(config.root.href.length); warn(logging, 'astro', `Missing pages directory: ${pagesDirRootRelative}`); } + config?._ctx?.injectedRoutes?.forEach(({pattern: name, entryPoint}) => { + const resolved = require.resolve(entryPoint, { paths: [cwd || fileURLToPath(config.root)] }); + const component = slash(path.relative(cwd || fileURLToPath(config.root), resolved)); + + const isDynamic = (str: string) => str?.[0] === '['; + const normalize = (str: string) => str?.substring(1, str?.length - 1); + + const segments = name.split(path.sep) + .filter(Boolean) + .map((s: string) => { + validateSegment(s); + + const dynamic = isDynamic(s); + const content = dynamic ? normalize(s) : s; + return [{ + content, + dynamic, + spread: isSpread(s) + }] + }); + + const type = resolved.endsWith('.astro') ? 'page' : 'endpoint'; + const isPage = type === 'page'; + const trailingSlash = isPage ? config.trailingSlash : "never"; + + const pattern = getPattern(segments, 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(); + + const collision = routes.find(({route: r}) => r === route); + if(collision) { + throw new Error(`An integration attempted to inject a route that is already used in your project: "${route}" at "${component}". \nThis route collides with: "${collision.component}".`); + } + + routes.push({type, route, pattern, segments, params, component, generate, pathname: pathname || void 0}) + }); + return { routes, }; diff --git a/packages/astro/src/core/routing/manifest/serialization.ts b/packages/astro/src/core/routing/manifest/serialization.ts index 5db04b49de..866c0d7bbc 100644 --- a/packages/astro/src/core/routing/manifest/serialization.ts +++ b/packages/astro/src/core/routing/manifest/serialization.ts @@ -16,6 +16,7 @@ export function serializeRouteData( export function deserializeRouteData(rawRouteData: SerializedRouteData): RouteData { return { + route: rawRouteData.route, type: rawRouteData.type, pattern: new RegExp(rawRouteData.pattern), params: rawRouteData.params, diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index c119eb1336..a1ec05f6a8 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -46,6 +46,9 @@ export async function runHookConfigSetup({ updateConfig: (newConfig) => { updatedConfig = mergeConfig(updatedConfig, newConfig) as AstroConfig; }, + injectRoute: (injectRoute) => { + updatedConfig._ctx.injectedRoutes.push(injectRoute); + }, }); } }