提交qiming-dev-inject

This commit is contained in:
Codex
2026-06-01 12:53:19 +08:00
parent 96af3b324b
commit 9e9486b7c2
76 changed files with 6397 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
export function parseArgs(args) {
const result = {
command: null,
options: {},
};
// 解析命令
if (args[0] === 'install' || args[0] === 'uninstall') {
result.command = args[0];
args = args.slice(1);
} else if (args.length === 0) {
return result;
}
// 解析选项
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--remote=')) {
result.options.remote = arg.split('=')[1];
} else if (arg === '--remote' && i + 1 < args.length) {
result.options.remote = args[i + 1];
i++; // 跳过下一个参数
} else if (arg.startsWith('--file=')) {
result.options.file = arg.split('=')[1];
} else if (arg === '--file' && i + 1 < args.length) {
result.options.file = args[i + 1];
i++; // 跳过下一个参数
} else if (arg === '--dry-run') {
result.options.dryRun = true;
} else if (arg === '--verbose') {
result.options.verbose = true;
} else if (arg === '--framework' || arg === '-f') {
result.options.framework = true;
} else if (!arg.startsWith('--')) {
// 未知参数,可能是错误输入
console.warn(`⚠️ 忽略未知参数: ${arg}`);
}
}
// 为 install 命令设置 remote 默认值
if (result.command === 'install' && !result.options.remote) {
result.options.remote = '/sdk/dev-monitor.js';
}
return result;
}

View File

@@ -0,0 +1,112 @@
/**
* 代码清理工具类 - 清理多余的空白字符和逗号
*/
export class CodeCleaner {
/**
* 清理多余的空白行
* @param {string} content - 要清理的内容
* @param {boolean} preserveDoubleNewline - 是否保留双换行(默认 true
* @returns {string} 清理后的内容
*/
static cleanBlankLines(content, preserveDoubleNewline = true) {
if (preserveDoubleNewline) {
// 保留单行空白,清理多余的连续空白行
return content.replace(/\n\s*\n\s*\n+/g, '\n\n');
} else {
// 清理所有多余空白行,只保留单个换行
return content.replace(/\n\s*\n+/g, '\n');
}
}
/**
* 清理行尾的空白字符
* @param {string} content - 要清理的内容
* @returns {string} 清理后的内容
*/
static cleanTrailingSpaces(content) {
return content.replace(/[ \t]+$/gm, '');
}
/**
* 清理多余的逗号
* @param {string} content - 要清理的内容
* @returns {string} 清理后的内容
*/
static cleanCommas(content) {
// 1. 清理单独的逗号行(整行都是逗号和空白)
content = content.replace(/^\s*,\s*\n/gm, '');
// 2. 清理行首的逗号和空白
content = content.replace(/^\s*,\s+/gm, '');
// 3. 清理连续的多个逗号
content = content.replace(/,\s*,\s*/g, ',');
return content;
}
/**
* 清理空的标签对
* @param {string} content - 要清理的内容
* @param {string} tagName - 标签名
* @returns {string} 清理后的内容
*/
static cleanEmptyTags(content, tagName) {
const regex = new RegExp(`<${tagName}[^>]*>\\s*</${tagName}>`, 'g');
return content.replace(regex, '');
}
/**
* 清理标签中间的空白行
* @param {string} content - 要清理的内容
* @param {string} tagName - 标签名(可以是正则表达式)
* @returns {string} 清理后的内容
*/
static cleanTagWhitespace(content, tagName) {
// 清理如 <Head>\n\n\n</Head> -> <Head>\n</Head>
content = content.replace(
new RegExp(`(<${tagName}[^>]*>)\\s*\\n\\s*\\n(\\s*</${tagName}>)`, 'g'),
'$1\n$2'
);
return content;
}
/**
* 综合清理:清理所有常见的代码格式问题
* @param {string} content - 要清理的内容
* @param {object} options - 清理选项
* @returns {string} 清理后的内容
*/
static cleanAll(content, options = {}) {
const {
blankLines = true,
trailingSpaces = true,
commas = false,
emptyTags = true,
tagWhitespace = true
} = options;
if (blankLines) {
content = this.cleanBlankLines(content);
}
if (trailingSpaces) {
content = this.cleanTrailingSpaces(content);
}
if (commas) {
content = this.cleanCommas(content);
}
if (emptyTags) {
['head', 'Head'].forEach(tag => {
content = this.cleanEmptyTags(content, tag);
});
}
if (tagWhitespace) {
['Head', 'head'].forEach(tag => {
content = this.cleanTagWhitespace(content, tag);
});
}
return content;
}
}

View File

@@ -0,0 +1,133 @@
import fs from 'fs';
import path from 'path';
import { lookupFiles } from './utils.js';
/**
* 文件查找工具类 - 支持多种后缀名尝试机制
*/
export class FileFinder {
/**
* 查找具有多种后缀名的文件
* @param {string} basePath - 基础路径(不包含后缀)
* @param {string[]} extensions - 后缀名数组,按优先级排序
* @param {string} startDir - 起始目录
* @returns {string|null} 找到的文件路径或 null
*/
static findWithExtensions(basePath, extensions = ['.tsx', '.ts', '.jsx', '.js'], startDir = process.cwd()) {
for (const ext of extensions) {
const fullPath = path.join(startDir, basePath + ext);
if (fs.existsSync(fullPath)) {
return basePath + ext;
}
}
return null;
}
/**
* 查找多个候选文件
* @param {string[]} candidates - 候选文件路径数组
* @param {string} startDir - 起始目录
* @returns {string[]} 找到的文件路径数组
*/
static findMultiple(candidates, startDir = process.cwd()) {
const foundFiles = [];
for (const candidate of candidates) {
if (candidate === '*.html') {
// 特殊处理:查找所有 HTML 文件
const htmlFiles = lookupFiles(startDir);
foundFiles.push(...htmlFiles);
} else if (fs.existsSync(path.join(startDir, candidate))) {
foundFiles.push(candidate);
}
}
return foundFiles;
}
/**
* 查找框架特定的入口文件
* @param {string} projectType - 项目类型
* @param {string} startDir - 起始目录
* @returns {string[]} 找到的文件路径数组
*/
static findFrameworkEntry(projectType, startDir = process.cwd()) {
const foundFiles = [];
switch (projectType) {
case 'vite':
// 尝试查找 vite.config 文件
const viteConfig = this.findWithExtensions('vite.config', ['.js', '.ts', '.mjs'], startDir);
if (viteConfig) {
foundFiles.push(viteConfig);
}
break;
case 'next-pages':
// 尝试查找 _document 文件
const documentFile = this.findWithExtensions('pages/_document', ['.tsx', '.jsx', '.ts', '.js'], startDir);
if (documentFile) {
foundFiles.push(documentFile);
}
// 尝试查找 _app 文件
const appFile = this.findWithExtensions('pages/_app', ['.tsx', '.jsx', '.ts', '.js'], startDir);
if (appFile) {
foundFiles.push(appFile);
}
break;
case 'next-app':
// 尝试查找 layout 文件
const layoutFile = this.findWithExtensions('app/layout', ['.tsx', '.jsx', '.ts', '.js'], startDir);
if (layoutFile) {
foundFiles.push(layoutFile);
}
// 尝试查找 globals.css
const globalsCss = 'app/globals.css';
if (fs.existsSync(path.join(startDir, globalsCss))) {
foundFiles.push(globalsCss);
}
break;
case 'next-hybrid':
// 尝试查找 app/layout 文件
const appLayoutFile = this.findWithExtensions('app/layout', ['.tsx', '.jsx', '.ts', '.js'], startDir);
if (appLayoutFile) {
foundFiles.push(appLayoutFile);
}
// 尝试查找 pages/_document 文件
const pagesDocumentFile = this.findWithExtensions('pages/_document', ['.tsx', '.jsx', '.ts', '.js'], startDir);
if (pagesDocumentFile) {
foundFiles.push(pagesDocumentFile);
}
break;
case 'create-react-app':
// 尝试查找 HTML 文件
const htmlFiles = ['public/index.html', 'index.html'];
foundFiles.push(...this.findMultiple(htmlFiles, startDir));
break;
case 'html-static':
// 尝试查找 HTML 文件
const staticHtmlFiles = ['index.html', '*.html'];
foundFiles.push(...this.findMultiple(staticHtmlFiles, startDir));
break;
default:
// 默认查找 HTML 文件
const defaultHtmlFiles = ['index.html', '*.html'];
foundFiles.push(...this.findMultiple(defaultHtmlFiles, startDir));
break;
}
return foundFiles;
}
}
// 导出便捷方法
export function findFrameworkEntry(projectType, startDir = process.cwd()) {
return FileFinder.findFrameworkEntry(projectType, startDir);
}

