From 72993a147baa1f8a1aa12eb2ba026dd6a8481cc7 Mon Sep 17 00:00:00 2001 From: haigou-web Date: Mon, 5 Oct 2026 21:08:29 +0800 Subject: [PATCH 1/3] fix: emit a file URL for the styles configFile Dart Sass resolves `@use` arguments as URLs, so a bare Windows path such as `C:/foo/settings.scss` is parsed as the scheme `c:` and never reaches the filesystem importer, failing with "Can't find stylesheet to import". POSIX paths start with `/`, which Sass accepts, so this only breaks on Windows and is unrelated to spaces in the path. Emit `file://` URLs for absolute paths instead, which Sass resolves on every platform and which also handle spaces correctly. Verified with sass 1.105.1: @use 'C:/.../proj with space/styles/settings.scss' -> Can't find stylesheet @use 'C:/.../proj-nospace/styles/settings.scss' -> Can't find stylesheet @use 'file:///C:/.../proj%20with%20space/...' -> ok @use 'file:///C:/.../proj-nospace/...' -> ok Refs #396 --- .../src/utils/configure-nuxt.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts b/packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts index d19a7be..4d67aa0 100644 --- a/packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts +++ b/packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts @@ -1,3 +1,5 @@ +import { isAbsolute } from 'node:path' +import { pathToFileURL } from 'node:url' import type { VuetifyNuxtContext } from './config' import type { Nuxt } from '@nuxt/schema' import { addPlugin, addTemplate, extendWebpackConfig, isNuxtMajorVersion, resolvePath } from '@nuxt/kit' @@ -7,8 +9,19 @@ import { toKebabCase } from './index' import { applyCascadeLayersHeadStyle, resolveVuetifyConfigFile } from './styles' import { addVuetifyNuxtPlugins } from './vuetify-nuxt-plugins' +/** + * Dart Sass resolves `@use` arguments as URLs, so a bare Windows path such as + * `C:/foo/bar.scss` is read as the scheme `c:` and never reaches the filesystem + * importer (`Can't find stylesheet to import`). On POSIX the resolved path + * starts with `/`, which Sass accepts, so only Windows is affected. Emitting a + * `file://` URL works on every platform and also handles spaces in the path. + */ +function toSassImportUrl (path: string): string { + return isAbsolute(path) ? pathToFileURL(path).href : path +} + export function getTemplate (source: string, settings: string | null): string { - return [settings ? `@use '${settings}';` : '', `@use '${source}';`].filter(Boolean).join('\n') + return [settings ? `@use '${toSassImportUrl(settings)}';` : '', `@use '${source}';`].filter(Boolean).join('\n') } export async function configureNuxt ( From e21b48eb327aa57b318b19750a570ee1569cfb58 Mon Sep 17 00:00:00 2001 From: haigou-web Date: Tue, 6 Oct 2026 11:06:27 +0800 Subject: [PATCH 2/3] fix: encode the apostrophe in the settings `@use` target, and cover the helper Review follow-up to 72993a1. `pathToFileURL` leaves `'` alone -- RFC 3986 lists it as a sub-delimiter, so it is legal in a URL path -- and `getTemplate` wraps the result in SINGLE quotes. A settings file under `O'Brien/` therefore produced @use 'file:///.../O'Brien/settings.scss'; which ends the string early: a stylesheet that is a syntax error rather than a stylesheet. A bare path had the same hole, so this closes one that was already open rather than one this change opened. `toSassImportUrl` is now exported and covered by a test file. It is a pure string transform, and the Windows branch it exists for cannot be reached at all on a non-Windows CI, so a test is the only thing between a refactor and a silent regression. The assertions are about "is this a `file:` URL", not about one exact encoding, because `pathToFileURL` follows the host platform. Also documented: POSIX absolute paths are URL-ised too (the same vocabulary upstream `@vuetify/unplugin-styles` uses for both its `sassPath` and its `configFile`), a relative path comes back unchanged, and a UNC path becomes a `file://host/...` URL whose non-empty host Dart Sass's filesystem importer may refuse to map -- unreproduced here, so noted rather than guessed at. Refs #396 Signed-off-by: haigou-web --- .../src/utils/configure-nuxt.ts | 24 +++++- .../test/sass-import-url.test.ts | 77 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 packages/vuetify-nuxt-module/test/sass-import-url.test.ts diff --git a/packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts b/packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts index 4d67aa0..6b23504 100644 --- a/packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts +++ b/packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts @@ -15,9 +15,29 @@ import { addVuetifyNuxtPlugins } from './vuetify-nuxt-plugins' * importer (`Can't find stylesheet to import`). On POSIX the resolved path * starts with `/`, which Sass accepts, so only Windows is affected. Emitting a * `file://` URL works on every platform and also handles spaces in the path. + * + * POSIX absolute paths get the same treatment rather than being left bare, which is what upstream + * `@vuetify/unplugin-styles` does for both its `sassPath` and its `configFile` — the two halves of + * the `@use` list are then written in one vocabulary instead of two. + * + * Absolute only. A relative path comes back unchanged because there is no directory to resolve it + * against here, and the one call site (`resolvePath`) never produces one. + * + * Known limit: a UNC path (`\\server\share\x.scss`) is absolute and becomes a `file://server/...` + * URL with a non-empty host, which Dart Sass's filesystem importer may refuse to map. Left as is + * because it could not be reproduced here — a project on a network share is the case to try first. + * + * The apostrophe is encoded by hand: `pathToFileURL` leaves it alone (RFC 3986 lists `'` as a + * sub-delimiter, so it is legal in a URL path), and the call site wraps the result in SINGLE + * quotes — a path like `O'Brien/settings.scss` would end the string early and produce a stylesheet + * that is a syntax error rather than a stylesheet. A bare path had the same hole, so this closes + * one that was already open rather than one this change opened. + * + * Exported for the unit test. It is a pure string transform, and on a non-Windows CI that test is + * the only way the Windows branch can be exercised at all. */ -function toSassImportUrl (path: string): string { - return isAbsolute(path) ? pathToFileURL(path).href : path +export function toSassImportUrl (path: string): string { + return isAbsolute(path) ? pathToFileURL(path).href.replaceAll("'", '%27') : path } export function getTemplate (source: string, settings: string | null): string { diff --git a/packages/vuetify-nuxt-module/test/sass-import-url.test.ts b/packages/vuetify-nuxt-module/test/sass-import-url.test.ts new file mode 100644 index 0000000..29e11e7 --- /dev/null +++ b/packages/vuetify-nuxt-module/test/sass-import-url.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { getTemplate, toSassImportUrl } from '../src/utils/configure-nuxt' + +/* + * `@use` arguments are URLs to Dart Sass, so a bare Windows path is read as the scheme `c:` and + * never reaches the filesystem importer — `Can't find stylesheet to import` (#396). + * + * The assertions below are deliberately about "is this a `file:` URL" rather than about one exact + * encoding: `pathToFileURL` follows the HOST platform, so pinning its byte-for-byte output would + * make the suite pass on Windows and assert a different thing everywhere else. What must not + * regress is that a path which needs to become a URL does, and that the ones which must not are + * left alone. + */ + +describe('toSassImportUrl', () => { + it('turns a Windows drive path into a file URL', () => { + // The case #396 is about: this is what used to be emitted bare. + const url = toSassImportUrl('C:\\Users\\u\\app\\settings.scss') + expect(url.startsWith('file:///')).toBe(true) + expect(url).not.toBe('C:\\Users\\u\\app\\settings.scss') + }) + + it('turns a POSIX absolute path into a file URL too', () => { + // Output changed here even though the reported bug did not reach POSIX. Same vocabulary as + // upstream `@vuetify/unplugin-styles`, which URL-ises both its `sassPath` and its `configFile`. + const url = toSassImportUrl('/home/u/app/settings.scss') + expect(url.startsWith('file:///')).toBe(true) + expect(url).not.toBe('/home/u/app/settings.scss') + }) + + it('leaves a relative path alone', () => { + // `resolvePath` guarantees an absolute path at the call site, so this is about not inventing a + // URL for something that is not one — `./settings.scss` has no directory to be absolute against. + expect(toSassImportUrl('./settings.scss')).toBe('./settings.scss') + expect(toSassImportUrl('vuetify/styles')).toBe('vuetify/styles') + }) + + it('does not wrap an input that is already a file URL', () => { + // Neither platform calls this absolute, so it cannot become `file:///file:///C:/x`. + expect(toSassImportUrl('file:///C:/x/settings.scss')).toBe('file:///C:/x/settings.scss') + }) + + it('encodes a path that would otherwise break the quoted @use', () => { + // `@use '';` is single-quoted, so a raw apostrophe in the path would end the string early. + const url = toSassImportUrl("/home/u/O'Brien/settings.scss") + expect(url).toContain('O%27Brien') + expect(url).not.toContain("'") + }) + + it('encodes a non-ASCII path rather than emitting raw characters', () => { + // Not hypothetical: this repository has been checked out under a Chinese home directory. + const url = toSassImportUrl('/home/u/胡竞初/settings.scss') + expect(url).not.toContain('胡') + expect(decodeURIComponent(url)).toContain('胡竞初') + }) + + it('encodes the characters that would truncate a URL', () => { + // A `#` would start a fragment and a `?` a query; both must survive as path characters. + const url = toSassImportUrl('/home/u/a#b?c/settings.scss') + expect(url).toContain('%23') + expect(url).toContain('%3F') + }) +}) + +describe('getTemplate', () => { + it('writes the settings as a @use whose target Sass can resolve', () => { + const template = getTemplate('vuetify/styles', '/home/u/app/settings.scss') + expect(template.startsWith("@use 'file:///")).toBe(true) + expect(template.endsWith("';\n@use 'vuetify/styles';")).toBe(true) + }) + + it('leaves the package specifier bare, because the bundler resolves it', () => { + // `source` is the literal `vuetify/styles` at every call site, never a file path — URL-ising it + // would take it away from Vite's own package resolution. + expect(getTemplate('vuetify/styles', null)).toBe("@use 'vuetify/styles';") + }) +}) From 19dacbfc0b2a095be3262b7ecbcc4555eab6cb8c Mon Sep 17 00:00:00 2001 From: haigou-web <206506176+haigou-web@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:46:19 +0800 Subject: [PATCH 3/3] fix: decide path absoluteness with pathe and encode the apostrophe on both branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node:path.isAbsolute follows the host platform, so a Windows drive path was treated as relative on POSIX — which made the new unit test fail there and left the Windows branch untested. pathe.isAbsolute is already the module's path vocabulary elsewhere. The apostrophe encoding no longer depends on the path being absolute, since this is an exported pure transform. --- .../src/utils/configure-nuxt.ts | 21 ++++++++++------ .../test/sass-import-url.test.ts | 25 +++++++++++++++---- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts b/packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts index 6b23504..ef90079 100644 --- a/packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts +++ b/packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts @@ -1,4 +1,4 @@ -import { isAbsolute } from 'node:path' +import { isAbsolute } from 'pathe' import { pathToFileURL } from 'node:url' import type { VuetifyNuxtContext } from './config' import type { Nuxt } from '@nuxt/schema' @@ -21,23 +21,28 @@ import { addVuetifyNuxtPlugins } from './vuetify-nuxt-plugins' * the `@use` list are then written in one vocabulary instead of two. * * Absolute only. A relative path comes back unchanged because there is no directory to resolve it - * against here, and the one call site (`resolvePath`) never produces one. + * against here, and the one call site (`resolvePath`) never produces one. "Absolute" is decided by + * `pathe.isAbsolute` rather than `node:path.isAbsolute`: the latter follows the host platform, so a + * Windows path would be treated as relative when the module runs on POSIX (and vice versa). The rest + * of this module already uses `pathe` for path semantics (`styles.ts`), so this keeps one definition. * * Known limit: a UNC path (`\\server\share\x.scss`) is absolute and becomes a `file://server/...` * URL with a non-empty host, which Dart Sass's filesystem importer may refuse to map. Left as is * because it could not be reproduced here — a project on a network share is the case to try first. * - * The apostrophe is encoded by hand: `pathToFileURL` leaves it alone (RFC 3986 lists `'` as a - * sub-delimiter, so it is legal in a URL path), and the call site wraps the result in SINGLE - * quotes — a path like `O'Brien/settings.scss` would end the string early and produce a stylesheet - * that is a syntax error rather than a stylesheet. A bare path had the same hole, so this closes - * one that was already open rather than one this change opened. + * The apostrophe is encoded on BOTH branches, not just the URL one: `pathToFileURL` leaves `'` alone + * (RFC 3986 lists it as a sub-delimiter, so it is legal in a URL path) while the call site wraps the + * result in SINGLE quotes — a path like `O'Brien/settings.scss` would end the string early and + * produce a stylesheet that is a syntax error rather than a stylesheet. A relative path can carry + * an apostrophe too, and this is an exported pure transform, so it must not rely on the call site + * happening to pass an absolute path. * * Exported for the unit test. It is a pure string transform, and on a non-Windows CI that test is * the only way the Windows branch can be exercised at all. */ export function toSassImportUrl (path: string): string { - return isAbsolute(path) ? pathToFileURL(path).href.replaceAll("'", '%27') : path + const url = isAbsolute(path) ? pathToFileURL(path).href : path + return url.replaceAll("'", '%27') } export function getTemplate (source: string, settings: string | null): string { diff --git a/packages/vuetify-nuxt-module/test/sass-import-url.test.ts b/packages/vuetify-nuxt-module/test/sass-import-url.test.ts index 29e11e7..efdee05 100644 --- a/packages/vuetify-nuxt-module/test/sass-import-url.test.ts +++ b/packages/vuetify-nuxt-module/test/sass-import-url.test.ts @@ -5,19 +5,28 @@ import { getTemplate, toSassImportUrl } from '../src/utils/configure-nuxt' * `@use` arguments are URLs to Dart Sass, so a bare Windows path is read as the scheme `c:` and * never reaches the filesystem importer — `Can't find stylesheet to import` (#396). * - * The assertions below are deliberately about "is this a `file:` URL" rather than about one exact - * encoding: `pathToFileURL` follows the HOST platform, so pinning its byte-for-byte output would - * make the suite pass on Windows and assert a different thing everywhere else. What must not - * regress is that a path which needs to become a URL does, and that the ones which must not are + * Absolute-vs-relative is decided by `pathe.isAbsolute`, not `node:path.isAbsolute`, so a Windows + * drive path is recognised as absolute on every platform. That is what lets this suite exercise the + * Windows branch on a POSIX CI instead of skipping it (and what makes the test pass there at all). + * + * The assertions are about "is this a `file:` URL" rather than one exact encoding: `pathToFileURL` + * follows the HOST platform, so pinning its byte-for-byte output would make the suite pass on + * Windows and assert a different thing everywhere else. What must not regress is that a path which + * needs to become a URL does, that the drive letter survives, and that the ones which must not are * left alone. */ describe('toSassImportUrl', () => { it('turns a Windows drive path into a file URL', () => { - // The case #396 is about: this is what used to be emitted bare. + // The case #396 is about: this is what used to be emitted bare. Runs on every platform now — + // `pathe.isAbsolute` does not consult the host, so the Windows branch is reachable from POSIX CI. const url = toSassImportUrl('C:\\Users\\u\\app\\settings.scss') expect(url.startsWith('file:///')).toBe(true) expect(url).not.toBe('C:\\Users\\u\\app\\settings.scss') + // Guard the two failure modes a bare `startsWith` would miss: a dropped drive letter, and + // backslashes surviving into the URL as separators. + expect(url).toContain('C:') + expect(url).not.toContain('\\') }) it('turns a POSIX absolute path into a file URL too', () => { @@ -35,6 +44,12 @@ describe('toSassImportUrl', () => { expect(toSassImportUrl('vuetify/styles')).toBe('vuetify/styles') }) + it('encodes an apostrophe in a relative path too', () => { + // The encoding must not depend on the path being absolute: this is an exported pure transform, + // and `getTemplate` wraps whatever comes back in single quotes. + expect(toSassImportUrl("./O'Brien/settings.scss")).toBe('./O%27Brien/settings.scss') + }) + it('does not wrap an input that is already a file URL', () => { // Neither platform calls this absolute, so it cannot become `file:///file:///C:/x`. expect(toSassImportUrl('file:///C:/x/settings.scss')).toBe('file:///C:/x/settings.scss')