Skip to content

Commit da98889

Browse files
authored
(svelte2tsx) Allow documenting components with a tagged HTML comment (#285)
* Generate a named default export in svelte2tsx components * Move TSX default export class name formatting to a util * Format default export class name in svelte2tsx in PascalCase * Implement finding @component comment and outputting it * Keep indentation in @component documentation comments * Remove additional extensions in classNameFromFilename * Add documentation about the new supported HTML comments
1 parent 6f30321 commit da98889

File tree

68 files changed

+289
-65
lines changed

Some content is hidden

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

68 files changed

+289
-65
lines changed

docs/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,35 @@ It's also necessary to add a `type="text/language-name"` or `lang="language-name
4545
</style>
4646
```
4747

48+
## Documenting components
49+
50+
To add documentation on a Svelte component that will show up as a docstring in
51+
LSP-compatible editors, you can use an HTML comment with the `@component` tag:
52+
53+
```html
54+
<!--
55+
@component
56+
Here's some documentation for this component. It will show up on hover for
57+
JavaScript/TypeScript projects using a LSP-compatible editor such as VSCode or
58+
Vim/Neovim with coc.nvim.
59+
60+
- You can use markdown here.
61+
- You can use code blocks here.
62+
- JSDoc/TSDoc will be respected by LSP-compatible editors.
63+
- Indentation will be respected as much as possible.
64+
-->
65+
66+
<!-- @component You can use a single line, too -->
67+
68+
<!-- @component But only the last documentation comment will be used -->
69+
70+
<main>
71+
<h1>
72+
Hello world
73+
</h1>
74+
</main>
75+
```
76+
4877
## Troubleshooting / FAQ
4978

5079
### Using TypeScript? See [this section](./preprocessors/typescript.md#troubleshooting--faq)

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,13 @@ function preprocessSvelteFile(document: Document, options: SvelteSnapshotOptions
112112
let text = document.getText();
113113

114114
try {
115-
const tsx = svelte2tsx(text, { strictMode: options.strictMode });
115+
const tsx = svelte2tsx(
116+
text,
117+
{
118+
strictMode: options.strictMode,
119+
filename: document.getFilePath() ?? undefined,
120+
}
121+
);
116122
text = tsx.code;
117123
tsxMap = tsx.map;
118124
if (tsxMap) {

packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ describe('CompletionProviderImpl', () => {
327327
item!,
328328
);
329329

330-
assert.strictEqual(detail, 'Auto import from ./imported-file.svelte\nclass default');
330+
assert.strictEqual(detail, 'Auto import from ./imported-file.svelte\nclass ImportedFile');
331331

332332
assert.strictEqual(
333333
harmonizeNewLines(additionalTextEdits![0]?.newText),
@@ -362,7 +362,7 @@ describe('CompletionProviderImpl', () => {
362362
item!,
363363
);
364364

365-
assert.strictEqual(detail, 'Auto import from ./imported-file.svelte\nclass default');
365+
assert.strictEqual(detail, 'Auto import from ./imported-file.svelte\nclass ImportedFile');
366366

367367
assert.strictEqual(
368368
harmonizeNewLines(additionalTextEdits![0]?.newText),

packages/svelte2tsx/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,9 @@
5757
"LICENSE",
5858
"svelte-jsx.d.ts",
5959
"svelte-shims.d.ts"
60-
]
60+
],
61+
"dependencies": {
62+
"dedent-js": "^1.0.1",
63+
"pascal-case": "^3.1.1"
64+
}
6165
}

packages/svelte2tsx/src/svelte2tsx.ts

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import dedent from 'dedent-js';
2+
import { pascalCase } from 'pascal-case';
13
import MagicString from 'magic-string';
4+
import path from 'path';
25
import { parseHtmlx } from './htmlxparser';
36
import { convertHtmlxToJsx } from './htmlxtojsx';
47
import { Node } from 'estree-walker';
@@ -36,6 +39,8 @@ type TemplateProcessResult = {
3639
slots: Map<string, Map<string, string>>;
3740
scriptTag: Node;
3841
moduleScriptTag: Node;
42+
/** To be added later as a comment on the default class export */
43+
componentDocumentation: string | null;
3944
};
4045

4146
class Scope {
@@ -53,12 +58,20 @@ type pendingStoreResolution<T> = {
5358
scope: Scope;
5459
};
5560

61+
/**
62+
* Add this tag to a HTML comment in a Svelte component and its contents will
63+
* be added as a docstring in the resulting JSX for the component class.
64+
*/
65+
const COMPONENT_DOCUMENTATION_HTML_COMMENT_TAG = '@component';
66+
5667
function processSvelteTemplate(str: MagicString): TemplateProcessResult {
5768
const htmlxAst = parseHtmlx(str.original);
5869

5970
let uses$$props = false;
6071
let uses$$restProps = false;
6172

73+
let componentDocumentation = null;
74+
6275
//track if we are in a declaration scope
6376
let isDeclaration = false;
6477

@@ -167,6 +180,18 @@ function processSvelteTemplate(str: MagicString): TemplateProcessResult {
167180
const enterArrowFunctionExpression = () => pushScope();
168181
const leaveArrowFunctionExpression = () => popScope();
169182

183+
const handleComment = (node: Node) => {
184+
if (
185+
'data' in node &&
186+
typeof node.data === 'string' &&
187+
node.data.includes(COMPONENT_DOCUMENTATION_HTML_COMMENT_TAG)
188+
) {
189+
componentDocumentation = node.data
190+
.replace(COMPONENT_DOCUMENTATION_HTML_COMMENT_TAG, '')
191+
.trim();
192+
}
193+
};
194+
170195
const handleIdentifier = (node: Node, parent: Node, prop: string) => {
171196
if (node.name === '$$props') {
172197
uses$$props = true;
@@ -259,6 +284,9 @@ function processSvelteTemplate(str: MagicString): TemplateProcessResult {
259284
}
260285

261286
switch (node.type) {
287+
case 'Comment':
288+
handleComment(node);
289+
break;
262290
case 'Identifier':
263291
handleIdentifier(node, parent, prop);
264292
break;
@@ -326,6 +354,7 @@ function processSvelteTemplate(str: MagicString): TemplateProcessResult {
326354
slots,
327355
uses$$props,
328356
uses$$restProps,
357+
componentDocumentation,
329358
};
330359
}
331360

@@ -765,11 +794,28 @@ function processInstanceScriptContent(str: MagicString, script: Node): InstanceS
765794
};
766795
}
767796

797+
function formatComponentDocumentation(contents?: string | null) {
798+
if (!contents) return '';
799+
if (!contents.includes('\n')) {
800+
return `/** ${contents} */\n`;
801+
}
802+
803+
const lines = dedent(contents)
804+
.split('\n')
805+
.map(line => ` *${line ? ` ${line}` : ''}`)
806+
.join('\n');
807+
808+
return `/**\n${lines}\n */\n`;
809+
}
810+
768811
function addComponentExport(
769812
str: MagicString,
770813
uses$$propsOr$$restProps: boolean,
771814
strictMode: boolean,
772815
isTsFile: boolean,
816+
/** A named export allows for TSDoc-compatible docstrings */
817+
className?: string,
818+
componentDocumentation?: string | null
773819
) {
774820
const propDef =
775821
// Omit partial-wrapper only if both strict mode and ts file, because
@@ -780,10 +826,30 @@ function addComponentExport(
780826
? '__sveltets_with_any(render().props)'
781827
: 'render().props'
782828
: `__sveltets_partial${uses$$propsOr$$restProps ? '_with_any' : ''}(render().props)`;
783-
str.append(
784-
// eslint-disable-next-line max-len
785-
`\n\nexport default class {\n $$prop_def = ${propDef}\n $$slot_def = render().slots\n}`,
786-
);
829+
830+
const doc = formatComponentDocumentation(componentDocumentation);
831+
832+
// eslint-disable-next-line max-len
833+
const statement = `\n\n${doc}export default class ${className ? `${className} ` : ''}{\n $$prop_def = ${propDef}\n $$slot_def = render().slots\n}`;
834+
835+
str.append(statement);
836+
}
837+
838+
/**
839+
* Returns a Svelte-compatible component name from a filename. Svelte
840+
* components must use capitalized tags, so we try to transform the filename.
841+
*
842+
* https://svelte.dev/docs#Tags
843+
*/
844+
export function classNameFromFilename(filename: string): string | undefined {
845+
try {
846+
const withoutExtensions = path.parse(filename).name?.split('.')[0];
847+
const inPascalCase = pascalCase(withoutExtensions);
848+
return inPascalCase;
849+
} catch (error) {
850+
console.warn(`Failed to create a name for the component class from filename ${filename}`);
851+
return undefined;
852+
}
787853
}
788854

789855
function isTsFile(scriptTag: Node | undefined, moduleScriptTag: Node | undefined) {
@@ -900,7 +966,14 @@ function createPropsStr(exportedNames: ExportedNames) {
900966
export function svelte2tsx(svelte: string, options?: { filename?: string; strictMode?: boolean }) {
901967
const str = new MagicString(svelte);
902968
// process the htmlx as a svelte template
903-
let { moduleScriptTag, scriptTag, slots, uses$$props, uses$$restProps } = processSvelteTemplate(
969+
let {
970+
moduleScriptTag,
971+
scriptTag,
972+
slots,
973+
uses$$props,
974+
uses$$restProps,
975+
componentDocumentation,
976+
} = processSvelteTemplate(
904977
str,
905978
);
906979

@@ -950,11 +1023,15 @@ export function svelte2tsx(svelte: string, options?: { filename?: string; strict
9501023
processModuleScriptTag(str, moduleScriptTag);
9511024
}
9521025

1026+
const className = options?.filename && classNameFromFilename(options?.filename);
1027+
9531028
addComponentExport(
9541029
str,
9551030
uses$$props || uses$$restProps,
9561031
!!options?.strictMode,
9571032
isTsFile(scriptTag, moduleScriptTag),
1033+
className,
1034+
componentDocumentation,
9581035
);
9591036

9601037
return {

packages/svelte2tsx/test/svelte2tsx/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe('svelte2tsx', () => {
1919
const input = fs.readFileSync(`${__dirname}/samples/${dir}/input.svelte`, 'utf-8').replace(/\s+$/, '').replace(/\r\n/g, "\n");
2020
const expectedOutput = fs.readFileSync(`${__dirname}/samples/${dir}/expected.tsx`, 'utf-8').replace(/\s+$/, '').replace(/\r\n/g, "\n");
2121

22-
const { map, code} = svelte2tsx(input, {strictMode: dir.endsWith('strictMode')});
22+
const { map, code} = svelte2tsx(input, {strictMode: dir.endsWith('strictMode'), filename: 'input.svelte'});
2323
assert.equal(code, expectedOutput);
2424
});
2525
});

packages/svelte2tsx/test/svelte2tsx/samples/array-binding-export/expected.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<></>
66
return { props: {a: a , b: b , c: c}, slots: {} }}
77

8-
export default class {
8+
export default class Input {
99
$$prop_def = __sveltets_partial(render().props)
1010
$$slot_def = render().slots
1111
}

packages/svelte2tsx/test/svelte2tsx/samples/ast-offset-none/expected.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ __sveltets_store_get(var);
33
<></>
44
return { props: {}, slots: {} }}
55

6-
export default class {
6+
export default class Input {
77
$$prop_def = __sveltets_partial(render().props)
88
$$slot_def = render().slots
99
}

packages/svelte2tsx/test/svelte2tsx/samples/ast-offset-some/expected.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<></>
44
return { props: {}, slots: {} }}
55

6-
export default class {
6+
export default class Input {
77
$$prop_def = __sveltets_partial(render().props)
88
$$slot_def = render().slots
99
}

packages/svelte2tsx/test/svelte2tsx/samples/await-with-$store/expected.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ function render() {
1313
</>})}}</>
1414
return { props: {}, slots: {} }}
1515

16-
export default class {
16+
export default class Input {
1717
$$prop_def = __sveltets_partial(render().props)
1818
$$slot_def = render().slots
1919
}

0 commit comments

Comments
 (0)