vite-vue-tagger
Version:
138 lines (118 loc) • 3.51 kB
text/typescript
// src/index.ts
import { parse } from '@vue/compiler-sfc';
import { parse as domParse, ElementNode } from '@vue/compiler-dom';
import MagicString from 'magic-string';
import { createHash } from 'crypto';
export interface TaggerOptions {
/** 开发/生产模式 */
mode?: 'development' | 'production';
/** 哈希盐值 */
hashSalt?: string;
/** 最小化属性 */
minify?: boolean;
/** 包含文件的正则 */
include: RegExp;
/** 排除文件的正则 */
exclude: RegExp;
}
const DEFAULT_OPTIONS: TaggerOptions = {
mode: 'development',
hashSalt: 'vite-vue-tagger',
minify: false,
include: /\.(vue|tsx|jsx)$/,
exclude: /node_modules/,
};
export default function ComponentTagger(options?: TaggerOptions) {
const opts = { ...DEFAULT_OPTIONS, ...options };
return {
name: 'vite-vue-tagger',
enforce: 'pre',
async transform(code: string, id: string) {
if (opts.mode === 'production') return;
if (opts.exclude.test(id)) return;
if (!opts.include.test(id)) return;
try {
const s = await processCode(code, id, opts);
return {
code: opts.minify ? minifyAttributes(s.toString()) : s.toString(),
map: s.generateMap({ source: id }),
};
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e);
console.error(`[vue-tagger] Error processing ${id}:\n${errorMessage}`);
}
},
};
}
async function processCode(code: string, id: string, opts: TaggerOptions) {
const s = new MagicString(code);
const { descriptor } = parse(code, { sourceMap: true });
if (!descriptor.template) return s;
const template = descriptor.template;
const ast = domParse(template.content, {
parseMode: 'html',
isNativeTag: () => true, // 假设全部为原生标签
});
ast.children.forEach((node: any) => {
if (node.type === 1) {
// ElementNode
processElement(node as ElementNode, template.loc.start.line, s, id, opts);
}
});
return s;
}
function processElement(node: ElementNode, lineOffset: number, s: MagicString, id: string, opts: TaggerOptions) {
const source = {
line: node.loc.start.line + lineOffset,
column: node.loc.start.column,
tag: node.tag,
};
const attrs = [
`data-lov-id="${id}:${source.line}:${source.column}"`,
`data-lov-name="${node.tag}"`,
`data-component-path="${id}"`,
`data-component-file="${id.split('/').pop()}"`,
`data-component-line="${source.line}"`,
`data-component-name="${getComponentName(id)}"`,
`data-component-content="${encodeContent(node, opts)}"`,
].join(' ');
s.appendLeft(
node.loc.start.offset + 1, // 在标签名后插入
` ${attrs} `
);
}
function getComponentName(filePath: string) {
return (
filePath
.split('/')
.pop()
?.replace(/\.(vue|tsx|jsx)$/, '')
?.replace(/([a-z])([A-Z])/g, '$1 $2') // 驼峰转空格
?.replace(/[-_]/g, ' ') || 'Anonymous'
);
}
function encodeContent(node: ElementNode, opts: TaggerOptions) {
const props = extractProps(node);
const contentHash = createHash('sha256')
.update(opts.hashSalt + node.loc.source)
.digest('hex')
.substr(0, 8);
return encodeURIComponent(
JSON.stringify({
props,
hash: contentHash,
})
);
}
function extractProps(node: ElementNode) {
return node.props.reduce((acc: any, prop) => {
if (prop.type === 6) {
// 属性节点
acc[prop.name] = prop.value?.content || '';
}
return acc;
}, {});
}
function minifyAttributes(code: string) {
return code.replace(/\s+data-[\w-]+="[^"]*"/g, (match) => match.replace(/\s+/g, ' ').trim());
}