Skip to content

Commit 873ec27

Browse files
authored
(feat) ComponentEvents interface (#459)
This adds the possibility to use a reserved interface name `ComponentEvents` and define all possible events within it. Also adds autocompletion for these events. Also disables autocompletions from HTMLPlugin on component tags. Also fixes an import type bug. #424 #304
1 parent d2429dd commit 873ec27

File tree

112 files changed

+725
-164
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

112 files changed

+725
-164
lines changed

docs/preprocessors/typescript.md

+33
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,38 @@ You will need to tell svelte-vscode to restart the svelte language server in ord
4242

4343
Hit `ctrl-shift-p` or `cmd-shift-p` on mac, type `svelte restart`, and select `Svelte: Restart Language Server`. Any errors you were seeing should now go away and you're now all set up!
4444

45+
## Typing component events
46+
47+
When you are using TypeScript, you can type which events your component has by defining a reserved `interface` (_NOT_ `type`) called `ComponentEvents`:
48+
49+
```html
50+
<script lang="ts">
51+
interface ComponentEvents {
52+
click: MouseEvent;
53+
hello: CustomEvent<boolean>;
54+
}
55+
56+
// ...
57+
</script>
58+
```
59+
60+
Doing this will give you autocompletion for these events as well as type safety when listening to the events in other components.
61+
62+
If you want to be sure that the interface definition names correspond to your dispatched events, you can use computed property names:
63+
64+
```html
65+
<script lang="ts">
66+
const hello = 'hello';
67+
interface ComponentEvents {
68+
[hello]: CustomEvent<boolean>;
69+
}
70+
// ...
71+
dispatch(hello, true);
72+
</script>
73+
```
74+
75+
> In case you ask why the events cannot be infered: Due to Svelte's dynamic nature, component events could be fired not only from a dispatcher created directly in the component, but from a dispatcher which is created as part of a mixin. This is almost impossible to infer, so we need you to tell us which events are possible.
76+
4577
## Troubleshooting / FAQ
4678

4779
### I cannot use TS inside my script even when `lang="ts"` is present
@@ -81,6 +113,7 @@ At the moment, you cannot. Only `script`/`style` tags are preprocessed/transpile
81113
### Why is VSCode not finding absolute paths for type imports?
82114

83115
You may need to set `baseUrl` in `tsconfig.json` at the project root to include (restart the language server to see this take effect):
116+
84117
```
85118
"compilerOptions": {
86119
"baseUrl": "."

packages/language-server/src/lib/documents/Document.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { urlToPath } from '../../utils';
22
import { WritableDocument } from './DocumentBase';
3-
import { extractScriptTags, extractStyleTag, TagInformation } from './utils';
3+
import { extractScriptTags, extractStyleTag, TagInformation, parseHtml } from './utils';
44
import { SvelteConfig, loadConfig } from './configLoader';
5+
import { HTMLDocument } from 'vscode-html-languageservice';
56

67
/**
78
* Represents a text document contains a svelte component.
@@ -12,6 +13,7 @@ export class Document extends WritableDocument {
1213
moduleScriptInfo: TagInformation | null = null;
1314
styleInfo: TagInformation | null = null;
1415
config!: SvelteConfig;
16+
html!: HTMLDocument;
1517

1618
constructor(public url: string, public content: string) {
1719
super();
@@ -22,10 +24,11 @@ export class Document extends WritableDocument {
2224
if (!this.config || this.config.loadConfigError) {
2325
this.config = loadConfig(this.getFilePath() || '');
2426
}
25-
const scriptTags = extractScriptTags(this.content);
27+
this.html = parseHtml(this.content);
28+
const scriptTags = extractScriptTags(this.content, this.html);
2629
this.scriptInfo = this.addDefaultLanguage(scriptTags?.script || null, 'script');
2730
this.moduleScriptInfo = this.addDefaultLanguage(scriptTags?.moduleScript || null, 'script');
28-
this.styleInfo = this.addDefaultLanguage(extractStyleTag(this.content), 'style');
31+
this.styleInfo = this.addDefaultLanguage(extractStyleTag(this.content, this.html), 'style');
2932
}
3033

3134
/**

packages/language-server/src/lib/documents/utils.ts

+29-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { clamp, isInRange, regexLastIndexOf } from '../../utils';
22
import { Position, Range } from 'vscode-languageserver';
3-
import { Node, getLanguageService } from 'vscode-html-languageservice';
3+
import { Node, getLanguageService, HTMLDocument } from 'vscode-html-languageservice';
44
import * as path from 'path';
55

66
export interface TagInformation {
@@ -39,7 +39,11 @@ function parseAttributes(
3939
}
4040

4141
const parser = getLanguageService();
42-
function parseHtml(text: string) {
42+
43+
/**
44+
* Parses text as HTML
45+
*/
46+
export function parseHtml(text: string): HTMLDocument {
4347
// We can safely only set getText because only this is used for parsing
4448
return parser.parseHTMLDocument(<any>{ getText: () => text });
4549
}
@@ -77,9 +81,9 @@ function blankIfBlocks(text: string): string {
7781
* @param source text content to extract tag from
7882
* @param tag the tag to extract
7983
*/
80-
function extractTags(text: string, tag: 'script' | 'style'): TagInformation[] {
84+
function extractTags(text: string, tag: 'script' | 'style', html?: HTMLDocument): TagInformation[] {
8185
text = blankIfBlocks(text);
82-
const rootNodes = parseHtml(text).roots;
86+
const rootNodes = html?.roots || parseHtml(text).roots;
8387
const matchedNodes = rootNodes
8488
.filter((node) => node.tag === tag)
8589
.filter((tag) => {
@@ -155,8 +159,9 @@ function extractTags(text: string, tag: 'script' | 'style'): TagInformation[] {
155159

156160
export function extractScriptTags(
157161
source: string,
162+
html?: HTMLDocument,
158163
): { script?: TagInformation; moduleScript?: TagInformation } | null {
159-
const scripts = extractTags(source, 'script');
164+
const scripts = extractTags(source, 'script', html);
160165
if (!scripts.length) {
161166
return null;
162167
}
@@ -166,8 +171,8 @@ export function extractScriptTags(
166171
return { script, moduleScript };
167172
}
168173

169-
export function extractStyleTag(source: string): TagInformation | null {
170-
const styles = extractTags(source, 'style');
174+
export function extractStyleTag(source: string, html?: HTMLDocument): TagInformation | null {
175+
const styles = extractTags(source, 'style', html);
171176
if (!styles.length) {
172177
return null;
173178
}
@@ -291,3 +296,20 @@ export function updateRelativeImport(oldPath: string, newPath: string, relativeI
291296
}
292297
return newImportPath;
293298
}
299+
300+
/**
301+
* Returns the node if offset is inside a component's starttag
302+
*/
303+
export function getNodeIfIsInComponentStartTag(
304+
html: HTMLDocument,
305+
offset: number,
306+
): Node | undefined {
307+
const node = html.findNodeAt(offset);
308+
if (
309+
!!node.tag &&
310+
node.tag[0] === node.tag[0].toUpperCase() &&
311+
(!node.startTagEnd || offset < node.startTagEnd)
312+
) {
313+
return node;
314+
}
315+
}

packages/language-server/src/plugins/html/HTMLPlugin.ts

+16-4
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import {
77
SymbolInformation,
88
CompletionItem,
99
} from 'vscode-languageserver';
10-
import { DocumentManager, Document, isInTag } from '../../lib/documents';
10+
import {
11+
DocumentManager,
12+
Document,
13+
isInTag,
14+
getNodeIfIsInComponentStartTag,
15+
} from '../../lib/documents';
1116
import { LSConfigManager, LSHTMLConfig } from '../../ls-config';
1217
import { svelteHtmlDataProvider } from './dataProvider';
1318
import { HoverProvider, CompletionsProvider } from '../interfaces';
@@ -20,8 +25,7 @@ export class HTMLPlugin implements HoverProvider, CompletionsProvider {
2025
constructor(docManager: DocumentManager, configManager: LSConfigManager) {
2126
this.configManager = configManager;
2227
docManager.on('documentChange', (document) => {
23-
const html = this.lang.parseHTMLDocument(document);
24-
this.documents.set(document, html);
28+
this.documents.set(document, document.html);
2529
});
2630
}
2731

@@ -63,14 +67,22 @@ export class HTMLPlugin implements HoverProvider, CompletionsProvider {
6367
this.lang.setCompletionParticipants([
6468
getEmmetCompletionParticipants(document, position, 'html', {}, emmetResults),
6569
]);
66-
const results = this.lang.doComplete(document, position, html);
70+
const results = this.isInComponentTag(html, document, position)
71+
? // Only allow emmet inside component element tags.
72+
// Other attributes/events would be false positives.
73+
CompletionList.create([])
74+
: this.lang.doComplete(document, position, html);
6775
return CompletionList.create(
6876
[...results.items, ...this.getLangCompletions(results.items), ...emmetResults.items],
6977
// Emmet completions change on every keystroke, so they are never complete
7078
emmetResults.items.length > 0,
7179
);
7280
}
7381

82+
private isInComponentTag(html: HTMLDocument, document: Document, position: Position) {
83+
return !!getNodeIfIsInComponentStartTag(html, document.offsetAt(position));
84+
}
85+
7486
private getLangCompletions(completions: CompletionItem[]): CompletionItem[] {
7587
const styleScriptTemplateCompletions = completions.filter((completion) =>
7688
['template', 'style', 'script'].includes(completion.label),

packages/language-server/src/plugins/typescript/DocumentSnapshot.ts

+19-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { RawSourceMap, SourceMapConsumer } from 'source-map';
2-
import svelte2tsx, { IExportedNames } from 'svelte2tsx';
2+
import svelte2tsx, { IExportedNames, ComponentEvents } from 'svelte2tsx';
33
import ts from 'typescript';
44
import { Position, Range } from 'vscode-languageserver';
55
import {
@@ -86,6 +86,7 @@ export namespace DocumentSnapshot {
8686
tsxMap,
8787
text,
8888
exportedNames,
89+
componentEvents,
8990
parserError,
9091
nrPrependedLines,
9192
scriptKind,
@@ -98,6 +99,7 @@ export namespace DocumentSnapshot {
9899
text,
99100
nrPrependedLines,
100101
exportedNames,
102+
componentEvents,
101103
tsxMap,
102104
);
103105
}
@@ -127,6 +129,7 @@ function preprocessSvelteFile(document: Document, options: SvelteSnapshotOptions
127129
let nrPrependedLines = 0;
128130
let text = document.getText();
129131
let exportedNames: IExportedNames = { has: () => false };
132+
let componentEvents: ComponentEvents | undefined = undefined;
130133

131134
const scriptKind = [
132135
getScriptKindFromAttributes(document.scriptInfo?.attributes ?? {}),
@@ -144,6 +147,7 @@ function preprocessSvelteFile(document: Document, options: SvelteSnapshotOptions
144147
text = tsx.code;
145148
tsxMap = tsx.map;
146149
exportedNames = tsx.exportedNames;
150+
componentEvents = tsx.events;
147151
if (tsxMap) {
148152
tsxMap.sources = [document.uri];
149153

@@ -171,7 +175,15 @@ function preprocessSvelteFile(document: Document, options: SvelteSnapshotOptions
171175
text = document.scriptInfo ? document.scriptInfo.content : '';
172176
}
173177

174-
return { tsxMap, text, exportedNames, parserError, nrPrependedLines, scriptKind };
178+
return {
179+
tsxMap,
180+
text,
181+
exportedNames,
182+
componentEvents,
183+
parserError,
184+
nrPrependedLines,
185+
scriptKind,
186+
};
175187
}
176188

177189
/**
@@ -189,6 +201,7 @@ export class SvelteDocumentSnapshot implements DocumentSnapshot {
189201
private readonly text: string,
190202
private readonly nrPrependedLines: number,
191203
private readonly exportedNames: IExportedNames,
204+
private readonly componentEvents?: ComponentEvents,
192205
private readonly tsxMap?: RawSourceMap,
193206
) {}
194207

@@ -216,6 +229,10 @@ export class SvelteDocumentSnapshot implements DocumentSnapshot {
216229
return this.exportedNames.has(name);
217230
}
218231

232+
getEvents() {
233+
return this.componentEvents?.getAll() || [];
234+
}
235+
219236
async getFragment() {
220237
if (!this.fragment) {
221238
const uri = pathToUrl(this.filePath);

0 commit comments

Comments
 (0)