View File

@@ -0,0 +1,850 @@
import {
readFile,
writeFile,
parseRemoteType,
generateScriptTag,
injectScriptToHtml,
removeInjectedScripts,
log,
logInfo,
logSuccess,
logError
} from './utils.js';
import { FileFinder } from './file-finder.js';
import { CodeCleaner } from './code-cleaner.js';
import fs from 'fs';
import path from 'path';
// 检测项目类型
export function detectProjectType(startDir = process.cwd()) {
const indicators = {
'vite': ['vite.config.js', 'vite.config.ts'],
'next-pages': ['next.config.js', 'pages/'],
'next-app': ['next.config.js', 'app/'],
'create-react-app': ['public/index.html', 'src/'],
'webpack': ['webpack.config.js'],
'html-static': ['index.html', '.html']
};
try {
// 按优先级顺序检测
const priorityOrder = ['vite', 'next-app', 'next-pages', 'create-react-app', 'webpack', 'html-static'];
for (const type of priorityOrder) {
const files = indicators[type];
for (const file of files) {
if (fs.existsSync(path.join(startDir, file))) {
// 特殊处理 Next.js
if (type.startsWith('next-')) {
const hasPages = fs.existsSync(path.join(startDir, 'pages'));
const hasApp = fs.existsSync(path.join(startDir, 'app'));
if (hasApp && hasPages) {
return 'next-hybrid'; // 两个目录都存在
} else if (hasApp) {
return 'next-app';
} else if (hasPages) {
return 'next-pages';
}
}
return type;
}
}
}
} catch (error) {
logError(`项目类型检测失败: ${error.message}`);
}
return 'unknown';
}
// 查找框架特定的入口文件 - 支持多次尝试机制
export function findFrameworkEntry(projectType, startDir = process.cwd()) {
return FileFinder.findFrameworkEntry(projectType, startDir);
}
// 为 Vite 项目注入脚本插件
export function injectToViteConfig(filePath, remote, scriptTag) {
try {
// 检查文件是否存在
if (!fs.existsSync(path.join(process.cwd(), filePath))) {
log(`Vite 配置文件 ${filePath} 不存在,跳过`, true);
return false;
}
let content = readFile(filePath);
// 先移除之前的注入(如果存在)- 支持新旧两种格式
// 使用字符串查找方式,确保能移除所有注入块(包括多个重复的情况)
const startMarker = '// <!-- DEV-INJECT-START -->';
const endMarker = '// <!-- DEV-INJECT-END -->';
let startIndex = content.indexOf(startMarker);
while (startIndex !== -1) {
const endIndex = content.indexOf(endMarker, startIndex);
if (endIndex !== -1) {
// 找到结束标记,移除从开始标记到结束标记后的所有内容
// 注意:逗号在 })() 和注释结束之间,所以会被包含在移除范围内
let removeEnd = endIndex + endMarker.length;
// 移除结束标记后的空白和换行
while (removeEnd < content.length && (content[removeEnd] === ' ' || content[removeEnd] === '\t' || content[removeEnd] === '\n')) {
removeEnd++;
}
// 移除整个块:从开始标记(包括前面的换行和空白)到结束标记后
// 向前查找,移除开始标记前的换行和空白
let removeStart = startIndex;
while (removeStart > 0 && (content[removeStart - 1] === ' ' || content[removeStart - 1] === '\t' || content[removeStart - 1] === '\n')) {
removeStart--;
if (content[removeStart] === '\n') {
removeStart++;
break;
}
}
content = content.slice(0, removeStart) + content.slice(removeEnd);
} else {
// 没有找到结束标记,只移除开始标记
content = content.slice(0, startIndex) + content.slice(startIndex + startMarker.length);
}
// 继续查找下一个注入块
startIndex = content.indexOf(startMarker);
}
// 移除旧版格式(向后兼容)
content = content.replace(
/\/\/\s*dev-inject-plugin[\s\S]*?}\s*}\s*,?\s*/g,
''
);
// 清理多余的逗号和空行
// 移除连续的逗号
content = content.replace(/,\s*,/g, ',');
// 移除数组开头的逗号
content = content.replace(/\[\s*,/g, '[');
// 移除数组元素之间多余的逗号
content = content.replace(/,\s*\)/g, ')');
content = content.replace(/,\s*\n\s*\]/g, '\n ]');
content = content.replace(/,\s*\n\s*\[/g, ' [');
// 清理多余的空行(保留最多一个空行)
content = content.replace(/\n\s*\n\s*\n+/g, '\n\n');
// 生成插件代码,传入原始 remote URL 而不是 scriptTag
const pluginCode = generateVitePlugin(remote, scriptTag);
// 查找 plugins 数组,并在其中插入插件
const pluginsMatch = content.match(/(plugins:\s*\[)([\s\S]*?)(\])/);
if (pluginsMatch) {
const beforePlugins = pluginsMatch[1];
const pluginsContent = pluginsMatch[2].trim();
const afterPlugins = pluginsMatch[3];
// 插入插件到 plugins 数组开头
const newPlugins = beforePlugins + '\n ' + pluginCode + '\n ' + pluginsContent + '\n ' + afterPlugins;
content = content.replace(pluginsMatch[0], newPlugins);
} else {
// 如果没有 plugins 数组,需要创建
log(`未找到 plugins 配置Vite 配置可能不标准: ${filePath}`, true);
return false;
}
writeFile(filePath, content);
logSuccess(`已向 ${filePath} 注入 Vite 插件`);
return true;
} catch (error) {
log(`Vite 插件注入失败: ${error.message}`, true);
return false;
}
}
// 更规范与健壮的版本
function generateVitePlugin(remote, { insertPosition = '</head>' } = {}) {
const safeRemote = JSON.stringify(remote); // 避免注入问题
const pluginName = 'dev-inject';
const scriptId = 'dev-inject-monitor';
const scriptInjection = `
<script data-id="${scriptId}">
(function() {
const remote = ${safeRemote};
const separator = remote.includes('?') ? '&' : '?';
const script = document.createElement('script');
script.src = remote + separator + 't=' + Date.now();
script.dataset.id = '${scriptId}-script';
script.defer = true;
// 防止重复注入
if (!document.querySelector('[data-id="${scriptId}-script"]')) {
document.head.appendChild(script);
}
})();
</script>
`;
return `
// <!-- DEV-INJECT-START -->
{
name: '${pluginName}',
enforce: 'post', // 确保在 HTML 注入阶段最后执行
transformIndexHtml(html) {
if (!html.includes('data-id="${scriptId}"')) {
return html.replace(${JSON.stringify(insertPosition)}, \`${scriptInjection}\\n${insertPosition}\`);
}
return html;
}
},
// <!-- DEV-INJECT-END -->
`;
}
// 为 Next.js 注入脚本到 _document
export function injectToNextDocument(filePath, scriptTag, remote = null) {
try {
// 检查文件是否存在
if (!fs.existsSync(path.join(process.cwd(), filePath))) {
log(`Next.js 文件 ${filePath} 不存在,跳过`, true);
return false;
}
let content = readFile(filePath);
// 检查是否是 _document.tsx/_document.js
if (filePath.includes('_document')) {
return injectToDocumentComponent(content, scriptTag, filePath, remote);
}
// 如果是 _app.tsx/_app.js
if (filePath.includes('_app')) {
return injectToAppComponent(content, scriptTag, filePath, remote);
}
log(`无法识别的 Next.js 文件类型: ${filePath}`, true);
return false;
} catch (error) {
log(`Next.js 注入失败: ${error.message}`, true);
return false;
}
}
// 注入到 Next.js _document 组件
function injectToDocumentComponent(content, scriptTag, filePath, remote = null) {
const isTypeScript = filePath.endsWith('.tsx');
// 先移除之前的注入
content = removeNextDocumentInjection(content);
// 生成脚本加载代码(优先使用动态方式)
let scriptInjection;
if (remote) {
// 使用动态脚本加载器(推荐方式,防止缓存)
const scriptLoader = generateDynamicScriptLoader(remote);
scriptInjection = `{/* DEV-INJECT-START */}
{typeof window !== 'undefined' && (
<script dangerouslySetInnerHTML={{
__html: ${JSON.stringify(scriptLoader.trim())}
}} />
)}
{/* DEV-INJECT-END */}`;
} else {
// 向后兼容:使用旧的 scriptTag 方式
scriptInjection = `{/* DEV-INJECT-START */}
{typeof window !== 'undefined' && (
${scriptTag}
)}
{/* DEV-INJECT-END */}`;
}
// 查找 <Head> 组件
const headRegex = /<Head[^>]*>([\s\S]*?)<\/Head>/;
const headMatch = content.match(headRegex);
if (headMatch) {
// 在 <Head> 组件内注入 - 使用 DEV-INJECT-START/END 标识
const beforeHead = headMatch[1];
const newHead = beforeHead + '\n ' + scriptInjection;
// 修复多余的 < 符号问题
const headAttributes = headMatch[0].match(/<Head([^>]*)>/);
const attributes = headAttributes ? headAttributes[1] : '';
content = content.replace(headRegex, `<Head${attributes}>\n ${newHead}\n </Head>`);
} else {
// 如果没有 <Head> 组件,需要添加
const importReactMatch = content.match(/import.*React.*from.*react/);
if (importReactMatch) {
const afterImport = content.indexOf(importReactMatch[0]) + importReactMatch[0].length;
const headComponent = isTypeScript ? `
import Head from 'next/head';` : `
import Head from 'next/head';`;
content = content.slice(0, afterImport) + headComponent + content.slice(afterImport);
}
// 在 Document 组件的 render 方法中添加 Head
const renderMatch = content.match(/render\(\)\s*{[\s\S]*?return\s*\(/);
if (renderMatch) {
const insertPoint = content.indexOf(renderMatch[0]) + renderMatch[0].length;
let headInsert;
if (remote) {
// 使用动态脚本加载器
const scriptLoader = generateDynamicScriptLoader(remote);
headInsert = `
<Head>
{/* DEV-INJECT-START */}
{typeof window !== 'undefined' && (
<script dangerouslySetInnerHTML={{
__html: ${JSON.stringify(scriptLoader.trim())}
}} />
)}
{/* DEV-INJECT-END */}
</Head>`;
} else {
// 向后兼容:使用旧的 scriptTag 方式
headInsert = `
<Head>
{/* DEV-INJECT-START */}
{typeof window !== 'undefined' && ${scriptTag.replace(/<script[^>]*>|<\/script>/g, '')}}
{/* DEV-INJECT-END */}
</Head>`;
}
content = content.slice(0, insertPoint) + headInsert + content.slice(insertPoint);
}
}
writeFile(filePath, content);
return true;
}
// 注入到 Next.js _app 组件
function injectToAppComponent(content, scriptTag, filePath, remote = null) {
// 移除之前的注入
content = removeNextAppInjection(content);
// 在 _app 组件中注入,使用 useEffect
const scriptLoader = generateScriptLoader(scriptTag, false, remote);
const useEffectInsert = `
{/* DEV-INJECT-START */}
${scriptLoader}
{/* DEV-INJECT-END */}`;
// 查找现有的 useEffect 或组件末尾
const existingUseEffect = content.match(/useEffect\([^)]*\)\s*=>\s*{[\s\S]*?},\s*\[?\s*\]?\s*\);?/);
if (existingUseEffect) {
// 在现有 useEffect 中添加
content = content.replace(
existingUseEffect[0],
existingUseEffect[0].replace('}, []);', ' \n' + useEffectInsert.replace(/useEffect\([^)]*\)\s*=>\s*{\n/, '').replace(/}, \[\];/, '') + '\n }, [];')
);
} else {
// 添加新的 useEffect
const componentEndMatch = content.match(/export default.*|}\s*$/);
if (componentEndMatch) {
const insertPoint = content.lastIndexOf(componentEndMatch[0]);
content = content.slice(0, insertPoint) + useEffectInsert + '\n\n' + content.slice(insertPoint);
}
}
writeFile(filePath, content);
return true;
}
// 为 Next.js App Router 注入
export function injectToNextAppLayout(filePath, scriptTag, remote = null) {
try {
// 检查文件是否存在
if (!fs.existsSync(path.join(process.cwd(), filePath))) {
log(`Next.js App Router 文件 ${filePath} 不存在,跳过`, true);
return false;
}
let content = readFile(filePath);
// 移除之前的注入
content = removeNextAppLayoutInjection(content);
// 在 layout.tsx/js 中注入到 <head> 内
const scriptLoader = generateScriptLoader(scriptTag, true, remote);
// 查找 <head> 标签
const headRegex = /<head[^>]*>([\s\S]*?)<\/head>/i;
const headMatch = content.match(headRegex);
if (headMatch) {
// 在 <head> 内注入
const beforeHead = headMatch[1];
const newHead = beforeHead + '\n ' + scriptLoader;
content = content.replace(headRegex, `<head${headMatch[0].slice(5, -6)}${newHead}</head>`);
} else {
// 如果没有 <head> 标签,在 <html> 标签后添加
const htmlRegex = /<html[^>]*>/;
const htmlMatch = content.match(htmlRegex);
if (htmlMatch) {
const insertPoint = content.indexOf(htmlMatch[0]) + htmlMatch[0].length;
const headTag = '\n <head>\n ' + scriptLoader + '\n </head>';
content = content.slice(0, insertPoint) + headTag + content.slice(insertPoint);
}
}
writeFile(filePath, content);
return true;
} catch (error) {
log(`Next.js App Router 注入失败: ${error.message}`, true);
return false;
}
}
// 生成动态脚本加载器 - 使用运行时时间戳防止缓存
function generateDynamicScriptLoader(remote, scriptId = 'dev-inject-monitor') {
const safeRemote = JSON.stringify(remote); // 避免注入问题
return `
(function() {
const remote = ${safeRemote};
const separator = remote.includes('?') ? '&' : '?';
const script = document.createElement('script');
script.src = remote + separator + 't=' + Date.now();
script.dataset.id = '${scriptId}-script';
script.defer = true;
// 防止重复注入
if (!document.querySelector('[data-id="${scriptId}-script"]')) {
document.head.appendChild(script);
}
})();
`;
}
// 生成脚本加载器 - 使用 DEV-INJECT 标识和动态时间戳
function generateScriptLoader(scriptTag, isHead = false, remote = null) {
// 如果提供了 remote URL使用动态脚本加载器推荐方式
if (remote) {
const scriptLoader = generateDynamicScriptLoader(remote);
if (isHead) {
// 对于 App Router layout注入到 <head> 内
return `{/* DEV-INJECT-START */}
{process.env.NODE_ENV === 'development' && (
<script dangerouslySetInnerHTML={{
__html: ${JSON.stringify(scriptLoader.trim())}
}} />
)}
{/* DEV-INJECT-END */}`;
}
// useEffect 方式(用于 _app 组件)
return `useEffect(() => {
if (typeof window !== 'undefined') {
${scriptLoader.trim()}
}
}, []);`;
}
// 向后兼容:如果没有提供 remote使用旧的 scriptTag 方式
if (isHead) {
// 对于 App Router layout注入到 <head> 内
return `{/* DEV-INJECT-START */}
{process.env.NODE_ENV === 'development' && (
${scriptTag}
)}
{/* DEV-INJECT-END */}`;
}
// 旧版 useEffect 方式(保留向后兼容)
return `useEffect(() => {
if (typeof window !== 'undefined') {
${scriptTag.replace(/<script[^>]*>|<\/script>/g, '').trim()}
}
}, []);`;
}
// 通用的移除注入内容函数
function removeInjectionContent(content, startMarker = '/* DEV-INJECT-START */', endMarker = '/* DEV-INJECT-END */') {
let startIndex = content.indexOf(startMarker);
while (startIndex !== -1) {
const endIndex = content.indexOf(endMarker, startIndex);
if (endIndex !== -1) {
// 找到这两行前后的换行符
const startLineIndex = content.lastIndexOf('\n', startIndex - 1);
const endLineEnd = content.indexOf('\n', endIndex + endMarker.length);
if (startLineIndex !== -1 && endLineEnd !== -1) {
// 移除整块内容
content = content.slice(0, startLineIndex + 1) + content.slice(endLineEnd + 1);
} else if (startIndex >= 0 && endIndex >= startIndex) {
// 简单的字符串切片
const endPos = endIndex + endMarker.length;
const endLine = content.indexOf('\n', endPos);
if (endLine !== -1) {
content = content.slice(0, startIndex) + content.slice(endLine + 1);
} else {
content = content.slice(0, startIndex) + content.slice(endIndex + endMarker.length);
}
}
}
// 继续查找下一个
startIndex = content.indexOf(startMarker);
}
// 清理多余的空白行
content = content.replace(/\n\s*\n\s*\n+/g, '\n\n');
return content;
}
// 移除 Next.js Document 注入
function removeNextDocumentInjection(content) {
return removeInjectionContent(content);
}
// 移除 Next.js App 注入
function removeNextAppInjection(content) {
// 移除 DEV-INJECT 相关的 useEffect只保留新版标准标识
const patterns = [
/\{\/\*\s*DEV-INJECT-START\s*\/\*\/[\s\S]*?\{\/\*\s*DEV-INJECT-END\s*\/\*\/\}/g,
/useEffect\(\(\) => \{\s*if \(typeof window !== 'undefined'\) \{[\s\S]*?\}\s*\}, \[\]\);?/g
];
patterns.forEach(pattern => {
content = content.replace(pattern, '');
});
return content;
}
// 移除 Next.js App Layout 注入
function removeNextAppLayoutInjection(content) {
// 使用字符串匹配移除 DEV-INJECT-START/END 之间的内容
const startMarker = '/* DEV-INJECT-START */';
const endMarker = '/* DEV-INJECT-END */';
let startIndex = content.indexOf(startMarker);
while (startIndex !== -1) {
const endIndex = content.indexOf(endMarker, startIndex);
if (endIndex !== -1) {
// 找到这两行前后的换行符
const startLineIndex = content.lastIndexOf('\n', startIndex - 1);
const endLineEnd = content.indexOf('\n', endIndex + endMarker.length);
if (startLineIndex !== -1 && endLineEnd !== -1) {
// 移除整块内容
content = content.slice(0, startLineIndex + 1) + content.slice(endLineEnd + 1);
} else {
const endPos = endIndex + endMarker.length;
const endLine = content.indexOf('\n', endPos);
if (endLine !== -1) {
content = content.slice(0, startIndex) + content.slice(endLine + 1);
} else {
content = content.slice(0, startIndex) + content.slice(endIndex + endMarker.length);
}
}
}
startIndex = content.indexOf(startMarker);
}
// 使用 CodeCleaner 清理代码格式
content = CodeCleaner.cleanAll(content, {
blankLines: true,
trailingSpaces: true,
emptyTags: true,
tagWhitespace: true
});
return content;
}
// 移除 Vite 插件注入
function removeViteInjection(content) {
// 移除新版标准标识的注入(优先),包括后面的逗号
content = content.replace(
/\/\/ <!-- DEV-INJECT-START -->[\s\S]*?\/\/ <!-- DEV-INJECT-END -->\s*,?\s*/g,
''
);
// 移除旧版格式的注入(向后兼容)
content = content.replace(
/\/\/ dev-inject-plugin[\s\S]*?}\s*}\s*,?\s*/g,
''
);
// 使用 CodeCleaner 清理代码格式
content = CodeCleaner.cleanAll(content, {
blankLines: true,
commas: true
});
return content;
}
// 智能卸载 - 智能移除不同框架的注入
export function smartUninstall(options = {}) {
const { file, dryRun = false, verbose = false } = options;
logInfo('开始移除注入的脚本...');
// 检测项目类型
const projectType = detectProjectType();
// 只在 verbose 模式下输出检测到的项目类型(避免重复)
log(`检测到项目类型: ${projectType}`, verbose);
// 查找入口文件
const entryFiles = file ? [file] : findFrameworkEntry(projectType);
if (entryFiles.length === 0) {
logInfo('未找到需要处理的文件');
return;
}
log(`找到 ${entryFiles.length} 个文件:`, verbose);
entryFiles.forEach(f => log(` - ${f}`, verbose));
let successCount = 0;
for (const entryFile of entryFiles) {
try {
if (projectType === 'next-pages' || projectType === 'next-hybrid') {
if (entryFile.includes('_document')) {
successCount += uninstallFromNextDocument(entryFile, dryRun, verbose);
} else if (entryFile.includes('_app')) {
successCount += uninstallFromNextApp(entryFile, dryRun, verbose);
}
} else if (projectType === 'next-app') {
successCount += uninstallFromNextAppLayout(entryFile, dryRun, verbose);
} else if (projectType === 'vite') {
successCount += uninstallFromViteConfig(entryFile, dryRun, verbose);
} else if (projectType === 'html-static' || projectType === 'create-react-app') {
successCount += uninstallFromHTML(entryFile, dryRun, verbose);
}
} catch (error) {
logError(`处理文件 ${entryFile} 失败: ${error.message}`);
}
}
if (successCount > 0) {
logSuccess(`成功清理 ${successCount} 个文件`);
} else {
logInfo('没有找到需要清理的注入脚本');
}
}
// 从 HTML 文件卸载
function uninstallFromHTML(filePath, dryRun, verbose) {
const content = readFile(filePath);
const originalContent = content;
const newContent = removeInjectedScripts(content);
if (newContent === originalContent) {
log(`文件 ${filePath} 无需清理`, verbose);
return 0;
}
if (!dryRun) {
writeFile(filePath, newContent);
logSuccess(`已从 ${filePath} 移除注入的脚本`);
} else {
logInfo(`[DRY RUN] 将从 ${filePath} 移除注入的脚本`);
}
return 1;
}
// 从 Vite 配置卸载
function uninstallFromViteConfig(filePath, dryRun, verbose) {
const content = readFile(filePath);
const originalContent = content;
const newContent = removeViteInjection(content);
if (newContent === originalContent) {
log(`文件 ${filePath} 无需清理`, verbose);
return 0;
}
if (!dryRun) {
writeFile(filePath, newContent);
logSuccess(`已从 ${filePath} 移除注入的 Vite 插件`);
} else {
logInfo(`[DRY RUN] 将从 ${filePath} 移除注入的插件`);
}
return 1;
}
// 从 Next.js Document 卸载
function uninstallFromNextDocument(filePath, dryRun, verbose) {
const content = readFile(filePath);
const originalContent = content;
const newContent = removeNextDocumentInjection(content);
if (newContent === originalContent) {
log(`文件 ${filePath} 无需清理`, verbose);
return 0;
}
if (!dryRun) {
writeFile(filePath, newContent);
logSuccess(`已从 ${filePath} 移除注入的脚本`);
} else {
logInfo(`[DRY RUN] 将从 ${filePath} 移除注入的脚本`);
}
return 1;
}
// 从 Next.js App 卸载
function uninstallFromNextApp(filePath, dryRun, verbose) {
const content = readFile(filePath);
const originalContent = content;
const newContent = removeNextAppInjection(content);
if (newContent === originalContent) {
log(`文件 ${filePath} 无需清理`, verbose);
return 0;
}
if (!dryRun) {
writeFile(filePath, newContent);
logSuccess(`已从 ${filePath} 移除注入的脚本`);
} else {
logInfo(`[DRY RUN] 将从 ${filePath} 移除注入的脚本`);
}
return 1;
}
// 从 Next.js App Layout 卸载
function uninstallFromNextAppLayout(filePath, dryRun, verbose) {
const content = readFile(filePath);
const originalContent = content;
const newContent = removeNextAppLayoutInjection(content);
if (newContent === originalContent) {
log(`文件 ${filePath} 无需清理`, verbose);
return 0;
}
if (!dryRun) {
writeFile(filePath, newContent);
logSuccess(`已从 ${filePath} 移除注入的脚本`);
} else {
logInfo(`[DRY RUN] 将从 ${filePath} 移除注入的脚本`);
}
return 1;
}
// 智能注入 - 根据项目类型选择最佳注入方式
export function smartInject(options = {}) {
const { remote, file, dryRun = false, verbose = false } = options;
// 检测项目类型
const projectType = detectProjectType();
// 只在 verbose 模式下输出检测到的项目类型(避免重复)
log(`检测到项目类型: ${projectType}`, verbose);
// 生成脚本标签
const remoteType = parseRemoteType(remote);
const scriptTag = generateScriptTag(remote, remoteType);
log(`脚本类型: ${remoteType}`, verbose);
log(`脚本标签: ${scriptTag}`, verbose);
// 查找入口文件
const entryFiles = file ? [file] : findFrameworkEntry(projectType);
if (entryFiles.length === 0) {
logError('未找到需要处理的文件');
return false;
}
log(`找到 ${entryFiles.length} 个文件:`, verbose);
entryFiles.forEach(f => log(` - ${f}`, verbose));
let successCount = 0;
const errors = [];
for (const entryFile of entryFiles) {
try {
// 检查文件是否存在
if (!fs.existsSync(path.join(process.cwd(), entryFile))) {
log(`文件 ${entryFile} 不存在,跳过`, verbose);
continue;
}
// 处理 Next.js Pages Router
if (projectType === 'next-pages' || projectType === 'next-hybrid') {
if (entryFile.includes('_document') || entryFile.includes('_app')) {
if (injectToNextDocument(entryFile, scriptTag, remote)) {
successCount++;
}
}
}
// 处理 Next.js App Router
else if (projectType === 'next-app') {
if (injectToNextAppLayout(entryFile, scriptTag, remote)) {
successCount++;
}
}
// 处理 Vite 项目
else if (projectType === 'vite') {
if (injectToViteConfig(entryFile, remote, scriptTag)) {
successCount++;
}
}
// 处理 HTML 和 create-react-app
else if (projectType === 'html-static' || projectType === 'create-react-app') {
let htmlContent = readFile(entryFile);
const originalContent = htmlContent;
// 传入 remote URL 以使用动态时间戳防止缓存
const newContent = injectScriptToHtml(htmlContent, scriptTag, 'dev-inject', remote);
if (newContent !== originalContent) {
if (!dryRun) {
writeFile(entryFile, newContent);
logSuccess(`已注入脚本到 ${entryFile}`);
} else {
logInfo(`[DRY RUN] 将注入脚本到 ${entryFile}`);
}
successCount++;
} else {
log(`文件 ${entryFile} 已包含注入内容`, verbose);
}
}
} catch (error) {
// 收集错误,但不立即报错
errors.push(`处理文件 ${entryFile} 失败: ${error.message}`);
log(`处理文件 ${entryFile} 失败: ${error.message}`, verbose);
}
}
// 只有在所有尝试都失败后才报错
if (successCount > 0) {
logSuccess(`成功处理 ${successCount} 个文件`);
// 只在 verbose 模式下输出详细信息
log(`项目类型: ${projectType}`, verbose);
log(`脚本地址: ${remote}`, verbose);
// 统一输出重启提示(不重复)
if (projectType.startsWith('next-')) {
logInfo('💡 Next.js 项目: 脚本已注入到组件中,需要重启开发服务器');
} else if (projectType === 'vite') {
logInfo('💡 Vite 项目: 脚本已注入到配置文件,需要重启开发服务器');
}
// 如果有部分错误,显示警告
if (errors.length > 0) {
logInfo(`⚠️ 部分文件处理失败: ${errors.length} 个错误`);
errors.forEach(error => log(` - ${error}`, verbose));
}
} else {
// 所有尝试都失败了
logError('没有成功处理任何文件');
if (errors.length > 0) {
logError('错误详情:');
errors.forEach(error => logError(` - ${error}`));
}
}
return successCount > 0;
}

