Skip to content

Commit 40e4ecf

Browse files
authoredNov 23, 2021
fix: support "setup" attribute in <script> tag in vue 3 (#676)
In Vue 3, when <script> tag has "setup" attribute, SFC parser assign script block to a separate field called "scriptSetup" ✅ Closes: #668
1 parent 551f2fb commit 40e4ecf

9 files changed

+1149
-1137
lines changed
 

‎src/typescript-reporter/extension/vue/TypeScriptVueExtension.ts

+52-25
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@ import { VueTemplateCompilerV3 } from './types/vue__compiler-sfc';
1010

1111
interface GenericScriptSFCBlock {
1212
content: string;
13-
attrs: Record<string, string | true>;
14-
start?: number;
15-
end?: number;
16-
lang?: string;
13+
attrs: Record<string, string | true | undefined>;
1714
src?: string;
1815
}
1916

@@ -94,6 +91,22 @@ function createTypeScriptVueExtension(
9491
};
9592
}
9693

94+
function mergeVueScriptsContent(
95+
scriptContent: string | undefined,
96+
scriptSetupContent: string | undefined
97+
): string {
98+
const scriptLines = scriptContent?.split(/\r?\n/) ?? [];
99+
const scriptSetupLines = scriptSetupContent?.split(/\r?\n/) ?? [];
100+
const maxScriptLines = Math.max(scriptLines.length, scriptSetupLines.length);
101+
const mergedScriptLines: string[] = [];
102+
103+
for (let line = 0; line < maxScriptLines; ++line) {
104+
mergedScriptLines.push(scriptLines[line] || scriptSetupLines[line]);
105+
}
106+
107+
return mergedScriptLines.join('\n');
108+
}
109+
97110
function getVueEmbeddedSource(fileName: string): TypeScriptEmbeddedSource | undefined {
98111
if (!fs.existsSync(fileName)) {
99112
return undefined;
@@ -105,25 +118,44 @@ function createTypeScriptVueExtension(
105118
let script: GenericScriptSFCBlock | undefined;
106119
if (isVueTemplateCompilerV2(compiler)) {
107120
const parsed = compiler.parseComponent(vueSourceText, {
108-
pad: 'space',
121+
pad: 'line',
109122
});
110123

111124
script = parsed.script;
112125
} else if (isVueTemplateCompilerV3(compiler)) {
113-
const parsed = compiler.parse(vueSourceText);
114-
115-
if (parsed.descriptor && parsed.descriptor.script) {
116-
const scriptV3 = parsed.descriptor.script;
117-
118-
// map newer version of SFCScriptBlock to the generic one
119-
script = {
120-
content: scriptV3.content,
121-
attrs: scriptV3.attrs,
122-
start: scriptV3.loc.start.offset,
123-
end: scriptV3.loc.end.offset,
124-
lang: scriptV3.lang,
125-
src: scriptV3.src,
126-
};
126+
const parsed = compiler.parse(vueSourceText, {
127+
pad: 'line',
128+
});
129+
130+
if (parsed.descriptor) {
131+
const parsedScript = parsed.descriptor.script;
132+
const parsedScriptSetup = parsed.descriptor.scriptSetup;
133+
let parsedContent = mergeVueScriptsContent(
134+
parsedScript?.content,
135+
parsedScriptSetup?.content
136+
);
137+
138+
if (parsedScriptSetup) {
139+
// a little bit naive, but should work in 99.9% cases without need for parsing script
140+
const alreadyHasExportDefault = /export\s+default[\s|{]/gm.test(parsedContent);
141+
142+
if (!alreadyHasExportDefault) {
143+
parsedContent += '\nexport default {};';
144+
}
145+
// add script setup lines at the end
146+
parsedContent +=
147+
"\n// @ts-ignore\nimport { defineProps, defineEmits, defineExpose, withDefaults } from '@vue/runtime-core';";
148+
}
149+
150+
if (parsedScript || parsedScriptSetup) {
151+
// map newer version of SFCScriptBlock to the generic one
152+
script = {
153+
content: parsedContent,
154+
attrs: {
155+
lang: parsedScript?.lang || parsedScriptSetup?.lang,
156+
},
157+
};
158+
}
127159
}
128160
} else {
129161
throw new Error(
@@ -141,12 +173,7 @@ function createTypeScriptVueExtension(
141173
}
142174
} else {
143175
// <script lang="ts"></script> block
144-
// pad blank lines to retain diagnostics location
145-
const lineOffset = vueSourceText.slice(0, script.start).split(/\r?\n/g).length;
146-
const paddedSourceText =
147-
Array(lineOffset).join('\n') + vueSourceText.slice(script.start, script.end);
148-
149-
return createVueInlineScriptEmbeddedSource(paddedSourceText, script.attrs.lang);
176+
return createVueInlineScriptEmbeddedSource(script.content, script.attrs.lang);
150177
}
151178
}
152179

‎src/typescript-reporter/extension/vue/types/vue__compiler-sfc.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ interface SFCDescriptor {
2323
filename: string;
2424
template: SFCBlock | null;
2525
script: SFCBlock | null;
26+
scriptSetup: SFCBlock | null;
2627
styles: SFCBlock[];
2728
customBlocks: SFCBlock[];
2829
}
@@ -37,7 +38,11 @@ interface SFCParseResult {
3738
errors: CompilerError[];
3839
}
3940

41+
interface SFCParserOptionsV3 {
42+
pad?: true | 'line' | 'space';
43+
}
44+
4045
export interface VueTemplateCompilerV3 {
4146
// eslint-disable-next-line @typescript-eslint/no-explicit-any
42-
parse(template: string, options?: any): SFCParseResult;
47+
parse(template: string, options?: SFCParserOptionsV3): SFCParseResult;
4348
}

‎test/e2e/TypeScriptVueExtension.spec.ts

+82-12
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
WEBPACK_DEV_SERVER_VERSION,
88
} from './sandbox/WebpackDevServerDriver';
99
import { FORK_TS_CHECKER_WEBPACK_PLUGIN_VERSION } from './sandbox/Plugin';
10+
import semver from 'semver/preload';
1011

1112
describe('TypeScript Vue Extension', () => {
1213
let sandbox: Sandbox;
@@ -29,21 +30,23 @@ describe('TypeScript Vue Extension', () => {
2930
typescript: '^3.8.0',
3031
tsloader: '^7.0.0',
3132
vueloader: '^15.8.3',
32-
vue: '^2.6.11',
33+
vue: '^2.0.0',
3334
compiler: 'vue-template-compiler',
35+
qrcodevue: '^1.7.0',
3436
},
3537
{
3638
async: true,
3739
typescript: '^3.8.0',
3840
tsloader: '^7.0.0',
39-
vueloader: 'v16.0.0-beta.3',
40-
vue: '^3.0.0-beta.14',
41+
vueloader: 'v16.8.3',
42+
vue: '^3.0.0',
4143
compiler: '@vue/compiler-sfc',
44+
qrcodevue: '^3.0.0',
4245
},
4346
])(
4447
'reports semantic error for %p',
45-
async ({ async, typescript, tsloader, vueloader, vue, compiler }) => {
46-
await sandbox.load([
48+
async ({ async, typescript, tsloader, vueloader, vue, compiler, qrcodevue }) => {
49+
const fixtures = [
4750
await readFixture(join(__dirname, 'fixtures/environment/typescript-vue.fixture'), {
4851
FORK_TS_CHECKER_WEBPACK_PLUGIN_VERSION: JSON.stringify(
4952
FORK_TS_CHECKER_WEBPACK_PLUGIN_VERSION
@@ -56,12 +59,23 @@ describe('TypeScript Vue Extension', () => {
5659
VUE_LOADER_VERSION: JSON.stringify(vueloader),
5760
VUE_VERSION: JSON.stringify(vue),
5861
VUE_COMPILER: JSON.stringify(compiler),
62+
QRCODE_VUE_VERSION: JSON.stringify(qrcodevue),
5963
ASYNC: JSON.stringify(async),
6064
}),
61-
await readFixture(join(__dirname, 'fixtures/implementation/typescript-vue.fixture')),
62-
]);
65+
await readFixture(join(__dirname, 'fixtures/implementation/typescript-vue-shared.fixture')),
66+
];
67+
if (semver.satisfies('2.0.0', vue)) {
68+
fixtures.push(
69+
await readFixture(join(__dirname, 'fixtures/implementation/typescript-vue2.fixture'))
70+
);
71+
} else if (semver.satisfies('3.0.0', vue)) {
72+
fixtures.push(
73+
await readFixture(join(__dirname, 'fixtures/implementation/typescript-vue3.fixture'))
74+
);
75+
}
76+
await sandbox.load(fixtures);
6377

64-
if (vue === '^2.6.11') {
78+
if (semver.satisfies('2.0.0', vue)) {
6579
await sandbox.write(
6680
'src/vue-shim.d.ts',
6781
[
@@ -71,7 +85,7 @@ describe('TypeScript Vue Extension', () => {
7185
'}',
7286
].join('\n')
7387
);
74-
} else {
88+
} else if (semver.satisfies('3.0.0', vue)) {
7589
await sandbox.write('src/vue-shim.d.ts', 'declare module "*.vue";');
7690
}
7791

@@ -98,7 +112,7 @@ describe('TypeScript Vue Extension', () => {
98112
'ERROR in src/component/LoggedIn.vue:27:21',
99113
"TS2304: Cannot find name 'getUserName'.",
100114
' 25 | const user: User = this.user;',
101-
' 26 | ',
115+
' 26 |',
102116
" > 27 | return user ? getUserName(user) : '';",
103117
' | ^^^^^^^^^^^',
104118
' 28 | }',
@@ -126,7 +140,7 @@ describe('TypeScript Vue Extension', () => {
126140
'ERROR in src/component/LoggedIn.vue:27:29',
127141
"TS2339: Property 'firstName' does not exist on type 'User'.",
128142
' 25 | const user: User = this.user;',
129-
' 26 | ',
143+
' 26 |',
130144
" > 27 | return user ? `${user.firstName} ${user.lastName}` : '';",
131145
' | ^^^^^^^^^',
132146
' 28 | }',
@@ -136,7 +150,7 @@ describe('TypeScript Vue Extension', () => {
136150
[
137151
'ERROR in src/model/User.ts:11:16',
138152
"TS2339: Property 'firstName' does not exist on type 'User'.",
139-
' 9 | ',
153+
' 9 |',
140154
' 10 | function getUserName(user: User): string {',
141155
' > 11 | return [user.firstName, user.lastName]',
142156
' | ^^^^^^^^^',
@@ -145,6 +159,62 @@ describe('TypeScript Vue Extension', () => {
145159
' 14 | }',
146160
].join('\n'),
147161
]);
162+
163+
// fix the error
164+
await sandbox.patch(
165+
'src/model/User.ts',
166+
' lastName?: string;',
167+
[' firstName?: string;', ' lastName?: string;'].join('\n')
168+
);
169+
await driver.waitForNoErrors();
170+
171+
if (semver.satisfies('3.0.0', vue)) {
172+
await sandbox.patch(
173+
'src/component/Header.vue',
174+
'defineProps({',
175+
['let x: number = "1"', 'defineProps({'].join('\n')
176+
);
177+
178+
errors = await driver.waitForErrors();
179+
expect(errors).toEqual([
180+
[
181+
'ERROR in src/component/Header.vue:6:5',
182+
"TS2322: Type '\"1\"' is not assignable to type 'number'.",
183+
' 4 |',
184+
' 5 | <script setup lang="ts">',
185+
' > 6 | let x: number = "1"',
186+
' | ^',
187+
' 7 | defineProps({',
188+
' 8 | title: String,',
189+
' 9 | });',
190+
].join('\n'),
191+
]);
192+
// fix the issue
193+
await sandbox.patch('src/component/Header.vue', 'let x: number = "1"', '');
194+
await driver.waitForNoErrors();
195+
196+
// introduce error in second <script>
197+
await sandbox.patch(
198+
'src/component/Logo.vue',
199+
'export default {',
200+
['let x: number = "1";', 'export default {'].join('\n')
201+
);
202+
203+
errors = await driver.waitForErrors();
204+
expect(errors).toEqual([
205+
[
206+
'ERROR in src/component/Logo.vue:15:5',
207+
"TS2322: Type '\"1\"' is not assignable to type 'number'.",
208+
' 13 |',
209+
' 14 | <script lang="ts">',
210+
' > 15 | let x: number = "1";',
211+
' | ^',
212+
' 16 | export default {',
213+
' 17 | inheritAttrs: false,',
214+
' 18 | customOptions: {}',
215+
].join('\n'),
216+
]);
217+
}
148218
}
149219
);
150220
});

‎test/e2e/fixtures/environment/typescript-vue.fixture

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"typescript": ${TYPESCRIPT_VERSION},
1818
"vue-loader": ${VUE_LOADER_VERSION},
1919
${VUE_COMPILER}: ${VUE_VERSION},
20-
"qrcode.vue": "^1.7.0",
20+
"qrcode.vue": ${QRCODE_VUE_VERSION},
2121
"webpack": ${WEBPACK_VERSION},
2222
"webpack-cli": ${WEBPACK_CLI_VERSION},
2323
"webpack-dev-server": ${WEBPACK_DEV_SERVER_VERSION}

‎test/e2e/fixtures/implementation/typescript-vue.fixture ‎test/e2e/fixtures/implementation/typescript-vue-shared.fixture

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/// src/App.vue
1+
/// src/component/LoginView.vue
22
<template>
33
<logged-in v-if="user" :user="user" @logout="logout"></logged-in>
44
<login-form v-else @login="login"></login-form>
@@ -108,7 +108,7 @@ export default {
108108
}
109109
},
110110
computed: {
111-
userName: () => {
111+
userName() {
112112
const user: User = this.user;
113113

114114
return user ? getUserName(user) : '';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/// src/App.vue
2+
<template>
3+
<LoginView />
4+
</template>
5+
6+
<script lang="ts">
7+
import LoginView from "@/component/LoginView.vue";
8+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/// src/App.vue
2+
<template>
3+
<Header title="My App" />
4+
<Logo />
5+
<LoginView />
6+
</template>
7+
8+
<script setup lang="ts">
9+
import Header from './component/Header.vue';
10+
import Logo from './component/Logo.vue';
11+
import LoginView from './component/LoginView.vue';
12+
</script>
13+
14+
/// src/component/Header.vue
15+
<template>
16+
<h1>{{ title }}</h1>
17+
</template>
18+
19+
<script setup lang="ts">
20+
defineProps({
21+
title: String,
22+
});
23+
</script>
24+
25+
<!-- Add "scoped" attribute to limit CSS to this component only -->
26+
<style scoped>
27+
h1 {
28+
font-size: 32px;
29+
}
30+
</style>
31+
32+
/// src/component/Logo.vue
33+
<template>
34+
<img src="public/logo.png" v-bind:class="size" />
35+
</template>
36+
37+
<script setup lang="ts">
38+
interface Props {
39+
size: 'sm' | 'lg' | 'xl';
40+
}
41+
withDefaults(defineProps<Props>(), {
42+
size: 'sm'
43+
})
44+
</script>
45+
46+
<script lang="ts">
47+
export default {
48+
inheritAttrs: false,
49+
customOptions: {}
50+
}
51+
</script>
52+
53+
<style scoped>
54+
img {
55+
width: 100%;
56+
}
57+
.sm {
58+
width: 50%;
59+
}
60+
.xl {
61+
width: 200%;
62+
}
63+
</style>

‎test/e2e/locks/TypeScriptVueExtension.spec.ts.7a0e6f723f1fb09c0e6cba9c4c0174e0.lock ‎test/e2e/locks/TypeScriptVueExtension.spec.ts.47651940d7f17c2a46b8d6969ee7e665.lock

+398-438
Large diffs are not rendered by default.

‎test/e2e/locks/TypeScriptVueExtension.spec.ts.d50e4101fb561fe08c088583258f24de.lock ‎test/e2e/locks/TypeScriptVueExtension.spec.ts.949d4e83d46a909e2f6e7746b6e46741.lock

+537-658
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.