Skip to content

Commit 52e3d5e

Browse files
authored
fix(language-service): format components with HTML void-element names (#5788)
1 parent b6254ec commit 52e3d5e

File tree

4 files changed

+309
-0
lines changed

4 files changed

+309
-0
lines changed

packages/language-core/lib/plugins/vue-tsx.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,5 +276,7 @@ function useCodegen(
276276
getGeneratedScript,
277277
getGeneratedTemplate,
278278
getImportComponentNames,
279+
getSetupBindingNames,
280+
getDirectAccessNames,
279281
};
280282
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// @ts-expect-error
2+
import beautify = require('vscode-html-languageservice/lib/umd/beautify/beautify-html.js');
3+
// @ts-expect-error
4+
import strings = require('vscode-html-languageservice/lib/umd/utils/strings.js');
5+
6+
/*
7+
* original file: https://github.com/microsoft/vscode-html-languageservice/blob/main/src/services/htmlFormatter.ts
8+
* commit: a134f3050c22fe80954241467cd429811792a81d (2024-03-22)
9+
* purpose: override to add void_elements option
10+
*/
11+
12+
/*---------------------------------------------------------------------------------------------
13+
* Copyright (c) Microsoft Corporation. All rights reserved.
14+
* Licensed under the MIT License. See License.txt in the project root for license information.
15+
*--------------------------------------------------------------------------------------------*/
16+
17+
import {
18+
type HTMLFormatConfiguration,
19+
Position,
20+
Range,
21+
type TextDocument,
22+
type TextEdit,
23+
} from 'vscode-html-languageservice';
24+
25+
export function format(
26+
document: TextDocument,
27+
range: Range | undefined,
28+
options: HTMLFormatConfiguration,
29+
voidElements?: string[],
30+
): TextEdit[] {
31+
let value = document.getText();
32+
let includesEnd = true;
33+
let initialIndentLevel = 0;
34+
const tabSize = options.tabSize || 4;
35+
if (range) {
36+
let startOffset = document.offsetAt(range.start);
37+
38+
// include all leading whitespace iff at the beginning of the line
39+
let extendedStart = startOffset;
40+
while (extendedStart > 0 && isWhitespace(value, extendedStart - 1)) {
41+
extendedStart--;
42+
}
43+
if (extendedStart === 0 || isEOL(value, extendedStart - 1)) {
44+
startOffset = extendedStart;
45+
}
46+
else {
47+
// else keep at least one whitespace
48+
if (extendedStart < startOffset) {
49+
startOffset = extendedStart + 1;
50+
}
51+
}
52+
53+
// include all following whitespace until the end of the line
54+
let endOffset = document.offsetAt(range.end);
55+
let extendedEnd = endOffset;
56+
while (extendedEnd < value.length && isWhitespace(value, extendedEnd)) {
57+
extendedEnd++;
58+
}
59+
if (extendedEnd === value.length || isEOL(value, extendedEnd)) {
60+
endOffset = extendedEnd;
61+
}
62+
range = Range.create(document.positionAt(startOffset), document.positionAt(endOffset));
63+
64+
// Do not modify if substring starts in inside an element
65+
// Ending inside an element is fine as it doesn't cause formatting errors
66+
const firstHalf = value.substring(0, startOffset);
67+
if (new RegExp(/.*[<][^>]*$/).test(firstHalf)) {
68+
// return without modification
69+
value = value.substring(startOffset, endOffset);
70+
return [{
71+
range: range,
72+
newText: value,
73+
}];
74+
}
75+
76+
includesEnd = endOffset === value.length;
77+
value = value.substring(startOffset, endOffset);
78+
79+
if (startOffset !== 0) {
80+
const startOfLineOffset = document.offsetAt(Position.create(range.start.line, 0));
81+
initialIndentLevel = computeIndentLevel(document.getText(), startOfLineOffset, options);
82+
}
83+
}
84+
else {
85+
range = Range.create(Position.create(0, 0), document.positionAt(value.length));
86+
}
87+
const htmlOptions = {
88+
indent_size: tabSize,
89+
indent_char: options.insertSpaces ? ' ' : '\t',
90+
indent_empty_lines: getFormatOption(options, 'indentEmptyLines', false),
91+
wrap_line_length: getFormatOption(options, 'wrapLineLength', 120),
92+
unformatted: getTagsFormatOption(options, 'unformatted', void 0),
93+
content_unformatted: getTagsFormatOption(options, 'contentUnformatted', void 0),
94+
indent_inner_html: getFormatOption(options, 'indentInnerHtml', false),
95+
preserve_newlines: getFormatOption(options, 'preserveNewLines', true),
96+
max_preserve_newlines: getFormatOption(options, 'maxPreserveNewLines', 32786),
97+
indent_handlebars: getFormatOption(options, 'indentHandlebars', false),
98+
end_with_newline: includesEnd && getFormatOption(options, 'endWithNewline', false),
99+
extra_liners: getTagsFormatOption(options, 'extraLiners', void 0),
100+
wrap_attributes: getFormatOption(options, 'wrapAttributes', 'auto'),
101+
wrap_attributes_indent_size: getFormatOption(options, 'wrapAttributesIndentSize', void 0),
102+
eol: '\n',
103+
indent_scripts: getFormatOption(options, 'indentScripts', 'normal'),
104+
templating: getTemplatingFormatOption(options, 'all'),
105+
unformatted_content_delimiter: getFormatOption(options, 'unformattedContentDelimiter', ''),
106+
...voidElements !== undefined && { void_elements: voidElements },
107+
};
108+
109+
let result = beautify.html_beautify(trimLeft(value), htmlOptions);
110+
if (initialIndentLevel > 0) {
111+
const indent = options.insertSpaces
112+
? strings.repeat(' ', tabSize * initialIndentLevel)
113+
: strings.repeat('\t', initialIndentLevel);
114+
result = result.split('\n').join('\n' + indent);
115+
if (range.start.character === 0) {
116+
result = indent + result; // keep the indent
117+
}
118+
}
119+
return [{
120+
range: range,
121+
newText: result,
122+
}];
123+
}
124+
125+
function trimLeft(str: string) {
126+
return str.replace(/^\s+/, '');
127+
}
128+
129+
function getFormatOption(options: HTMLFormatConfiguration, key: keyof HTMLFormatConfiguration, dflt: any): any {
130+
if (options && options.hasOwnProperty(key)) {
131+
const value = options[key];
132+
if (value !== null) {
133+
return value;
134+
}
135+
}
136+
return dflt;
137+
}
138+
139+
function getTagsFormatOption(
140+
options: HTMLFormatConfiguration,
141+
key: keyof HTMLFormatConfiguration,
142+
dflt: string[] | undefined,
143+
): string[] | undefined {
144+
const list = <string> getFormatOption(options, key, null);
145+
if (typeof list === 'string') {
146+
if (list.length > 0) {
147+
return list.split(',').map(t => t.trim().toLowerCase());
148+
}
149+
return [];
150+
}
151+
return dflt;
152+
}
153+
154+
function getTemplatingFormatOption(
155+
options: HTMLFormatConfiguration,
156+
dflt: string,
157+
): ('auto' | 'none' | 'angular' | 'django' | 'erb' | 'handlebars' | 'php' | 'smarty')[] | undefined {
158+
const value = getFormatOption(options, 'templating', dflt);
159+
if (value === true) {
160+
return ['auto'];
161+
}
162+
if (value === false || value === dflt || Array.isArray(value) === false) {
163+
return ['none'];
164+
}
165+
return value;
166+
}
167+
168+
function computeIndentLevel(content: string, offset: number, options: HTMLFormatConfiguration): number {
169+
let i = offset;
170+
let nChars = 0;
171+
const tabSize = options.tabSize || 4;
172+
while (i < content.length) {
173+
const ch = content.charAt(i);
174+
if (ch === ' ') {
175+
nChars++;
176+
}
177+
else if (ch === '\t') {
178+
nChars += tabSize;
179+
}
180+
else {
181+
break;
182+
}
183+
i++;
184+
}
185+
return Math.floor(nChars / tabSize);
186+
}
187+
188+
function isEOL(text: string, offset: number) {
189+
return '\r\n'.indexOf(text.charAt(offset)) !== -1;
190+
}
191+
192+
function isWhitespace(text: string, offset: number) {
193+
return ' \t'.indexOf(text.charAt(offset)) !== -1;
194+
}

packages/language-service/lib/plugins/vue-template.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { create as createPugService } from 'volar-service-pug';
2020
import * as html from 'vscode-html-languageservice';
2121
import { URI, Utils } from 'vscode-uri';
2222
import { loadModelModifiersData, loadTemplateData } from '../data';
23+
import { format } from '../htmlFormatter';
2324
import { AttrNameCasing, getAttrNameCasing, getTagNameCasing, TagNameCasing } from '../nameCasing';
2425
import { createReferenceResolver, resolveEmbeddedCode } from '../utils';
2526

@@ -140,6 +141,45 @@ export function create(
140141
}
141142
return parseHTMLDocument(document);
142143
};
144+
htmlService.format = (document, range, options) => {
145+
let voidElements: string[] | undefined;
146+
const info = resolveEmbeddedCode(context, document.uri);
147+
const codegen = info && tsCodegen.get(info.root.sfc);
148+
if (codegen) {
149+
const componentNames = new Set([
150+
...codegen.getImportComponentNames(),
151+
...codegen.getSetupBindingNames(),
152+
]);
153+
// copied from https://github.com/microsoft/vscode-html-languageservice/blob/10daf45dc16b4f4228987cf7cddf3a7dbbdc7570/src/beautify/beautify-html.js#L2746-L2761
154+
voidElements = [
155+
'area',
156+
'base',
157+
'br',
158+
'col',
159+
'embed',
160+
'hr',
161+
'img',
162+
'input',
163+
'keygen',
164+
'link',
165+
'menuitem',
166+
'meta',
167+
'param',
168+
'source',
169+
'track',
170+
'wbr',
171+
'!doctype',
172+
'?xml',
173+
'basefont',
174+
'isindex',
175+
].filter(tag =>
176+
tag
177+
&& !componentNames.has(tag)
178+
&& !componentNames.has(capitalize(camelize(tag)))
179+
);
180+
}
181+
return format(document, range, options, voidElements);
182+
};
143183
}
144184