View File

@@ -0,0 +1,115 @@
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
export function showHelp() {
console.log(`
@xagi/dev-inject - 开发环境脚本注入工具
用法:
npx @xagi/dev-inject <命令> [选项]
或使用 pnpm:
pnpm dlx @xagi/dev-inject <命令> [选项]
命令:
install 注入脚本到 HTML 文件
uninstall 从 HTML 文件中移除注入的脚本
选项:
--remote <url|path> 脚本地址(可选,默认值: /sdk/dev-monitor.js
- 远程URL: http://localhost:9000/monitor.js
- 绝对路径: /scripts/monitor.js
--file <path> 指定要处理的 HTML 文件(可选)
- 如果不指定,会自动查找项目中的 HTML 文件
--dry-run 预览模式,显示将要执行的操作但不实际修改文件
--verbose 显示详细输出信息
--framework, -f 使用框架感知注入模式
- 自动检测 Vite、Next.js 等框架
- 使用最佳注入方式,无需手动修改 HTML
--help, -h 显示帮助信息
--version, -v 显示版本信息
示例:
# 静默安装(推荐,自动确认,无需交互)
npx -y @xagi/dev-inject install --framework
# 使用默认脚本地址 (/sdk/dev-monitor.js)
npx @xagi/dev-inject install
# 使用默认地址 + 框架感知注入
npx @xagi/dev-inject install --framework
# 传统 HTML 注入
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js
# 框架感知注入(推荐)
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js --framework
# Vite 项目自动注入
npx @xagi/dev-inject install --remote=/scripts/dev-monitor.js -f --verbose
# Next.js App Router 注入
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js --framework
# Next.js Pages Router 注入
npx @xagi/dev-inject install --remote=/scripts/dev-monitor.js -f
# 指定特定 HTML 文件(传统模式)
npx @xagi/dev-inject install --remote=/scripts/monitor.js --file=./public/index.html
# 使用 pnpm dlx
pnpm dlx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js --framework
# 预览将要执行的操作
npx @xagi/dev-inject install --remote=/scripts/monitor.js --framework --dry-run
# 移除框架注入
npx @xagi/dev-inject uninstall --framework
# 移除传统注入
npx @xagi/dev-inject uninstall
支持的脚本格式:
- 远程 HTTP/HTTPS URL
- 以 / 开头的绝对路径(相对于网站根目录)
框架支持:
🟢 Vite: 自动注入 Vite 插件到 vite.config.js
🟢 Next.js App Router: 注入到 app/layout.tsx
🟢 Next.js Pages Router: 注入到 pages/_document.tsx
🟢 Create React App: 注入到 public/index.html
🟢 传统 HTML: 直接修改 HTML 文件
框架注入优势:
✅ 自动检测项目类型
✅ 使用最佳注入方式
✅ 仅在开发环境生效
✅ 零侵入性修改
✅ 支持热重载
注意:
- 使用 --framework 会自动选择最佳注入方式
- 框架注入后需要重启开发服务器
- 传统模式脚本会被注入到 HTML 文件的 </head> 标签之前
- 重复执行 install 会自动替换之前的注入
- 使用 uninstall 可以完全移除所有注入的脚本
`);
}
export function showVersion() {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packagePath = join(__dirname, '../package.json');
const packageJson = JSON.parse(readFileSync(packagePath, 'utf8'));
console.log(`@xagi/dev-inject v${packageJson.version}`);
} catch (error) {
console.log('@xagi/dev-inject v1.0.0');
}
}

