diff --git a/src/string/dedent.spec.ts b/src/string/dedent.spec.ts new file mode 100644 index 000000000..e7c113125 --- /dev/null +++ b/src/string/dedent.spec.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { dedent } from './dedent'; + +describe('dedent', () => { + it('removes indentation from a multi-line string', () => { + const result = dedent` + line 1 + line 2 + line 3 + `; + expect(result).toBe('line 1\n line 2\nline 3'); + }); + + it('works as a function', () => { + const result = dedent(` + line 1 + line 2 + `); + expect(result).toBe('line 1\nline 2'); + }); + + it('handles interpolation', () => { + const value = 'world'; + const result = dedent` + hello + ${value} + `; + expect(result).toBe('hello\nworld'); + }); + + it('removes common indentation with blank lines', () => { + const result = dedent` + line 1 + + line 2 + `; + expect(result).toBe('line 1\n\n line 2'); + }); + + it('removes leading and trailing empty lines', () => { + const result = dedent` + text + `; + expect(result).toBe('text'); + }); + + it('handles mixed indentation correctly (closest common indent)', () => { + const result = dedent` +
+ +
+ `; + expect(result).toBe('
\n \n
'); + }); + + it('does not strip indentation if there is none common', () => { + const result = dedent` + line 1 + line 2 + `; + expect(result).toBe(' line 1\nline 2'); + }); + + it('handles lines that do not start with common indent', () => { + const result = dedent` + indented +no-indent + `; + expect(result).toBe(' indented\nno-indent'); + }); + + it('handles string without leading newline', () => { + const result = dedent(' hello\n world'); + expect(result).toBe('hello\nworld'); + }); + + it('handles string without trailing newline', () => { + const result = dedent(' hello\n world'); + expect(result).toBe('hello\nworld'); + }); + + it('handles empty string', () => { + const result = dedent(''); + expect(result).toBe(''); + }); + + it('handles string with only whitespace', () => { + const result = dedent(' \n \n '); + expect(result).toBe(' '); + }); + + it('handles single line without indentation', () => { + const result = dedent('hello'); + expect(result).toBe('hello'); + }); + + it('handles lines with content but no leading whitespace', () => { + const result = dedent('line1\nline2\nline3'); + expect(result).toBe('line1\nline2\nline3'); + }); + + it('preserves non-indented content line when common indent exists', () => { + const result = dedent(' indented\na'); + expect(result).toBe(' indented\na'); + }); + + describe('tag composition (TC39 proposal)', () => { + it('works with a simple tag function', () => { + const upper = (strings: TemplateStringsArray, ...values: unknown[]) => { + let result = ''; + for (let i = 0; i < strings.length; i++) { + result += strings[i]; + if (i < values.length) { + result += String(values[i]); + } + } + return result.toUpperCase(); + }; + + const dedentedUpper = dedent(upper) as (strings: TemplateStringsArray, ...values: unknown[]) => string; + const result = dedentedUpper` + hello + world + `; + expect(result).toBe('HELLO\nWORLD'); + }); + + it('preserves interpolation with tag composition', () => { + const identity = (strings: TemplateStringsArray, ...values: unknown[]) => { + let result = ''; + for (let i = 0; i < strings.length; i++) { + result += strings[i]; + if (i < values.length) { + result += String(values[i]); + } + } + return result; + }; + + const dedentedIdentity = dedent(identity) as (strings: TemplateStringsArray, ...values: unknown[]) => string; + const name = 'es-toolkit'; + const result = dedentedIdentity` + Welcome to + ${name}! + `; + expect(result).toBe('Welcome to\nes-toolkit!'); + }); + }); +}); diff --git a/src/string/dedent.ts b/src/string/dedent.ts new file mode 100644 index 000000000..4f35a0c16 --- /dev/null +++ b/src/string/dedent.ts @@ -0,0 +1,124 @@ +/** + * A tag function type that can be used with template literals. + */ +type TagFunction = (strings: TemplateStringsArray, ...values: unknown[]) => T; + +/** + * Removes the common leading indentation from a multi-line string. + * + * This function can be used as a tagged template literal, a regular function, + * or composed with another tag function (TC39 String.dedent proposal). + * + * @param {TemplateStringsArray} template - The template string array. + * @param {unknown[]} args - The values to be interpolated. + * @returns {string} - The dedented string. + * + * @example + * dedent` + * hello + * world + * `; // returns "hello\nworld" + * + * @example + * dedent(" hello\n world"); // returns "hello\nworld" + * + * @example + * const html = (s: TemplateStringsArray) => s.join(''); + * dedent(html)` + *
Hello
+ * `; // returns "
Hello
" + */ +export function dedent(template: TemplateStringsArray, ...args: unknown[]): string; +export function dedent(str: string): string; +export function dedent(tagFn: TagFunction): TagFunction; +export function dedent( + template: TemplateStringsArray | string | TagFunction, + ...args: unknown[] +): string | TagFunction { + if (typeof template === 'function') { + const tagFn = template; + return function dedentedTag(strings: TemplateStringsArray, ...values: unknown[]): T { + const dedentedStrings = dedentTemplateStringsArray(strings); + return tagFn(dedentedStrings, ...values); + }; + } + + if (typeof template === 'string') { + return processDedent(template); + } + + let result = ''; + for (let i = 0; i < template.length; i++) { + result += template[i]; + if (i < args.length) { + result += String(args[i]); + } + } + + return processDedent(result); +} + +function dedentTemplateStringsArray(strings: TemplateStringsArray): TemplateStringsArray { + const joined = strings.join('\x00'); + const dedented = processDedent(joined); + const result = dedented.split('\x00'); + + const templateArray = result as unknown as TemplateStringsArray; + Object.defineProperty(templateArray, 'raw', { + value: result, + writable: false, + enumerable: false, + configurable: false, + }); + + return templateArray; +} + +function processDedent(input: string): string { + const lines = input.split('\n'); + let commonIndent: string | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.trim() === '') { + continue; + } + + const match = line.match(/^(\s+)/); + const indent = match ? match[1] : ''; + + if (commonIndent === null) { + commonIndent = indent; + } else { + let j = 0; + while (j < commonIndent.length && j < indent.length && commonIndent[j] === indent[j]) { + j++; + } + commonIndent = commonIndent.substring(0, j); + } + } + + const indentToRemove = commonIndent || ''; + + const outputLines: string[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.startsWith(indentToRemove)) { + outputLines.push(line.substring(indentToRemove.length)); + } else { + outputLines.push(''); + } + } + + if (outputLines.length > 0 && outputLines[0].trim() === '') { + outputLines.shift(); + } + + if (outputLines.length > 0 && outputLines[outputLines.length - 1].trim() === '') { + outputLines.pop(); + } + + return outputLines.join('\n'); +} diff --git a/src/string/index.ts b/src/string/index.ts index e08cd2543..205d95d13 100644 --- a/src/string/index.ts +++ b/src/string/index.ts @@ -2,6 +2,7 @@ export { camelCase } from './camelCase.ts'; export { capitalize } from './capitalize.ts'; export { constantCase } from './constantCase.ts'; export { deburr } from './deburr.ts'; +export { dedent } from './dedent.ts'; export { escape } from './escape.ts'; export { escapeRegExp } from './escapeRegExp.ts'; export { kebabCase } from './kebabCase.ts';