Skip to content

text-replace ​

Replaces text in every file with strings or regular expressions, with sync or async replacer functions that see the match and the file, then adds an optional header and footer. Source maps follow every change. Binary files are left alone. It replaces both gulp-replace and gulp-header/gulp-footer.

sh
npm install --save-dev @zilero/gulp-text-replace

Usage ​

ts
import { textReplace } from '@zilero/gulp-text-replace';
import { dest, src } from 'gulp';
import { pipeline } from 'node:stream/promises';

const version = '2.0.0';

export const release = (): Promise<void> =>
  pipeline(
    src('dist/**/*.{html,js}', { sourcemaps: true }),
    textReplace({
      rules: [
        { search: '__VERSION__', replace: version },
        { search: /https?:\/\/localhost:\d+/g, replace: 'https://example.com' }
      ],
      header: ({ file }) => `/*! ${file.relative} | MIT */\n`
    }),
    dest('release', { sourcemaps: '.' })
  );

A replacer function gets the match, its groups, its offset and the file, and can be async:

js
import { readFile } from 'node:fs/promises';

textReplace({
  rules: {
    search: /<!--\s*include:(?<name>[\w.-]+)\s*-->/g,
    replace: async ({ namedGroups }) => readFile(`src/partials/${namedGroups.name}`, 'utf8')
  }
});

Options ​

OptionTypeDefaultDescription
rulesReplaceRule | ReplaceRule[]noneOne rule or several, applied in order: each sees the result of the previous one.
headerstring | functionnoneAdded at the start of every file, after the rules and after a byte order mark. A function gets { file } and returns the text or a promise of it.
footerstring | functionnoneAdded at the end of every file, after the rules. Same forms as header.
skipBinarybooleantrueLeave files that contain a NUL byte (images, fonts, archives) untouched.

At least one of rules, header or footer is required ("Set at least one of rules, header or footer"). Unknown keys, an empty rules list and an empty search string are an error when the plugin is created.

A rule ​

KeyTypeDescription
searchstring | RegExpA string matches literally, everywhere it occurs. A RegExp follows JavaScript: add g for every match.
replacestring | functionThe replacement. With a RegExp, a string's $ patterns are filled in (see below). A function returns the replacement for one match.

The replacer function gets one object:

FieldDescription
matchThe matched text.
groupsCapture groups of a RegExp, in order; empty for a string search.
namedGroupsNamed capture groups.
offsetWhere the match starts in the contents.
fileThe Vinyl file.

What a replacer returns is converted with String(), as String.prototype.replace does: a number 2 is written as 2, null as null. undefined, which is what a replacer that forgot to return gives, emits an error instead (The replacer for /a/ returned undefined; return the replacement text), rather than deleting the match or writing undefined.

A header or footer function must return a string; anything else emits an error (header must return the text as a string, got undefined).

The package also exports the types TextReplaceOptions, ReplaceRule, Replacer, ReplacerInput and FileText.

Notes ​

  • A string search with a string replacement is literal on both sides: $&, $1 and other $ patterns are inserted as written, not interpreted.
  • With a RegExp, a string replacement fills in $&, $1…$99, $<name>, $`, $' and $$ the way String.prototype.replace does. $<name> for a group the RegExp does not have is empty, even for a name such as toString (there is no prototype lookup); in a RegExp without named groups it stays as written.
  • A RegExp without g replaces the first match only, as String.prototype.replace does; with a replacer function too. A global or sticky RegExp starts from the beginning in every file: its lastIndex never carries over.
  • Empty matches are replaced too: /^/gm with '> ' quotes every line.
  • A header goes after a leading byte order mark (U+FEFF), which stays the first character of the file.
  • A file counts as binary when its first 8000 bytes contain a NUL byte, the check git makes. With skipBinary: false, binary files are decoded as UTF-8 like the others.
  • Files without contents pass through; streamed contents (buffer: false) emit an error.
  • Replacers run one match at a time, in order, so an async replacer can rely on the order of the calls.
  • With src(…, { sourcemaps: true }), file.sourceMap follows every change (replacements, header, footer) and chains into the file's existing map, so it still points at the original text. When the file is the first step after src, the original text (sourcesContent) of the map src started is kept.
  • Source maps are built only for files that have one: files without file.sourceMap skip that work.

Recipes ​

A license banner from package.json:

ts
import pkg from './package.json' with { type: 'json' };

textReplace({ header: `/*! ${pkg.name} v${pkg.version} | ${pkg.license} */\n` });

Quote every line:

ts
textReplace({ rules: { search: /^/gm, replace: '> ' } });

Number the occurrences of a placeholder:

ts
textReplace({ rules: { search: '__ID__', replace: ({ offset }) => String(offset) } });

Migrating ​

Beforetext-replace
replace(search, replacement)textReplace({ rules: { search, replace: replacement } })
several .pipe(replace(…))textReplace({ rules: [ … ] }), one pass
replace(/(a)/g, function (match, p1, offset) { this.file })replace: ({ match, groups, offset, file }) => …
replace(search, replacement, { skipBinary: false })skipBinary: false
header('/*! <%= pkg.version %> */\n', { pkg })header: `/*! ${pkg.version} */\n` (no lodash templates)
footer(text)footer: text, or ({ file }) => text

Unlike gulp-header, the header goes after a byte order mark, and source maps follow every change without gulp-sourcemaps.

From 1.x ​

text-replace is new in 2.0: there is no 1.x to migrate from.

Released under the MIT License.