diff --git a/src/transforms/tree.js b/src/transforms/tree.js index 43f3d1bc5e..097f02b347 100644 --- a/src/transforms/tree.js +++ b/src/transforms/tree.js @@ -169,20 +169,29 @@ function nodeData(field) { function normalizer(delimiter = "/") { return `${delimiter}` === "/" ? (P) => P // paths are already slash-separated - : (P) => P.map(slashEscape).map(replaceAll(delimiter, "/")); // TODO string.replaceAll when supported + : (P) => P.map(slashDelimiter(delimiter)); } -function slashEscape(string) { - return string.replace(/\//g, "\\/"); +function slashDelimiter(delimiter) { + const search = new RegExp(`(\\\\*)(${regexEscape(delimiter)}|/)`, "g"); + return (value) => + value == null + ? null + : value.replace( + search, + (match, a, b) => + b === delimiter + ? a.length & 1 + ? `${a.slice(1)}${delimiter}` // drop one backslash + : `${a}/` // replace delimiter with slash + : a.length & 1 + ? `${a}\\\\/` // add two backslashes to escape backslash + : `${a}\\/` // add one backslash to escape slash + ); } function slashUnescape(string) { - return string.replace(/\\\//g, "/"); -} - -function replaceAll(search, replace) { - search = new RegExp(regexEscape(search), "g"); - return (value) => (value == null ? null : `${value}`.replace(search, replace)); + return string.replace(/\\\//g, "/").replace(/\\\\/g, "\\"); // TODO count backslashes properly } function regexEscape(string) { diff --git a/test/plots/tree-delimiter.ts b/test/plots/tree-delimiter.ts index bc734fbca2..f9492c2005 100644 --- a/test/plots/tree-delimiter.ts +++ b/test/plots/tree-delimiter.ts @@ -3,19 +3,22 @@ import * as Plot from "@observablehq/plot"; export async function treeDelimiter() { return Plot.plot({ axis: null, - height: 120, + height: 150, margin: 10, marginLeft: 40, marginRight: 190, marks: [ Plot.tree( [ - "foo;bar;https://example.com", - "foo;bar;https://example.com/posts/1", - "foo;baz;https://example.com/posts/2", - "foo;bar\\;baz;https://example2.com", // “bar;baz” should be a single node - "foo;bar/baz;https://example4.com", // "bar/baz" should be a single node, distinct from “bar;baz” - "foo;bar\\/baz;https://example3.com" // “bar\/baz” should be a single node + "foo;a;//example", // foo → a → //example + "foo;a;//example/1", // foo → a → //example/1 + "foo;b;//example/2", // foo → b → //example/2 + "foo;c\\;c;//example2", // foo → c;c → //example2 + "foo;d\\\\;d;//example2", // foo → d\ → d → //example3 + "foo;d\\\\;\\d;//example2", // foo → d\ → \d → //example3 + "foo;e\\\\\\;e;//example2", // foo → e\;e → //example3 + "foo;f/f;//example4", // foo → f/f → //example4 + "foo;g\\/g;//example3" // foo → g\/g → //example3 ], {delimiter: ";"} ) @@ -26,21 +29,22 @@ export async function treeDelimiter() { export async function treeDelimiter2() { return Plot.plot({ axis: null, - height: 120, + height: 150, margin: 10, marginLeft: 40, marginRight: 190, marks: [ - Plot.tree( - [ - "foo/bar/https:\\/\\/example.com", - "foo/bar/https:\\/\\/example.com\\/posts\\/1", - "foo/baz/https:\\/\\/example.com\\/posts\\/2", - "foo/bar;baz/https:\\/\\/example2.com", // “bar;baz” should be a single node - "foo/bar\\/baz/https:\\/\\/example4.com", // "bar/baz" should be a single node, distinct from “bar;baz” - "foo/bar\\\\\\/baz/https:\\/\\/example3.com" // “bar\/baz” should be a single node - ] - ) + Plot.tree([ + "foo/a/\\/\\/example", // foo → a → //example + "foo/a/\\/\\/example\\/1", // foo → a → //example/1 + "foo/b/\\/\\/example\\/2", // foo → b → //example/2 + "foo/c;c/\\/\\/example2", // foo → c;c → //example2 + "foo/d\\\\/d/\\/\\/example2", // foo → d\ → d → //example3 + "foo/d\\\\/\\d/\\/\\/example2", // foo → d\ → \d → //example3 + "foo/e\\\\;e/\\/\\/example2", // foo → e\;e → //example3 + "foo/f\\/f/\\/\\/example4", // foo → f/f → //example4 + "foo/g\\\\\\/g/\\/\\/example3" // foo → g\/g → //example3 + ]) ] }); }