/** * Jdenticon * https://github.com/dmester/jdenticon * Copyright © Daniel Mester Pirttijärvi */ const { Transform } = require("stream"); const { SourceMapConsumer, SourceMapGenerator } = require("source-map"); /** * Finds substrings and replaces them with other strings, keeping any input source map up-to-date. * * @example * const replacement = new Replacement([ * ["find this", "replace with this"], * [/find this/gi, "replace with this"] * ]); * replacement.replace(input, inputSourceMap); * * @example * const replacement = new Replacement("find this", "replace with this"); * replacement.replace(input, inputSourceMap); */ class Replacement { constructor(...definition) { /** * @type {function(Array, string): void} */ this._matchers = []; /** * @type {Array} */ this._ranges = []; this.add(definition); } /** * @param {string} input * @returns {Array} */ matchAll(input) { const ranges = [...this._ranges]; this._matchers.forEach(matcher => matcher(ranges, input)); let lastReplacement; return ranges .sort((a, b) => a.start - b.start) .filter(replacement => { if (!lastReplacement || lastReplacement.end <= replacement.start) { lastReplacement = replacement; return true; } }); } /** * @param {string} input * @param {SourceMap=} inputSourceMap * @returns {{ output: string, sourceMap: SourceMap }} */ replace(input, inputSourceMap) { const ranges = this.matchAll(input); const offset = new Offset(); const reader = new InputReader(input); const sourceMap = new SourceMapSpooler(inputSourceMap); const output = []; if (sourceMap.isEmpty()) { sourceMap.initEmpty(reader.lines); } ranges.forEach((range, rangeIndex) => { output.push(reader.readTo(range.start)); output.push(range.replacement); const inputStart = reader.pos; const replacedText = reader.readTo(range.end); if (replacedText === range.replacement) { return; // Nothing to do } const inputEnd = reader.pos; const replacementLines = range.replacement.split(/\n/g); const lineDifference = replacementLines.length + inputStart.line - inputEnd.line - 1; const outputStart = { line: inputStart.line + offset.lineOffset, column: inputStart.column + offset.getColumnOffset(inputStart.line) }; const outputEnd = { line: inputEnd.line + offset.lineOffset + lineDifference, column: replacementLines.length > 1 ? replacementLines[replacementLines.length - 1].length : inputStart.column + offset.getColumnOffset(inputStart.line) + range.replacement.length } sourceMap.spoolTo(inputStart.line, inputStart.column, offset); offset.lineOffset += lineDifference; offset.setColumnOffset(inputEnd.line, outputEnd.column - inputEnd.column); if (range.name || replacementLines.length === 1 && range.replacement) { const mappingBeforeStart = sourceMap.lastMapping(); const mappingAfterStart = sourceMap.nextMapping(); if (mappingAfterStart && mappingAfterStart.generatedLine === inputStart.line && mappingAfterStart.generatedColumn === inputStart.column ) { sourceMap.addMapping({ original: { line: mappingAfterStart.originalLine, column: mappingAfterStart.originalColumn, }, generated: { line: outputStart.line, column: outputStart.column }, source: mappingAfterStart.source, name: range.name, }); } else if (mappingBeforeStart && mappingBeforeStart.generatedLine === inputStart.line) { sourceMap.addMapping({ original: { line: mappingBeforeStart.originalLine + inputStart.line - mappingBeforeStart.generatedLine, column: mappingBeforeStart.originalColumn + inputStart.column - mappingBeforeStart.generatedColumn, }, generated: { line: outputStart.line, column: outputStart.column }, source: mappingBeforeStart.source, name: range.name, }); } } else if (range.replacement) { // Map longer replacements to a virtual file defined in the source map const generatedSourceName = sourceMap.addSourceContent(replacedText, range.replacement); for (var i = 0; i < replacementLines.length; i++) { // Don't map empty lines if (replacementLines[i]) { sourceMap.addMapping({ original: { line: i + 1, column: 0, }, generated: { line: outputStart.line + i, column: i ? 0 : outputStart.column, }, source: generatedSourceName, }); } } } sourceMap.skipTo(inputEnd.line, inputEnd.column, offset); // Add a source map node directly after the replacement to terminate the replacement const mappingAfterEnd = sourceMap.nextMapping(); const mappingBeforeEnd = sourceMap.lastMapping(); if (mappingAfterEnd && mappingAfterEnd.generatedLine === inputEnd.line && mappingAfterEnd.generatedColumn === inputEnd.column ) { // No extra source map node needed when the replacement is directly followed by another node } else if (rangeIndex + 1 < ranges.length && range.end === ranges[rangeIndex + 1].start) { // The next replacement range is adjacent to this one } else if (reader.endOfLine()) { // End of line, no point in adding a following node } else if (!mappingBeforeEnd || mappingBeforeEnd.generatedLine !== inputEnd.line) { // No applicable preceding node found } else { sourceMap.addMapping({ original: { line: mappingBeforeEnd.originalLine + inputEnd.line - mappingBeforeEnd.generatedLine, column: mappingBeforeEnd.originalColumn + inputEnd.column - mappingBeforeEnd.generatedColumn, }, generated: { line: outputEnd.line, column: outputEnd.column }, source: mappingBeforeEnd.source, }); } }); // Flush remaining input to output and source map output.push(reader.readToEnd()); sourceMap.spoolToEnd(offset); return { output: output.join(""), sourceMap: sourceMap.toJSON(), }; } add(value) { const target = this; function addRecursive(innerValue) { if (innerValue != null) { if (Array.isArray(innerValue)) { const needle = innerValue[0]; if (typeof needle === "string") { target.addText(...innerValue); } else if (needle instanceof RegExp) { target.addRegExp(...innerValue); } else { innerValue.forEach(addRecursive); } } else if (innerValue instanceof Replacement) { target._matchers.push(innerValue._matchers); target._ranges.push(innerValue._ranges); } else if (typeof innerValue === "object") { target.addRange(innerValue); } else { throw new Error("Unknown replacement argument specified."); } } } addRecursive(value); } /** * @param {RegExp} re * @param {string|function(string, ...):string} replacement * @param {{ name: string }=} rangeOpts */ addRegExp(re, replacement, rangeOpts) { const replacementFactory = this._createReplacementFactory(replacement); this._matchers.push((ranges, input) => { const isGlobalRegExp = /g/.test(re.flags); let match; let isFirstIteration = true; while ((isFirstIteration || isGlobalRegExp) && (match = re.exec(input))) { ranges.push(new OverwriteRange({ ...rangeOpts, start: match.index, end: match.index + match[0].length, replacement: replacementFactory(match, match.index, input), })); isFirstIteration = false; } }); } /** * @param {string} needle * @param {string|function(string, ...):string} replacement * @param {{ name: string }=} rangeOpts */ addText(needle, replacement, rangeOpts) { const replacementFactory = this._createReplacementFactory(replacement); this._matchers.push((ranges, input) => { let index = -needle.length; while ((index = input.indexOf(needle, index + needle.length)) >= 0) { ranges.push(new OverwriteRange({ ...rangeOpts, start: index, end: index + needle.length, replacement: replacementFactory([needle], index, input), })); } }); } /** * @param {OverwriteRange} range */ addRange(range) { this._ranges.push(new OverwriteRange(range)); } /** * @param {string|function(string, ...):string} replacement * @returns {function(Array, number, string):string} */ _createReplacementFactory(replacement) { if (typeof replacement === "function") { return (match, index, input) => replacement(...match, index, input); } if (replacement == null) { return () => ""; } replacement = replacement.toString(); if (replacement.indexOf("$") < 0) { return () => replacement; } return (match, index, input) => replacement.replace(/\$(\d+|[$&`'])/g, matchedPattern => { if (matchedPattern === "$$") { return "$"; } if (matchedPattern === "$&") { return match[0]; } if (matchedPattern === "$`") { return input.substring(0, index); } if (matchedPattern === "$'") { return input.substring(index + match[0].length); } const matchArrayIndex = Number(matchedPattern.substring(1)); return match[matchArrayIndex]; }); } } class InputReader { /** * @param {string} input */ constructor (input) { // Find index of all line breaks const lineBreakIndexes = []; let index = -1; while ((index = input.indexOf("\n", index + 1)) >= 0) { lineBreakIndexes.push(index); } this._input = input; this._inputCursorExclusive = 0; this._output = []; this._lineBreakIndexes = lineBreakIndexes; /** * Number of lines in the input file. * @type {number} */ this.lines = this._lineBreakIndexes.length + 1; /** * Position of the input cursor. Line number is one-based and column number is zero-based. * @type {{ line: number, column: number }} */ this.pos = { line: 1, column: 0 }; } readTo(exclusiveIndex) { let result = ""; if (this._inputCursorExclusive < exclusiveIndex) { result = this._input.substring(this._inputCursorExclusive, exclusiveIndex); this._inputCursorExclusive = exclusiveIndex; this._updatePos(); } return result; } readToEnd() { return this.readTo(this._input.length); } endOfLine() { const nextChar = this._input[this._inputCursorExclusive]; return !nextChar || nextChar === "\r" || nextChar === "\n"; } _updatePos() { let line = this.pos.line; while ( line - 1 < this._lineBreakIndexes.length && this._lineBreakIndexes[line - 1] < this._inputCursorExclusive ) { line++; } const lineStartIndex = this._lineBreakIndexes[line - 2]; const column = this._inputCursorExclusive - (lineStartIndex || -1) - 1; this.pos = { line, column }; } } class SourceMapSpooler { /** * @param {SourceMap=} inputSourceMap */ constructor(inputSourceMap) { let generator; let file; let sources; let mappings = []; if (inputSourceMap) { if (!(inputSourceMap instanceof SourceMapConsumer)) { inputSourceMap = new SourceMapConsumer(inputSourceMap); } generator = new SourceMapGenerator({ file: inputSourceMap.file, sourceRoot: inputSourceMap.sourceRoot, }); inputSourceMap.sources.forEach(function(sourceFile) { const content = inputSourceMap.sourceContentFor(sourceFile); if (content != null) { generator.setSourceContent(sourceFile, content); } }); inputSourceMap.eachMapping(mapping => { mappings.push(mapping); }); mappings.sort((a, b) => a.generatedLine == b.generatedLine ? a.generatedColumn - b.generatedColumn : a.generatedLine - b.generatedLine); file = inputSourceMap.file; sources = inputSourceMap.sources; } else { generator = new SourceMapGenerator(); file = "input"; sources = []; } this._generator = generator; this._sources = new Set(sources); this._file = file; this._mappingsCursor = 0; this._mappings = mappings; this._contents = new Map(); } lastMapping() { return this._mappings[this._mappingsCursor - 1]; } nextMapping() { return this._mappings[this._mappingsCursor]; } addMapping(mapping) { this._generator.addMapping(mapping); } isEmpty() { return this._mappings.length === 0; } initEmpty(lines) { this._mappings = []; for (var i = 0; i < lines; i++) { this._mappings.push({ originalLine: i + 1, originalColumn: 0, generatedLine: i + 1, generatedColumn: 0, source: this._file }); } } addSourceContent(replacedText, content) { let sourceName = this._contents.get(content); if (!sourceName) { const PREFIX = "replacement/"; let sourceNameWithoutNumber = PREFIX; sourceName = sourceNameWithoutNumber + "1"; if (replacedText.length > 0 && replacedText.length < 25) { replacedText = replacedText .replace(/^[^0-9a-z-_]+|[^0-9a-z-_]+$/ig, "") .replace(/\s+/g, "-") .replace(/[^0-9a-z-_]/ig, ""); if (replacedText) { sourceNameWithoutNumber = PREFIX + replacedText + "-"; sourceName = PREFIX + replacedText; } } let counter = 2; while (this._sources.has(sourceName)) { sourceName = sourceNameWithoutNumber + counter++; } this._sources.add(sourceName); this._contents.set(content, sourceName); this._generator.setSourceContent(sourceName, content); } return sourceName; } /** * Copies source map info from input to output up to but not including the specified position. * @param {number} line * @param {number} column * @param {Offset} offset */ spoolTo(line, column, offset) { this._consume(line, column, offset, true); } /** * Copies remaining source map info from input to output. * @param {number} line * @param {number} column * @param {Offset} offset */ spoolToEnd(offset) { this._consume(null, null, offset, true); } /** * Discards source map info from input up to but not including the specified position. * @param {number} line * @param {number} column * @param {Offset} offset */ skipTo(line, column, offset) { this._consume(line, column, offset, false); } toJSON() { return this._generator.toJSON(); } _consume(line, column, offset, keep) { let mapping; while ( (mapping = this._mappings[this._mappingsCursor]) && ( line == null || mapping.generatedLine < line || mapping.generatedLine == line && mapping.generatedColumn < column ) ) { if (keep) { this._generator.addMapping({ original: { line: mapping.originalLine, column: mapping.originalColumn, }, generated: { line: mapping.generatedLine + offset.lineOffset, column: mapping.generatedColumn + offset.getColumnOffset(mapping.generatedLine), }, source: mapping.source, name: mapping.name, }); } this._mappingsCursor++; } } } class Offset { constructor() { this.lineOffset = 0; this._columnOffset = 0; this._columnOffsetForLine = 0; } setColumnOffset(lineNumber, columnOffset) { this._columnOffsetForLine = lineNumber; this._columnOffset = columnOffset; } getColumnOffset(lineNumber) { return this._columnOffsetForLine === lineNumber ? this._columnOffset : 0; } } class OverwriteRange { constructor(options) { if (!isFinite(options.start)) { throw new Error("A replacement start index is required."); } if (!isFinite(options.end)) { throw new Error("A replacement end index is required."); } if (options.end < options.start) { throw new Error("Replacement end index cannot precede its start index."); } /** * Inclusive start index. * @type {number} */ this.start = options.start; /** * Exclusive start index. * @type {number} */ this.end = options.end; /** * The replacement interval will be replaced with this string. * @type string */ this.replacement = "" + options.replacement; /** * Optional name that will be mapped in the source map. * @type string */ this.name = options.name; } } function gulp(replacements) { if (typeof replacements === "string" || replacements instanceof RegExp) { replacements = Array.from(arguments); } const replacer = new Replacement(replacements); return new Transform({ objectMode: true, transform(inputFile, _, fileDone) { const input = inputFile.contents.toString(); const output = replacer.replace(input, inputFile.sourceMap); inputFile.contents = Buffer.from(output.output); if (inputFile.sourceMap) { inputFile.sourceMap = output.sourceMap; } fileDone(null, inputFile); } }); } module.exports = { Replacement, gulp };