Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions src/string/dedent.spec.ts
Original file line number Diff line number Diff line change
@@ -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`
<div>
<span>
</div>
`;
expect(result).toBe('<div>\n <span>\n</div>');
});

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!');
});
});
});
124 changes: 124 additions & 0 deletions src/string/dedent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* A tag function type that can be used with template literals.
*/
type TagFunction<T = unknown> = (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)`
* <div>Hello</div>
* `; // returns "<div>Hello</div>"
*/
export function dedent(template: TemplateStringsArray, ...args: unknown[]): string;
export function dedent(str: string): string;
export function dedent<T>(tagFn: TagFunction<T>): TagFunction<T>;
export function dedent<T>(
template: TemplateStringsArray | string | TagFunction<T>,
...args: unknown[]
): string | TagFunction<T> {
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');
}
1 change: 1 addition & 0 deletions src/string/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down