From 45809597182a64bce7e3b5e6c4c2ec6e755a8e46 Mon Sep 17 00:00:00 2001 From: KimKyuHoi Date: Thu, 15 Jan 2026 00:41:06 +0900 Subject: [PATCH 1/3] feat(dedent): add dedent function for template literals --- src/string/dedent.spec.ts | 69 ++++++++++++++++++++++++++++ src/string/dedent.ts | 94 +++++++++++++++++++++++++++++++++++++++ src/string/index.ts | 1 + 3 files changed, 164 insertions(+) create mode 100644 src/string/dedent.spec.ts create mode 100644 src/string/dedent.ts diff --git a/src/string/dedent.spec.ts b/src/string/dedent.spec.ts new file mode 100644 index 000000000..92cef791b --- /dev/null +++ b/src/string/dedent.spec.ts @@ -0,0 +1,69 @@ +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 + `; + // 'line 1' has 4 spaces, 'line 2' has 2 spaces. Common is 2 spaces. + // Wait, checking my logic... + // line 1: " line 1" (4 spaces) + // line 2: " line 2" (2 spaces) + // Common: 2 spaces. + // Result: " line 1\nline 2" + expect(result).toBe(' line 1\nline 2'); + }); +}); diff --git a/src/string/dedent.ts b/src/string/dedent.ts new file mode 100644 index 000000000..11c08e58d --- /dev/null +++ b/src/string/dedent.ts @@ -0,0 +1,94 @@ +/** + * Removes the common leading indentation from a multi-line string. + * This function can be used as a tagged template literal or as a regular function. + * + * @param {TemplateStringsArray | string} template - The template string array or a standard string. + * @param {unknown[]} args - The values to be interpolated into the template string. + * @returns {string} The dedented string with common indentation removed. + * + * @example + * // As a tagged template literal: + * dedent` + * function hello() { + * console.log('world'); + * } + * `; // returns "function hello() {\n console.log('world');\n}" + * + * @example + * // As a regular function: + * dedent(" line 1\n line 2"); // returns "line 1\n line 2" + */ +export function dedent(template: TemplateStringsArray | string, ...args: unknown[]): string { + let result = ''; + + if (typeof template === 'string') { + result = template; + } else { + // Handle tagged template literal + for (let i = 0; i < template.length; i++) { + result += template[i]; + if (i < args.length) { + result += String(args[i]); + } + } + } + + const lines = result.split('\n'); + let commonIndent: string | null = null; + + // Find common indentation + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Skip empty lines or lines with only whitespace + if (line.trim() === '') { + continue; + } + + const match = line.match(/^(\s+)/); + const indent = match ? match[1] : ''; + + if (commonIndent === null) { + commonIndent = indent; + } else { + // Find common prefix (indentation) + let j = 0; + while (j < commonIndent.length && j < indent.length && commonIndent[j] === indent[j]) { + j++; + } + commonIndent = commonIndent.substring(0, j); + } + } + + // If no common indentation found (or it's empty string), verify if we need to trim the start/end + // But strictly per logic, if commonIndent is null, it means all lines are empty/whitespace. + // If commonIndent is "", it means no common indentation. + 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 { + if (line.trim() === '') { + outputLines.push(''); + } else { + outputLines.push(line); + } + } + } + + // Remove leading newline (opening line) + if (outputLines.length > 0 && outputLines[0].trim() === '') { + outputLines.shift(); + } + + // Remove trailing newline (closing line) + 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'; From 12d366029eca4dbab0eb41f0faedfd552a7573f2 Mon Sep 17 00:00:00 2001 From: KimKyuHoi Date: Thu, 15 Jan 2026 00:50:11 +0900 Subject: [PATCH 2/3] feat(dedent): add dedent function to remove common indentation --- src/string/dedent.spec.ts | 49 ++++++++++++++++++++++++++++++++++----- src/string/dedent.ts | 6 +---- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/string/dedent.spec.ts b/src/string/dedent.spec.ts index 92cef791b..d31221ccb 100644 --- a/src/string/dedent.spec.ts +++ b/src/string/dedent.spec.ts @@ -58,12 +58,49 @@ describe('dedent', () => { line 1 line 2 `; - // 'line 1' has 4 spaces, 'line 2' has 2 spaces. Common is 2 spaces. - // Wait, checking my logic... - // line 1: " line 1" (4 spaces) - // line 2: " line 2" (2 spaces) - // Common: 2 spaces. - // Result: " line 1\nline 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'); + }); }); diff --git a/src/string/dedent.ts b/src/string/dedent.ts index 11c08e58d..a105d4a67 100644 --- a/src/string/dedent.ts +++ b/src/string/dedent.ts @@ -72,11 +72,7 @@ export function dedent(template: TemplateStringsArray | string, ...args: unknown if (line.startsWith(indentToRemove)) { outputLines.push(line.substring(indentToRemove.length)); } else { - if (line.trim() === '') { - outputLines.push(''); - } else { - outputLines.push(line); - } + outputLines.push(''); } } From 780a543a8cf1ec6540390245889f619017d95046 Mon Sep 17 00:00:00 2001 From: KimKyuHoi Date: Thu, 15 Jan 2026 01:00:01 +0900 Subject: [PATCH 3/3] feat(dedent): add dedent function to remove common indentation --- src/string/dedent.spec.ts | 43 ++++++++++++++++++ src/string/dedent.ts | 94 ++++++++++++++++++++++++++------------- 2 files changed, 107 insertions(+), 30 deletions(-) diff --git a/src/string/dedent.spec.ts b/src/string/dedent.spec.ts index d31221ccb..e7c113125 100644 --- a/src/string/dedent.spec.ts +++ b/src/string/dedent.spec.ts @@ -103,4 +103,47 @@ no-indent 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 index a105d4a67..4f35a0c16 100644 --- a/src/string/dedent.ts +++ b/src/string/dedent.ts @@ -1,46 +1,86 @@ +/** + * 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 or as a regular function. * - * @param {TemplateStringsArray | string} template - The template string array or a standard string. - * @param {unknown[]} args - The values to be interpolated into the template string. - * @returns {string} The dedented string with common indentation removed. + * 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 - * // As a tagged template literal: * dedent` - * function hello() { - * console.log('world'); - * } - * `; // returns "function hello() {\n console.log('world');\n}" + * hello + * world + * `; // returns "hello\nworld" * * @example - * // As a regular function: - * dedent(" line 1\n line 2"); // returns "line 1\n line 2" + * dedent(" hello\n world"); // returns "hello\nworld" + * + * @example + * const html = (s: TemplateStringsArray) => s.join(''); + * dedent(html)` + *
Hello
+ * `; // returns "
Hello
" */ -export function dedent(template: TemplateStringsArray | string, ...args: unknown[]): string { - let result = ''; +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') { - result = template; - } else { - // Handle tagged template literal - for (let i = 0; i < template.length; i++) { - result += template[i]; - if (i < args.length) { - result += String(args[i]); - } + return processDedent(template); + } + + let result = ''; + for (let i = 0; i < template.length; i++) { + result += template[i]; + if (i < args.length) { + result += String(args[i]); } } - const lines = result.split('\n'); + 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; - // Find common indentation for (let i = 0; i < lines.length; i++) { const line = lines[i]; - // Skip empty lines or lines with only whitespace if (line.trim() === '') { continue; } @@ -51,7 +91,6 @@ export function dedent(template: TemplateStringsArray | string, ...args: unknown if (commonIndent === null) { commonIndent = indent; } else { - // Find common prefix (indentation) let j = 0; while (j < commonIndent.length && j < indent.length && commonIndent[j] === indent[j]) { j++; @@ -60,9 +99,6 @@ export function dedent(template: TemplateStringsArray | string, ...args: unknown } } - // If no common indentation found (or it's empty string), verify if we need to trim the start/end - // But strictly per logic, if commonIndent is null, it means all lines are empty/whitespace. - // If commonIndent is "", it means no common indentation. const indentToRemove = commonIndent || ''; const outputLines: string[] = []; @@ -76,12 +112,10 @@ export function dedent(template: TemplateStringsArray | string, ...args: unknown } } - // Remove leading newline (opening line) if (outputLines.length > 0 && outputLines[0].trim() === '') { outputLines.shift(); } - // Remove trailing newline (closing line) if (outputLines.length > 0 && outputLines[outputLines.length - 1].trim() === '') { outputLines.pop(); }