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, 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(), + ], +}