diff --git a/packages/iles/src/node/plugin/documents.ts b/packages/iles/src/node/plugin/documents.ts index dca7aee2..560bda49 100644 --- a/packages/iles/src/node/plugin/documents.ts +++ b/packages/iles/src/node/plugin/documents.ts @@ -10,9 +10,10 @@ import { debug, serialize } from './utils' const definitionRegex = /(function|const|let|var)[\s\n]+\buseDocuments\b/ const usageRegex = /\buseDocuments[\s\n]*\(([^)]+)\)/g -const fileCanUseDocuments = /(\.vue|\.[tj]sx?)$/ +const fileCanUseDocuments = /(\.vue|\.[tj]sx?)(?:$|\?)/ const DOCS_VIRTUAL_ID = '/@islands/documents' +const docsVirtualIdFilter = new RegExp(`^${DOCS_VIRTUAL_ID.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\?|$)`) interface DocumentModule { pattern: string @@ -31,55 +32,57 @@ export default function documentsPlugin (config: AppConfig): Plugin { configureServer (devServer) { server = devServer }, - resolveId (id) { - if (id.startsWith(DOCS_VIRTUAL_ID)) + resolveId: { + filter: { id: docsVirtualIdFilter }, + handler (id) { return id + }, }, // Extract frontmatter for each file in the matching pattern, and create a // module where the default export is an array with each matching document. - async load (id, options) { - if (!id.startsWith(DOCS_VIRTUAL_ID)) return - - const { query: { pattern: rawPath } } = parseId(id) - - // Extract pattern from the virtual module path, and resolve any alias. - const path = relative(root, await config.resolvePath(rawPath) || rawPath) - const pattern = path.includes('*') ? path : `${path}/**/*.{md,mdx}` - - // Allow Vite to automatically detect added or removed files. - if (server) - modulesById[id] = { pattern, hasDocument: path => micromatch.isMatch(path, pattern) } - - // Obtain files matching the specified pattern and extract frontmatter. - const files = await glob(pattern, { cwd: root }) - debug.documents('%s %O', rawPath, { path, pattern, files }) - - let data = await Promise.all(files.map(async (file) => { - const frontmatter = await pages.api.frontmatterForPageOrFile(file) - frontmatter.meta.filename ||= file - return frontmatter - })) - - // Filter drafts from documents if needed. - if (!drafts) - data = data.filter(page => !page.draft) - debug.documents(`${files.length} files, ${data.length} documents, drafts: ${drafts}`) - - // Create the structure of each document in the default export. - const documents = data.map(({ route: _, meta, layout, ...frontmatter }, index) => { - return { ...meta, ...frontmatter, meta, frontmatter, component: `${index}_component` } - }) - - // Serialize all the documents, adding a `component` factory function. - const serialized = serialize(documents).replace(/component:"(\w+)"/g, (_, id) => { - const index = id.split('_component')[0] - return `component: unwrapDefault(() => import('/${documents[index].filename}'))` - }) - - // Use defineAsyncComponent to support using . - // HMR works by updating the value of the computed ref, while preserving - // any previously resolved component promises to avoid refetching. - return ` + load: { + filter: { id: docsVirtualIdFilter }, + async handler (id, _options) { + const { query: { pattern: rawPath } } = parseId(id) + + // Extract pattern from the virtual module path, and resolve any alias. + const path = relative(root, await config.resolvePath(rawPath) || rawPath) + const pattern = path.includes('*') ? path : `${path}/**/*.{md,mdx}` + + // Allow Vite to automatically detect added or removed files. + if (server) + modulesById[id] = { pattern, hasDocument: path => micromatch.isMatch(path, pattern) } + + // Obtain files matching the specified pattern and extract frontmatter. + const files = await glob(pattern, { cwd: root }) + debug.documents('%s %O', rawPath, { path, pattern, files }) + + let data = await Promise.all(files.map(async (file) => { + const frontmatter = await pages.api.frontmatterForPageOrFile(file) + frontmatter.meta.filename ||= file + return frontmatter + })) + + // Filter drafts from documents if needed. + if (!drafts) + data = data.filter(page => !page.draft) + debug.documents(`${files.length} files, ${data.length} documents, drafts: ${drafts}`) + + // Create the structure of each document in the default export. + const documents = data.map(({ route: _, meta, layout, ...frontmatter }, index) => { + return { ...meta, ...frontmatter, meta, frontmatter, component: `${index}_component` } + }) + + // Serialize all the documents, adding a `component` factory function. + const serialized = serialize(documents).replace(/component:"(\w+)"/g, (_, id) => { + const index = id.split('_component')[0] + return `component: unwrapDefault(() => import('/${documents[index].filename}'))` + }) + + // Use defineAsyncComponent to support using . + // HMR works by updating the value of the computed ref, while preserving + // any previously resolved component promises to avoid refetching. + return ` import { shallowRef, defineAsyncComponent } from 'vue' export const documents = ${serialized} @@ -108,25 +111,29 @@ export default function documentsPlugin (config: AppConfig): Plugin { mod.documents.ref = documents.ref }) } - ` + ` + }, }, - async transform (code, id) { - // Replace each usage of useDocuments with an import of a virtual module. - if (fileCanUseDocuments.test(id) && !definitionRegex.test(code)) { - const paths: [string, string][] = [] - code = code.replace(usageRegex, (_, path) => { - path = path.trim().slice(1, -1) - const id = `_documents_${paths.length}` - paths.push([id, path]) - return id - }) - if (paths.length) { - const imports = paths.map(([id, path]) => - `import ${id} from '${DOCS_VIRTUAL_ID}?pattern=${path}'`) + transform: { + filter: { id: fileCanUseDocuments }, + async handler (code, id) { + // Replace each usage of useDocuments with an import of a virtual module. + if (!definitionRegex.test(code)) { + const paths: [string, string][] = [] + code = code.replace(usageRegex, (_, path) => { + path = path.trim().slice(1, -1) + const id = `_documents_${paths.length}` + paths.push([id, path]) + return id + }) + if (paths.length) { + const imports = paths.map(([id, path]) => + `import ${id} from '${DOCS_VIRTUAL_ID}?pattern=${path}'`) - return `${code};${imports.join(';')}` + return `${code};${imports.join(';')}` + } } - } + }, }, hotUpdate ({ file, modules }) { const relFile = relative(root, file) diff --git a/packages/iles/src/node/plugin/plugin.ts b/packages/iles/src/node/plugin/plugin.ts index e3749217..5d8c6812 100644 --- a/packages/iles/src/node/plugin/plugin.ts +++ b/packages/iles/src/node/plugin/plugin.ts @@ -36,6 +36,7 @@ async function transformUserFile (path: string) { } const templateLayoutRegex = // +const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Public: Configures MDX, Vue, Components, and Islands plugins. export default function IslandsPlugins (appConfig: AppConfig): PluginOption[] { @@ -57,6 +58,12 @@ export default function IslandsPlugins (appConfig: AppConfig): PluginOption[] { return path.includes(appConfig.layoutsDir) } + const resolveIdFilter = new RegExp(`^(?:${ + [ILES_APP_ENTRY, APP_CONFIG_REQUEST_PATH, USER_APP_REQUEST_PATH, USER_SITE_REQUEST_PATH, NOT_FOUND_REQUEST_PATH, defaultLayoutPath] + .map(escapeRegex) + .join('|') + })$`) + return [ { name: 'iles', @@ -74,47 +81,65 @@ export default function IslandsPlugins (appConfig: AppConfig): PluginOption[] { const result = await transformUserFile(appPath) detectMDXComponents(result.code, appConfig, undefined) }, - async resolveId (id) { - if (id === ILES_APP_ENTRY) - return APP_PATH + resolveId: { + filter: { id: resolveIdFilter }, + async handler (id) { + if (id === ILES_APP_ENTRY) + return APP_PATH - if (id === APP_CONFIG_REQUEST_PATH || id === USER_APP_REQUEST_PATH || id === USER_SITE_REQUEST_PATH) - return id + if (id === APP_CONFIG_REQUEST_PATH || id === USER_APP_REQUEST_PATH || id === USER_SITE_REQUEST_PATH) + return id - if (id === NOT_FOUND_REQUEST_PATH) - return NOT_FOUND_COMPONENT_PATH + if (id === NOT_FOUND_REQUEST_PATH) + return NOT_FOUND_COMPONENT_PATH - // Prevent import analysis failure if the default layout doesn't exist. - if (id === defaultLayoutPath) return resolve(root, id.slice(1)) + // Prevent import analysis failure if the default layout doesn't exist. + if (id === defaultLayoutPath) return resolve(root, id.slice(1)) + }, }, - async load (id) { - if (id === APP_CONFIG_REQUEST_PATH) { - const { base, debug, jsx, ssg: { sitemap }, siteUrl, markdown: { overrideElements = [] } } = appConfig - const clientConfig: AppClientConfig = { base, debug, root, jsx, sitemap, siteUrl, overrideElements } - return `export default ${serialize(clientConfig)}` - } - - const userFilename = (id === USER_APP_REQUEST_PATH && appPath) - || (id === USER_SITE_REQUEST_PATH && sitePath) - if (userFilename) { - this.addWatchFile(userFilename) - const result = await transformUserFile(userFilename) - - if (id === USER_APP_REQUEST_PATH) - detectMDXComponents(result.code, appConfig, server) - - if (id === USER_SITE_REQUEST_PATH) - return extendSite(result.code, appConfig) - - return result - } - - if ((isBuild || process.env.VITEST) && id.includes(defaultLayoutPath) && !await exists(resolve(root, defaultLayoutPath.slice(1)))) - return '' + load: { + filter: { + id: { + include: [ + APP_CONFIG_REQUEST_PATH, + USER_APP_REQUEST_PATH, + USER_SITE_REQUEST_PATH, + new RegExp(escapeRegex(defaultLayoutPath)), + ], + }, + }, + async handler (id) { + if (id === APP_CONFIG_REQUEST_PATH) { + const { base, debug, jsx, ssg: { sitemap }, siteUrl, markdown: { overrideElements = [] } } = appConfig + const clientConfig: AppClientConfig = { base, debug, root, jsx, sitemap, siteUrl, overrideElements } + return `export default ${serialize(clientConfig)}` + } + + const userFilename = (id === USER_APP_REQUEST_PATH && appPath) + || (id === USER_SITE_REQUEST_PATH && sitePath) + if (userFilename) { + this.addWatchFile(userFilename) + const result = await transformUserFile(userFilename) + + if (id === USER_APP_REQUEST_PATH) + detectMDXComponents(result.code, appConfig, server) + + if (id === USER_SITE_REQUEST_PATH) + return extendSite(result.code, appConfig) + + return result + } + + if ((isBuild || process.env.VITEST) && id.includes(defaultLayoutPath) && !await exists(resolve(root, defaultLayoutPath.slice(1)))) + return '' + }, }, - transform (code, id) { - if (id === APP_COMPONENT_PATH && !isBuild && appConfig.debug) - return code.replace('const DebugPanel = () => null', () => `import DebugPanel from '${DEBUG_COMPONENT_PATH}'`) + transform: { + filter: { id: APP_COMPONENT_PATH }, + handler (code, _id) { + if (!isBuild && appConfig.debug) + return code.replace('const DebugPanel = () => null', () => `import DebugPanel from '${DEBUG_COMPONENT_PATH}'`) + }, }, hotUpdate ({ file }) { if (file === appPath) return [this.environment.moduleGraph.getModuleById(USER_APP_REQUEST_PATH)!] @@ -128,25 +153,31 @@ export default function IslandsPlugins (appConfig: AppConfig): PluginOption[] { { name: 'iles:detect-islands-in-vue', enforce: 'pre', - async transform (code, id) { - const { path, query } = parseId(id) + transform: { + filter: { id: /\.vue(?:$|\?)/ }, + async handler (code, id) { + const { path, query } = parseId(id) - if (query.vue !== undefined && query.type === 'script-client') - return 'export default {}; if (import.meta.hot) import.meta.hot.accept()' + if (query.vue !== undefined && query.type === 'script-client') + return 'export default {}; if (import.meta.hot) import.meta.hot.accept()' - if (isSFCMain(path, query) && code.includes('client:') && code.includes(' - s.appendRight(sfcIndex, value ? `${key}:${value},` : `${key},`) + transform: { + filter: { id: /\.(?:mdx?|vue)(?:$|\?)/ }, + async handler (code, id, options) { + const { path, query } = parseId(id) + const isMdx = isMarkdown(path) + if (!isMdx && !isVueScript(path, query)) return + + const isLayoutFile = isLayout(path) + const isPage = plugins.pages.api.isPage(path) + if (!isMdx && !isLayoutFile && !isPage) return + + const sfcIndex = indexOfVueComponentDefinition(code) + if (!sfcIndex || sfcIndex === -1) + return + + const s = new MagicString(code) + const appendToSfc = (key: string, value?: string) => + s.appendRight(sfcIndex, value ? `${key}:${value},` : `${key},`) + + if (isLayoutFile) { + appendToSfc('name', `'${pascalCase(basename(path).replace('.vue', 'Layout'))}'`) + return s.toString() + } + + appendToSfc('inheritAttrs', serialize(false)) + + const { meta, layout = 'default', route: _r, ...frontmatter } + = await plugins.pages.api.frontmatterForPageOrFile(path, code) + + if (isMdx) { + // NOTE: Expose each frontmatter property to the MDX file. + const keys = Object.keys(frontmatter) + const bindings = Object.entries(frontmatter) + .map(([key, value]) => `${key} = ${serialize(value)}`) + + bindings.push(`meta = ${serialize(meta)}`) + bindings.push(`frontmatter = { ${keys.length > 0 ? keys.join(', ') : ''} }`) + + s.prepend(`const ${bindings.join(', ')};`) + appendToSfc('...meta, ...frontmatter, meta, frontmatter') + } + else { + s.prepend(`const _meta = ${serialize(meta)}, _frontmatter = ${serialize(frontmatter)};`) + appendToSfc('..._meta, ..._frontmatter, meta: _meta, frontmatter: _frontmatter') + } + + if (isPage) { + appendToSfc('layoutName', serialize(layout)) + appendToSfc('layoutFn', String(layout) === 'false' + ? 'false' + : `() => import('${layoutsRoot}/${layout}.vue').then(m => m.default)`) + } - if (isLayoutFile) { - appendToSfc('name', `'${pascalCase(basename(path).replace('.vue', 'Layout'))}'`) return s.toString() - } - - appendToSfc('inheritAttrs', serialize(false)) - - const { meta, layout = 'default', route: _r, ...frontmatter } - = await plugins.pages.api.frontmatterForPageOrFile(path, code) - - if (isMdx) { - // NOTE: Expose each frontmatter property to the MDX file. - const keys = Object.keys(frontmatter) - const bindings = Object.entries(frontmatter) - .map(([key, value]) => `${key} = ${serialize(value)}`) - - bindings.push(`meta = ${serialize(meta)}`) - bindings.push(`frontmatter = { ${keys.length > 0 ? keys.join(', ') : ''} }`) - - s.prepend(`const ${bindings.join(', ')};`) - appendToSfc('...meta, ...frontmatter, meta, frontmatter') - } - else { - s.prepend(`const _meta = ${serialize(meta)}, _frontmatter = ${serialize(frontmatter)};`) - appendToSfc('..._meta, ..._frontmatter, meta: _meta, frontmatter: _frontmatter') - } - - if (isPage) { - appendToSfc('layoutName', serialize(layout)) - appendToSfc('layoutFn', String(layout) === 'false' - ? 'false' - : `() => import('${layoutsRoot}/${layout}.vue').then(m => m.default)`) - } - - return s.toString() + }, }, }, @@ -231,12 +268,15 @@ export default function IslandsPlugins (appConfig: AppConfig): PluginOption[] { apply: 'serve', enforce: 'post', // Force a refresh for all page computed properties. - async transform (code, id) { - const { path } = parseId(id) - if (isLayout(path) || plugins.pages.api.isPage(path)) { - return `${code} + transform: { + filter: { id: /\.(?:mdx?|vue)(?:$|\?)/ }, + async handler (code, id) { + const { path } = parseId(id) + if (isLayout(path) || plugins.pages.api.isPage(path)) { + return `${code} import.meta.hot?.accept('/${relative(root, path)}', (...args) => __ILES_PAGE_UPDATE__(args)) ` + } } }, }, diff --git a/packages/images/src/images.ts b/packages/images/src/images.ts index ad7e6464..eb5f68a9 100644 --- a/packages/images/src/images.ts +++ b/packages/images/src/images.ts @@ -46,11 +46,12 @@ export default function IlesImagePresets (presets: ImagePresets, options?: Optio plugin, { name: '@islands/images:inject-mdx-component', - transform (code, id) { - if (id.includes('/composables/mdxComponents.js')) { + transform: { + filter: { id: /\/composables\/mdxComponents\.js/ }, + handler (code, _id) { code = code.replace('inject(mdxComponentsKey)', '{ img: _Picture, ...inject(mdxComponentsKey) }') return `import _Picture from '${PICTURE_COMPONENT_PATH}'\n${code}` - } + }, }, }, ], diff --git a/packages/mdx/src/mdx-vite-plugins.ts b/packages/mdx/src/mdx-vite-plugins.ts index eec699a3..d08998b4 100644 --- a/packages/mdx/src/mdx-vite-plugins.ts +++ b/packages/mdx/src/mdx-vite-plugins.ts @@ -1,5 +1,3 @@ -import { extname } from 'path' - import type { Plugin, TransformResult } from 'vite' import type { createFormatAwareProcessors } from '@mdx-js/mdx/internal-create-format-aware-processors' import hash from 'hash-sum' @@ -13,10 +11,6 @@ export default function IlesMdx (options: MarkdownOptions = {}): Plugin[] { let markdownProcessor: ReturnType let isDevelopment: boolean - function shouldTransform (path: string) { - return markdownProcessor.extnames.includes(extname(path)) - } - async function createMdxProcessor (sourcemap: string | boolean) { const { createFormatAwareProcessors } = await import('@mdx-js/mdx/internal-create-format-aware-processors') markdownProcessor = createFormatAwareProcessors({ @@ -28,6 +22,8 @@ export default function IlesMdx (options: MarkdownOptions = {}): Plugin[] { }) } + const markdownIdFilter = /\.(?:md|mdx)(?:$|\?)/ + return [ { name: 'iles:mdx:compile', @@ -37,20 +33,21 @@ export default function IlesMdx (options: MarkdownOptions = {}): Plugin[] { await createMdxProcessor(isDevelopment || config.build.sourcemap) }, - async transform (value, path) { - if (!shouldTransform(path)) return - - const compiled = await markdownProcessor.process({ value, path }) - return { code: String(compiled.value), map: compiled.map } as TransformResult + transform: { + filter: { id: markdownIdFilter }, + async handler (value, path) { + const compiled = await markdownProcessor.process({ value, path }) + return { code: String(compiled.value), map: compiled.map } as TransformResult + }, }, }, { name: 'iles:mdx:sfc', - async transform (code, path) { - if (!shouldTransform(path)) return - - return code.replace('export default function MDXContent', () => ` + transform: { + filter: { id: markdownIdFilter }, + async handler (code, path) { + return code.replace('export default function MDXContent', () => ` import { defineComponent as $defineComponent } from 'iles/jsx-runtime' const _sfc_main = /* @__PURE__ */ $defineComponent(MDXContent, {${ @@ -59,17 +56,18 @@ const _sfc_main = /* @__PURE__ */ $defineComponent(MDXContent, {${ export default _sfc_main function MDXContent`) + }, }, }, { name: 'iles:mdx:hmr', apply: 'serve', - transform (code: string, path: string) { - if (!shouldTransform(path)) return - - const hmrId = hash(`${path.split('?', 2)[0]}default`) + transform: { + filter: { id: markdownIdFilter }, + handler (code: string, path: string) { + const hmrId = hash(`${path.split('?', 2)[0]}default`) - return `${code} + return `${code} _sfc_main.__hmrId = "${hmrId}" __VUE_HMR_RUNTIME__.createRecord("${hmrId}", _sfc_main) import.meta.hot?.accept(({ default: component } = {}) => { @@ -77,6 +75,7 @@ function MDXContent`) __VUE_HMR_RUNTIME__.reload(component.__hmrId, component) }) ` + }, }, }, ] diff --git a/packages/pages/src/pages.ts b/packages/pages/src/pages.ts index 684cc551..8a2d937a 100644 --- a/packages/pages/src/pages.ts +++ b/packages/pages/src/pages.ts @@ -8,6 +8,9 @@ import { MODULE_ID } from './types' export * from './types' +const escapedModuleId = MODULE_ID.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +const moduleIdRegex = new RegExp(`^(?:\\0|/@id/)?${escapedModuleId}(?:\\?.*)?$`) + /** * An iles module that injects remark plugins to parse pages and expose it * to the MDX JS expressions as `meta` and `pages`. @@ -63,17 +66,23 @@ export default function IlesPages (): any { async buildStart () { await api.addAllPages() }, - async resolveId (id) { - if (id === MODULE_ID) + resolveId: { + filter: { id: moduleIdRegex }, + async handler (_id) { return MODULE_ID + }, }, - async load (id) { - if (id === MODULE_ID) + load: { + filter: { id: moduleIdRegex }, + async handler (_id) { return generatedRoutes ||= await api.generateRoutesModule() + }, }, - async transform (_code, id) { - if (id.includes('vue&type=page')) + transform: { + filter: { id: /vue&type=page/ }, + async handler (_code, _id) { return 'export default {};' + }, }, }