From c7cbd806472d93a0a4c1d65098b279a66987b813 Mon Sep 17 00:00:00 2001 From: asssslay Date: Thu, 5 Nov 2026 15:18:33 +0300 Subject: [PATCH 1/6] feat(csv-stringify): preserve structure with relaxed quotes --- packages/csv-stringify/lib/api/index.js | 7 +- .../lib/api/normalize_options.js | 11 +++ packages/csv-stringify/lib/index.d.ts | 13 +++ .../samples/option.relax_quotes.js | 8 ++ packages/csv-stringify/test/api.types.ts | 1 + .../csv-stringify/test/option.relax_quotes.js | 13 +++ .../csv-stringify/test/option.relax_quotes.ts | 80 +++++++++++++++++++ packages/csv/test/sync.js | 35 ++++++++ 8 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 packages/csv-stringify/samples/option.relax_quotes.js create mode 100644 packages/csv-stringify/test/option.relax_quotes.js create mode 100644 packages/csv-stringify/test/option.relax_quotes.ts diff --git a/packages/csv-stringify/lib/api/index.js b/packages/csv-stringify/lib/api/index.js index 1fb77be2b..e22ed8673 100644 --- a/packages/csv-stringify/lib/api/index.js +++ b/packages/csv-stringify/lib/api/index.js @@ -186,6 +186,7 @@ const stringifier = function (options, state, info) { delimiter, escape, quote, + relax_quotes, quoted, quoted_empty, quoted_string, @@ -250,7 +251,9 @@ const stringifier = function (options, state, info) { } } const shouldQuote = - containsQuote === true || + (containsQuote === true && + (relax_quotes === false || + value.trimStart().startsWith(quote))) || containsdelimiter || containsRecordDelimiter || quoted || @@ -259,7 +262,7 @@ const stringifier = function (options, state, info) { if (shouldQuote === true && containsEscape === true) { value = value.replaceAll(escape, () => escape + escape); } - if (containsQuote === true) { + if (shouldQuote === true && containsQuote === true) { value = value.replaceAll(quote, () => escape + quote); } if (shouldQuote === true) { diff --git a/packages/csv-stringify/lib/api/normalize_options.js b/packages/csv-stringify/lib/api/normalize_options.js index a1b58d70c..b354ed812 100644 --- a/packages/csv-stringify/lib/api/normalize_options.js +++ b/packages/csv-stringify/lib/api/normalize_options.js @@ -53,6 +53,17 @@ const normalize_options = function (opts) { ]), ]; } + // Normalize option `relax_quotes` + if (options.relax_quotes === undefined || options.relax_quotes === null) { + options.relax_quotes = false; + } else if (typeof options.relax_quotes !== "boolean") { + return [ + new CsvError("CSV_OPTION_RELAX_QUOTES_INVALID_TYPE", [ + "option `relax_quotes` must be a boolean,", + `got ${JSON.stringify(options.relax_quotes)}`, + ]), + ]; + } // Normalize option `quoted` if (options.quoted === undefined || options.quoted === null) { options.quoted = false; diff --git a/packages/csv-stringify/lib/index.d.ts b/packages/csv-stringify/lib/index.d.ts index 93c823210..ef2678a1e 100644 --- a/packages/csv-stringify/lib/index.d.ts +++ b/packages/csv-stringify/lib/index.d.ts @@ -11,6 +11,7 @@ export type CastReturnObject = { value: string | null | undefined } & Pick< | "delimiter" | "escape" | "quote" + | "relax_quotes" | "quoted" | "quoted_empty" | "quoted_string" @@ -89,6 +90,12 @@ export interface OptionsNormalized extends stream.TransformOptions { * The quote characters, defaults to the ", an empty quote value will preserve the original field. */ quote: string; + /** + * Preserve interior quotes in unquoted fields when quoting is enabled, defaults to false. + * Fields containing delimiters, line breaks, or leading quotes are still quoted. + * Parse this non-RFC 4180 output with `relax_quotes: true`. + */ + relax_quotes: boolean; /** * Boolean, default to false, quote all the non-empty fields even if not required. */ @@ -178,6 +185,12 @@ export interface Options extends stream.TransformOptions { * The quote characters, defaults to the ", an empty quote value will preserve the original field. */ quote?: string | Buffer | boolean; + /** + * Preserve interior quotes in unquoted fields when quoting is enabled, defaults to false. + * Fields containing delimiters, line breaks, or leading quotes are still quoted. + * Parse this non-RFC 4180 output with `relax_quotes: true`. + */ + relax_quotes?: boolean; /** * Boolean, default to false, quote all the non-empty fields even if not required. */ diff --git a/packages/csv-stringify/samples/option.relax_quotes.js b/packages/csv-stringify/samples/option.relax_quotes.js new file mode 100644 index 000000000..9db9ec7cc --- /dev/null +++ b/packages/csv-stringify/samples/option.relax_quotes.js @@ -0,0 +1,8 @@ +import assert from "node:assert/strict"; +import { stringify } from "csv-stringify/sync"; + +const output = stringify([['say "hi"', "a,b", "a\nb", '"hi"']], { + relax_quotes: true, +}); + +assert.equal(output, 'say "hi","a,b","a\nb","""hi"""\n'); diff --git a/packages/csv-stringify/test/api.types.ts b/packages/csv-stringify/test/api.types.ts index f40b23c3c..ddc6a3804 100644 --- a/packages/csv-stringify/test/api.types.ts +++ b/packages/csv-stringify/test/api.types.ts @@ -32,6 +32,7 @@ describe("API Types", function () { "quoted_match", "quoted_string", "record_delimiter", + "relax_quotes", ]); }); diff --git a/packages/csv-stringify/test/option.relax_quotes.js b/packages/csv-stringify/test/option.relax_quotes.js new file mode 100644 index 000000000..4fe498607 --- /dev/null +++ b/packages/csv-stringify/test/option.relax_quotes.js @@ -0,0 +1,13 @@ +import "should"; +import { stringify } from "../lib/index.js"; + +describe("Option `relax_quotes`", function () { + it("validates boolean values", function () { + (() => stringify([], { relax_quotes: 1 })).should.throw( + "option `relax_quotes` must be a boolean, got 1", + ); + (() => stringify([], { relax_quotes: "true" })).should.throw( + 'option `relax_quotes` must be a boolean, got "true"', + ); + }); +}); diff --git a/packages/csv-stringify/test/option.relax_quotes.ts b/packages/csv-stringify/test/option.relax_quotes.ts new file mode 100644 index 000000000..21b98a66b --- /dev/null +++ b/packages/csv-stringify/test/option.relax_quotes.ts @@ -0,0 +1,80 @@ +import "should"; +import { stringify } from "../lib/index.js"; +import { stringify as stringifySync, normalize_options } from "../lib/sync.js"; + +describe("Option `relax_quotes`", function () { + it("defaults to false", function () { + const [, options] = normalize_options({}); + options.relax_quotes.should.eql(false); + stringifySync([['say "hi"']]).should.eql('"say ""hi"""\n'); + stringifySync([['say "hi"']], { relax_quotes: false }).should.eql( + '"say ""hi"""\n', + ); + }); + + it("preserves interior quotes in unquoted fields", function () { + stringifySync([['say "hi"', 'a"b', 'a""b']], { + relax_quotes: true, + }).should.eql('say "hi",a"b,a""b\n'); + }); + + it("still quotes delimiters and line breaks", function () { + stringifySync([['say "hi", bob'], ['say "hi"\nbob'], ['say "hi"\rbob']], { + relax_quotes: true, + }).should.eql('"say ""hi"", bob"\n"say ""hi""\nbob"\n"say ""hi""\rbob"\n'); + }); + + it("still quotes leading quotes", function () { + stringifySync([['"hi"', '"unfinished', ' "hi"']], { + relax_quotes: true, + }).should.eql('"""hi""","""unfinished"," ""hi"""\n'); + }); + + it("respects explicit quoting selectors", function () { + for (const options of [ + { quoted: true }, + { quoted_string: true }, + { quoted_match: /hi/ }, + ]) { + stringifySync([['say "hi"']], { + relax_quotes: true, + ...options, + }).should.eql('"say ""hi"""\n'); + } + }); + + it("escapes custom quotes only when enclosing the field", function () { + stringifySync([["a|b\\c", "a|b\\c,d", "|a\\b"]], { + relax_quotes: true, + quote: "|", + escape: "\\", + }).should.eql("a|b\\c,|a\\|b\\\\c,d|,|\\|a\\\\b|\n"); + }); + + it("supports multi-character quotes and delimiters", function () { + stringifySync([["a||b", "||a", "a||b::c"]], { + relax_quotes: true, + quote: "||", + delimiter: "::", + }).should.eql('a||b::||"||a||::||a"||b::c||\n'); + }); + + it("supports the callback API", function (next) { + stringify([['say "hi"', 'a,"b"']], { relax_quotes: true }, (err, data) => { + if (err) return next(err); + data.should.eql('say "hi","a,""b"""\n'); + next(); + }); + }); + + it("supports field-level cast options", function () { + stringifySync([[1, 2]], { + cast: { + number: (value) => ({ + value: `say "${value}"`, + relax_quotes: value === 1, + }), + }, + }).should.eql('say "1","say ""2"""\n'); + }); +}); diff --git a/packages/csv/test/sync.js b/packages/csv/test/sync.js index 6f3ad8077..d5ddc2d65 100644 --- a/packages/csv/test/sync.js +++ b/packages/csv/test/sync.js @@ -22,4 +22,39 @@ describe("api sync", function () { it("stringify", function () { stringify([["abc", "def"]]).should.eql("abc,def\n"); }); + + it("round trips relaxed quotes without changing records or fields", function () { + const options = { relax_quotes: true }; + const text = 'say "hi",plain\n"a,b","a\nb"\n"""hi""",a"b\n'; + const records = parse(text, options); + const output = stringify(records, options); + output.should.eql(text); + parse(output, options).should.eql(records); + records.should.have.length(3); + records.forEach((record) => record.should.have.length(2)); + }); + + it("round trips relaxed quotes with custom separators and escaping", function () { + const options = { + relax_quotes: true, + delimiter: "::", + record_delimiter: "__", + quote: "|", + escape: "\\", + }; + const records = [ + ["a|b\\c", "a::b"], + ["a__b", "|leading"], + ]; + const output = stringify(records, options); + output.should.startWith("a|b\\c::"); + parse(output, options).should.eql(records); + }); + + it("preserves leading quotes when parsing with trim enabled", function () { + const options = { relax_quotes: true, trim: true }; + const text = '" ""hi""",plain\n'; + const records = parse(text, options); + parse(stringify(records, options), options).should.eql(records); + }); }); From 32ce490ffc2f9ef520edf49c4bdbba20836b79fc Mon Sep 17 00:00:00 2001 From: asssslay Date: Thu, 5 Nov 2026 15:21:09 +0300 Subject: [PATCH 2/6] test(csv): exercise relaxed quote source exports --- packages/csv/test/option.relax_quotes.js | 40 ++++++++++++++++++++++++ packages/csv/test/sync.js | 35 --------------------- 2 files changed, 40 insertions(+), 35 deletions(-) create mode 100644 packages/csv/test/option.relax_quotes.js diff --git a/packages/csv/test/option.relax_quotes.js b/packages/csv/test/option.relax_quotes.js new file mode 100644 index 000000000..e9c36f290 --- /dev/null +++ b/packages/csv/test/option.relax_quotes.js @@ -0,0 +1,40 @@ +import "should"; + +const { parse, stringify } = await import("../lib/sync.js"); + +describe("Option `relax_quotes`", function () { + it("round trips relaxed quotes without changing records or fields", function () { + const options = { relax_quotes: true }; + const text = 'say "hi",plain\n"a,b","a\nb"\n"""hi""",a"b\n'; + const records = parse(text, options); + const output = stringify(records, options); + output.should.eql(text); + parse(output, options).should.eql(records); + records.should.have.length(3); + records.forEach((record) => record.should.have.length(2)); + }); + + it("round trips relaxed quotes with custom separators and escaping", function () { + const options = { + relax_quotes: true, + delimiter: "::", + record_delimiter: "__", + quote: "|", + escape: "\\", + }; + const records = [ + ["a|b\\c", "a::b"], + ["a__b", "|leading"], + ]; + const output = stringify(records, options); + output.should.startWith("a|b\\c::"); + parse(output, options).should.eql(records); + }); + + it("preserves leading quotes when parsing with trim enabled", function () { + const options = { relax_quotes: true, trim: true }; + const text = '" ""hi""",plain\n'; + const records = parse(text, options); + parse(stringify(records, options), options).should.eql(records); + }); +}); diff --git a/packages/csv/test/sync.js b/packages/csv/test/sync.js index d5ddc2d65..6f3ad8077 100644 --- a/packages/csv/test/sync.js +++ b/packages/csv/test/sync.js @@ -22,39 +22,4 @@ describe("api sync", function () { it("stringify", function () { stringify([["abc", "def"]]).should.eql("abc,def\n"); }); - - it("round trips relaxed quotes without changing records or fields", function () { - const options = { relax_quotes: true }; - const text = 'say "hi",plain\n"a,b","a\nb"\n"""hi""",a"b\n'; - const records = parse(text, options); - const output = stringify(records, options); - output.should.eql(text); - parse(output, options).should.eql(records); - records.should.have.length(3); - records.forEach((record) => record.should.have.length(2)); - }); - - it("round trips relaxed quotes with custom separators and escaping", function () { - const options = { - relax_quotes: true, - delimiter: "::", - record_delimiter: "__", - quote: "|", - escape: "\\", - }; - const records = [ - ["a|b\\c", "a::b"], - ["a__b", "|leading"], - ]; - const output = stringify(records, options); - output.should.startWith("a|b\\c::"); - parse(output, options).should.eql(records); - }); - - it("preserves leading quotes when parsing with trim enabled", function () { - const options = { relax_quotes: true, trim: true }; - const text = '" ""hi""",plain\n'; - const records = parse(text, options); - parse(stringify(records, options), options).should.eql(records); - }); }); From 683a29e979ff610b7c873228c41a79afc74382d3 Mon Sep 17 00:00:00 2001 From: David Worms Date: Thu, 5 Nov 2026 13:45:33 +0100 Subject: [PATCH 3/6] test(csv-stringify): sample renaming --- .../csv-stringify/test/option.relax_quotes.ts | 43 +++++++------------ 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/packages/csv-stringify/test/option.relax_quotes.ts b/packages/csv-stringify/test/option.relax_quotes.ts index 21b98a66b..ab551e332 100644 --- a/packages/csv-stringify/test/option.relax_quotes.ts +++ b/packages/csv-stringify/test/option.relax_quotes.ts @@ -1,50 +1,45 @@ import "should"; -import { stringify } from "../lib/index.js"; -import { stringify as stringifySync, normalize_options } from "../lib/sync.js"; +import { stringify, normalize_options } from "../lib/sync.js"; describe("Option `relax_quotes`", function () { - it("defaults to false", function () { + it("normalisation defaults to false", function () { const [, options] = normalize_options({}); options.relax_quotes.should.eql(false); - stringifySync([['say "hi"']]).should.eql('"say ""hi"""\n'); - stringifySync([['say "hi"']], { relax_quotes: false }).should.eql( - '"say ""hi"""\n', - ); }); it("preserves interior quotes in unquoted fields", function () { - stringifySync([['say "hi"', 'a"b', 'a""b']], { + stringify([['ab"c"', 'd"ef', 'hi""j']], { relax_quotes: true, - }).should.eql('say "hi",a"b,a""b\n'); + }).should.eql('ab"c",d"ef,hi""j\n'); }); - it("still quotes delimiters and line breaks", function () { - stringifySync([['say "hi", bob'], ['say "hi"\nbob'], ['say "hi"\rbob']], { + it("still quotes delimiters and record delimiters", function () { + stringify([['a"b",c'], ['d"e"\nf'], ['h"i"\rj']], { relax_quotes: true, - }).should.eql('"say ""hi"", bob"\n"say ""hi""\nbob"\n"say ""hi""\rbob"\n'); + }).should.eql(['"a""b"",c"', '"d""e""\nf"', '"h""i""\rj"', ""].join("\n")); }); it("still quotes leading quotes", function () { - stringifySync([['"hi"', '"unfinished', ' "hi"']], { + stringify([['"abc"', '"def', ' "hij"']], { relax_quotes: true, - }).should.eql('"""hi""","""unfinished"," ""hi"""\n'); + }).should.eql('"""abc""","""def"," ""hij"""\n'); }); it("respects explicit quoting selectors", function () { for (const options of [ { quoted: true }, { quoted_string: true }, - { quoted_match: /hi/ }, + { quoted_match: /def/ }, ]) { - stringifySync([['say "hi"']], { + stringify([['abc "def"']], { relax_quotes: true, ...options, - }).should.eql('"say ""hi"""\n'); + }).should.eql('"abc ""def"""\n'); } }); it("escapes custom quotes only when enclosing the field", function () { - stringifySync([["a|b\\c", "a|b\\c,d", "|a\\b"]], { + stringify([["a|b\\c", "a|b\\c,d", "|a\\b"]], { relax_quotes: true, quote: "|", escape: "\\", @@ -52,23 +47,15 @@ describe("Option `relax_quotes`", function () { }); it("supports multi-character quotes and delimiters", function () { - stringifySync([["a||b", "||a", "a||b::c"]], { + stringify([["a||b", "||a", "a||b::c"]], { relax_quotes: true, quote: "||", delimiter: "::", }).should.eql('a||b::||"||a||::||a"||b::c||\n'); }); - it("supports the callback API", function (next) { - stringify([['say "hi"', 'a,"b"']], { relax_quotes: true }, (err, data) => { - if (err) return next(err); - data.should.eql('say "hi","a,""b"""\n'); - next(); - }); - }); - it("supports field-level cast options", function () { - stringifySync([[1, 2]], { + stringify([[1, 2]], { cast: { number: (value) => ({ value: `say "${value}"`, From 9d04f9cfd52d9bb6196db70d246b31fdfd339a69 Mon Sep 17 00:00:00 2001 From: David Worms Date: Thu, 5 Nov 2026 13:45:49 +0100 Subject: [PATCH 4/6] refactor(csv-stringify): should quote simplification --- packages/csv-stringify/lib/api/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/csv-stringify/lib/api/index.js b/packages/csv-stringify/lib/api/index.js index e22ed8673..690be4de0 100644 --- a/packages/csv-stringify/lib/api/index.js +++ b/packages/csv-stringify/lib/api/index.js @@ -259,10 +259,12 @@ const stringifier = function (options, state, info) { quoted || quotedString || quotedMatch; + // Escape all escape characters if quoted if (shouldQuote === true && containsEscape === true) { value = value.replaceAll(escape, () => escape + escape); } - if (shouldQuote === true && containsQuote === true) { + // Escape all quote characters if quoted + if (shouldQuote === true) { value = value.replaceAll(quote, () => escape + quote); } if (shouldQuote === true) { From e678be798d9487a6e5fa7d1e19259e4626bc3c56 Mon Sep 17 00:00:00 2001 From: David Worms Date: Thu, 5 Nov 2026 13:56:12 +0100 Subject: [PATCH 5/6] test(csv): relax quotes simplification --- packages/csv/test/option.quote.ts | 6 ++---- packages/csv/test/option.relax_quotes.js | 17 +++++------------ 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/csv/test/option.quote.ts b/packages/csv/test/option.quote.ts index d1cd66c80..7b97c6221 100644 --- a/packages/csv/test/option.quote.ts +++ b/packages/csv/test/option.quote.ts @@ -11,10 +11,8 @@ describe("Option `quote`", function () { }); it("delimiter", function () { - const text = '"ab,c",",def"'; - const normalized = `${text}\n`; - stringify(parse(text)).should.eql(normalized); - parse(stringify(parse(text))).should.eql(parse(text)); + const text = '"ab,c",",def"\n'; + stringify(parse(text)).should.eql(text); }); it("record delimiter", function () { diff --git a/packages/csv/test/option.relax_quotes.js b/packages/csv/test/option.relax_quotes.js index e9c36f290..289c84cb6 100644 --- a/packages/csv/test/option.relax_quotes.js +++ b/packages/csv/test/option.relax_quotes.js @@ -3,18 +3,13 @@ import "should"; const { parse, stringify } = await import("../lib/sync.js"); describe("Option `relax_quotes`", function () { - it("round trips relaxed quotes without changing records or fields", function () { + it("round trips without changing records or fields", function () { const options = { relax_quotes: true }; - const text = 'say "hi",plain\n"a,b","a\nb"\n"""hi""",a"b\n'; - const records = parse(text, options); - const output = stringify(records, options); - output.should.eql(text); - parse(output, options).should.eql(records); - records.should.have.length(3); - records.forEach((record) => record.should.have.length(2)); + const text = 'ab"c",def\n"hi,j","k\nlm"\n"""nop""",q"xy\n'; + stringify(parse(text, options), options).should.eql(text); }); - it("round trips relaxed quotes with custom separators and escaping", function () { + it("round trips with custom separators and escaping", function () { const options = { relax_quotes: true, delimiter: "::", @@ -26,9 +21,7 @@ describe("Option `relax_quotes`", function () { ["a|b\\c", "a::b"], ["a__b", "|leading"], ]; - const output = stringify(records, options); - output.should.startWith("a|b\\c::"); - parse(output, options).should.eql(records); + parse(stringify(records, options), options).should.eql(records); }); it("preserves leading quotes when parsing with trim enabled", function () { From 4fc705716f35e448add66fd08403a50d9d8f8f9f Mon Sep 17 00:00:00 2001 From: David Worms Date: Mon, 9 Nov 2026 11:41:57 +0100 Subject: [PATCH 6/6] docs(csv-stringify): relax_quotes sample --- .../csv-stringify/samples/option.relax_quotes.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/csv-stringify/samples/option.relax_quotes.js b/packages/csv-stringify/samples/option.relax_quotes.js index 9db9ec7cc..ce2bafa2b 100644 --- a/packages/csv-stringify/samples/option.relax_quotes.js +++ b/packages/csv-stringify/samples/option.relax_quotes.js @@ -1,8 +1,14 @@ import assert from "node:assert/strict"; import { stringify } from "csv-stringify/sync"; -const output = stringify([['say "hi"', "a,b", "a\nb", '"hi"']], { - relax_quotes: true, -}); +const output = stringify( + [['relax "quotes"', "I,contain,delimiters", '"I"contain"quotes"']], + { + relax_quotes: true, + }, +); -assert.equal(output, 'say "hi","a,b","a\nb","""hi"""\n'); +assert.equal( + output, + 'relax "quotes","I,contain,delimiters","""I""contain""quotes"""\n', +);