Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support <script setup /> in vue 3 #676

Merged
merged 3 commits into from
Nov 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
77 changes: 52 additions & 25 deletions src/typescript-reporter/extension/vue/TypeScriptVueExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@ import { VueTemplateCompilerV3 } from './types/vue__compiler-sfc';

interface GenericScriptSFCBlock {
content: string;
attrs: Record<string, string | true>;
start?: number;
end?: number;
lang?: string;
attrs: Record<string, string | true | undefined>;
src?: string;
}

Expand Down Expand Up @@ -94,6 +91,22 @@ function createTypeScriptVueExtension(
};
}

function mergeVueScriptsContent(
scriptContent: string | undefined,
scriptSetupContent: string | undefined
): string {
const scriptLines = scriptContent?.split(/\r?\n/) ?? [];
const scriptSetupLines = scriptSetupContent?.split(/\r?\n/) ?? [];
const maxScriptLines = Math.max(scriptLines.length, scriptSetupLines.length);
const mergedScriptLines: string[] = [];

for (let line = 0; line < maxScriptLines; ++line) {
mergedScriptLines.push(scriptLines[line] || scriptSetupLines[line]);
}

return mergedScriptLines.join('\n');
}

function getVueEmbeddedSource(fileName: string): TypeScriptEmbeddedSource | undefined {
if (!fs.existsSync(fileName)) {
return undefined;
Expand All @@ -105,25 +118,44 @@ function createTypeScriptVueExtension(
let script: GenericScriptSFCBlock | undefined;
if (isVueTemplateCompilerV2(compiler)) {
const parsed = compiler.parseComponent(vueSourceText, {
pad: 'space',
pad: 'line',
});

script = parsed.script;
} else if (isVueTemplateCompilerV3(compiler)) {
const parsed = compiler.parse(vueSourceText);

if (parsed.descriptor && parsed.descriptor.script) {
const scriptV3 = parsed.descriptor.script;

// map newer version of SFCScriptBlock to the generic one
script = {
content: scriptV3.content,
attrs: scriptV3.attrs,
start: scriptV3.loc.start.offset,
end: scriptV3.loc.end.offset,
lang: scriptV3.lang,
src: scriptV3.src,
};
const parsed = compiler.parse(vueSourceText, {
pad: 'line',
});

if (parsed.descriptor) {
const parsedScript = parsed.descriptor.script;
const parsedScriptSetup = parsed.descriptor.scriptSetup;
let parsedContent = mergeVueScriptsContent(
parsedScript?.content,
parsedScriptSetup?.content
);

if (parsedScriptSetup) {
// a little bit naive, but should work in 99.9% cases without need for parsing script
const alreadyHasExportDefault = /export\s+default[\s|{]/gm.test(parsedContent);

if (!alreadyHasExportDefault) {
parsedContent += '\nexport default {};';
}
// add script setup lines at the end
parsedContent +=
"\n// @ts-ignore\nimport { defineProps, defineEmits, defineExpose, withDefaults } from '@vue/runtime-core';";
}

if (parsedScript || parsedScriptSetup) {
// map newer version of SFCScriptBlock to the generic one
script = {
content: parsedContent,
attrs: {
lang: parsedScript?.lang || parsedScriptSetup?.lang,
},
};
}
}
} else {
throw new Error(
Expand All @@ -141,12 +173,7 @@ function createTypeScriptVueExtension(
}
} else {
// <script lang="ts"></script> block
// pad blank lines to retain diagnostics location
const lineOffset = vueSourceText.slice(0, script.start).split(/\r?\n/g).length;
const paddedSourceText =
Array(lineOffset).join('\n') + vueSourceText.slice(script.start, script.end);

return createVueInlineScriptEmbeddedSource(paddedSourceText, script.attrs.lang);
return createVueInlineScriptEmbeddedSource(script.content, script.attrs.lang);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface SFCDescriptor {
filename: string;
template: SFCBlock | null;
script: SFCBlock | null;
scriptSetup: SFCBlock | null;
styles: SFCBlock[];
customBlocks: SFCBlock[];
}
Expand All @@ -37,7 +38,11 @@ interface SFCParseResult {
errors: CompilerError[];
}

interface SFCParserOptionsV3 {
pad?: true | 'line' | 'space';
}

export interface VueTemplateCompilerV3 {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parse(template: string, options?: any): SFCParseResult;
parse(template: string, options?: SFCParserOptionsV3): SFCParseResult;
}
94 changes: 82 additions & 12 deletions test/e2e/TypeScriptVueExtension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
WEBPACK_DEV_SERVER_VERSION,
} from './sandbox/WebpackDevServerDriver';
import { FORK_TS_CHECKER_WEBPACK_PLUGIN_VERSION } from './sandbox/Plugin';
import semver from 'semver/preload';

describe('TypeScript Vue Extension', () => {
let sandbox: Sandbox;
Expand All @@ -29,21 +30,23 @@ describe('TypeScript Vue Extension', () => {
typescript: '^3.8.0',
tsloader: '^7.0.0',
vueloader: '^15.8.3',
vue: '^2.6.11',
vue: '^2.0.0',
compiler: 'vue-template-compiler',
qrcodevue: '^1.7.0',
},
{
async: true,
typescript: '^3.8.0',
tsloader: '^7.0.0',
vueloader: 'v16.0.0-beta.3',
vue: '^3.0.0-beta.14',
vueloader: 'v16.8.3',
vue: '^3.0.0',
compiler: '@vue/compiler-sfc',
qrcodevue: '^3.0.0',
},
])(
'reports semantic error for %p',
async ({ async, typescript, tsloader, vueloader, vue, compiler }) => {
await sandbox.load([
async ({ async, typescript, tsloader, vueloader, vue, compiler, qrcodevue }) => {
const fixtures = [
await readFixture(join(__dirname, 'fixtures/environment/typescript-vue.fixture'), {
FORK_TS_CHECKER_WEBPACK_PLUGIN_VERSION: JSON.stringify(
FORK_TS_CHECKER_WEBPACK_PLUGIN_VERSION
Expand All @@ -56,12 +59,23 @@ describe('TypeScript Vue Extension', () => {
VUE_LOADER_VERSION: JSON.stringify(vueloader),
VUE_VERSION: JSON.stringify(vue),
VUE_COMPILER: JSON.stringify(compiler),
QRCODE_VUE_VERSION: JSON.stringify(qrcodevue),
ASYNC: JSON.stringify(async),
}),
await readFixture(join(__dirname, 'fixtures/implementation/typescript-vue.fixture')),
]);
await readFixture(join(__dirname, 'fixtures/implementation/typescript-vue-shared.fixture')),
];
if (semver.satisfies('2.0.0', vue)) {
fixtures.push(
await readFixture(join(__dirname, 'fixtures/implementation/typescript-vue2.fixture'))
);
} else if (semver.satisfies('3.0.0', vue)) {
fixtures.push(
await readFixture(join(__dirname, 'fixtures/implementation/typescript-vue3.fixture'))
);
}
await sandbox.load(fixtures);

if (vue === '^2.6.11') {
if (semver.satisfies('2.0.0', vue)) {
await sandbox.write(
'src/vue-shim.d.ts',
[
Expand All @@ -71,7 +85,7 @@ describe('TypeScript Vue Extension', () => {
'}',
].join('\n')
);
} else {
} else if (semver.satisfies('3.0.0', vue)) {
await sandbox.write('src/vue-shim.d.ts', 'declare module "*.vue";');
}

Expand All @@ -98,7 +112,7 @@ describe('TypeScript Vue Extension', () => {
'ERROR in src/component/LoggedIn.vue:27:21',
"TS2304: Cannot find name 'getUserName'.",
' 25 | const user: User = this.user;',
' 26 | ',
' 26 |',
" > 27 | return user ? getUserName(user) : '';",
' | ^^^^^^^^^^^',
' 28 | }',
Expand Down Expand Up @@ -126,7 +140,7 @@ describe('TypeScript Vue Extension', () => {
'ERROR in src/component/LoggedIn.vue:27:29',
"TS2339: Property 'firstName' does not exist on type 'User'.",
' 25 | const user: User = this.user;',
' 26 | ',
' 26 |',
" > 27 | return user ? `${user.firstName} ${user.lastName}` : '';",
' | ^^^^^^^^^',
' 28 | }',
Expand All @@ -136,7 +150,7 @@ describe('TypeScript Vue Extension', () => {
[
'ERROR in src/model/User.ts:11:16',
"TS2339: Property 'firstName' does not exist on type 'User'.",
' 9 | ',
' 9 |',
' 10 | function getUserName(user: User): string {',
' > 11 | return [user.firstName, user.lastName]',
' | ^^^^^^^^^',
Expand All @@ -145,6 +159,62 @@ describe('TypeScript Vue Extension', () => {
' 14 | }',
].join('\n'),
]);

// fix the error
await sandbox.patch(
'src/model/User.ts',
' lastName?: string;',
[' firstName?: string;', ' lastName?: string;'].join('\n')
);
await driver.waitForNoErrors();

if (semver.satisfies('3.0.0', vue)) {
await sandbox.patch(
'src/component/Header.vue',
'defineProps({',
['let x: number = "1"', 'defineProps({'].join('\n')
);

errors = await driver.waitForErrors();
expect(errors).toEqual([
[
'ERROR in src/component/Header.vue:6:5',
"TS2322: Type '\"1\"' is not assignable to type 'number'.",
' 4 |',
' 5 | <script setup lang="ts">',
' > 6 | let x: number = "1"',
' | ^',
' 7 | defineProps({',
' 8 | title: String,',
' 9 | });',
].join('\n'),
]);
// fix the issue
await sandbox.patch('src/component/Header.vue', 'let x: number = "1"', '');
await driver.waitForNoErrors();

// introduce error in second <script>
await sandbox.patch(
'src/component/Logo.vue',
'export default {',
['let x: number = "1";', 'export default {'].join('\n')
);

errors = await driver.waitForErrors();
expect(errors).toEqual([
[
'ERROR in src/component/Logo.vue:15:5',
"TS2322: Type '\"1\"' is not assignable to type 'number'.",
' 13 |',
' 14 | <script lang="ts">',
' > 15 | let x: number = "1";',
' | ^',
' 16 | export default {',
' 17 | inheritAttrs: false,',
' 18 | customOptions: {}',
].join('\n'),
]);
}
}
);
});
2 changes: 1 addition & 1 deletion test/e2e/fixtures/environment/typescript-vue.fixture
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"typescript": ${TYPESCRIPT_VERSION},
"vue-loader": ${VUE_LOADER_VERSION},
${VUE_COMPILER}: ${VUE_VERSION},
"qrcode.vue": "^1.7.0",
"qrcode.vue": ${QRCODE_VUE_VERSION},
"webpack": ${WEBPACK_VERSION},
"webpack-cli": ${WEBPACK_CLI_VERSION},
"webpack-dev-server": ${WEBPACK_DEV_SERVER_VERSION}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/// src/App.vue
/// src/component/LoginView.vue
<template>
<logged-in v-if="user" :user="user" @logout="logout"></logged-in>
<login-form v-else @login="login"></login-form>
Expand Down Expand Up @@ -108,7 +108,7 @@ export default {
}
},
computed: {
userName: () => {
userName() {
const user: User = this.user;

return user ? getUserName(user) : '';
Expand Down
8 changes: 8 additions & 0 deletions test/e2e/fixtures/implementation/typescript-vue2.fixture
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/// src/App.vue
<template>
<LoginView />
</template>

<script lang="ts">
import LoginView from "@/component/LoginView.vue";
</script>
63 changes: 63 additions & 0 deletions test/e2e/fixtures/implementation/typescript-vue3.fixture
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/// src/App.vue
<template>
<Header title="My App" />
<Logo />
<LoginView />
</template>

<script setup lang="ts">
import Header from './component/Header.vue';
import Logo from './component/Logo.vue';
import LoginView from './component/LoginView.vue';
</script>

/// src/component/Header.vue
<template>
<h1>{{ title }}</h1>
</template>

<script setup lang="ts">
defineProps({
title: String,
});
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h1 {
font-size: 32px;
}
</style>

/// src/component/Logo.vue
<template>
<img src="public/logo.png" v-bind:class="size" />
</template>

<script setup lang="ts">
interface Props {
size: 'sm' | 'lg' | 'xl';
}
withDefaults(defineProps<Props>(), {
size: 'sm'
})
</script>

<script lang="ts">
export default {
inheritAttrs: false,
customOptions: {}
}
</script>

<style scoped>
img {
width: 100%;
}
.sm {
width: 50%;
}
.xl {
width: 200%;
}
</style>