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, +}));