diff --git a/packages/vite/src/node/__tests__/build.spec.ts b/packages/vite/src/node/__tests__/build.spec.ts index c194760f856866..7efcac128b5586 100644 --- a/packages/vite/src/node/__tests__/build.spec.ts +++ b/packages/vite/src/node/__tests__/build.spec.ts @@ -1,5 +1,7 @@ +import fs from 'node:fs' import fsp from 'node:fs/promises' -import { basename, resolve } from 'node:path' +import os from 'node:os' +import path, { basename, resolve } from 'node:path' import { stripVTControlCharacters } from 'node:util' import colors from 'picocolors' import type { @@ -1562,6 +1564,230 @@ test('copies public directory after building same environment with write false f ).resolves.toBe('') }) +describe('symlinked HTML entries', () => { + const tempDirs: string[] = [] + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + function makeTempDir(): string { + // realpath so the path we pass as `root` matches `config.root` on systems + // where the temp directory itself is a symlink (/var -> /private/var). + const dir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'vite-html-symlink-')), + ) + tempDirs.push(dir) + return dir + } + + function writePage(file: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync( + file, + '\n', + ) + } + + function writeMain(root: string): void { + fs.writeFileSync(path.join(root, 'main.js'), 'console.log("hi")\n') + } + + function htmlFileNames(output: RolldownOutput): string[] { + return output.output + .filter( + (item) => item.type === 'asset' && item.fileName.endsWith('.html'), + ) + .map((item) => item.fileName) + .sort() + } + + test('emits dist/index.html and a nested entry when the HTML files are symlinks outside root', async () => { + const base = makeTempDir() + const external = path.join(base, 'external') + const root = path.join(base, 'sandbox') + writePage(path.join(external, 'index.html')) + writePage(path.join(external, 'nested', 'page.html')) + // Same basename as the nested entry, different directory: the output path + // has to keep the directory, not collapse to `page.html`. + writePage(path.join(external, 'other', 'page.html')) + fs.mkdirSync(path.join(root, 'nested'), { recursive: true }) + fs.mkdirSync(path.join(root, 'other'), { recursive: true }) + writeMain(root) + fs.symlinkSync( + path.join(external, 'index.html'), + path.join(root, 'index.html'), + ) + fs.symlinkSync( + path.join(external, 'nested', 'page.html'), + path.join(root, 'nested', 'page.html'), + ) + fs.symlinkSync( + path.join(external, 'other', 'page.html'), + path.join(root, 'other', 'page.html'), + ) + + const result = (await build({ + root, + configFile: false, + logLevel: 'silent', + base: './', + build: { + rolldownOptions: { + input: { + // Chunk names must not replace the HTML output paths. + main: path.join(root, 'index.html'), + nested: path.join(root, 'nested', 'page.html'), + other: path.join(root, 'other', 'page.html'), + }, + }, + }, + })) as RolldownOutput + + expect(htmlFileNames(result)).toEqual([ + 'index.html', + 'nested/page.html', + 'other/page.html', + ]) + const indexHtml = fs.readFileSync( + path.join(root, 'dist', 'index.html'), + 'utf-8', + ) + const nestedHtml = fs.readFileSync( + path.join(root, 'dist', 'nested', 'page.html'), + 'utf-8', + ) + const otherHtml = fs.readFileSync( + path.join(root, 'dist', 'other', 'page.html'), + 'utf-8', + ) + expect(indexHtml).toContain('./assets/') + expect(indexHtml).not.toContain('../') + expect(nestedHtml).toContain('../assets/') + expect(otherHtml).toContain('../assets/') + }) + + test('default index.html symlink emits dist/index.html', async () => { + const base = makeTempDir() + const external = path.join(base, 'external') + const root = path.join(base, 'sandbox') + writePage(path.join(external, 'index.html')) + fs.mkdirSync(root) + writeMain(root) + fs.symlinkSync( + path.join(external, 'index.html'), + path.join(root, 'index.html'), + ) + + const result = (await build({ + root, + configFile: false, + logLevel: 'silent', + })) as RolldownOutput + + expect(htmlFileNames(result)).toEqual(['index.html']) + expect(fs.existsSync(path.join(root, 'dist', 'index.html'))).toBe(true) + }) + + test('relative multi-page inputs keep nested symlink output paths', async () => { + const base = makeTempDir() + const external = path.join(base, 'external') + const root = path.join(base, 'sandbox') + writePage(path.join(external, 'index.html')) + writePage(path.join(external, 'nested', 'page.html')) + fs.mkdirSync(path.join(root, 'nested'), { recursive: true }) + writeMain(root) + fs.symlinkSync( + path.join(external, 'index.html'), + path.join(root, 'index.html'), + ) + fs.symlinkSync( + path.join(external, 'nested', 'page.html'), + path.join(root, 'nested', 'page.html'), + ) + + const result = (await build({ + root, + configFile: false, + logLevel: 'silent', + build: { + rolldownOptions: { + cwd: root, + input: ['index.html', 'nested/page.html'], + }, + }, + })) as RolldownOutput + + expect(htmlFileNames(result)).toEqual(['index.html', 'nested/page.html']) + expect(fs.existsSync(path.join(root, 'dist', 'nested', 'page.html'))).toBe( + true, + ) + }) + + test('ordinary files and a symlinked root keep their HTML output paths', async () => { + const plain = makeTempDir() + writePage(path.join(plain, 'index.html')) + writePage(path.join(plain, 'nested', 'page.html')) + writeMain(plain) + const plainResult = (await build({ + root: plain, + configFile: false, + logLevel: 'silent', + build: { + rolldownOptions: { + input: [ + path.join(plain, 'index.html'), + path.join(plain, 'nested', 'page.html'), + ], + }, + }, + })) as RolldownOutput + expect(htmlFileNames(plainResult)).toEqual([ + 'index.html', + 'nested/page.html', + ]) + + const base = makeTempDir() + const real = path.join(base, 'real') + const link = path.join(base, 'link') + writePage(path.join(real, 'index.html')) + writePage(path.join(real, 'nested', 'page.html')) + writeMain(real) + fs.symlinkSync(real, link, 'dir') + + const linkedInput = (await build({ + root: link, + configFile: false, + logLevel: 'silent', + build: { + rolldownOptions: { + input: [ + path.join(link, 'index.html'), + path.join(link, 'nested', 'page.html'), + ], + }, + }, + })) as RolldownOutput + expect(htmlFileNames(linkedInput)).toEqual([ + 'index.html', + 'nested/page.html', + ]) + expect(fs.existsSync(path.join(real, 'dist', 'index.html'))).toBe(true) + expect(fs.existsSync(path.join(real, 'dist', 'nested', 'page.html'))).toBe( + true, + ) + + const linkedDefault = (await build({ + root: link, + configFile: false, + logLevel: 'silent', + })) as RolldownOutput + expect(htmlFileNames(linkedDefault)).toEqual(['index.html']) + }) +}) + async function buildProjectWithRenderBuiltUrl( renderBuiltUrl: (filename: string) => string, ) { diff --git a/packages/vite/src/node/plugins/html.ts b/packages/vite/src/node/plugins/html.ts index c09d5067086c37..c7d72589bc588c 100644 --- a/packages/vite/src/node/plugins/html.ts +++ b/packages/vite/src/node/plugins/html.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs' import path from 'node:path' import { URL } from 'node:url' import escapeHtml from 'escape-html' @@ -13,6 +14,7 @@ import type { OutputAsset, OutputBundle, OutputChunk, + ResolvedId, RollupError, SourceMapInput, } from 'rolldown' @@ -424,6 +426,139 @@ export function getCssFilesForChunk( return filteredFiles } +/** + * Resolved HTML id -> absolute configured input path. + * + * `buildStart` still has the input path under the project root. The resolver + * then realpaths it, so a symlink entry whose target is outside `root` shows + * up here as that outside path. The emitted file name has to follow the + * configured path (`index.html`, `nested/page.html`) instead. + * https://github.com/vitejs/vite/issues/23585 + */ +const htmlEntryLogicalPaths = perEnvironmentState( + () => new Map(), +) + +function htmlInputFiles(input: string[] | Record): string[] { + return Array.isArray(input) ? input : Object.values(input) +} + +// The Rust resolver and Node can spell the same Windows file differently +// (notably a short 8.3 path versus its long path). Compare file identity as a +// fallback when their normalized strings do not match. +function htmlFileIdentity(file: string): string | undefined { + try { + const { dev, ino } = fs.statSync(file) + if (ino) return `\0html-file:${dev}:${ino}` + } catch { + // A missing input is reported by the build itself. + } +} + +function htmlPathInsideRoot( + root: string, + filePath: string, +): string | undefined { + const normalizedRoot = normalizePath(root) + const prefix = normalizedRoot.endsWith('/') + ? normalizedRoot + : `${normalizedRoot}/` + if (filePath.startsWith(prefix)) return filePath.slice(prefix.length) + + // On Windows the configured input may use a long path while config.root + // comes back from realpath with 8.3 directory names. Walk directories by + // identity so a spelling difference cannot make an in-root entry escape. + const rootIdentity = htmlFileIdentity(root) + if (!rootIdentity) return + const parts: string[] = [path.basename(filePath)] + let parent = path.dirname(filePath) + while (parent !== path.dirname(parent)) { + if (htmlFileIdentity(parent) === rootIdentity) { + return normalizePath(parts.reverse().join('/')) + } + parts.push(path.basename(parent)) + parent = path.dirname(parent) + } +} + +function preferHtmlLogicalPath( + logicalById: Map, + id: string, + logical: string, + root: string, +): void { + const existing = logicalById.get(id) + // Two configured inputs can realpath to one module. Keep a path that is + // inside root when the other one is not, so the emitted name cannot escape. + if ( + !existing || + (!htmlPathInsideRoot(root, existing) && htmlPathInsideRoot(root, logical)) + ) { + logicalById.set(id, logical) + } +} + +async function recordHtmlEntryLogicalPaths( + root: string, + cwd: string, + input: string[] | Record | undefined, + logicalById: Map, + resolveId: (id: string) => Promise, +): Promise { + logicalById.clear() + if (input == null) return + for (const file of htmlInputFiles(input)) { + if (!htmlLangRE.test(file)) continue + // Relative inputs are resolved from rolldown's cwd, matching the path the + // resolver starts from. Absolute inputs already include their directory. + const logical = normalizePath(path.resolve(cwd, file)) + // Key the map with the same id `transform` will see. That id comes from + // the Rust resolver, which realpaths symlinks. Node's `realpath` is not a + // substitute: rules_js patches it to stay inside a Bazel sandbox, and the + // Rust resolver does not. `this.resolve` goes through that resolver. + let resolved: ResolvedId | null = null + try { + resolved = await resolveId(logical) + } catch { + // A missing input is reported when the build resolves the entry. + } + const identity = htmlFileIdentity(logical) + if (identity) { + preferHtmlLogicalPath(logicalById, identity, logical, root) + } + if (resolved && !resolved.external) { + const resolvedId = normalizePath(resolved.id) + preferHtmlLogicalPath(logicalById, resolvedId, logical, root) + if (resolvedId !== logical) { + preferHtmlLogicalPath(logicalById, logical, logical, root) + } + } else { + preferHtmlLogicalPath(logicalById, logical, logical, root) + } + } +} + +/** + * HTML asset path relative to `root`. Use the configured input when that path + * is inside `root`. Otherwise keep `path.relative(root, resolvedId)`, which is + * what a symlinked root already relies on (`config.root` is the realpath, and + * the resolved id is the real file inside it). + */ +function htmlEmitPathRelativeToRoot( + root: string, + normalizedId: string, + logicalById: Map, +): string { + const logical = + logicalById.get(normalizedId) || + logicalById.get(htmlFileIdentity(normalizedId) ?? '') + if (logical) { + const relative = htmlPathInsideRoot(root, logical) + if (relative) return relative + } + return normalizePath(path.relative(root, normalizedId)) +} + /** * Compiles index.html into an entry js module */ @@ -451,11 +586,25 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { return environment.config.isBundled }, + async buildStart(options) { + await recordHtmlEntryLogicalPaths( + config.root, + options.cwd, + options.input, + htmlEntryLogicalPaths(this), + (id) => this.resolve(id), + ) + }, + transform: { filter: { id: /\.html$/ }, async handler(html, id) { id = normalizePath(id) - const relativeUrlPath = normalizePath(path.relative(config.root, id)) + const relativeUrlPath = htmlEmitPathRelativeToRoot( + config.root, + id, + htmlEntryLogicalPaths(this), + ) const publicPath = `/${relativeUrlPath}` const publicBase = getBaseInHTML(relativeUrlPath, config) @@ -914,8 +1063,10 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { ) for (const [normalizedId, html] of processedHtml(this)) { - const relativeUrlPath = normalizePath( - path.relative(config.root, normalizedId), + const relativeUrlPath = htmlEmitPathRelativeToRoot( + config.root, + normalizedId, + htmlEntryLogicalPaths(this), ) const assetsBase = getBaseInHTML(relativeUrlPath, config) const toOutputFilePath = ( @@ -1098,9 +1249,10 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { inlineEntryChunk.add(chunk.fileName) } - const shortEmitName = normalizePath( - path.relative(config.root, normalizedId), - ) + // Same path as `relativeUrlPath`: the asset name and the relative URL + // base both have to be the logical entry, or `base: './'` points at + // the realpath outside root. + const shortEmitName = relativeUrlPath this.emitFile({ type: 'asset', originalFileName: normalizedId,