mirror of
https://github.com/withastro/astro.git
synced 2024-12-23 21:53:55 -05:00
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 <nate@skypack.dev> Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
This commit is contained in:
parent
48161b77ca
commit
23fceb93ac
6 changed files with 78 additions and 17 deletions
|
@ -710,6 +710,11 @@ export type InjectedScriptStage = 'before-hydration' | 'head-inline' | 'page' |
|
||||||
* Resolved Astro Config
|
* Resolved Astro Config
|
||||||
* Config with user settings along with all defaults filled in.
|
* Config with user settings along with all defaults filled in.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface InjectedRoute {
|
||||||
|
pattern: string,
|
||||||
|
entryPoint: string
|
||||||
|
}
|
||||||
export interface AstroConfig extends z.output<typeof AstroConfigSchema> {
|
export interface AstroConfig extends z.output<typeof AstroConfigSchema> {
|
||||||
// Public:
|
// Public:
|
||||||
// This is a more detailed type than zod validation gives us.
|
// This is a more detailed type than zod validation gives us.
|
||||||
|
@ -721,6 +726,7 @@ export interface AstroConfig extends z.output<typeof AstroConfigSchema> {
|
||||||
// that is different from the user-exposed configuration.
|
// that is different from the user-exposed configuration.
|
||||||
// TODO: Create an AstroConfig class to manage this, long-term.
|
// TODO: Create an AstroConfig class to manage this, long-term.
|
||||||
_ctx: {
|
_ctx: {
|
||||||
|
injectedRoutes: InjectedRoute[],
|
||||||
adapter: AstroAdapter | undefined;
|
adapter: AstroAdapter | undefined;
|
||||||
renderers: AstroRenderer[];
|
renderers: AstroRenderer[];
|
||||||
scripts: { stage: InjectedScriptStage; content: string }[];
|
scripts: { stage: InjectedScriptStage; content: string }[];
|
||||||
|
@ -929,6 +935,7 @@ export interface AstroIntegration {
|
||||||
updateConfig: (newConfig: Record<string, any>) => void;
|
updateConfig: (newConfig: Record<string, any>) => void;
|
||||||
addRenderer: (renderer: AstroRenderer) => void;
|
addRenderer: (renderer: AstroRenderer) => void;
|
||||||
injectScript: (stage: InjectedScriptStage, content: string) => void;
|
injectScript: (stage: InjectedScriptStage, content: string) => void;
|
||||||
|
injectRoute: (injectRoute: InjectedRoute) => void;
|
||||||
// TODO: Add support for `injectElement()` for full HTML element injection, not just scripts.
|
// 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
|
// This may require some refactoring of `scripts`, `styles`, and `links` into something
|
||||||
// more generalized. Consider the SSR use-case as well.
|
// more generalized. Consider the SSR use-case as well.
|
||||||
|
@ -966,6 +973,7 @@ export interface RoutePart {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteData {
|
export interface RouteData {
|
||||||
|
route: string,
|
||||||
component: string;
|
component: string;
|
||||||
generate: (data?: any) => string;
|
generate: (data?: any) => string;
|
||||||
params: string[];
|
params: string[];
|
||||||
|
|
|
@ -55,7 +55,7 @@ class AstroBuilder {
|
||||||
this.origin = config.site
|
this.origin = config.site
|
||||||
? new URL(config.site).origin
|
? new URL(config.site).origin
|
||||||
: `http://localhost:${config.server.port}`;
|
: `http://localhost:${config.server.port}`;
|
||||||
this.manifest = createRouteManifest({ config }, this.logging);
|
this.manifest = {routes: []};
|
||||||
this.timer = {};
|
this.timer = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,6 +66,8 @@ class AstroBuilder {
|
||||||
this.timer.init = performance.now();
|
this.timer.init = performance.now();
|
||||||
this.timer.viteStart = performance.now();
|
this.timer.viteStart = performance.now();
|
||||||
this.config = await runHookConfigSetup({ config: this.config, command: 'build' });
|
this.config = await runHookConfigSetup({ config: this.config, command: 'build' });
|
||||||
|
this.manifest = createRouteManifest({ config: this.config }, this.logging);
|
||||||
|
|
||||||
const viteConfig = await createVite(
|
const viteConfig = await createVite(
|
||||||
{
|
{
|
||||||
mode: this.mode,
|
mode: this.mode,
|
||||||
|
|
|
@ -338,7 +338,7 @@ export async function validateConfig(
|
||||||
// First-Pass Validation
|
// First-Pass Validation
|
||||||
const result = {
|
const result = {
|
||||||
...(await AstroConfigRelativeSchema.parseAsync(userConfig)),
|
...(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)
|
// Final-Pass Validation (perform checks that require the full config object)
|
||||||
if (
|
if (
|
||||||
|
|
|
@ -4,10 +4,12 @@ import type { LogOptions } from '../../logger/core';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import slash from 'slash';
|
import slash from 'slash';
|
||||||
|
import { createRequire } from 'module';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { warn } from '../../logger/core.js';
|
import { warn } from '../../logger/core.js';
|
||||||
import { resolvePages } from '../../util.js';
|
import { resolvePages } from '../../util.js';
|
||||||
import { getRouteGenerator } from './generator.js';
|
import { getRouteGenerator } from './generator.js';
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
basename: string;
|
basename: string;
|
||||||
|
@ -93,6 +95,23 @@ function isSpread(str: string) {
|
||||||
return spreadPattern.test(str);
|
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) {
|
function comparator(a: Item, b: Item) {
|
||||||
if (a.isIndex !== b.isIndex) {
|
if (a.isIndex !== b.isIndex) {
|
||||||
if (a.isIndex) return isSpread(a.file) ? 1 : -1;
|
if (a.isIndex) return isSpread(a.file) ? 1 : -1;
|
||||||
|
@ -168,20 +187,7 @@ export function createRouteManifest(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const segment = isDir ? basename : name;
|
const segment = isDir ? basename : name;
|
||||||
if (/^\$/.test(segment)) {
|
validateSegment(segment, file);
|
||||||
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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = getParts(segment, file);
|
const parts = getParts(segment, file);
|
||||||
const isIndex = isDir ? false : basename.startsWith('index.');
|
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)
|
const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic)
|
||||||
? `/${segments.map((segment) => segment[0].content).join('/')}`
|
? `/${segments.map((segment) => segment[0].content).join('/')}`
|
||||||
: null;
|
: null;
|
||||||
|
const route = `/${segments.map(([{dynamic, content}]) => dynamic ? `[${content}]` : content).join('/')}`.toLowerCase();
|
||||||
|
|
||||||
routes.push({
|
routes.push({
|
||||||
|
route,
|
||||||
type: item.isPage ? 'page' : 'endpoint',
|
type: item.isPage ? 'page' : 'endpoint',
|
||||||
pattern,
|
pattern,
|
||||||
segments,
|
segments,
|
||||||
|
@ -265,12 +273,51 @@ export function createRouteManifest(
|
||||||
|
|
||||||
if (fs.existsSync(pages)) {
|
if (fs.existsSync(pages)) {
|
||||||
walk(fileURLToPath(pages), [], []);
|
walk(fileURLToPath(pages), [], []);
|
||||||
} else {
|
} else if (config?._ctx?.injectedRoutes?.length === 0) {
|
||||||
const pagesDirRootRelative = pages.href.slice(config.root.href.length);
|
const pagesDirRootRelative = pages.href.slice(config.root.href.length);
|
||||||
|
|
||||||
warn(logging, 'astro', `Missing pages directory: ${pagesDirRootRelative}`);
|
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 {
|
return {
|
||||||
routes,
|
routes,
|
||||||
};
|
};
|
||||||
|
|
|
@ -16,6 +16,7 @@ export function serializeRouteData(
|
||||||
|
|
||||||
export function deserializeRouteData(rawRouteData: SerializedRouteData): RouteData {
|
export function deserializeRouteData(rawRouteData: SerializedRouteData): RouteData {
|
||||||
return {
|
return {
|
||||||
|
route: rawRouteData.route,
|
||||||
type: rawRouteData.type,
|
type: rawRouteData.type,
|
||||||
pattern: new RegExp(rawRouteData.pattern),
|
pattern: new RegExp(rawRouteData.pattern),
|
||||||
params: rawRouteData.params,
|
params: rawRouteData.params,
|
||||||
|
|
|
@ -46,6 +46,9 @@ export async function runHookConfigSetup({
|
||||||
updateConfig: (newConfig) => {
|
updateConfig: (newConfig) => {
|
||||||
updatedConfig = mergeConfig(updatedConfig, newConfig) as AstroConfig;
|
updatedConfig = mergeConfig(updatedConfig, newConfig) as AstroConfig;
|
||||||
},
|
},
|
||||||
|
injectRoute: (injectRoute) => {
|
||||||
|
updatedConfig._ctx.injectedRoutes.push(injectRoute);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue