Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/normalize-filename-case.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes styles being stripped when the project root is started with a path whose case differs from the actual filesystem case (e.g. running `astro dev` from `d:\dev\app` while the folder on disk is `D:\dev\app`).
15 changes: 14 additions & 1 deletion packages/astro/src/vite-plugin-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,26 @@ export function normalizeFilename(filename: string, root: URL) {
// is imported via a TypeScript path alias and Vite produces a relative virtual module ID.
const url = new URL(filename, root);
filename = viteID(url);
} else if (filename.startsWith('/') && !commonAncestorPath(filename, fileURLToPath(root))) {
} else if (filename.startsWith('/') && !isPathInRoot(filename, fileURLToPath(root))) {
const url = new URL('.' + filename, root);
filename = viteID(url);
}
return removeLeadingForwardSlashWindows(filename);
}

/**
* Check whether `filename` lives under `rootPath`. Falls back to a case-insensitive
* comparison so that paths whose case differs from `rootPath` (e.g. a `d:\dev\foo`
* cwd versus a `D:\dev\foo` filesystem on Windows, or any case-insensitive macOS
* volume) are still recognized as project-internal absolute paths.
*/
function isPathInRoot(filename: string, rootPath: string) {
if (commonAncestorPath(filename, rootPath)) {
return true;
}
return commonAncestorPath(filename.toLowerCase(), rootPath.toLowerCase()) !== '';
}

const postfixRE = /[?#].*$/s;
export function cleanUrl(url: string): string {
return url.replace(postfixRE, '');
Expand Down
82 changes: 82 additions & 0 deletions packages/astro/test/css-path-case.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { after, before, describe, it } from 'node:test';
import * as cheerio from 'cheerio';
import { type DevServer, type Fixture, loadFixture } from './test-utils.ts';

/**
* Regression test for https://github.com/withastro/astro/issues/14013
*
* On case-insensitive filesystems (macOS, Windows) the dev server can be started
* from a project root whose case differs from the actual on-disk case (e.g.
* `d:\dev\app` vs `D:\dev\app`). `normalizeFilename` compares the configured
* `root` against Vite-resolved module ids via `commonAncestorPath`, which is
* case-sensitive. When the two disagree on case at the first path segment (a
* Windows drive letter, or the leading directory on macOS) `commonAncestorPath`
* returns `''`, so the absolute id is no longer recognized as project-internal
* and gets rewritten to a bogus path. That misses the compile-metadata cache and
* strips the component's scoped `<style>` from the page.
*
* To reproduce the discrepancy we flip the case of the first alphabetic
* character of the root path. On a case-insensitive filesystem the flipped path
* still resolves to the real fixture, while Vite resolves module ids with the
* canonical case — exactly the mismatch from the issue. On a case-sensitive
* filesystem (most Linux CI) the flipped path does not exist, so the suite is
* skipped.
*/
const realRoot = fileURLToPath(new URL('./fixtures/css-path-case/', import.meta.url));

/** Flip the case of the first ASCII letter — the macOS leading dir or Windows drive letter. */
function flipFirstLetterCase(p: string): string {
const i = p.search(/[a-zA-Z]/);
if (i === -1) return p;
const ch = p[i];
const flipped = ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
return p.slice(0, i) + flipped + p.slice(i + 1);
}

const caseMismatchedRoot = flipFirstLetterCase(realRoot);

// Detect a case-insensitive filesystem directly rather than checking the OS:
// the flipped-case path resolves to the real fixture only when the filesystem
// ignores case (macOS, Windows). This is the precondition the test needs and is
// more accurate than an OS check (macOS is case-insensitive too, and case
// sensitivity can vary per-volume/per-directory on both macOS and Windows).
const isCaseInsensitiveFs = caseMismatchedRoot !== realRoot && fs.existsSync(caseMismatchedRoot);

describe('CSS scoped styles with a case-mismatched project root', {
skip: !isCaseInsensitiveFs,
}, () => {
let fixture: Fixture;
let devServer: DevServer;
let $: cheerio.CheerioAPI;

before(async () => {
fixture = await loadFixture({ root: caseMismatchedRoot });
devServer = await fixture.startDevServer();
const html = await fixture.fetch('/').then((res) => res.text());
$ = cheerio.load(html);
});

after(async () => {
await devServer?.stop();
});

it('applies the scope to the element', () => {
const h1 = $('h1');
const scopedAttribute = Object.keys(h1[0]?.attribs ?? {}).find((key) =>
/^data-astro-cid-/.test(key),
);
assert.ok(scopedAttribute, 'expected the <h1> to carry a data-astro-cid-* scope attribute');
});

it('injects the scoped style into the page (issue #14013)', () => {
const injectedStyles = $('style').text().replace(/\s/g, '');
assert.equal(
injectedStyles.includes('color:rgb(255,165,0)'),
true,
'expected the scoped <style> to be injected even though the root case differs from disk',
);
});
});
8 changes: 8 additions & 0 deletions packages/astro/test/fixtures/css-path-case/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/css-path-case",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
17 changes: 17 additions & 0 deletions packages/astro/test/fixtures/css-path-case/src/pages/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
---

<html>
<head>
<title>Case Test</title>
</head>
<body>
<h1>Hello world</h1>
</body>
</html>

<style>
h1 {
color: rgb(255, 165, 0);
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as assert from 'node:assert/strict';
import * as path from 'node:path';
import { describe, it } from 'node:test';
import { pathToFileURL } from 'node:url';
import { normalizeFilename } from '../../../dist/vite-plugin-utils/index.js';

// Build a fixture path that is absolute on both POSIX and Windows. On POSIX,
// `path.resolve('/Users/me/project')` is `/Users/me/project`; on Windows it
// becomes something like `D:\\Users\\me\\project` (the CWD drive gets
// prepended). Using this lets tests that pass the resolved path to Node's URL
// machinery behave identically on both platforms.
const projectRoot = path.resolve('/Users/me/project');
const projectRootUrl = pathToFileURL(projectRoot + path.sep);
// `normalizeFilename` returns paths with forward slashes (it runs the result
// through `viteID`/`slash`), so build expectations the same way.
const projectRootSlash = projectRoot.replaceAll(path.sep, '/');

describe('normalizeFilename', () => {
it('strips the /@fs prefix from filesystem paths', () => {
const root = pathToFileURL('/Users/me/project/');
const result = normalizeFilename('/@fs/Users/me/project/src/pages/index.astro', root);
assert.equal(result, '/Users/me/project/src/pages/index.astro');
});

it('resolves relative paths against root', () => {
const result = normalizeFilename('./src/components/Foo.astro', projectRootUrl);
assert.equal(result, `${projectRootSlash}/src/components/Foo.astro`);
});

it('preserves absolute paths that live inside root', () => {
const root = pathToFileURL('/Users/me/project/');
const result = normalizeFilename('/Users/me/project/src/pages/index.astro', root);
assert.equal(result, '/Users/me/project/src/pages/index.astro');
});

it('preserves absolute paths when their case differs from root (issue #14013)', () => {
// Reproduces the case-insensitive filesystem scenario (Windows or macOS) where
// the user starts the dev server from a path whose case differs from disk.
// `root` comes from process.cwd() with one case, but Vite resolves modules with
// the canonical filesystem case. The two must still be treated as the same path.
const root = pathToFileURL('/users/me/project/');
const result = normalizeFilename('/Users/me/project/src/pages/index.astro', root);
assert.equal(result, '/Users/me/project/src/pages/index.astro');
});
});
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading