Skip to content
Merged
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
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,15 @@
},
"prettier": "@webdeveric/prettier-config",
"devDependencies": {
"@commitlint/config-conventional": "^21.0.2",
"@commitlint/types": "^21.0.1",
"@commitlint/config-conventional": "^21.1.0",
"@commitlint/types": "^21.1.0",
"@types/node": "^24.13.2",
"@typescript/native-preview": "7.0.0-dev.20260622.1",
"@typescript/native-preview": "7.0.0-dev.20260629.1",
"@vitest/coverage-v8": "^4.1.9",
"@webdeveric/eslint-config-ts": "^0.12.0",
"@webdeveric/prettier-config": "^0.4.0",
"commitlint": "^21.0.2",
"commitlint-plugin-cspell": "^0.9.2",
"commitlint": "^21.1.0",
"commitlint-plugin-cspell": "^0.9.3",
"conventional-changelog-conventionalcommits": "^9.3.1",
"cspell": "^10.0.1",
"eslint": "^8.57.1",
Expand All @@ -103,10 +103,10 @@
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.8",
"prettier": "^3.8.4",
"prettier": "^3.9.3",
"rimraf": "^6.1.3",
"semantic-release": "^25.0.5",
"validate-package-exports": "^1.1.0",
"validate-package-exports": "^1.1.1",
"vitest": "^4.1.9"
}
}
563 changes: 283 additions & 280 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Longest-alternative-first so a lone `\r` from a CRLF pair is never matched on its own.
export const NEWLINE_RAW_PATTERN = String.raw`\r\n|\r|\n`;

// Whitespace excluding line breaks, since those are matched separately via `NEWLINE`.
export const INDENT_CHAR_RAW_PATTERN = String.raw`[^\S\r\n]`;
39 changes: 39 additions & 0 deletions src/dedent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { INDENT_CHAR_RAW_PATTERN, NEWLINE_RAW_PATTERN } from './constants.js';
import { findIndentation } from './findIndentation.js';
import { stripIndent } from './stripIndent.js';

/**
* This is a tagged template function that removes common leading indentation from
* each line, re-indents any interpolated multi-line values to match, and trims
* leading/trailing whitespace from the result.
*
* @example
* ```ts
* const value = 'World';
*
* dedent`
* Hello, ${value}!
* `; // 'Hello, World!'
* ```
*/
export const dedent = (raw: readonly string[], ...values: unknown[]): string => {
const indent = findIndentation(raw);

if (!indent) {
return String.raw({ raw }, ...values).trim();
}

const trailingWhitespace = new RegExp(String.raw`(?:${NEWLINE_RAW_PATTERN})(?<indent>${INDENT_CHAR_RAW_PATTERN}*)$`);
const newlinePattern = new RegExp(NEWLINE_RAW_PATTERN, 'g');

const lines = raw.map((line) => stripIndent(line, indent));

return String.raw(
{ raw: lines },
...values.map((value, index) => {
const extraIndent = lines[index]?.match(trailingWhitespace)?.groups?.['indent'];

return extraIndent ? String(value).replaceAll(newlinePattern, `$&${extraIndent}`) : value;
}),
).trim();
};
12 changes: 12 additions & 0 deletions src/findIndentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { INDENT_CHAR_RAW_PATTERN, NEWLINE_RAW_PATTERN } from './constants.js';