View File

@@ -0,0 +1,206 @@
import {
lookupFiles,
readFile,
writeFile,
parseRemoteType,
generateScriptTag,
injectScriptToHtml,
removeInjectedScripts,
log,
logInfo,
logSuccess,
logError,
} from './utils.js';
import {
detectProjectType,
smartInject as smartInjectFramework,
} from './framework-inject.js';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
/**
* 获取当前包版本号
*/
function getVersion() {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packagePath = join(__dirname, '..', 'package.json');
const packageJson = JSON.parse(readFileSync(packagePath, 'utf8'));
return packageJson.version;
} catch (error) {
return 'unknown';
}
}
/**
* 安装脚本 - 支持框架感知
*/
export async function installScript(options = {}) {
const {
remote,
file,
dryRun = false,
verbose = false,
framework = false,
} = options;
const version = getVersion();
logInfo(`开始执行脚本注入... (v${version})`);
// 检测是否使用框架注入模式
if (framework) {
logInfo('使用框架感知注入模式...');
// 检测项目类型(只在 verbose 模式下输出,避免重复)
const projectType = detectProjectType();
log(`检测到项目类型: ${projectType}`, verbose);
// 对于现代框架,使用智能注入
if (
['vite', 'next-app', 'next-pages', 'next-hybrid'].includes(projectType)
) {
try {
const success = smartInjectFramework({ remote, dryRun, verbose });
// smartInjectFramework 内部已经输出了所有必要的日志,这里不需要重复输出
return success;
} catch (error) {
logError(`框架注入失败: ${error.message}`);
logInfo('回退到传统 HTML 注入模式...');
}
}
}
// 传统 HTML 注入模式(兜底方案)
logInfo('使用传统 HTML 注入模式...');
// 查找 HTML 文件
const targetFiles = file ? [file] : lookupFiles();
if (targetFiles.length === 0) {
throw new Error('未找到 HTML 文件');
}
log(`找到 ${targetFiles.length} 个 HTML 文件:`, verbose);
targetFiles.forEach(f => log(` - ${f}`, verbose));
// 解析远程脚本类型和生成脚本标签
const remoteType = parseRemoteType(remote);
const scriptTag = generateScriptTag(remote, remoteType);
log(`脚本类型: ${remoteType}`, verbose);
log(`脚本标签: ${scriptTag}`, verbose);
// 处理每个 HTML 文件
let successCount = 0;
for (const htmlFile of targetFiles) {
try {
// 读取 HTML 内容
let htmlContent = readFile(htmlFile);
const originalContent = htmlContent;
// 移除之前注入的脚本(避免重复)
htmlContent = removeInjectedScripts(htmlContent);
// 注入新脚本 - 传入 remote URL 以使用动态时间戳
const newContent = injectScriptToHtml(htmlContent, scriptTag, 'dev-inject', remote);
// 检查是否有变化
if (newContent === originalContent) {
log(`文件 ${htmlFile} 无需更新`, verbose);
successCount++;
continue;
}
// 写入文件
if (!dryRun) {
writeFile(htmlFile, newContent);
logSuccess(`已注入脚本到 ${htmlFile}`);
} else {
logInfo(`[DRY RUN] 将注入脚本到 ${htmlFile}`);
}
successCount++;
} catch (error) {
logError(`处理文件 ${htmlFile} 失败: ${error.message}`);
}
}
if (successCount > 0) {
logSuccess(`成功处理 ${successCount} 个文件`);
logInfo(`脚本地址: ${remote}`);
if (remoteType === 'absolute-path') {
logInfo('提示:请确保静态文件服务器可以访问该路径');
}
} else {
logError('没有成功处理任何文件');
}
}
/**
* 卸载脚本从 HTML 文件
*/
export async function uninstallScript(options = {}) {
const { file, dryRun = false, verbose = false, framework } = options;
// 如果指定了 framework 标志,使用框架感知卸载
if (framework) {
const { smartUninstall } = await import('./framework-inject.js');
return smartUninstall(options);
}
logInfo('开始移除注入的脚本...');
// 查找 HTML 文件
const targetFiles = file ? [file] : lookupFiles();
if (targetFiles.length === 0) {
logInfo('未找到 HTML 文件');
return;
}
log(`找到 ${targetFiles.length} 个 HTML 文件:`, verbose);
targetFiles.forEach(f => log(` - ${f}`, verbose));
// 处理每个 HTML 文件
let successCount = 0;
for (const htmlFile of targetFiles) {
try {
// 读取 HTML 内容
let htmlContent = readFile(htmlFile);
const originalContent = htmlContent;
// 移除注入的脚本
const newContent = removeInjectedScripts(htmlContent);
// 检查是否有变化
if (newContent === originalContent) {
log(`文件 ${htmlFile} 无需清理`, verbose);
successCount++;
continue;
}
// 写入文件
if (!dryRun) {
writeFile(htmlFile, newContent);
logSuccess(`已从 ${htmlFile} 移除注入的脚本`);
} else {
logInfo(`[DRY RUN] 将从 ${htmlFile} 移除注入的脚本`);
}
successCount++;
} catch (error) {
logError(`处理文件 ${htmlFile} 失败: ${error.message}`);
}
}
if (successCount > 0) {
logSuccess(`成功清理 ${successCount} 个文件`);
} else {
logInfo('没有找到需要清理的注入脚本');
}
}

