Skip to content

Instantly share code, notes, and snippets.

@Radiergummi
Last active October 30, 2024 15:40
Show Gist options
  • Save Radiergummi/198ae14450a947a45b7cadaeab6ccc61 to your computer and use it in GitHub Desktop.
Save Radiergummi/198ae14450a947a45b7cadaeab6ccc61 to your computer and use it in GitHub Desktop.
Laravel Route replacement in Frontend code at build-time
import { type RouteParameters, type Routes } from '$api/routes.js';
/**
* Resolve a route without parameters
*
* Takes the name of a route as defined in the Laravel application and returns
* the URI of the endpoint.
*
* @param name Route name
*/
declare function route<Name extends RoutesWithoutParameters>(name: Name): Routes[Name];
/**
* Resolves a route name to a URI and replaces any bound parameters
*
* Takes the name of a route as defined in the Laravel application and a set of
* parameters to replace, and returns the URI of the endpoint.
* As routes are statically analyzed, the function will request the proper type
* and names of the bound parameters.
*
* @param name Route name
* @param bindings Array of bound parameters
*/
declare function route<
Name extends RoutesWithParameters,
Bindings extends RouteParameters[Name],
A extends Bindings extends Extract<Bindings, [unknown, ...unknown[]]> ? Bindings : never,
>(name: Name, bindings: A): Routes[Name];
/**
* Resolves a route name to a URI and replaces any named bound parameters
*
* Takes the name of a route as defined in the Laravel application and a set of
* parameters to replace, and returns the URI of the endpoint.
* As routes are statically analyzed, the function will request the proper type
* and names of the bound parameters.
* Requires the names of the keys to match the names of the parameters, giving
* additional type safety.
*
* @param name Route name
* @param namedBindings Bound named route parameters
*/
declare function route<
Name extends RoutesWithParameters,
Bindings extends RouteParameters[Name],
O extends Bindings extends Extract<Bindings, Record<string, unknown>> ? Bindings : never,
>(name: Name, namedBindings: O): Routes[Name];
/**
* Resolves a route name to a URI and replaces any bound parameters
*
* Takes the name of a route as defined in the Laravel application and a set of
* parameters to replace, and returns the URI of the endpoint.
* As routes are statically analyzed, the function will request the proper type
* and names of the bound parameters.
* You can pass additional parameters by adding them as spread arguments.
*
* @param name Route name
* @param scalarBinding First bound route parameter
*/
declare function route<
Name extends RoutesWithParameters,
Bindings extends RouteParameters[Name],
B1 extends Bindings extends [infer A] ? A : never,
>(name: Name, scalarBinding: B1): Routes[Name];
/**
* Resolves a route name to a URI and replaces any bound parameters
*
* Takes the name of a route as defined in the Laravel application and a set of
* parameters to replace, and returns the URI of the endpoint.
* As routes are statically analyzed, the function will request the proper type
* and names of the bound parameters.
* You can pass additional parameters by adding them as spread arguments.
*
* @param name Route name
* @param firstBinding First bound route parameter
* @param secondBinding Second bound route parameter
*/
declare function route<
Name extends RoutesWithParameters,
Bindings extends RouteParameters[Name],
B1 extends Bindings extends [infer A, infer _B] ? A : never,
B2 extends Bindings extends [infer _A, infer B] ? B : never,
>(name: Name, firstBinding: B1, secondBinding: B2): Routes[Name];
/**
* Resolves a route name to a URI and replaces any bound parameters
*
* Takes the name of a route as defined in the Laravel application and a set of
* parameters to replace, and returns the URI of the endpoint.
* As routes are statically analyzed, the function will request the proper type
* and names of the bound parameters.
* You can pass additional parameters by adding them as spread arguments.
*
* @param name Route name
* @param firstBinding First bound route parameter
* @param secondBinding Second bound route parameter
* @param thirdBinding Third bound route parameter
*/
declare function route<
Name extends RoutesWithParameters,
Bindings extends RouteParameters[Name],
B1 extends Bindings extends [infer A, infer _B, infer _C] ? A : never,
B2 extends Bindings extends [infer _A, infer B, infer _C] ? B : never,
B3 extends Bindings extends [infer _A, infer _B, infer C] ? C : never,
>(name: Name, firstBinding: B1, secondBinding: B2, thirdBinding: B3): Routes[Name];
/**
* Resolves a route name to a URI and replaces any bound parameters
*
* Takes the name of a route as defined in the Laravel application and a set of
* parameters to replace, and returns the URI of the endpoint.
* As routes are statically analyzed, the function will request the proper type
* and names of the bound parameters.
* You can pass additional parameters by adding them as spread arguments.
*
* @param name Route name
* @param firstBinding First bound route parameter
* @param secondBinding Second bound route parameter
* @param thirdBinding Third bound route parameter
* @param fourthBinding Fourth bound route parameter
*/
declare function route<
Name extends RoutesWithParameters,
Bindings extends RouteParameters[Name],
B1 extends Bindings extends [infer A, infer _B, infer _C, infer _D] ? A : never,
B2 extends Bindings extends [infer _A, infer B, infer _C, infer _D] ? B : never,
B3 extends Bindings extends [infer _A, infer _B, infer C, infer _D] ? C : never,
B4 extends Bindings extends [infer _A, infer _B, infer _C, infer D] ? D : never,
>(
name: Name,
firstBinding: B1,
secondBinding: B2,
thirdBinding: B3,
fourthBinding: B4,
): Routes[Name];
/**
* Resolves a route name to a URI and replaces any bound parameters
*
* Takes the name of a route as defined in the Laravel application and a set of
* parameters to replace, and returns the URI of the endpoint.
* As routes are statically analyzed, the function will request the proper type
* and names of the bound parameters.
*
* You can pass the parameters one after another, as an array, or even an object
* with named keys.
*/
declare function route<
Name extends keyof Routes & keyof RouteParameters,
Bindings extends RouteParameters[Name],
F extends Bindings extends unknown[] ? Bindings[0] : never,
R extends Bindings extends [infer _A, ...infer B] ? B : never,
>(name: Name & string, bindings?: Bindings | F, ...rest: (never | R)[]);
type FilteredKeys<T, V> = {
[K in keyof T]: T[K] extends V ? K : never;
}[keyof T];
type RoutesWithoutParameters = keyof Routes & FilteredKeys<RouteParameters, never[]>;
type RoutesWithParameters = keyof Routes & Exclude<keyof RouteParameters, RoutesWithoutParameters>;
// region Type soundness tests
// // region No allowed parameters
// route( 'api.v0.projects' );
// // endregion
//
// // region Missing parameters
// // @ts-expect-error -- should require parameters
// route( 'api.v0.conversations.messages.delete' );
// // @ts-expect-error -- should require parameters
// route( 'api.v0.conversations.messages.delete', [] );
// // @ts-expect-error -- should require parameters
// route( 'api.v0.conversations.messages.delete', {} );
// // endregion
//
// // region Too few parameters
// // @ts-expect-error -- should require two parameters
// route( 'api.v0.conversations.messages.delete', 'first' );
// // @ts-expect-error -- should require two parameters
// route( 'api.v0.conversations.messages.delete', [ 'first' ] );
// // @ts-expect-error -- should require two parameters
// route( 'api.v0.conversations.messages.delete', { conversation: 'first' } );
// // endregion
//
// // region Correct calls
// export const _a = route( 'api.v0.conversations.messages.delete', 'first', 'second' );
// route( 'api.v0.conversations.messages.delete', [ 'first', 'second' ] );
// route( 'api.v0.conversations.messages.delete', { conversation: 'first', message: 'second' } );
// // endregion
//
// // region Bad types
// // @ts-expect-error -- should require first parameter to be a string
// route( 'api.v0.conversations.messages.delete', 42, 'second', 'third' );
// // @ts-expect-error -- should require first parameter to be a string
// route( 'api.v0.conversations.messages.delete', [ 42, 'second' ] );
// // @ts-expect-error -- should require first parameter to be a string
// route( 'api.v0.conversations.messages.delete', { conversation: 42, message: 'second' } );
// // endregion
//
// // region Surplus parameters
// // @ts-expect-error -- should not accept additional parameters
// route( 'api.v0.conversations.messages.delete', 'first', 'second', 'third' );
// // @ts-expect-error -- should not accept additional parameters
// route( 'api.v0.conversations.messages.delete', [ 'first', 'second', 'third' ] );
// // Should tolerate surplus object keys
// route( 'api.v0.conversations.messages.delete', { conversation: 'first', message: 'second', surplus: 'third' } );
// // endregion
//
// // region Object keys
// // @ts-expect-error -- should require object keys to match
// route( 'api.v0.conversations.messages.delete', { conversation: 'first', meffage: 'second' } );
// // endregion
// endregion
import _generate from '@babel/generator';
import * as parser from '@babel/parser';
import _traverse from '@babel/traverse';
import * as t from '@babel/types';
import { parse as vueParse } from '@vue/compiler-sfc';
import MagicString from 'magic-string';
import { createHash } from 'node:crypto';
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path';
import type { Logger, Plugin, ResolvedConfig, UserConfig } from 'vite';
// This is necessary unfortunately due to the export structure of these modules
// @ts-expect-error
const traverse = _traverse.default as typeof _traverse;
// @ts-expect-error
const generate = _generate.default as typeof _generate;
export function typeGeneration( options?: PluginOptions ) {
let routesFilePath: string | undefined = undefined;
let entryPoints: ( readonly [ string, string ] )[] = [];
let routeUpdater: RouteUpdater | undefined = undefined;
let dotEnvUpdater: DotEnvUpdater | undefined = undefined;
let tsConfigUpdater: TypescriptConfigUpdater | undefined = undefined;
async function updateStubs( updaters: ConfigUpdater[] ) {
await Promise.all( updaters.map( ( updater ) => updater.load() ) );
}
return {
name: 'vite-plugin-type-generation',
enforce: 'pre',
async config( config ) {
const applicationEntryPoints = await resolveDefaultEntryPoints(
config,
options?.applicationsPath ??
resolve( config.root ?? process.cwd(), 'resources', 'apps' ),
);
entryPoints = [
[ '$api', join( dirname( __dirname ), 'generated', 'api' ) ],
...applicationEntryPoints,
];
await addDefaultEntryPoints( config, entryPoints );
},
async configResolved( config ) {
const root = config.root ?? process.cwd();
routesFilePath =
options?.routesFilePath ?? resolve( root, 'storage', 'routes.generated.json' );
tsConfigUpdater = new TypescriptConfigUpdater(
config,
{
compilerOptions: options?.compilerOptions,
entryPoints,
environmentTypeFile: options?.envStubFile ?? 'env.d.ts',
outputPath: join( dirname( __dirname ), 'generated' ),
tsConfigName: options?.tsConfigFile,
},
config.logger,
);
routeUpdater = new RouteUpdater(
routesFilePath,
{
generateMetadataStub: options?.generateMetadataStub,
metadataStubName: options?.metadataStubFile,
mode: config.mode,
outputPath: join( dirname( __dirname ), 'generated' ),
typeStubName: options?.routeStubFile,
},
config.logger,
);
await updateStubs( [ routeUpdater, tsConfigUpdater ] );
},
async configureServer( server ) {
// The DotEnv updater is only required in development, so we can create it in the server
// phase only
dotEnvUpdater = new DotEnvUpdater(
() => server.config.env,
{
outputPath: join( dirname( __dirname ), 'generated' ),
typeStubName: options?.envStubFile,
},
server.config.logger,
);
await updateStubs( [ dotEnvUpdater ] );
// Watch the server-side route file, so we can hot-reload any routing dependencies
server.watcher.add( routesFilePath );
},
async watchChange( id, change ) {
// If the route file changes, update the stubs. This way, Vite will automatically
// recompile all modules that depend on routing information, thus incorporating any
// changes in the server-side routing immediately.
if ( id === routesFilePath ) {
await updateStubs( [ routeUpdater ] );
}
},
async buildStart( options ) {
// Right when the build starts, update the stubs once: This way, they will be available
// for type checking.
await updateStubs( [ routeUpdater ] );
},
async handleHotUpdate( { file, modules, server } ) {
if ( file === routesFilePath ) {
const [ mod ] = modules;
// Invalidate all modules that use `import.meta.route`
server.moduleGraph.invalidateModule( mod );
}
},
async transform( code, id ) {
// Exclude libraries and non-code files
if ( id.includes( 'node_modules' ) || !/\.(js|ts|jsx|tsx|mjs|mts|vue)$/.test( id ) ) {
return;
}
// Using magic string, we can keep track of the changes automatically, so the source
// maps in the final build output will reference the correct original code.
const s = new MagicString( code );
// Since we need to access the transformation context within the transformer callback,
// we bind the context here-
const transformer = replaceRoutes.bind( this );
// If we're looking at a Vue Single-File Component file, we extract the actual JS/TS
// source code from it, to avoid parsing errors.
if ( id.endsWith( '.vue' ) ) {
const { descriptor } = vueParse( code );
const script = descriptor.script || descriptor.scriptSetup;
// If this Vue file doesn't even have a script block, there's nothing to do and we
// can quit early.
if ( !script ) {
return;
}
// We only want to transform the script content itself, not the whole SFC file, so
// we create a separate magic string for the script content only.
const sVue = new MagicString( script.content );
transformer( script.content, sVue, routeUpdater );
// If the transformer changed the script-scoped magic string, we overwrite only that
// section in the file-scoped magic string—basically, we "carry over" the changes to
// the full file's content after the replacement is done.
if ( sVue.hasChanged() ) {
const start = script.loc.start.offset;
const end = script.loc.end.offset;
s.overwrite( start, end, sVue.toString() );
}
} else {
// Back to the original Vue condition, we're processing an ordinary module file, so
// we can just transform the file content in total.
transformer( code, s, routeUpdater );
}
// If the file-scoped magic string has changed (this includes any changes from the Vue
// overwrite above or not), we emit a change to the Rollup process.
if ( s.hasChanged() ) {
return {
code: s.toString(),
map: s.generateMap( { hires: true } ),
};
}
},
} satisfies Plugin;
}
/**
* Replaces import.meta.route() calls in code, preserving the modifications in a MagicString
* instance to keep source maps intact
*
* @param code
* @param s
* @param routeUpdater
*/
function replaceRoutes( code: string, s: MagicString, routeUpdater: RouteUpdater ) {
// Load the routing map before we start transforming, so we don't perform unnecessary
// work in initializing it
const routes = routeUpdater?.routes;
// Parse the source code into an abstract syntax tree that we can traverse further
const ast = parser.parse( code, {
sourceType: 'module',
plugins: [ 'typescript' ],
} );
// Store a reference to the transform context, so we can access its API inside the
// AST traversal below
const context = this;
traverse( ast, {
CallExpression( path ) {
const { node } = path;
// Check if it's an `import.meta.route()` call
if (
!(
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'MetaProperty' &&
node.callee.object.meta.name === 'import' &&
( node.callee.property as t.Identifier ).name === 'route'
)
) {
return;
}
// This module contains an import.meta.route() call, so we can add it to the
// module dependencies of the route file. This way, if the route file changes
// while hot module reload is enabled (during development), a change in the
// route file will also invalidate this module.
context.addWatchFile( routeUpdater.file );
const firstArgument = node.arguments.at( 0 );
if ( !firstArgument ) {
throw new Error( 'import.meta.route() must be invoked with a route name to resolve' );
}
if ( !t.isStringLiteral( firstArgument ) ) {
throw new Error( 'Invalid import.meta.route() invocation: Not a route name' );
}
const routeName = firstArgument.value;
if ( !routeName || !routes.has( routeName ) ) {
throw new Error(
`Invalid import.meta.route() invocation: ${routeName} does not resolve`,
);
}
const routeInfo = routes.get( routeName );
let uriTemplate = routeInfo.uri;
const templateElements = [];
const expressions = [];
let lastIndex = 0;
const paramsNode = node.arguments[ 1 ];
if (
t.isArrayExpression( paramsNode ) ||
( node.arguments.length > 1 && !t.isObjectExpression( paramsNode ) )
) {
// Handle ordered replacement (array or variadic arguments):
// -> import.meta.route('foo', first, second, third)
// -> import.meta.route('foo', [first, second, third])
// Wrap the parameter list: Take the second parameter if it's an array,
// or slice the arguments after the route name.
let params = t.isArrayExpression( paramsNode )
? paramsNode.elements
: node.arguments.slice( 1 );
for ( const param of routeInfo.parameters ) {
const index = routeInfo.parameters.indexOf( param );
const paramName = param.name;
const placeholder = `{${paramName}}`;
const placeholderIndex = uriTemplate.indexOf( placeholder );
if ( placeholderIndex === -1 ) {
continue;
}
const node = params[ index ];
if ( t.isLiteral( node ) ) {
uriTemplate = uriTemplate.replace(
placeholder,
( node as t.StringLiteral ).value,
);
continue;
}
const prefix = uriTemplate.slice( lastIndex, placeholderIndex );
if ( prefix ) {
templateElements.push(
t.templateElement(
{
raw: prefix,
cooked: prefix,
},
false,
),
);
}
expressions.push( node );
lastIndex = placeholderIndex + placeholder.length;
}
} else if ( t.isObjectExpression( paramsNode ) ) {
// Handle named replacement (single dictionary argument):
// -> import.meta.route('foo', { third, first, second })
// Reduce the parameter argument into an actual map of parameter names to
// bound values. Note that the value may be a literal or an expression of
// arbitrary complexity, but will always be a Node instance.
const boundParameters = paramsNode.properties.reduce<Record<string, t.Node>>(
( props, prop ) => {
// Make sure the key is a normal object property, and a string or
// number, nothing fancy:
//
// import.meta.route('foo', { test: someVar })
// import.meta.route('foo', { 123: 'bar' })
// import.meta.route('foo', { ['test']: 'bar' })
//
// Since we have to compile the route call statically into a plain
// string, we cannot access any runtime code and thus cannot process
// more complex keys. Specifically, the following is NOT possible:
//
// import.meta.route('foo', { [someFunction()]: 'bar' })
//
// Since that would require calling `someFunction` at compile time,
// and that's just not possible.
if (
!t.isObjectProperty( prop ) ||
( !t.isStringLiteral( prop.key ) &&
!t.isNumericLiteral( prop.key ) &&
!t.isIdentifier( prop.key ) )
) {
throw new Error(
`Invalid import.meta.route() invocation: ` +
'When using named parameters, it must be literal names, ' +
'no binary expressions or function calls.',
);
}
const key = t.isIdentifier( prop.key ) ? prop.key.name : prop.key.value;
return {
...props,
[ key ]: prop.value,
};
},
{},
);
// Before we iterate over the route's inherent parameters, we perform a
// separate, optimistic pass on the bound parameters to replace any eventual
// literal parameters.
// This ensures we don't end up with imbalanced template strings, since we
// can directly inline literals into the output string.
for ( const [ key, node ] of Object.entries( boundParameters ) ) {
if ( t.isLiteral( node ) ) {
uriTemplate = uriTemplate.replace(
`{${key}}`,
( node as t.StringLiteral ).value,
);
}
}
// Now that literals have already been replaced in the URI template, we can
// iterate over the other parameters and replace them with dynamic
// expressions in the output template string.
for ( const param of routeInfo.parameters ) {
const key = param.name;
const placeholder = `{${key}}`;
const placeholderIndex = uriTemplate.indexOf( placeholder );
// If there is no matching placeholder in the string, that means we have
// replaced the parameter with a literal value already, and can skip it.
if ( placeholderIndex === -1 ) {
continue;
}
// On the other hand, if the parameter isn't bound, it has erroneously
// been omitted in the source code, and we're going to bail: This is
// most probably a bug.
if ( !( key in boundParameters ) ) {
throw new Error(
`Invalid route: ${routeName} is missing a required parameter: '${key}'`,
);
}
// Take the plain string between the last placeholder and the current
// one, so we can append that as a template part to the full template
// string, that is:
// "/api/v0/conversations/{uuid1}/messages/{uuid2}"
// ^--------------------^ ^--------^
// | |
// The two parts highlighted above will be added separately as template
// elements to the string.
const prefix = uriTemplate.slice( lastIndex, placeholderIndex );
templateElements.push(
t.templateElement(
{
raw: prefix,
cooked: prefix,
},
false,
),
);
// Add the actually bound expression to the output string (the code in
// the curly braces). This way, we can even pass variables to the route
// call, and the template string will just include the variables (or any
// other arbitrarily complex expression).
expressions.push( boundParameters[ key ] );
lastIndex = placeholderIndex + placeholder.length;
}
}
const remainingStaticPart = uriTemplate.slice( lastIndex );
templateElements.push(
t.templateElement(
{
raw: remainingStaticPart,
cooked: remainingStaticPart,
},
true,
),
);
const templateLiteralNode = t.templateLiteral( templateElements, expressions );
path.replaceWith( templateLiteralNode );
const { code: newCode } = generate( ast, {}, code );
s.overwrite( 0, code.length, newCode );
},
} );
return s;
}
async function resolveDefaultEntryPoints( _config: UserConfig, applicationsPath: string ) {
const entries = await readdir( applicationsPath, { withFileTypes: true } );
return entries
.filter( ( entry ) => entry.isDirectory() )
.map( ( entry ) => [ `@${entry.name}`, resolve( entry.parentPath, entry.name ) ] as const );
}
async function addDefaultEntryPoints(
config: UserConfig,
entryPoints: ( readonly [ string, string ] )[],
) {
config.resolve.alias = config.resolve.alias ?? [];
const existingAliases = Array.isArray( config.resolve.alias )
? config.resolve.alias
: [ config.resolve.alias ];
config.resolve.alias = [
...entryPoints.map( ( [ entryPoint, path ] ) => ( {
find: entryPoint,
replacement: path,
} ) ),
...existingAliases,
];
}
abstract class ConfigUpdater<T = unknown> {
protected preamble = `// This file has been automatically generated.
// Do NOT modify it manually, as any changes will be overwritten.`;
readonly #outputPath: string;
readonly #logger: Logger;
/**
* Checksum of the configuration source to use for caching
*/
#checksum: Buffer | undefined = undefined;
protected constructor( outputPath: string, logger: Logger ) {
this.#outputPath = outputPath;
this.#logger = logger;
}
protected get checksum(): Buffer | undefined {
return this.#checksum;
}
protected set checksum( checksum: Buffer ) {
this.#checksum = checksum;
}
protected get logger() {
return this.#logger;
}
abstract load(): Promise<T>;
protected async writeGeneratedFile( name: string, content: string ) {
const path = join( this.#outputPath, name );
this.#logger.info( `[vite-plugin-type-generation] Writing generated file ${path}…`, {
timestamp: true,
} );
await mkdir( dirname( path ), { recursive: true } );
await writeFile( path, `${this.preamble}\n\n${content}`, {
encoding: 'utf-8',
flag: 'w',
mode: 0o644,
} );
}
}
type RouteUpdaterOptions = {
typeStubName?: string;
metadataStubName?: string;
outputPath: string;
mode: string;
generateMetadataStub?: boolean;
};
class RouteUpdater extends ConfigUpdater<LaravelRoutes> {
readonly #routesFile: string;
readonly #options: RouteUpdaterOptions;
#routes: LaravelRoutes | undefined;
public constructor( routesFile: string, options: RouteUpdaterOptions, logger: Logger ) {
super( options.outputPath, logger );
this.#routesFile = routesFile;
this.#options = options;
}
public get file() {
return this.#routesFile;
}
public get routes() {
return this.#routes;
}
public async load() {
const [ content, checksum ] = await this.#readFile();
if ( this.checksum?.equals( checksum ) ) {
return;
}
this.#routes = this.#parseRoutes( content );
await Promise.all( [ this.#updateTypeStub(), this.#updateMetadataStub() ] );
this.checksum = checksum;
return this.#routes;
}
#updateTypeStub() {
const stub = this.#generateTypeStub();
return this.writeGeneratedFile(
join( 'api', this.#options.typeStubName ?? 'routes.d.ts' ),
stub,
);
}
#updateMetadataStub() {
if ( this.#options.mode !== 'development' || !this.#options.generateMetadataStub ) {
return Promise.resolve();
}
const stub = this.#generateMetadataStub();
return this.writeGeneratedFile( this.#options.metadataStubName ?? 'metadata.ts', stub );
}
#generateTypeStub() {
const routeParameters =
this.#routes
.entries()
.map( ( [ name, { parameters, description } ] ) => {
if ( parameters.length === 0 ) {
return [ name, 'never[]', description ];
}
const types = parameters.map( ( { name, types } ) => [ name, types.join( ' | ' ) ] );
const ordered = types.map( ( [ , types ] ) => types ).join( ', ' );
const named = types.map( ( [ name, type ] ) => `${name}: ${type}` ).join( ', ' );
return [ name, `[ ${ordered} ] | { ${named} }`, description ];
} )
.map(
( [ name, type, description ] ) =>
( description ? '\n' + this.#renderDocBlock( description ) : '' ) +
`\nreadonly '${name}': ${type};`,
)
.flatMap( ( line ) => line.split( '\n' ) )
.map( ( line ) => ` ${line}` )
.reduce(
( typeStub, line ) => typeStub + line + '\n',
'export interface RouteParameters\n{',
) + '}';
const routeUris =
this.#routes
.entries()
.map(
( [ name, route ] ) =>
( route.description ? '\n' + this.#renderDocBlock( route.description ) : '' ) +
`\nreadonly '${name}': ${this.#routeToTemplateType( route )},`,
)
.flatMap( ( line ) => line.split( '\n' ) )
.map( ( line ) => ` ${line}` )
.reduce( ( typeStub, line ) => typeStub + line + '\n', 'export interface Routes\n{' ) +
'}';
const routeDictionary =
this.#routes
.entries()
.map( ( [ name, { uri } ] ) => `'${name}': '${uri}',` )
.flatMap( ( line ) => line.split( '\n' ) )
.map( ( line ) => ` ${line}` )
.reduce(
( typeStub, line ) => typeStub + line + '\n',
'export const routes: Routes = {\n',
) + '};';
return [ routeUris, routeParameters, routeDictionary ].join( '\n\n' );
}
#generateMetadataStub() {
const types = [
'type RouteMeta = {',
' uri: string;',
' name: string;',
' methods: string[];',
' domain: string | null;',
' description: string | null;',
' handler: string;',
' middleware: string[];',
' parameters: {',
' name: string;',
' optional: boolean;',
' constraint: string | null;',
' types: string[];',
' propertyBinding: string | null;',
' }[];',
'}',
].join( '\n' );
const metadata =
'export const metadata: Record<keyof Routes, RouteMeta> = ' +
JSON.stringify( this.#routes.entries(), null, 4 ) +
';';
return [ 'import type { Routes } from "$api/routes";', types, metadata ].join( '\n\n' );
}
#parseRoutes( raw: string ) {
try {
const parsed = JSON.parse( raw );
return new Map<string, LaravelRouteMetadata>( Object.entries( parsed ) );
} catch ( cause ) {
throw new Error( `Failed to parse route file: ${cause}`, { cause } );
}
}
async #readFile() {
let content: string;
try {
content = await readFile( this.#routesFile, 'utf-8' );
} catch ( error ) {
throw new Error(
`Could not load Laravel routes: ${error}. ` +
`Try running "php artisan route:sync" first.`,
);
}
const checksum = createHash( 'md5' ).update( content ).digest();
return [ content, checksum ] as const;
}
#routeToTemplateType( route: LaravelRouteMetadata ) {
return route.parameters.reduce(
( uri, parameter ) => {
const types = parameter.types;
if ( !types.includes( 'string' ) ) {
types.push( 'string' );
}
return uri.replace( `{${parameter.name}}`, `\${ ${types.join( ' | ' )} }` );
},
'`' + route.uri + '`',
);
}
#renderDocBlock( description: string | null ) {
if ( !description ) {
return '';
}
const linesWithSummary = description.match( /^(.+)\n\n([\s\S]+)/ );
const lines: string[] = [];
if ( linesWithSummary ) {
const [ , summary, content ] = linesWithSummary;
lines.push( `**${summary}**`, '' );
lines.push( ...content.split( '\n' ) );
} else {
lines.push( ...description.split( '\n' ) );
}
return [ '/**', ...lines.map( ( line ) => ` * ${line}` ), ' */' ].join( '\n' );
}
}
type DotEnvUpdaterOptions = {
outputPath: string;
typeStubName?: string;
};
class DotEnvUpdater extends ConfigUpdater {
#envTypeStubHeader = `
import type {route} from '../routes';
declare global
{
interface ImportMetaEnv
{`;
#envTypeStubFooter = `
}
interface ImportMeta
{
readonly env: ImportMetaEnv;
dirname: string;
route: typeof route;
}
}
export {};`;
readonly #options: DotEnvUpdaterOptions;
readonly #env: () => Record<string, string>;
#variables: ( readonly [ string, string ] )[];
constructor(
getEnv: () => Record<string, string>,
options: DotEnvUpdaterOptions,
logger: Logger,
) {
super( options.outputPath, logger );
this.#options = options;
this.#env = getEnv;
}
public async load() {
// Collect all environment variables from the runtime config
this.#variables = Object.entries( this.#env() ).map(
( [ key, value ] ) => [ key, typeof value ] as const,
);
// Generate a checksum for the variables to avoid unnecessary
// writes to disk
const checksum = createHash( 'md5' ).update( this.#variables.join() ).digest();
// If the checksum is the same as the last one, we can skip the type stub generation
if ( this.checksum?.equals( checksum ) ) {
return;
}
await this.#updateTypeStub();
this.checksum = checksum;
return Object.fromEntries( this.#variables );
}
#updateTypeStub() {
// Generate the type stub for those variables
const stubs = this.#generateTypeStub();
// Write the type stub to the output file
return this.writeGeneratedFile( this.#options.typeStubName ?? 'env.d.ts', stubs );
}
#generateTypeStub() {
const stub =
this.#variables
.map( ( [ key, type ] ) => `readonly ${key}: ${type}` )
.reduce( ( stub, line ) => `${stub}\n ${line};`, this.#envTypeStubHeader ) +
this.#envTypeStubFooter;
return stub.trim();
}
}
type TypescriptConfigUpdaterOptions = {
compilerOptions?: Record<string, any>;
entryPoints: ( readonly [ string, string ] )[];
environmentTypeFile: string | undefined;
outputPath: string;
tsConfigName?: string;
};
class TypescriptConfigUpdater extends ConfigUpdater {
#viteConfig: ResolvedConfig;
#options: TypescriptConfigUpdaterOptions;
#aliases: [ string, string ][];
constructor(
viteConfig: ResolvedConfig,
options: TypescriptConfigUpdaterOptions,
logger: Logger,
) {
super( options.outputPath, logger );
this.#viteConfig = viteConfig;
this.#options = options;
}
public async load() {
this.#aliases = Object.entries( await this.#resolveCanonicalAliases() );
// Generate a checksum for the aliases to avoid unnecessary
// writes to disk
const checksum = createHash( 'md5' ).update( this.#aliases.join() ).digest();
// If the checksum is the same as the last one, we can skip the type stub generation
if ( this.checksum?.equals( checksum ) ) {
return;
}
const outputDirectory = dirname( this.#options.outputPath );
const apiDirectory = resolve( outputDirectory, 'api' );
// Ensure all output directories exist
await mkdir( apiDirectory, { recursive: true } );
await this.#updateConfig();
}
#updateConfig() {
const tsConfig = this.#generateConfig();
return this.writeGeneratedFile(
this.#options?.tsConfigName ?? 'tsconfig.json',
JSON.stringify( tsConfig, null, 4 ),
);
}
#generateConfig() {
const root = this.#viteConfig.root ?? process.cwd();
const base = this.#options.outputPath;
return {
$schema: 'https://json.schemastore.org/tsconfig',
compilerOptions: {
verbatimModuleSyntax: true,
isolatedModules: true,
jsx: 'preserve',
jsxFactory: 'VueJSX',
lib: [ 'ESNext', 'DOM', 'DOM.Iterable' ],
moduleResolution: 'bundler',
module: 'ESNext',
target: 'ESNext',
...this.#options.compilerOptions,
baseUrl: root,
// rootDirs: [ pathToRoot, relative(outputDirectory, apiDirectory) ],
noEmit: true,
paths: this.#aliases.reduce(
( paths, [ alias, path ] ) => ( {
...paths,
[ `${alias}/*` ]: [ `./${join( path, '*' )}` ],
} ),
{
'$api/*': [ `./${join( relative( root, resolve( base, 'api' ) ), '*' )}` ],
},
),
},
include: this.#aliases
.filter( ( [ , alias ] ) =>
this.#options.entryPoints.some( ( [ , path ] ) => alias === relative( root, path ) ),
)
.flatMap( ( [ , path ] ) => [
join( relative( base, resolve( root, path ) ), '**', '*.ts' ),
join( relative( base, resolve( root, path ) ), '**', '*.d.ts' ),
join( relative( base, resolve( root, path ) ), '**', '*.vue' ),
join( relative( base, resolve( root, path ) ), '**', '*.js' ),
] )
.concat( [
// Generated UnPlugin components stubs, see:
// https://github.com/unplugin/unplugin-vue-components#typescript
relative( base, resolve( root, 'components.d.ts' ) ),
// Environment type stubs
relative( base, resolve( base, this.#options.environmentTypeFile ) ),
] )
.filter( Boolean ),
exclude: [
join( relative( base, root ), 'public', '**', '*' ),
'../../apps/**/*.config.ts',
'../../apps/**/*.config.js',
],
};
}
async #resolveCanonicalAliases() {
return ( this.#viteConfig.resolve.alias ?? [] )
.filter( ( alias ) => typeof alias.find === 'string' )
.reduce<Record<string, string>>( ( aliases, { find, replacement } ) => {
const path = replacement.startsWith( this.#viteConfig.root )
? relative( this.#viteConfig.root, replacement )
: replacement;
return { ...aliases, [ find as string ]: path };
}, {} );
}
}
interface PluginOptions {
compilerOptions: Record<string, any>;
applicationsPath?: string;
envStubFile?: string;
tsConfigFile?: string;
routesFilePath?: string;
routeStubFile?: string;
metadataStubFile?: string;
generateMetadataStub?: boolean;
}
type LaravelRouteMetadata = {
uri: string;
name: string;
methods: string[];
domain: string | null;
handler: string;
middleware: string[] | null;
description: string | null;
parameters: {
name: string;
optional: boolean;
constraint: string | null;
types: string[];
propertyBinding: string | null;
}[];
};
type LaravelRoutes = Map<string, LaravelRouteMetadata>;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment