Skip to content

Commit

Permalink
fix: support "setup" attribute in <script> tag in vue 3 (#676)
Browse files Browse the repository at this point in the history
In Vue 3, when <script> tag has "setup" attribute, SFC parser assign 
script block to a separate field called "scriptSetup"

✅ Closes: #668
  • Loading branch information
piotr-oles committed Nov 23, 2021
1 parent 551f2fb commit 40e4ecf
Show file tree
Hide file tree
Showing 9 changed files with 1,149 additions and 1,137 deletions.
77 changes: 52 additions & 25 deletions src/typescript-reporter/extension/vue/TypeScriptVueExtension.ts
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
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
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
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
@@ -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
@@ -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
@@ -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>

0 comments on commit 40e4ecf

Please sign in to comment.