Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/vuetify-nuxt-module/configuration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ declare module 'virtual:vuetify-ssr-client-hints-configuration' {
defaultTheme: string
themeNames: string[]
cookieName: string
cookieDomain?: string
cookieSecure?: boolean
cookieSameSite: 'lax' | 'strict' | 'none'
darkThemeName: string
lightThemeName: string
useBrowserThemeOnly: boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,22 @@ function useSSRClientHints () {
const {
baseUrl,
cookieName,
cookieDomain,
cookieSecure,
cookieSameSite,
defaultTheme,
} = ssrClientHintsConfiguration.prefersColorSchemeOptions
const cookieNamePrefix = `${cookieName}=`
initial.value.colorSchemeFromCookie = document.cookie?.split(';')?.find(c => c.trim().startsWith(cookieNamePrefix))?.split('=')[1] ?? defaultTheme
const date = new Date()
const expires = new Date(date.setDate(date.getDate() + 365))
initial.value.colorSchemeCookie = `${cookieName}=${initial.value.colorSchemeFromCookie}; Path=${baseUrl}; Expires=${expires.toUTCString()}; SameSite=Lax`
initial.value.colorSchemeCookie = `${cookieName}=${initial.value.colorSchemeFromCookie}; Path=${baseUrl}; Expires=${expires.toUTCString()}; SameSite=${cookieSameSite[0].toUpperCase()}${cookieSameSite.slice(1)}`
if (cookieDomain) {
initial.value.colorSchemeCookie += `; Domain=${cookieDomain}`
}
if (cookieSecure) {
initial.value.colorSchemeCookie += '; Secure'
}

return initial
}
Original file line number Diff line number Diff line change
Expand Up @@ -364,18 +364,31 @@ function writeThemeCookie (
const cookieName = ssrClientHintsConfiguration.prefersColorSchemeOptions.cookieName
const themeName = clientHintsRequest.colorSchemeFromCookie ?? ssrClientHintsConfiguration.prefersColorSchemeOptions.defaultTheme
const path = ssrClientHintsConfiguration.prefersColorSchemeOptions.baseUrl
const domain = ssrClientHintsConfiguration.prefersColorSchemeOptions.cookieDomain
const secure = ssrClientHintsConfiguration.prefersColorSchemeOptions.cookieSecure
const sameSite = ssrClientHintsConfiguration.prefersColorSchemeOptions.cookieSameSite

const date = new Date()
const expires = new Date(date.setDate(date.getDate() + 365))
if (!clientHintsRequest.firstRequest || !ssrClientHintsConfiguration.reloadOnFirstRequest) {
useCookie(cookieName, {
path,
domain,
expires,
sameSite: 'lax',
sameSite,
secure,
}).value = themeName
}

return `${cookieName}=${themeName}; Path=${path}; Expires=${expires.toUTCString()}; SameSite=Lax`
let cookie = `${cookieName}=${themeName}; Path=${path}; Expires=${expires.toUTCString()}; SameSite=${sameSite[0].toUpperCase()}${sameSite.slice(1)}`
if (domain) {
cookie += `; Domain=${domain}`
}
if (secure) {
cookie += '; Secure'
}

return cookie
}

export default plugin
37 changes: 37 additions & 0 deletions packages/vuetify-nuxt-module/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,9 +366,43 @@ export interface MOptions {
/**
* The name for the cookie.
*
* @deprecated Use `cookie.name` instead.
* @default 'color-scheme'
*/
cookieName?: string
/**
* Cookie attributes for the color scheme cookie.
*/
cookie?: {
/**
* The name for the cookie.
*
* @default 'color-scheme'
*/
name?: string
/**
* The domain for the color scheme cookie.
*
* Useful to share the cookie across subdomains, e.g. `.example.com`.
*
* @default undefined
*/
domain?: string
/**
* Mark the cookie as `Secure`.
*
* Forced to `true` when `sameSite` is `'none'`.
*
* @default undefined
*/
secure?: boolean
/**
* The `SameSite` attribute for the cookie.
*
* @default 'lax'
*/
sameSite?: 'lax' | 'strict' | 'none'
}
/**
* The name for the dark theme.
*
Expand Down Expand Up @@ -468,6 +502,9 @@ export interface SSRClientHintsConfiguration {
defaultTheme: string
themeNames: string[]
cookieName: string
cookieDomain?: string
cookieSecure?: boolean
cookieSameSite: 'lax' | 'strict' | 'none'
darkThemeName: string
lightThemeName: string
}
Expand Down
26 changes: 24 additions & 2 deletions packages/vuetify-nuxt-module/src/utils/ssr-client-hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export interface ResolvedClientHints {
defaultTheme: string
themeNames: string[]
cookieName: string
cookieDomain?: string
cookieSecure?: boolean
cookieSameSite: 'lax' | 'strict' | 'none'
darkThemeName: string
lightThemeName: string
useBrowserThemeOnly: boolean
Expand All @@ -25,6 +28,23 @@ const disabledClientHints: ResolvedClientHints = Object.freeze({
prefersReducedMotion: false,
})

type PrefersColorSchemeInput = NonNullable<NonNullable<VuetifyNuxtContext['moduleOptions']['ssrClientHints']>['prefersColorSchemeOptions']>

function resolveColorSchemeCookie (options: PrefersColorSchemeInput, logger: VuetifyNuxtContext['logger']) {
if (options.cookieName !== undefined) {
logger.warn('[vuetify-nuxt-module] `prefersColorSchemeOptions.cookieName` is deprecated, use `prefersColorSchemeOptions.cookie.name` instead.')
}

const cookieSameSite = options.cookie?.sameSite ?? 'lax'

return {
cookieName: options.cookie?.name ?? options.cookieName ?? 'color-scheme',
cookieDomain: options.cookie?.domain,
cookieSecure: cookieSameSite === 'none' ? true : options.cookie?.secure,
cookieSameSite,
}
}

export function prepareSSRClientHints (baseUrl: string, ctx: VuetifyNuxtContext) {
if (!ctx.isSSR || ctx.isNuxtGenerate) {
return disabledClientHints
Expand Down Expand Up @@ -76,14 +96,16 @@ export function prepareSSRClientHints (baseUrl: string, ctx: VuetifyNuxtContext)
throw new Error('Vuetify dark theme and light theme are the same, change darkThemeName or lightThemeName!')
}

const pcsOptions = ssrClientHintsConfiguration.prefersColorSchemeOptions

clientHints.prefersColorSchemeOptions = {
baseUrl,
defaultTheme,
themeNames: Array.from(Object.keys(themes)),
cookieName: ssrClientHintsConfiguration.prefersColorSchemeOptions?.cookieName ?? 'color-scheme',
...resolveColorSchemeCookie(pcsOptions, ctx.logger),
darkThemeName,
lightThemeName,
useBrowserThemeOnly: ssrClientHintsConfiguration.prefersColorSchemeOptions?.useBrowserThemeOnly ?? false,
useBrowserThemeOnly: pcsOptions?.useBrowserThemeOnly ?? false,
}
}

Expand Down
73 changes: 73 additions & 0 deletions packages/vuetify-nuxt-module/test/ssr-client-hints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { VuetifyNuxtContext } from '../src/utils/config'
import { describe, expect, it, vi } from 'vitest'
import { prepareSSRClientHints } from '../src/utils/ssr-client-hints'

function createCtx (prefersColorSchemeOptions: any) {
const warn = vi.fn()
const ctx = {
isSSR: true,
isNuxtGenerate: false,
logger: { warn },
moduleOptions: {
ssrClientHints: {
prefersColorScheme: true,
prefersColorSchemeOptions,
},
},
vuetifyOptions: {
theme: {
defaultTheme: 'light',
themes: { light: {}, dark: {} },
},
},
} as unknown as VuetifyNuxtContext
return { ctx, warn }
}

describe('prepareSSRClientHints cookie normalisation', () => {
it('uses cookie.* fields when provided', () => {
const { ctx, warn } = createCtx({
cookie: { name: 'cs', domain: '.example.com', secure: true, sameSite: 'strict' },
})
const opts = prepareSSRClientHints('/', ctx).prefersColorSchemeOptions
expect(opts?.cookieName).toBe('cs')
expect(opts?.cookieDomain).toBe('.example.com')
expect(opts?.cookieSecure).toBe(true)
expect(opts?.cookieSameSite).toBe('strict')
expect(warn).not.toHaveBeenCalled()
})

it('maps the deprecated cookieName and warns once', () => {
const { ctx, warn } = createCtx({ cookieName: 'legacy' })
const opts = prepareSSRClientHints('/', ctx).prefersColorSchemeOptions
expect(opts?.cookieName).toBe('legacy')
expect(opts?.cookieSameSite).toBe('lax')
expect(opts?.cookieDomain).toBeUndefined()
expect(opts?.cookieSecure).toBeUndefined()
expect(warn).toHaveBeenCalledTimes(1)
})

it('prefers cookie.name over the deprecated cookieName', () => {
const { ctx, warn } = createCtx({ cookieName: 'legacy', cookie: { name: 'newname' } })
const opts = prepareSSRClientHints('/', ctx).prefersColorSchemeOptions
expect(opts?.cookieName).toBe('newname')
expect(warn).toHaveBeenCalledTimes(1)
})

it('applies defaults when nothing is set', () => {
const { ctx, warn } = createCtx({})
const opts = prepareSSRClientHints('/', ctx).prefersColorSchemeOptions
expect(opts?.cookieName).toBe('color-scheme')
expect(opts?.cookieSameSite).toBe('lax')
expect(opts?.cookieDomain).toBeUndefined()
expect(opts?.cookieSecure).toBeUndefined()
expect(warn).not.toHaveBeenCalled()
})

it('forces secure when sameSite is none', () => {
const { ctx } = createCtx({ cookie: { sameSite: 'none', secure: false } })
const opts = prepareSSRClientHints('/', ctx).prefersColorSchemeOptions
expect(opts?.cookieSameSite).toBe('none')
expect(opts?.cookieSecure).toBe(true)
})
})