Last active
October 30, 2024 15:40
-
-
Save Radiergummi/198ae14450a947a45b7cadaeab6ccc61 to your computer and use it in GitHub Desktop.
Laravel Route replacement in Frontend code at build-time
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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