diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index c043c2ba5..101fa3f45 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -25,6 +25,6 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm run lint --write - - uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef + - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 with: commit-message: 'style: fix code style' diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml index 4f0c4f535..f472a0bad 100644 --- a/.github/workflows/pkg.pr.new.yml +++ b/.github/workflows/pkg.pr.new.yml @@ -40,4 +40,4 @@ jobs: run: pnpm build - name: Release - run: pnpx pkg-pr-new publish --comment=off --compact --pnpm . + run: pnpx pkg-pr-new publish --compact --pnpm . diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a3035e5a..3870f9ba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +# [0.12.0](https://github.com/posva/unplugin-vue-router/compare/v0.11.2...v0.12.0) (2025-03-04) + +### Bug Fixes + +- **data-loaders:** allow nested loaders to run on invalidation ([0665635](https://github.com/posva/unplugin-vue-router/commit/0665635f78a3cbebcb676c288545e870f76a9243)), closes [#583](https://github.com/posva/unplugin-vue-router/issues/583) +- unpin `unplugin` ([#592](https://github.com/posva/unplugin-vue-router/issues/592)) ([89daf52](https://github.com/posva/unplugin-vue-router/commit/89daf524bd71c01a48cc7c02021e20388666da79)) + +### Performance Improvements + +- replace `@rollup/pluginutils` with `unplugin-utils` ([#579](https://github.com/posva/unplugin-vue-router/issues/579)) ([e83a972](https://github.com/posva/unplugin-vue-router/commit/e83a972feb2156191353dbf32411f1d6fd6f9142)) + +## [0.11.2](https://github.com/posva/unplugin-vue-router/compare/v0.11.1...v0.11.2) (2025-01-26) + +### Features + +- allow HMR Callback ([170df11](https://github.com/posva/unplugin-vue-router/commit/170df1187fd488b8d4eceef4fa6895a54bc711dc)), closes [#503](https://github.com/posva/unplugin-vue-router/issues/503) +- fix indent in generated js for auto-routes ([b734d9a](https://github.com/posva/unplugin-vue-router/commit/b734d9a1883eef472ca38514f5d44f7f99e4f20b)) + +## [0.11.1](https://github.com/posva/unplugin-vue-router/compare/v0.11.0...v0.11.1) (2025-01-21) + +### Bug Fixes + +- remove empty chunks ([#575](https://github.com/posva/unplugin-vue-router/issues/575)) ([02b0e24](https://github.com/posva/unplugin-vue-router/commit/02b0e243c1866f8fb4aa0e4d33ece7eb21cb0ea9)) + # [0.11.0](https://github.com/posva/unplugin-vue-router/compare/v0.10.9...v0.11.0) (2025-01-21) ### Bug Fixes diff --git a/client.d.ts b/client.d.ts index 01d734ef3..e6afb9f3b 100644 --- a/client.d.ts +++ b/client.d.ts @@ -9,6 +9,7 @@ declare module 'vue-router/auto-routes' { /** * Setups hot module replacement for routes. * @param router - The router instance + * @param hotUpdateCallback - Callback to be called after replacing the routes and before the navigation * @example * ```ts * import { createRouter, createWebHistory } from 'vue-router' @@ -22,7 +23,10 @@ declare module 'vue-router/auto-routes' { * } * ``` */ - export function handleHotUpdate(router: Router): void + export function handleHotUpdate( + router: Router, + hotUpdateCallback?: (newRoutes: RouteRecordRaw[]) => void + ): void } declare module 'vue-router' { diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index e3f0586b6..8999ac06c 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,6 +1,7 @@ import { DefaultTheme, defineConfig } from 'vitepress' import { transformerTwoslash } from '@shikijs/vitepress-twoslash' -import { version } from '../../package.json' +import { ModuleResolutionKind } from 'typescript' +import llmstxt from 'vitepress-plugin-llms' import { headTitle, headDescription, @@ -9,11 +10,37 @@ import { releases, discord, } from './meta' +import { version } from '../../package.json' import { typedRouterFile, typedRouterFileAsModule } from './twoslash-files' import { extraFiles } from './twoslash/files' -import { ModuleResolutionKind } from 'typescript' export default defineConfig({ + title: headTitle, + description: headDescription, + + vite: { + plugins: [ + llmstxt({ + description: 'The official Router for Vue.js', + details: ` +- Type Safe routes +- File based routing +- Data Loaders for efficient data fetching +`.trim(), + ignoreFiles: [ + // + // path.join(__dirname, '../'), + // path.join(__dirname, '../../public/'), + // path.join(__dirname, '../../api/**/*'), + // path.join(__dirname, '../../index.md'), + 'index.md', + // 'api/*', + 'api/**/*', + ], + }), + ], + }, + markdown: { codeTransformers: [ transformerTwoslash({ @@ -31,9 +58,6 @@ export default defineConfig({ ], }, - title: headTitle, - description: headDescription, - head: [ // ['meta', { name: 'theme-color', content: '#ffca28' }], // TODO: icon and color @@ -220,6 +244,10 @@ function sidebarDataLoaders(): SidebarGroup { text: 'Cancelling a load', link: '/data-loaders/load-cancellation', }, + { + text: 'Nuxt', + link: '/data-loaders/nuxt', + }, { text: 'SSR', link: '/data-loaders/ssr', diff --git a/docs/.vitepress/twoslash-files.ts b/docs/.vitepress/twoslash-files.ts index d00078d09..b8ed18561 100644 --- a/docs/.vitepress/twoslash-files.ts +++ b/docs/.vitepress/twoslash-files.ts @@ -8,25 +8,37 @@ declare module 'vue-router/auto-routes' { ParamValueZeroOrOne, } from 'vue-router' + /** + * Route name map generated by unplugin-vue-router + */ export interface RouteNamedMap { - '/': RouteRecordInfo<'/', '/', Record, Record> + '/': RouteRecordInfo< + '/', + '/', + Record, + Record, + never + > '/users': RouteRecordInfo< '/users', '/users', Record, - Record + Record, + never > '/users/[id]': RouteRecordInfo< '/users/[id]', '/users/:id', { id: ParamValue }, - { id: ParamValue } + { id: ParamValue }, + '/users/[id]/edit' > '/users/[id]/edit': RouteRecordInfo< '/users/[id]/edit', '/users/:id/edit', { id: ParamValue }, - { id: ParamValue } + { id: ParamValue }, + never > } } diff --git a/docs/.vitepress/twoslash/code/typed-router.ts b/docs/.vitepress/twoslash/code/typed-router.ts index d8144865a..8260a4f5a 100644 --- a/docs/.vitepress/twoslash/code/typed-router.ts +++ b/docs/.vitepress/twoslash/code/typed-router.ts @@ -8,24 +8,33 @@ declare module 'vue-router/auto-routes' { } from 'vue-router' export interface RouteNamedMap { - '/': RouteRecordInfo<'/', '/', Record, Record> + '/': RouteRecordInfo< + '/', + '/', + Record, + Record, + never + > '/users': RouteRecordInfo< '/users', '/users', Record, - Record + Record, + never > '/users/[id]': RouteRecordInfo< '/users/[id]', '/users/:id', { id: ParamValue }, - { id: ParamValue } + { id: ParamValue }, + '/users/[id]/edit' > '/users/[id]/edit': RouteRecordInfo< '/users/[id]/edit', '/users/:id/edit', { id: ParamValue }, - { id: ParamValue } + { id: ParamValue }, + never > } } diff --git a/docs/data-loaders/index.md b/docs/data-loaders/index.md index f3151a6fe..9e1858014 100644 --- a/docs/data-loaders/index.md +++ b/docs/data-loaders/index.md @@ -2,7 +2,7 @@ Data loaders streamline any asynchronous state management with Vue Router, like **Data Fetching**. Adopting Data loaders ensures a consistent and efficient way to manage data fetching in your application. Keep all the benefits of using libraries like [Pinia Colada](./colada/) or [Apollo](./apollo/) and integrate them seamlessly with client-side navigation. -This is achieved by extracting the loading logic **outside** of the component `setup` (unlike ``). This way, the loading logic can be executed independently of the component lifecycle, and the component can focus on rendering the data. Data Loaders are automatically collected and awaited within a navigation guard, ensuring the data is ready before rendering the component. +This is achieved by extracting the loading logic **outside** of the component `setup` (unlike ``). This way, the loading logic can be executed independently of the component life cycle, and the component can focus on rendering the data. Data Loaders are automatically collected and awaited within a navigation guard, ensuring the data is ready before rendering the component. ## Features @@ -44,11 +44,13 @@ app.mount('#app') There are different data loaders implementation, the most simple one is the [Basic Loader](./basic/) which always reruns data fetching. A more efficient one, is the [Colada Loader](./colada/) which uses [@pinia/colada](https://github.com/posva/pinia-colada) under the hood. In the following examples, we will be using the _basic loader_. -Loaders are [composables](https://vuejs.org/guide/reusability/composables.html) defined through a `defineLoader` function like `defineBasicLoader` or `defineColadaLoader`. They are _used_ in the component `setup` to extract the needed information. +Loaders are [composables](https://vuejs.org/guide/reusability/composables.html) defined through a `defineLoader` function like `defineBasicLoader` or `defineColadaLoader`. They are _used_ in the component `setup` to extract the needed information. -To get started, _define_ and _export_ a loader from a page: +To get started, _define_ and _**export**_ a loader from a **page** component: -```vue{2,5-7,11-16} twoslash +::: code-group + +```vue{2,5-7,11-16} twoslash [src/pages/users/[id].vue] ``` +::: tip + +When using a loader in a non-page component, you must **export the loader** from the page components where it is used. If you only import and use the loader in a regular component, the router will not recognize it and won't trigger or await it during navigation. + +::: + ## Nested Routes When defining nested routes, you don't need to worry about exporting the loader in both the parent and the child components. This will be automatically optimized for you and the loader will be shared between the parent and the child components. diff --git a/docs/guide/extending-routes.md b/docs/guide/extending-routes.md index 7df8ad801..7d3421018 100644 --- a/docs/guide/extending-routes.md +++ b/docs/guide/extending-routes.md @@ -110,6 +110,10 @@ const router = createRouter({ }) ``` +::: warning +Routes added at runtime [require special handling for HMR](./hmr.md#runtime-routes). +::: + As this plugin evolves, this should be used less and less and only become necessary in specific scenarios. One example of this is using [vite-plugin-vue-layouts](https://github.com/JohnCampionJr/vite-plugin-vue-layouts) which can only be used this way: diff --git a/docs/guide/file-based-routing.md b/docs/guide/file-based-routing.md index 0adf551b6..d9e037fc0 100644 --- a/docs/guide/file-based-routing.md +++ b/docs/guide/file-based-routing.md @@ -109,6 +109,39 @@ const routes = [ All generated routes that have a `component` property will have a `name` property. This avoid accidentally directing your users to a parent route. By default, names are generated using the file path, but you can override this behavior by passing a custom `getRouteName()` function. You will get TypeScript validation almost everywhere, so changing this should be easy. +## Route groups + +Sometimes, it helps to organize your file structure in a way that doesn't change the URL of your routes. Route groups let you organize your routes logically, in a way that makes sense to you, without affecting the actual URLs. For example, if you have several routes that share the same layout, you can group them together using route groups. Consider the following file structure: + +```text +src/pages/ +├── (admin)/ +│   ├── dashboard.vue +│   └── settings.vue +└── (user)/ +    ├── profile.vue +   └── order.vue +``` + +Resulting URLs: +```text +- `/dashboard` -> renders `src/pages/(admin)/dashboard.vue` +- `/settings` -> renders `src/pages/(admin)/settings.vue` +- `/profile` -> renders `src/pages/(user)/profile.vue` +- `/order` -> renders `src/pages/(user)/order.vue` +``` + +This approach allows you to organize your files for better maintainability without changing the structure of your application's routes. + +You can also use route groups in page components. This is equivalent to name the page component `index.vue`: + +```text +src/pages/ +└─── admin/ +    ├── (dashboard).vue // Becomes index.vue of admin route +    └── settings.vue +``` + ## Named views It is possible to define [named views](https://router.vuejs.org/guide/essentials/named-views.html#named-views) by appending an `@` + a name to their filename, e.g. a file named `src/pages/index@aux.vue` will generate a route of: diff --git a/docs/guide/hmr.md b/docs/guide/hmr.md index 530ad692d..c85a60608 100644 --- a/docs/guide/hmr.md +++ b/docs/guide/hmr.md @@ -21,3 +21,36 @@ if (import.meta.hot) { // [!code ++] handleHotUpdate(router) // [!code ++] } // [!code ++] ``` + +## Runtime routes + +If you add routes at runtime, you will have to add them within a callback to ensure they are added during development. + +```ts{16-23} [src/router.ts] +import { createRouter, createWebHistory } from 'vue-router' +import { routes, handleHotUpdate } from 'vue-router/auto-routes' + +export const router = createRouter({ + history: createWebHistory(), + routes, +}) + +function addRedirects() { + router.addRoute({ + path: '/new-about', + redirect: '/about?from=/new-about', + }) +} + +if (import.meta.hot) { + handleHotUpdate(router, (newRoutes) => { + addRedirects() + }) +} else { + // production + addRedirects() +} +``` + + +This is **optional**, you can also just reload the page. diff --git a/docs/guide/typescript.md b/docs/guide/typescript.md index 25b790d5c..a33dee239 100644 --- a/docs/guide/typescript.md +++ b/docs/guide/typescript.md @@ -60,7 +60,17 @@ declare module 'vue-router/auto-routes' { // these are the raw param types (accept numbers, strings, booleans, etc) { path: ParamValue }, // these are the normalized params as found in useRoute().params - { path: ParamValue } + { path: ParamValue }, + // this is a union of all children route names + // if the route does not have nested routes, pass `never` or omit this generic entirely + 'custom-dynamic-child-name' + > + 'custom-dynamic-child-name': RouteRecordInfo< + 'custom-dynamic-child-name', + '/added-during-runtime/[...path]/child', + { path: ParamValue }, + { path: ParamValue }, + never > } } @@ -76,13 +86,20 @@ import { useRoute, type RouteLocationNormalizedLoaded } from 'vue-router' // ---cut-end--- // @errors: 2322 2339 // @moduleResolution: bundler -// these are all valid -const userWithIdCasted = useRoute() as RouteLocationNormalizedLoaded<'/users/[id]'> -userWithIdCasted.params.id -const userWithIdTypeParam = useRoute<'/users/[id]'>() -userWithIdTypeParam.params.id -// 👇 this one is the easiest to write because it autocompletes -const userWithIdParam = useRoute('/users/[id]') -userWithIdParam.params -// ^? +// These are all valid ways to get a typed route and return the +// provided route's and any of its child routes' typings. +// Note that `/users/[id]/edit` is a child route +// of `/users/[id]` in this example. + +// Not recommended, since this leaves out any child routes' typings. +const userRouteWithIdCasted = + useRoute() as RouteLocationNormalizedLoaded<'/users/[id]'> +userRouteWithIdCasted.params.id +// Better way, but no autocompletion. +const userRouteWithIdTypeParam = useRoute<'/users/[id]'>() +userRouteWithIdTypeParam.params.id +// 👇 This one is the easiest to write because it autocompletes. +const userRouteWithIdParam = useRoute('/users/[id]') +userRouteWithIdParam.name +// ^? ``` diff --git a/docs/introduction.md b/docs/introduction.md index 71e4147e2..9377b8a08 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -102,31 +102,39 @@ After adding this plugin, **start the dev server** (usually `npm run dev`) **to // It's recommended to commit this file. // Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry. -import type { - RouteRecordInfo, - ParamValue, - ParamValueOneOrMore, - ParamValueZeroOrMore, - ParamValueZeroOrOne, -} from 'vue-router' - declare module 'vue-router/auto-routes' { + import type { + RouteRecordInfo, + ParamValue, + ParamValueOneOrMore, + ParamValueZeroOrMore, + ParamValueZeroOrOne, + } from 'vue-router' + /** * Route name map generated by unplugin-vue-router */ export interface RouteNamedMap { - '/': RouteRecordInfo<'/', '/', Record, Record> + '/': RouteRecordInfo< + '/', + '/', + Record, + Record, + never + > '/about': RouteRecordInfo< '/about', '/about', Record, - Record + Record, + never > '/users/[id]': RouteRecordInfo< - '/[id]', - '/:id', + '/users/[id]', + '/users/:id', { id: ParamValue }, - { id: ParamValue } + { id: ParamValue }, + never > } } diff --git a/examples/nuxt/app.vue b/examples/nuxt/app.vue index c74503838..fc366cc22 100644 --- a/examples/nuxt/app.vue +++ b/examples/nuxt/app.vue @@ -14,6 +14,14 @@ const state = useState('custom-date', () => new Date().toUTCString()) {{ route.href }} + | + {{ + route.href + }} + | + {{ + route.href + }} diff --git a/examples/nuxt/colada.options.ts b/examples/nuxt/colada.options.ts new file mode 100644 index 000000000..35abf20bc --- /dev/null +++ b/examples/nuxt/colada.options.ts @@ -0,0 +1,5 @@ +import type { PiniaColadaOptions } from '@pinia/colada' + +export default { + // Options here +} satisfies PiniaColadaOptions diff --git a/examples/nuxt/nuxt.config.ts b/examples/nuxt/nuxt.config.ts index 590b4bce0..e159970f4 100644 --- a/examples/nuxt/nuxt.config.ts +++ b/examples/nuxt/nuxt.config.ts @@ -1,7 +1,7 @@ export default defineNuxtConfig({ devtools: { enabled: true }, - modules: ['@pinia/nuxt'], + modules: ['@pinia/nuxt', '@pinia/colada-nuxt'], experimental: { typedPages: true, diff --git a/examples/nuxt/package.json b/examples/nuxt/package.json index f113f2c6e..512134a39 100644 --- a/examples/nuxt/package.json +++ b/examples/nuxt/package.json @@ -7,12 +7,13 @@ "preview": "nuxt preview" }, "devDependencies": { - "@pinia/nuxt": "^0.9.0", - "nuxt": "^3.15.2", + "@pinia/colada-nuxt": "^0.2.0", + "@pinia/nuxt": "^0.11.0", + "nuxt": "^3.17.4", "unplugin-vue-router": "workspace:*" }, "dependencies": { - "@pinia/colada": "^0.13.3", - "pinia": "^2.3.1" + "@pinia/colada": "^0.16.1", + "pinia": "^3.0.2" } } diff --git a/examples/nuxt/pages/users/[id].vue b/examples/nuxt/pages/users/[id].vue index d0c607928..77415787a 100644 --- a/examples/nuxt/pages/users/[id].vue +++ b/examples/nuxt/pages/users/[id].vue @@ -32,6 +32,9 @@ const route = useRoute('users-id') diff --git a/examples/nuxt/pages/users/colada-[userId].vue b/examples/nuxt/pages/users/colada-[userId].vue index 4714ecac8..c1bc90369 100644 --- a/examples/nuxt/pages/users/colada-[userId].vue +++ b/examples/nuxt/pages/users/colada-[userId].vue @@ -6,52 +6,55 @@ export const useUserData = defineColadaLoader('users-colada-userId', { key: (to) => ['user', to.params.userId], async query(to) { console.log('fetching user...') - await delay(1000) const user = { id: to.params.userId, when: Date.now(), n: Math.round(Math.random() * 10000), name: 'John Doe', } + await delay(1000) console.table(user) return user }, - // TODO: could display existing data - // lazy: (to, from) => !from || to.name !== from.name, + // can be flexible + // lazy: (to, from) => !!(from && to.name === from.name), lazy: false, }) " `; + +exports[`definePage > works with jsx 1`] = ` +"export default { + name: 'custom', + path: '/custom', + }" +`; diff --git a/src/core/context.ts b/src/core/context.ts index 50798b577..db44cfef3 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -19,6 +19,7 @@ import { generateVueRouterProxy as _generateVueRouterProxy } from '../codegen/vu import { definePageTransform, extractDefinePageNameAndPath } from './definePage' import { EditableTreeNode } from './extendRoutes' import { isPackageExists as isPackageInstalled } from 'local-pkg' +import { ts } from '../utils' export function createRoutesContext(options: ResolvedOptions) { const { dts: preferDTS, root, routesFolder } = options @@ -114,6 +115,7 @@ export function createRoutesContext(options: ResolvedOptions) { const routeBlock = getRouteBlock(filePath, content, options) // TODO: should warn if hasDefinePage and customRouteBlock // if (routeBlock) logger.log(routeBlock) + node.setCustomRouteBlock(filePath, { ...routeBlock, ...definedPageNameAndPath, @@ -186,10 +188,11 @@ export function createRoutesContext(options: ResolvedOptions) { importsMap )}\n` - let hmr = ` -export function handleHotUpdate(_router) { + let hmr = ts` +export function handleHotUpdate(_router, _hotUpdateCallback) { if (import.meta.hot) { import.meta.hot.data.router = _router + import.meta.hot.data.router_hotUpdateCallback = _hotUpdateCallback } } @@ -204,7 +207,18 @@ if (import.meta.hot) { for (const route of mod.routes) { router.addRoute(route) } - router.replace('') + // call the hotUpdateCallback for custom updates + import.meta.hot.data.router_hotUpdateCallback?.(mod.routes) + const route = router.currentRoute.value + router.replace({ + ...route, + // NOTE: we should be able to just do ...route but the router + // currently skips resolving and can give errors with renamed routes + // so we explicitly set remove matched and name + name: undefined, + matched: undefined, + force: true + }) }) } ` diff --git a/src/core/customBlock.ts b/src/core/customBlock.ts index b8c8f5da4..fd0ce5ffc 100644 --- a/src/core/customBlock.ts +++ b/src/core/customBlock.ts @@ -13,18 +13,7 @@ export function getRouteBlock( const parsedSFC = parse(content, { pad: 'space' }).descriptor const blockStr = parsedSFC?.customBlocks.find((b) => b.type === 'route') - if (!blockStr) return - - let result = parseCustomBlock(blockStr, path, options) - - // validation - if (result) { - if (result.path != null && !result.path.startsWith('/')) { - warn(`Overridden path must start with "/". Found in "${path}".`) - } - } - - return result + if (blockStr) return parseCustomBlock(blockStr, path, options) } export interface CustomRouteBlock diff --git a/src/core/definePage.spec.ts b/src/core/definePage.spec.ts index 22f604178..07d92d4f8 100644 --- a/src/core/definePage.spec.ts +++ b/src/core/definePage.spec.ts @@ -135,7 +135,7 @@ definePage({ }) }) - it.todo('works with jsx', async () => { + it('works with jsx', async () => { const code = ` const a = 1 definePage({ @@ -146,11 +146,11 @@ definePage({ `, result = (await definePageTransform({ code, - id: 'src/pages/basic.vue?definePage&jsx', + id: 'src/pages/basic.jsx?definePage&lang.jsx', })) as Exclude expect(result).toBeDefined() expect(result).toHaveProperty('code') - expect(result?.code).toMatchInlineSnapshot() + expect(result?.code).toMatchSnapshot() }) it('throws if definePage uses a variable from the setup', async () => { diff --git a/src/core/definePage.ts b/src/core/definePage.ts index aa0240dd0..2c9719a1c 100644 --- a/src/core/definePage.ts +++ b/src/core/definePage.ts @@ -4,12 +4,15 @@ import { parseSFC, MagicString, checkInvalidScopeReference, + babelParse, + getLang, } from '@vue-macros/common' import type { Thenable, TransformResult } from 'unplugin' import type { CallExpression, Node, ObjectProperty, + Program, Statement, StringLiteral, } from '@babel/types' @@ -25,6 +28,39 @@ function isStringLiteral(node: Node | null | undefined): node is StringLiteral { return node?.type === 'StringLiteral' } +/** + * Generate the ast from a code string and an id. Works with SFC and non-SFC files. + */ +function getCodeAst(code: string, id: string) { + let offset = 0 + let ast: Program | undefined + const lang = getLang(id.split(MACRO_DEFINE_PAGE_QUERY)[0]!) + if (lang === 'vue') { + const sfc = parseSFC(code, id) + if (sfc.scriptSetup) { + ast = sfc.getSetupAst() + offset = sfc.scriptSetup.loc.start.offset + } else if (sfc.script) { + ast = sfc.getScriptAst() + offset = sfc.script.loc.start.offset + } + } else if (/[jt]sx?$/.test(lang)) { + ast = babelParse(code, lang) + } + + const definePageNodes: CallExpression[] = (ast?.body || []) + .map((node) => { + const definePageCallNode = + node.type === 'ExpressionStatement' ? node.expression : node + return isCallOf(definePageCallNode, MACRO_DEFINE_PAGE) + ? definePageCallNode + : null + }) + .filter((node) => !!node) + + return { ast, offset, definePageNodes } +} + export function definePageTransform({ code, id, @@ -41,20 +77,8 @@ export function definePageTransform({ return isExtractingDefinePage ? 'export default {}' : undefined } - // TODO: handle also non SFC - - const sfc = parseSFC(code, id) - if (!sfc.scriptSetup) return - - const { scriptSetup, getSetupAst } = sfc - const setupAst = getSetupAst() - - const definePageNodes = (setupAst?.body || ([] as Node[])) - .map((node) => { - if (node.type === 'ExpressionStatement') node = node.expression - return isCallOf(node, MACRO_DEFINE_PAGE) ? node : null - }) - .filter((node): node is CallExpression => !!node) + const { ast, offset, definePageNodes } = getCodeAst(code, id) + if (!ast) return if (!definePageNodes.length) { return isExtractingDefinePage @@ -67,7 +91,6 @@ export function definePageTransform({ } const definePageNode = definePageNodes[0]! - const setupOffset = scriptSetup.loc.start.offset // we only want the page info if (isExtractingDefinePage) { @@ -82,13 +105,13 @@ export function definePageTransform({ ) } - const scriptBindings = setupAst?.body ? getIdentifiers(setupAst.body) : [] + const scriptBindings = ast.body ? getIdentifiers(ast.body) : [] // this will throw if a property from the script setup is used in definePage checkInvalidScopeReference(routeRecord, MACRO_DEFINE_PAGE, scriptBindings) - s.remove(setupOffset + routeRecord.end!, code.length) - s.remove(0, setupOffset + routeRecord.start!) + s.remove(offset + routeRecord.end!, code.length) + s.remove(0, offset + routeRecord.start!) s.prepend(`export default `) // find all static imports and filter out the ones that are not used @@ -156,11 +179,8 @@ export function definePageTransform({ const s = new MagicString(code) - // s.removeNode(definePageNode, { offset: setupOffset }) - s.remove( - setupOffset + definePageNode.start!, - setupOffset + definePageNode.end! - ) + // s.removeNode(definePageNode, { offset }) + s.remove(offset + definePageNode.start!, offset + definePageNode.end!) return generateTransform(s, id) } @@ -172,19 +192,8 @@ export function extractDefinePageNameAndPath( ): { name?: string; path?: string } | null | undefined { if (!sfcCode.includes(MACRO_DEFINE_PAGE)) return - const sfc = parseSFC(sfcCode, id) - - if (!sfc.scriptSetup) return - - const { getSetupAst } = sfc - const setupAst = getSetupAst() - - const definePageNodes = (setupAst?.body ?? ([] as Node[])) - .map((node) => { - if (node.type === 'ExpressionStatement') node = node.expression - return isCallOf(node, MACRO_DEFINE_PAGE) ? node : null - }) - .filter((node): node is CallExpression => !!node) + const { ast, definePageNodes } = getCodeAst(sfcCode, id) + if (!ast) return if (!definePageNodes.length) { return diff --git a/src/core/extendRoutes.spec.ts b/src/core/extendRoutes.spec.ts index 730a0e647..608bcb8c4 100644 --- a/src/core/extendRoutes.spec.ts +++ b/src/core/extendRoutes.spec.ts @@ -1,9 +1,14 @@ -import { expect, describe, it } from 'vitest' +import { expect, describe, it, beforeAll } from 'vitest' import { PrefixTree } from './tree' import { DEFAULT_OPTIONS, resolveOptions } from '../options' import { EditableTreeNode } from './extendRoutes' +import { mockWarn } from '../../tests/vitest-mock-warn' describe('EditableTreeNode', () => { + beforeAll(() => { + mockWarn() + }) + const RESOLVED_OPTIONS = resolveOptions(DEFAULT_OPTIONS) it('creates an editable tree node', () => { const tree = new PrefixTree(RESOLVED_OPTIONS) @@ -251,4 +256,46 @@ describe('EditableTreeNode', () => { }, ]) }) + + it('can override children path with relative ones', () => { + const tree = new PrefixTree(RESOLVED_OPTIONS) + const editable = new EditableTreeNode(tree) + const parent = editable.insert('parent', 'file.vue') + const child = parent.insert('child', 'file.vue') + const grandChild = child.insert('grandchild', 'file.vue') + + child.path = 'relative' + expect(child.path).toBe('relative') + expect(child.fullPath).toBe('/parent/relative') + expect(grandChild.fullPath).toBe('/parent/relative/grandchild') + + child.path = '/absolute' + expect(child.path).toBe('/absolute') + expect(child.fullPath).toBe('/absolute') + expect(grandChild.fullPath).toBe('/absolute/grandchild') + }) + + it('can override paths at tho root', () => { + const tree = new PrefixTree(RESOLVED_OPTIONS) + const editable = new EditableTreeNode(tree) + const parent = editable.insert('parent', 'file.vue') + const child = parent.insert('child', 'child.vue') + + parent.path = '/p' + expect(parent.path).toBe('/p') + expect(parent.fullPath).toBe('/p') + expect(child.fullPath).toBe('/p/child') + }) + + it('still creates valid paths if the path misses a leading slash', () => { + const tree = new PrefixTree(RESOLVED_OPTIONS) + const editable = new EditableTreeNode(tree) + const parent = editable.insert('parent', 'file.vue') + const child = parent.insert('child', 'file.vue') + + parent.path = 'bar' + expect(parent.path).toBe('/bar') + expect(parent.fullPath).toBe('/bar') + expect(child.fullPath).toBe('/bar/child') + }) }) diff --git a/src/core/extendRoutes.ts b/src/core/extendRoutes.ts index 8692d73e1..540e91e6d 100644 --- a/src/core/extendRoutes.ts +++ b/src/core/extendRoutes.ts @@ -1,7 +1,6 @@ import { RouteMeta } from 'vue-router' import { CustomRouteBlock } from './customBlock' import { type TreeNode } from './tree' -import { warn } from './utils' /** * A route node that can be modified by the user. The tree can be iterated to be traversed. @@ -142,11 +141,13 @@ export class EditableTreeNode { * Override the path of the route. You must ensure `params` match with the existing path. */ set path(path: string) { - if (!path.startsWith('/')) { - warn( - `Only absolute paths are supported. Make sure that "${path}" starts with a slash "/".` - ) - return + // automatically prefix the path with `/` if the route is at the root of the tree + // that matches the behavior of node.insert('path', 'file.vue') that also adds it + if ( + (!this.node.parent || this.node.parent.isRoot()) && + !path.startsWith('/') + ) { + path = '/' + path } this.node.value.addEditOverride({ path }) } diff --git a/src/core/moduleConstants.ts b/src/core/moduleConstants.ts index 7c20875ae..17aacae52 100644 --- a/src/core/moduleConstants.ts +++ b/src/core/moduleConstants.ts @@ -1,3 +1,6 @@ +/** + * @deprecated should be removed in favor of just vue-router + */ export const MODULE_VUE_ROUTER_AUTO = 'vue-router/auto' // vue-router/auto/routes was more natural but didn't work well with TS export const MODULE_ROUTES_PATH = `${MODULE_VUE_ROUTER_AUTO}-routes` diff --git a/src/core/tree.spec.ts b/src/core/tree.spec.ts index 01b347145..045978cdc 100644 --- a/src/core/tree.spec.ts +++ b/src/core/tree.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_OPTIONS, resolveOptions } from '../options' import { PrefixTree } from './tree' -import { TreeNodeType } from './treeNodeValue' +import { TreeNodeType, type TreeRouteParam } from './treeNodeValue' import { resolve } from 'pathe' import { mockWarn } from '../../tests/vitest-mock-warn' @@ -22,7 +22,7 @@ describe('Tree', () => { expect(child).toBeDefined() expect(child.value).toMatchObject({ rawSegment: 'foo', - path: '/foo', + fullPath: '/foo', _type: TreeNodeType.static, }) expect(child.children.size).toBe(0) @@ -37,7 +37,7 @@ describe('Tree', () => { expect(child.value).toMatchObject({ rawSegment: '[id]', params: [{ paramName: 'id' }], - path: '/:id', + fullPath: '/:id', _type: TreeNodeType.param, }) expect(child.children.size).toBe(0) @@ -50,14 +50,14 @@ describe('Tree', () => { expect(tree.children.get('[id]_a')!.value).toMatchObject({ rawSegment: '[id]_a', params: [{ paramName: 'id' }], - path: '/:id()_a', + fullPath: '/:id()_a', _type: TreeNodeType.param, }) expect(tree.children.get('[a]e[b]f')!.value).toMatchObject({ rawSegment: '[a]e[b]f', params: [{ paramName: 'a' }, { paramName: 'b' }], - path: '/:a()e:b()f', + fullPath: '/:a()e:b()f', _type: TreeNodeType.param, }) }) @@ -155,7 +155,7 @@ describe('Tree', () => { modifier: '+', }, ], - path: '/:id+', + fullPath: '/:id+', _type: TreeNodeType.param, }) }) @@ -173,7 +173,7 @@ describe('Tree', () => { modifier: '*', }, ], - path: '/:id*', + fullPath: '/:id*', _type: TreeNodeType.param, }) }) @@ -191,7 +191,7 @@ describe('Tree', () => { modifier: '?', }, ], - path: '/:id?', + fullPath: '/:id?', _type: TreeNodeType.param, }) }) @@ -292,7 +292,7 @@ describe('Tree', () => { expect(index.value).toMatchObject({ rawSegment: 'index', // the root should have a '/' instead of '' for the autocompletion - path: '/', + fullPath: '/', }) expect(index).toBeDefined() const a = tree.children.get('a')! @@ -300,7 +300,7 @@ describe('Tree', () => { expect(a.value.components.get('default')).toBeUndefined() expect(a.value).toMatchObject({ rawSegment: 'a', - path: '/a', + fullPath: '/a', }) expect(Array.from(a.children.keys())).toEqual(['index', 'b']) const aIndex = a.children.get('index')! @@ -308,14 +308,14 @@ describe('Tree', () => { expect(Array.from(aIndex.children.keys())).toEqual([]) expect(aIndex.value).toMatchObject({ rawSegment: 'index', - path: '/a', + fullPath: '/a', }) tree.insert('a', 'a.vue') expect(a.value.components.get('default')).toBe('a.vue') expect(a.value).toMatchObject({ rawSegment: 'a', - path: '/a', + fullPath: '/a', }) }) @@ -328,7 +328,7 @@ describe('Tree', () => { expect(child.value).toMatchObject({ rawSegment: '[id]+', params: [{ paramName: 'id', modifier: '+' }], - path: '/:id+', + fullPath: '/:id+', pathSegment: ':id+', _type: TreeNodeType.param, }) @@ -346,7 +346,7 @@ describe('Tree', () => { expect(child.value).toMatchObject({ rawSegment: '[id]', params: [{ paramName: 'id' }], - path: '/:id', + fullPath: '/:id', pathSegment: ':id', }) expect(child.children.size).toBe(0) @@ -419,6 +419,25 @@ describe('Tree', () => { expect(node.fullPath).toBe('/custom-child') }) + // https://github.com/posva/unplugin-vue-router/pull/597 + // added because in Nuxt the result was different + it('does not contain duplicated params when a child route overrides the path', () => { + const tree = new PrefixTree(RESOLVED_OPTIONS) + tree.insert('[a]', '[a].vue') + const node = tree.insert('[a]/b', '[a]/b.vue') + node.value.setOverride('', { + path: '/:a()/new-b', + }) + expect(node.params).toHaveLength(1) + expect(node.params[0]).toEqual({ + paramName: 'a', + isSplat: false, + modifier: '', + optional: false, + repeatable: false, + } satisfies TreeRouteParam) + }) + it('removes trailing slash from path but not from name', () => { const tree = new PrefixTree(RESOLVED_OPTIONS) tree.insert('a/index', 'a/index.vue') @@ -524,7 +543,7 @@ describe('Tree', () => { expect(users.value).toMatchObject({ rawSegment: 'users.new', pathSegment: 'users/new', - path: '/users/new', + fullPath: '/users/new', _type: TreeNodeType.static, }) }) @@ -543,7 +562,7 @@ describe('Tree', () => { expect(lesson.value).toMatchObject({ rawSegment: '1.2.3-lesson', pathSegment: '1.2.3-lesson', - path: '/1.2.3-lesson', + fullPath: '/1.2.3-lesson', _type: TreeNodeType.static, }) }) diff --git a/src/core/tree.ts b/src/core/tree.ts index 501a7c3c3..dc27963a1 100644 --- a/src/core/tree.ts +++ b/src/core/tree.ts @@ -130,12 +130,18 @@ export class TreeNode { this.value.setOverride(filePath, routeBlock) } - getSortedChildren() { + getSortedChildren(): TreeNode[] { return Array.from(this.children.values()).sort((a, b) => a.path.localeCompare(b.path) ) } + getSortedChildrenDeep(): TreeNode[] { + return Array.from(this.children.values()) + .flatMap((child) => [child, ...child.getSortedChildrenDeep()]) + .sort((a, b) => a.path.localeCompare(b.path)) + } + /** * Delete and detach itself from the tree. */ @@ -195,7 +201,7 @@ export class TreeNode { * Returns the route path of the node including parent paths. */ get fullPath() { - return this.value.overrides.path ?? this.value.path + return this.value.fullPath } /** @@ -247,7 +253,7 @@ export class TreeNode { */ isRoot() { return ( - !this.parent && this.value.path === '/' && !this.value.components.size + !this.parent && this.value.fullPath === '/' && !this.value.components.size ) } diff --git a/src/core/treeNodeValue.ts b/src/core/treeNodeValue.ts index 2812537c3..668d3cb87 100644 --- a/src/core/treeNodeValue.ts +++ b/src/core/treeNodeValue.ts @@ -68,15 +68,24 @@ class _TreeNodeValueBase { } /** - * fullPath of the node based on parent nodes + * Path of the node. Can be absolute or not. If it has been overridden, it + * will return the overridden path. */ get path(): string { - const parentPath = this.parent?.path - // both the root record and the index record have a path of / - const pathSegment = this.overrides.path ?? this.pathSegment - return (!parentPath || parentPath === '/') && pathSegment === '' - ? '/' - : joinPath(parentPath || '', pathSegment) + return this.overrides.path ?? this.pathSegment + } + + /** + * Full path of the node including parent nodes. + */ + get fullPath(): string { + const pathSegment = this.path + // if the path is absolute, we don't need to join it with the parent + if (pathSegment.startsWith('/')) { + return pathSegment + } + + return joinPath(this.parent?.fullPath ?? '', pathSegment) } toString(): string { diff --git a/src/core/utils.spec.ts b/src/core/utils.spec.ts index e3c30d5cd..8dead0747 100644 --- a/src/core/utils.spec.ts +++ b/src/core/utils.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { trimExtension } from './utils' +import { joinPath, trimExtension } from './utils' describe('utils', () => { describe('trimExtension', () => { @@ -15,4 +15,44 @@ describe('utils', () => { expect(trimExtension('foo.page.vue', ['.vue'])).toBe('foo.page') }) }) + + describe('joinPath', () => { + it('joins paths', () => { + expect(joinPath('/foo', 'bar')).toBe('/foo/bar') + expect(joinPath('/foo', 'bar', 'baz')).toBe('/foo/bar/baz') + expect(joinPath('/foo', 'bar', 'baz', 'qux')).toBe('/foo/bar/baz/qux') + expect(joinPath('/foo', 'bar', 'baz', 'qux', 'quux')).toBe( + '/foo/bar/baz/qux/quux' + ) + }) + + it('adds a leading slash if missing', () => { + expect(joinPath('foo')).toBe('/foo') + expect(joinPath('foo', '')).toBe('/foo') + expect(joinPath('foo', 'bar')).toBe('/foo/bar') + expect(joinPath('foo', 'bar', 'baz')).toBe('/foo/bar/baz') + }) + + it('works with empty paths', () => { + expect(joinPath('', '', '', '')).toBe('/') + expect(joinPath('', '/', '', '')).toBe('/') + expect(joinPath('', '/', '', '/')).toBe('/') + expect(joinPath('', '/', '/', '/')).toBe('/') + expect(joinPath('/', '', '', '')).toBe('/') + }) + + it('collapses slashes', () => { + expect(joinPath('/foo/', 'bar')).toBe('/foo/bar') + expect(joinPath('/foo', 'bar')).toBe('/foo/bar') + expect(joinPath('/foo', 'bar/', 'foo')).toBe('/foo/bar/foo') + expect(joinPath('/foo', 'bar', 'foo')).toBe('/foo/bar/foo') + }) + + it('keeps trailing slashes', () => { + expect(joinPath('/foo', 'bar/')).toBe('/foo/bar/') + expect(joinPath('/foo/', 'bar/')).toBe('/foo/bar/') + expect(joinPath('/foo/', 'bar', 'baz/')).toBe('/foo/bar/baz/') + expect(joinPath('/foo/', 'bar/', 'baz/')).toBe('/foo/bar/baz/') + }) + }) }) diff --git a/src/core/utils.ts b/src/core/utils.ts index 171ff7d2b..63f269b49 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -116,7 +116,7 @@ export function joinPath(...paths: string[]): string { // check path to avoid adding a trailing slash when joining an empty string (path && '/' + path.replace(LEADING_SLASH_RE, '')) } - return result + return result || '/' } function paramToName({ paramName, modifier, isSplat }: TreeRouteParam) { diff --git a/src/data-loaders/auto-exports.ts b/src/data-loaders/auto-exports.ts index b8ad40fef..b0e8c6547 100644 --- a/src/data-loaders/auto-exports.ts +++ b/src/data-loaders/auto-exports.ts @@ -1,4 +1,4 @@ -import { createFilter } from '@rollup/pluginutils' +import { createFilter } from 'unplugin-utils' import type { Plugin } from 'vite' import MagicString from 'magic-string' import { findStaticImports, parseStaticImport } from 'mlly' @@ -41,7 +41,7 @@ const PLUGIN_NAME = 'unplugin-vue-router:data-loaders-auto-export' */ export interface AutoExportLoadersOptions { /** - * Filter page components to apply the auto-export (defined with `createFilter()` from `@rollup/pluginutils`) or array + * Filter page components to apply the auto-export (defined with `createFilter()` from `unplugin-utils`) or array * of globs. */ filterPageComponents: ((id: string) => boolean) | string[] diff --git a/src/data-loaders/defineColadaLoader.spec.ts b/src/data-loaders/defineColadaLoader.spec.ts index 6256a9742..691316ed9 100644 --- a/src/data-loaders/defineColadaLoader.spec.ts +++ b/src/data-loaders/defineColadaLoader.spec.ts @@ -27,7 +27,7 @@ import { setActivePinia, createPinia, getActivePinia } from 'pinia' import { PiniaColada, useQueryCache, - serializeTreeMap, + serializeQueryCache, hydrateQueryCache, } from '@pinia/colada' import { RouteLocationNormalizedLoaded } from 'vue-router' @@ -254,10 +254,10 @@ describe( }, }) - const serializedCache = [ + const serializedCache = { // entry with successful data for id - ['id', ['data', null, Date.now()], undefined], - ] satisfies ReturnType + '["id"]': ['data', null, 0], + } satisfies ReturnType wrapper.vm.$.appContext.app.runWithContext(() => { hydrateQueryCache(useQueryCache(pinia), serializedCache) @@ -310,9 +310,10 @@ describe( }, }) - const serializedCache = [ - ['id', ['data', null, Date.now()], undefined], - ] satisfies ReturnType + const serializedCache = { + // entry with successful data for id + '["id"]': ['data', null, 0], + } satisfies ReturnType wrapper.vm.$.appContext.app.runWithContext(() => { hydrateQueryCache(useQueryCache(pinia), serializedCache) @@ -324,5 +325,63 @@ describe( expect(getCurrentContext()).toEqual([]) }) + + it('can refetch nested loaders on invalidation', async () => { + const nestedQuery = vi.fn(async () => [{ id: 0 }, { id: 1 }]) + const useListData = defineColadaLoader({ + query: nestedQuery, + key: () => ['items'], + }) + + const useDetailData = defineColadaLoader({ + key: (to) => ['items', to.params.id as string], + async query(to) { + const list = await useListData() + const item = list.find( + (item) => String(item.id) === (to.params.id as string) + ) + if (!item) { + throw new Error('Not Found') + } + return { ...item, when: Date.now() } + }, + }) + + const component = defineComponent({ + setup() { + return { ...useDetailData() } + }, + template: `

