UNPKG

codeschmiede-toolkit

Version:

A/B Test Development Toolkit

380 lines (300 loc) 10.3 kB
'use strict'; 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, };