diff --git a/packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts b/packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts index d19a7be..ef90079 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 'pathe' +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,44 @@ 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. + * + * 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. "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 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 { + const url = isAbsolute(path) ? pathToFileURL(path).href : path + return url.replaceAll("'", '%27') +} + 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 ( 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..efdee05 --- /dev/null +++ b/packages/vuetify-nuxt-module/test/sass-import-url.test.ts @@ -0,0 +1,92 @@ +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). + * + * 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. 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', () => { + // 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('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') + }) + + 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';") + }) +})