diff --git a/src/core/builders/vite/plugins/__tests__/devHtmlPrerender.test.ts b/src/core/builders/vite/plugins/__tests__/devHtmlPrerender.test.ts
new file mode 100644
index 00000000..aae19975
--- /dev/null
+++ b/src/core/builders/vite/plugins/__tests__/devHtmlPrerender.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest';
+import { Window } from 'happy-dom';
+import { pointToDevServer } from '../devHtmlPrerender';
+import {
+ fakeDevServer,
+ fakeInternalConfig,
+} from '~/core/utils/testing/fake-objects';
+import { normalizePath } from '~/core/utils/paths';
+import { resolve } from 'node:path';
+
+describe('Dev HTML Prerender Plugin', () => {
+ describe('pointToDevServer', () => {
+ it.each([
+ // File paths should be resolved
+ ['style.css', 'http://localhost:5173/entrypoints/popup/style.css'],
+ ['./style.css', 'http://localhost:5173/entrypoints/popup/style.css'],
+ ['../style.css', 'http://localhost:5173/entrypoints/style.css'],
+ ['~/assets/style.css', 'http://localhost:5173/assets/style.css'],
+ ['~~/assets/style.css', 'http://localhost:5173/assets/style.css'],
+ ['~local/style.css', 'http://localhost:5173/style.css'],
+ ['~absolute/style.css', 'http://localhost:5173/assets/style.css'],
+ ['~file', 'http://localhost:5173/example.css'],
+ // Absolute paths are loaded with the `/@fs/` base path
+ [
+ '~outside/test.css',
+ `http://localhost:5173/@fs${
+ process.platform === 'win32'
+ ? '/' + normalizePath(resolve('/some/non-root/test.css')) // "/D:/some/non-root/test.css"
+ : '/some/non-root/test.css'
+ }`,
+ ],
+ // URLs should not be changed
+ ['https://example.com/style.css', 'https://example.com/style.css'],
+ ])('should transform "%s" into "%s"', (input, expected) => {
+ const { document } = new Window({
+ url: 'http://localhost',
+ });
+ const root = '/some/root';
+ const config = fakeInternalConfig({
+ root,
+ alias: {
+ '~local': '.',
+ '~absolute': `${root}/assets`,
+ '~file': `${root}/example.css`,
+ '~outside': `${root}/../non-root`,
+ '~~': root,
+ '~': root,
+ },
+ });
+ const server = fakeDevServer({
+ hostname: 'localhost',
+ port: 5173,
+ origin: 'http://localhost:5173',
+ });
+ const id = root + '/entrypoints/popup/index.html';
+
+ document.head.innerHTML = ``;
+ pointToDevServer(config, server, id, document as any, 'link', 'href');
+
+ const actual = document.querySelector('link')!;
+ expect(actual.getAttribute('href')).toBe(expected);
+ });
+ });
+});
diff --git a/src/core/builders/vite/plugins/devHtmlPrerender.ts b/src/core/builders/vite/plugins/devHtmlPrerender.ts
index d32e7298..f56432d0 100644
--- a/src/core/builders/vite/plugins/devHtmlPrerender.ts
+++ b/src/core/builders/vite/plugins/devHtmlPrerender.ts
@@ -1,8 +1,9 @@
import type * as vite from 'vite';
-import { InternalConfig } from '~/types';
+import { InternalConfig, WxtDevServer } from '~/types';
import { getEntrypointName } from '~/core/utils/entrypoints';
import { parseHTML } from 'linkedom';
-import { dirname, isAbsolute, relative, resolve } from 'node:path';
+import { dirname, relative, resolve } from 'node:path';
+import { normalizePath } from '~/core/utils/paths';
// Cache the preamble script for all devHtmlPrerender plugins, not just one
let reactRefreshPreamble = '';
@@ -47,25 +48,10 @@ export function devHtmlPrerender(
const { document } = parseHTML(code);
- const pointToDevServer = (
- querySelector: string,
- attr: string,
- ): void => {
- document.querySelectorAll(querySelector).forEach((element) => {
- const src = element.getAttribute(attr);
- if (!src) return;
-
- if (isAbsolute(src)) {
- element.setAttribute(attr, server.origin + src);
- } else if (src.startsWith('.')) {
- const abs = resolve(dirname(id), src);
- const pathname = relative(config.root, abs);
- element.setAttribute(attr, `${server.origin}/${pathname}`);
- }
- });
- };
- pointToDevServer('script[type=module]', 'src');
- pointToDevServer('link[rel=stylesheet]', 'href');
+ const _pointToDevServer = (querySelector: string, attr: string) =>
+ pointToDevServer(config, server, id, document, querySelector, attr);
+ _pointToDevServer('script[type=module]', 'src');
+ _pointToDevServer('link[rel=stylesheet]', 'href');
// Add a script to add page reloading
const reloader = document.createElement('script');
@@ -146,3 +132,63 @@ export function devHtmlPrerender(
},
];
}
+
+export function pointToDevServer(
+ config: Omit,
+ server: WxtDevServer,
+ id: string,
+ document: Document,
+ querySelector: string,
+ attr: string,
+) {
+ document.querySelectorAll(querySelector).forEach((element) => {
+ const src = element.getAttribute(attr);
+ if (!src || isUrl(src)) return;
+
+ let resolvedAbsolutePath: string | undefined;
+
+ // Check if src uses a project alias
+ const matchingAlias = Object.entries(config.alias).find(([key]) =>
+ src.startsWith(key),
+ );
+ if (matchingAlias) {
+ // Matches a import alias
+ const [alias, replacement] = matchingAlias;
+ resolvedAbsolutePath = resolve(
+ config.root,
+ src.replace(alias, replacement),
+ );
+ } else {
+ // Some file path relative to the HTML file
+ resolvedAbsolutePath = resolve(dirname(id), src);
+ }
+
+ // Apply the final file path
+ if (resolvedAbsolutePath) {
+ const relativePath = normalizePath(
+ relative(config.root, resolvedAbsolutePath),
+ );
+
+ if (relativePath.startsWith('.')) {
+ // Outside the config.root directory, serve the absolute path
+ let path = normalizePath(resolvedAbsolutePath);
+ // Add "/" to start of windows paths ("D:/some/path" -> "/D:/some/path")
+ if (!path.startsWith('/')) path = '/' + path;
+ element.setAttribute(attr, `${server.origin}/@fs${path}`);
+ } else {
+ // Inside the project, use relative path
+ const url = new URL(relativePath, server.origin);
+ element.setAttribute(attr, url.href);
+ }
+ }
+ });
+}
+
+function isUrl(str: string): boolean {
+ try {
+ new URL(str);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/src/core/utils/testing/fake-objects.ts b/src/core/utils/testing/fake-objects.ts
index 626ba557..b30f2217 100644
--- a/src/core/utils/testing/fake-objects.ts
+++ b/src/core/utils/testing/fake-objects.ts
@@ -22,6 +22,7 @@ import {
UserManifest,
} from '~/types';
import { mock } from 'vitest-mock-extended';
+import { vi } from 'vitest';
faker.seed(__TEST_SEED__);
@@ -269,3 +270,20 @@ export const fakeManifestCommand = fakeObjectCreator(() => ({
max: 9,
})}`,
}));
+
+export const fakeDevServer = fakeObjectCreator(() => ({
+ hostname: 'localhost',
+ origin: 'http://localhost',
+ port: 5173,
+ reloadContentScript: vi.fn(),
+ reloadExtension: vi.fn(),
+ reloadPage: vi.fn(),
+ restart: vi.fn(),
+ restartBrowser: vi.fn(),
+ stop: vi.fn(),
+ start: vi.fn(),
+ watcher: mock(),
+ transformHtml: vi.fn(),
+ ws: mock(),
+ currentOutput: undefined,
+}));