`, + }) + + const router = getRouter() + router.addRoute({ + name: 'item-id', + path: '/items/:id', + meta: { loaders: [useDetailData] }, + component, + }) + + const pinia = createPinia() + + mount(RouterViewMock, { + global: { + plugins: [[DataLoaderPlugin, { router }], pinia, PiniaColada], + }, + }) + + await router.push('/items/0') + const queryCache = useQueryCache(pinia) + + expect(nestedQuery).toHaveBeenCalledTimes(1) + await expect( + queryCache.invalidateQueries({ key: ['items'] }) + ).resolves.toBeDefined() + expect(nestedQuery).toHaveBeenCalledTimes(2) + + await router.push('/items/1') + // FIXME: + // expect(nestedQuery).toHaveBeenCalledTimes(2) + }) } ) diff --git a/src/data-loaders/defineColadaLoader.ts b/src/data-loaders/defineColadaLoader.ts index 154896407..a30379412 100644 --- a/src/data-loaders/defineColadaLoader.ts +++ b/src/data-loaders/defineColadaLoader.ts @@ -168,12 +168,13 @@ export function defineColadaLoader( // set the current context before loading so nested loaders can use it setCurrentContext([entry, router, to]) + const app = router[APP_KEY] + if (!entry.ext) { // console.log(`🚀 creating query for "${key}"`) entry.ext = useQuery({ ...options, - // FIXME: type Promise instead of Promise - query: () => { + query: (): Promise => { const route = entry.route.value const [trackedRoute, params, query, hash] = trackRoute(route) entry.tracked.set( @@ -186,9 +187,14 @@ export function defineColadaLoader( } ) - return loader(trackedRoute, { - signal: route.meta[ABORT_CONTROLLER_KEY]?.signal, - }) + // needed for automatic refetching and nested loaders + // https://github.com/posva/unplugin-vue-router/issues/583 + return app.runWithContext(() => + loader(trackedRoute, { + // TODO: provide the query signal too + signal: route.meta[ABORT_CONTROLLER_KEY]?.signal, + }) + ) }, key: () => toValueWithParameters(options.key, entry.route.value), // TODO: cleanup if gc @@ -202,6 +208,7 @@ export function defineColadaLoader( reload = false } } + // TODO: should also reload in the case of nested loaders if a nested loader has been invalidated const { isLoading, data, error, ext } = entry @@ -380,6 +387,7 @@ export function defineColadaLoader( // fallback to the global router and routes for useDataLoaders used within components const router = _router || useRouter() const route = _route || (useRoute() as RouteLocationNormalizedLoaded) + const app = router[APP_KEY] const entries = router[ LOADER_ENTRIES_KEY @@ -399,7 +407,7 @@ export function defineColadaLoader( // console.log( // `🔁 loading from useData for "${options.key}": "${route.fullPath}"` // ) - router[APP_KEY].runWithContext(() => + app.runWithContext(() => // in this case we always need to run the functions for nested loaders consistency load(route, router, undefined, parentEntry, true) ) @@ -447,22 +455,22 @@ export function defineColadaLoader( error, isLoading, reload: (to: RouteLocationNormalizedLoaded = router.currentRoute.value) => - router[APP_KEY].runWithContext(() => - load(to, router, undefined, undefined, true) - ).then(() => entry!.commit(to)), + app + .runWithContext(() => load(to, router, undefined, undefined, true)) + .then(() => entry!.commit(to)), // pinia colada refetch: ( to: RouteLocationNormalizedLoaded = router.currentRoute.value ) => - router[APP_KEY].runWithContext(() => - load(to, router, undefined, undefined, true) - ).then(() => (entry!.commit(to), entry!.ext!.state.value)), + app + .runWithContext(() => load(to, router, undefined, undefined, true)) + .then(() => (entry!.commit(to), entry!.ext!.state.value)), refresh: ( to: RouteLocationNormalizedLoaded = router.currentRoute.value ) => - router[APP_KEY].runWithContext(() => load(to, router)).then( - () => (entry!.commit(to), entry.ext!.state.value) - ), + app + .runWithContext(() => load(to, router)) + .then(() => (entry!.commit(to), entry.ext!.state.value)), status: ext!.status, asyncStatus: ext!.asyncStatus, state: ext!.state, @@ -571,12 +579,19 @@ export interface UseDataLoaderColadaResult< UseQueryReturn, 'isPending' | 'status' | 'asyncStatus' | 'state' > { + /** + * Equivalent to `useQuery().refetch()`. Refetches the data no matter if its stale or not. + * @see reload - It also calls `refetch()` but returns an empty promise + */ refetch: ( to?: RouteLocationNormalizedLoaded // TODO: we might need to add this in the future // ...coladaArgs: Parameters['refresh']> ) => ReturnType['refetch']> + /** + * Equivalent to `useQuery().refresh()`. Refetches the data **only** if it's stale. + */ refresh: ( to?: RouteLocationNormalizedLoaded ) => ReturnType['refetch']> diff --git a/src/index.ts b/src/index.ts index d9c43ec78..d51df9472 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,13 +16,13 @@ import { mergeAllExtensions, } from './options' import { createViteContext } from './core/vite' -import { createFilter } from '@rollup/pluginutils' +import { createFilter } from 'unplugin-utils' import { join } from 'pathe' import { appendExtensionListToPattern } from './core/utils' import { MACRO_DEFINE_PAGE_QUERY } from './core/definePage' import { createAutoExportPlugin } from './data-loaders/auto-exports' -export * from './types' +export type * from './types' export { DEFAULT_OPTIONS } diff --git a/src/options.ts b/src/options.ts index 69883a4df..7fe1f7e0f 100644 --- a/src/options.ts +++ b/src/options.ts @@ -54,14 +54,17 @@ export interface RoutesFolderOption { filePatterns?: _OverridableOption /** - * Allows to override the global `exclude` option for this folder. It can also extend the global values by passing a - * function that returns an array. + * Allows to override the global `exclude` option for this folder. It can + * also extend the global values by passing a function that returns an array. */ exclude?: _OverridableOption /** - * Allows to override the global `extensions` option for this folder. It can also extend the global values by passing - * a function that returns an array. + * Allows to override the global `extensions` option for this folder. It can + * also extend the global values by passing a function that returns an array. + * The provided extensions are removed from the final route. For example, + * `.page.vue` allows to suffix all pages with `.page.vue` and remove it from + * the route name. */ extensions?: _OverridableOption } @@ -134,7 +137,7 @@ export interface Options { // NOTE: the comment below contains ZWJ characters to allow the sequence `**/*` to be displayed correctly /** * Pattern to match files in the `routesFolder`. Defaults to `**‍/*` plus a combination of all the possible extensions, - * e.g. `**‍/*.{vue,md}` if `extensions` is set to `['.vue', '.md']`. + * e.g. `**‍/*.{vue,md}` if `extensions` is set to `['.vue', '.md']`. This is relative to the {@link RoutesFolderOption['src']} and * @default `['**‍/*']` */ filePatterns?: string[] | string diff --git a/src/utils/index.ts b/src/utils/index.ts index 965a224cf..c74ccc691 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -11,6 +11,6 @@ export type _Awaitable = T | PromiseLike export type LiteralStringUnion = | LiteralType | (BaseType & Record) -// + // for highlighting export const ts = String.raw diff --git a/tests/data-loaders/tester.ts b/tests/data-loaders/tester.ts index 160399ec9..1f71a6cdf 100644 --- a/tests/data-loaders/tester.ts +++ b/tests/data-loaders/tester.ts @@ -19,7 +19,7 @@ import { NavigationResult, type DataLoaderPluginOptions, type DataLoaderContextBase, - type DefineDataLoaderOptionsBase, + type DefineDataLoaderOptionsBase_LaxData, type UseDataLoader, } from 'unplugin-vue-router/data-loaders' import { mockPromise } from '../utils' @@ -36,7 +36,7 @@ export function testDefineLoader( to: RouteLocationNormalizedLoaded, context: DataLoaderContextBase ) => Promise - } & DefineDataLoaderOptionsBase & { key?: string } + } & DefineDataLoaderOptionsBase_LaxData & { key?: string } ) => UseDataLoader, { plugins, @@ -52,7 +52,7 @@ export function testDefineLoader( function mockedLoader( // boolean is easier to handle for router mock - options?: DefineDataLoaderOptionsBase & { key?: string } + options?: DefineDataLoaderOptionsBase_LaxData & { key?: string } ) { const [spy, resolve, reject] = mockPromise( // not correct as T could be something else @@ -1146,7 +1146,6 @@ export function testDefineLoader( const isVisible = ref(true) - const wrapper = mount( () => (isVisible.value ? h(RouterViewMock) : h('p', ['hidden'])), { diff --git a/tsup-runtime.config.ts b/tsdown-runtime.config.ts similarity index 86% rename from tsup-runtime.config.ts rename to tsdown-runtime.config.ts index 3dc8625c5..d18dba18a 100644 --- a/tsup-runtime.config.ts +++ b/tsdown-runtime.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from 'tsup' -import { commonOptions } from './tsup.config' +import { defineConfig } from 'tsdown' +import { commonOptions } from './tsdown.config' export default defineConfig([ { diff --git a/tsup.config.ts b/tsdown.config.ts similarity index 79% rename from tsup.config.ts rename to tsdown.config.ts index b7308a639..55dad249b 100644 --- a/tsup.config.ts +++ b/tsdown.config.ts @@ -1,9 +1,7 @@ -import { defineConfig, type Options } from 'tsup' +import { defineConfig, type Options } from 'tsdown' export const commonOptions = { - clean: true, format: ['cjs', 'esm'], - dts: true, external: [ '@vue/compiler-sfc', 'vue', @@ -12,8 +10,6 @@ export const commonOptions = { '@pinia/colada', 'pinia', ], - cjsInterop: true, - splitting: true, } satisfies Options export default defineConfig([ diff --git a/volar/index.cjs b/volar/index.cjs new file mode 100644 index 000000000..8de4fc1e6 --- /dev/null +++ b/volar/index.cjs @@ -0,0 +1,43 @@ +// @ts-check + +/** + * @type {import('@vue/language-core').VueLanguagePlugin} + */ +const plugin = () => { + return { + version: 2.1, + getEmbeddedCodes(fileName, sfc) { + const names = []; + for (let i = 0; i < sfc.customBlocks.length; i++) { + const block = sfc.customBlocks[i] + if (block.type === 'route') { + const lang = block.lang === 'txt' ? 'json' : block.lang + names.push({ id: `route_${i}`, lang }) + } + } + return names + }, + resolveEmbeddedCode(fileName, sfc, embeddedCode) { + const match = embeddedCode.id.match(/^route_(\d+)$/) + if (match) { + const index = parseInt(match[1]) + const block = sfc.customBlocks[index] + embeddedCode.content.push([ + block.content, + block.name, + 0, + { + verification: true, + completion: true, + semantic: true, + navigation: true, + structure: true, + format: true, + }, + ]) + } + }, + } +} + +module.exports = plugin diff --git a/volar/index.js b/volar/index.js deleted file mode 100644 index a64f35705..000000000 --- a/volar/index.js +++ /dev/null @@ -1,46 +0,0 @@ -const plugin = () => { - return { - getEmbeddedFileNames(fileName, sfc) { - const fileNames = [] - for (let i = 0; i < sfc.customBlocks.length; i++) { - const block = sfc.customBlocks[i] - if (block.type === 'route' && block.lang === 'ts') { - fileNames.push(`${fileName}.route_${i}.${block.lang}`) - } - } - return fileNames - }, - - resolveEmbeddedFile(fileName, sfc, embeddedFile) { - const match = embeddedFile.fileName.match(/^(.*)\.route_(\d+)\.([^.]+)$/) - if (match) { - const index = parseInt(match[2]) - const block = sfc.customBlocks[index] - embeddedFile.capabilities = { - diagnostics: true, - foldingRanges: true, - formatting: true, - documentSymbol: true, - codeActions: true, - inlayHints: true, - } - embeddedFile.isTsHostFile = true - embeddedFile.codeGen.addCode2(block.content, 0, { - vueTag: 'customBlock', - vueTagIndex: index, - capabilities: { - basic: true, - references: true, - definitions: true, - diagnostic: true, - rename: true, - completion: true, - semanticTokens: true, - }, - }) - } - }, - } -} - -export default plugin