From 742e65e241bf0c8570812a65c38e0a6ddc08c8cb Mon Sep 17 00:00:00 2001 From: Andreas Johansson Date: Fri, 25 Apr 2025 20:21:15 +0200 Subject: [PATCH 1/3] feat(vue): add route key and meta to route context This allows the developer to use their own key for the routeMap matching, as well as provide meta data per route. This also re-exports beforeEach handlers, so that they can be overridden in the virtual files. --- packages/fastify-vue/context.js | 4 ++++ packages/fastify-vue/rendering.js | 2 +- packages/fastify-vue/routing.js | 5 +++-- packages/fastify-vue/server.js | 6 +++++- packages/fastify-vue/virtual-ts/create.ts | 7 +++++-- packages/fastify-vue/virtual-ts/index.ts | 3 +++ packages/fastify-vue/virtual-ts/mount.ts | 2 +- packages/fastify-vue/virtual/create.js | 7 +++++-- packages/fastify-vue/virtual/index.js | 3 +++ packages/fastify-vue/virtual/mount.js | 2 +- 10 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/fastify-vue/context.js b/packages/fastify-vue/context.js index 596e7be8..530bbe59 100644 --- a/packages/fastify-vue/context.js +++ b/packages/fastify-vue/context.js @@ -25,6 +25,8 @@ export default class RouteContext { // Populated this.head = {} this.data = route.data + this.key = route.key + this.meta = route.meta this.state = null // Route settings this.layout = route.layout @@ -51,6 +53,8 @@ export default class RouteContext { state: this.state, data: this.data, head: this.head, + key: this.key, + meta: this.meta, layout: this.layout, getMeta: this.getMeta, getData: this.getData, diff --git a/packages/fastify-vue/rendering.js b/packages/fastify-vue/rendering.js index eb1ebb00..21571124 100644 --- a/packages/fastify-vue/rendering.js +++ b/packages/fastify-vue/rendering.js @@ -6,7 +6,7 @@ import { createHtmlTemplates } from './templating.js' export async function createRenderFunction ({ routes, create }) { // Used when hydrating Vue Router on the client - const routeMap = Object.fromEntries(routes.map(_ => [_.path, _])) + const routeMap = Object.fromEntries(routes.map(_ => [_.key, _])) // Registered as reply.render() return function () { if (this.request.route.streaming) { diff --git a/packages/fastify-vue/routing.js b/packages/fastify-vue/routing.js index 04ffdf46..57b5a3f8 100644 --- a/packages/fastify-vue/routing.js +++ b/packages/fastify-vue/routing.js @@ -41,7 +41,7 @@ export async function createRoute ({ client, errorHandler, route }, scope, confi } // Used when hydrating Vue Router on the client - const routeMap = Object.fromEntries(client.routes.map(_ => [_.path, _])) + const routeMap = Object.fromEntries(client.routes.map(_ => [_.key, _])) // Extend with route context initialization module RouteContext.extend(client.context) @@ -139,7 +139,8 @@ export async function createRoute ({ client, errorHandler, route }, scope, confi if (route.getData) { // If getData is provided, register JSON endpoint for it - scope.get(`/-/data${routePath}`, { + const dataPath = (route.dataPath ?? route.path).replace(/:[^+]+\+/, '*') + scope.get(`/-/data${dataPath}`, { onRequest, async handler (req, reply) { reply.send(await route.getData(req.route)) diff --git a/packages/fastify-vue/server.js b/packages/fastify-vue/server.js index 38f2fd9f..d276cb4c 100644 --- a/packages/fastify-vue/server.js +++ b/packages/fastify-vue/server.js @@ -6,6 +6,7 @@ class Routes extends Array { return { id: route.id, path: route.path, + key: route.key, name: route.name, layout: route.layout, getData: !!route.getData, @@ -29,6 +30,7 @@ export async function createRoutes (fromPromise, { param } = { param: /\[([.\w]+ id: routeDef.path, name: routeDef.path ?? routeModule.path, path: routeDef.path ?? routeModule.path, + key: routeDef.path ?? routeModule.path, ...routeModule, } }), @@ -61,11 +63,13 @@ export async function createRoutes (fromPromise, { param } = { param: /\[([.\w]+ .replace(param, (_, m) => `:${m}`) // Replace '/index' with '/' .replace(/\/index$/, '/') - // Remove trailing slashs + // Remove trailing slashes .replace(/(.+)\/+$/, (...m) => m[1]), ...routeModule, } + route.key = route.path + if (route.name === '') { route.name = 'catch-all' } diff --git a/packages/fastify-vue/virtual-ts/create.ts b/packages/fastify-vue/virtual-ts/create.ts index 9e921b37..142d6f1d 100644 --- a/packages/fastify-vue/virtual-ts/create.ts +++ b/packages/fastify-vue/virtual-ts/create.ts @@ -4,8 +4,8 @@ import { createHistory, serverRouteContext, routeLayout, - createBeforeEachHandler, } from '@fastify/vue/client' +import { createClientBeforeEachHandler, createServerBeforeEachHandler } from '$app/index.ts' import { createHead as createClientHead } from '@unhead/vue/client' import { createHead as createServerHead } from '@unhead/vue/server' @@ -40,9 +40,12 @@ export default async function create (ctx) { } if (isServer) { + if (createServerBeforeEachHandler) { + router.beforeEach(createServerBeforeEachHandler(ctx)) + } instance.provide(serverRouteContext, ctxHydration) } else { - router.beforeEach(createBeforeEachHandler(ctx, layoutRef)) + router.beforeEach(createClientBeforeEachHandler(ctx, layoutRef)) } instance.use(router) diff --git a/packages/fastify-vue/virtual-ts/index.ts b/packages/fastify-vue/virtual-ts/index.ts index 4f15d300..d755a437 100644 --- a/packages/fastify-vue/virtual-ts/index.ts +++ b/packages/fastify-vue/virtual-ts/index.ts @@ -1,4 +1,7 @@ import { createRoutes } from '@fastify/vue/server' +export { createBeforeEachHandler as createClientBeforeEachHandler } from '@fastify/vue/client' + +export const createServerBeforeEachHandler = null export default { routes: createRoutes(import('$app/routes.ts')), diff --git a/packages/fastify-vue/virtual-ts/mount.ts b/packages/fastify-vue/virtual-ts/mount.ts index 7a82360b..8521646d 100644 --- a/packages/fastify-vue/virtual-ts/mount.ts +++ b/packages/fastify-vue/virtual-ts/mount.ts @@ -8,7 +8,7 @@ async function mountApp (...targets) { const ctxHydration = await extendContext(window.route, context) const resolvedRoutes = await hydrateRoutes(routes) const routeMap = Object.fromEntries( - resolvedRoutes.map((route) => [route.path, route]), + resolvedRoutes.map((route) => [route.key, route]), ) const { instance, router } = await create({ ctxHydration, diff --git a/packages/fastify-vue/virtual/create.js b/packages/fastify-vue/virtual/create.js index 9e921b37..f91705af 100644 --- a/packages/fastify-vue/virtual/create.js +++ b/packages/fastify-vue/virtual/create.js @@ -4,8 +4,8 @@ import { createHistory, serverRouteContext, routeLayout, - createBeforeEachHandler, } from '@fastify/vue/client' +import { createClientBeforeEachHandler, createServerBeforeEachHandler } from '$app/index.js' import { createHead as createClientHead } from '@unhead/vue/client' import { createHead as createServerHead } from '@unhead/vue/server' @@ -40,9 +40,12 @@ export default async function create (ctx) { } if (isServer) { + if (createServerBeforeEachHandler) { + router.beforeEach(createServerBeforeEachHandler(ctx)) + } instance.provide(serverRouteContext, ctxHydration) } else { - router.beforeEach(createBeforeEachHandler(ctx, layoutRef)) + router.beforeEach(createClientBeforeEachHandler(ctx, layoutRef)) } instance.use(router) diff --git a/packages/fastify-vue/virtual/index.js b/packages/fastify-vue/virtual/index.js index 05f4dde6..89f829f2 100644 --- a/packages/fastify-vue/virtual/index.js +++ b/packages/fastify-vue/virtual/index.js @@ -1,4 +1,7 @@ import { createRoutes } from '@fastify/vue/server' +export { createBeforeEachHandler as createClientBeforeEachHandler } from '@fastify/vue/client' + +export const createServerBeforeEachHandler = null export default { routes: createRoutes(import('$app/routes.js')), diff --git a/packages/fastify-vue/virtual/mount.js b/packages/fastify-vue/virtual/mount.js index ca88e463..e0c3702b 100644 --- a/packages/fastify-vue/virtual/mount.js +++ b/packages/fastify-vue/virtual/mount.js @@ -9,7 +9,7 @@ async function mountApp (...targets) { const ctxHydration = await extendContext(window.route, context) const resolvedRoutes = await hydrateRoutes(routes) const routeMap = Object.fromEntries( - resolvedRoutes.map((route) => [route.path, route]), + resolvedRoutes.map((route) => [route.key, route]), ) const { instance, router } = await create({ ctxHydration, From e0a449bb4e2ec3a1ae7cac176731f09856c9c8f7 Mon Sep 17 00:00:00 2001 From: Andreas Johansson Date: Fri, 25 Apr 2025 20:23:30 +0200 Subject: [PATCH 2/3] feat(vue): add i18n starter This example overrides $app/index.js and provides new route creation as well as beforeEach handlers to enable locale prefix and domain routing. --- pnpm-lock.yaml | 87 +++++ starters/vue-i18n/.eslintignore | 1 + starters/vue-i18n/.eslintrc | 31 ++ starters/vue-i18n/README.md | 3 + starters/vue-i18n/client/assets/logo.svg | 31 ++ starters/vue-i18n/client/base.css | 58 ++++ starters/vue-i18n/client/composables/i18n.js | 25 ++ starters/vue-i18n/client/context.js | 17 + starters/vue-i18n/client/i18n.config.js | 5 + starters/vue-i18n/client/i18n.js | 26 ++ starters/vue-i18n/client/index.html | 13 + starters/vue-i18n/client/index.js | 8 + starters/vue-i18n/client/pages/index.vue | 56 ++++ .../client/pages/wildcard/[slug+].vue | 39 +++ starters/vue-i18n/client/root.vue | 16 + starters/vue-i18n/client/routing.js | 302 ++++++++++++++++++ starters/vue-i18n/package.json | 30 ++ starters/vue-i18n/postcss.config.js | 12 + starters/vue-i18n/server.js | 18 ++ starters/vue-i18n/tailwind.config.js | 12 + starters/vue-i18n/vite.config.js | 11 + 21 files changed, 801 insertions(+) create mode 100644 starters/vue-i18n/.eslintignore create mode 100644 starters/vue-i18n/.eslintrc create mode 100644 starters/vue-i18n/README.md create mode 100644 starters/vue-i18n/client/assets/logo.svg create mode 100644 starters/vue-i18n/client/base.css create mode 100644 starters/vue-i18n/client/composables/i18n.js create mode 100644 starters/vue-i18n/client/context.js create mode 100644 starters/vue-i18n/client/i18n.config.js create mode 100644 starters/vue-i18n/client/i18n.js create mode 100644 starters/vue-i18n/client/index.html create mode 100644 starters/vue-i18n/client/index.js create mode 100644 starters/vue-i18n/client/pages/index.vue create mode 100644 starters/vue-i18n/client/pages/wildcard/[slug+].vue create mode 100644 starters/vue-i18n/client/root.vue create mode 100644 starters/vue-i18n/client/routing.js create mode 100644 starters/vue-i18n/package.json create mode 100644 starters/vue-i18n/postcss.config.js create mode 100644 starters/vue-i18n/server.js create mode 100644 starters/vue-i18n/tailwind.config.js create mode 100644 starters/vue-i18n/vite.config.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab154f3a..9d84b77a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1324,6 +1324,55 @@ importers: specifier: ^6.2.4 version: 6.2.5(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.29.3)(tsx@4.19.3)(yaml@2.7.1) + starters/vue-i18n: + dependencies: + '@fastify/one-line-logger': + specifier: ^2.0.2 + version: 2.0.2 + '@fastify/vite': + specifier: workspace:^ + version: link:../../packages/fastify-vite + '@fastify/vue': + specifier: workspace:^ + version: link:../../packages/fastify-vue + '@unhead/vue': + specifier: ^2.0.5 + version: 2.0.8(vue@3.5.13(typescript@5.8.3)) + fastify: + specifier: ^5.3.2 + version: 5.3.2 + vue: + specifier: ^3.5.13 + version: 3.5.13(typescript@5.8.3) + vue-i18n: + specifier: ^11.1.3 + version: 11.1.3(vue@3.5.13(typescript@5.8.3)) + vue-router: + specifier: ^4.5.0 + version: 4.5.0(vue@3.5.13(typescript@5.8.3)) + devDependencies: + '@tailwindcss/postcss': + specifier: ^4.1.1 + version: 4.1.2 + '@tailwindcss/vite': + specifier: ^4.1.1 + version: 4.1.2(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.29.3)(tsx@4.19.3)(yaml@2.7.1)) + '@vitejs/plugin-vue': + specifier: ^5.2.3 + version: 5.2.3(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.29.3)(tsx@4.19.3)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.3)) + postcss: + specifier: ^8.5.3 + version: 8.5.3 + postcss-preset-env: + specifier: ^10.1.5 + version: 10.1.5(postcss@8.5.3) + tailwindcss: + specifier: ^4.1.1 + version: 4.1.2 + vite: + specifier: ^6.2.4 + version: 6.2.5(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.29.3)(tsx@4.19.3)(yaml@2.7.1) + starters/vue-kitchensink: dependencies: '@fastify/formbody': @@ -2592,6 +2641,7 @@ packages: '@fastify/vite@6.0.7': resolution: {integrity: sha512-+dRo9KUkvmbqdmBskG02SwigWl06Mwkw8SBDK1zTNH6vd4DyXbRvI7RmJEmBkLouSU81KTzy1+OzwHSffqSD6w==} + bundledDependencies: [] '@fastify/vite@8.1.2': resolution: {integrity: sha512-W/XC2wmDjGwzQGa1SFphn+7w6KlzOYpK69yWAV4T3c3BZb5JcFgrM/f/ZRQpQ4kgYZfNs5UbfC6CA5Zccw2xtw==} @@ -2621,6 +2671,18 @@ packages: resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==} engines: {node: '>=18.18'} + '@intlify/core-base@11.1.3': + resolution: {integrity: sha512-cMuHunYO7LE80azTitcvEbs1KJmtd6g7I5pxlApV3Jo547zdO3h31/0uXpqHc+Y3RKt1wo2y68RGSx77Z1klyA==} + engines: {node: '>= 16'} + + '@intlify/message-compiler@11.1.3': + resolution: {integrity: sha512-7rbqqpo2f5+tIcwZTAG/Ooy9C8NDVwfDkvSeDPWUPQW+Dyzfw2o9H103N5lKBxO7wxX9dgCDjQ8Umz73uYw3hw==} + engines: {node: '>= 16'} + + '@intlify/shared@11.1.3': + resolution: {integrity: sha512-pTFBgqa/99JRA2H1qfyqv97MKWJrYngXBA/I0elZcYxvJgcCw3mApAoPW3mJ7vx3j+Ti0FyKUFZ4hWxdjKaxvA==} + engines: {node: '>= 16'} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -7347,6 +7409,12 @@ packages: peerDependencies: eslint: ^8.57.0 || ^9.0.0 + vue-i18n@11.1.3: + resolution: {integrity: sha512-Pcylh9z9S5+CJAqgbRZ3EKxFIBIrtY5YUppU722GIT65+Nukm0TCqiQegZnNLCZkXGthxe0cpqj0AoM51H+6Gw==} + engines: {node: '>= 16'} + peerDependencies: + vue: ^3.0.0 + vue-router@4.5.0: resolution: {integrity: sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==} peerDependencies: @@ -8706,6 +8774,18 @@ snapshots: '@humanwhocodes/retry@0.4.2': {} + '@intlify/core-base@11.1.3': + dependencies: + '@intlify/message-compiler': 11.1.3 + '@intlify/shared': 11.1.3 + + '@intlify/message-compiler@11.1.3': + dependencies: + '@intlify/shared': 11.1.3 + source-map-js: 1.2.1 + + '@intlify/shared@11.1.3': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -13863,6 +13943,13 @@ snapshots: transitivePeerDependencies: - supports-color + vue-i18n@11.1.3(vue@3.5.13(typescript@5.8.3)): + dependencies: + '@intlify/core-base': 11.1.3 + '@intlify/shared': 11.1.3 + '@vue/devtools-api': 6.6.4 + vue: 3.5.13(typescript@5.8.3) + vue-router@4.5.0(vue@3.5.13(typescript@5.8.3)): dependencies: '@vue/devtools-api': 6.6.4 diff --git a/starters/vue-i18n/.eslintignore b/starters/vue-i18n/.eslintignore new file mode 100644 index 00000000..53c37a16 --- /dev/null +++ b/starters/vue-i18n/.eslintignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/starters/vue-i18n/.eslintrc b/starters/vue-i18n/.eslintrc new file mode 100644 index 00000000..be9e0ffa --- /dev/null +++ b/starters/vue-i18n/.eslintrc @@ -0,0 +1,31 @@ +{ + parser: '@babel/eslint-parser', + parserOptions: { + requireConfigFile: false, + ecmaVersion: 2021, + sourceType: 'module', + babelOptions: { + presets: ['@babel/preset-react'], + }, + ecmaFeatures: { + jsx: true, + }, + }, + extends: [ + 'plugin:react/recommended', + 'standard', + ], + plugins: [ + 'react', + ], + rules: { + 'comma-dangle': ['error', 'always-multiline'], + 'react/prop-types': 'off', + 'import/no-absolute-path': 'off', + }, + settings: { + react: { + version: '18.0', + }, + }, +} diff --git a/starters/vue-i18n/README.md b/starters/vue-i18n/README.md new file mode 100644 index 00000000..3e242159 --- /dev/null +++ b/starters/vue-i18n/README.md @@ -0,0 +1,3 @@ +
+ +The official **[@fastify/vue](https://github.com/fastify/fastify-vite/tree/dev/packages/fastify-vue)** i18n starter template. diff --git a/starters/vue-i18n/client/assets/logo.svg b/starters/vue-i18n/client/assets/logo.svg new file mode 100644 index 00000000..39c9396a --- /dev/null +++ b/starters/vue-i18n/client/assets/logo.svg @@ -0,0 +1,31 @@ + + Drawing + + + + + + + + + + + + + + + + + Layer 1 + + image/svg+xml + + + + + + + + + + \ No newline at end of file diff --git a/starters/vue-i18n/client/base.css b/starters/vue-i18n/client/base.css new file mode 100644 index 00000000..6519f04e --- /dev/null +++ b/starters/vue-i18n/client/base.css @@ -0,0 +1,58 @@ +@import 'tailwindcss'; + +:root { + --color-base: #f1f1f1; + --color-highlight: #ff80ff; +} +html { + background: #222; +} +#root { + width: 800px; + margin: 0 auto; + padding: 2em; + box-shadow: 5px 5px 30px rgba(0,0,0,0.4); + border-radius: 10px; + background-color: rgba(255, 255, 255, 0.1); + font-family: Avenir, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: var(--color-base); + margin-top: 60px; + & a { + color: var(--color-highlight); + text-decoration: none; + font-weight: bold; + border-bottom: 1px solid var(--color-highlight); + &:hover { + color: #ffde00; + } + &:active { + color: #eecf00 + } + } + & p { + font-size: 1.2em; + } + & ul { + & li { + &:not(:last-child) { + margin-bottom: 0.5em; + } + break-inside: avoid; + font-size: 1em; + } + } + & code { + color: #ffde00; + font-weight: bold; + font-family: 'Consolas', 'Andale Mono', monospace; + font-size: 0.9em; + } + & img { + width: 14em; + } + & button { + margin: 0 0.5em; + } +} diff --git a/starters/vue-i18n/client/composables/i18n.js b/starters/vue-i18n/client/composables/i18n.js new file mode 100644 index 00000000..86e34083 --- /dev/null +++ b/starters/vue-i18n/client/composables/i18n.js @@ -0,0 +1,25 @@ +import { useRoute, useRouter } from 'vue-router'; + +export function useI18nUtils() { + const router = useRouter(); + const routes = router.getRoutes(); + const route = useRoute(); + + const localePath = (path) => { + if ('name' in path) { + const nameWithLocalePrefix = `${route.meta.locale}__${path.name}`; + for (const route of routes) { + if (route.name === nameWithLocalePrefix) { + path.name = nameWithLocalePrefix; + break; + } + } + } + + return path; + }; + + return { + localePath, + }; +} diff --git a/starters/vue-i18n/client/context.js b/starters/vue-i18n/client/context.js new file mode 100644 index 00000000..c7238c28 --- /dev/null +++ b/starters/vue-i18n/client/context.js @@ -0,0 +1,17 @@ +// The default export function runs exactly once on +// the server and once on the client during the +// first render, that is, it's not executed again +// in subsequent client-side navigation. +export default async (ctx) => { + // Set default params here for fetch/axios or similar XHR library + ctx.state.locale = ctx.meta.locale +} + +// State initializer, must be a function called state +// as this is a special context.js export and has +// special processing, e.g., serialization and hydration +export function state() { + return { + locale: 'sv', + } +} diff --git a/starters/vue-i18n/client/i18n.config.js b/starters/vue-i18n/client/i18n.config.js new file mode 100644 index 00000000..07ec1586 --- /dev/null +++ b/starters/vue-i18n/client/i18n.config.js @@ -0,0 +1,5 @@ +export default { + locales: ['en', 'sv', 'da'], // The first locale is the default + localePrefix: true, + localeDomains: {}, +} \ No newline at end of file diff --git a/starters/vue-i18n/client/i18n.js b/starters/vue-i18n/client/i18n.js new file mode 100644 index 00000000..59ffa66e --- /dev/null +++ b/starters/vue-i18n/client/i18n.js @@ -0,0 +1,26 @@ +import { createI18n } from 'vue-i18n' + +const i18nConfig = createI18n({ + locale: 'en', + legacy: false, + fallbackLocale: 'en', + messages: { + sv: { + message: { + welcome: "Välkommen till {'@'}fastify/vue!", + }, + }, + en: { + message: { + welcome: "Welcome to {'@'}fastify/vue!", + }, + }, + da: { + message: { + welcome: "Velkommen til {'@'}fastify/vue!", + }, + }, + }, +}) + +export const i18n = i18nConfig \ No newline at end of file diff --git a/starters/vue-i18n/client/index.html b/starters/vue-i18n/client/index.html new file mode 100644 index 00000000..c9155b1a --- /dev/null +++ b/starters/vue-i18n/client/index.html @@ -0,0 +1,13 @@ + + + + + + + + +
+ + + + diff --git a/starters/vue-i18n/client/index.js b/starters/vue-i18n/client/index.js new file mode 100644 index 00000000..bd3bf2d6 --- /dev/null +++ b/starters/vue-i18n/client/index.js @@ -0,0 +1,8 @@ +import { createRoutes } from '/routing.js' +export { createClientBeforeEachHandler, createServerBeforeEachHandler } from '/routing.js' + +export default { + routes: createRoutes(import('$app/routes.js')), + create: import('$app/create.js'), + context: import('$app/context.js'), +} diff --git a/starters/vue-i18n/client/pages/index.vue b/starters/vue-i18n/client/pages/index.vue new file mode 100644 index 00000000..b809ba30 --- /dev/null +++ b/starters/vue-i18n/client/pages/index.vue @@ -0,0 +1,56 @@ + + + + + + + diff --git a/starters/vue-i18n/client/pages/wildcard/[slug+].vue b/starters/vue-i18n/client/pages/wildcard/[slug+].vue new file mode 100644 index 00000000..a4087355 --- /dev/null +++ b/starters/vue-i18n/client/pages/wildcard/[slug+].vue @@ -0,0 +1,39 @@ + + + + + \ No newline at end of file diff --git a/starters/vue-i18n/client/root.vue b/starters/vue-i18n/client/root.vue new file mode 100644 index 00000000..c9faba1a --- /dev/null +++ b/starters/vue-i18n/client/root.vue @@ -0,0 +1,16 @@ + diff --git a/starters/vue-i18n/client/routing.js b/starters/vue-i18n/client/routing.js new file mode 100644 index 00000000..74fa0ba1 --- /dev/null +++ b/starters/vue-i18n/client/routing.js @@ -0,0 +1,302 @@ +import { serverRouteContext } from '@fastify/vue/client' +import i18nConfig from '/i18n.config.js' + +// Otherwise we get a ReferenceError, but since +// this function is only ran once, there's no overhead +class Routes extends Array { + toJSON () { + return this.map((route) => { + return { + id: route.id, + path: route.path, + dataPath: route.dataPath, + name: route.name, + key: route.key, + meta: route.meta, + layout: route.layout, + getData: !!route.getData, + getMeta: !!route.getMeta, + onEnter: !!route.onEnter, + } + }) + } +} + +export function createServerBeforeEachHandler ({ routeMap, ctxHydration }) { + return function beforeCreate (to) { + // This navigation guard handles the case when the routes are + // the same in multiple locales but we are using domains to match + // which route to use. + const ctx = routeMap[ctxHydration.req.host + '__' + to.matched[0].path] ?? routeMap['*__' + to.matched[0].path] + if (ctx && to.name !== ctx.name) { + return { name: ctx.name, params: to.params, query: to.query } + } + } +} + +export async function createRoutes (fromPromise) { + const { locales, localeDomains, localePrefix } = i18nConfig + const { default: from } = await fromPromise + const importPaths = Object.keys(from) + const promises = [] + const i18n = Object.keys(localeDomains).length > 0 || localePrefix + const defaultLocale = Array.isArray(locales) && locales.length > 0 ? locales[0] : 'en' + + if (Array.isArray(from)) { + for (const routeDef of from) { + promises.push( + await getRouteModule(routeDef.path, routeDef.component).then((routeModule) => { + return { + id: routeDef.path, + name: routeDef.path ?? routeModule.path, + path: routeDef.path ?? routeModule.path, + key: `*__${routeDef.path ?? routeModule.path}`, + meta: { + localePrefix, + locale: defaultLocale, + }, + ...routeModule, + } + }), + ) + } + } else { + // Ensure that static routes have precedence over the dynamic ones + for (const path of importPaths.sort((a, b) => a > b ? -1 : 1)) { + const rts = await getRouteModule(path, from[path]).then((routeModule) => { + const ret = [] + + const baseRoute = { + id: path, + layout: routeModule.layout, + name: path + // Remove /pages and .jsx extension + .slice(6, -4) + // Remove params + .replace(/\[([.\w]+\+?)\]/, (_, m) => '') + // Remove leading and trailing slashes + .replace(/^\/*|\/*$/g, '') + // Replace slashes with underscores + .replace(/\//g, '_'), + path: + routeModule.path ?? + path + // Remove /pages and .jsx extension + .slice(6, -4) + // Replace [id] with :id and [slug+] with :slug+ + .replace(/\[([.\w]+\+?)\]/, (_, m) => `:${m}`) + // Replace '/index' with '/' + .replace(/\/index$/, '/') + // Remove trailing slashes + .replace(/(.+)\/+$/, (...m) => m[1]), + ...routeModule, + } + + if (baseRoute.name === '') { + baseRoute.name = 'catch-all' + } + + baseRoute.key = `*__${baseRoute.path}` + baseRoute.dataPath = baseRoute.path + baseRoute.meta = { + localePrefix, + locale: defaultLocale, + } + + if (i18n) { + // Add the customized locale routes + for (const locale of locales) { + const localeRoute = Object.assign({}, baseRoute) + localeRoute.name = `${locale}__${localeRoute.name}` + localeRoute.meta = { + localePrefix, + locale, + } + + // If the route has a custom i18n path, use it, otherwise use standard path + const localePath = routeModule.i18n ? routeModule.i18n[locale] : null + if (localePath) { + localeRoute.path = localePath + } + + // Add the find-my-way locale domain constraint + if (localeDomains[locale]) { + localeRoute.constraints = { host: localeDomains[locale] } + localeRoute.domain = localeDomains[locale] + localeRoute.key = `${localeRoute.domain }__${localeRoute.path}` + + // Prepend the locale to the dataPath to avoid conflicts + // with other domains + localeRoute.dataPath = `/${locale}${localeRoute.path}` + } else if (localePrefix) { + if (localeRoute.path === '/') { + localeRoute.path = locale === defaultLocale ? '/' : `/${locale}` + localeRoute.dataPath = `/${locale}` + } else { + localeRoute.path = `/${locale}${localeRoute.path}` + localeRoute.dataPath = localeRoute.path + } + + localeRoute.key = `*__${localeRoute.path}` + } + + ret.push(localeRoute) + } + } else { + // Add the default locale route + const baseLocaleRoute = Object.assign({}, baseRoute) + ret.push(baseLocaleRoute) + } + + return ret + }) + + promises.push(...rts) + } + } + + return new Routes(...promises) +} + + +export function createClientBeforeEachHandler ({ routeMap, ctxHydration }, layout) { + return async function beforeCreate (to) { + // The client-side route context, fallback to unset domain constraint + const ctx = routeMap[window.location.host + '__' + to.matched[0].path] ?? routeMap['*__' + to.matched[0].path] + if (to.name !== ctx.name) { + return { name: ctx.name, params: to.params, query: to.query } + } + + // Indicates whether or not this is a first render on the client + ctx.firstRender = ctxHydration.firstRender + + ctx.state = ctxHydration.state + ctx.actions = ctxHydration.actions + + // Update layoutRef + layout.value = ctx.layout ?? 'default' + + // If it is, take server context data from hydration and return immediately + if (ctx.firstRender) { + ctx.data = ctxHydration.data + ctx.head = ctxHydration.head + // Ensure this block doesn't run again during client-side navigation + ctxHydration.firstRender = false + to.meta[serverRouteContext] = ctx + return + } + + // If we have a getData function registered for this route + if (ctx.getData) { + try { + ctx.data = await jsonDataFetch(to.fullPath, ctx.meta.localePrefix, ctx.meta.locale) + } catch (error) { + ctx.error = error + } + } + // Note that ctx.loader() at this point will resolve the + // memoized module, so there's barely any overhead + const { getMeta, onEnter } = await ctx.loader() + if (ctx.getMeta) { + ctx.head = await getMeta(ctx) + ctxHydration.useHead.push(ctx.head) + } + if (ctx.onEnter) { + const updatedData = await onEnter(ctx) + if (updatedData) { + if (!ctx.data) { + ctx.data = {} + } + Object.assign(ctx.data, updatedData) + } + } + to.meta[serverRouteContext] = ctx + } +} + +export async function hydrateRoutes (fromInput) { + let from = fromInput + if (Array.isArray(from)) { + from = Object.fromEntries( + from.map((route) => [route.path, route]), + ) + } + return window.routes.map((route) => { + route.loader = memoImport(from[route.id]) + route.component = () => route.loader() + return route + }) +} + +function memoImport (func) { + // Otherwise we get a ReferenceError, but since this function + // is only ran once for each route, there's no overhead + const kFuncExecuted = Symbol('kFuncExecuted') + const kFuncValue = Symbol('kFuncValue') + func[kFuncExecuted] = false + return async () => { + if (!func[kFuncExecuted]) { + func[kFuncValue] = await func() + func[kFuncExecuted] = true + } + return func[kFuncValue] + } +} + +async function jsonDataFetch (path, localePrefix, locale) { + const dataPath = localePrefix ? path : `/${locale+path}` + const response = await fetch(`/-/data${dataPath}`) + let data + let error + try { + data = await response.json() + } catch (err) { + error = err + } + if (data?.statusCode === 500) { + throw new Error(data.message) + } + if (error) { + throw error + } + return data +} + +function getRouteModuleExports (routeModule) { + return { + // The Route component (default export) + component: routeModule.default, + // The Layout Route component + layout: routeModule.layout, + // Route-level hooks + getData: routeModule.getData, + getMeta: routeModule.getMeta, + onEnter: routeModule.onEnter, + // Other Route-level settings + i18n: routeModule.i18n, + streaming: routeModule.streaming, + clientOnly: routeModule.clientOnly, + serverOnly: routeModule.serverOnly, + // Server configure function + configure: routeModule.configure, + // // Route-level Fastify hooks + onRequest: routeModule.onRequest ?? undefined, + preParsing: routeModule.preParsing ?? undefined, + preValidation: routeModule.preValidation ?? undefined, + preHandler: routeModule.preHandler ?? undefined, + preSerialization: routeModule.preSerialization ?? undefined, + onError: routeModule.onError ?? undefined, + onSend: routeModule.onSend ?? undefined, + onResponse: routeModule.onResponse ?? undefined, + onTimeout: routeModule.onTimeout ?? undefined, + onRequestAbort: routeModule.onRequestAbort ?? undefined, + } +} + +async function getRouteModule (path, routeModuleInput) { + if (typeof routeModuleInput === 'function') { + const routeModule = await routeModuleInput() + return getRouteModuleExports(routeModule) + } + return getRouteModuleExports(routeModuleInput) +} diff --git a/starters/vue-i18n/package.json b/starters/vue-i18n/package.json new file mode 100644 index 00000000..53a0287d --- /dev/null +++ b/starters/vue-i18n/package.json @@ -0,0 +1,30 @@ +{ + "name": "vue-i18n", + "description": "The vue-i18n starter template for @fastify/vue", + "type": "module", + "scripts": { + "dev": "node server.js --dev", + "build": "vite build", + "start": "node server.js", + "lint": "eslint . --ext .js,.jsx --fix" + }, + "dependencies": { + "@fastify/one-line-logger": "^2.0.2", + "@fastify/vite": "workspace:^", + "@fastify/vue": "workspace:^", + "fastify": "^5.3.2", + "@unhead/vue": "^2.0.5", + "vue": "^3.5.13", + "vue-i18n": "^11.1.3", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.1", + "@tailwindcss/vite": "^4.1.1", + "@vitejs/plugin-vue": "^5.2.3", + "postcss": "^8.5.3", + "postcss-preset-env": "^10.1.5", + "tailwindcss": "^4.1.1", + "vite": "^6.2.4" + } +} \ No newline at end of file diff --git a/starters/vue-i18n/postcss.config.js b/starters/vue-i18n/postcss.config.js new file mode 100644 index 00000000..528a473e --- /dev/null +++ b/starters/vue-i18n/postcss.config.js @@ -0,0 +1,12 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + 'postcss-preset-env': { + stage: 1, + features: { + // Let Tailwind handle it + 'nesting-rules': false + } + }, + } +} diff --git a/starters/vue-i18n/server.js b/starters/vue-i18n/server.js new file mode 100644 index 00000000..22b66f28 --- /dev/null +++ b/starters/vue-i18n/server.js @@ -0,0 +1,18 @@ +import Fastify from 'fastify' +import FastifyVite from '@fastify/vite' + +const server = Fastify({ + logger: { + transport: { + target: '@fastify/one-line-logger' + } + } +}) + +await server.register(FastifyVite, { + root: import.meta.url, + renderer: '@fastify/vue', +}) + +await server.vite.ready() +await server.listen({ port: 3000 }) diff --git a/starters/vue-i18n/tailwind.config.js b/starters/vue-i18n/tailwind.config.js new file mode 100644 index 00000000..81d1e2c0 --- /dev/null +++ b/starters/vue-i18n/tailwind.config.js @@ -0,0 +1,12 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './client/index.html', + './client/**/*.vue', + ], + theme: { + extend: {}, + }, + plugins: [], +} + diff --git a/starters/vue-i18n/vite.config.js b/starters/vue-i18n/vite.config.js new file mode 100644 index 00000000..6c77ba02 --- /dev/null +++ b/starters/vue-i18n/vite.config.js @@ -0,0 +1,11 @@ +import { join } from 'node:path' +import viteFastifyVue from '@fastify/vue/plugin' +import viteVue from '@vitejs/plugin-vue' + +export default { + root: join(import.meta.dirname, 'client'), + plugins: [ + viteFastifyVue(), + viteVue(), + ], +} From eb7fd40962a44676133268dcb62b3af8c0a3d5d9 Mon Sep 17 00:00:00 2001 From: Andreas Johansson Date: Tue, 12 Aug 2025 11:00:46 +0200 Subject: [PATCH 3/3] fix(vue): prevent circular import This confuses vite's build import analysis --- packages/fastify-vue/virtual-ts/create.ts | 7 +- packages/fastify-vue/virtual/create.js | 7 +- packages/fastify-vue/virtual/index.js | 3 - starters/vue-i18n/client/create.js | 122 ++++++++++++++++++++++ starters/vue-i18n/client/index.js | 1 - starters/vue-i18n/client/routing.js | 69 ------------ 6 files changed, 126 insertions(+), 83 deletions(-) create mode 100644 starters/vue-i18n/client/create.js diff --git a/packages/fastify-vue/virtual-ts/create.ts b/packages/fastify-vue/virtual-ts/create.ts index 142d6f1d..9e921b37 100644 --- a/packages/fastify-vue/virtual-ts/create.ts +++ b/packages/fastify-vue/virtual-ts/create.ts @@ -4,8 +4,8 @@ import { createHistory, serverRouteContext, routeLayout, + createBeforeEachHandler, } from '@fastify/vue/client' -import { createClientBeforeEachHandler, createServerBeforeEachHandler } from '$app/index.ts' import { createHead as createClientHead } from '@unhead/vue/client' import { createHead as createServerHead } from '@unhead/vue/server' @@ -40,12 +40,9 @@ export default async function create (ctx) { } if (isServer) { - if (createServerBeforeEachHandler) { - router.beforeEach(createServerBeforeEachHandler(ctx)) - } instance.provide(serverRouteContext, ctxHydration) } else { - router.beforeEach(createClientBeforeEachHandler(ctx, layoutRef)) + router.beforeEach(createBeforeEachHandler(ctx, layoutRef)) } instance.use(router) diff --git a/packages/fastify-vue/virtual/create.js b/packages/fastify-vue/virtual/create.js index f91705af..9e921b37 100644 --- a/packages/fastify-vue/virtual/create.js +++ b/packages/fastify-vue/virtual/create.js @@ -4,8 +4,8 @@ import { createHistory, serverRouteContext, routeLayout, + createBeforeEachHandler, } from '@fastify/vue/client' -import { createClientBeforeEachHandler, createServerBeforeEachHandler } from '$app/index.js' import { createHead as createClientHead } from '@unhead/vue/client' import { createHead as createServerHead } from '@unhead/vue/server' @@ -40,12 +40,9 @@ export default async function create (ctx) { } if (isServer) { - if (createServerBeforeEachHandler) { - router.beforeEach(createServerBeforeEachHandler(ctx)) - } instance.provide(serverRouteContext, ctxHydration) } else { - router.beforeEach(createClientBeforeEachHandler(ctx, layoutRef)) + router.beforeEach(createBeforeEachHandler(ctx, layoutRef)) } instance.use(router) diff --git a/packages/fastify-vue/virtual/index.js b/packages/fastify-vue/virtual/index.js index 89f829f2..05f4dde6 100644 --- a/packages/fastify-vue/virtual/index.js +++ b/packages/fastify-vue/virtual/index.js @@ -1,7 +1,4 @@ import { createRoutes } from '@fastify/vue/server' -export { createBeforeEachHandler as createClientBeforeEachHandler } from '@fastify/vue/client' - -export const createServerBeforeEachHandler = null export default { routes: createRoutes(import('$app/routes.js')), diff --git a/starters/vue-i18n/client/create.js b/starters/vue-i18n/client/create.js new file mode 100644 index 00000000..9e81b111 --- /dev/null +++ b/starters/vue-i18n/client/create.js @@ -0,0 +1,122 @@ +import { createApp, createSSRApp, reactive, ref } from 'vue'; +import { createRouter } from 'vue-router'; +import { + createHistory, + serverRouteContext, + routeLayout, +} from '@fastify/vue/client'; +import { createHead as createClientHead } from '@unhead/vue/client'; +import { createHead as createServerHead } from '@unhead/vue/server'; + +import * as root from '$app/root.vue'; + +function createServerBeforeEachHandler ({ routeMap, ctxHydration }) { + return function beforeCreate (to) { + // This navigation guard handles the case when the routes are + // the same in multiple locales but we are using domains to match + // which route to use. + const ctx = routeMap[ctxHydration.req.host + '__' + to.matched[0].path] ?? routeMap['*__' + to.matched[0].path] + if (ctx && to.name !== ctx.name) { + return { name: ctx.name, params: to.params, query: to.query } + } + } +} + +function createClientBeforeEachHandler ({ routeMap, ctxHydration }, layout) { + return async function beforeCreate (to) { + // The client-side route context, fallback to unset domain constraint + const ctx = routeMap[window.location.host + '__' + to.matched[0].path] ?? routeMap['*__' + to.matched[0].path] + if (to.name !== ctx.name) { + return { name: ctx.name, params: to.params, query: to.query } + } + + // Indicates whether or not this is a first render on the client + ctx.firstRender = ctxHydration.firstRender + + ctx.state = ctxHydration.state + ctx.actions = ctxHydration.actions + + // Update layoutRef + layout.value = ctx.layout ?? 'default' + + // If it is, take server context data from hydration and return immediately + if (ctx.firstRender) { + ctx.data = ctxHydration.data + ctx.head = ctxHydration.head + // Ensure this block doesn't run again during client-side navigation + ctxHydration.firstRender = false + to.meta[serverRouteContext] = ctx + return + } + + // If we have a getData function registered for this route + if (ctx.getData) { + try { + ctx.data = await jsonDataFetch(to.fullPath, ctx.meta.localePrefix, ctx.meta.locale) + } catch (error) { + ctx.error = error + } + } + // Note that ctx.loader() at this point will resolve the + // memoized module, so there's barely any overhead + const { getMeta, onEnter } = await ctx.loader() + if (ctx.getMeta) { + ctx.head = await getMeta(ctx) + ctxHydration.useHead.push(ctx.head) + } + if (ctx.onEnter) { + const updatedData = await onEnter(ctx) + if (updatedData) { + if (!ctx.data) { + ctx.data = {} + } + Object.assign(ctx.data, updatedData) + } + } + to.meta[serverRouteContext] = ctx + } +} + +export default async function create(ctx) { + const { routes, ctxHydration } = ctx; + + const instance = ctxHydration.clientOnly + ? createApp(root.default) + : createSSRApp(root.default); + + let scrollBehavior = null; + if (typeof root.scrollBehavior === 'function') { + scrollBehavior = root.scrollBehavior; + } + + const history = createHistory(); + const router = createRouter({ history, routes, scrollBehavior }); + const layoutRef = ref(ctxHydration.layout ?? 'default'); + + const isServer = import.meta.env.SSR; + instance.config.globalProperties.$isServer = isServer; + + const head = isServer ? createServerHead() : createClientHead(); + instance.use(head); + ctxHydration.useHead = head; + + instance.provide(routeLayout, layoutRef); + if (!isServer && ctxHydration.state) { + ctxHydration.state = reactive(ctxHydration.state); + } + + if (isServer) { + router.beforeEach(createServerBeforeEachHandler(ctx)); + instance.provide(serverRouteContext, ctxHydration); + } else { + router.beforeEach(createClientBeforeEachHandler(ctx, layoutRef)); + } + + instance.use(router); + + if (typeof root.configure === 'function') { + await root.configure({ app: instance, router, head }); + } + + return { instance, ctx, state: ctxHydration.state, router }; +} diff --git a/starters/vue-i18n/client/index.js b/starters/vue-i18n/client/index.js index bd3bf2d6..6c09fc45 100644 --- a/starters/vue-i18n/client/index.js +++ b/starters/vue-i18n/client/index.js @@ -1,5 +1,4 @@ import { createRoutes } from '/routing.js' -export { createClientBeforeEachHandler, createServerBeforeEachHandler } from '/routing.js' export default { routes: createRoutes(import('$app/routes.js')), diff --git a/starters/vue-i18n/client/routing.js b/starters/vue-i18n/client/routing.js index 74fa0ba1..02eb3383 100644 --- a/starters/vue-i18n/client/routing.js +++ b/starters/vue-i18n/client/routing.js @@ -1,4 +1,3 @@ -import { serverRouteContext } from '@fastify/vue/client' import i18nConfig from '/i18n.config.js' // Otherwise we get a ReferenceError, but since @@ -22,18 +21,6 @@ class Routes extends Array { } } -export function createServerBeforeEachHandler ({ routeMap, ctxHydration }) { - return function beforeCreate (to) { - // This navigation guard handles the case when the routes are - // the same in multiple locales but we are using domains to match - // which route to use. - const ctx = routeMap[ctxHydration.req.host + '__' + to.matched[0].path] ?? routeMap['*__' + to.matched[0].path] - if (ctx && to.name !== ctx.name) { - return { name: ctx.name, params: to.params, query: to.query } - } - } -} - export async function createRoutes (fromPromise) { const { locales, localeDomains, localePrefix } = i18nConfig const { default: from } = await fromPromise @@ -158,62 +145,6 @@ export async function createRoutes (fromPromise) { return new Routes(...promises) } - -export function createClientBeforeEachHandler ({ routeMap, ctxHydration }, layout) { - return async function beforeCreate (to) { - // The client-side route context, fallback to unset domain constraint - const ctx = routeMap[window.location.host + '__' + to.matched[0].path] ?? routeMap['*__' + to.matched[0].path] - if (to.name !== ctx.name) { - return { name: ctx.name, params: to.params, query: to.query } - } - - // Indicates whether or not this is a first render on the client - ctx.firstRender = ctxHydration.firstRender - - ctx.state = ctxHydration.state - ctx.actions = ctxHydration.actions - - // Update layoutRef - layout.value = ctx.layout ?? 'default' - - // If it is, take server context data from hydration and return immediately - if (ctx.firstRender) { - ctx.data = ctxHydration.data - ctx.head = ctxHydration.head - // Ensure this block doesn't run again during client-side navigation - ctxHydration.firstRender = false - to.meta[serverRouteContext] = ctx - return - } - - // If we have a getData function registered for this route - if (ctx.getData) { - try { - ctx.data = await jsonDataFetch(to.fullPath, ctx.meta.localePrefix, ctx.meta.locale) - } catch (error) { - ctx.error = error - } - } - // Note that ctx.loader() at this point will resolve the - // memoized module, so there's barely any overhead - const { getMeta, onEnter } = await ctx.loader() - if (ctx.getMeta) { - ctx.head = await getMeta(ctx) - ctxHydration.useHead.push(ctx.head) - } - if (ctx.onEnter) { - const updatedData = await onEnter(ctx) - if (updatedData) { - if (!ctx.data) { - ctx.data = {} - } - Object.assign(ctx.data, updatedData) - } - } - to.meta[serverRouteContext] = ctx - } -} - export async function hydrateRoutes (fromInput) { let from = fromInput if (Array.isArray(from)) {