From 3b099726d103652136e99cee2692c41f92c9653d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 11 Sep 2026 23:37:56 +0200 Subject: [PATCH] fix(edit-docs): write exported files with LF line endings The exported trees carry the line endings of whatever the note content happened to hold, so a Windows run of edit-docs wrote CRLF or mixed endings into docs/, the help HTML and the demo. Git normalizes the difference away on staging, which leaves a working tree full of files that read as changed until they are added. Route every entry extractZip writes through normalizeLineEndings, which collapses CRLF pairs and leaves a lone CR alone, the same rule Git applies. Binary entries are returned untouched and detected the way Git detects them: a NUL byte within the first 8000 bytes. The demo/** rules in .gitattributes match only a root-level demo/, never apps/edit-docs/demo/, so that tree had no eol attribute at all and is now covered by the export instead. Co-Authored-By: Claude Opus 5 (1M context) --- apps/edit-docs/src/utils.spec.ts | 37 +++++++++++++++++++++++++-- apps/edit-docs/src/utils.ts | 44 +++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/apps/edit-docs/src/utils.spec.ts b/apps/edit-docs/src/utils.spec.ts index 631747cc91..4e9948c375 100644 --- a/apps/edit-docs/src/utils.spec.ts +++ b/apps/edit-docs/src/utils.spec.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import { rewriteHelpLinks } from "./utils.js"; +import { normalizeLineEndings, rewriteHelpLinks } from "./utils.js"; // utils.ts bootstraps Electron at module load (registers the `trilium-app://` scheme and // pulls in the desktop window service, whose resource_dir lookup calls process.exit in a // non-Electron test runner). Stub those side-effectful imports so the pure helpers below -// (rewriteHelpLinks) can be imported in isolation. +// (rewriteHelpLinks, normalizeLineEndings) can be imported in isolation. vi.mock("electron", () => ({ default: { app: {}, protocol: { registerSchemesAsPrivileged: () => {} } } })); vi.mock("@triliumnext/desktop/src/protocol.js", () => ({ registerTriliumAppScheme: () => {}, setupTriliumAppProtocol: () => {} })); vi.mock("@triliumnext/desktop/src/services/window.js", () => ({ default: {}, setupWindowing: () => {} })); @@ -66,3 +66,36 @@ describe("rewriteHelpLinks", () => { expect(rewriteHelpLinks(input)).toBe(input); }); }); + +describe("normalizeLineEndings", () => { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + it("rewrites CRLF to LF and keeps a lone CR, LF and trailing CR", () => { + const input = encoder.encode("# Title\r\n\r\nA line.\nAnother\rline.\r"); + expect(decoder.decode(normalizeLineEndings(input))).toBe("# Title\n\nA line.\nAnother\rline.\r"); + }); + + it("keeps multi-byte characters intact", () => { + const input = encoder.encode("Ștergere\r\nnotițe 😀\r\n"); + expect(decoder.decode(normalizeLineEndings(input))).toBe("Ștergere\nnotițe 😀\n"); + }); + + it("returns the same content when there is no CRLF to rewrite", () => { + const input = encoder.encode("Already normalized.\n"); + expect(normalizeLineEndings(input)).toBe(input); + }); + + it("leaves binary content untouched", () => { + // A PNG header, whose byte 4 is the NUL that marks the content as binary, followed by + // pixel data that happens to contain a CRLF pair. + const input = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x0d, 0x0a, 0xff]); + expect(normalizeLineEndings(input)).toBe(input); + }); + + it("normalizes content whose NUL sits past the 8000-byte detection window", () => { + const prefix = "a".repeat(8000); + const input = encoder.encode(`${prefix}\r\n\0`); + expect(decoder.decode(normalizeLineEndings(input))).toBe(`${prefix}\n\0`); + }); +}); diff --git a/apps/edit-docs/src/utils.ts b/apps/edit-docs/src/utils.ts index 9d5021bd51..5bb50c5fe6 100644 --- a/apps/edit-docs/src/utils.ts +++ b/apps/edit-docs/src/utils.ts @@ -220,10 +220,52 @@ export async function extractZip(zipFilePath: string, outputPath: string, ignore const fileContent = await readContent(); await fs.mkdir(path.dirname(destPath), { recursive: true }); - await fs.writeFile(destPath, fileContent); + await fs.writeFile(destPath, normalizeLineEndings(fileContent)); } }); promise.resolve(); }, 1000); await promise; } + +/** + * Rewrites CRLF line endings to LF, so that `extractZip` writes the same bytes on Windows as it + * does elsewhere. The exported trees (`docs/`, the help HTML, the demo) are stored with LF, and + * Git only hides the difference once a file is staged. + * + * Binary entries such as images keep their bytes. They are detected the way Git detects them: a + * NUL byte within the first {@link BINARY_DETECTION_LENGTH} bytes marks the content as binary. + */ +export function normalizeLineEndings(content: Uint8Array): Uint8Array { + if (isBinary(content)) { + return content; + } + + const normalized = new Uint8Array(content.length); + let length = 0; + for (const [index, byte] of content.entries()) { + if (byte === CARRIAGE_RETURN && content[index + 1] === LINE_FEED) { + continue; + } + + normalized[length++] = byte; + } + + return length === content.length ? content : normalized.subarray(0, length); +} + +const NUL = 0x00; +const LINE_FEED = 0x0a; +const CARRIAGE_RETURN = 0x0d; +const BINARY_DETECTION_LENGTH = 8000; + +function isBinary(content: Uint8Array) { + const end = Math.min(content.length, BINARY_DETECTION_LENGTH); + for (let index = 0; index < end; index++) { + if (content[index] === NUL) { + return true; + } + } + + return false; +}