From f3eadce3dbb02d2b725134a58ed72acf10ccfade Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 27 Oct 2022 18:18:51 +0200 Subject: [PATCH 1/6] fix(nuxt): use parser to generate page metadata --- packages/nuxt/src/pages/macros.ts | 133 -------------------------- packages/nuxt/src/pages/module.ts | 18 ++-- packages/nuxt/src/pages/page-meta.ts | 134 +++++++++++++++++++++++++++ packages/nuxt/src/pages/utils.ts | 2 +- test/basic.test.ts | 13 ++- test/fixtures/basic/pages/index.vue | 7 +- 6 files changed, 155 insertions(+), 152 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/src/pages/macros.ts b/packages/nuxt/src/pages/macros.ts deleted file mode 100644 index ec4e9a7ab6a..00000000000 --- 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 a059281e6b0..70bcf368ab4 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: { @@ -48,7 +48,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' + }) } }) @@ -98,15 +100,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 00000000000..65f2e67bac0 --- /dev/null +++ b/packages/nuxt/src/pages/page-meta.ts @@ -0,0 +1,134 @@ +import { pathToFileURL } from 'node:url' +import { createUnplugin } from 'unplugin' +import { parseQuery, parseURL, withQuery } 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 } 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) { + return options.dirs.some(dir => typeof dir === 'string' ? id.startsWith(dir) : dir.test(id)) + }, + 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 + } + } + } + + const macroMatch = code.match(/\bdefinePageMeta\s*\(\s*/) + + // Remove any references to the macro from our pages + if (!parseQuery(search).macro) { + if (macroMatch) { + walk(this.parse(code), { + 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) + + // [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 { default } from "${specifier}"`) + return result() + } + + if (!macroMatch && !code.includes('export { default }') && !code.includes('__nuxt_page_meta')) { + s.overwrite(0, code.length, 'export default {}') + return result() + } + + const currentExports = findExports(code) + for (const match of currentExports) { + if (match.type !== 'default') { + continue + } + if (match.specifier && match._type === 'named') { + s.overwrite(0, code.length, `export { default } from "${match.specifier}"`) + return result() + } + } + + const importMap = new Map() + for (const i of findStaticImports(code)) { + 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), { + 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() + } + } +}) diff --git a/packages/nuxt/src/pages/utils.ts b/packages/nuxt/src/pages/utils.ts index 2bc25b3f0a5..e52e6b76cbc 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/test/basic.test.ts b/test/basic.test.ts index 7d95f6712c1..a016df07b6d 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"}', //