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) <[email protected]>
This commit is contained in:
Elian Doran
2026-09-12 11:08:02 +02:00
co-authored by Claude Opus 5
parent 87fae79c54
commit 3b099726d1
2 changed files with 78 additions and 3 deletions
+35 -2
View File
@@ -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`);
});
});
+43 -1
View File
@@ -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;
}