diff --git a/packages/vuetify-nuxt-module/package.json b/packages/vuetify-nuxt-module/package.json index fb75ea0..afeb06c 100644 --- a/packages/vuetify-nuxt-module/package.json +++ b/packages/vuetify-nuxt-module/package.json @@ -70,12 +70,14 @@ "lint": "eslint .", "lint:fix": "nr lint --fix", "publint": "publint", - "test": "vitest run", - "test:watch": "vitest watch", + "test": "vitest run --exclude 'test/e2e/**'", + "test:watch": "vitest watch --exclude 'test/e2e/**'", + "test:e2e": "vitest run test/e2e", "release": "bumpp" }, "dependencies": { "@nuxt/kit": "catalog:", + "@vuetify/unplugin-styles": "catalog:", "defu": "catalog:", "destr": "catalog:", "pathe": "catalog:", @@ -83,7 +85,6 @@ "semver": "catalog:", "ufo": "catalog:", "unconfig": "catalog:", - "upath": "catalog:", "vite-plugin-vuetify": "catalog:", "vuetify": "catalog:" }, @@ -110,6 +111,7 @@ "eslint": "catalog:", "luxon": "catalog:", "nuxt": "catalog:", + "playwright-core": "catalog:", "publint": "catalog:", "rimraf": "catalog:", "sass": "catalog:", @@ -121,6 +123,7 @@ "build": { "externals": [ "@vuetify/loader-shared", + "@vuetify/unplugin-styles", "node:child_process", "node:fs", "consola", @@ -131,7 +134,6 @@ "rollup", "sass", "sass-embedded", - "upath", "ufo", "unconfig", "vite", diff --git a/packages/vuetify-nuxt-module/src/types.ts b/packages/vuetify-nuxt-module/src/types.ts index 4465479..a1caf3e 100644 --- a/packages/vuetify-nuxt-module/src/types.ts +++ b/packages/vuetify-nuxt-module/src/types.ts @@ -255,6 +255,18 @@ export interface MOptions { * Path to the custom Vuetify SASS configuration file. */ configFile: string + /** + * Caching options forwarded to `@vuetify/unplugin-styles`. + * + * @default true + */ + cache?: boolean | { + path?: string + sassOptions?: Record + } + /** + * @deprecated Use `styles.cache` instead. + */ experimental?: { cache?: boolean } diff --git a/packages/vuetify-nuxt-module/src/utils/config.ts b/packages/vuetify-nuxt-module/src/utils/config.ts index 1003a7c..1004d05 100644 --- a/packages/vuetify-nuxt-module/src/utils/config.ts +++ b/packages/vuetify-nuxt-module/src/utils/config.ts @@ -35,7 +35,6 @@ export interface VuetifyNuxtContext { viteVersion: string enableRules?: boolean rulesConfiguration?: { fromLabs?: boolean, configFile?: string } - stylesCachePath?: string stylesConfigFile?: string } diff --git a/packages/vuetify-nuxt-module/src/utils/configure-vite.ts b/packages/vuetify-nuxt-module/src/utils/configure-vite.ts index 4592148..1b30387 100644 --- a/packages/vuetify-nuxt-module/src/utils/configure-vite.ts +++ b/packages/vuetify-nuxt-module/src/utils/configure-vite.ts @@ -1,6 +1,8 @@ import type { Nuxt } from '@nuxt/schema' import type { ObjectImportPluginOptions } from '@vuetify/loader-shared' import type { VuetifyNuxtContext } from './config' +import { isObject } from '@vuetify/loader-shared' +import Styles from '@vuetify/unplugin-styles/vite' import defu from 'defu' import semver from 'semver' import { vuetifyConfigurationPlugin } from '../vite/vuetify-configuration-plugin' @@ -8,11 +10,14 @@ import { vuetifyDateConfigurationPlugin } from '../vite/vuetify-date-configurati import { vuetifyIconsPlugin } from '../vite/vuetify-icons-configuration-plugin' import { vuetifyImportPlugin } from '../vite/vuetify-import-plugin' import { vuetifySSRClientHintsPlugin } from '../vite/vuetify-ssr-client-hints-plugin' -import { vuetifyStylesPlugin } from '../vite/vuetify-styles-plugin' import { createTransformAssetUrls } from './index' import { checkVuetifyPlugins } from './module' import { isPackageExists } from './package' +function resolveStylesCache (stylesOption: { cache?: unknown, experimental?: { cache?: unknown } }) { + return stylesOption.cache ?? stylesOption.experimental?.cache +} + export function configureVite (configKey: string, nuxt: Nuxt, ctx: VuetifyNuxtContext) { nuxt.hook('vite:extend', ({ config }) => checkVuetifyPlugins(config)) nuxt.hook('vite:extendConfig', viteInlineConfig => { @@ -80,9 +85,19 @@ export function configureVite (configKey: string, nuxt: Nuxt, ctx: VuetifyNuxtCo } viteInlineConfig.plugins.push(vuetifyImportPlugin({ autoImport })) - // exclude styles plugin - if ((ctx.moduleOptions.styles as any) !== false && ctx.moduleOptions.styles !== 'none') { - viteInlineConfig.plugins.push(vuetifyStylesPlugin(ctx)) + + const stylesOption = ctx.moduleOptions.styles + if (stylesOption === 'none') { + viteInlineConfig.plugins.push(Styles({ styles: 'none' })) + } else if (isObject(stylesOption) && 'configFile' in stylesOption) { + if (!ctx.stylesConfigFile) { + throw new Error('vuetify-nuxt-module: styles.configFile could not be resolved') + } + const cache = resolveStylesCache(stylesOption) + viteInlineConfig.plugins.push(Styles({ + settings: ctx.stylesConfigFile, + ...(cache === undefined ? {} : { cache: cache as never }), + })) } viteInlineConfig.plugins.push(vuetifyConfigurationPlugin(ctx), vuetifyIconsPlugin(ctx), vuetifyDateConfigurationPlugin(ctx)) if (ctx.ssrClientHints.enabled) { diff --git a/packages/vuetify-nuxt-module/src/utils/loader.ts b/packages/vuetify-nuxt-module/src/utils/loader.ts index baa2033..bccdd96 100644 --- a/packages/vuetify-nuxt-module/src/utils/loader.ts +++ b/packages/vuetify-nuxt-module/src/utils/loader.ts @@ -11,7 +11,6 @@ import { prepareIcons } from './icons' import { mergeVuetifyModules } from './layers' import { cleanupBlueprint, detectDate, resolveVuetifyComponents } from './module' import { prepareSSRClientHints } from './ssr-client-hints' -import { prepareVuetifyStyles } from './styles-compiler' export async function load ( options: VuetifyModuleOptions, @@ -108,8 +107,6 @@ export async function load ( } } } - - await prepareVuetifyStyles(nuxt, ctx) } export function registerWatcher (options: VuetifyModuleOptions, nuxt: Nuxt, ctx: VuetifyNuxtContext) { diff --git a/packages/vuetify-nuxt-module/src/utils/styles-compiler.ts b/packages/vuetify-nuxt-module/src/utils/styles-compiler.ts deleted file mode 100644 index 8bd851c..0000000 --- a/packages/vuetify-nuxt-module/src/utils/styles-compiler.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { Nuxt } from '@nuxt/schema' -import type { VuetifyNuxtContext } from './config' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { pathToFileURL } from 'node:url' -import { resolvePath } from '@nuxt/kit' -import { isObject, normalizePath, resolveVuetifyBase } from '@vuetify/loader-shared' -import { dirname, join, relative, resolve } from 'pathe' - -import { cleanupOldStylesCaches, collectVuetifyCssFiles, createStylesCacheHash, resolveStylesCachePaths, resolveVuetifyConfigFile } from './styles' - -export async function prepareVuetifyStyles (nuxt: Nuxt, ctx: VuetifyNuxtContext) { - const stylesConfig = ctx.moduleOptions.styles - - if (!isObject(stylesConfig) || !('configFile' in stylesConfig)) { - return - } - - if (stylesConfig.experimental?.cache === false) { - return - } - - const vuetifyBase = await resolveVuetifyBase() - let configFile: string | undefined - let configContent = '' - - if (stylesConfig.configFile) { - configFile = await resolvePath(resolveVuetifyConfigFile(stylesConfig.configFile, nuxt)) - ctx.stylesConfigFile = configFile - if (existsSync(configFile)) { - configContent = readFileSync(configFile, 'utf8') - // Add to watch list - if (!ctx.vuetifyFilesToWatch.includes(configFile)) { - ctx.vuetifyFilesToWatch.push(configFile) - } - } - } - - if (!configFile) { - return - } - - // Calculate hash - const hash = createStylesCacheHash( - ctx.vuetifyVersion, - ctx.viteVersion, - configContent, - configFile, - ) - - const { stylesDir, cacheDir } = resolveStylesCachePaths(nuxt.options.rootDir, hash) - ctx.stylesCachePath = cacheDir - - cleanupOldStylesCaches(stylesDir, hash) - - if (existsSync(cacheDir)) { - return - } - - ctx.logger.info('Compiling Vuetify styles...') - - // Load SASS compiler - let sass: any - try { - sass = await import('sass') - } catch { - try { - sass = await import('sass-embedded') - } catch { - ctx.logger.warn('Could not load "sass" or "sass-embedded". Skipping styles pre-compilation.') - return - } - } - - // Generate cache - const files = collectVuetifyCssFiles(vuetifyBase) - - for (const file of files) { - const relativePath = relative(vuetifyBase, file) - const cacheFile = join(cacheDir, relativePath) // .css - - // Check if .sass or .scss exists - const sassFile = file.replace(/\.css$/, '.sass') - const scssFile = file.replace(/\.css$/, '.scss') - - let targetFile: string | undefined - if (existsSync(sassFile)) { - targetFile = sassFile - } else if (existsSync(scssFile)) { - targetFile = scssFile - } - - if (targetFile) { - const dir = dirname(cacheFile) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - - const content = `@use "${normalizePath(configFile)}";\n@use "${normalizePath(targetFile)}";\n` - - try { - const result = sass.compileString(content, { - loadPaths: [ - dirname(configFile), - dirname(targetFile), - resolve(vuetifyBase, '..'), - resolve(vuetifyBase, '../..'), // In case of monorepo/hoisting issues, but standard is enough - vuetifyBase, - ], - url: new URL(pathToFileURL(cacheFile).href), - }) - writeFileSync(cacheFile, result.css, 'utf8') - } catch (error) { - ctx.logger.error(`Failed to compile ${targetFile}:`, error) - } - } - } - - // Create metadata.json - const metadata = { - hash, - vuetifyVersion: ctx.vuetifyVersion, - viteVersion: ctx.viteVersion, - configFile, - createdAt: new Date().toISOString(), - } - writeFileSync(join(cacheDir, 'metadata.json'), JSON.stringify(metadata, null, 2), 'utf8') -} diff --git a/packages/vuetify-nuxt-module/src/utils/styles.ts b/packages/vuetify-nuxt-module/src/utils/styles.ts index 8dfccd1..68d9e64 100644 --- a/packages/vuetify-nuxt-module/src/utils/styles.ts +++ b/packages/vuetify-nuxt-module/src/utils/styles.ts @@ -1,7 +1,6 @@ import type { Nuxt } from '@nuxt/schema' -import { createHash } from 'node:crypto' -import { existsSync, readdirSync, rmSync, statSync } from 'node:fs' -import { isAbsolute, join, resolve } from 'pathe' +import { existsSync } from 'node:fs' +import { isAbsolute, resolve } from 'pathe' export function resolveVuetifyConfigFile (configFile: string, nuxt: Nuxt) { if (typeof configFile === 'string' && !isAbsolute(configFile)) { @@ -14,66 +13,3 @@ export function resolveVuetifyConfigFile (configFile: string, nuxt: Nuxt) { } return configFile } - -export function createStylesCacheHash ( - vuetifyVersion: string, - viteVersion: string, - configContent: string, - configFile: string, -) { - return createHash('sha256') - .update(vuetifyVersion) - .update(viteVersion) - .update(configContent) - .update(configFile) - .digest('hex') - .slice(0, 8) -} - -export function resolveStylesCachePaths (rootDir: string, hash: string) { - const stylesDir = resolve(rootDir, 'node_modules/.cache/vuetify-nuxt-module/styles') - const cacheDir = join(stylesDir, hash) - return { - stylesDir, - cacheDir, - } -} - -export function cleanupOldStylesCaches (stylesDir: string, currentHash: string) { - if (!existsSync(stylesDir)) { - return - } - - const dirents = readdirSync(stylesDir, { withFileTypes: true }) - for (const dirent of dirents) { - if (dirent.isDirectory() && dirent.name !== currentHash) { - rmSync(join(stylesDir, dirent.name), { recursive: true, force: true }) - } - } -} - -export function collectVuetifyCssFiles (vuetifyBase: string) { - const files: string[] = [] - findCssFiles(join(vuetifyBase, 'lib/components'), files) - findCssFiles(join(vuetifyBase, 'lib/styles'), files) - return files -} - -function findCssFiles (dir: string, fileList: string[] = []) { - if (!existsSync(dir)) { - return fileList - } - const files = readdirSync(dir) - for (const file of files) { - const filePath = join(dir, file) - const stat = statSync(filePath) - if (stat.isDirectory()) { - findCssFiles(filePath, fileList) - } else { - if (file.endsWith('.css')) { - fileList.push(filePath) - } - } - } - return fileList -} diff --git a/packages/vuetify-nuxt-module/src/vite/vuetify-styles-plugin.ts b/packages/vuetify-nuxt-module/src/vite/vuetify-styles-plugin.ts deleted file mode 100644 index e24ec59..0000000 --- a/packages/vuetify-nuxt-module/src/vite/vuetify-styles-plugin.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { Plugin } from 'vite' -import type { VuetifyNuxtContext } from '../utils/config' -import fs from 'node:fs' -import fsp from 'node:fs/promises' -import { pathToFileURL } from 'node:url' -import { resolvePath } from '@nuxt/kit' -import { isObject, normalizePath, resolveVuetifyBase } from '@vuetify/loader-shared' -import { isAbsolute, relative as relativePath } from 'pathe' -import semver from 'semver' -import path from 'upath' - -export function vuetifyStylesPlugin ( - ctx: VuetifyNuxtContext, -) { - let configFile: string | undefined - let vuetifyBase: string | undefined - const options = { styles: ctx.moduleOptions.styles } - const noneFiles = new Set() - let isNone = false - let sassVariables = false - let fileImport = false - const PREFIX = 'vuetify-styles/' - const SSR_PREFIX = `/@${PREFIX}` - const resolveCss = resolveCssFactory() - const toPath = (file: string) => fileImport ? pathToFileURL(file).href : normalizePath(file) - - return { - name: 'vuetify:styles:nuxt', - enforce: 'pre', - async configResolved (config) { - if (config.plugins.some(plugin => plugin.name === 'vuetify:styles')) { - throw new Error('Remove vite-plugin-vuetify from your Nuxt config file, this module registers a modified version.') - } - - if (isObject(options.styles) && 'configFile' in options.styles) { - sassVariables = true - fileImport = semver.gt(ctx.viteVersion, '5.4.2') - configFile = ctx.stylesConfigFile ?? await resolvePath(options.styles.configFile) - } else { - isNone = options.styles === 'none' - } - vuetifyBase = await resolveVuetifyBase() - }, - async resolveId (source, importer, { custom, ssr }) { - if (!sassVariables) { - return - } - - if (source.startsWith(PREFIX) || source.startsWith(SSR_PREFIX)) { - if (/\.s[ca]ss$/.test(source)) { - return source - } - - const idx = source.indexOf('?') - return idx === -1 ? source : source.slice(0, idx) - } - - if ( - vuetifyBase - && importer - && source.endsWith('.css') - && isSubdir(vuetifyBase, path.isAbsolute(source) ? source : importer) - ) { - let resolutionId: string | undefined - - if (source.startsWith('.')) { - resolutionId = path.resolve(path.dirname(importer), source) - } else if (path.isAbsolute(source)) { - resolutionId = source - } else { - const resolution = await this.resolve(source, importer, { skipSelf: true, custom }) - if (resolution) { - resolutionId = resolution.id - } - } - - if (!resolutionId) { - return - } - - const target = await resolveCss(resolutionId) - - if (isNone) { - noneFiles.add(target) - return target - } - - if (ctx.stylesCachePath) { - const relative = path.relative(vuetifyBase, target) - const cacheFile = path.resolve(ctx.stylesCachePath, relative.replace(/\.s[ac]ss$/, '.css')) - if (fs.existsSync(cacheFile)) { - return cacheFile - } - } - - return `${ssr ? SSR_PREFIX : PREFIX}${path.relative(vuetifyBase, target)}` - } - - return undefined - }, - load (id) { - if (sassVariables) { - if (!vuetifyBase) { - return - } - const target = id.startsWith(PREFIX) - ? path.resolve(vuetifyBase, id.slice(PREFIX.length)) - : (id.startsWith(SSR_PREFIX) - ? path.resolve(vuetifyBase, id.slice(SSR_PREFIX.length)) - : undefined) - - if (target) { - const suffix = /\.scss/.test(target) ? ';\n' : '\n' - return { - code: `@use "${toPath(configFile!)}"${suffix}@use "${toPath(target)}"${suffix}`, - map: { - mappings: '', - }, - } - } - } - - return isNone && noneFiles.has(id) ? '' : undefined - }, - } -} - -function resolveCssFactory () { - const mappings = new Map() - return async (source: string) => { - let mapping = mappings.get(source) - if (!mapping) { - try { - mapping = source.replace(/\.css$/, '.sass') - await fsp.access(mapping, fs.constants.R_OK) - } catch (error) { - if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { - throw error - } - try { - mapping = source.replace(/\.css$/, '.scss') - await fsp.access(mapping, fs.constants.R_OK) - } catch { - // If neither sass nor scss exists, fallback to css - mapping = source - } - } - mappings.set(source, mapping) - } - return mapping - } -} - -function isSubdir (root: string, test: string) { - const relative = relativePath(root, test) - return relative && !relative.startsWith('..') && !isAbsolute(relative) -} diff --git a/packages/vuetify-nuxt-module/test/e2e/styles-ssr.spec.ts b/packages/vuetify-nuxt-module/test/e2e/styles-ssr.spec.ts new file mode 100644 index 0000000..5f94b12 --- /dev/null +++ b/packages/vuetify-nuxt-module/test/e2e/styles-ssr.spec.ts @@ -0,0 +1,230 @@ +import type { Browser } from 'playwright-core' +import { fileURLToPath } from 'node:url' +import { + $fetch, + createTest, + getBrowser, + setTestContext, + url, +} from '@nuxt/test-utils/e2e' +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest' + +type StylesMode = 'none' | 'configFile' +type RunType = 'dev' | 'build' + +const rootDir = fileURLToPath(new URL('../fixtures/styles-ssr', import.meta.url)) + +const matrix: Array<{ mode: StylesMode, runType: RunType }> = [ + { mode: 'none', runType: 'dev' }, + { mode: 'none', runType: 'build' }, + { mode: 'configFile', runType: 'dev' }, + { mode: 'configFile', runType: 'build' }, +] + +for (const { mode, runType } of matrix) { + describe(`styles-ssr — ${mode} / ${runType}`, () => { + // Note: running multiple @nuxt/test-utils setups in one file shares a + // single module-level `currentContext` (see setTestContext in + // @nuxt/test-utils). If we used the `setup()` helper, only the last + // describe's context would survive to beforeAll, so earlier describes' + // fixtures/servers would never start. By using `createTest` and wiring + // vitest hooks ourselves, we can re-bind the context to the correct + // describe in each beforeAll/afterAll. + // + // Also: with `dev: true`, `build` must remain `true` (its default) — + // @nuxt/test-utils still loads the Nuxt instance via the build code path + // and dereferences `ctx.nuxt.options.rootDir` when spawning `nuxi _dev`. + const hooks = createTest({ + rootDir, + server: true, + browser: true, + build: true, + dev: runType === 'dev', + env: { STYLES_MODE: mode }, + }) + + let prevStylesMode: string | undefined + beforeAll(async () => { + // `env` in the setup options only reaches the spawned server process. + // `loadNuxt` / `buildNuxt` run in-process and read `process.env` + // directly, so we must set STYLES_MODE before the build runs or the + // in-process build for `configFile` mode silently falls back to + // `'none'` (the fixture's default), stripping the Vuetify styles + // from the static bundle while the runtime config claims otherwise. + prevStylesMode = process.env.STYLES_MODE + process.env.STYLES_MODE = mode + setTestContext(hooks.ctx) + await hooks.beforeAll() + }, hooks.ctx.options.setupTimeout) + beforeEach(() => setTestContext(hooks.ctx)) + afterAll(async () => { + setTestContext(hooks.ctx) + await hooks.afterAll() + setTestContext(undefined) + if (prevStylesMode === undefined) { + delete process.env.STYLES_MODE + } else { + process.env.STYLES_MODE = prevStylesMode + } + }, hooks.ctx.options.teardownTimeout) + + let browser: Browser | undefined + + beforeAll(async () => { + browser = await getBrowser() + }) + + it('scenario 1 — FOUC: SSR HTML inlines theme variables', async () => { + const html = await $fetch('/') + // Vuetify injects CSS variables on the theme class. `--v-theme-surface` + // must be present in the SSR payload before any JS runs. + expect(html).toMatch(/--v-theme-surface\s*:/) + // Vuetify root class is present on SSR. + expect(html).toContain('v-theme--light') + }) + + it('scenario 2 — hydration: no Vue warnings in console', async () => { + const pageCtx = await browser!.newContext() + const p = await pageCtx.newPage() + const warnings: string[] = [] + p.on('console', msg => { + const text = msg.text() + if (/Hydration|mismatch|\[Vue warn\]/.test(text)) { + warnings.push(text) + } + }) + try { + await p.goto(url('/'), { waitUntil: 'load' }) + expect(warnings, `[${mode}/${runType}] hydration warnings:\n${warnings.join('\n')}`).toEqual([]) + } finally { + await p.close() + await pageCtx.close() + } + }) + + it('scenario 3 — SSR client hints: dark theme applied server-side', async () => { + const ctx = await browser!.newContext({ + extraHTTPHeaders: { 'Sec-CH-Prefers-Color-Scheme': 'dark' }, + }) + const hintsPage = await ctx.newPage() + try { + const response = await hintsPage.goto(url('/'), { waitUntil: 'commit' }) + const body = (await response?.text()) ?? '' + expect(body).toContain('v-theme--dark') + } finally { + await hintsPage.close() + await ctx.close() + } + }) + + if (mode === 'configFile') { + it('scenario 4 — custom SCSS variable: $body-font-family applied', async () => { + const pageCtx = await browser!.newContext() + const p = await pageCtx.newPage() + try { + await p.goto(url('/'), { waitUntil: 'load' }) + // Wait until stylesheets are actually applied before reading + // computed styles. Vuetify's CSS-layer rules only take effect + // after the linked stylesheets finish loading. + await p.waitForFunction(() => { + return Array.from(document.styleSheets).some(s => { + try { + return s.cssRules.length > 0 + } catch { + return false + } + }) + }) + const font = await p.evaluate(() => { + return getComputedStyle( + document.querySelector('#body-font-sample') as HTMLElement, + ).fontFamily + }) + expect(font, `[${mode}/${runType}] body font`).toContain('Comic Sans MS') + } finally { + await p.close() + await pageCtx.close() + } + }) + } + + if (mode === 'configFile') { + it('scenario 5 — CSS layers: @layer vuetify-components is declared and populated', async () => { + // `styles: 'none'` intentionally skips Vuetify's global stylesheet, + // so only configFile mode is expected to emit `@layer vuetify-*` + // rules for this fixture. + const pageCtx = await browser!.newContext() + const p = await pageCtx.newPage() + try { + await p.goto(url('/'), { waitUntil: 'load' }) + // We check both that the layer name appears in the page's loaded + // CSS text (confirming the build pipeline preserves `@layer` + // declarations end-to-end) and — when exposed by the browser's + // CSSOM — that at least one rule is nested inside it. Some + // bundler-emitted CSS splits the layer block across files; the + // textual check covers those cases. + const result = await p.evaluate(async () => { + const links = Array.from( + document.querySelectorAll('link[rel=stylesheet]'), + ) as HTMLLinkElement[] + const texts = await Promise.all( + links.map(l => + fetch(l.href).then(r => r.text()).catch(() => ''), + ), + ) + const inlineStyles = Array.from( + document.querySelectorAll('style'), + ).map(s => s.textContent ?? '') + const allCss = [...texts, ...inlineStyles].join('\n') + return { + hasLayerBlock: /@layer\s+vuetify-components\s*\{/.test(allCss), + hasLayerDecl: /@layer[^;{}]*\bvuetify-components\b/.test(allCss), + } + }) + expect(result.hasLayerDecl, `[${mode}/${runType}] @layer vuetify-components declaration`).toBe(true) + expect(result.hasLayerBlock, `[${mode}/${runType}] @layer vuetify-components block`).toBe(true) + } finally { + await p.close() + await pageCtx.close() + } + }) + } + + // This scenario captured a real regression that was fixed upstream in + // @vuetify/unplugin-styles: in Nuxt dev mode, SSR HTML references + // `/_nuxt/vuetify-styles/*` virtual URLs which Vite's transformMiddleware + // serves with the base stripped (e.g. `/vuetify-styles/...`). The upstream + // plugin's resolveId/load filters were anchored to a bare `vuetify-styles` + // prefix and so missed the leading slash, yielding 404s. Prod builds were + // always clean because the virtual CSS is materialised as real static + // files. We run the scenario in all four matrix combos to keep the + // regression covered going forward. + it(`scenario 6 — no 404 responses for any asset during load`, async () => { + const ctx = await browser!.newContext() + const p = await ctx.newPage() + const notFound: string[] = [] + p.on('response', res => { + if (res.status() === 404) { + notFound.push(`${res.status()} ${res.url()}`) + } + }) + try { + await p.goto(url('/'), { waitUntil: 'load' }) + expect( + notFound, + `[${mode}/${runType}] unexpected 404 responses:\n${notFound.join('\n')}`, + ).toEqual([]) + } finally { + await p.close() + await ctx.close() + } + }) + }) +} diff --git a/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/app.vue b/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/app.vue new file mode 100644 index 0000000..bdce8b8 --- /dev/null +++ b/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/app.vue @@ -0,0 +1,5 @@ + diff --git a/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/assets/settings.scss b/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/assets/settings.scss new file mode 100644 index 0000000..052bd6e --- /dev/null +++ b/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/assets/settings.scss @@ -0,0 +1,3 @@ +@use 'vuetify/settings' with ( + $body-font-family: ('Comic Sans MS', sans-serif), +); diff --git a/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/nuxt.config.ts b/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/nuxt.config.ts new file mode 100644 index 0000000..a130ca9 --- /dev/null +++ b/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/nuxt.config.ts @@ -0,0 +1,33 @@ +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import MyModule from '../../../src/module' + +const mode = process.env.STYLES_MODE ?? 'none' + +const stylesOption + = mode === 'configFile' + ? { + configFile: fileURLToPath( + new URL('assets/settings.scss', import.meta.url), + ), + } + : 'none' as const + +export default defineNuxtConfig({ + modules: [MyModule], + ssr: true, + vuetify: { + moduleOptions: { + styles: stylesOption, + ssrClientHints: { + prefersColorScheme: true, + prefersColorSchemeOptions: { + defaultTheme: 'light', + darkThemeName: 'dark', + lightThemeName: 'light', + cookieName: 'color-scheme', + }, + }, + }, + }, +}) diff --git a/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/package.json b/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/package.json new file mode 100644 index 0000000..705fc6a --- /dev/null +++ b/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/package.json @@ -0,0 +1,5 @@ +{ + "name": "styles-ssr", + "type": "module", + "private": true +} diff --git a/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/pages/index.vue b/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/pages/index.vue new file mode 100644 index 0000000..8936ff5 --- /dev/null +++ b/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/pages/index.vue @@ -0,0 +1,10 @@ + diff --git a/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/vuetify.config.ts b/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/vuetify.config.ts new file mode 100644 index 0000000..0132345 --- /dev/null +++ b/packages/vuetify-nuxt-module/test/fixtures/styles-ssr/vuetify.config.ts @@ -0,0 +1,11 @@ +import { defineVuetifyConfiguration } from '../../../custom-configuration.mjs' + +export default defineVuetifyConfiguration({ + theme: { + defaultTheme: 'light', + themes: { + light: { dark: false, colors: {} }, + dark: { dark: true, colors: {} }, + }, + }, +}) diff --git a/packages/vuetify-nuxt-module/test/unplugin-styles.test.ts b/packages/vuetify-nuxt-module/test/unplugin-styles.test.ts new file mode 100644 index 0000000..f6fd983 --- /dev/null +++ b/packages/vuetify-nuxt-module/test/unplugin-styles.test.ts @@ -0,0 +1,182 @@ +import type { Nuxt } from '@nuxt/schema' +import type { VuetifyNuxtContext } from '../src/utils/config' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@vuetify/unplugin-styles/vite', async importOriginal => { + const original = await importOriginal() + const Styles = original.default + const wrapped: typeof Styles = opts => { + const plugin = Styles(opts) + // Shallow-clone so assertions aren't affected if the real factory mutates + // its argument (e.g. normalises a path or fills in defaults). + // Non-enumerable so the property doesn't leak into Vite's plugin-shape + // introspection or console output. + Object.defineProperty(plugin, '__options', { value: { ...opts }, enumerable: false }) + return plugin + } + return { ...original, default: wrapped } +}) + +// eslint-disable-next-line import/first -- mock must hoist above this import +import { configureVite } from '../src/utils/configure-vite' + +// Intentionally hard-coded: if @vuetify/unplugin-styles renames its plugin, +// these tests should break loudly so we re-verify the wiring in configureVite. +const PLUGIN_NAME = '@vuetify/unplugin-styles' + +function createStubNuxt () { + let extendConfigCb: ((cfg: any) => void) | undefined + let lastCfg: any + const nuxt = { + hook (event: string, cb: any) { + if (event === 'vite:extendConfig') { + extendConfigCb = cb + } + }, + } as unknown as Nuxt + return { + nuxt, + get lastCfg () { + return lastCfg + }, + runExtendConfig () { + if (!extendConfigCb) { + throw new Error('vite:extendConfig was not registered') + } + const cfg: any = { plugins: [] } + try { + extendConfigCb(cfg) + } finally { + // expose partial state for post-throw inspection + lastCfg = cfg + } + return cfg + }, + } +} + +function createCtx (overrides: Partial = {}): VuetifyNuxtContext { + return { + moduleOptions: {}, + vuetifyOptions: {}, + isSSR: false, + viteVersion: '5.0.0', + ssrClientHints: { enabled: false } as any, + ...overrides, + } as VuetifyNuxtContext +} + +function findStylesPlugin (plugins: any[]) { + // Sentinel: configureVite is synchronous — plugin entries must not be thenables. + // If unplugin ever returns Promise, this surfaces as a clear error + // instead of silently making every "plugin present" assertion fail. + for (const p of plugins) { + if (p && typeof (p as any).then === 'function') { + throw new Error('configureVite registered an async plugin entry — unexpected') + } + } + return plugins.find(p => p && typeof p === 'object' && p.name === PLUGIN_NAME) +} + +describe('configureVite — @vuetify/unplugin-styles wiring', () => { + it('does not register the plugin when styles is undefined', () => { + const { nuxt, runExtendConfig } = createStubNuxt() + configureVite('vuetify', nuxt, createCtx({ moduleOptions: {} })) + const cfg = runExtendConfig() + expect(findStylesPlugin(cfg.plugins)).toBeUndefined() + }) + + it('does not register the plugin when styles is true', () => { + const { nuxt, runExtendConfig } = createStubNuxt() + configureVite('vuetify', nuxt, createCtx({ moduleOptions: { styles: true } })) + const cfg = runExtendConfig() + expect(findStylesPlugin(cfg.plugins)).toBeUndefined() + }) + + it('registers the plugin with { styles: \'none\' } for styles: \'none\'', () => { + const { nuxt, runExtendConfig } = createStubNuxt() + configureVite('vuetify', nuxt, createCtx({ moduleOptions: { styles: 'none' } })) + const cfg = runExtendConfig() + const plugin = findStylesPlugin(cfg.plugins) + expect(plugin).toBeDefined() + expect((plugin as any).__options).toEqual({ styles: 'none' }) + }) + + it('registers the plugin with { settings } when configFile is provided', () => { + const { nuxt, runExtendConfig } = createStubNuxt() + configureVite( + 'vuetify', + nuxt, + createCtx({ + moduleOptions: { styles: { configFile: 'whatever.scss' } }, + stylesConfigFile: '/abs/path/settings.scss', + }), + ) + const cfg = runExtendConfig() + const plugin = findStylesPlugin(cfg.plugins) + expect(plugin).toBeDefined() + expect((plugin as any).__options).toEqual({ settings: '/abs/path/settings.scss' }) + }) + + it('forwards styles.cache when configFile is provided', () => { + const { nuxt, runExtendConfig } = createStubNuxt() + configureVite( + 'vuetify', + nuxt, + createCtx({ + moduleOptions: { styles: { configFile: 'whatever.scss', cache: false } as any }, + stylesConfigFile: '/abs/path/settings.scss', + }), + ) + const cfg = runExtendConfig() + const plugin = findStylesPlugin(cfg.plugins) + expect(plugin).toBeDefined() + expect((plugin as any).__options).toEqual({ settings: '/abs/path/settings.scss', cache: false }) + }) + + it('supports legacy styles.experimental.cache as fallback', () => { + const { nuxt, runExtendConfig } = createStubNuxt() + configureVite( + 'vuetify', + nuxt, + createCtx({ + moduleOptions: { styles: { configFile: 'whatever.scss', experimental: { cache: false } } }, + stylesConfigFile: '/abs/path/settings.scss', + }), + ) + const cfg = runExtendConfig() + const plugin = findStylesPlugin(cfg.plugins) + expect(plugin).toBeDefined() + expect((plugin as any).__options).toEqual({ settings: '/abs/path/settings.scss', cache: false }) + }) + + it('throws when configFile is provided but stylesConfigFile is not resolved, without registering the plugin before throwing', () => { + const stub = createStubNuxt() + configureVite( + 'vuetify', + stub.nuxt, + createCtx({ + moduleOptions: { styles: { configFile: 'whatever.scss' } }, + // stylesConfigFile intentionally unset + }), + ) + expect(() => stub.runExtendConfig()).toThrow( + 'vuetify-nuxt-module: styles.configFile could not be resolved', + ) + // The partial plugin array collected up to the throw point must not + // contain our unplugin — ordering regressions that push then throw + // should fail this assertion. + expect(findStylesPlugin(stub.lastCfg?.plugins ?? [])).toBeUndefined() + }) + + it('does not register the plugin for object styles without configFile', () => { + const { nuxt, runExtendConfig } = createStubNuxt() + configureVite( + 'vuetify', + nuxt, + createCtx({ moduleOptions: { styles: { colors: false, utilities: false } as any } }), + ) + const cfg = runExtendConfig() + expect(findStylesPlugin(cfg.plugins)).toBeUndefined() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65fcced..f88c5d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ catalogs: '@vite-pwa/vitepress': specifier: ^1.1.0 version: 1.1.0 + '@vuetify/unplugin-styles': + specifier: ^1.0.0-beta.10 + version: 1.0.0-beta.10 bumpp: specifier: ^10.4.1 version: 10.4.1 @@ -153,6 +156,9 @@ catalogs: perfect-debounce: specifier: ^2.1.0 version: 2.1.0 + playwright-core: + specifier: ^1.58.0 + version: 1.59.1 publint: specifier: ^0.3.18 version: 0.3.18 @@ -186,9 +192,6 @@ catalogs: unocss: specifier: ^66.6.5 version: 66.6.5 - upath: - specifier: ^2.0.1 - version: 2.0.1 vite: specifier: 7.3.1 version: 7.3.1 @@ -221,8 +224,8 @@ catalogs: version: 7.4.0 overrides: - vitepress: 2.0.0-alpha.16 '@vite-pwa/vitepress>vitepress': 2.0.0-alpha.16 + vitepress: 2.0.0-alpha.16 importers: @@ -443,6 +446,9 @@ importers: '@nuxt/kit': specifier: 'catalog:' version: 4.4.2(magicast@0.5.2) + '@vuetify/unplugin-styles': + specifier: 'catalog:' + version: 1.0.0-beta.10(@nuxt/kit@4.4.2(magicast@0.5.2))(@nuxt/schema@4.3.1)(sass-embedded@1.97.3)(sass@1.97.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.88.2(esbuild@0.27.3)) defu: specifier: 'catalog:' version: 6.1.4 @@ -464,9 +470,6 @@ importers: unconfig: specifier: 'catalog:' version: 7.5.0 - upath: - specifier: 'catalog:' - version: 2.0.1 vite-plugin-vuetify: specifier: 'catalog:' version: 2.1.3(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(vuetify@4.0.1) @@ -512,7 +515,7 @@ importers: version: 4.3.1 '@nuxt/test-utils': specifier: 'catalog:' - version: 4.0.0(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.0(magicast@0.5.2)(playwright-core@1.59.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@nuxtjs/i18n': specifier: 'catalog:' version: 10.2.3(@vue/compiler-dom@3.5.29)(db0@0.3.4)(eslint@10.0.2(jiti@2.6.1))(ioredis@5.10.0)(magicast@0.5.2)(rollup@4.59.0)(vue@3.5.29(typescript@5.9.3)) @@ -540,6 +543,9 @@ importers: nuxt: specifier: 'catalog:' version: 4.3.1(@parcel/watcher@2.5.6)(@types/node@25.3.3)(@vitejs/devtools@0.1.3(@pnpm/logger@1001.0.1)(db0@0.3.4)(ioredis@5.10.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)))(@vue/compiler-sfc@3.5.29)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@10.0.2(jiti@2.6.1))(ioredis@5.10.0)(magicast@0.5.2)(optionator@0.9.4)(rollup@4.59.0)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.2.5(typescript@5.9.3))(yaml@2.8.2) + playwright-core: + specifier: 'catalog:' + version: 1.59.1 publint: specifier: 'catalog:' version: 0.3.18 @@ -3786,6 +3792,36 @@ packages: vue: ^3.0.0 vuetify: '>=3' + '@vuetify/unplugin-styles@1.0.0-beta.10': + resolution: {integrity: sha512-3fpGIPqlV4MC+hetWJkPPz0C1AyiE1HIIkI++slB3UHrETlMCSn0kpfdGwW4VohXj53TNnVI5s3IImOIvAaEYg==} + engines: {node: '>=22'} + peerDependencies: + '@nuxt/kit': ^3 || ^4 + '@nuxt/schema': ^3 || ^4 + '@rspack/core': ^1 + astro: ^4 || ^5 + sass: ^1.77.0 + sass-embedded: ^1.77.0 + vite: '>=3' + webpack: ^4 || ^5 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@nuxt/schema': + optional: true + '@rspack/core': + optional: true + astro: + optional: true + sass: + optional: true + sass-embedded: + optional: true + vite: + optional: true + webpack: + optional: true + '@vueuse/core@14.2.1': resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==} peerDependencies: @@ -6449,6 +6485,11 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -10072,7 +10113,7 @@ snapshots: rc9: 3.0.0 std-env: 3.10.0 - '@nuxt/test-utils@4.0.0(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@nuxt/test-utils@4.0.0(magicast@0.5.2)(playwright-core@1.59.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@clack/prompts': 1.0.0 '@nuxt/devtools-kit': 2.7.0(magicast@0.5.2)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -10101,9 +10142,10 @@ snapshots: tinyexec: 1.0.4 ufo: 1.6.3 unplugin: 3.0.0 - vitest-environment-nuxt: 1.0.1(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + vitest-environment-nuxt: 1.0.1(magicast@0.5.2)(playwright-core@1.59.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) vue: 3.5.29(typescript@5.9.3) optionalDependencies: + playwright-core: 1.59.1 vitest: 4.0.18(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - crossws @@ -11789,6 +11831,19 @@ snapshots: vue: 3.5.29(typescript@5.9.3) vuetify: 4.0.1(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.29(typescript@5.9.3)) + '@vuetify/unplugin-styles@1.0.0-beta.10(@nuxt/kit@4.4.2(magicast@0.5.2))(@nuxt/schema@4.3.1)(sass-embedded@1.97.3)(sass@1.97.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.88.2(esbuild@0.27.3))': + dependencies: + pathe: 2.0.3 + semver: 7.7.4 + unplugin: 3.0.0 + optionalDependencies: + '@nuxt/kit': 4.4.2(magicast@0.5.2) + '@nuxt/schema': 4.3.1 + sass: 1.97.3 + sass-embedded: 1.97.3 + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + webpack: 5.88.2(esbuild@0.27.3) + '@vueuse/core@14.2.1(vue@3.5.29(typescript@5.9.3))': dependencies: '@types/web-bluetooth': 0.0.21 @@ -15111,6 +15166,8 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.59.1: {} + pluralize@8.0.0: {} pnpm-workspace-yaml@1.6.0: @@ -16820,9 +16877,9 @@ snapshots: - universal-cookie - yaml - vitest-environment-nuxt@1.0.1(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + vitest-environment-nuxt@1.0.1(magicast@0.5.2)(playwright-core@1.59.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - '@nuxt/test-utils': 4.0.0(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@nuxt/test-utils': 4.0.0(magicast@0.5.2)(playwright-core@1.59.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - '@cucumber/cucumber' - '@jest/globals' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 897d3bb..97cde96 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -38,6 +38,7 @@ catalog: '@unocss/nuxt': ^66.6.5 '@vite-pwa/assets-generator': ^1.0.2 '@vite-pwa/vitepress': ^1.1.0 + '@vuetify/unplugin-styles': ^1.0.0-beta.10 bumpp: ^10.4.1 conventional-github-releaser: ^3.1.5 date-fns: ^4.1.0 @@ -55,6 +56,7 @@ catalog: nuxt: ^4.3.1 pathe: ^2.0.3 perfect-debounce: ^2.1.0 + playwright-core: ^1.58.0 publint: ^0.3.18 rimraf: ^6.1.3 sass: ^1.97.3 @@ -66,7 +68,6 @@ catalog: ufo: ^1.6.3 unconfig: ^7.5.0 unocss: ^66.6.5 - upath: ^2.0.1 vite: 7.3.1 vite-plugin-pwa: ^1.2.0 vite-plugin-vuetify: ^2.1.3