Skip to content

Instantly share code, notes, and snippets.

@webstrand
Last active March 30, 2025 20:18
Show Gist options
  • Select an option

  • Save webstrand/3615668e508d1a3e08af4697c7ce6208 to your computer and use it in GitHub Desktop.

Select an option

Save webstrand/3615668e508d1a3e08af4697c7ce6208 to your computer and use it in GitHub Desktop.
Tagged template literal unindenter. It's caches the intermediate unindented segments.
const cache = new Map<TemplateStringsArray | string, readonly [string, ...string[]]>();
export function dedent(tsa: TemplateStringsArray, ...substitutions: unknown[]): string {
let segments = cache.get(tsa);
if (!segments) {
const dedupeKey = JSON.stringify(tsa);
segments = cache.get(dedupeKey);
if (!segments) {
if (tsa.length === 0) throw new Error("Missing string segments");
const strings = [...tsa] as [string, ...string[]];
// Find the leading newline then trim it
if (!strings[0].startsWith("\n"))
throw new Error("Missing leading newline, please open a newline immediately following dedent");
strings[0] = strings[0].slice(1);
// Find the trailing newline and the trailing whitespace, then trim it
const trailing = /(.*)(?:^|\n)([ \t]*)$/s.exec(strings[strings.length - 1]!);
if (!trailing) throw new Error("Missing trailing newline and indent");
strings[strings.length - 1] = trailing[1]!;
const indent = trailing[2]!;
// We build the matcher for finding newline+indent to replace
// if the indent fails to match we instead capture what indentation there is
// so that we can report an error.
const matcher = new RegExp(`(^|\n)(?:${indent}|([ \t]+))`, "g");
let line = 0;
// Replace the whitespace in every string segment.
for (let i = 0; i !== strings.length; i += 1) {
strings[i] = strings[i]!.replaceAll(matcher, (match, a, malformed) => {
line += 1;
if (malformed != null) throw new Error(`Malformed indentation on line ${line}`);
return a;
});
}
cache.set(dedupeKey, (segments = strings));
}
cache.set(tsa, segments);
}
let concat = segments[0];
for (let i = 1; i !== segments.length; i += 1) {
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
concat += substitutions[i - 1];
concat += segments[i]!;
}
return concat;
}
import.meta.env.TEST && (dedent.__cache__ = cache);
import { dedent } from "./dedent.ts"
for(let i = 0; i < 10; i++) {
console.log(dedent`
Hello, World!
this block of text gets unindented
interpolation works: ${i}
enjoy!
```)
}
/*
Output strips the leading indentation:
Hello, World!
this block of text gets unindented
interpolation works: ${i}
enjoy!
*/
import {expect, test} from "vitest";
import {dedent} from "./dedent.mts";
const identity = (strings: TemplateStringsArray, ..._substitutions: unknown[]) => strings;
test("TemplateStringsArray are unique", () => {
expect(identity`foo`).not.toBe(identity`foo`);
});
test("TemplateStringsArray are interned", () => {
const test = (param: unknown) => identity`foo${param}`;
expect(test("foo")).toBe(test("bar"));
});
test("should reject empty", () => {
expect(() => dedent([] as never)).toThrow();
});
test("should reject missing leading newline", () => {
expect(() => dedent``).toThrow(/missing leading newline/i);
expect(() => dedent`a`).toThrow(/missing leading newline/i);
expect(() => dedent`a\n`).toThrow(/missing leading newline/i);
expect(() => dedent` \n `).toThrow(/missing leading newline/i);
});
test("should reject missing trailing newline+indent", () => {
expect(() => dedent`\n a`).toThrow(/missing trailing newline and indent/i);
expect(() => dedent`\n a\n a`).toThrow(/missing trailing newline and indent/i);
expect(() => dedent`\n a\n a\n a`).toThrow(/missing trailing newline and indent/i);
});
test("should accept empty indentation", () => {
expect(dedent`\n`).toEqual("");
expect(dedent`\n `).toEqual("");
expect(dedent`\n \n `).toEqual("");
});
test("should dedent", () => {
expect(dedent`
`).toEqual("");
expect(dedent`\n a\n `).toEqual("a");
expect(dedent`
a
`).toEqual("a");
expect(dedent`\n a\n b\n c\n d\n `).toEqual("a\nb\nc\nd");
expect(dedent`
a
b
c
d
`).toEqual(`a
b
c
d`);
expect(dedent`
a
b
c
d
`).toEqual(` a
b
c
d`);
});
test("should allow substitutions", () => {
expect(dedent`
a${"\n"}
`).toEqual("a\n");
expect(dedent`
a: ${"\n "},
b: ${"Object"},
c: ${Infinity}
${"d: x"}
`).toEqual("a: \n ,\n b: Object,\nc: Infinity\nd: x");
});
test("should reject malformed indentation", () => {
expect(
() => dedent`
example
bad indent
example
`,
).toThrow(/malformed indentation/i);
expect(
() => dedent`
example
${"bad indent"}
example
`,
).toThrow(/malformed indentation/i);
});
test("should tolerate empty lines", () => {
expect(dedent`
example
tolerate
`).toEqual(`example\n\ntolerate`);
expect(dedent`
example
${"not malformed"}
tolerate
`).toEqual(`example\nnot malformed\ntolerate`);
});
test("should produce correct strings on reuse", () => {
const reuse = (a: unknown) => dedent`
this is an ${a}
`;
expect(reuse("example: A")).toEqual("this is an example: A");
expect(reuse("example: B")).toEqual("this is an example: B");
});
test("should cache", () => {
dedent.__cache__.clear();
void dedent`\nfoo\n`;
expect(dedent.__cache__.size).toBe(2);
expect(new Set(dedent.__cache__.values()).size).toBe(1);
});
test("should deduplicate cache", () => {
dedent.__cache__.clear();
void dedent`\nfoo\n`;
expect(dedent.__cache__.size === 2);
expect(new Set(dedent.__cache__.values()).size).toBe(1);
void dedent`\nfoo\n`;
expect(dedent.__cache__.size === 3);
expect(new Set(dedent.__cache__.values()).size).toBe(1);
});
test("should reuse cache", () => {
const reuse = (a: unknown) => dedent`\nfoo${a}\n`;
expect(reuse(1)).toBe("foo1");
// Mutate
const cacheEntry = dedent.__cache__.get(JSON.stringify(identity`\nfoo${null}\n`));
expect(cacheEntry).toBeDefined();
(cacheEntry! as never as string[])[0] = "bar";
expect(reuse(1)).toBe("bar1");
// Dont allow malformed cache to escape.
dedent.__cache__.clear();
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment