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.
npm install --save-dev @zilero/gulp-text-replaceUsage
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:
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
| Option | Type | Default | Description |
|---|---|---|---|
rules | ReplaceRule | ReplaceRule[] | none | One rule or several, applied in order: each sees the result of the previous one. |
header | string | function | none | Added 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. |
footer | string | function | none | Added at the end of every file, after the rules. Same forms as header. |
skipBinary | boolean | true | Leave 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
| Key | Type | Description |
|---|---|---|
search | string | RegExp | A string matches literally, everywhere it occurs. A RegExp follows JavaScript: add g for every match. |
replace | string | function | The 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:
| Field | Description |
|---|---|
match | The matched text. |
groups | Capture groups of a RegExp, in order; empty for a string search. |
namedGroups | Named capture groups. |
offset | Where the match starts in the contents. |
file | The 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:
$&,$1and other$patterns are inserted as written, not interpreted. - With a RegExp, a string replacement fills in
$&,$1…$99,$<name>,$`,$'and$$the wayString.prototype.replacedoes.$<name>for a group the RegExp does not have is empty, even for a name such astoString(there is no prototype lookup); in a RegExp without named groups it stays as written. - A RegExp without
greplaces the first match only, asString.prototype.replacedoes; with a replacer function too. A global or sticky RegExp starts from the beginning in every file: itslastIndexnever carries over. - Empty matches are replaced too:
/^/gmwith'> '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.sourceMapfollows 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 aftersrc, the original text (sourcesContent) of the mapsrcstarted is kept. - Source maps are built only for files that have one: files without
file.sourceMapskip that work.
Recipes
A license banner from package.json:
import pkg from './package.json' with { type: 'json' };
textReplace({ header: `/*! ${pkg.name} v${pkg.version} | ${pkg.license} */\n` });Quote every line:
textReplace({ rules: { search: /^/gm, replace: '> ' } });Number the occurrences of a placeholder:
textReplace({ rules: { search: '__ID__', replace: ({ offset }) => String(offset) } });Migrating
From gulp-replace, gulp-header and gulp-footer
| Before | text-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.