From 97dea88f604c2353be9b2bf087344c91371a6c1f Mon Sep 17 00:00:00 2001 From: Anoesj Date: Wed, 19 Mar 2025 18:06:30 +0100 Subject: [PATCH 01/17] feat: volar plugins (WIP) --- package.json | 7 ++ playground/src/pages/custom-name-and-path.vue | 5 +- playground/tsconfig.json | 3 +- playground/typed-router.d.ts | 68 ++++++++++++++++++ pnpm-lock.yaml | 9 ++- src/codegen/generateDTS.ts | 31 ++++++-- .../generateFilePathToRouteNamesMap.ts | 30 ++++++++ src/core/context.ts | 2 + .../volar/entries/sfc-route-blocks.ts | 27 ++++--- src/volar/entries/sfc-typed-router.ts | 72 +++++++++++++++++++ src/volar/utils/augment-vls-ctx.ts | 27 +++++++ tsup-runtime.config.ts | 14 +++- 12 files changed, 273 insertions(+), 22 deletions(-) create mode 100644 src/codegen/generateFilePathToRouteNamesMap.ts rename volar/index.cjs => src/volar/entries/sfc-route-blocks.ts (66%) create mode 100644 src/volar/entries/sfc-typed-router.ts create mode 100644 src/volar/utils/augment-vls-ctx.ts diff --git a/package.json b/package.json index 00d84562d..943d5383c 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,12 @@ "import": "./dist/data-loaders/pinia-colada.js", "require": "./dist/data-loaders/pinia-colada.cjs" }, + "./volar/sfc-route-blocks": { + "require": "./dist/volar/sfc-route-blocks.cjs" + }, + "./volar/sfc-typed-router": { + "require": "./dist/volar/sfc-typed-router.cjs" + }, "./client": { "types": "./client.d.ts" } @@ -141,6 +147,7 @@ "local-pkg": "^1.0.0", "magic-string": "^0.30.17", "mlly": "^1.7.4", + "muggle-string": "^0.4.1", "pathe": "^2.0.2", "picomatch": "^4.0.2", "scule": "^1.3.0", diff --git a/playground/src/pages/custom-name-and-path.vue b/playground/src/pages/custom-name-and-path.vue index 59a93096f..14f00c953 100644 --- a/playground/src/pages/custom-name-and-path.vue +++ b/playground/src/pages/custom-name-and-path.vue @@ -9,4 +9,7 @@ - + diff --git a/playground/tsconfig.json b/playground/tsconfig.json index 7d5fdc430..3bfd00252 100644 --- a/playground/tsconfig.json +++ b/playground/tsconfig.json @@ -35,7 +35,8 @@ }, "vueCompilerOptions": { "plugins": [ - "../volar/index.cjs" + "unplugin-vue-router/volar/sfc-route-blocks", + "unplugin-vue-router/volar/sfc-typed-router" ] }, "references": [ diff --git a/playground/typed-router.d.ts b/playground/typed-router.d.ts index 1cce685e1..e3ff34bff 100644 --- a/playground/typed-router.d.ts +++ b/playground/typed-router.d.ts @@ -73,4 +73,72 @@ declare module 'vue-router/auto-routes' { '/vuefire-tests/get-doc': RouteRecordInfo<'/vuefire-tests/get-doc', '/vuefire-tests/get-doc', Record, Record>, '/with-extension': RouteRecordInfo<'/with-extension', '/with-extension', Record, Record>, } + + /** + * File path to route names map by unplugin-vue-router + */ + export interface FilePathToRouteNamesMap { + '/var/www/open-source/unplugin-vue-router/playground/src/pages/(test-group).vue': '/(test-group)' | '/(test-group)/test-group-child', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/(test-group)/test-group-child.vue': '/(test-group)/test-group-child', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/index.vue': 'home', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/index@named.vue': 'home', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/[name].vue': '/[name]', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/[...path].vue': '/[...path]', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/[...path]+.vue': '/[...path]+', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/@[profileId].vue': '/@[profileId]', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/about.vue': '/about', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/about.extra.nested.vue': '/about.extra.nested', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/articles.vue': '/articles' | '/articles/' | '/articles/[id]' | '/articles/[id]+', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/articles/index.vue': '/articles/', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/articles/[id].vue': '/articles/[id]', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/articles/[id]+.vue': '/articles/[id]+', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/custom-definePage.vue': '/custom-definePage', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/custom-name.vue': 'a rebel', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/deep/nesting/works/too.vue': '/custom/page', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/deep/nesting/works/[[files]]+.vue': '/deep/nesting/works/[[files]]+', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/deep/nesting/works/too.vue': '/deep/nesting/works/at-root-but-from-nested', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/deep/nesting/works/custom-name-and-path.vue': 'deep the most rebel', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/deep/nesting/works/custom-path.vue': '/deep/nesting/works/custom-path', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/deep/nesting/works/custom-name.vue': 'deep a rebel', + '/var/www/open-source/unplugin-vue-router/playground/src/docs/real/index.md': '/docs/[lang]/real/', + '/var/www/open-source/unplugin-vue-router/playground/src/features/feature-1/pages/index.vue': '/feature-1/', + '/var/www/open-source/unplugin-vue-router/playground/src/features/feature-1/pages/about.vue': '/feature-1/about', + '/var/www/open-source/unplugin-vue-router/playground/src/features/feature-2/pages/index.vue': '/feature-2/', + '/var/www/open-source/unplugin-vue-router/playground/src/features/feature-2/pages/about.vue': '/feature-2/about', + '/var/www/open-source/unplugin-vue-router/playground/src/features/feature-3/pages/index.vue': '/feature-3/', + '/var/www/open-source/unplugin-vue-router/playground/src/features/feature-3/pages/about.vue': '/feature-3/about', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/file(ignored-parentheses).vue': '/file(ignored-parentheses)', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/index.vue': '/from-root', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/group/(thing).vue': '/group/(thing)', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/custom-name-and-path.vue': 'the most rebel', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/multiple-[a]-[b]-params.vue': '/multiple-[a]-[b]-params', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/my-optional-[[slug]].vue': '/my-optional-[[slug]]', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/n-[[n]]/index.vue': '/n-[[n]]/', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/n-[[n]]/[[more]]+/index.vue': '/n-[[n]]/[[more]]+/', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/n-[[n]]/[[more]]+/[final].vue': '/n-[[n]]/[[more]]+/[final]', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/nested-group/(group).vue': '/nested-group/(group)', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/nested-group/(nested-group-first-level)/(nested-group-deep)/nested-group-deep-child.vue': '/nested-group/(nested-group-first-level)/(nested-group-deep)/nested-group-deep-child', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/nested-group/(nested-group-first-level)/nested-group-first-level-child.vue': '/nested-group/(nested-group-first-level)/nested-group-first-level-child', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/partial-[name].vue': '/partial-[name]', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/custom-path.vue': '/custom-path', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/test-[a-id].vue': '/test-[a-id]', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/todos/index.vue': '/todos/', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/users/index.vue': '/users/', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/users/[id].vue': '/users/[id]', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/users/[id].edit.vue': '/users/[id].edit', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/users/colada-loader.[id].vue': '/users/colada-loader.[id]', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/users/nested.route.deep.vue': '/users/nested.route.deep', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/users/pinia-colada.[id].vue': '/users/pinia-colada.[id]', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/users/query.[id].vue': '/users/query.[id]', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/users/tq-query.[id].vue': '/users/tq-query.[id]', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/vuefire-tests/get-doc.vue': '/vuefire-tests/get-doc', + '/var/www/open-source/unplugin-vue-router/playground/src/pages/with-extension.page.vue': '/with-extension', + } + + /** + * Get a route's name by file path + */ + export type GetRouteNameByPath = T extends keyof FilePathToRouteNamesMap + ? FilePathToRouteNamesMap[T] + : keyof import('vue-router/auto-routes').RouteNamedMap } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e215ea6f9..f95eb6cdc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: mlly: specifier: ^1.7.4 version: 1.7.4 + muggle-string: + specifier: ^0.4.1 + version: 0.4.1 pathe: specifier: ^2.0.2 version: 2.0.2 @@ -5928,7 +5931,7 @@ packages: resolution: {integrity: sha512-jBYKBNFADTN+L+MdesNX/TB3XuDSyaWynKMDgR+yCSln0GQ9Tfb7JS2lr46s2LiFUT1WsmfWsSvIElyxzOPqcQ==} hasBin: true peerDependencies: - typescript: '>=5.0.0' + typescript: 5.8.2 vue@3.5.13: resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==} @@ -8283,7 +8286,7 @@ snapshots: '@vue/shared': 3.5.13 estree-walker: 2.0.2 magic-string: 0.30.17 - postcss: 8.5.1 + postcss: 8.5.3 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.13': @@ -12577,7 +12580,7 @@ snapshots: vite@5.4.14(@types/node@22.13.1)(terser@5.39.0): dependencies: esbuild: 0.21.5 - postcss: 8.5.1 + postcss: 8.5.3 rollup: 4.34.6 optionalDependencies: '@types/node': 22.13.1 diff --git a/src/codegen/generateDTS.ts b/src/codegen/generateDTS.ts index 02cba64c2..b749ef9e4 100644 --- a/src/codegen/generateDTS.ts +++ b/src/codegen/generateDTS.ts @@ -1,12 +1,24 @@ import { ts } from '../utils' +/** + * Removes empty lines and indent by two spaces to match the rest of the file. + */ +function normalizeLines(code: string) { + return code.split('\n') + .filter((line) => line.length !== 0) + .map((line) => ' ' + line) + .join('\n') +} + export function generateDTS({ routesModule, routeNamedMap, + filePathToRouteNamesMap, }: { vueRouterModule: string routesModule: string routeNamedMap: string + filePathToRouteNamesMap: string }) { return ts` /* eslint-disable */ @@ -28,12 +40,19 @@ declare module '${routesModule}' { /** * Route name map generated by unplugin-vue-router */ -${routeNamedMap - // remove empty lines and indent by two spaces to match the rest of the file - .split('\n') - .filter((line) => line.length !== 0) - .map((line) => ' ' + line) - .join('\n')} +${normalizeLines(routeNamedMap)} + + /** + * File path to route names map by unplugin-vue-router + */ +${normalizeLines(filePathToRouteNamesMap)} + + /** + * Get a route's name by file path + */ + export type GetRouteNameByPath = T extends keyof FilePathToRouteNamesMap + ? FilePathToRouteNamesMap[T] + : keyof import('vue-router/auto-routes').RouteNamedMap } `.trimStart() } diff --git a/src/codegen/generateFilePathToRouteNamesMap.ts b/src/codegen/generateFilePathToRouteNamesMap.ts new file mode 100644 index 000000000..3157173c5 --- /dev/null +++ b/src/codegen/generateFilePathToRouteNamesMap.ts @@ -0,0 +1,30 @@ +import type { TreeNode } from '../core/tree' + +export function generateFilePathToRouteNamesMap(node: TreeNode): string { + if (node.isRoot()) { + return `export interface FilePathToRouteNamesMap { +${node.getSortedChildren().map(generateFilePathToRouteNamesMap).join('')}}` + } + + const routeNamesUnion = recursiveGetRouteNames(node).map(name => `'${name}'`).join(' | ') + + return ( + // if the node has a filePath, it's a component, it has a routeName and it should be + // referenced in the FilePathToRouteNamesMap otherwise it should be skipped + // TODO: can we use `RouteNameWithChildren` from https://github.com/vuejs/router/pull/2475 here if merged? + Array.from(node.value.components.values().map(file => ` '${file}': ${routeNamesUnion},\n`)).join('') + + (node.children.size > 0 + ? node.getSortedChildren().map(generateFilePathToRouteNamesMap).join('\n') + : '') + ) +} + +/** + * Gets the name of the provided node and all of its children + */ +function recursiveGetRouteNames (node: TreeNode): TreeNode['name'][] { + return [ + node.name, + ...node.getSortedChildren().values().map(child => recursiveGetRouteNames(child)) + ].flat() +} diff --git a/src/core/context.ts b/src/core/context.ts index 5d9de314e..f6f0f8e71 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -3,6 +3,7 @@ import { TreeNode, PrefixTree } from './tree' import { promises as fs } from 'fs' import { asRoutePath, ImportsMap, logTree, throttle } from './utils' import { generateRouteNamedMap } from '../codegen/generateRouteMap' +import { generateFilePathToRouteNamesMap } from '../codegen/generateFilePathToRouteNamesMap' import { MODULE_ROUTES_PATH, MODULE_VUE_ROUTER_AUTO } from './moduleConstants' import { generateRouteRecord } from '../codegen/generateRouteRecords' import fg from 'fast-glob' @@ -240,6 +241,7 @@ if (import.meta.hot) { vueRouterModule: MODULE_VUE_ROUTER_AUTO, routesModule: MODULE_ROUTES_PATH, routeNamedMap: generateRouteNamedMap(routeTree), + filePathToRouteNamesMap: generateFilePathToRouteNamesMap(routeTree), }) } diff --git a/volar/index.cjs b/src/volar/entries/sfc-route-blocks.ts similarity index 66% rename from volar/index.cjs rename to src/volar/entries/sfc-route-blocks.ts index 8de4fc1e6..2c1e159ea 100644 --- a/volar/index.cjs +++ b/src/volar/entries/sfc-route-blocks.ts @@ -1,27 +1,34 @@ -// @ts-check +import type { VueLanguagePlugin } from '@vue/language-core' -/** - * @type {import('@vue/language-core').VueLanguagePlugin} - */ -const plugin = () => { +const plugin: VueLanguagePlugin = () => { return { version: 2.1, - getEmbeddedCodes(fileName, sfc) { + getEmbeddedCodes(_fileName, sfc) { const names = []; + for (let i = 0; i < sfc.customBlocks.length; i++) { - const block = sfc.customBlocks[i] + const block = sfc.customBlocks[i]! + if (block.type === 'route') { + console.log(block.lang) const lang = block.lang === 'txt' ? 'json' : block.lang names.push({ id: `route_${i}`, lang }) } } + return names }, - resolveEmbeddedCode(fileName, sfc, embeddedCode) { + resolveEmbeddedCode(_fileName, sfc, embeddedCode) { const match = embeddedCode.id.match(/^route_(\d+)$/) - if (match) { + + if (match && match[1] !== undefined) { const index = parseInt(match[1]) const block = sfc.customBlocks[index] + + if (!block) { + return + } + embeddedCode.content.push([ block.content, block.name, @@ -40,4 +47,4 @@ const plugin = () => { } } -module.exports = plugin +export default plugin diff --git a/src/volar/entries/sfc-typed-router.ts b/src/volar/entries/sfc-typed-router.ts new file mode 100644 index 000000000..eef83b1b7 --- /dev/null +++ b/src/volar/entries/sfc-typed-router.ts @@ -0,0 +1,72 @@ +import type { VueLanguagePlugin } from '@vue/language-core' +import { replaceAll, toString } from 'muggle-string' +import { augmentVlsCtx } from '../utils/augment-vls-ctx' + +const plugin: VueLanguagePlugin = () => { + const RE = { + USE_ROUTE: { + /** Targets the spot between `useRoute` and `()` */ + BEFORE_PARENTHESES: /(?<=useRoute)(\s*)(?=\(\))/g, + /** Targets the spot right before `useRoute()` */ + BEFORE: /(?=useRoute(\s*)\(\))/g, + /** Targets the spot right after `useRoute()` */ + AFTER: /(?<=useRoute(\s*)\(\))/g, + }, + DOLLAR_ROUTE: { + /** + * When using `$route` in a template, it is referred + * to as `__VLS_ctx.$route` in the virtual file. + */ + VLS_CTX: /\b__VLS_ctx.\$route\b/g, + }, + } + + return { + version: 2.1, + resolveEmbeddedCode (fileName, _sfc, embeddedFile) { + if (!embeddedFile.id.startsWith('script_')) { + return + } + + // TODO: Do we want to apply this to EVERY .vue file or only to components that the user wrote themselves? + + const routeNameGetter = `import('vue-router/auto-routes').GetRouteNameByPath<'${fileName}'>` + const routeNameGetterGeneric = `<${routeNameGetter}>` + const typedCall = `useRoute${routeNameGetterGeneric}` + + if (embeddedFile.id.startsWith('script_ts')) { + // Inserts generic into `useRoute()` calls. + // We only apply this mutation on