From 491d02f6ca77ba20a99f112651eef722f06bdecd Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 2 Nov 2022 06:28:41 -0400 Subject: [PATCH] fix(nuxt)!: use parser to generate page metadata (#8536) --- packages/nuxt/package.json | 1 + packages/nuxt/src/pages/macros.ts | 133 --------------- packages/nuxt/src/pages/module.ts | 18 ++- packages/nuxt/src/pages/page-meta.ts | 160 +++++++++++++++++++ packages/nuxt/src/pages/utils.ts | 2 +- packages/vite/src/plugins/composable-keys.ts | 2 +- pnpm-lock.yaml | 2 + test/basic.test.ts | 13 +- test/fixtures/basic/pages/index.vue | 7 +- 9 files changed, 185 insertions(+), 153 deletions(-) delete mode 100644 packages/nuxt/src/pages/macros.ts create mode 100644 packages/nuxt/src/pages/page-meta.ts diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index e14c533d64..70374d0fe8 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -50,6 +50,7 @@ "defu": "^6.1.0", "destr": "^1.2.0", "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.1", "fs-extra": "^10.1.0", "globby": "^13.1.2", "h3": "^0.8.6", diff --git a/packages/nuxt/src/pages/macros.ts b/packages/nuxt/src/pages/macros.ts deleted file mode 100644 index ec4e9a7ab6..0000000000 --- a/packages/nuxt/src/pages/macros.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { pathToFileURL } from 'node:url' -import { createUnplugin } from 'unplugin' -import { parseQuery, parseURL, withQuery } from 'ufo' -import { findStaticImports, findExports } from 'mlly' -import MagicString from 'magic-string' -import { isAbsolute } from 'pathe' - -export interface TransformMacroPluginOptions { - macros: Record - dev?: boolean - sourcemap?: boolean -} - -export const TransformMacroPlugin = createUnplugin((options: TransformMacroPluginOptions) => { - return { - name: 'nuxt:pages-macros-transform', - enforce: 'post', - transformInclude (id) { - if (!id || id.startsWith('\x00')) { return false } - const { pathname, search } = parseURL(decodeURIComponent(pathToFileURL(id).href)) - return pathname.endsWith('.vue') || !!parseQuery(search).macro - }, - transform (code, id) { - const s = new MagicString(code) - const { search } = parseURL(decodeURIComponent(pathToFileURL(id).href)) - - function result () { - if (s.hasChanged()) { - return { - code: s.toString(), - map: options.sourcemap - ? s.generateMap({ source: id, includeContent: true }) - : undefined - } - } - } - - // Tree-shake out any runtime references to the macro. - // We do this first as it applies to all files, not just those with the query - for (const macro in options.macros) { - const match = code.match(new RegExp(`\\b${macro}\\s*\\(\\s*`)) - if (match?.[0]) { - s.overwrite(match.index!, match.index! + match[0].length, `/*#__PURE__*/ false && ${match[0]}`) - } - } - - if (!parseQuery(search).macro) { - return result() - } - - const imports = findStaticImports(code) - - // Purge all imports bringing side effects, such as CSS imports - for (const entry of imports) { - if (!entry.imports) { - s.remove(entry.start, entry.end) - } - } - - // [webpack] Re-export any imports from script blocks in the components - // with workaround for vue-loader bug: https://github.com/vuejs/vue-loader/pull/1911 - const scriptImport = imports.find(i => parseQuery(i.specifier.replace('?macro=true', '')).type === 'script') - if (scriptImport) { - // https://github.com/vuejs/vue-loader/pull/1911 - // https://github.com/vitejs/vite/issues/8473 - const url = isAbsolute(scriptImport.specifier) ? pathToFileURL(scriptImport.specifier).href : scriptImport.specifier - const parsed = parseURL(decodeURIComponent(url).replace('?macro=true', '')) - const specifier = withQuery(parsed.pathname, { macro: 'true', ...parseQuery(parsed.search) }) - s.overwrite(0, code.length, `export { meta } from "${specifier}"`) - return result() - } - - const currentExports = findExports(code) - for (const match of currentExports) { - if (match.type !== 'default') { - continue - } - if (match.specifier && match._type === 'named') { - // [webpack] Export named exports rather than the default (component) - s.overwrite(match.start, match.end, `export {${Object.values(options.macros).join(', ')}} from "${match.specifier}"`) - return result() - } else if (!options.dev) { - // ensure we tree-shake any _other_ default exports out of the macro script - s.overwrite(match.start, match.end, '/*#__PURE__*/ false &&') - s.append('\nexport default {}') - } - } - - for (const macro in options.macros) { - // Skip already-processed macros - if (currentExports.some(e => e.name === options.macros[macro])) { - continue - } - - const { 0: match, index = 0 } = code.match(new RegExp(`\\b${macro}\\s*\\(\\s*`)) || {} as RegExpMatchArray - const macroContent = match ? extractObject(code.slice(index + match.length)) : 'undefined' - - s.append(`\nexport const ${options.macros[macro]} = ${macroContent}`) - } - - return result() - } - } -}) - -const starts = { - '{': '}', - '[': ']', - '(': ')', - '<': '>', - '"': '"', - "'": "'" -} - -const QUOTE_RE = /["']/ - -function extractObject (code: string) { - // Strip comments - code = code.replace(/^\s*\/\/.*$/gm, '') - - const stack: string[] = [] - let result = '' - do { - if (stack[0] === code[0] && result.slice(-1) !== '\\') { - stack.shift() - } else if (code[0] in starts && !QUOTE_RE.test(stack[0])) { - stack.unshift(starts[code[0] as keyof typeof starts]) - } - result += code[0] - code = code.slice(1) - } while (stack.length && code.length) - return result -} diff --git a/packages/nuxt/src/pages/module.ts b/packages/nuxt/src/pages/module.ts index 38d5d91353..0ed574b9d8 100644 --- a/packages/nuxt/src/pages/module.ts +++ b/packages/nuxt/src/pages/module.ts @@ -7,7 +7,7 @@ import type { NuxtApp, NuxtPage } from '@nuxt/schema' import { joinURL } from 'ufo' import { distDir } from '../dirs' import { resolvePagesRoutes, normalizeRoutes } from './utils' -import { TransformMacroPlugin, TransformMacroPluginOptions } from './macros' +import { PageMetaPlugin, PageMetaPluginOptions } from './page-meta' export default defineNuxtModule({ meta: { @@ -64,7 +64,9 @@ export default defineNuxtModule({ const pathPattern = new RegExp(`(^|\\/)(${dirs.map(escapeRE).join('|')})/`) if (event !== 'change' && path.match(pathPattern)) { - await updateTemplates() + await updateTemplates({ + filter: template => template.filename === 'routes.mjs' + }) } }) @@ -114,15 +116,15 @@ export default defineNuxtModule({ }) // Extract macros from pages - const macroOptions: TransformMacroPluginOptions = { + const pageMetaOptions: PageMetaPluginOptions = { dev: nuxt.options.dev, sourcemap: nuxt.options.sourcemap.server || nuxt.options.sourcemap.client, - macros: { - definePageMeta: 'meta' - } + dirs: nuxt.options._layers.map( + layer => resolve(layer.config.srcDir, layer.config.dir?.pages || 'pages') + ) } - addVitePlugin(TransformMacroPlugin.vite(macroOptions)) - addWebpackPlugin(TransformMacroPlugin.webpack(macroOptions)) + addVitePlugin(PageMetaPlugin.vite(pageMetaOptions)) + addWebpackPlugin(PageMetaPlugin.webpack(pageMetaOptions)) // Add router plugin addPlugin(resolve(runtimeDir, 'router')) diff --git a/packages/nuxt/src/pages/page-meta.ts b/packages/nuxt/src/pages/page-meta.ts new file mode 100644 index 0000000000..e4a76cf28a --- /dev/null +++ b/packages/nuxt/src/pages/page-meta.ts @@ -0,0 +1,160 @@ +import { pathToFileURL } from 'node:url' +import { createUnplugin } from 'unplugin' +import { parseQuery, parseURL, stringifyQuery } from 'ufo' +import { findStaticImports, findExports, StaticImport, parseStaticImport } from 'mlly' +import type { CallExpression, Expression } from 'estree' +import { walk } from 'estree-walker' +import MagicString from 'magic-string' +import { isAbsolute, normalize } from 'pathe' + +export interface PageMetaPluginOptions { + dirs: Array + dev?: boolean + sourcemap?: boolean +} + +export const PageMetaPlugin = createUnplugin((options: PageMetaPluginOptions) => { + return { + name: 'nuxt:pages-macros-transform', + enforce: 'post', + transformInclude (id) { + const query = parseMacroQuery(id) + id = normalize(id) + + const isPagesDir = options.dirs.some(dir => typeof dir === 'string' ? id.startsWith(dir) : dir.test(id)) + if (!isPagesDir && !query.macro) { return false } + + const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href)) + return /\.(m?[jt]sx?|vue)/.test(pathname) + }, + transform (code, id) { + const query = parseMacroQuery(id) + if (query.type && query.type !== 'script') { return } + + const s = new MagicString(code) + function result () { + if (s.hasChanged()) { + return { + code: s.toString(), + map: options.sourcemap + ? s.generateMap({ source: id, includeContent: true }) + : undefined + } + } + } + + const hasMacro = code.match(/\bdefinePageMeta\s*\(\s*/) + + // Remove any references to the macro from our pages + if (!query.macro) { + if (hasMacro) { + walk(this.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest' + }), { + enter (_node) { + if (_node.type !== 'CallExpression' || (_node as CallExpression).callee.type !== 'Identifier') { return } + const node = _node as CallExpression & { start: number, end: number } + const name = 'name' in node.callee && node.callee.name + if (name === 'definePageMeta') { + s.overwrite(node.start, node.end, 'false && {}') + } + } + }) + } + return result() + } + + const imports = findStaticImports(code) + + // [vite] Re-export any script imports + const scriptImport = imports.find(i => parseMacroQuery(i.specifier).type === 'script') + if (scriptImport) { + const specifier = rewriteQuery(scriptImport.specifier) + s.overwrite(0, code.length, `export { default } from ${JSON.stringify(specifier)}`) + return result() + } + + // [webpack] Re-export any exports from script blocks in the components + const currentExports = findExports(code) + for (const match of currentExports) { + if (match.type !== 'default' || !match.specifier) { + continue + } + + const specifier = rewriteQuery(match.specifier) + s.overwrite(0, code.length, `export { default } from ${JSON.stringify(specifier)}`) + return result() + } + + if (!hasMacro && !code.includes('export { default }') && !code.includes('__nuxt_page_meta')) { + s.overwrite(0, code.length, 'export default {}') + return result() + } + + const importMap = new Map() + for (const i of imports) { + const parsed = parseStaticImport(i) + for (const name of [ + parsed.defaultImport, + ...Object.keys(parsed.namedImports || {}), + parsed.namespacedImport + ].filter(Boolean) as string[]) { + importMap.set(name, i) + } + } + + walk(this.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest' + }), { + enter (_node) { + if (_node.type !== 'CallExpression' || (_node as CallExpression).callee.type !== 'Identifier') { return } + const node = _node as CallExpression & { start: number, end: number } + const name = 'name' in node.callee && node.callee.name + if (name !== 'definePageMeta') { return } + + const meta = node.arguments[0] as Expression & { start: number, end: number } + + let contents = `const __nuxt_page_meta = ${code!.slice(meta.start, meta.end) || '{}'}\nexport default __nuxt_page_meta` + + walk(meta, { + enter (_node) { + if (_node.type === 'CallExpression') { + const node = _node as CallExpression & { start: number, end: number } + const name = 'name' in node.callee && node.callee.name + if (name && importMap.has(name)) { + contents = importMap.get(name)!.code + '\n' + contents + } + } + } + }) + + s.overwrite(0, code.length, contents) + } + }) + + if (!s.hasChanged() && !code.includes('__nuxt_page_meta')) { + s.overwrite(0, code.length, 'export default {}') + } + + return result() + } + } +}) + +// https://github.com/vuejs/vue-loader/pull/1911 +// https://github.com/vitejs/vite/issues/8473 +function rewriteQuery (id: string) { + const query = stringifyQuery({ macro: 'true', ...parseMacroQuery(id) }) + return id.replace(/\?.+$/, '?' + query) +} + +function parseMacroQuery (id: string) { + const { search } = parseURL(decodeURIComponent(isAbsolute(id) ? pathToFileURL(id).href : id).replace(/\?macro=true$/, '')) + const query = parseQuery(search) + if (id.includes('?macro=true')) { + return { macro: 'true', ...query } + } + return query +} diff --git a/packages/nuxt/src/pages/utils.ts b/packages/nuxt/src/pages/utils.ts index 2bc25b3f0a..e52e6b76cb 100644 --- a/packages/nuxt/src/pages/utils.ts +++ b/packages/nuxt/src/pages/utils.ts @@ -231,7 +231,7 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set = routes: genArrayFromRaw(routes.map((route) => { const file = normalize(route.file) const metaImportName = genSafeVariableName(file) + 'Meta' - metaImports.add(genImport(`${file}?macro=true`, [{ name: 'meta', as: metaImportName }])) + metaImports.add(genImport(`${file}?macro=true`, [{ name: 'default', as: metaImportName }])) let aliasCode = `${metaImportName}?.alias || []` if (Array.isArray(route.alias) && route.alias.length) { diff --git a/packages/vite/src/plugins/composable-keys.ts b/packages/vite/src/plugins/composable-keys.ts index 1b649363c0..f85db90343 100644 --- a/packages/vite/src/plugins/composable-keys.ts +++ b/packages/vite/src/plugins/composable-keys.ts @@ -24,7 +24,7 @@ export const composableKeysPlugin = createUnplugin((options: ComposableKeysOptio enforce: 'post', transformInclude (id) { const { pathname, search } = parseURL(decodeURIComponent(pathToFileURL(id).href)) - return !pathname.match(/node_modules\/nuxt3?\//) && pathname.match(/\.(m?[jt]sx?|vue)/) && parseQuery(search).type !== 'style' + return !pathname.match(/node_modules\/nuxt3?\//) && pathname.match(/\.(m?[jt]sx?|vue)/) && parseQuery(search).type !== 'style' && !parseQuery(search).macro }, transform (code, id) { if (!KEYED_FUNCTIONS_RE.test(code)) { return } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d15bcdc88..4a5e1593eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,6 +425,7 @@ importers: defu: ^6.1.0 destr: ^1.2.0 escape-string-regexp: ^5.0.0 + estree-walker: ^3.0.1 fs-extra: ^10.1.0 globby: ^13.1.2 h3: ^0.8.6 @@ -469,6 +470,7 @@ importers: defu: 6.1.0 destr: 1.2.0 escape-string-regexp: 5.0.0 + estree-walker: 3.0.1 fs-extra: 10.1.0 globby: 13.1.2 h3: 0.8.6 diff --git a/test/basic.test.ts b/test/basic.test.ts index 7d95f6712c..a016df07b6 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -600,8 +600,7 @@ describe.skipIf(process.env.NUXT_TEST_DEV || process.env.TEST_WITH_WEBPACK)('inl '{--scoped:"scoped"}', //