diff --git a/packages/vite/src/node/__tests__/utils.spec.ts b/packages/vite/src/node/__tests__/utils.spec.ts index 3bf82f6b86f84b..302f5549e2da53 100644 --- a/packages/vite/src/node/__tests__/utils.spec.ts +++ b/packages/vite/src/node/__tests__/utils.spec.ts @@ -1,40 +1,67 @@ -import { injectQuery, isWindows } from '../utils' +import { injectQuery, isJSRequest, isWindows } from '../utils' -if (isWindows) { - // this test will work incorrectly on unix systems - test('normalize windows path', () => { - expect(injectQuery('C:\\User\\Vite\\Project', 'direct')).toEqual( - 'C:/User/Vite/Project?direct' +describe('injectQuery', () => { + if (isWindows) { + // this test will work incorrectly on unix systems + test('normalize windows path', () => { + expect(injectQuery('C:\\User\\Vite\\Project', 'direct')).toEqual( + 'C:/User/Vite/Project?direct' + ) + }) + } + + test('path with multiple spaces', () => { + expect(injectQuery('/usr/vite/path with space', 'direct')).toEqual( + '/usr/vite/path with space?direct' ) }) -} -test('path with multiple spaces', () => { - expect(injectQuery('/usr/vite/path with space', 'direct')).toEqual( - '/usr/vite/path with space?direct' - ) -}) + test('path with multiple % characters', () => { + expect(injectQuery('/usr/vite/not%20a%20space', 'direct')).toEqual( + '/usr/vite/not%20a%20space?direct' + ) + }) -test('path with multiple % characters', () => { - expect(injectQuery('/usr/vite/not%20a%20space', 'direct')).toEqual( - '/usr/vite/not%20a%20space?direct' - ) -}) + test('path with %25', () => { + expect(injectQuery('/usr/vite/%25hello%25', 'direct')).toEqual( + '/usr/vite/%25hello%25?direct' + ) + }) -test('path with %25', () => { - expect(injectQuery('/usr/vite/%25hello%25', 'direct')).toEqual( - '/usr/vite/%25hello%25?direct' - ) -}) + test('path with unicode', () => { + expect(injectQuery('/usr/vite/東京', 'direct')).toEqual( + '/usr/vite/東京?direct' + ) + }) -test('path with unicode', () => { - expect(injectQuery('/usr/vite/東京', 'direct')).toEqual( - '/usr/vite/東京?direct' - ) + test('path with unicode, space, and %', () => { + expect(injectQuery('/usr/vite/東京 %20 hello', 'direct')).toEqual( + '/usr/vite/東京 %20 hello?direct' + ) + }) }) -test('path with unicode, space, and %', () => { - expect(injectQuery('/usr/vite/東京 %20 hello', 'direct')).toEqual( - '/usr/vite/東京 %20 hello?direct' - ) +describe('isJSRequest', () => { + const knownJsSrcExtensions = ['.js', '.ts'] + test.each([ + ['', true], // bare imports are js + ['.js', true], + ['.ts', true], + ['.x', false], // not in extensions list => false + ['/', false] // directory => false + ])('path ending with "%s" returns %s', (suffix, expected) => { + const path = `/x/y/foo${suffix}` + expect(isJSRequest(path, knownJsSrcExtensions)).toBe(expected) + // also tests combinations of querystring and hash, must be the same result + expect(isJSRequest(`${path}?foo=.js`, knownJsSrcExtensions)).toBe(expected) + expect(isJSRequest(`${path}#.js`, knownJsSrcExtensions)).toBe(expected) + expect(isJSRequest(`${path}?foo=.js#.js`, knownJsSrcExtensions)).toBe( + expected + ) + expect(isJSRequest(`${path}?foo=.x`, knownJsSrcExtensions)).toBe(expected) + expect(isJSRequest(`${path}#.x`, knownJsSrcExtensions)).toBe(expected) + expect(isJSRequest(`${path}?foo=.x#.x`, knownJsSrcExtensions)).toBe( + expected + ) + }) }) diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 5ef64be1f33454..0145399e2bc5a2 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -21,7 +21,11 @@ import { ESBuildOptions } from './plugins/esbuild' import dotenv from 'dotenv' import dotenvExpand from 'dotenv-expand' import { Alias, AliasOptions } from 'types/alias' -import { CLIENT_DIR, DEFAULT_ASSETS_RE } from './constants' +import { + CLIENT_DIR, + DEFAULT_ASSETS_RE, + DEFAULT_KNOWN_JS_SRC_EXTENSIONS +} from './constants' import { InternalResolveOptions, ResolveOptions, @@ -129,6 +133,13 @@ export interface UserConfig { * Specify additional files to be treated as static assets. */ assetsInclude?: string | RegExp | (string | RegExp)[] + /** + * Specify additional extensions to be treated as sources of js modules + * requires plugins to be installed that transform the extensions! + * + * Plugin authors should set this with the config hook + */ + knownJsSrcExtensions?: string[] /** * Server specific options, e.g. host, port, https... */ @@ -195,7 +206,12 @@ export interface InlineConfig extends UserConfig { export type ResolvedConfig = Readonly< Omit< UserConfig, - 'plugins' | 'alias' | 'dedupe' | 'assetsInclude' | 'optimizeDeps' + | 'plugins' + | 'alias' + | 'dedupe' + | 'assetsInclude' + | 'optimizeDeps' + | 'knownJsSrcExtensions' > & { configFile: string | undefined configFileDependencies: string[] @@ -214,6 +230,7 @@ export type ResolvedConfig = Readonly< server: ResolvedServerOptions build: ResolvedBuildOptions assetsInclude: (file: string) => boolean + knownJsSrcExtensions: string[] logger: Logger createResolver: (options?: Partial) => ResolveFn optimizeDeps: Omit @@ -270,6 +287,11 @@ export async function resolveConfig( // user config may provide an alternative mode mode = config.mode || mode + // user config may provide alternative default known js src extensions + if (!config.knownJsSrcExtensions) { + config.knownJsSrcExtensions = [...DEFAULT_KNOWN_JS_SRC_EXTENSIONS] + } + // resolve plugins const rawUserPlugins = (config.plugins || []).flat().filter((p) => { return p && (!p.apply || p.apply === command) @@ -390,6 +412,15 @@ export async function resolveConfig( ) : '' + //ensure leading dot and deduplicate + const resolvedKnownJsSrcExtensions = [ + ...new Set( + config.knownJsSrcExtensions?.map((ext) => + ext.startsWith('.') ? ext : `.${ext}` + ) + ) + ] + const resolved: ResolvedConfig = { ...config, configFile: configFile ? normalizePath(configFile) : undefined, @@ -416,6 +447,7 @@ export async function resolveConfig( assetsInclude(file: string) { return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file) }, + knownJsSrcExtensions: resolvedKnownJsSrcExtensions, logger, createResolver, optimizeDeps: { diff --git a/packages/vite/src/node/constants.ts b/packages/vite/src/node/constants.ts index 4352869419d897..b76bba9951d412 100644 --- a/packages/vite/src/node/constants.ts +++ b/packages/vite/src/node/constants.ts @@ -15,6 +15,19 @@ export const DEFAULT_EXTENSIONS = [ '.json' ] +// warning, these are only default values. At runtime you must check config.knownJsSrcExtensions +export const DEFAULT_KNOWN_JS_SRC_EXTENSIONS = [ + '.js', + '.ts', + '.jsx', + '.tsx', + '.mjs', + // the extensions below should be removed once their respective plugins add them via config hook + '.vue', + '.marko', + '.svelte' +] + export const JS_TYPES_RE = /\.(?:j|t)sx?$|\.mjs$/ export const OPTIMIZABLE_ENTRY_RE = /\.(?:m?js|ts)$/ diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index 4c5c66ce7c52b3..ff09e7184a54fc 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -48,12 +48,12 @@ const clientDir = normalizePath(CLIENT_DIR) const skipRE = /\.(map|json)$/ const canSkip = (id: string) => skipRE.test(id) || isDirectCSSRequest(id) -function isExplicitImportRequired(url: string) { - return !isJSRequest(cleanUrl(url)) && !isCSSRequest(url) +function isExplicitImportRequired(url: string, knownJSSrcExtensions: string[]) { + return !isJSRequest(url, knownJSSrcExtensions) && !isCSSRequest(url) } -function markExplicitImport(url: string) { - if (isExplicitImportRequired(url)) { +function markExplicitImport(url: string, knownJSSrcExtensions: string[]) { + if (isExplicitImportRequired(url, knownJSSrcExtensions)) { return injectQuery(url, 'import') } return url @@ -89,7 +89,7 @@ function markExplicitImport(url: string) { * ``` */ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { - const { root, base } = config + const { root, base, knownJsSrcExtensions } = config const clientPublicPath = path.posix.join(base, CLIENT_PUBLIC_PATH) let server: ViteDevServer @@ -120,7 +120,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { imports = parseImports(source)[0] } catch (e) { const isVue = importer.endsWith('.vue') - const maybeJSX = !isVue && isJSRequest(importer) + const maybeJSX = !isVue && isJSRequest(importer, knownJsSrcExtensions) const msg = isVue ? `Install @vitejs/plugin-vue to handle .vue files.` @@ -217,7 +217,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { // make the URL browser-valid if not SSR if (!ssr) { // mark non-js/css imports with `?import` - url = markExplicitImport(url) + url = markExplicitImport(url, knownJsSrcExtensions) // for relative js/css imports, inherit importer's version query // do not do this for unknown type imports, otherwise the appended @@ -416,7 +416,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { } if ( !/^('.*'|".*"|`.*`)$/.test(url) || - isExplicitImportRequired(url.slice(1, -1)) + isExplicitImportRequired(url.slice(1, -1), knownJsSrcExtensions) ) { needQueryInjectHelper = true str().overwrite(start, end, `__vite__injectQuery(${url}, 'import')`) @@ -471,7 +471,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { const normalizedAcceptedUrls = new Set() for (const { url, start, end } of acceptedUrls) { const [normalized] = await moduleGraph.resolveUrl( - toAbsoluteUrl(markExplicitImport(url)) + toAbsoluteUrl(markExplicitImport(url, knownJsSrcExtensions)) ) normalizedAcceptedUrls.add(normalized) str().overwrite(start, end, JSON.stringify(normalized)) diff --git a/packages/vite/src/node/server/middlewares/transform.ts b/packages/vite/src/node/server/middlewares/transform.ts index 963d11cd76ec45..3e22493ae2e26a 100644 --- a/packages/vite/src/node/server/middlewares/transform.ts +++ b/packages/vite/src/node/server/middlewares/transform.ts @@ -39,7 +39,7 @@ export function transformMiddleware( server: ViteDevServer ): Connect.NextHandleFunction { const { - config: { root, logger, cacheDir }, + config: { root, logger, cacheDir, knownJsSrcExtensions }, moduleGraph } = server @@ -132,7 +132,7 @@ export function transformMiddleware( } if ( - isJSRequest(url) || + isJSRequest(url, knownJsSrcExtensions) || isImportRequest(url) || isCSSRequest(url) || isHTMLProxy(url) diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index ceaff9799a38ad..f2dfcff64c9279 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -106,16 +106,13 @@ export const isExternalUrl = (url: string): boolean => externalRE.test(url) export const dataUrlRE = /^\s*data:/i export const isDataUrl = (url: string): boolean => dataUrlRE.test(url) -const knownJsSrcRE = /\.((j|t)sx?|mjs|vue|marko|svelte)($|\?)/ -export const isJSRequest = (url: string): boolean => { +export const isJSRequest = ( + url: string, + knownJSSrcExtensions: string[] +): boolean => { url = cleanUrl(url) - if (knownJsSrcRE.test(url)) { - return true - } - if (!path.extname(url) && !url.endsWith('/')) { - return true - } - return false + const ext = path.extname(url) + return ext ? knownJSSrcExtensions.includes(ext) : !url.endsWith('/') } const importQueryRE = /(\?|&)import=?(?:&|$)/