Skip to content

Commit 17390a6

Browse files
henrybrewer00-dotcomhenrybrewer93-designematipico
authored
fix(astro): match case-mismatched project paths in normalizeFilename (#16703)
Co-authored-by: Henry <henrybrewer93@icloud.com> Co-authored-by: ematipico <estoppa@cloudflare.com>
1 parent 2c0bc94 commit 17390a6

7 files changed

Lines changed: 177 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
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`).

packages/astro/src/vite-plugin-utils/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,26 @@ export function normalizeFilename(filename: string, root: URL) {
4646
// is imported via a TypeScript path alias and Vite produces a relative virtual module ID.
4747
const url = new URL(filename, root);
4848
filename = viteID(url);
49-
} else if (filename.startsWith('/') && !commonAncestorPath(filename, fileURLToPath(root))) {
49+
} else if (filename.startsWith('/') && !isPathInRoot(filename, fileURLToPath(root))) {
5050
const url = new URL('.' + filename, root);
5151
filename = viteID(url);
5252
}
5353
return removeLeadingForwardSlashWindows(filename);
5454
}
5555

56+
/**
57+
* Check whether `filename` lives under `rootPath`. Falls back to a case-insensitive
58+
* comparison so that paths whose case differs from `rootPath` (e.g. a `d:\dev\foo`
59+
* cwd versus a `D:\dev\foo` filesystem on Windows, or any case-insensitive macOS
60+
* volume) are still recognized as project-internal absolute paths.
61+
*/
62+
function isPathInRoot(filename: string, rootPath: string) {
63+
if (commonAncestorPath(filename, rootPath)) {
64+
return true;
65+
}
66+
return commonAncestorPath(filename.toLowerCase(), rootPath.toLowerCase()) !== '';
67+
}
68+
5669
const postfixRE = /[?#].*$/s;
5770
export function cleanUrl(url: string): string {
5871
return url.replace(postfixRE, '');
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import assert from 'node:assert/strict';
2+
import * as fs from 'node:fs';
3+
import { fileURLToPath } from 'node:url';
4+
import { after, before, describe, it } from 'node:test';
5+
import * as cheerio from 'cheerio';
6+
import { type DevServer, type Fixture, loadFixture } from './test-utils.ts';
7+
8+
/**
9+
* Regression test for https://github.com/withastro/astro/issues/14013
10+
*
11+
* On case-insensitive filesystems (macOS, Windows) the dev server can be started
12+
* from a project root whose case differs from the actual on-disk case (e.g.
13+
* `d:\dev\app` vs `D:\dev\app`). `normalizeFilename` compares the configured
14+
* `root` against Vite-resolved module ids via `commonAncestorPath`, which is
15+
* case-sensitive. When the two disagree on case at the first path segment (a
16+
* Windows drive letter, or the leading directory on macOS) `commonAncestorPath`
17+
* returns `''`, so the absolute id is no longer recognized as project-internal
18+
* and gets rewritten to a bogus path. That misses the compile-metadata cache and
19+
* strips the component's scoped `<style>` from the page.
20+
*
21+
* To reproduce the discrepancy we flip the case of the first alphabetic
22+
* character of the root path. On a case-insensitive filesystem the flipped path
23+
* still resolves to the real fixture, while Vite resolves module ids with the
24+
* canonical case — exactly the mismatch from the issue. On a case-sensitive
25+
* filesystem (most Linux CI) the flipped path does not exist, so the suite is
26+
* skipped.
27+
*/
28+
const realRoot = fileURLToPath(new URL('./fixtures/css-path-case/', import.meta.url));
29+
30+
/** Flip the case of the first ASCII letter — the macOS leading dir or Windows drive letter. */
31+
function flipFirstLetterCase(p: string): string {
32+
const i = p.search(/[a-zA-Z]/);
33+
if (i === -1) return p;
34+
const ch = p[i];
35+
const flipped = ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
36+
return p.slice(0, i) + flipped + p.slice(i + 1);
37+
}
38+
39+
const caseMismatchedRoot = flipFirstLetterCase(realRoot);
40+
41+
// Detect a case-insensitive filesystem directly rather than checking the OS:
42+
// the flipped-case path resolves to the real fixture only when the filesystem
43+
// ignores case (macOS, Windows). This is the precondition the test needs and is
44+
// more accurate than an OS check (macOS is case-insensitive too, and case
45+
// sensitivity can vary per-volume/per-directory on both macOS and Windows).
46+
const isCaseInsensitiveFs = caseMismatchedRoot !== realRoot && fs.existsSync(caseMismatchedRoot);
47+
48+
describe('CSS scoped styles with a case-mismatched project root', {
49+
skip: !isCaseInsensitiveFs,
50+
}, () => {
51+
let fixture: Fixture;
52+
let devServer: DevServer;
53+
let $: cheerio.CheerioAPI;
54+
55+
before(async () => {
56+
fixture = await loadFixture({ root: caseMismatchedRoot });
57+
devServer = await fixture.startDevServer();
58+
const html = await fixture.fetch('/').then((res) => res.text());
59+
$ = cheerio.load(html);
60+
});
61+
62+
after(async () => {
63+
await devServer?.stop();
64+
});
65+
66+
it('applies the scope to the element', () => {
67+
const h1 = $('h1');
68+
const scopedAttribute = Object.keys(h1[0]?.attribs ?? {}).find((key) =>
69+
/^data-astro-cid-/.test(key),
70+
);
71+
assert.ok(scopedAttribute, 'expected the <h1> to carry a data-astro-cid-* scope attribute');
72+
});
73+
74+
it('injects the scoped style into the page (issue #14013)', () => {
75+
const injectedStyles = $('style').text().replace(/\s/g, '');
76+
assert.equal(
77+
injectedStyles.includes('color:rgb(255,165,0)'),
78+
true,
79+
'expected the scoped <style> to be injected even though the root case differs from disk',
80+
);
81+
});
82+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "@test/css-path-case",
3+
"version": "0.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"astro": "workspace:*"
7+
}
8+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
---
3+
4+
<html>
5+
<head>
6+
<title>Case Test</title>
7+
</head>
8+
<body>
9+
<h1>Hello world</h1>
10+
</body>
11+
</html>
12+
13+
<style>
14+
h1 {
15+
color: rgb(255, 165, 0);
16+
}
17+
</style>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as assert from 'node:assert/strict';
2+
import * as path from 'node:path';
3+
import { describe, it } from 'node:test';
4+
import { pathToFileURL } from 'node:url';
5+
import { normalizeFilename } from '../../../dist/vite-plugin-utils/index.js';
6+
7+
// Build a fixture path that is absolute on both POSIX and Windows. On POSIX,
8+
// `path.resolve('/Users/me/project')` is `/Users/me/project`; on Windows it
9+
// becomes something like `D:\\Users\\me\\project` (the CWD drive gets
10+
// prepended). Using this lets tests that pass the resolved path to Node's URL
11+
// machinery behave identically on both platforms.
12+
const projectRoot = path.resolve('/Users/me/project');
13+
const projectRootUrl = pathToFileURL(projectRoot + path.sep);
14+
// `normalizeFilename` returns paths with forward slashes (it runs the result
15+
// through `viteID`/`slash`), so build expectations the same way.
16+
const projectRootSlash = projectRoot.replaceAll(path.sep, '/');
17+
18+
describe('normalizeFilename', () => {
19+
it('strips the /@fs prefix from filesystem paths', () => {
20+
const root = pathToFileURL('/Users/me/project/');
21+
const result = normalizeFilename('/@fs/Users/me/project/src/pages/index.astro', root);
22+
assert.equal(result, '/Users/me/project/src/pages/index.astro');
23+
});
24+
25+
it('resolves relative paths against root', () => {
26+
const result = normalizeFilename('./src/components/Foo.astro', projectRootUrl);
27+
assert.equal(result, `${projectRootSlash}/src/components/Foo.astro`);
28+
});
29+
30+
it('preserves absolute paths that live inside root', () => {
31+
const root = pathToFileURL('/Users/me/project/');
32+
const result = normalizeFilename('/Users/me/project/src/pages/index.astro', root);
33+
assert.equal(result, '/Users/me/project/src/pages/index.astro');
34+
});
35+
36+
it('preserves absolute paths when their case differs from root (issue #14013)', () => {
37+
// Reproduces the case-insensitive filesystem scenario (Windows or macOS) where
38+
// the user starts the dev server from a path whose case differs from disk.
39+
// `root` comes from process.cwd() with one case, but Vite resolves modules with
40+
// the canonical filesystem case. The two must still be treated as the same path.
41+
const root = pathToFileURL('/users/me/project/');
42+
const result = normalizeFilename('/Users/me/project/src/pages/index.astro', root);
43+
assert.equal(result, '/Users/me/project/src/pages/index.astro');
44+
});
45+
});

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)