Skip to content

Commit 2f68da1

Browse files
authored
feat(ssr-client-hints): nested cookie config for color scheme (#367)
1 parent 0225c2e commit 2f68da1

6 files changed

Lines changed: 162 additions & 5 deletions

File tree

packages/vuetify-nuxt-module/configuration.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ declare module 'virtual:vuetify-ssr-client-hints-configuration' {
3636
defaultTheme: string
3737
themeNames: string[]
3838
cookieName: string
39+
cookieDomain?: string
40+
cookieSecure?: boolean
41+
cookieSameSite: 'lax' | 'strict' | 'none'
3942
darkThemeName: string
4043
lightThemeName: string
4144
useBrowserThemeOnly: boolean

packages/vuetify-nuxt-module/src/runtime/plugins/vuetify-client-hints.client.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,13 +153,22 @@ function useSSRClientHints () {
153153
const {
154154
baseUrl,
155155
cookieName,
156+
cookieDomain,
157+
cookieSecure,
158+
cookieSameSite,
156159
defaultTheme,
157160
} = ssrClientHintsConfiguration.prefersColorSchemeOptions
158161
const cookieNamePrefix = `${cookieName}=`
159162
initial.value.colorSchemeFromCookie = document.cookie?.split(';')?.find(c => c.trim().startsWith(cookieNamePrefix))?.split('=')[1] ?? defaultTheme
160163
const date = new Date()
161164
const expires = new Date(date.setDate(date.getDate() + 365))
162-
initial.value.colorSchemeCookie = `${cookieName}=${initial.value.colorSchemeFromCookie}; Path=${baseUrl}; Expires=${expires.toUTCString()}; SameSite=Lax`
165+
initial.value.colorSchemeCookie = `${cookieName}=${initial.value.colorSchemeFromCookie}; Path=${baseUrl}; Expires=${expires.toUTCString()}; SameSite=${cookieSameSite[0].toUpperCase()}${cookieSameSite.slice(1)}`
166+
if (cookieDomain) {
167+
initial.value.colorSchemeCookie += `; Domain=${cookieDomain}`
168+
}
169+
if (cookieSecure) {
170+
initial.value.colorSchemeCookie += '; Secure'
171+
}
163172

164173
return initial
165174
}

packages/vuetify-nuxt-module/src/runtime/plugins/vuetify-client-hints.server.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,18 +364,31 @@ function writeThemeCookie (
364364
const cookieName = ssrClientHintsConfiguration.prefersColorSchemeOptions.cookieName
365365
const themeName = clientHintsRequest.colorSchemeFromCookie ?? ssrClientHintsConfiguration.prefersColorSchemeOptions.defaultTheme
366366
const path = ssrClientHintsConfiguration.prefersColorSchemeOptions.baseUrl
367+
const domain = ssrClientHintsConfiguration.prefersColorSchemeOptions.cookieDomain
368+
const secure = ssrClientHintsConfiguration.prefersColorSchemeOptions.cookieSecure
369+
const sameSite = ssrClientHintsConfiguration.prefersColorSchemeOptions.cookieSameSite
367370

368371
const date = new Date()
369372
const expires = new Date(date.setDate(date.getDate() + 365))
370373
if (!clientHintsRequest.firstRequest || !ssrClientHintsConfiguration.reloadOnFirstRequest) {
371374
useCookie(cookieName, {
372375
path,
376+
domain,
373377
expires,
374-
sameSite: 'lax',
378+
sameSite,
379+
secure,
375380
}).value = themeName
376381
}
377382

378-
return `${cookieName}=${themeName}; Path=${path}; Expires=${expires.toUTCString()}; SameSite=Lax`
383+
let cookie = `${cookieName}=${themeName}; Path=${path}; Expires=${expires.toUTCString()}; SameSite=${sameSite[0].toUpperCase()}${sameSite.slice(1)}`
384+
if (domain) {
385+
cookie += `; Domain=${domain}`
386+
}
387+
if (secure) {
388+
cookie += '; Secure'
389+
}
390+
391+
return cookie
379392
}
380393

381394
export default plugin

packages/vuetify-nuxt-module/src/types.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,9 +366,43 @@ export interface MOptions {
366366
/**
367367
* The name for the cookie.
368368
*
369+
* @deprecated Use `cookie.name` instead.
369370
* @default 'color-scheme'
370371
*/
371372
cookieName?: string
373+
/**
374+
* Cookie attributes for the color scheme cookie.
375+
*/
376+
cookie?: {
377+
/**
378+
* The name for the cookie.
379+
*
380+
* @default 'color-scheme'
381+
*/
382+
name?: string
383+
/**
384+
* The domain for the color scheme cookie.
385+
*
386+
* Useful to share the cookie across subdomains, e.g. `.example.com`.
387+
*
388+
* @default undefined
389+
*/
390+
domain?: string
391+
/**
392+
* Mark the cookie as `Secure`.
393+
*
394+
* Forced to `true` when `sameSite` is `'none'`.
395+
*
396+
* @default undefined
397+
*/
398+
secure?: boolean
399+
/**
400+
* The `SameSite` attribute for the cookie.
401+
*
402+
* @default 'lax'
403+
*/
404+
sameSite?: 'lax' | 'strict' | 'none'
405+
}
372406
/**
373407
* The name for the dark theme.
374408
*
@@ -468,6 +502,9 @@ export interface SSRClientHintsConfiguration {
468502
defaultTheme: string
469503
themeNames: string[]
470504
cookieName: string
505+
cookieDomain?: string
506+
cookieSecure?: boolean
507+
cookieSameSite: 'lax' | 'strict' | 'none'
471508
darkThemeName: string
472509
lightThemeName: string
473510
}

packages/vuetify-nuxt-module/src/utils/ssr-client-hints.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export interface ResolvedClientHints {
1111
defaultTheme: string
1212
themeNames: string[]
1313
cookieName: string
14+
cookieDomain?: string
15+
cookieSecure?: boolean
16+
cookieSameSite: 'lax' | 'strict' | 'none'
1417
darkThemeName: string
1518
lightThemeName: string
1619
useBrowserThemeOnly: boolean
@@ -25,6 +28,23 @@ const disabledClientHints: ResolvedClientHints = Object.freeze({
2528
prefersReducedMotion: false,
2629
})
2730

31+
type PrefersColorSchemeInput = NonNullable<NonNullable<VuetifyNuxtContext['moduleOptions']['ssrClientHints']>['prefersColorSchemeOptions']>
32+
33+
function resolveColorSchemeCookie (options: PrefersColorSchemeInput, logger: VuetifyNuxtContext['logger']) {
34+
if (options.cookieName !== undefined) {
35+
logger.warn('[vuetify-nuxt-module] `prefersColorSchemeOptions.cookieName` is deprecated, use `prefersColorSchemeOptions.cookie.name` instead.')
36+
}
37+
38+
const cookieSameSite = options.cookie?.sameSite ?? 'lax'
39+
40+
return {
41+
cookieName: options.cookie?.name ?? options.cookieName ?? 'color-scheme',
42+
cookieDomain: options.cookie?.domain,
43+
cookieSecure: cookieSameSite === 'none' ? true : options.cookie?.secure,
44+
cookieSameSite,
45+
}
46+
}
47+
2848
export function prepareSSRClientHints (baseUrl: string, ctx: VuetifyNuxtContext) {
2949
if (!ctx.isSSR || ctx.isNuxtGenerate) {
3050
return disabledClientHints
@@ -76,14 +96,16 @@ export function prepareSSRClientHints (baseUrl: string, ctx: VuetifyNuxtContext)
7696
throw new Error('Vuetify dark theme and light theme are the same, change darkThemeName or lightThemeName!')
7797
}
7898

99+
const pcsOptions = ssrClientHintsConfiguration.prefersColorSchemeOptions
100+
79101
clientHints.prefersColorSchemeOptions = {
80102
baseUrl,
81103
defaultTheme,
82104
themeNames: Array.from(Object.keys(themes)),
83-
cookieName: ssrClientHintsConfiguration.prefersColorSchemeOptions?.cookieName ?? 'color-scheme',
105+
...resolveColorSchemeCookie(pcsOptions, ctx.logger),
84106
darkThemeName,
85107
lightThemeName,
86-
useBrowserThemeOnly: ssrClientHintsConfiguration.prefersColorSchemeOptions?.useBrowserThemeOnly ?? false,
108+
useBrowserThemeOnly: pcsOptions?.useBrowserThemeOnly ?? false,
87109
}
88110
}
89111

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { VuetifyNuxtContext } from '../src/utils/config'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { prepareSSRClientHints } from '../src/utils/ssr-client-hints'
4+
5+
function createCtx (prefersColorSchemeOptions: any) {
6+
const warn = vi.fn()
7+
const ctx = {
8+
isSSR: true,
9+
isNuxtGenerate: false,
10+
logger: { warn },
11+
moduleOptions: {
12+
ssrClientHints: {
13+
prefersColorScheme: true,
14+
prefersColorSchemeOptions,
15+
},
16+
},
17+
vuetifyOptions: {
18+
theme: {
19+
defaultTheme: 'light',
20+
themes: { light: {}, dark: {} },
21+
},
22+
},
23+
} as unknown as VuetifyNuxtContext
24+
return { ctx, warn }
25+
}
26+
27+
describe('prepareSSRClientHints cookie normalisation', () => {
28+
it('uses cookie.* fields when provided', () => {
29+
const { ctx, warn } = createCtx({
30+
cookie: { name: 'cs', domain: '.example.com', secure: true, sameSite: 'strict' },
31+
})
32+
const opts = prepareSSRClientHints('/', ctx).prefersColorSchemeOptions
33+
expect(opts?.cookieName).toBe('cs')
34+
expect(opts?.cookieDomain).toBe('.example.com')
35+
expect(opts?.cookieSecure).toBe(true)
36+
expect(opts?.cookieSameSite).toBe('strict')
37+
expect(warn).not.toHaveBeenCalled()
38+
})
39+
40+
it('maps the deprecated cookieName and warns once', () => {
41+
const { ctx, warn } = createCtx({ cookieName: 'legacy' })
42+
const opts = prepareSSRClientHints('/', ctx).prefersColorSchemeOptions
43+
expect(opts?.cookieName).toBe('legacy')
44+
expect(opts?.cookieSameSite).toBe('lax')
45+
expect(opts?.cookieDomain).toBeUndefined()
46+
expect(opts?.cookieSecure).toBeUndefined()
47+
expect(warn).toHaveBeenCalledTimes(1)
48+
})
49+
50+
it('prefers cookie.name over the deprecated cookieName', () => {
51+
const { ctx, warn } = createCtx({ cookieName: 'legacy', cookie: { name: 'newname' } })
52+
const opts = prepareSSRClientHints('/', ctx).prefersColorSchemeOptions
53+
expect(opts?.cookieName).toBe('newname')
54+
expect(warn).toHaveBeenCalledTimes(1)
55+
})
56+
57+
it('applies defaults when nothing is set', () => {
58+
const { ctx, warn } = createCtx({})
59+
const opts = prepareSSRClientHints('/', ctx).prefersColorSchemeOptions
60+
expect(opts?.cookieName).toBe('color-scheme')
61+
expect(opts?.cookieSameSite).toBe('lax')
62+
expect(opts?.cookieDomain).toBeUndefined()
63+
expect(opts?.cookieSecure).toBeUndefined()
64+
expect(warn).not.toHaveBeenCalled()
65+
})
66+
67+
it('forces secure when sameSite is none', () => {
68+
const { ctx } = createCtx({ cookie: { sameSite: 'none', secure: false } })
69+
const opts = prepareSSRClientHints('/', ctx).prefersColorSchemeOptions
70+
expect(opts?.cookieSameSite).toBe('none')
71+
expect(opts?.cookieSecure).toBe(true)
72+
})
73+
})

0 commit comments

Comments
 (0)