codeschmiede-toolkit
Version:
A/B Test Development Toolkit
380 lines (300 loc) • 10.3 kB
JavaScript
;
const fs = require('fs');
const path = require('path');
const acorn = require('acorn');
const walk = require('acorn-walk');
const RAW_LINE_BREAK_PATTERN = /\r\n|\r|\n/g;
const RAW_LINE_BREAK_TEST_PATTERN = /\r\n|\r|\n/;
const SOURCE_LINE_BREAK_PATTERN = /\r|\n/;
const DECODER_CACHE = new Map();
function detectLineEnding(source) {
const match = source.match(/\r\n|\r|\n/);
return match ? match[0] : '\n';
}
function getLineIndent(source, index) {
let lineStart = index;
while (lineStart > 0 && !SOURCE_LINE_BREAK_PATTERN.test(source[lineStart - 1])) {
lineStart -= 1;
}
const prefix = source.slice(lineStart, index);
const indentMatch = prefix.match(/^\s*/u);
return indentMatch ? indentMatch[0] : '';
}
function decodeTemplateChunk(rawChunk) {
if (DECODER_CACHE.has(rawChunk)) {
return DECODER_CACHE.get(rawChunk);
}
const decoded = Function(`return \`${rawChunk}\`;`)();
DECODER_CACHE.set(rawChunk, decoded);
return decoded;
}
function pushTextFragment(lines, text) {
if (text.length === 0) {
return;
}
lines[lines.length - 1].push({
type: 'text',
value: text,
});
}
function appendRawQuasi(lines, rawValue) {
let lastIndex = 0;
rawValue.replace(RAW_LINE_BREAK_PATTERN, (match, offset) => {
const rawChunk = rawValue.slice(lastIndex, offset);
pushTextFragment(lines, decodeTemplateChunk(rawChunk));
lines.push([]);
lastIndex = offset + match.length;
return match;
});
const lastChunk = rawValue.slice(lastIndex);
pushTextFragment(lines, decodeTemplateChunk(lastChunk));
}
function isBlankLine(line) {
return !line.some((fragment) => fragment.type === 'expression') &&
line.every((fragment) => fragment.type !== 'text' || fragment.value.trim() === '');
}
function trimBoundaryLines(lines) {
const result = lines.slice();
while (result.length > 0 && isBlankLine(result[0])) {
result.shift();
}
while (result.length > 0 && isBlankLine(result[result.length - 1])) {
result.pop();
}
return result;
}
function trimLineStart(line) {
let shouldTrim = true;
const fragments = [];
for (const fragment of line) {
if (!shouldTrim) {
fragments.push(fragment);
continue;
}
if (fragment.type === 'expression') {
shouldTrim = false;
fragments.push(fragment);
continue;
}
const trimmedValue = fragment.value.replace(/^\s+/u, '');
if (trimmedValue.length === 0) {
continue;
}
fragments.push({
type: 'text',
value: trimmedValue,
});
shouldTrim = false;
}
return fragments;
}
function escapeSingleQuotedText(value) {
return value
.replace(/\\/g, '\\\\')
.replace(/'/g, '\\\'')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
function escapeTemplateText(value) {
return value
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$\{/g, '\\${')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
function buildLineLiteral(line) {
const hasExpression = line.some((fragment) => fragment.type === 'expression');
if (!hasExpression) {
const textValue = line
.filter((fragment) => fragment.type === 'text')
.map((fragment) => fragment.value)
.join('');
return `'${escapeSingleQuotedText(textValue)}'`;
}
const templateBody = line.map((fragment) => {
if (fragment.type === 'expression') {
return `\${${fragment.value}}`;
}
return escapeTemplateText(fragment.value);
}).join('');
return `\`${templateBody}\``;
}
function sliceWithReplacements(source, start, end, replacements) {
const sliceReplacements = replacements
.filter((replacement) => replacement.start >= start && replacement.end <= end)
.sort((left, right) => right.start - left.start);
let result = source.slice(start, end);
for (const replacement of sliceReplacements) {
const relativeStart = replacement.start - start;
const relativeEnd = replacement.end - start;
result = `${result.slice(0, relativeStart)}${replacement.value}${result.slice(relativeEnd)}`;
}
return result;
}
function isTaggedTemplate(node, parent) {
return Boolean(parent && parent.type === 'TaggedTemplateExpression' && parent.quasi === node);
}
function buildTemplateReplacement(node, source, replacements, parent) {
const original = source.slice(node.start, node.end);
if (isTaggedTemplate(node, parent) || !RAW_LINE_BREAK_TEST_PATTERN.test(original)) {
return null;
}
const lines = [[]];
for (let index = 0; index < node.quasis.length; index += 1) {
appendRawQuasi(lines, node.quasis[index].value.raw);
if (index < node.expressions.length) {
lines[lines.length - 1].push({
type: 'expression',
value: sliceWithReplacements(
source,
node.expressions[index].start,
node.expressions[index].end,
replacements
),
});
}
}
const normalizedLines = trimBoundaryLines(lines).map(trimLineStart);
const lineLiterals = normalizedLines.map(buildLineLiteral);
if (lineLiterals.length === 0) {
return '\'\'';
}
if (lineLiterals.length === 1) {
return lineLiterals[0];
}
const eol = detectLineEnding(source);
const continuationIndent = `${getLineIndent(source, node.start)} `;
return lineLiterals.join(` +${eol}${continuationIndent}`);
}
function collectTemplateLiterals(ast) {
const templates = [];
walk.ancestor(ast, {
TemplateLiteral(node, ancestors) {
templates.push({
node,
parent: ancestors[ancestors.length - 2] || null,
});
},
});
return templates.sort((left, right) => {
const leftSize = left.node.end - left.node.start;
const rightSize = right.node.end - right.node.start;
return leftSize - rightSize;
});
}
function transformSource(source) {
const ast = acorn.parse(source, {
ecmaVersion: 'latest',
sourceType: 'module',
allowHashBang: true,
});
const replacements = [];
for (const template of collectTemplateLiterals(ast)) {
const replacement = buildTemplateReplacement(template.node, source, replacements, template.parent);
if (!replacement || replacement === source.slice(template.node.start, template.node.end)) {
continue;
}
replacements.push({
start: template.node.start,
end: template.node.end,
value: replacement,
});
}
if (replacements.length === 0) {
return source;
}
return replacements
.sort((left, right) => right.start - left.start)
.reduce((currentSource, replacement) => {
return `${currentSource.slice(0, replacement.start)}${replacement.value}${currentSource.slice(replacement.end)}`;
}, source);
}
function collectJavaScriptFiles(rootDir) {
if (!fs.existsSync(rootDir)) {
return [];
}
const files = [];
const entries = fs.readdirSync(rootDir, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
if (entry.name === 'node_modules' || entry.name === 'vendor') {
continue;
}
const entryPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
files.push(...collectJavaScriptFiles(entryPath));
continue;
}
if (entry.isFile() && entry.name.endsWith('.js')) {
files.push(entryPath);
}
}
return files;
}
function processFile(filePath) {
try {
const originalSource = fs.readFileSync(filePath, 'utf8');
const transformedSource = transformSource(originalSource);
if (transformedSource === originalSource) {
return {
filePath,
status: 'unchanged',
};
}
fs.writeFileSync(filePath, transformedSource, 'utf8');
return {
filePath,
status: 'updated',
};
} catch (error) {
return {
filePath,
status: 'error',
error,
};
}
}
function runInlineHtml(options = {}) {
const cwd = options.cwd || process.cwd();
const inputDir = options.inputDir || path.join(cwd, 'src');
const logger = options.logger || console;
const files = collectJavaScriptFiles(inputDir);
if (files.length === 0) {
logger.log(`No JavaScript files found in ${inputDir}`);
return {
files,
results: [],
updated: 0,
unchanged: 0,
errors: 0,
};
}
const results = files.map(processFile);
const updated = results.filter((result) => result.status === 'updated');
const unchanged = results.filter((result) => result.status === 'unchanged');
const errors = results.filter((result) => result.status === 'error');
for (const result of updated) {
logger.log(`updated ${path.relative(cwd, result.filePath) || result.filePath}`);
}
for (const result of unchanged) {
logger.log(`unchanged ${path.relative(cwd, result.filePath) || result.filePath}`);
}
for (const result of errors) {
logger.error(`error ${path.relative(cwd, result.filePath) || result.filePath}: ${result.error.message}`);
}
logger.log(`Processed ${results.length} file(s): ${updated.length} updated, ${unchanged.length} unchanged, ${errors.length} error(s).`);
return {
files,
results,
updated: updated.length,
unchanged: unchanged.length,
errors: errors.length,
};
}
module.exports = {
collectJavaScriptFiles,
processFile,
runInlineHtml,
transformSource,
};