Last active
March 30, 2025 20:18
-
-
Save webstrand/3615668e508d1a3e08af4697c7ce6208 to your computer and use it in GitHub Desktop.
Tagged template literal unindenter. It's caches the intermediate unindented segments.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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! | |
| */ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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