From 6a4a977ce58a8e2cf38fbe57c556f99beeb013b0 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sun, 2 Aug 2026 04:35:01 +0530 Subject: [PATCH] refactor(node): normalize line endings at the file read boundary Adds readTextFile/readTextFileSync (CRLF/CR -> LF) next to the raw retrying readFile and switches the markdown-source readers to them (content loader, dynamic route templates, local search, includes). Raw reads stay in place where bytes must be preserved (serve, init scaffolding). Co-Authored-By: Claude Fable 5 --- __tests__/unit/node/utils/fs.test.ts | 34 +++++++++++++++++++++++++ src/node/contentLoader.ts | 4 +-- src/node/plugins/dynamicRoutesPlugin.ts | 4 +-- src/node/plugins/localSearchPlugin.ts | 4 +-- src/node/utils/fs.ts | 16 ++++++++++++ src/node/utils/processIncludes.ts | 4 +-- 6 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 __tests__/unit/node/utils/fs.test.ts diff --git a/__tests__/unit/node/utils/fs.test.ts b/__tests__/unit/node/utils/fs.test.ts new file mode 100644 index 00000000..7072a471 --- /dev/null +++ b/__tests__/unit/node/utils/fs.test.ts @@ -0,0 +1,34 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { readFile, readTextFile, readTextFileSync } from 'node/utils/fs' + +describe('node/utils/fs', () => { + let root: string + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-fs-')) + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + test('readFile keeps line endings as is', async () => { + const file = path.join(root, 'crlf.txt') + await writeFile(file, 'a\r\nb\rc\nd') + expect(await readFile(file)).toBe('a\r\nb\rc\nd') + }) + + test('readTextFile normalizes CRLF and CR to LF', async () => { + const file = path.join(root, 'crlf.txt') + await writeFile(file, 'a\r\nb\rc\nd') + expect(await readTextFile(file)).toBe('a\nb\nc\nd') + }) + + test('readTextFileSync normalizes CRLF and CR to LF', async () => { + const file = path.join(root, 'crlf.txt') + await writeFile(file, 'a\r\nb\rc\nd') + expect(readTextFileSync(file)).toBe('a\nb\nc\nd') + }) +}) diff --git a/src/node/contentLoader.ts b/src/node/contentLoader.ts index 8396b35c..2356d6e5 100644 --- a/src/node/contentLoader.ts +++ b/src/node/contentLoader.ts @@ -9,7 +9,7 @@ import { mergeMarkdownLocales } from './markdown/markdown' import type { Awaitable, MarkdownEnv } from './shared' -import { readFile } from './utils/fs' +import { readTextFile } from './utils/fs' import { glob, normalizeGlob, type GlobOptions } from './utils/glob' export interface ContentOptions { @@ -122,7 +122,7 @@ export function createContentLoader( if (cached && timestamp === cached.timestamp) return cached.data - const src = await readFile(file) + const src = await readTextFile(file) const renderExcerpt = options.excerpt const { data: frontmatter, excerpt } = matter( diff --git a/src/node/plugins/dynamicRoutesPlugin.ts b/src/node/plugins/dynamicRoutesPlugin.ts index fdb3249e..962e456e 100644 --- a/src/node/plugins/dynamicRoutesPlugin.ts +++ b/src/node/plugins/dynamicRoutesPlugin.ts @@ -12,7 +12,7 @@ import { } from 'vite' import type { Awaitable } from '../shared' import { type SiteConfig, type UserConfig } from '../siteConfig' -import { readFile } from '../utils/fs' +import { readTextFile } from '../utils/fs' import { glob, normalizeGlob, type GlobOptions } from '../utils/glob' import { ModuleGraph } from '../utils/moduleGraph' import { resolveRewrites } from './rewritesPlugin' @@ -158,7 +158,7 @@ export const dynamicRoutesPlugin = async ( moduleGraph.add(id, [routeFile]) moduleGraph.add(routeFile, [matched.loaderPath]) - let baseContent = await readFile(routeFile) + let baseContent = await readTextFile(routeFile) // inject raw content // this is intended for integration with CMS diff --git a/src/node/plugins/localSearchPlugin.ts b/src/node/plugins/localSearchPlugin.ts index 8079a601..bdeaa4b9 100644 --- a/src/node/plugins/localSearchPlugin.ts +++ b/src/node/plugins/localSearchPlugin.ts @@ -7,7 +7,7 @@ import type { SiteConfig } from '../config' import type { DefaultTheme } from '../defaultTheme' import { createMarkdownRenderer } from '../markdown/markdown' import { getLocaleForPath, slash, type MarkdownEnv } from '../shared' -import { readFile } from '../utils/fs' +import { readTextFile } from '../utils/fs' import { processIncludes } from '../utils/processIncludes' const debug = createDebug('vitepress:local-search') @@ -55,7 +55,7 @@ export async function localSearchPlugin( const { srcDir, cleanUrls = false } = siteConfig const relativePath = slash(path.relative(srcDir, file)) const env: MarkdownEnv = { path: file, relativePath, cleanUrls } - const raw = await readFile(file).catch((e) => { + const raw = await readTextFile(file).catch((e) => { if (e.code === 'ENOENT') { debug(`File not found: ${file}`) return '' diff --git a/src/node/utils/fs.ts b/src/node/utils/fs.ts index c4365ae1..0a772c57 100644 --- a/src/node/utils/fs.ts +++ b/src/node/utils/fs.ts @@ -1,7 +1,9 @@ +import fs from 'node:fs' import { readFile as fsReadFile } from 'node:fs/promises' import { setTimeout } from 'node:timers/promises' const retryCodes = new Set(['EMFILE', 'ENFILE']) +const newlineRE = /\r\n?/g /** * Reads a file as utf8, retrying with backoff when the process is @@ -18,3 +20,17 @@ export async function readFile(file: string): Promise { } } } + +/** + * Reads a text file like `readFile`, with line endings normalized to `\n`. + */ +export async function readTextFile(file: string): Promise { + return (await readFile(file)).replace(newlineRE, '\n') +} + +/** + * Synchronous `readTextFile`, for use inside synchronous markdown-it rules. + */ +export function readTextFileSync(file: string): string { + return fs.readFileSync(file, 'utf8').replace(newlineRE, '\n') +} diff --git a/src/node/utils/processIncludes.ts b/src/node/utils/processIncludes.ts index 08e02d32..2880a46b 100644 --- a/src/node/utils/processIncludes.ts +++ b/src/node/utils/processIncludes.ts @@ -3,7 +3,7 @@ import { replaceAsync, type MarkdownItAsync } from 'markdown-it-async' import path from 'node:path' import { findRegion } from '../markdown/plugins/snippet' import { slash, type MarkdownEnv } from '../shared' -import { readFile } from './fs' +import { readTextFile } from './fs' export function processIncludes( md: MarkdownItAsync, @@ -41,7 +41,7 @@ export function processIncludes( // chain are cycles, the same file may still be included by siblings if (includePath === file || ancestors.includes(includePath)) return m - let content = await readFile(includePath) + let content = await readTextFile(includePath) if (region) { const [regionName] = region