diff --git a/test/fixture.test.ts b/src/__tests__/fixture.test.ts similarity index 71% rename from test/fixture.test.ts rename to src/__tests__/fixture.test.ts index 0869dcc..ddc126e 100644 --- a/test/fixture.test.ts +++ b/src/__tests__/fixture.test.ts @@ -2,7 +2,7 @@ import { resolve } from 'path' import { promises as fs } from 'fs' import { describe, expect, it } from 'vitest' import { transformWithEsbuild } from 'vite' -import { transform } from '../src/transform' +import { transform } from '../plugin' describe('fixture', async () => { const resolveId = (id: string) => id @@ -61,23 +61,26 @@ describe('fixture', async () => { \\"./sibling.ts\\": () => import(\\"./sibling.ts?foo=bar&raw=true&lang.ts\\") }; export const parent = { - \\"../../playground/src/main.ts\\": () => import(\\"../../playground/src/main.ts?url&lang.ts\\").then(m => m[\\"default\\"]) + }; export const rootMixedRelative = { - \\"/build.config.ts\\": () => import(\\"../../build.config.ts?url&lang.ts\\").then(m => m[\\"default\\"]), - \\"/client.d.ts\\": () => import(\\"../../client.d.ts?url&lang.ts\\").then(m => m[\\"default\\"]), - \\"/playground/package.json\\": () => import(\\"../../playground/package.json?url&lang.json\\").then(m => m[\\"default\\"]), - \\"/takeover.d.ts\\": () => import(\\"../../takeover.d.ts?url&lang.ts\\").then(m => m[\\"default\\"]), - \\"/types.ts\\": () => import(\\"../../types.ts?url&lang.ts\\").then(m => m[\\"default\\"]) + \\"/build.config.ts\\": () => import(\\"../../../build.config.ts?url&lang.ts\\").then(m => m[\\"default\\"]), + \\"/client.d.ts\\": () => import(\\"../../../client.d.ts?url&lang.ts\\").then(m => m[\\"default\\"]), + \\"/src/__tests__/fixture.test.ts\\": () => import(\\"../fixture.test.ts?url&lang.ts\\").then(m => m[\\"default\\"]), + \\"/src/__tests__/parse.test.ts\\": () => import(\\"../parse.test.ts?url&lang.ts\\").then(m => m[\\"default\\"]), + \\"/src/__tests__/utils.test.ts\\": () => import(\\"../utils.test.ts?url&lang.ts\\").then(m => m[\\"default\\"]), + \\"/takeover.d.ts\\": () => import(\\"../../../takeover.d.ts?url&lang.ts\\").then(m => m[\\"default\\"]), + \\"/types.ts\\": () => import(\\"../../../types.ts?url&lang.ts\\").then(m => m[\\"default\\"]) }; export const cleverCwd1 = { \\"./node_modules/framework/pages/hello.page.js\\": () => import(\\"./node_modules/framework/pages/hello.page.js\\") }; export const cleverCwd2 = { - \\"../../playground/src/fixtures/a.ts\\": () => import(\\"../../playground/src/fixtures/a.ts\\"), - \\"../../playground/src/fixtures/b.ts\\": () => import(\\"../../playground/src/fixtures/b.ts\\"), + \\"../fixture.test.ts\\": () => import(\\"../fixture.test.ts\\"), \\"./modules/a.ts\\": () => import(\\"./modules/a.ts\\"), - \\"./modules/b.ts\\": () => import(\\"./modules/b.ts\\") + \\"./modules/b.ts\\": () => import(\\"./modules/b.ts\\"), + \\"../parse.test.ts\\": () => import(\\"../parse.test.ts\\"), + \\"../utils.test.ts\\": () => import(\\"../utils.test.ts\\") }; " `) @@ -87,20 +90,21 @@ describe('fixture', async () => { const root = resolve(__dirname, './fixtures') const code = [ 'import.meta.glob(\'/modules/*.ts\')', - 'import.meta.glob([\'/../../playground/src/fixtures/*.ts\'])', + 'import.meta.glob([\'/../*.ts\'])', ].join('\n') expect((await transform(code, 'virtual:module', root, resolveId, options))?.s.toString()) .toMatchInlineSnapshot(` -"{ -\\"/modules/a.ts\\": () => import(\\"/modules/a.ts\\"), -\\"/modules/b.ts\\": () => import(\\"/modules/b.ts\\"), -\\"/modules/index.ts\\": () => import(\\"/modules/index.ts\\") -} -{ -\\"/../../playground/src/fixtures/a.ts\\": () => import(\\"/../../playground/src/fixtures/a.ts\\"), -\\"/../../playground/src/fixtures/b.ts\\": () => import(\\"/../../playground/src/fixtures/b.ts\\"), -\\"/../../playground/src/fixtures/index.ts\\": () => import(\\"/../../playground/src/fixtures/index.ts\\") -}"`, + "{ + \\"/modules/a.ts\\": () => import(\\"/modules/a.ts\\"), + \\"/modules/b.ts\\": () => import(\\"/modules/b.ts\\"), + \\"/modules/index.ts\\": () => import(\\"/modules/index.ts\\") + } + { + \\"/../fixture.test.ts\\": () => import(\\"/../fixture.test.ts\\"), + \\"/../parse.test.ts\\": () => import(\\"/../parse.test.ts\\"), + \\"/../utils.test.ts\\": () => import(\\"/../utils.test.ts\\") + }" + `, ) try { diff --git a/test/fixtures/.gitignore b/src/__tests__/fixtures/.gitignore similarity index 100% rename from test/fixtures/.gitignore rename to src/__tests__/fixtures/.gitignore diff --git a/test/fixtures/index.ts b/src/__tests__/fixtures/index.ts similarity index 95% rename from test/fixtures/index.ts rename to src/__tests__/fixtures/index.ts index f7fff18..22be977 100644 --- a/test/fixtures/index.ts +++ b/src/__tests__/fixtures/index.ts @@ -42,7 +42,7 @@ export const parent = import.meta.glob('../../playground/src/*.ts', { as: 'url' export const rootMixedRelative = import.meta.glob([ '/*.ts', - '../../playground/*.json', + '../*.ts', ], { as: 'url' }) export const cleverCwd1 = import.meta.glob( @@ -51,7 +51,7 @@ export const cleverCwd1 = import.meta.glob( export const cleverCwd2 = import.meta.glob([ './modules/*.ts', - '../../playground/src/fixtures/*.ts', + '../*.ts', '!**/index.ts', ], ) diff --git a/test/fixtures/modules/a.ts b/src/__tests__/fixtures/modules/a.ts similarity index 100% rename from test/fixtures/modules/a.ts rename to src/__tests__/fixtures/modules/a.ts diff --git a/test/fixtures/modules/b.ts b/src/__tests__/fixtures/modules/b.ts similarity index 100% rename from test/fixtures/modules/b.ts rename to src/__tests__/fixtures/modules/b.ts diff --git a/test/fixtures/modules/index.ts b/src/__tests__/fixtures/modules/index.ts similarity index 100% rename from test/fixtures/modules/index.ts rename to src/__tests__/fixtures/modules/index.ts diff --git a/test/fixtures/node_modules/framework/pages/hello.page.js b/src/__tests__/fixtures/node_modules/framework/pages/hello.page.js similarity index 100% rename from test/fixtures/node_modules/framework/pages/hello.page.js rename to src/__tests__/fixtures/node_modules/framework/pages/hello.page.js diff --git a/test/fixtures/sibling.ts b/src/__tests__/fixtures/sibling.ts similarity index 100% rename from test/fixtures/sibling.ts rename to src/__tests__/fixtures/sibling.ts diff --git a/test/parse.test.ts b/src/__tests__/parse.test.ts similarity index 99% rename from test/parse.test.ts rename to src/__tests__/parse.test.ts index be3112b..8fdfea4 100644 --- a/test/parse.test.ts +++ b/src/__tests__/parse.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseImportGlob } from '../src/parse' +import { parseImportGlob } from '../plugin' async function run(input: string) { const items = await parseImportGlob(input, process.cwd(), process.cwd(), id => id) diff --git a/test/utils.test.ts b/src/__tests__/utils.test.ts similarity index 95% rename from test/utils.test.ts rename to src/__tests__/utils.test.ts index 30d430a..a5b956b 100644 --- a/test/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getCommonBase } from '../src/utils' +import { getCommonBase } from '../plugin' describe('getCommonBase()', async () => { it('basic', () => { diff --git a/src/glob.ts b/src/glob.ts deleted file mode 100644 index 984a887..0000000 --- a/src/glob.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { posix } from 'path' - -export async function toAbsoluteGlob( - glob: string, - root: string, - dirname: string, - resolveId: (id: string) => string | Promise, -): Promise { - let pre = '' - if (glob.startsWith('!')) { - pre = '!' - glob = glob.slice(1) - } - - if (glob.startsWith('/')) - return pre + posix.join(root, glob.slice(1)) - if (glob.startsWith('./')) - return pre + posix.join(dirname, glob.slice(2)) - if (glob.startsWith('../')) - return pre + posix.join(dirname, glob) - if (glob.startsWith('**')) - return pre + glob - - const resolved = await resolveId(glob) - if (resolved.startsWith('/')) - return pre + resolved - - throw new Error(`Invalid glob: ${glob}. It must starts with '/' or './'`) -} diff --git a/src/index.ts b/src/index.ts index d7f8fc5..fc9e517 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,81 +1,3 @@ -import type { ModuleNode, Plugin, ResolvedConfig, ViteDevServer } from 'vite' -import mm from 'micromatch' -import type { ParsedImportGlob, PluginOptions } from '../types' -import { transform } from './transform' +import { importGlobPlugin } from './plugin' -export default function (options: PluginOptions = {}): Plugin { - let server: ViteDevServer | undefined - let config: ResolvedConfig - const map = new Map() - - function updateMap(id: string, info: ParsedImportGlob[]) { - const allGlobs = info.map(i => i.globsResolved) - map.set(id, allGlobs) - // add those allGlobs to the watcher - server?.watcher.add(allGlobs.flatMap(i => i.filter(i => i[0] !== '!'))) - } - - function getAffectedModules(file: string) { - const modules: ModuleNode[] = [] - for (const [id, allGlobs] of map) { - if (allGlobs.some(glob => mm.isMatch(file, glob))) - modules.push(...(server?.moduleGraph.getModulesByFile(id) || [])) - } - return modules - } - - return { - name: 'vite-plugin-glob', - config() { - return { - server: { - watch: { - disableGlobbing: false, - }, - }, - } - }, - configResolved(_config) { - config = _config - }, - buildStart() { - map.clear() - }, - configureServer(_server) { - server = _server - const handleFileAddUnlink = (file: string) => { - const modules = getAffectedModules(file) - _server.ws.send({ - type: 'update', - updates: modules.map((mod) => { - _server.moduleGraph.invalidateModule(mod) - return { - acceptedPath: mod.id!, - path: mod.id!, - timestamp: Date.now(), - type: 'js-update', - } - }), - }) - } - server.watcher.on('unlink', handleFileAddUnlink) - server.watcher.on('add', handleFileAddUnlink) - }, - async transform(code, id) { - const result = await transform( - code, - id, - config.root, - im => this.resolve(im, id).then(i => i?.id || im), - options, - ) - if (result) { - updateMap(id, result.matches) - return { - code: result.s.toString(), - map: result.s.generateMap(), - } - } - }, - } -} +export default importGlobPlugin diff --git a/src/parse.ts b/src/parse.ts deleted file mode 100644 index 25a07ad..0000000 --- a/src/parse.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { ArrayExpression, CallExpression, Literal, Node } from 'estree' -import { parseExpressionAt } from 'acorn' -import type { GeneralGlobOptions, ParsedImportGlob } from '../types' -import { toAbsoluteGlob } from './glob' - -const importGlobRE = /\bimport\.meta\.(importGlob|glob|globEager|globEagerDefault)(?:<\w+>)?\s*\(/g - -const knownOptions = { - as: 'string', - eager: 'boolean', - export: 'string', - exhaustive: 'boolean', -} as const - -const forceDefaultAs = ['raw', 'url'] - -export async function parseImportGlob( - code: string, - dir: string | null, - root: string, - resolveId: (id: string) => string | Promise, -): Promise { - const matchs = Array.from(code.matchAll(importGlobRE)) - - const tasks = matchs.map(async (match, index) => { - const type = match[1] - const start = match.index! - - const err = (msg: string) => { - const e = new Error(`Invalid glob import syntax: ${msg}`) - ;(e as any).pos = start - return e - } - - let ast: CallExpression - - try { - ast = parseExpressionAt( - code, - start, - { - ecmaVersion: 'latest', - sourceType: 'module', - ranges: true, - }, - ) as any - } - catch (e) { - const _e = e as any - if (_e.message && _e.message.startsWith('Unterminated string constant')) - return undefined! - throw _e - } - - if (ast.type !== 'CallExpression') - throw err(`Expect CallExpression, got ${ast.type}`) - - if (ast.arguments.length < 1 || ast.arguments.length > 2) - throw err(`Expected 1-2 arguments, but got ${ast.arguments.length}`) - - const arg1 = ast.arguments[0] as ArrayExpression | Literal - const arg2 = ast.arguments[1] as Node | undefined - - const globs: string[] = [] - if (arg1.type === 'ArrayExpression') { - for (const element of arg1.elements) { - if (!element) - continue - if (element.type !== 'Literal') - throw err('Could only use literals') - if (typeof element.value !== 'string') - throw err(`Expected glob to be a string, but got "${typeof element.value}"`) - - globs.push(element.value) - } - } - else if (arg1.type === 'Literal') { - if (typeof arg1.value !== 'string') - throw err(`Expected glob to be a string, but got "${typeof arg1.value}"`) - globs.push(arg1.value) - } - else { - throw err('Could only use literals') - } - - // if (!globs.every(i => i.match(/^[.\/!]/))) - // throw err('pattern must start with "." or "/" (relative to project root) or alias path') - - // arg2 - const options: GeneralGlobOptions = {} - if (arg2) { - if (arg2.type !== 'ObjectExpression') - throw err(`Expected the second argument o to be a object literal, but got "${arg2.type}"`) - - for (const property of arg2.properties) { - if (property.type === 'SpreadElement' || property.key.type !== 'Identifier') - throw err('Could only use literals') - - const name = property.key.name as keyof GeneralGlobOptions - - if (name === 'query') { - if (property.value.type === 'ObjectExpression') { - const data: Record = {} - for (const prop of property.value.properties) { - if (prop.type === 'SpreadElement' || prop.key.type !== 'Identifier' || prop.value.type !== 'Literal') - throw err('Could only use literals') - data[prop.key.name] = prop.value.value as any - } - options.query = data - } - else if (property.value.type === 'Literal') { - if (typeof property.value.value !== 'string') - throw err(`Expected query to be a string, but got "${typeof property.value.value}"`) - options.query = property.value.value - } - else { - throw err('Could only use literals') - } - continue - } - - if (!(name in knownOptions)) - throw err(`Unknown options ${name}`) - - if (property.value.type !== 'Literal') - throw err('Could only use literals') - - const valueType = typeof property.value.value - if (valueType === 'undefined') - continue - - if (valueType !== knownOptions[name]) - throw err(`Expected the type of option "${name}" to be "${knownOptions[name]}", but got "${valueType}"`) - options[name] = property.value.value as any - } - } - - if (options.as && forceDefaultAs.includes(options.as)) { - if (options.export && options.export !== 'default') - throw err(`Option "export" can only be "default" when "as" is "${options.as}", but got "${options.export}"`) - options.export = 'default' - } - - if (options.as && options.query) - throw err('Options "as" and "query" cannot be used together') - - if (options.as) - options.query = options.as - - const end = ast.range![1] - - const globsResolved = await Promise.all(globs.map(glob => toAbsoluteGlob(glob, root, dir ?? root, resolveId))) - const isRelative = globs.every(i => '.!'.includes(i[0])) - - return { - match, - index, - globs, - globsResolved, - isRelative, - options, - type, - start, - end, - } - }) - - return (await Promise.all(tasks)) - .filter(Boolean) -} diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..a3d07bf --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,460 @@ +import path, { posix } from 'path' +import type { ModuleNode, Plugin, ResolvedConfig, ViteDevServer } from 'vite' +import mm from 'micromatch' +import type { ArrayExpression, CallExpression, Literal, Node } from 'estree' +import { parseExpressionAt } from 'acorn' +import MagicString from 'magic-string' +import fg from 'fast-glob' +import { stringifyQuery } from 'ufo' +import type { GeneralGlobOptions, ParsedImportGlob, PluginOptions } from '../types' + +const cssLangs = '\\.(css|less|sass|scss|styl|stylus|pcss|postcss)($|\\?)' +const cssLangRE = new RegExp(cssLangs) + +export function importGlobPlugin(options: PluginOptions = {}): Plugin { + let server: ViteDevServer | undefined + let config: ResolvedConfig + const map = new Map() + + function updateMap(id: string, info: ParsedImportGlob[]) { + const allGlobs = info.map(i => i.globsResolved) + map.set(id, allGlobs) + // add those allGlobs to the watcher + server?.watcher.add(allGlobs.flatMap(i => i.filter(i => i[0] !== '!'))) + } + + function getAffectedModules(file: string) { + const modules: ModuleNode[] = [] + for (const [id, allGlobs] of map) { + if (allGlobs.some(glob => mm.isMatch(file, glob))) + modules.push(...(server?.moduleGraph.getModulesByFile(id) || [])) + } + return modules + } + + return { + name: 'vite-plugin-glob', + config() { + return { + server: { + watch: { + disableGlobbing: false, + }, + }, + } + }, + configResolved(_config) { + config = _config + }, + buildStart() { + map.clear() + }, + configureServer(_server) { + server = _server + const handleFileAddUnlink = (file: string) => { + const modules = getAffectedModules(file) + _server.ws.send({ + type: 'update', + updates: modules.map((mod) => { + _server.moduleGraph.invalidateModule(mod) + return { + acceptedPath: mod.id!, + path: mod.id!, + timestamp: Date.now(), + type: 'js-update', + } + }), + }) + } + server.watcher.on('unlink', handleFileAddUnlink) + server.watcher.on('add', handleFileAddUnlink) + }, + async transform(code, id) { + const result = await transform( + code, + id, + config.root, + im => this.resolve(im, id).then(i => i?.id || im), + options, + ) + if (result) { + updateMap(id, result.matches) + return { + code: result.s.toString(), + map: result.s.generateMap(), + } + } + }, + } +} + +const importGlobRE = /\bimport\.meta\.(importGlob|glob|globEager|globEagerDefault)(?:<\w+>)?\s*\(/g + +const knownOptions = { + as: 'string', + eager: 'boolean', + export: 'string', + exhaustive: 'boolean', +} as const + +const forceDefaultAs = ['raw', 'url'] + +export async function parseImportGlob( + code: string, + dir: string | null, + root: string, + resolveId: (id: string) => string | Promise, +): Promise { + const matchs = Array.from(code.matchAll(importGlobRE)) + + const tasks = matchs.map(async (match, index) => { + const type = match[1] + const start = match.index! + + const err = (msg: string) => { + const e = new Error(`Invalid glob import syntax: ${msg}`) + ;(e as any).pos = start + return e + } + + let ast: CallExpression + + try { + ast = parseExpressionAt( + code, + start, + { + ecmaVersion: 'latest', + sourceType: 'module', + ranges: true, + }, + ) as any + } + catch (e) { + const _e = e as any + if (_e.message && _e.message.startsWith('Unterminated string constant')) + return undefined! + throw _e + } + + if (ast.type !== 'CallExpression') + throw err(`Expect CallExpression, got ${ast.type}`) + + if (ast.arguments.length < 1 || ast.arguments.length > 2) + throw err(`Expected 1-2 arguments, but got ${ast.arguments.length}`) + + const arg1 = ast.arguments[0] as ArrayExpression | Literal + const arg2 = ast.arguments[1] as Node | undefined + + const globs: string[] = [] + if (arg1.type === 'ArrayExpression') { + for (const element of arg1.elements) { + if (!element) + continue + if (element.type !== 'Literal') + throw err('Could only use literals') + if (typeof element.value !== 'string') + throw err(`Expected glob to be a string, but got "${typeof element.value}"`) + + globs.push(element.value) + } + } + else if (arg1.type === 'Literal') { + if (typeof arg1.value !== 'string') + throw err(`Expected glob to be a string, but got "${typeof arg1.value}"`) + globs.push(arg1.value) + } + else { + throw err('Could only use literals') + } + + // if (!globs.every(i => i.match(/^[.\/!]/))) + // throw err('pattern must start with "." or "/" (relative to project root) or alias path') + + // arg2 + const options: GeneralGlobOptions = {} + if (arg2) { + if (arg2.type !== 'ObjectExpression') + throw err(`Expected the second argument o to be a object literal, but got "${arg2.type}"`) + + for (const property of arg2.properties) { + if (property.type === 'SpreadElement' || property.key.type !== 'Identifier') + throw err('Could only use literals') + + const name = property.key.name as keyof GeneralGlobOptions + + if (name === 'query') { + if (property.value.type === 'ObjectExpression') { + const data: Record = {} + for (const prop of property.value.properties) { + if (prop.type === 'SpreadElement' || prop.key.type !== 'Identifier' || prop.value.type !== 'Literal') + throw err('Could only use literals') + data[prop.key.name] = prop.value.value as any + } + options.query = data + } + else if (property.value.type === 'Literal') { + if (typeof property.value.value !== 'string') + throw err(`Expected query to be a string, but got "${typeof property.value.value}"`) + options.query = property.value.value + } + else { + throw err('Could only use literals') + } + continue + } + + if (!(name in knownOptions)) + throw err(`Unknown options ${name}`) + + if (property.value.type !== 'Literal') + throw err('Could only use literals') + + const valueType = typeof property.value.value + if (valueType === 'undefined') + continue + + if (valueType !== knownOptions[name]) + throw err(`Expected the type of option "${name}" to be "${knownOptions[name]}", but got "${valueType}"`) + options[name] = property.value.value as any + } + } + + if (options.as && forceDefaultAs.includes(options.as)) { + if (options.export && options.export !== 'default') + throw err(`Option "export" can only be "default" when "as" is "${options.as}", but got "${options.export}"`) + options.export = 'default' + } + + if (options.as && options.query) + throw err('Options "as" and "query" cannot be used together') + + if (options.as) + options.query = options.as + + const end = ast.range![1] + + const globsResolved = await Promise.all(globs.map(glob => toAbsoluteGlob(glob, root, dir ?? root, resolveId))) + const isRelative = globs.every(i => '.!'.includes(i[0])) + + return { + match, + index, + globs, + globsResolved, + isRelative, + options, + type, + start, + end, + } + }) + + return (await Promise.all(tasks)) + .filter(Boolean) +} + +const importPrefix = '__vite_glob_next_' + +const { basename, dirname, relative, join } = posix + +export async function transform( + code: string, + id: string, + root: string, + resolveId: (id: string) => Promise | string, + options?: PluginOptions, +) { + id = toPosixPath(id) + root = toPosixPath(root) + const dir = isVirtualModule(id) ? null : dirname(id) + let matches = await parseImportGlob(code, dir, root, resolveId) + + if (options?.takeover) { + matches.forEach((i) => { + if (i.type === 'globEager') + i.options.eager = true + if (i.type === 'globEagerDefault') { + i.options.eager = true + i.options.export = 'default' + } + }) + } + else { + matches = matches.filter(i => i.type === 'importGlob') + } + + if (!matches.length) + return + + const s = new MagicString(code) + + const staticImports = (await Promise.all( + matches.map(async ({ globsResolved, isRelative, options, index, start, end }) => { + const cwd = getCommonBase(globsResolved) ?? root + const files = (await fg(globsResolved, { + cwd, + absolute: true, + dot: !!options.exhaustive, + ignore: options.exhaustive + ? [] + : [join(cwd, '**/node_modules/**')], + })) + .filter(file => file !== id) + .sort() + + const objectProps: string[] = [] + const staticImports: string[] = [] + + let query = !options.query + ? '' + : typeof options.query === 'string' + ? options.query + : stringifyQuery(options.query as any) + + if (query && !query.startsWith('?')) + query = `?${query}` + + const resolvePaths = (file: string) => { + if (!dir) { + if (isRelative) + throw new Error('In virtual modules, all globs must start with \'/\'') + const filePath = `/${relative(root, file)}` + return { filePath, importPath: filePath } + } + + let importPath = relative(dir, file) + if (!importPath.startsWith('.')) + importPath = `./${importPath}` + + let filePath: string + if (isRelative) { + filePath = importPath + } + else { + filePath = relative(root, file) + if (!filePath.startsWith('.')) + filePath = `/${filePath}` + } + + return { filePath, importPath } + } + + files.forEach((file, i) => { + const paths = resolvePaths(file) + const filePath = paths.filePath + let importPath = paths.importPath + let importQuery = query + + if (isCSSRequest(file)) + importQuery = importQuery ? `${importQuery}&used` : '?used' + + if (importQuery && importQuery !== '?raw') { + const fileExtension = basename(file).split('.').slice(-1)[0] + if (fileExtension) + importQuery = `${importQuery}&lang.${fileExtension}` + } + + importPath = `${importPath}${importQuery}` + + if (options.eager) { + const variableName = `${importPrefix}${index}_${i}` + const expression = options.export + ? `{ ${options.export} as ${variableName} }` + : `* as ${variableName}` + staticImports.push(`import ${expression} from ${JSON.stringify(importPath)}`) + objectProps.push(`${JSON.stringify(filePath)}: ${variableName}`) + } + else { + let importStatement = `import(${JSON.stringify(importPath)})` + if (options.export) + importStatement += `.then(m => m[${JSON.stringify(options.export)}])` + objectProps.push(`${JSON.stringify(filePath)}: () => ${importStatement}`) + } + }) + + const replacement = `{\n${objectProps.join(',\n')}\n}` + s.overwrite(start, end, replacement) + + return staticImports + }), + )).flat() + + if (staticImports.length) + s.prepend(`${staticImports.join('\n')}\n`) + + return { + s, + matches, + } +} + +export async function toAbsoluteGlob( + glob: string, + root: string, + dirname: string, + resolveId: (id: string) => string | Promise, +): Promise { + let pre = '' + if (glob.startsWith('!')) { + pre = '!' + glob = glob.slice(1) + } + + if (glob.startsWith('/')) + return pre + posix.join(root, glob.slice(1)) + if (glob.startsWith('./')) + return pre + posix.join(dirname, glob.slice(2)) + if (glob.startsWith('../')) + return pre + posix.join(dirname, glob) + if (glob.startsWith('**')) + return pre + glob + + const resolved = await resolveId(glob) + if (resolved.startsWith('/')) + return pre + resolved + + throw new Error(`Invalid glob: ${glob}. It must starts with '/' or './'`) +} + +export function isCSSRequest(request: string): boolean { + return cssLangRE.test(request) +} + +export function getCommonBase(globsResolved: string[]): null | string { + const bases = globsResolved.filter(g => !g.startsWith('!')).map((glob) => { + let { base } = mm.scan(glob) + // `scan('a/foo.js')` returns `base: 'a/foo.js'` + if (path.posix.basename(base).includes('.')) + base = path.posix.dirname(base) + + return base + }) + + if (!bases.length) + return null + + let commonAncestor = '' + const dirS = bases[0].split('/') + for (let i = 0; i < dirS.length; i++) { + const candidate = dirS.slice(0, i + 1).join('/') + if (bases.every(base => base.startsWith(candidate))) + commonAncestor = candidate + else + break + } + if (!commonAncestor) + commonAncestor = '/' + + return commonAncestor +} + +export function toPosixPath(p: string) { + return p.split('\\').join('/') +} + +export function isVirtualModule(id: string) { + // https://vitejs.dev/guide/api-plugin.html#virtual-modules-convention + return id.startsWith('virtual:') || id.startsWith('\0') || !id.includes('/') +} + diff --git a/src/transform.ts b/src/transform.ts deleted file mode 100644 index 69e1df5..0000000 --- a/src/transform.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { posix } from 'path' -import MagicString from 'magic-string' -import fg from 'fast-glob' -import { stringifyQuery } from 'ufo' -import type { PluginOptions } from '../types' -import { parseImportGlob } from './parse' -import { getCommonBase, isCSSRequest, isVirtualModule, toPosixPath } from './utils' - -const importPrefix = '__vite_glob_next_' - -const { basename, dirname, relative, join } = posix - -export async function transform( - code: string, - id: string, - root: string, - resolveId: (id: string) => Promise | string, - options?: PluginOptions, -) { - id = toPosixPath(id) - root = toPosixPath(root) - const dir = isVirtualModule(id) ? null : dirname(id) - let matches = await parseImportGlob(code, dir, root, resolveId) - - if (options?.takeover) { - matches.forEach((i) => { - if (i.type === 'globEager') - i.options.eager = true - if (i.type === 'globEagerDefault') { - i.options.eager = true - i.options.export = 'default' - } - }) - } - else { - matches = matches.filter(i => i.type === 'importGlob') - } - - if (!matches.length) - return - - const s = new MagicString(code) - - const staticImports = (await Promise.all( - matches.map(async ({ globsResolved, isRelative, options, index, start, end }) => { - const cwd = getCommonBase(globsResolved) ?? root - const files = (await fg(globsResolved, { - cwd, - absolute: true, - dot: !!options.exhaustive, - ignore: options.exhaustive - ? [] - : [join(cwd, '**/node_modules/**')], - })) - .filter(file => file !== id) - .sort() - - const objectProps: string[] = [] - const staticImports: string[] = [] - - let query = !options.query - ? '' - : typeof options.query === 'string' - ? options.query - : stringifyQuery(options.query as any) - - if (query && !query.startsWith('?')) - query = `?${query}` - - const resolvePaths = (file: string) => { - if (!dir) { - if (isRelative) - throw new Error('In virtual modules, all globs must start with \'/\'') - const filePath = `/${relative(root, file)}` - return { filePath, importPath: filePath } - } - - let importPath = relative(dir, file) - if (!importPath.startsWith('.')) - importPath = `./${importPath}` - - let filePath: string - if (isRelative) { - filePath = importPath - } - else { - filePath = relative(root, file) - if (!filePath.startsWith('.')) - filePath = `/${filePath}` - } - - return { filePath, importPath } - } - - files.forEach((file, i) => { - const paths = resolvePaths(file) - const filePath = paths.filePath - let importPath = paths.importPath - let importQuery = query - - if (isCSSRequest(file)) - importQuery = importQuery ? `${importQuery}&used` : '?used' - - if (importQuery && importQuery !== '?raw') { - const fileExtension = basename(file).split('.').slice(-1)[0] - if (fileExtension) - importQuery = `${importQuery}&lang.${fileExtension}` - } - - importPath = `${importPath}${importQuery}` - - if (options.eager) { - const variableName = `${importPrefix}${index}_${i}` - const expression = options.export - ? `{ ${options.export} as ${variableName} }` - : `* as ${variableName}` - staticImports.push(`import ${expression} from ${JSON.stringify(importPath)}`) - objectProps.push(`${JSON.stringify(filePath)}: ${variableName}`) - } - else { - let importStatement = `import(${JSON.stringify(importPath)})` - if (options.export) - importStatement += `.then(m => m[${JSON.stringify(options.export)}])` - objectProps.push(`${JSON.stringify(filePath)}: () => ${importStatement}`) - } - }) - - const replacement = `{\n${objectProps.join(',\n')}\n}` - s.overwrite(start, end, replacement) - - return staticImports - }), - )).flat() - - if (staticImports.length) - s.prepend(`${staticImports.join('\n')}\n`) - - return { - s, - matches, - } -} diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index bfe7fa1..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,45 +0,0 @@ -import path from 'path' -import mm from 'micromatch' - -const cssLangs = '\\.(css|less|sass|scss|styl|stylus|pcss|postcss)($|\\?)' -const cssLangRE = new RegExp(cssLangs) - -export const isCSSRequest = (request: string): boolean => - cssLangRE.test(request) - -export function getCommonBase(globsResolved: string[]): null | string { - const bases = globsResolved.filter(g => !g.startsWith('!')).map((glob) => { - let { base } = mm.scan(glob) - // `scan('a/foo.js')` returns `base: 'a/foo.js'` - if (path.posix.basename(base).includes('.')) - base = path.posix.dirname(base) - - return base - }) - - if (!bases.length) - return null - - let commonAncestor = '' - const dirS = bases[0].split('/') - for (let i = 0; i < dirS.length; i++) { - const candidate = dirS.slice(0, i + 1).join('/') - if (bases.every(base => base.startsWith(candidate))) - commonAncestor = candidate - else - break - } - if (!commonAncestor) - commonAncestor = '/' - - return commonAncestor -} - -export function toPosixPath(p: string) { - return p.split('\\').join('/') -} - -export function isVirtualModule(id: string) { - // https://vitejs.dev/guide/api-plugin.html#virtual-modules-convention - return id.startsWith('virtual:') || id.startsWith('\0') || !id.includes('/') -}