@staticbolt/lsp 1.0.0-beta.25 → 1.0.0-beta.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.mjs +329 -184
- package/lib/index.mjs.map +1 -1
- package/package.json +4 -3
- package/src/helpers/config-loader.ts +19 -14
- package/src/helpers/merge-html-data.ts +253 -0
- package/src/html-server.ts +8 -17
- package/src/index.ts +14 -2
- package/src/modes/embedded-support.ts +40 -172
- package/src/modes/html-mode.ts +17 -33
- package/src/modes/language-modes.ts +8 -69
- package/src/modes/markdown-regions.ts +92 -0
package/lib/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { format, stripVTControlCharacters } from "node:util";
|
|
1
2
|
import * as vs from "vscode-languageserver/node";
|
|
2
3
|
import * as vscode$1 from "vscode-languageserver";
|
|
3
4
|
import vscode, { RequestType } from "vscode-languageserver";
|
|
@@ -11,10 +12,12 @@ import * as path$1 from "node:path";
|
|
|
11
12
|
import path, { join } from "node:path";
|
|
12
13
|
import { pathToFileURL } from "node:url";
|
|
13
14
|
import vscodeHtml from "vscode-html-languageservice";
|
|
15
|
+
import { markdownToMdast } from "satteri";
|
|
14
16
|
import { Resolver } from "@staticbolt/core";
|
|
15
17
|
|
|
16
18
|
//#region src/helpers/config-loader.ts
|
|
17
|
-
|
|
19
|
+
/** Iterated in order, so the first one that exists wins. */
|
|
20
|
+
const CONFIG_CANDIDATES = /* @__PURE__ */ new Set([".staticbolt.ts", ".staticbolt.js"]);
|
|
18
21
|
async function findConfigFile(workspaceRoot) {
|
|
19
22
|
for (const name of CONFIG_CANDIDATES) {
|
|
20
23
|
const full = join(workspaceRoot, name);
|
|
@@ -46,7 +49,7 @@ var ConfigLoader = class extends EventEmitter {
|
|
|
46
49
|
this.configPath = await findConfigFile(this.workspaceRoot);
|
|
47
50
|
if (!this.configPath) return null;
|
|
48
51
|
await this.load();
|
|
49
|
-
this.watch(
|
|
52
|
+
this.watch();
|
|
50
53
|
return this.current;
|
|
51
54
|
}
|
|
52
55
|
/** Stop watching and release all resources. */
|
|
@@ -68,9 +71,13 @@ var ConfigLoader = class extends EventEmitter {
|
|
|
68
71
|
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
|
69
72
|
}
|
|
70
73
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
/**
|
|
75
|
+
* The directory is watched rather than the file. A watch on the file itself is bound to the inode, so an editor saving through
|
|
76
|
+
* a temporary file and a rename leaves it watching a replaced file, and on macOS it never reports a plain write either.
|
|
77
|
+
*/
|
|
78
|
+
watch() {
|
|
79
|
+
this.watcher = watch(this.workspaceRoot, { persistent: false }, (_event, filename) => {
|
|
80
|
+
if (typeof filename === "string" && CONFIG_CANDIDATES.has(filename)) this.scheduleReload();
|
|
74
81
|
});
|
|
75
82
|
this.watcher.on("error", (error) => this.emit("error", error));
|
|
76
83
|
}
|
|
@@ -78,11 +85,9 @@ var ConfigLoader = class extends EventEmitter {
|
|
|
78
85
|
if (this.debounceTimer !== null) clearTimeout(this.debounceTimer);
|
|
79
86
|
this.debounceTimer = setTimeout(async () => {
|
|
80
87
|
this.debounceTimer = null;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
} catch {
|
|
85
|
-
this.emit("error", /* @__PURE__ */ new Error(`Config file was removed: ${this.configPath}`));
|
|
88
|
+
this.configPath = await findConfigFile(this.workspaceRoot);
|
|
89
|
+
if (!this.configPath) {
|
|
90
|
+
this.emit("error", /* @__PURE__ */ new Error(`Config file was removed from: ${this.workspaceRoot}`));
|
|
86
91
|
return;
|
|
87
92
|
}
|
|
88
93
|
await this.load();
|
|
@@ -121,7 +126,7 @@ var ConfigManager = class {
|
|
|
121
126
|
for (const configLoader of this.configs.values()) configLoader.dispose();
|
|
122
127
|
}
|
|
123
128
|
async collectData(config) {
|
|
124
|
-
|
|
129
|
+
const collected = [];
|
|
125
130
|
const plugins = config.plugins ?? [];
|
|
126
131
|
for (const pluginOrArray of plugins) {
|
|
127
132
|
const plugins = Array.isArray(pluginOrArray) ? pluginOrArray : [pluginOrArray];
|
|
@@ -130,10 +135,11 @@ var ConfigManager = class {
|
|
|
130
135
|
const htmlData = await plugin.lspHtmlData();
|
|
131
136
|
if (htmlData) {
|
|
132
137
|
htmlDataInjectPluginName(htmlData, plugin.name);
|
|
133
|
-
|
|
138
|
+
collected.push(htmlData);
|
|
134
139
|
}
|
|
135
140
|
}
|
|
136
141
|
}
|
|
142
|
+
this.lspHtmlData = collected;
|
|
137
143
|
}
|
|
138
144
|
};
|
|
139
145
|
function htmlDataInjectPluginName(htmlData, pluginName) {
|
|
@@ -245,17 +251,92 @@ function getLanguageModelCache(maxEntries, cleanupIntervalTimeInSec, parse) {
|
|
|
245
251
|
};
|
|
246
252
|
}
|
|
247
253
|
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region src/modes/markdown-regions.ts
|
|
256
|
+
/**
|
|
257
|
+
* Markdown allows raw HTML anywhere, so a ".md" file is served as HTML with the parts that can never be HTML — front matter, code
|
|
258
|
+
* blocks and code spans — blanked out first. Blanking keeps every offset, so positions still point at the same place in the
|
|
259
|
+
* file.
|
|
260
|
+
*/
|
|
261
|
+
/** None of these nest, so the regions they produce never overlap. */
|
|
262
|
+
const NON_HTML_NODES = /* @__PURE__ */ new Set([
|
|
263
|
+
"code",
|
|
264
|
+
"inlineCode",
|
|
265
|
+
"yaml",
|
|
266
|
+
"toml"
|
|
267
|
+
]);
|
|
268
|
+
/** The regions of a markdown document that must not be treated as HTML, sorted by start offset. */
|
|
269
|
+
function findMarkdownNonHtmlRegions(text) {
|
|
270
|
+
let tree;
|
|
271
|
+
try {
|
|
272
|
+
tree = markdownToMdast(text);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.warn("[staticbolt] could not parse markdown, its whole content is handled as HTML:", error);
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
const regions = [];
|
|
278
|
+
const pending = [tree];
|
|
279
|
+
while (pending.length > 0) {
|
|
280
|
+
const node = pending.pop();
|
|
281
|
+
if (NON_HTML_NODES.has(node.type)) {
|
|
282
|
+
const start = node.position?.start.offset;
|
|
283
|
+
const end = node.position?.end.offset;
|
|
284
|
+
if (start !== void 0 && end !== void 0) regions.push({
|
|
285
|
+
start,
|
|
286
|
+
end
|
|
287
|
+
});
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if ("children" in node) pending.push(...node.children);
|
|
291
|
+
}
|
|
292
|
+
return toUtf16Offsets(text, regions.toSorted((a, b) => a.start - b.start));
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The parser counts code points, the editor counts UTF-16 code units. The two only drift apart once a character outside the basic
|
|
296
|
+
* plane, an emoji most of the time, sits before a region.
|
|
297
|
+
*/
|
|
298
|
+
function toUtf16Offsets(text, regions) {
|
|
299
|
+
if (!/[\uD800-\uDBFF]/.test(text)) return regions;
|
|
300
|
+
const astral = [];
|
|
301
|
+
let codePoint = 0;
|
|
302
|
+
for (const character of text) {
|
|
303
|
+
if (character.length === 2) astral.push(codePoint);
|
|
304
|
+
codePoint++;
|
|
305
|
+
}
|
|
306
|
+
const toUtf16 = (offset) => offset + astral.filter((position) => position < offset).length;
|
|
307
|
+
return regions.map((region) => ({
|
|
308
|
+
start: toUtf16(region.start),
|
|
309
|
+
end: toUtf16(region.end)
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
/** Replaces every region with spaces, keeping the length of the text and its line breaks. Regions must be sorted and disjoint. */
|
|
313
|
+
function blankRegions(text, regions) {
|
|
314
|
+
if (regions.length === 0) return text;
|
|
315
|
+
let result = "";
|
|
316
|
+
let cursor = 0;
|
|
317
|
+
for (const region of regions) {
|
|
318
|
+
result += text.slice(cursor, region.start) + text.slice(region.start, region.end).replaceAll(/[^\n\r]/g, " ");
|
|
319
|
+
cursor = region.end;
|
|
320
|
+
}
|
|
321
|
+
return result + text.slice(cursor);
|
|
322
|
+
}
|
|
323
|
+
|
|
248
324
|
//#endregion
|
|
249
325
|
//#region src/modes/embedded-support.ts
|
|
250
326
|
const TokenType = vscodeHtml.TokenType;
|
|
251
|
-
const Position = vscodeHtml.Position;
|
|
252
327
|
function getDocumentRegions(languageService, document) {
|
|
328
|
+
const source = document.getText();
|
|
329
|
+
const isMarkdown = document.languageId === "markdown";
|
|
330
|
+
const markdownRegions = isMarkdown ? findMarkdownNonHtmlRegions(source).map((region) => ({
|
|
331
|
+
...region,
|
|
332
|
+
languageId: void 0
|
|
333
|
+
})) : [];
|
|
334
|
+
const htmlText = isMarkdown ? blankRegions(source, markdownRegions) : source;
|
|
253
335
|
const regions = [];
|
|
254
|
-
const scanner = languageService.createScanner(
|
|
336
|
+
const scanner = languageService.createScanner(htmlText);
|
|
255
337
|
let lastTagName = "";
|
|
256
338
|
let lastAttributeName = null;
|
|
257
339
|
let languageIdFromType;
|
|
258
|
-
const importedScripts = [];
|
|
259
340
|
let token = scanner.scan();
|
|
260
341
|
while (token !== TokenType.EOS) {
|
|
261
342
|
switch (token) {
|
|
@@ -282,11 +363,7 @@ function getDocumentRegions(languageService, document) {
|
|
|
282
363
|
lastAttributeName = scanner.getTokenText();
|
|
283
364
|
break;
|
|
284
365
|
case TokenType.AttributeValue:
|
|
285
|
-
if (lastAttributeName === "
|
|
286
|
-
let value = scanner.getTokenText();
|
|
287
|
-
if (value[0] === "'" || value[0] === "\"") value = value.slice(1, -1);
|
|
288
|
-
importedScripts.push(value);
|
|
289
|
-
} else if (lastAttributeName === "type" && lastTagName.toLowerCase() === "script") {
|
|
366
|
+
if (lastAttributeName === "type" && lastTagName.toLowerCase() === "script") {
|
|
290
367
|
const token = scanner.getTokenText();
|
|
291
368
|
if (/["'](module|(text|application)\/(java|ecma)script|text\/babel)["']/.test(token) || token === "module") languageIdFromType = "javascript";
|
|
292
369
|
else if (/["']text\/typescript["']/.test(token)) languageIdFromType = "typescript";
|
|
@@ -296,7 +373,7 @@ function getDocumentRegions(languageService, document) {
|
|
|
296
373
|
if (attributeLanguageId) {
|
|
297
374
|
let start = scanner.getTokenOffset();
|
|
298
375
|
let end = scanner.getTokenEnd();
|
|
299
|
-
const firstChar =
|
|
376
|
+
const firstChar = htmlText[start];
|
|
300
377
|
if (firstChar === "'" || firstChar === "\"") {
|
|
301
378
|
start++;
|
|
302
379
|
end--;
|
|
@@ -304,8 +381,7 @@ function getDocumentRegions(languageService, document) {
|
|
|
304
381
|
regions.push({
|
|
305
382
|
languageId: attributeLanguageId,
|
|
306
383
|
start,
|
|
307
|
-
end
|
|
308
|
-
attributeValue: true
|
|
384
|
+
end
|
|
309
385
|
});
|
|
310
386
|
}
|
|
311
387
|
}
|
|
@@ -313,58 +389,30 @@ function getDocumentRegions(languageService, document) {
|
|
|
313
389
|
}
|
|
314
390
|
token = scanner.scan();
|
|
315
391
|
}
|
|
392
|
+
const allRegions = mergeRegions(regions, markdownRegions);
|
|
393
|
+
const htmlDocument = isMarkdown ? TextDocument.create(document.uri, "html", document.version, htmlText) : document;
|
|
316
394
|
return {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
getLanguagesInDocument: () => getLanguagesInDocument(document, regions),
|
|
321
|
-
getImportedScripts: () => importedScripts
|
|
395
|
+
getLanguageAtPosition: (position) => getLanguageAtPosition(document, allRegions, position),
|
|
396
|
+
getLanguagesInDocument: () => getLanguagesInDocument(allRegions),
|
|
397
|
+
getHtmlDocument: () => htmlDocument
|
|
322
398
|
};
|
|
323
399
|
}
|
|
324
|
-
|
|
400
|
+
/** The lookups below read the regions in order, so an overlap is dropped: a scanned region always wins over a markdown one. */
|
|
401
|
+
function mergeRegions(scanned, markdown) {
|
|
402
|
+
if (markdown.length === 0) return scanned;
|
|
403
|
+
const merged = [...scanned, ...markdown].toSorted((a, b) => a.start - b.start || b.end - a.end);
|
|
325
404
|
const result = [];
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (!(region.end > currentOffset && region.start < endOffset)) continue;
|
|
331
|
-
const start = Math.max(region.start, currentOffset);
|
|
332
|
-
const startPos = document.positionAt(start);
|
|
333
|
-
if (currentOffset < region.start) result.push({
|
|
334
|
-
start: currentPos,
|
|
335
|
-
end: startPos,
|
|
336
|
-
languageId: "html"
|
|
337
|
-
});
|
|
338
|
-
const end = Math.min(region.end, endOffset);
|
|
339
|
-
const endPos = document.positionAt(end);
|
|
340
|
-
if (end > region.start) result.push({
|
|
341
|
-
start: startPos,
|
|
342
|
-
end: endPos,
|
|
343
|
-
languageId: region.languageId,
|
|
344
|
-
attributeValue: region.attributeValue
|
|
345
|
-
});
|
|
346
|
-
currentOffset = end;
|
|
347
|
-
currentPos = endPos;
|
|
348
|
-
}
|
|
349
|
-
if (currentOffset < endOffset) {
|
|
350
|
-
const endPos = range ? range.end : document.positionAt(endOffset);
|
|
351
|
-
result.push({
|
|
352
|
-
start: currentPos,
|
|
353
|
-
end: endPos,
|
|
354
|
-
languageId: "html"
|
|
355
|
-
});
|
|
405
|
+
for (const region of merged) {
|
|
406
|
+
const previous = result.at(-1);
|
|
407
|
+
if (previous && region.start < previous.end) continue;
|
|
408
|
+
result.push(region);
|
|
356
409
|
}
|
|
357
410
|
return result;
|
|
358
411
|
}
|
|
359
|
-
function getLanguagesInDocument(
|
|
360
|
-
const
|
|
361
|
-
for (const region of regions)
|
|
362
|
-
|
|
363
|
-
result.push(region.languageId);
|
|
364
|
-
if (result.length === 3) return result;
|
|
365
|
-
}
|
|
366
|
-
result.push("html");
|
|
367
|
-
return result;
|
|
412
|
+
function getLanguagesInDocument(regions) {
|
|
413
|
+
const languages = /* @__PURE__ */ new Set(["html"]);
|
|
414
|
+
for (const region of regions) if (region.languageId) languages.add(region.languageId);
|
|
415
|
+
return [...languages];
|
|
368
416
|
}
|
|
369
417
|
function getLanguageAtPosition(document, regions, position) {
|
|
370
418
|
const offset = document.offsetAt(position);
|
|
@@ -373,89 +421,216 @@ function getLanguageAtPosition(document, regions, position) {
|
|
|
373
421
|
} else break;
|
|
374
422
|
return "html";
|
|
375
423
|
}
|
|
376
|
-
function getEmbeddedDocument(document, contents, languageId, shouldIgnoreAttributeValues) {
|
|
377
|
-
let currentPos = 0;
|
|
378
|
-
const oldContent = document.getText();
|
|
379
|
-
let result = "";
|
|
380
|
-
let lastSuffix = "";
|
|
381
|
-
for (const c of contents) {
|
|
382
|
-
if (!(c.languageId === languageId && (!shouldIgnoreAttributeValues || !c.attributeValue))) continue;
|
|
383
|
-
result = substituteWithWhitespace(result, currentPos, c.start, oldContent, lastSuffix, getPrefix(c));
|
|
384
|
-
result += updateContent(c, oldContent.slice(c.start, c.end));
|
|
385
|
-
currentPos = c.end;
|
|
386
|
-
lastSuffix = getSuffix(c);
|
|
387
|
-
}
|
|
388
|
-
result = substituteWithWhitespace(result, currentPos, oldContent.length, oldContent, lastSuffix, "");
|
|
389
|
-
return TextDocument.create(document.uri, languageId, document.version, result);
|
|
390
|
-
}
|
|
391
|
-
function getPrefix(c) {
|
|
392
|
-
if (c.attributeValue) switch (c.languageId) {
|
|
393
|
-
case "css": return "__{";
|
|
394
|
-
}
|
|
395
|
-
return "";
|
|
396
|
-
}
|
|
397
|
-
function getSuffix(c) {
|
|
398
|
-
if (c.attributeValue) switch (c.languageId) {
|
|
399
|
-
case "css": return "}";
|
|
400
|
-
case "javascript": return ";";
|
|
401
|
-
}
|
|
402
|
-
return "";
|
|
403
|
-
}
|
|
404
|
-
function updateContent(c, content) {
|
|
405
|
-
if (!c.attributeValue && c.languageId === "javascript") return content.replace(`<!--`, `/* `).replace(`-->`, ` */`);
|
|
406
|
-
if (c.languageId === "css") return content.replace(/("|")/g, (match, _, offset) => {
|
|
407
|
-
const spaces = " ".repeat(match.length - 1);
|
|
408
|
-
const afterChar = content[offset + match.length];
|
|
409
|
-
if (!afterChar || afterChar.includes(" ")) return `${spaces}"`;
|
|
410
|
-
return `"${spaces}`;
|
|
411
|
-
});
|
|
412
|
-
return content;
|
|
413
|
-
}
|
|
414
|
-
function substituteWithWhitespace(result, start, end, oldContent, before, after) {
|
|
415
|
-
result += before;
|
|
416
|
-
let accumulatedWS = -before.length;
|
|
417
|
-
for (let index = start; index < end; index++) {
|
|
418
|
-
const ch = oldContent[index];
|
|
419
|
-
if (ch === "\n" || ch === "\r") {
|
|
420
|
-
accumulatedWS = 0;
|
|
421
|
-
result += ch;
|
|
422
|
-
} else accumulatedWS++;
|
|
423
|
-
}
|
|
424
|
-
result = append(result, " ", accumulatedWS - after.length);
|
|
425
|
-
result += after;
|
|
426
|
-
return result;
|
|
427
|
-
}
|
|
428
|
-
function append(result, string_, n) {
|
|
429
|
-
while (n > 0) {
|
|
430
|
-
if (n & 1) result += string_;
|
|
431
|
-
n >>= 1;
|
|
432
|
-
string_ += string_;
|
|
433
|
-
}
|
|
434
|
-
return result;
|
|
435
|
-
}
|
|
436
424
|
function getAttributeLanguage(attributeName) {
|
|
437
425
|
const match = attributeName.match(/^(style)$|^(on\w+)$/i);
|
|
438
426
|
if (!match) return null;
|
|
439
427
|
return match[1] ? "css" : "javascript";
|
|
440
428
|
}
|
|
441
429
|
|
|
430
|
+
//#endregion
|
|
431
|
+
//#region src/helpers/merge-html-data.ts
|
|
432
|
+
/** Separates the documentation of two contributors. Rendered as a horizontal rule, unless the description is plain text. */
|
|
433
|
+
const MARKDOWN_SEPARATOR = "\n\n---\n\n";
|
|
434
|
+
const PLAINTEXT_SEPARATOR = "\n\n";
|
|
435
|
+
/** Groups entries by key, preserving the order of first appearance. */
|
|
436
|
+
function groupBy(items, toKey) {
|
|
437
|
+
const groups = /* @__PURE__ */ new Map();
|
|
438
|
+
const ordered = [];
|
|
439
|
+
for (const item of items) {
|
|
440
|
+
const key = toKey(item);
|
|
441
|
+
let group = groups.get(key);
|
|
442
|
+
if (!group) {
|
|
443
|
+
group = [];
|
|
444
|
+
groups.set(key, group);
|
|
445
|
+
ordered.push(group);
|
|
446
|
+
}
|
|
447
|
+
group.push(item);
|
|
448
|
+
}
|
|
449
|
+
return ordered;
|
|
450
|
+
}
|
|
451
|
+
/** Markdown wins over plain text, so a contributor asking for rich text still gets it. Plain strings imply no preference. */
|
|
452
|
+
function mergeDescriptionKind(descriptions) {
|
|
453
|
+
let kind;
|
|
454
|
+
for (const description of descriptions) {
|
|
455
|
+
if (typeof description === "string" || kind === "markdown") continue;
|
|
456
|
+
kind = description.kind;
|
|
457
|
+
}
|
|
458
|
+
return kind;
|
|
459
|
+
}
|
|
460
|
+
/** Concatenates every distinct description, so no contributor's documentation gets lost. */
|
|
461
|
+
function mergeDescriptions(descriptions) {
|
|
462
|
+
const defined = descriptions.filter((description) => description !== void 0);
|
|
463
|
+
if (defined.length <= 1) return defined[0];
|
|
464
|
+
const parts = [];
|
|
465
|
+
const seen = /* @__PURE__ */ new Set();
|
|
466
|
+
for (const description of defined) {
|
|
467
|
+
const text = (typeof description === "string" ? description : description.value).trim();
|
|
468
|
+
if (!text || seen.has(text)) continue;
|
|
469
|
+
seen.add(text);
|
|
470
|
+
parts.push(text);
|
|
471
|
+
}
|
|
472
|
+
if (parts.length === 0) return void 0;
|
|
473
|
+
const kind = mergeDescriptionKind(defined);
|
|
474
|
+
const value = parts.join(kind === "plaintext" ? PLAINTEXT_SEPARATOR : MARKDOWN_SEPARATOR);
|
|
475
|
+
return kind ? {
|
|
476
|
+
kind,
|
|
477
|
+
value
|
|
478
|
+
} : value;
|
|
479
|
+
}
|
|
480
|
+
function mergeReferences(references) {
|
|
481
|
+
const merged = [];
|
|
482
|
+
const seen = /* @__PURE__ */ new Set();
|
|
483
|
+
for (const list of references) {
|
|
484
|
+
const entries = list ?? [];
|
|
485
|
+
for (const reference of entries) {
|
|
486
|
+
const key = `${reference.name} ${reference.url}`;
|
|
487
|
+
if (seen.has(key)) continue;
|
|
488
|
+
seen.add(key);
|
|
489
|
+
merged.push(reference);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return merged.length > 0 ? merged : void 0;
|
|
493
|
+
}
|
|
494
|
+
function mergeBrowsers(browsers) {
|
|
495
|
+
const merged = /* @__PURE__ */ new Set();
|
|
496
|
+
for (const list of browsers) {
|
|
497
|
+
const entries = list ?? [];
|
|
498
|
+
for (const browser of entries) merged.add(browser);
|
|
499
|
+
}
|
|
500
|
+
return merged.size > 0 ? [...merged] : void 0;
|
|
501
|
+
}
|
|
502
|
+
/** The values an attribute contributes, with its `valueSet` reference expanded. */
|
|
503
|
+
function resolveValues(attribute, valueSets) {
|
|
504
|
+
const values = attribute.values ?? [];
|
|
505
|
+
if (!attribute.valueSet) return values;
|
|
506
|
+
return [...valueSets.get(attribute.valueSet) ?? [], ...values];
|
|
507
|
+
}
|
|
508
|
+
/** Merges entries sharing the same value name into one. Unlike tags and attributes, value names are case-sensitive. */
|
|
509
|
+
function mergeValues(values) {
|
|
510
|
+
return groupBy(values, (value) => value.name).map((group) => {
|
|
511
|
+
if (group.length === 1) return group[0];
|
|
512
|
+
return {
|
|
513
|
+
name: group[0].name,
|
|
514
|
+
description: mergeDescriptions(group.map((value) => value.description)),
|
|
515
|
+
references: mergeReferences(group.map((value) => value.references)),
|
|
516
|
+
browsers: mergeBrowsers(group.map((value) => value.browsers)),
|
|
517
|
+
status: group.find((value) => value.status)?.status
|
|
518
|
+
};
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Merges entries sharing the same attribute name into one, combining their descriptions, values, references and browser support.
|
|
523
|
+
*
|
|
524
|
+
* `valueSet` references are expanded into the merged `values`, so attributes contributed by different plugins can each bring
|
|
525
|
+
* their own value set and still end up with a single, complete value list.
|
|
526
|
+
*/
|
|
527
|
+
function mergeAttributes(attributes, valueSets) {
|
|
528
|
+
return groupBy(attributes, (attribute) => attribute.name.toLowerCase()).map((group) => {
|
|
529
|
+
if (group.length === 1 && !group[0].valueSet) return group[0];
|
|
530
|
+
const values = mergeValues(group.flatMap((attribute) => resolveValues(attribute, valueSets)));
|
|
531
|
+
return {
|
|
532
|
+
name: group[0].name,
|
|
533
|
+
description: mergeDescriptions(group.map((attribute) => attribute.description)),
|
|
534
|
+
values: values.length > 0 ? values : void 0,
|
|
535
|
+
references: mergeReferences(group.map((attribute) => attribute.references)),
|
|
536
|
+
browsers: mergeBrowsers(group.map((attribute) => attribute.browsers)),
|
|
537
|
+
status: group.find((attribute) => attribute.status)?.status
|
|
538
|
+
};
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
/** Merges entries sharing the same tag name into one, including their attributes. */
|
|
542
|
+
function mergeTags(tags, valueSets) {
|
|
543
|
+
return groupBy(tags, (tag) => tag.name.toLowerCase()).map((group) => {
|
|
544
|
+
const attributes = mergeAttributes(group.flatMap((tag) => tag.attributes ?? []), valueSets);
|
|
545
|
+
if (group.length === 1) return {
|
|
546
|
+
...group[0],
|
|
547
|
+
attributes
|
|
548
|
+
};
|
|
549
|
+
return {
|
|
550
|
+
name: group[0].name,
|
|
551
|
+
description: mergeDescriptions(group.map((tag) => tag.description)),
|
|
552
|
+
attributes,
|
|
553
|
+
references: mergeReferences(group.map((tag) => tag.references)),
|
|
554
|
+
browsers: mergeBrowsers(group.map((tag) => tag.browsers)),
|
|
555
|
+
status: group.find((tag) => tag.status)?.status,
|
|
556
|
+
void: group.some((tag) => tag.void)
|
|
557
|
+
};
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
/** Value sets sharing a name are merged, so an attribute referencing one gets the values of every contributor. */
|
|
561
|
+
function collectValueSets(htmlData) {
|
|
562
|
+
const valueSets = /* @__PURE__ */ new Map();
|
|
563
|
+
const collected = htmlData.flatMap((data) => data.valueSets ?? []);
|
|
564
|
+
for (const valueSet of collected) {
|
|
565
|
+
const existing = valueSets.get(valueSet.name);
|
|
566
|
+
valueSets.set(valueSet.name, existing ? mergeValues([...existing, ...valueSet.values]) : valueSet.values);
|
|
567
|
+
}
|
|
568
|
+
return valueSets;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Builds a single data provider out of every collected `HTMLDataV1`, merging tags, attributes and values that share the same name
|
|
572
|
+
* instead of reporting them once per contributor.
|
|
573
|
+
*/
|
|
574
|
+
function createMergedHtmlDataProvider(id, htmlData) {
|
|
575
|
+
const valueSets = collectValueSets(htmlData);
|
|
576
|
+
const tags = mergeTags(htmlData.flatMap((data) => data.tags ?? []), valueSets);
|
|
577
|
+
const globalAttributes = mergeAttributes(htmlData.flatMap((data) => data.globalAttributes ?? []), valueSets);
|
|
578
|
+
const tagsByName = new Map(tags.map((tag) => [tag.name.toLowerCase(), tag]));
|
|
579
|
+
const attributesByTag = /* @__PURE__ */ new Map();
|
|
580
|
+
function provideAttributes(tag) {
|
|
581
|
+
const key = tag.toLowerCase();
|
|
582
|
+
const cached = attributesByTag.get(key);
|
|
583
|
+
if (cached) return cached;
|
|
584
|
+
const tagAttributes = tagsByName.get(key)?.attributes;
|
|
585
|
+
if (!tagAttributes || tagAttributes.length === 0) return globalAttributes;
|
|
586
|
+
const attributes = mergeAttributes([...globalAttributes, ...tagAttributes], valueSets);
|
|
587
|
+
attributesByTag.set(key, attributes);
|
|
588
|
+
return attributes;
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
getId() {
|
|
592
|
+
return id;
|
|
593
|
+
},
|
|
594
|
+
isApplicable(languageId) {
|
|
595
|
+
return languageId === "html";
|
|
596
|
+
},
|
|
597
|
+
provideTags() {
|
|
598
|
+
return tags;
|
|
599
|
+
},
|
|
600
|
+
provideAttributes,
|
|
601
|
+
provideValues(tag, attribute) {
|
|
602
|
+
const name = attribute.toLowerCase();
|
|
603
|
+
return provideAttributes(tag).find((a) => a.name.toLowerCase() === name)?.values ?? [];
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
442
608
|
//#endregion
|
|
443
609
|
//#region src/modes/html-mode.ts
|
|
444
610
|
function getHTMLMode(htmlLanguageService) {
|
|
445
611
|
const htmlDocuments = getLanguageModelCache(10, 60, (document) => htmlLanguageService.parseHTMLDocument(document));
|
|
612
|
+
let lastHtmlData;
|
|
613
|
+
let lastProvider;
|
|
614
|
+
function setHtmlDataProviders(htmlData) {
|
|
615
|
+
if (!lastProvider || lastHtmlData !== htmlData) {
|
|
616
|
+
lastHtmlData = htmlData;
|
|
617
|
+
lastProvider = createMergedHtmlDataProvider(FILE_PROTOCOL, htmlData);
|
|
618
|
+
}
|
|
619
|
+
htmlLanguageService.setDataProviders(false, [lastProvider]);
|
|
620
|
+
}
|
|
446
621
|
return {
|
|
447
622
|
getId() {
|
|
448
623
|
return "html";
|
|
449
624
|
},
|
|
450
625
|
async doComplete(document, position, documentContext, htmlData) {
|
|
451
|
-
setHtmlDataProviders(
|
|
626
|
+
setHtmlDataProviders(htmlData);
|
|
452
627
|
const htmlDocument = htmlDocuments.get(document);
|
|
453
628
|
const completionList = await htmlLanguageService.doComplete2(document, position, htmlDocument, documentContext);
|
|
454
629
|
for (const item of completionList.items) item.sortText = "0_" + item.label;
|
|
455
630
|
return completionList;
|
|
456
631
|
},
|
|
457
632
|
async doHover(document, position, htmlData) {
|
|
458
|
-
setHtmlDataProviders(
|
|
633
|
+
setHtmlDataProviders(htmlData);
|
|
459
634
|
return htmlLanguageService.doHover(document, position, htmlDocuments.get(document));
|
|
460
635
|
},
|
|
461
636
|
async onDocumentRemoved(document) {
|
|
@@ -480,32 +655,12 @@ function getHTMLMode(htmlLanguageService) {
|
|
|
480
655
|
}
|
|
481
656
|
};
|
|
482
657
|
}
|
|
483
|
-
function setHtmlDataProviders(htmlLanguageService, htmlData) {
|
|
484
|
-
htmlLanguageService.setDataProviders(false, [{
|
|
485
|
-
getId() {
|
|
486
|
-
return FILE_PROTOCOL;
|
|
487
|
-
},
|
|
488
|
-
isApplicable(languageId) {
|
|
489
|
-
return languageId === "html";
|
|
490
|
-
},
|
|
491
|
-
provideValues(tag, attribute) {
|
|
492
|
-
const attributes = htmlData.flatMap((data) => data.tags?.filter((t) => t.name === tag) ?? []).flatMap((t) => t.attributes?.filter((a) => a.name === attribute) ?? []);
|
|
493
|
-
return [...htmlData.flatMap((data) => data.globalAttributes?.filter((a) => a.name === attribute) ?? []), ...attributes].flatMap((a) => a.values ?? []);
|
|
494
|
-
},
|
|
495
|
-
provideTags() {
|
|
496
|
-
return htmlData.flatMap((data) => data.tags ?? []);
|
|
497
|
-
},
|
|
498
|
-
provideAttributes(tag) {
|
|
499
|
-
const tags = htmlData.flatMap((data) => data.tags?.filter((t) => t.name === tag) ?? []);
|
|
500
|
-
const globalAttributes = htmlData.flatMap((data) => data.globalAttributes ?? []);
|
|
501
|
-
const attributes = tags.flatMap((t) => t.attributes ?? []);
|
|
502
|
-
return [...globalAttributes, ...attributes];
|
|
503
|
-
}
|
|
504
|
-
}]);
|
|
505
|
-
}
|
|
506
658
|
|
|
507
659
|
//#endregion
|
|
508
660
|
//#region src/modes/language-modes.ts
|
|
661
|
+
function isCompletionItemData(value) {
|
|
662
|
+
return value && typeof value.languageId === "string" && typeof value.uri === "string" && typeof value.offset === "number";
|
|
663
|
+
}
|
|
509
664
|
const FILE_PROTOCOL = "staticbolt-server";
|
|
510
665
|
function getLanguageModes(clientCapabilities, requestService) {
|
|
511
666
|
const htmlLanguageService = vscodeHtml.getLanguageService({
|
|
@@ -547,20 +702,13 @@ function getLanguageModes(clientCapabilities, requestService) {
|
|
|
547
702
|
async updateDataProviders(dataProviders) {
|
|
548
703
|
htmlLanguageService.setDataProviders(true, dataProviders);
|
|
549
704
|
},
|
|
705
|
+
getHtmlDocument(document) {
|
|
706
|
+
return documentRegions.get(document).getHtmlDocument();
|
|
707
|
+
},
|
|
550
708
|
getModeAtPosition(document, position) {
|
|
551
709
|
const languageId = documentRegions.get(document).getLanguageAtPosition(position);
|
|
552
710
|
if (languageId) return modes[languageId];
|
|
553
711
|
},
|
|
554
|
-
getModesInRange(document, range) {
|
|
555
|
-
return documentRegions.get(document).getLanguageRanges(range).map((r) => {
|
|
556
|
-
return {
|
|
557
|
-
start: r.start,
|
|
558
|
-
end: r.end,
|
|
559
|
-
mode: r.languageId ? modes[r.languageId] : void 0,
|
|
560
|
-
attributeValue: r.attributeValue
|
|
561
|
-
};
|
|
562
|
-
});
|
|
563
|
-
},
|
|
564
712
|
getAllModesInDocument(document) {
|
|
565
713
|
const result = [];
|
|
566
714
|
for (const languageId of documentRegions.get(document).getLanguagesInDocument()) {
|
|
@@ -569,14 +717,6 @@ function getLanguageModes(clientCapabilities, requestService) {
|
|
|
569
717
|
}
|
|
570
718
|
return result;
|
|
571
719
|
},
|
|
572
|
-
getAllModes() {
|
|
573
|
-
const result = [];
|
|
574
|
-
for (const languageId in modes) {
|
|
575
|
-
const mode = modes[languageId];
|
|
576
|
-
if (mode) result.push(mode);
|
|
577
|
-
}
|
|
578
|
-
return result;
|
|
579
|
-
},
|
|
580
720
|
getMode(languageId) {
|
|
581
721
|
return modes[languageId];
|
|
582
722
|
},
|
|
@@ -718,10 +858,6 @@ function startServer(connection, runtime) {
|
|
|
718
858
|
let workspaceFolders = [];
|
|
719
859
|
let configManager;
|
|
720
860
|
let languageModes;
|
|
721
|
-
const documentSettings = {};
|
|
722
|
-
documents.onDidClose((document) => {
|
|
723
|
-
delete documentSettings[document.document.uri];
|
|
724
|
-
});
|
|
725
861
|
connection.onInitialize((parameters) => {
|
|
726
862
|
if (Array.isArray(parameters.workspaceFolders)) lspSearchRoots = parameters.workspaceFolders;
|
|
727
863
|
else {
|
|
@@ -776,8 +912,9 @@ function startServer(connection, runtime) {
|
|
|
776
912
|
isIncomplete: true,
|
|
777
913
|
items: []
|
|
778
914
|
};
|
|
915
|
+
const htmlDocument = languageModes.getHtmlDocument(document);
|
|
779
916
|
const documentContext = getDocumentContext(document.uri, workspaceFolders);
|
|
780
|
-
return mode.doComplete(
|
|
917
|
+
return mode.doComplete(htmlDocument, textDocumentPosition.position, documentContext, configManager.lspHtmlData);
|
|
781
918
|
}, null, `Error while computing completions for ${textDocumentPosition.textDocument.uri}`, token);
|
|
782
919
|
});
|
|
783
920
|
connection.onCompletionResolve((item, token) => {
|
|
@@ -788,7 +925,7 @@ function startServer(connection, runtime) {
|
|
|
788
925
|
if (!document) return item;
|
|
789
926
|
const mode = languageModes.getMode(data.languageId);
|
|
790
927
|
if (!mode?.doResolve) return item;
|
|
791
|
-
return mode.doResolve(document, item);
|
|
928
|
+
return mode.doResolve(languageModes.getHtmlDocument(document), item);
|
|
792
929
|
}, item, `Error while resolving completion proposal`, token);
|
|
793
930
|
});
|
|
794
931
|
connection.onHover((textDocumentPosition, token) => {
|
|
@@ -800,7 +937,7 @@ function startServer(connection, runtime) {
|
|
|
800
937
|
const mode = languageModes.getModeAtPosition(document, textDocumentPosition.position);
|
|
801
938
|
if (!mode?.doHover) return null;
|
|
802
939
|
if (!await configManager.get(projectRoot)) return null;
|
|
803
|
-
return mode.doHover(document, textDocumentPosition.position, configManager.lspHtmlData);
|
|
940
|
+
return mode.doHover(languageModes.getHtmlDocument(document), textDocumentPosition.position, configManager.lspHtmlData);
|
|
804
941
|
}, null, `Error while computing hover for ${textDocumentPosition.textDocument.uri}`, token);
|
|
805
942
|
});
|
|
806
943
|
connection.onDocumentLinks((documentLinkParameter, token) => {
|
|
@@ -810,16 +947,14 @@ function startServer(connection, runtime) {
|
|
|
810
947
|
const document = documents.get(documentLinkParameter.textDocument.uri);
|
|
811
948
|
if (!document) return [];
|
|
812
949
|
const links = [];
|
|
950
|
+
const htmlDocument = languageModes.getHtmlDocument(document);
|
|
813
951
|
const documentContext = getDocumentContext(document.uri, workspaceFolders);
|
|
814
|
-
for (const mode of languageModes.getAllModesInDocument(document)) if (mode.findDocumentLinks) pushAll(links, await mode.findDocumentLinks(
|
|
952
|
+
for (const mode of languageModes.getAllModesInDocument(document)) if (mode.findDocumentLinks) pushAll(links, await mode.findDocumentLinks(htmlDocument, documentContext, projectRoot));
|
|
815
953
|
return links;
|
|
816
954
|
}, [], `Error while document links for ${documentLinkParameter.textDocument.uri}`, token);
|
|
817
955
|
});
|
|
818
956
|
connection.listen();
|
|
819
957
|
}
|
|
820
|
-
function isCompletionItemData(value) {
|
|
821
|
-
return value && typeof value.languageId === "string" && typeof value.uri === "string" && typeof value.offset === "number";
|
|
822
|
-
}
|
|
823
958
|
|
|
824
959
|
//#endregion
|
|
825
960
|
//#region src/utils/node-fs.ts
|
|
@@ -882,8 +1017,18 @@ function getNodeFileFS() {
|
|
|
882
1017
|
//#endregion
|
|
883
1018
|
//#region src/index.ts
|
|
884
1019
|
const connection = vs.createConnection();
|
|
885
|
-
|
|
886
|
-
console
|
|
1020
|
+
/**
|
|
1021
|
+
* `RemoteConsole` takes a single string, but the shared logger calls `console` with several arguments and colours them with
|
|
1022
|
+
* chalk. Passing the methods straight through drops everything after the first argument, and the output panel renders no ANSI —
|
|
1023
|
+
* so format the arguments the way `console` would, then strip the escapes.
|
|
1024
|
+
*/
|
|
1025
|
+
function forward(write) {
|
|
1026
|
+
return (...messages) => write(stripVTControlCharacters(format(...messages)));
|
|
1027
|
+
}
|
|
1028
|
+
console.log = forward(connection.console.log.bind(connection.console));
|
|
1029
|
+
console.info = forward(connection.console.info.bind(connection.console));
|
|
1030
|
+
console.warn = forward(connection.console.warn.bind(connection.console));
|
|
1031
|
+
console.error = forward(connection.console.error.bind(connection.console));
|
|
887
1032
|
process.on("unhandledRejection", (error) => {
|
|
888
1033
|
connection.console.error(formatError(`Unhandled exception`, error));
|
|
889
1034
|
});
|