/**
* Find indentation on first line that isn't whitespace only.
*/
export function findIndentation(lines: readonly string[]): string | undefined {
return lines
.find((line) => !/^\s*$/.test(line))
?.match(new RegExp(String.raw`^(?:${NEWLINE_RAW_PATTERN})?(?<whiteSpace>${INDENT_CHAR_RAW_PATTERN}+)`))?.groups?.[
'whiteSpace'
];
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ export * from './convert.js';
export * from './countCharacter.js';
export * from './countCharCode.js';
export * from './counter.js';
export * from './dedent.js';
export * from './deepDecodeURI.js';
export * from './deepFreeze.js';
export * from './delay.js';
export * from './delayAnimationFrame.js';
export * from './delayAnimationFrames.js';
export * from './describeInput.js';
export * from './escapeRegExp.js';
export * from './findIndentation.js';
export * from './get.js';
export * from './getDateString.js';
export * from './getISODateString.js';
Expand Down Expand Up @@ -58,6 +60,7 @@ export * from './redactCredentialsInURL.js';
export * from './resultify.js';
export * from './secToString.js';
export * from './set.js';
export * from './stripIndent.js';
export * from './stripWhitespace.js';
export * from './suffix.js';
export * from './toPascalCase.js';
Expand Down
8 changes: 8 additions & 0 deletions src/stripIndent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { NEWLINE_RAW_PATTERN } from './constants.js';

/**
* Remove `indent` immediately following every line break in `input`.
*/
export function stripIndent(input: string, indent: string): string {
return input.replaceAll(new RegExp(String.raw`(?<newLine>${NEWLINE_RAW_PATTERN})${indent}`, 'g'), '$<newLine>');
}
19 changes: 6 additions & 13 deletions src/trimIndentation.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
export function trimIndentation(input: string): string {
const lines = input.split('\n');
// Find indentation on first line that isn't whitespace only.
const whiteSpace = lines.find((line) => !/^\s*$/.test(line))?.match(/^(?<whiteSpace>\s*)/)?.groups?.['whiteSpace'];

if (whiteSpace) {
const wsPattern = new RegExp(`^${whiteSpace}`);
import { NEWLINE_RAW_PATTERN } from './constants.js';
import { findIndentation } from './findIndentation.js';
import { stripIndent } from './stripIndent.js';

return lines
.map((line) => line.replace(wsPattern, ''))
.join('\n')
.trim();
}
export function trimIndentation(input: string): string {
const whiteSpace = findIndentation(input.split(new RegExp(NEWLINE_RAW_PATTERN)));

return input;
return (whiteSpace ? stripIndent(input, whiteSpace) : input).trim();
}
34 changes: 34 additions & 0 deletions test/dedent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';

import { dedent } from '../src/dedent.js';

describe('dedent()', () => {
it('Trims indentation from a string', () => {
const description = 'line 2\nline 3';

const now = Date.now();

const obj = {
toString(): string {
return 'test';
},
};

const output = dedent`
line 1
${description}
now: ${now}
obj: ${obj}
`;

expect(output).toBe(`line 1\n line 2\n line 3\nnow: ${now}\nobj: test`);
});

it('Does not trim if no indentation is found', () => {
const now = Date.now();

const output = dedent`The time is ${now}`;

expect(output).toBe(`The time is ${now}`);
});
});
45 changes: 45 additions & 0 deletions test/findIndentation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';

import { findIndentation } from '../src/findIndentation.js';

describe('findIndentation()', () => {
it('Finds the indentation of the first non-blank line', () => {
expect(findIndentation([' line 1', ' line 2'])).toBe(' ');
});

it('Skips leading blank lines', () => {
expect(findIndentation(['', ' ', ' line 1'])).toBe(' ');
});

it('Returns undefined when the first non-blank line has no indentation', () => {
expect(findIndentation(['line 1', ' line 2'])).toBeUndefined();
});

it('Returns undefined when all lines are blank', () => {
expect(findIndentation(['', ' ', ''])).toBeUndefined();
});

it('Returns undefined for an empty array', () => {
expect(findIndentation([])).toBeUndefined();
});

it('Handles tabs as indentation', () => {
expect(findIndentation(['\tline 1', '\tline 2'])).toBe('\t');
});

it('Handles mixed tabs and spaces as indentation', () => {
expect(findIndentation(['\t line 1'])).toBe('\t ');
});

it('Skips a leading line break before matching indentation', () => {
expect(findIndentation(['\n line 1'])).toBe(' ');
});

it('Skips a leading CRLF line break before matching indentation', () => {
expect(findIndentation(['\r\n line 1'])).toBe(' ');
});

it('Skips a leading CR line break before matching indentation', () => {
expect(findIndentation(['\r line 1'])).toBe(' ');
});
});
49 changes: 49 additions & 0 deletions test/stripIndent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';

import { stripIndent } from '../src/stripIndent.js';

describe('stripIndent()', () => {
it('Removes the given indent after each line break', () => {
expect(stripIndent('line 1\n line 2\n line 3', ' ')).toBe('line 1\nline 2\nline 3');
});

it('Only removes the indent immediately following a line break, not elsewhere in the line', () => {
expect(stripIndent(' line 1\n line 2', ' ')).toBe(' line 1\nline 2');
});

it('Only removes indent that exactly matches', () => {
expect(stripIndent('line 1\n line 2', ' ')).toBe('line 1\n line 2');
});

it('Does not remove indentation shorter than the given indent', () => {
expect(stripIndent('line 1\n line 2', ' ')).toBe('line 1\n line 2');
});

it('Handles CRLF line breaks', () => {
expect(stripIndent('line 1\r\n line 2\r\n line 3', ' ')).toBe('line 1\r\nline 2\r\nline 3');
});

it('Handles CR line breaks', () => {
expect(stripIndent('line 1\r line 2\r line 3', ' ')).toBe('line 1\rline 2\rline 3');
});

it('Handles tabs as the indent', () => {
expect(stripIndent('line 1\n\tline 2\n\tline 3', '\t')).toBe('line 1\nline 2\nline 3');
});

it('Returns the input unchanged when the indent is an empty string', () => {
expect(stripIndent('line 1\n line 2', '')).toBe('line 1\n line 2');
});

it('Returns the input unchanged when there is nothing to strip', () => {
expect(stripIndent('line 1\nline 2', ' ')).toBe('line 1\nline 2');
});

it('Returns an empty string for empty input', () => {
expect(stripIndent('', ' ')).toBe('');
});

it('Strips indentation from multiple consecutive line breaks', () => {
expect(stripIndent('line 1\n \n line 2', ' ')).toBe('line 1\n\nline 2');
});
});
Loading