From f08b1e1b48583950d0daa72602c734a837b4084b Mon Sep 17 00:00:00 2001 From: Artur Date: Thu, 23 Apr 2026 18:20:43 +0200 Subject: [PATCH 1/4] feat(cli): add keyColumn option for CSV loaders and update related commands --- packages/cli/src/cli/cmd/cleanup.ts | 1 + packages/cli/src/cli/cmd/i18n.ts | 3 + packages/cli/src/cli/cmd/lockfile.ts | 1 + packages/cli/src/cli/cmd/purge.ts | 1 + packages/cli/src/cli/cmd/run/_types.ts | 8 +- packages/cli/src/cli/cmd/run/execute.ts | 15 ++- packages/cli/src/cli/cmd/run/frozen.ts | 2 + packages/cli/src/cli/cmd/run/plan.ts | 29 ++--- .../src/cli/cmd/show/_shared-key-command.ts | 1 + packages/cli/src/cli/cmd/status.ts | 1 + packages/cli/src/cli/loaders/csv.spec.ts | 102 ++++++++++++++++++ packages/cli/src/cli/loaders/csv.ts | 77 +++++++++++-- packages/cli/src/cli/loaders/index.ts | 3 +- packages/cli/src/cli/utils/buckets.spec.ts | 37 +++++++ packages/cli/src/cli/utils/buckets.ts | 19 ++++ packages/spec/src/config.ts | 5 + 16 files changed, 261 insertions(+), 44 deletions(-) diff --git a/packages/cli/src/cli/cmd/cleanup.ts b/packages/cli/src/cli/cmd/cleanup.ts index aeaba6375..a1367fb27 100644 --- a/packages/cli/src/cli/cmd/cleanup.ts +++ b/packages/cli/src/cli/cmd/cleanup.ts @@ -69,6 +69,7 @@ export default new Command() { defaultLocale: sourceLocale, formatter: i18nConfig!.formatter, + keyColumn: bucket.keyColumn, }, bucket.lockedKeys, bucket.lockedPatterns, diff --git a/packages/cli/src/cli/cmd/i18n.ts b/packages/cli/src/cli/cmd/i18n.ts index 3f828ad54..8965e4154 100644 --- a/packages/cli/src/cli/cmd/i18n.ts +++ b/packages/cli/src/cli/cmd/i18n.ts @@ -222,6 +222,7 @@ export default new Command() defaultLocale: sourceLocale, injectLocale: bucket.injectLocale, formatter: i18nConfig!.formatter, + keyColumn: bucket.keyColumn, }, bucket.lockedKeys, bucket.lockedPatterns, @@ -263,6 +264,7 @@ export default new Command() defaultLocale: sourceLocale, returnUnlocalizedKeys: true, injectLocale: bucket.injectLocale, + keyColumn: bucket.keyColumn, }, bucket.lockedKeys, bucket.lockedPatterns, @@ -377,6 +379,7 @@ export default new Command() defaultLocale: sourceLocale, injectLocale: bucket.injectLocale, formatter: i18nConfig!.formatter, + keyColumn: bucket.keyColumn, }, bucket.lockedKeys, bucket.lockedPatterns, diff --git a/packages/cli/src/cli/cmd/lockfile.ts b/packages/cli/src/cli/cmd/lockfile.ts index cf2737ac0..0efa7e56f 100644 --- a/packages/cli/src/cli/cmd/lockfile.ts +++ b/packages/cli/src/cli/cmd/lockfile.ts @@ -42,6 +42,7 @@ export default new Command() { defaultLocale: sourceLocale, formatter: i18nConfig!.formatter, + keyColumn: bucket.keyColumn, }, bucket.lockedKeys, bucket.lockedPatterns, diff --git a/packages/cli/src/cli/cmd/purge.ts b/packages/cli/src/cli/cmd/purge.ts index b1577efad..4e20a908b 100644 --- a/packages/cli/src/cli/cmd/purge.ts +++ b/packages/cli/src/cli/cmd/purge.ts @@ -102,6 +102,7 @@ export default new Command() defaultLocale: sourceLocale, injectLocale: bucket.injectLocale, formatter: i18nConfig!.formatter, + keyColumn: bucket.keyColumn, }, bucket.lockedKeys, bucket.lockedPatterns, diff --git a/packages/cli/src/cli/cmd/run/_types.ts b/packages/cli/src/cli/cmd/run/_types.ts index 967d05deb..eea7eb68e 100644 --- a/packages/cli/src/cli/cmd/run/_types.ts +++ b/packages/cli/src/cli/cmd/run/_types.ts @@ -1,9 +1,4 @@ -import { - bucketTypeSchema, - I18nConfig, - localeCodeSchema, - bucketTypes, -} from "@lingo.dev/_spec"; +import { bucketTypeSchema, I18nConfig, bucketTypes } from "@lingo.dev/_spec"; import { z } from "zod"; import { ILocalizer } from "../../localizer/_types"; @@ -36,6 +31,7 @@ export type CmdRunTask = { localizableKeys: string[]; onlyKeys: string[]; formatter?: "prettier" | "biome"; + keyColumn?: string; }; export const flagsSchema = z.object({ diff --git a/packages/cli/src/cli/cmd/run/execute.ts b/packages/cli/src/cli/cmd/run/execute.ts index b992a0bfc..d24316126 100644 --- a/packages/cli/src/cli/cmd/run/execute.ts +++ b/packages/cli/src/cli/cmd/run/execute.ts @@ -34,9 +34,7 @@ export default async function execute(input: CmdRunContext) { { title: "Initializing localization engine", task: async (ctx, task) => { - task.title = `Localization engine ${chalk.hex(colors.green)( - "ready", - )} (${ctx.localizer!.id})`; + task.title = `Localization engine ${chalk.hex(colors.green)("ready")} (${ctx.localizer!.id})`; }, }, { @@ -124,9 +122,9 @@ function createWorkerStatusMessage(args: { "[locale]", args.assignedTask.targetLocale, ); - return `[${chalk.hex(colors.yellow)( - `${args.percentage}%`, - )}] Processing: ${chalk.dim(displayPath)} (${chalk.hex(colors.yellow)( + return `[${chalk.hex(colors.yellow)(`${args.percentage}%`)}] Processing: ${chalk.dim(displayPath)} (${chalk.hex( + colors.yellow, + )( args.assignedTask.sourceLocale, )} -> ${chalk.hex(colors.yellow)(args.assignedTask.targetLocale)})`; } @@ -147,9 +145,7 @@ function createExecutionProgressMessage(ctx: CmdRunContext) { return `Processed ${chalk.green(succeededTasksCount)}/${ ctx.tasks.length - }, Failed ${chalk.red(failedTasksCount)}, Skipped ${chalk.dim( - skippedTasksCount, - )}`; + }, Failed ${chalk.red(failedTasksCount)}, Skipped ${chalk.dim(skippedTasksCount)}`; } function createLoaderForTask(assignedTask: CmdRunTask) { @@ -160,6 +156,7 @@ function createLoaderForTask(assignedTask: CmdRunTask) { defaultLocale: assignedTask.sourceLocale, injectLocale: assignedTask.injectLocale, formatter: assignedTask.formatter, + keyColumn: assignedTask.keyColumn, }, assignedTask.lockedKeys, assignedTask.lockedPatterns, diff --git a/packages/cli/src/cli/cmd/run/frozen.ts b/packages/cli/src/cli/cmd/run/frozen.ts index deace5d58..23fe7c611 100644 --- a/packages/cli/src/cli/cmd/run/frozen.ts +++ b/packages/cli/src/cli/cmd/run/frozen.ts @@ -60,6 +60,7 @@ export default async function frozen(input: CmdRunContext) { defaultLocale: resolvedSourceLocale, injectLocale: bucket.injectLocale, formatter: input.config!.formatter, + keyColumn: bucket.keyColumn, }, bucket.lockedKeys, bucket.lockedPatterns, @@ -101,6 +102,7 @@ export default async function frozen(input: CmdRunContext) { defaultLocale: resolvedSourceLocale, returnUnlocalizedKeys: true, injectLocale: bucket.injectLocale, + keyColumn: bucket.keyColumn, }, bucket.lockedKeys, bucket.lockedPatterns, diff --git a/packages/cli/src/cli/cmd/run/plan.ts b/packages/cli/src/cli/cmd/run/plan.ts index 1cb8dc0b1..18870f1ae 100644 --- a/packages/cli/src/cli/cmd/run/plan.ts +++ b/packages/cli/src/cli/cmd/run/plan.ts @@ -39,23 +39,15 @@ export default async function plan( task: async (ctx, task) => { const bucketCount = buckets.length; const bucketFilter = input.flags.bucket - ? ` ${chalk.dim( - `(filtered by: ${chalk.hex(colors.yellow)( - input.flags.bucket!.join(", "), - )})`, - )}` + ? ` ${chalk.dim(`(filtered by: ${chalk.hex(colors.yellow)(input.flags.bucket!.join(", "))})`)}` : ""; - task.title = `Found ${chalk.hex(colors.yellow)( - bucketCount.toString(), - )} bucket(s)${bucketFilter}`; + task.title = `Found ${chalk.hex(colors.yellow)(bucketCount.toString())} bucket(s)${bucketFilter}`; }, }, { title: "Detecting locales", task: async (ctx, task) => { - task.title = `Found ${chalk.hex(colors.yellow)( - _targetLocales.length.toString(), - )} target locale(s)`; + task.title = `Found ${chalk.hex(colors.yellow)(_targetLocales.length.toString())} target locale(s)`; }, }, { @@ -82,15 +74,9 @@ export default async function plan( } const fileFilter = input.flags.file - ? ` ${chalk.dim( - `(filtered by: ${chalk.hex(colors.yellow)( - input.flags.file.join(", "), - )})`, - )}` + ? ` ${chalk.dim(`(filtered by: ${chalk.hex(colors.yellow)(input.flags.file.join(", "))})`)}` : ""; - task.title = `Found ${chalk.hex(colors.yellow)( - patterns.length.toString(), - )} path pattern(s)${fileFilter}`; + task.title = `Found ${chalk.hex(colors.yellow)(patterns.length.toString())} path pattern(s)${fileFilter}`; }, }, { @@ -137,14 +123,13 @@ export default async function plan( localizableKeys: bucket.localizableKeys || [], onlyKeys: input.flags.key || [], formatter: input.config!.formatter, + keyColumn: bucket.keyColumn, }); } } } - task.title = `Prepared ${chalk.hex(colors.green)( - ctx.tasks.length.toString(), - )} translation task(s)`; + task.title = `Prepared ${chalk.hex(colors.green)(ctx.tasks.length.toString())} translation task(s)`; }, }, ], diff --git a/packages/cli/src/cli/cmd/show/_shared-key-command.ts b/packages/cli/src/cli/cmd/show/_shared-key-command.ts index 4e689fff8..5a84a87c3 100644 --- a/packages/cli/src/cli/cmd/show/_shared-key-command.ts +++ b/packages/cli/src/cli/cmd/show/_shared-key-command.ts @@ -61,6 +61,7 @@ export async function executeKeyCommand( { defaultLocale: sourceLocale, injectLocale: bucket.injectLocale, + keyColumn: bucket.keyColumn, }, [], // Don't apply any filtering when reading [], diff --git a/packages/cli/src/cli/cmd/status.ts b/packages/cli/src/cli/cmd/status.ts index 692eb46d1..bfc20a5ef 100644 --- a/packages/cli/src/cli/cmd/status.ts +++ b/packages/cli/src/cli/cmd/status.ts @@ -199,6 +199,7 @@ export default new Command() defaultLocale: sourceLocale, injectLocale: bucket.injectLocale, formatter: i18nConfig!.formatter, + keyColumn: bucket.keyColumn, }, bucket.lockedKeys, bucket.lockedPatterns, diff --git a/packages/cli/src/cli/loaders/csv.spec.ts b/packages/cli/src/cli/loaders/csv.spec.ts index 8998d6b0b..f9ddbb30b 100644 --- a/packages/cli/src/cli/loaders/csv.spec.ts +++ b/packages/cli/src/cli/loaders/csv.spec.ts @@ -94,4 +94,106 @@ describe("csv loader", () => { "The first pull must be for the default locale", ); }); + + describe("key column validation", () => { + it("should throw a descriptive error when the key column has duplicate values", async () => { + // Mirrors the Salesfloor bug: first column is categorical, not unique, + // which previously caused silent row collapse. + const dupeCsv = buildCsv([ + ["type", "en", "es"], + ["code", "Hello", ""], + ["code", "Bye", ""], + ["menu", "Home", ""], + ["menu", "Profile", ""], + ]); + + const loader = createCsvLoader(); + loader.setDefaultLocale("en"); + + await expect(loader.pull("en", dupeCsv)).rejects.toThrow( + /CSV column "type" has duplicate values/, + ); + }); + + it("should not throw when the detected key column is unique", async () => { + const loader = createCsvLoader(); + loader.setDefaultLocale("en"); + + // sampleCsv has unique `id` values — this should just work. + await expect(loader.pull("en", sampleCsv)).resolves.toBeDefined(); + }); + + it("should use the `keyColumn` option instead of the first column", async () => { + // First column `type` is repeated; `id` is unique. + // With keyColumn: "id", the loader must use `id` and not fail on `type` dupes. + const csv = buildCsv([ + ["type", "id", "en", "es"], + ["code", "hello", "Hello", ""], + ["code", "bye", "Bye", ""], + ["menu", "home", "Home", ""], + ]); + + const loader = createCsvLoader({ keyColumn: "id" }); + loader.setDefaultLocale("en"); + + const result = await loader.pull("en", csv); + expect(result).toEqual({ hello: "Hello", bye: "Bye", home: "Home" }); + }); + + it("should throw when `keyColumn` points to a column not in the CSV", async () => { + const loader = createCsvLoader({ keyColumn: "nonexistent" }); + loader.setDefaultLocale("en"); + + await expect(loader.pull("en", sampleCsv)).rejects.toThrow( + /CSV key column "nonexistent" is not present/, + ); + }); + + it("should still throw on duplicates when `keyColumn` points at a non-unique column", async () => { + const csv = buildCsv([ + ["id", "group", "en"], + ["1", "menu", "Hello"], + ["2", "menu", "Bye"], + ]); + + const loader = createCsvLoader({ keyColumn: "group" }); + loader.setDefaultLocale("en"); + + await expect(loader.pull("en", csv)).rejects.toThrow( + /CSV column "group" has duplicate values/, + ); + }); + + it("should correctly push translations when `keyColumn` is not the first column", async () => { + // Metadata columns (`type`, `group`) come first; `id` is the key in the middle. + // Verifies that push matches translations by id (not row index) and preserves + // non-key columns through the round-trip. + const csv = buildCsv([ + ["type", "group", "id", "en"], + ["code", "ui", "hello", "Hello"], + ["code", "ui", "bye", "Bye"], + ["menu", "nav", "home", "Home"], + ]); + + const loader = createCsvLoader({ keyColumn: "id" }); + loader.setDefaultLocale("en"); + await loader.pull("en", csv); + + const esCsv = await loader.push("es", { + hello: "Hola", + bye: "Adiós", + home: "Inicio", + }); + + const parsed = parse(esCsv, { + columns: true, + skip_empty_lines: true, + }); + expect(parsed).toEqual([ + { type: "code", group: "ui", id: "hello", en: "Hello", es: "Hola" }, + { type: "code", group: "ui", id: "bye", en: "Bye", es: "Adiós" }, + { type: "menu", group: "nav", id: "home", en: "Home", es: "Inicio" }, + ]); + }); + }); }); diff --git a/packages/cli/src/cli/loaders/csv.ts b/packages/cli/src/cli/loaders/csv.ts index 982bdaf58..ad196a254 100644 --- a/packages/cli/src/cli/loaders/csv.ts +++ b/packages/cli/src/cli/loaders/csv.ts @@ -15,8 +15,16 @@ export function detectKeyColumnName(csvString: string) { return firstColumn || "KEY"; } -export default function createCsvLoader() { - return composeLoaders(_createCsvLoader(), createPullOutputCleaner()); +export type CsvLoaderOptions = { + /** + * Name of the column to use as the unique row identifier. + * If omitted, defaults to the first column in the CSV header. + */ + keyColumn?: string; +}; + +export default function createCsvLoader(options?: CsvLoaderOptions) { + return composeLoaders(_createCsvLoader(options), createPullOutputCleaner()); } type InternalTransferState = { @@ -25,18 +33,42 @@ type InternalTransferState = { items: Record; }; -function _createCsvLoader(): ILoader { +function _createCsvLoader( + options?: CsvLoaderOptions, +): ILoader { + // Validation runs once per loader instance. The loader is created per file + // path, and the same file is pulled once per locale (source + each target) — + // re-validating on every pull would do the same O(N) work N+1 times for + // identical content, with no possibility of catching new issues. + let validated = false; + return createLoader({ async pull(locale, input) { - const keyColumnName = detectKeyColumnName( - input.split("\n").find((l) => l.length)!, - ); + const keyColumnName = + options?.keyColumn ?? + detectKeyColumnName(input.split("\n").find((l) => l.length)!); const inputParsed = parse(input, { columns: true, skip_empty_lines: true, relax_column_count_less: true, }) as Record[]; + if (!validated) { + if (options?.keyColumn && inputParsed.length > 0) { + const availableColumns = Object.keys(inputParsed[0]); + if (!availableColumns.includes(options.keyColumn)) { + throw new Error( + `CSV key column "${options.keyColumn}" is not present in the file. ` + + `Available columns: ${availableColumns.join(", ")}. ` + + `Either rename a column to "${options.keyColumn}" or update the "keyColumn" setting in your bucket config.`, + ); + } + } + + assertUniqueKeys(inputParsed, keyColumnName); + validated = true; + } + const items: Record = {}; // Assign keys that already have translation so AI doesn't re-generate it. @@ -89,6 +121,39 @@ function _createCsvLoader(): ILoader { }); } +/** + * Validates that the key column has unique values across all rows. + * Without this check, rows with repeated key values silently overwrite + * each other when building the translation map, causing most source rows + * to be dropped and the last row's translation to be broadcast everywhere. + */ +function assertUniqueKeys( + rows: Record[], + keyColumnName: string, +): void { + const seen = new Set(); + const duplicates = new Set(); + for (const row of rows) { + const key = row[keyColumnName]; + if (key === undefined || key === "") continue; + if (seen.has(key)) { + duplicates.add(key); + } else { + seen.add(key); + } + } + if (duplicates.size === 0) return; + + const preview = [...duplicates].slice(0, 3).join(", "); + const more = duplicates.size > 3 ? `, and ${duplicates.size - 3} more` : ""; + throw new Error( + `CSV column "${keyColumnName}" has duplicate values (${preview}${more}). ` + + `Lingo uses this column as a unique row identifier, so duplicates would cause rows to silently overwrite each other. ` + + `Fix: make the first column unique (add an "id" column or move your source-language column first), ` + + `or set "keyColumn" in this bucket's config to point at a column with unique values.`, + ); +} + /** * This is a simple extra loader that is used to clean the data written to lockfile */ diff --git a/packages/cli/src/cli/loaders/index.ts b/packages/cli/src/cli/loaders/index.ts index 649495152..ef6386736 100644 --- a/packages/cli/src/cli/loaders/index.ts +++ b/packages/cli/src/cli/loaders/index.ts @@ -58,6 +58,7 @@ type BucketLoaderOptions = { injectLocale?: string[]; targetLocale?: string; formatter?: FormatterType; + keyColumn?: string; }; /** @@ -133,7 +134,7 @@ export default function createBucketLoader( return composeLoaders( createTextFileLoader(bucketPathPattern), createLockedPatternsLoader(lockedPatterns), - createCsvLoader(), + createCsvLoader({ keyColumn: options.keyColumn }), createEnsureKeyOrderLoader(), createFlatLoader(), createLockedKeysLoader(lockedKeys || []), diff --git a/packages/cli/src/cli/utils/buckets.spec.ts b/packages/cli/src/cli/utils/buckets.spec.ts index 01cf62a16..e61842628 100644 --- a/packages/cli/src/cli/utils/buckets.spec.ts +++ b/packages/cli/src/cli/utils/buckets.spec.ts @@ -147,6 +147,43 @@ describe("getBuckets", () => { ]); }); + it("should accept `keyColumn` on a csv bucket", () => { + mockGlobSync(["src/translations.csv"]); + const buckets = getBuckets({ + $schema: "https://lingo.dev/schema/i18n.json", + version: 0, + locale: { source: "en", targets: ["es"] }, + buckets: { + csv: { + include: ["src/translations.csv"], + keyColumn: "id", + }, + }, + } as any); + expect(buckets[0].keyColumn).toBe("id"); + }); + + it("should warn and ignore `keyColumn` when set on a non-csv bucket", () => { + mockGlobSync(["src/i18n/en.json"]); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const buckets = getBuckets({ + $schema: "https://lingo.dev/schema/i18n.json", + version: 0, + locale: { source: "en", targets: ["es"] }, + buckets: { + json: { + include: ["src/i18n/[locale].json"], + keyColumn: "id", + }, + }, + } as any); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringMatching(/"keyColumn" is only supported on "csv" buckets/), + ); + expect(buckets[0].keyColumn).toBeUndefined(); + warnSpy.mockRestore(); + }); + it("should return bucket with multiple locale placeholders", () => { mockGlobSync( ["src/i18n/en/en.json"], diff --git a/packages/cli/src/cli/utils/buckets.ts b/packages/cli/src/cli/utils/buckets.ts index ff0b3c4c4..86e7c618a 100644 --- a/packages/cli/src/cli/utils/buckets.ts +++ b/packages/cli/src/cli/utils/buckets.ts @@ -12,6 +12,11 @@ import { import { bucketTypeSchema } from "@lingo.dev/_spec"; import Z from "zod"; +// Track bucket types we've already warned about for misplaced `keyColumn`, +// so the warning fires only once per CLI invocation (getBuckets is called +// from multiple command stages). +const warnedKeyColumnTypes = new Set(); + type BucketConfig = { type: Z.infer; paths: Array<{ pathPattern: string; delimiter?: LocaleDelimiter }>; @@ -21,6 +26,7 @@ type BucketConfig = { ignoredKeys?: string[]; preservedKeys?: string[]; localizableKeys?: string[]; + keyColumn?: string; }; export function getBuckets(i18nConfig: I18nConfig) { @@ -58,6 +64,19 @@ export function getBuckets(i18nConfig: I18nConfig) { if (bucketEntry.localizableKeys) { config.localizableKeys = bucketEntry.localizableKeys; } + if (bucketEntry.keyColumn) { + if (bucketType !== "csv") { + if (!warnedKeyColumnTypes.has(bucketType)) { + warnedKeyColumnTypes.add(bucketType); + console.warn( + `Warning: "keyColumn" is only supported on "csv" buckets, but was set on "${bucketType}". ` + + `The setting will be ignored. Remove it from this bucket's config to silence this warning.`, + ); + } + } else { + config.keyColumn = bucketEntry.keyColumn; + } + } return config; }, ); diff --git a/packages/spec/src/config.ts b/packages/spec/src/config.ts index db7e10323..c9eb68847 100644 --- a/packages/spec/src/config.ts +++ b/packages/spec/src/config.ts @@ -241,6 +241,11 @@ export const bucketValueSchemaV1_3 = Z.object({ .describe( "Keys within files where the current locale should be injected or removed.", ), + keyColumn: Z.string() + .optional() + .describe( + "CSV buckets only: name of the column to use as the unique row identifier. Defaults to the first column in the CSV header.", + ), }).describe("Configuration options for a translation bucket."); export const configV1_3Definition = extendConfigDefinition( From eab1c89869031d16471aadcad70cd0a62f0a69d7 Mon Sep 17 00:00:00 2001 From: Artur Date: Thu, 23 Apr 2026 18:23:04 +0200 Subject: [PATCH 2/4] chore: add changeset for csv keyColumn feature Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/csv-key-column.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/csv-key-column.md diff --git a/.changeset/csv-key-column.md b/.changeset/csv-key-column.md new file mode 100644 index 000000000..f2daf2d5e --- /dev/null +++ b/.changeset/csv-key-column.md @@ -0,0 +1,6 @@ +--- +"lingo.dev": patch +"@lingo.dev/_spec": patch +--- + +feat(cli): add `keyColumn` option for CSV buckets to specify which column is the unique row identifier, and validate key uniqueness to prevent silent data loss from duplicate keys From c046a87459a39631ffdf978b2048d6f343ae2ce6 Mon Sep 17 00:00:00 2001 From: Artur Date: Thu, 23 Apr 2026 18:36:26 +0200 Subject: [PATCH 3/4] fix(cli): handle empty CSV and sparse first-row edge cases in key column validation - Use split(/\r?\n/) with trim check instead of non-null assertion for empty CSV safety - Parse header row separately (to_line: 1) for column validation to avoid missing columns when first data row is sparse with relax_column_count_less Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/cli/loaders/csv.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/cli/loaders/csv.ts b/packages/cli/src/cli/loaders/csv.ts index ad196a254..ebf79c4cc 100644 --- a/packages/cli/src/cli/loaders/csv.ts +++ b/packages/cli/src/cli/loaders/csv.ts @@ -44,9 +44,10 @@ function _createCsvLoader( return createLoader({ async pull(locale, input) { + const firstNonEmptyLine = + input.split(/\r?\n/).find((line) => line.trim().length > 0) ?? ""; const keyColumnName = - options?.keyColumn ?? - detectKeyColumnName(input.split("\n").find((l) => l.length)!); + options?.keyColumn ?? detectKeyColumnName(firstNonEmptyLine); const inputParsed = parse(input, { columns: true, skip_empty_lines: true, @@ -54,8 +55,12 @@ function _createCsvLoader( }) as Record[]; if (!validated) { - if (options?.keyColumn && inputParsed.length > 0) { - const availableColumns = Object.keys(inputParsed[0]); + if (options?.keyColumn) { + const [headerRow = []] = parse(input, { + to_line: 1, + skip_empty_lines: true, + }) as string[][]; + const availableColumns = headerRow.map((col) => String(col)); if (!availableColumns.includes(options.keyColumn)) { throw new Error( `CSV key column "${options.keyColumn}" is not present in the file. ` + From 53599f3e80e4d44616b04d6e20528b429bcb8046 Mon Sep 17 00:00:00 2001 From: Artur Date: Thu, 23 Apr 2026 20:23:36 +0200 Subject: [PATCH 4/4] feat(cli): add keyColumn configuration to CSV i18n settings --- packages/cli/demo/csv/i18n.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/demo/csv/i18n.json b/packages/cli/demo/csv/i18n.json index fc07221f9..a8363b762 100644 --- a/packages/cli/demo/csv/i18n.json +++ b/packages/cli/demo/csv/i18n.json @@ -10,7 +10,8 @@ "csv": { "include": [ "./example.csv" - ] + ], + "keyColumn": "KEY" } }, "$schema": "https://lingo.dev/schema/i18n.json"