Skip to content
Open
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
40 changes: 39 additions & 1 deletion packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 (
Expand Down
92 changes: 92 additions & 0 deletions packages/vuetify-nuxt-module/test/sass-import-url.test.ts
Original file line number Diff line number Diff line change
@@ -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 '<url>';` 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';")
})
})