Skip to content

Commit

Permalink
fix: preserve trailing new line (#457)
Browse files Browse the repository at this point in the history
Remove new line loader. Move functionality to text file loader. Use
target locale file as source of truth to preserve trailing new line.
If it does not exist, use source locale file as source of truth.
  • Loading branch information
mathio authored Feb 10, 2025
1 parent 5f2cc49 commit 8ffff97
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 59 deletions.
5 changes: 5 additions & 0 deletions .changeset/strong-mangos-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"lingo.dev": patch
---

fix trailing new lines
99 changes: 99 additions & 0 deletions packages/cli/src/cli/loaders/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import _ from "lodash";
import fs from "fs/promises";
import createBucketLoader from "./index";
import createTextFileLoader from "./text-file";

describe("bucket loaders", () => {
beforeEach(() => {
Expand Down Expand Up @@ -1345,6 +1346,92 @@ Mundo!`;
});
});
});

describe("text-file", () => {
describe("when there is no target locale file", () => {
it("should preserve trailing new line based on the source locale", async () => {
setupFileMocks();

const input = "Hello\n";
const expectedOutput = "Hola\n";

mockFileOperationsForPaths({
"i18n/en.txt": input,
"i18n/es.txt": "",
});

const textFileLoader = createTextFileLoader("i18n/[locale].txt");
textFileLoader.setDefaultLocale("en");
await textFileLoader.pull("en");

await textFileLoader.push("es", "Hola");

expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.txt", expectedOutput, { encoding: "utf-8", flag: "w" });
});

it("should not add trailing new line based on the source locale", async () => {
setupFileMocks();

const input = "Hello";
const expectedOutput = "Hola";

mockFileOperationsForPaths({
"i18n/en.txt": input,
"i18n/es.txt": "",
});

const textFileLoader = createTextFileLoader("i18n/[locale].txt");
textFileLoader.setDefaultLocale("en");
await textFileLoader.pull("en");

await textFileLoader.push("es", "Hola");

expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.txt", expectedOutput, { encoding: "utf-8", flag: "w" });
});
});

describe("when there is a target locale file", () => {
it("should preserve trailing new lines based on the target locale", async () => {
setupFileMocks();

const input = "Hello";
const expectedOutput = "Hola\n";

mockFileOperationsForPaths({
"i18n/en.txt": input,
"i18n/es.txt": "Foo\n",
});

const textFileLoader = createTextFileLoader("i18n/[locale].txt");
textFileLoader.setDefaultLocale("en");
await textFileLoader.pull("en");

await textFileLoader.push("es", "Hola");

expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.txt", expectedOutput, { encoding: "utf-8", flag: "w" });
});

it("should not add trailing new line based on the target locale", async () => {
setupFileMocks();

const input = "Hello\n";
const expectedOutput = "Hola";

mockFileOperationsForPaths({
"i18n/en.txt": input,
"i18n/es.txt": "Foo",
});

const textFileLoader = createTextFileLoader("i18n/[locale].txt");
textFileLoader.setDefaultLocale("en");
await textFileLoader.pull("en");

await textFileLoader.push("es", "Hola");

expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.txt", expectedOutput, { encoding: "utf-8", flag: "w" });
});
});
});
});

// Helper functions
Expand Down Expand Up @@ -1373,3 +1460,15 @@ function mockFileOperations(input: string) {
(fs.readFile as any).mockImplementation(() => Promise.resolve(input));
(fs.writeFile as any).mockImplementation(() => Promise.resolve());
}

function mockFileOperationsForPaths(input: Record<string, string>) {
(fs.access as any).mockImplementation((path) =>
input.hasOwnProperty(path) ? Promise.resolve() : Promise.reject(`fs.access: ${path} not mocked`),
);
(fs.readFile as any).mockImplementation((path) =>
input.hasOwnProperty(path) ? Promise.resolve(input[path]) : Promise.reject(`fs.readFile: ${path} not mocked`),
);
(fs.writeFile as any).mockImplementation((path) =>
input.hasOwnProperty(path) ? Promise.resolve() : Promise.reject(`fs:writeFile: ${path} not mocked`),
);
}
18 changes: 0 additions & 18 deletions packages/cli/src/cli/loaders/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import createVttLoader from "./vtt";
import createVariableLoader from "./variable";
import createSyncLoader from "./sync";
import createPlutilJsonTextLoader from "./plutil-json-loader";
import createNewLineLoader from "./new-line";

export default function createBucketLoader(
bucketType: Z.infer<typeof bucketTypeSchema>,
Expand All @@ -39,7 +38,6 @@ export default function createBucketLoader(
case "android":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createAndroidLoader(),
createFlatLoader(),
createSyncLoader(),
Expand All @@ -48,7 +46,6 @@ export default function createBucketLoader(
case "csv":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createCsvLoader(),
createFlatLoader(),
createSyncLoader(),
Expand All @@ -57,7 +54,6 @@ export default function createBucketLoader(
case "html":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createPrettierLoader({ parser: "html", alwaysFormat: true }),
createHtmlLoader(),
createSyncLoader(),
Expand All @@ -66,7 +62,6 @@ export default function createBucketLoader(
case "json":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createPrettierLoader({ parser: "json" }),
createJsonLoader(),
createFlatLoader(),
Expand All @@ -76,7 +71,6 @@ export default function createBucketLoader(
case "markdown":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createPrettierLoader({ parser: "markdown" }),
createMarkdownLoader(),
createSyncLoader(),
Expand All @@ -85,7 +79,6 @@ export default function createBucketLoader(
case "po":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createPoLoader(),
createFlatLoader(),
createSyncLoader(),
Expand All @@ -95,23 +88,20 @@ export default function createBucketLoader(
case "properties":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createPropertiesLoader(),
createSyncLoader(),
createUnlocalizableLoader(),
);
case "xcode-strings":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createXcodeStringsLoader(),
createSyncLoader(),
createUnlocalizableLoader(),
);
case "xcode-stringsdict":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createXcodeStringsdictLoader(),
createFlatLoader(),
createSyncLoader(),
Expand All @@ -120,7 +110,6 @@ export default function createBucketLoader(
case "xcode-xcstrings":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createPlutilJsonTextLoader(),
createJsonLoader(),
createXcodeXcstringsLoader(),
Expand All @@ -132,7 +121,6 @@ export default function createBucketLoader(
case "yaml":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createPrettierLoader({ parser: "yaml" }),
createYamlLoader(),
createFlatLoader(),
Expand All @@ -142,7 +130,6 @@ export default function createBucketLoader(
case "yaml-root-key":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createPrettierLoader({ parser: "yaml" }),
createYamlLoader(),
createRootKeyLoader(true),
Expand All @@ -153,7 +140,6 @@ export default function createBucketLoader(
case "flutter":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createPrettierLoader({ parser: "json" }),
createJsonLoader(),
createFlutterLoader(),
Expand All @@ -164,7 +150,6 @@ export default function createBucketLoader(
case "xliff":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createXliffLoader(),
createFlatLoader(),
createSyncLoader(),
Expand All @@ -173,7 +158,6 @@ export default function createBucketLoader(
case "xml":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createXmlLoader(),
createFlatLoader(),
createSyncLoader(),
Expand All @@ -182,7 +166,6 @@ export default function createBucketLoader(
case "srt":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createSrtLoader(),
createSyncLoader(),
createUnlocalizableLoader(),
Expand All @@ -197,7 +180,6 @@ export default function createBucketLoader(
case "vtt":
return composeLoaders(
createTextFileLoader(bucketPathPattern),
createNewLineLoader(),
createVttLoader(),
createSyncLoader(),
createUnlocalizableLoader(),
Expand Down
24 changes: 0 additions & 24 deletions packages/cli/src/cli/loaders/new-line.ts

This file was deleted.

53 changes: 36 additions & 17 deletions packages/cli/src/cli/loaders/text-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,54 @@ import { createLoader } from "./_utils";
export default function createTextFileLoader(pathPattern: string): ILoader<void, string> {
return createLoader({
async pull(locale) {
const draftPath = pathPattern.replace("[locale]", locale);
const finalPath = path.resolve(draftPath);

// Handle non-existent files
const exists = await fs
.access(finalPath)
.then(() => true)
.catch(() => false);
if (!exists) {
return "";
}

const result = await fs.readFile(finalPath, "utf-8");
return result;
const result = await readFileForLocale(pathPattern, locale);
const trimmedResult = result.trim();
return trimmedResult;
},
async push(locale, data) {
async push(locale, data, _, originalLocale) {
const draftPath = pathPattern.replace("[locale]", locale);
const finalPath = path.resolve(draftPath);

// Create parent directories if needed
const dirPath = path.dirname(finalPath);
await fs.mkdir(dirPath, { recursive: true });

// Ensure consistent line endings
const finalPayload = data.trim();
const trimmedPayload = data.trim();

// Add trailing new line if needed
const trailingNewLine = await getTrailingNewLine(pathPattern, locale, originalLocale);
let finalPayload = trimmedPayload + trailingNewLine;

await fs.writeFile(finalPath, finalPayload, {
encoding: "utf-8",
flag: "w",
});
},
});
}

async function readFileForLocale(pathPattern: string, locale: string) {
const draftPath = pathPattern.replace("[locale]", locale);
const finalPath = path.resolve(draftPath);
const exists = await fs
.access(finalPath)
.then(() => true)
.catch(() => false);
if (!exists) {
return "";
}
return fs.readFile(finalPath, "utf-8");
}

async function getTrailingNewLine(pathPattern: string, locale: string, originalLocale: string) {
let templateData = await readFileForLocale(pathPattern, locale);
if (!templateData) {
templateData = await readFileForLocale(pathPattern, originalLocale);
}

if (templateData?.match(/[\r\n]$/)) {
const ending = templateData?.includes("\r\n") ? "\r\n" : templateData?.includes("\r") ? "\r" : "\n";
return ending;
}
return "";
}

0 comments on commit 8ffff97

Please sign in to comment.