View File

@@ -0,0 +1,275 @@
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
/**
* 查找项目中的 HTML 文件
*/
export function lookupFiles(startDir = process.cwd()) {
const htmlFiles = [];
function searchDirectory(dir) {
try {
const files = readdirSync(dir);
for (const file of files) {
const fullPath = join(dir, file);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
// 跳过 node_modules 等目录
if (!['node_modules', '.git', 'dist', 'build'].includes(file)) {
searchDirectory(fullPath);
}
} else if (file.endsWith('.html')) {
htmlFiles.push(fullPath);
}
}
} catch (error) {
// 忽略无法访问的目录
}
}
searchDirectory(startDir);
// 优先返回 index.html否则返回第一个找到的 HTML 文件
const indexHtml = htmlFiles.find(file => file.endsWith('index.html'));
return indexHtml ? [indexHtml] : htmlFiles;
}
/**
* 读取文件内容
*/
export function readFile(filePath) {
try {
return readFileSync(filePath, 'utf8');
} catch (error) {
throw new Error(`无法读取文件 ${filePath}: ${error.message}`);
}
}
/**
* 写入文件内容
*/
export function writeFile(filePath, content) {
try {
writeFileSync(filePath, content, 'utf8');
} catch (error) {
throw new Error(`无法写入文件 ${filePath}: ${error.message}`);
}
}
/**
* 解析远程脚本类型
*/
export function parseRemoteType(remote) {
if (remote.startsWith('http://') || remote.startsWith('https://')) {
return 'url';
} else if (remote.startsWith('/')) {
return 'absolute-path';
} else {
throw new Error(
`不支持的远程路径格式: ${remote}。请使用完整 URL (http://...) 或绝对路径 (/path/to/script.js)`
);
}
}
/**
* 生成脚本标签
*/
export function generateScriptTag(remote, type) {
// 所有类型都添加时间戳参数避免缓存
const separator = remote.includes('?') ? '&' : '?';
const timestamp = Date.now();
if (type === 'url') {
return `<script src="${remote}${separator}t=${timestamp}"></script>`;
} else if (type === 'absolute-path') {
return `<script src="${remote}${separator}t=${timestamp}"></script>`;
} else {
throw new Error(`未知的远程类型: ${type}`);
}
}
/**
* 从 HTML 内容中移除注入的脚本 - 同时清理前后的空白行
*/
export function removeInjectedScripts(htmlContent, identifier = 'dev-inject') {
// 使用标准的开始/结束标识移除注入的代码
// 匹配包括前后换行符和空白在内的完整块
// 使用非贪婪匹配确保只匹配一个注入块
const injectBlockRegex = /(\r?\n)?\s*<!-- DEV-INJECT-START -->[\s\S]*?<!-- DEV-INJECT-END -->\s*(\r?\n)?/g;
let cleaned = htmlContent.replace(injectBlockRegex, (match, beforeNewline, afterNewline) => {
// 如果匹配块前后都有换行符,只保留一个换行符
if (beforeNewline && afterNewline) {
return '\n';
}
// 如果只有前面的换行符,保留它
if (beforeNewline) {
return beforeNewline;
}
// 如果只有后面的换行符,保留它
if (afterNewline) {
return afterNewline;
}
// 如果都没有,返回空字符串
return '';
});
// 清理移除后可能产生的多余空白行
// 清理 <head> 标签后的多余空白行(保留一个换行)
cleaned = cleaned.replace(/(<head[^>]*>)\s*\n\s*\n+(\s*)/g, '$1\n$2');
// 清理 </head> 前的多余空白行(保留一个换行)
cleaned = cleaned.replace(/(\s*)\n\s*\n+(\s*<\/head>)/g, '$1\n$2');
// 清理连续的多个空白行(保留最多一个空行)
cleaned = cleaned.replace(/\n\s*\n\s*\n+/g, '\n\n');
// 清理空行(只包含空白字符的行)
cleaned = cleaned.replace(/^\s+$/gm, '');
return cleaned;
}
/**
* 向 HTML 中注入脚本 - 使用动态时间戳防止缓存
*/
export function injectScriptToHtml(
htmlContent,
scriptTag,
identifier = 'dev-inject',
remote = null
) {
// 先清除之前的注入
htmlContent = removeInjectedScripts(htmlContent, identifier);
// 生成脚本内容 - 优先使用动态时间戳方式
// 注意:脚本内容前后不包含额外的换行符,由插入逻辑统一处理
let scriptContent;
if (remote) {
// 使用动态脚本加载器(推荐方式,防止缓存)
const safeRemote = JSON.stringify(remote);
const scriptId = `${identifier}-monitor`;
scriptContent = `<!-- DEV-INJECT-START -->
<script data-id="${scriptId}">
(function() {
const remote = ${safeRemote};
const separator = remote.includes('?') ? '&' : '?';
const script = document.createElement('script');
script.src = remote + separator + 't=' + Date.now();
script.dataset.id = '${scriptId}-script';
script.defer = true;
// 防止重复注入
if (!document.querySelector('[data-id="${scriptId}-script"]')) {
document.head.appendChild(script);
}
})();
</script>
<!-- DEV-INJECT-END -->`;
} else {
// 向后兼容:从 scriptTag 中提取 URL 并添加时间戳
const scriptSrc = scriptTag.match(/src="([^"]*)"/)?.[1] || '';
if (scriptSrc) {
// 提取基础 URL移除可能已存在的时间戳参数
const baseUrl = scriptSrc.split('?')[0];
const separator = scriptSrc.includes('?') ? '&' : '?';
scriptContent = `<!-- DEV-INJECT-START -->
<script>
(function() {
const remote = ${JSON.stringify(baseUrl)};
const separator = remote.includes('?') ? '&' : '?';
const script = document.createElement('script');
script.src = remote + separator + 't=' + Date.now();
script.defer = true;
// 防止重复注入
if (!document.querySelector('script[src*="' + remote.split('/').pop() + '"]')) {
document.head.appendChild(script);
}
})();
</script>
<!-- DEV-INJECT-END -->`;
} else {
// 如果无法提取 URL使用旧的静态方式
scriptContent = `<!-- DEV-INJECT-START -->
${scriptTag}
<!-- DEV-INJECT-END -->`;
}
}
// 查找 </head> 标签
const headEndIndex = htmlContent.lastIndexOf('</head>');
if (headEndIndex !== -1) {
// 在 </head> 之前注入
const beforeHead = htmlContent.substring(0, headEndIndex);
const afterHead = htmlContent.substring(headEndIndex);
// 检查 </head> 前是否有非空白内容
const beforeHeadTrimmed = beforeHead.trimEnd();
const needsNewlineBefore = beforeHeadTrimmed.length > 0 && !beforeHeadTrimmed.endsWith('\n');
// 构建注入内容,统一格式
let result;
if (needsNewlineBefore) {
// 如果前面有内容,添加换行符后插入脚本
result = `${beforeHeadTrimmed}\n${scriptContent}\n${afterHead}`;
} else {
// 如果前面是空白或换行,直接插入(脚本内容已有标识注释)
// 确保脚本前后有适当的换行
const trimmedBefore = beforeHead.trimEnd();
if (trimmedBefore.length === 0) {
// 如果前面完全是空白,直接插入
result = `${beforeHead}${scriptContent}\n${afterHead}`;
} else {
// 前面有内容但末尾是换行,直接插入
result = `${beforeHead}${scriptContent}\n${afterHead}`;
}
}
// 清理可能的多余空白行(保留最多一个空行)
// 清理 <head> 标签后的多余空白行
result = result.replace(/(<head[^>]*>)\s*\n\s*\n+(\s*)/g, '$1\n$2');
// 清理 </head> 前的多余空白行
result = result.replace(/(\s*)\n\s*\n+(\s*<\/head>)/g, '$1\n$2');
// 清理连续的多个空白行
result = result.replace(/\n\s*\n\s*\n+/g, '\n\n');
return result;
} else {
// 如果没有找到 </head>,查找 <body> 标签
const bodyStartIndex = htmlContent.indexOf('<body');
if (bodyStartIndex !== -1) {
const bodyEndIndex = htmlContent.indexOf('>', bodyStartIndex) + 1;
const beforeBody = htmlContent.substring(0, bodyEndIndex);
const afterBody = htmlContent.substring(bodyEndIndex);
return `${beforeBody}
${scriptContent}
${afterBody}`;
} else {
// 如果都没有找到,在文件末尾添加
return `${htmlContent}
${scriptContent}`;
}
}
}
/**
* 日志输出函数
*/
export function log(message, verbose = false) {
if (verbose) {
console.log(`[dev-inject] ${message}`);
}
}
export function logError(message) {
console.error(`❌ [dev-inject] ${message}`);
}
export function logSuccess(message) {
console.log(`✅ [dev-inject] ${message}`);
}
export function logInfo(message) {
console.log(` [dev-inject] ${message}`);
}