145185
builtInData ??= loadTemplateData(context.env.locale ?? 'en');
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { defineFormatTest } from '../utils/format';
2+
3+
const title = '#' + __filename.split('.')[0];
4+
5+
defineFormatTest({
6+
title: title + ' (with component)',
7+
languageId: 'vue',
8+
input: `
9+
<script setup lang="ts">
10+
const Link = () => { }
11+
</script>
12+
13+
<template>
14+
\t<Link>
15+
\t1
16+
\t</Link>
17+
\t<img>
18+
\t2
19+
\t</img>
20+
\t<foo>
21+
\t1
22+
\t</foo>
23+
</template>
24+
`.trim(),
25+
output: `
26+
<script setup lang="ts">
27+
const Link = () => { }
28+
</script>
29+
30+
<template>
31+
\t<Link>
32+
\t\t1
33+
\t</Link>
34+
\t<img>
35+
\t2
36+
\t</img>
37+
\t<foo>
38+
\t\t1
39+
\t</foo>
40+
</template>
41+
`.trim(),
42+
});
43+
44+
defineFormatTest({
45+
title: title + ' (without component)',
46+
languageId: 'vue',
47+
input: `
48+
<template>
49+
\t<Link>
50+
\t1
51+
\t</Link>
52+
\t<img>
53+
\t2
54+
\t</img>
55+
\t<foo>
56+
\t1
57+
\t</foo>
58+
</template>
59+
`.trim(),
60+
output: `
61+
<template>
62+
\t<Link>
63+
\t1
64+
\t</Link>
65+
\t<img>
66+
\t2
67+
\t</img>
68+
\t<foo>
69+
\t\t1
70+
\t</foo>
71+
</template>
72+
`.trim(),
73+
});

0 commit comments

Comments
 (0)