From 239e3609b253009984f76f3325d32352991cf8db Mon Sep 17 00:00:00 2001 From: Charan Rathore Date: Sat, 26 Sep 2026 10:58:02 +0530 Subject: [PATCH 1/4] fix: preserve logical HTML entry paths for symlinks (fix #23585) --- .../vite/src/node/__tests__/build.spec.ts | 228 +++++++++++++++++- packages/vite/src/node/plugins/html.ts | 130 +++++++++- 2 files changed, 351 insertions(+), 7 deletions(-) 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..81cb5d30d6e8ab 100644 --- a/packages/vite/src/node/plugins/html.ts +++ b/packages/vite/src/node/plugins/html.ts @@ -13,6 +13,7 @@ import type { OutputAsset, OutputBundle, OutputChunk, + ResolvedId, RollupError, SourceMapInput, } from 'rolldown' @@ -424,6 +425,106 @@ 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) +} + +function isInsideRoot(root: string, filePath: string): boolean { + const normalizedRoot = normalizePath(root) + const prefix = normalizedRoot.endsWith('/') + ? normalizedRoot + : `${normalizedRoot}/` + return filePath.startsWith(prefix) +} + +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 || + (!isInsideRoot(root, existing) && isInsideRoot(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. + } + 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) + if (logical && isInsideRoot(root, logical)) { + const normalizedRoot = normalizePath(root) + const prefix = normalizedRoot.endsWith('/') + ? normalizedRoot + : `${normalizedRoot}/` + return logical.slice(prefix.length) + } + return normalizePath(path.relative(root, normalizedId)) +} + /** * Compiles index.html into an entry js module */ @@ -451,11 +552,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 +1029,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 +1215,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, From fe0215f8d7565f5b8af932e5537445d7f86d0063 Mon Sep 17 00:00:00 2001 From: Charan Rathore Date: Sat, 26 Sep 2026 11:38:20 +0530 Subject: [PATCH 2/4] fix: match HTML paths across Windows 8.3 aliases --- packages/vite/src/node/plugins/html.ts | 54 +++++++++++++++++++++----- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/vite/src/node/plugins/html.ts b/packages/vite/src/node/plugins/html.ts index 81cb5d30d6e8ab..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' @@ -442,12 +443,42 @@ function htmlInputFiles(input: string[] | Record): string[] { return Array.isArray(input) ? input : Object.values(input) } -function isInsideRoot(root: string, filePath: string): boolean { +// 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}/` - return filePath.startsWith(prefix) + 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( @@ -461,7 +492,7 @@ function preferHtmlLogicalPath( // inside root when the other one is not, so the emitted name cannot escape. if ( !existing || - (!isInsideRoot(root, existing) && isInsideRoot(root, logical)) + (!htmlPathInsideRoot(root, existing) && htmlPathInsideRoot(root, logical)) ) { logicalById.set(id, logical) } @@ -491,6 +522,10 @@ async function recordHtmlEntryLogicalPaths( } 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) @@ -514,13 +549,12 @@ function htmlEmitPathRelativeToRoot( normalizedId: string, logicalById: Map, ): string { - const logical = logicalById.get(normalizedId) - if (logical && isInsideRoot(root, logical)) { - const normalizedRoot = normalizePath(root) - const prefix = normalizedRoot.endsWith('/') - ? normalizedRoot - : `${normalizedRoot}/` - return logical.slice(prefix.length) + 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)) } From b4d26caedfe88282b107c47b90c69746b77d44ec Mon Sep 17 00:00:00 2001 From: Charan Rathore <141534082+charan-rathore@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:54:22 +0530 Subject: [PATCH 3/4] ci: retrigger checks From a5f2fe3b858cda179ba155cfbb6e1d663225263b Mon Sep 17 00:00:00 2001 From: Instinct Agent Date: Sun, 27 Sep 2026 11:26:14 +0530 Subject: [PATCH 4/4] ci: retrigger checks