Skip to content

Commit d7daf01

Browse files
authored
Merge pull request #548 from lejunyang/main
fix: inject vue inspector attributes via compiler transform
2 parents ea5e6d3 + 74974b9 commit d7daf01

11 files changed

Lines changed: 392 additions & 29 deletions

File tree

packages/core/src/server/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1-
export { transformCode } from './transform';
1+
export {
2+
createVueInspectorNodeTransform,
3+
transformCode,
4+
} from './transform';
25
export * from './use-client';
3-
export * from './server';
6+
export * from './server';

packages/core/src/server/transform/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { transformJsx } from './transform-jsx';
44
import { transformMdx } from './transform-mdx';
55
import { transformSvelte } from './transform-svelte';
66
import { transformVue } from './transform-vue';
7+
export { createVueInspectorNodeTransform } from './vue-node-transform';
78
import { EscapeTags, PathType, isIgnoredFile } from '../../shared';
89
import { getRelativeOrAbsolutePath } from '../server';
910

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type {
2+
AttributeNode,
3+
ElementNode,
4+
NodeTransform,
5+
SourceLocation,
6+
TemplateChildNode,
7+
TextNode,
8+
TransformContext,
9+
} from '@vue/compiler-dom';
10+
import type { EscapeTags, PathType } from '../../shared';
11+
import {
12+
PathName,
13+
getMappingFilePath,
14+
isEscapeTags,
15+
normalizePath,
16+
} from '../../shared';
17+
import { getRelativeOrAbsolutePath } from '../server';
18+
19+
const VueElementType = 1;
20+
const VueTextType = 2;
21+
const VueAttributeType = 6;
22+
23+
const CodeInspectorEscapeTags = [
24+
'style',
25+
'script',
26+
'template',
27+
'transition',
28+
'keepalive',
29+
'keep-alive',
30+
'component',
31+
'slot',
32+
'teleport',
33+
'transition-group',
34+
'transitiongroup',
35+
'suspense',
36+
'fragment',
37+
];
38+
39+
type VueInspectorNodeTransformOptions = {
40+
escapeTags?: EscapeTags;
41+
pathType?: PathType;
42+
mappings?:
43+
| Record<string, string>
44+
| Array<{ find: string | RegExp; replacement: string }>;
45+
};
46+
47+
function hasInspectorPath(node: ElementNode) {
48+
return node.props.some(
49+
(prop) => prop.type === VueAttributeType && prop.name === PathName,
50+
);
51+
}
52+
53+
function createTextNode(content: string, loc: SourceLocation): TextNode {
54+
return {
55+
type: VueTextType,
56+
content,
57+
loc,
58+
} as TextNode;
59+
}
60+
61+
function createAttributeNode(
62+
name: string,
63+
content: string,
64+
loc: SourceLocation,
65+
): AttributeNode {
66+
return {
67+
type: VueAttributeType,
68+
name,
69+
nameLoc: loc,
70+
value: createTextNode(content, loc),
71+
loc,
72+
} as AttributeNode;
73+
}
74+
75+
function getNodeFilePath(
76+
context: TransformContext,
77+
options: VueInspectorNodeTransformOptions,
78+
) {
79+
const filename = normalizePath(context.filename || '');
80+
const mappedFilePath = getMappingFilePath(filename, options.mappings);
81+
return getRelativeOrAbsolutePath(
82+
mappedFilePath,
83+
options.pathType ?? 'relative',
84+
);
85+
}
86+
87+
export function createVueInspectorNodeTransform(
88+
options: VueInspectorNodeTransformOptions = {},
89+
): NodeTransform {
90+
const finalEscapeTags = [
91+
...CodeInspectorEscapeTags,
92+
...(options.escapeTags || []),
93+
];
94+
95+
return ((node: TemplateChildNode, context: TransformContext) => {
96+
if (
97+
node.type !== VueElementType ||
98+
hasInspectorPath(node) ||
99+
isEscapeTags(finalEscapeTags, node.tag)
100+
) {
101+
return;
102+
}
103+
104+
const { line, column } = node.loc.start;
105+
const filePath = getNodeFilePath(context, options);
106+
node.props.unshift(
107+
createAttributeNode(
108+
PathName,
109+
`${filePath}:${line}:${column}:${node.tag}`,
110+
node.loc,
111+
),
112+
);
113+
}) as NodeTransform;
114+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
export { transformCode } from './transform';
1+
export { createVueInspectorNodeTransform, transformCode, } from './transform';
22
export * from './use-client';
33
export * from './server';

packages/core/types/server/transform/index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { createVueInspectorNodeTransform } from './vue-node-transform';
12
import { EscapeTags, PathType } from '../../shared';
23
type FileType = 'vue' | 'jsx' | 'svelte' | 'astro' | 'mdx' | unknown;
34
type TransformCodeParams = {
@@ -9,4 +10,3 @@ type TransformCodeParams = {
910
mdx?: boolean;
1011
};
1112
export declare function transformCode(params: TransformCodeParams): Promise<string>;
12-
export {};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { NodeTransform } from '@vue/compiler-dom';
2+
import type { EscapeTags, PathType } from '../../shared';
3+
type VueInspectorNodeTransformOptions = {
4+
escapeTags?: EscapeTags;
5+
pathType?: PathType;
6+
mappings?: Record<string, string> | Array<{
7+
find: string | RegExp;
8+
replacement: string;
9+
}>;
10+
};
11+
export declare function createVueInspectorNodeTransform(options?: VueInspectorNodeTransformOptions): NodeTransform;
12+
export {};

packages/webpack/src/index.ts

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
CodeOptions,
33
RecordInfo,
4+
createVueInspectorNodeTransform,
45
getCodeWithWebComponent,
56
getProjectRecord,
67
isDev,
@@ -14,10 +15,111 @@ const compatibleDirname = path.dirname(fileURLToPath(import.meta.url));
1415

1516
interface LoaderOptions extends CodeOptions {
1617
record: RecordInfo;
18+
vueCompilerNodeTransform?: boolean;
1719
}
1820

1921
const baseLoaderPath = path.resolve(compatibleDirname, './loader.js');
2022
const injectLoaderPath = path.resolve(compatibleDirname, './inject-loader.js');
23+
const codeInspectorVueNodeTransform = Symbol.for(
24+
'code-inspector.vueNodeTransform',
25+
);
26+
27+
function getUseItems(rule: any): any[] {
28+
if (!rule) {
29+
return [];
30+
}
31+
32+
if (Array.isArray(rule.use)) {
33+
return rule.use;
34+
}
35+
36+
if (rule.loader) {
37+
return [rule];
38+
}
39+
40+
return [];
41+
}
42+
43+
function walkRules(rules: any[], visitor: (rule: any) => void) {
44+
rules.forEach((rule) => {
45+
visitor(rule);
46+
if (Array.isArray(rule.rules)) {
47+
walkRules(rule.rules, visitor);
48+
}
49+
if (Array.isArray(rule.oneOf)) {
50+
walkRules(rule.oneOf, visitor);
51+
}
52+
});
53+
}
54+
55+
function isVueLoader(loader: string) {
56+
return /(^|[\\/])vue-loader([\\/]|$)/.test(loader);
57+
}
58+
59+
function isVueTemplateLoader(loader: string) {
60+
return /(^|[\\/])vue-loader[\\/]dist[\\/]templateLoader\.js$/.test(loader);
61+
}
62+
63+
function applyVueCompilerNodeTransform(options: CodeOptions, compiler: any) {
64+
const _compiler = compiler?.compiler || compiler;
65+
const module = _compiler?.options?.module;
66+
const rules = module?.rules || module?.loaders || [];
67+
let applied = false;
68+
69+
walkRules(rules, (rule) => {
70+
getUseItems(rule).forEach((item) => {
71+
const loader = typeof item === 'string' ? item : item?.loader;
72+
if (
73+
typeof loader !== 'string' ||
74+
(!isVueLoader(loader) && !isVueTemplateLoader(loader))
75+
) {
76+
return;
77+
}
78+
79+
if (typeof item === 'string') {
80+
return;
81+
}
82+
83+
if (!item.options || typeof item.options !== 'object') {
84+
item.options = {};
85+
}
86+
87+
const loaderOptions = item.options;
88+
if (
89+
!loaderOptions.compilerOptions ||
90+
typeof loaderOptions.compilerOptions !== 'object'
91+
) {
92+
loaderOptions.compilerOptions = {};
93+
}
94+
95+
const compilerOptions = loaderOptions.compilerOptions;
96+
if (!Array.isArray(compilerOptions.nodeTransforms)) {
97+
compilerOptions.nodeTransforms = [];
98+
}
99+
100+
const nodeTransforms = compilerOptions.nodeTransforms;
101+
const hasRegistered = nodeTransforms.some(
102+
(transform: any) => transform?.[codeInspectorVueNodeTransform],
103+
);
104+
105+
if (!hasRegistered) {
106+
const transform = createVueInspectorNodeTransform({
107+
escapeTags: options.escapeTags,
108+
mappings: options.mappings,
109+
pathType: options.pathType,
110+
});
111+
Object.defineProperty(transform, codeInspectorVueNodeTransform, {
112+
value: true,
113+
});
114+
nodeTransforms.push(transform);
115+
}
116+
117+
applied = true;
118+
});
119+
});
120+
121+
return applied;
122+
}
21123

22124
function hasRegisteredCodeInspectorLoader(rules: any[]) {
23125
return rules.some((rule) =>
@@ -165,14 +267,18 @@ class WebpackCodeInspectorPlugin {
165267
if (this.options.cache) {
166268
// 用来在 cache 情况下启动 node server
167269
record.port =
168-
this.options.port || getProjectRecord(record)?.previousPort;
270+
this.options.port || getProjectRecord(record)?.previousPort || 0;
169271
getPureClientCodeString(this.options, record, true);
170272
} else {
171273
cache.version = `code-inspector-${Date.now()}`;
172274
}
173275
}
174276

175-
applyLoader({ ...this.options, record }, compiler);
277+
const vueCompilerNodeTransform = applyVueCompilerNodeTransform(
278+
this.options,
279+
compiler,
280+
);
281+
applyLoader({ ...this.options, record, vueCompilerNodeTransform }, compiler);
176282

177283
if (
178284
compiler?.hooks?.emit &&

packages/webpack/src/loader.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ async function transformWebpackCodeInspectorContent(
7979
filePath.endsWith('.html') &&
8080
params.get('type') === 'template' &&
8181
params.has('vue');
82+
if (options.vueCompilerNodeTransform && (isVue || isHtmlVue)) {
83+
return content;
84+
}
8285
if (isVue || isHtmlVue) {
8386
return await transformCode({
8487
content,

test/core/server/transform/transform-vue.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
11
import { describe, it, expect, vi } from 'vitest';
22
import fs from 'fs';
3+
import { createRequire } from 'node:module';
34
import {
45
resolveVueCompilerDom,
56
transformVue,
67
} from '@/core/src/server/transform/transform-vue';
8+
import {
9+
createVueInspectorNodeTransform,
10+
} from '@/core/src/server/transform/vue-node-transform';
711
import { PathName } from '@/core/src/shared/constant';
812

913
// Only mock server module, not fs
1014
vi.mock('@/core/src/server/server', () => ({
1115
ProjectRootPath: '/mock/project',
16+
getRelativeOrAbsolutePath: (filePath: string, pathType?: string) =>
17+
pathType === 'relative'
18+
? filePath.replace('/mock/project/', '')
19+
: filePath,
1220
}));
1321

22+
const coreRequire = createRequire(`${process.cwd()}/packages/core/package.json`);
23+
const { compile } = coreRequire('@vue/compiler-dom') as { compile: any };
24+
1425
describe('transformVue', () => {
1526
const filePath = 'test/file.vue';
1627
// Note: don't include 'template' in escapeTags for testing template content
@@ -704,3 +715,44 @@ const count = ref(0);
704715
});
705716
});
706717
});
718+
719+
describe('createVueInspectorNodeTransform', () => {
720+
it('should inject data-insp-path through Vue compiler AST without changing source', () => {
721+
const source = '<div><span>Hi</span></div>';
722+
const result = compile(source, {
723+
filename: '/mock/project/src/App.vue',
724+
nodeTransforms: [
725+
createVueInspectorNodeTransform({
726+
pathType: 'relative',
727+
}),
728+
],
729+
});
730+
731+
expect(source).toBe('<div><span>Hi</span></div>');
732+
expect(result.code).toContain(
733+
`"${PathName}": "src/App.vue:1:1:div"`,
734+
);
735+
expect(result.code).toContain(
736+
`"${PathName}": "src/App.vue:1:6:span"`,
737+
);
738+
});
739+
740+
it('should respect escapeTags and existing data-insp-path attributes', () => {
741+
const result = compile(
742+
`<div ${PathName}="existing"><custom-card>Skip</custom-card></div>`,
743+
{
744+
filename: '/mock/project/src/App.vue',
745+
nodeTransforms: [
746+
createVueInspectorNodeTransform({
747+
escapeTags: [/^custom-/],
748+
pathType: 'relative',
749+
}),
750+
],
751+
},
752+
);
753+
754+
expect(result.code.match(new RegExp(PathName, 'g'))?.length).toBe(1);
755+
expect(result.code).toContain(`"${PathName}": "existing"`);
756+
expect(result.code).not.toContain(':custom-card');
757+
});
758+
});

0 commit comments

Comments
 (0)