@staticbolt/lsp 1.0.0-beta.26 → 1.0.0-beta.28

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 CHANGED
@@ -12,10 +12,12 @@ import * as path$1 from "node:path";
12
12
  import path, { join } from "node:path";
13
13
  import { pathToFileURL } from "node:url";
14
14
  import vscodeHtml from "vscode-html-languageservice";
15
+ import { markdownToMdast } from "satteri";
15
16
  import { Resolver } from "@staticbolt/core";
16
17
 
17
18
  //#region src/helpers/config-loader.ts
18
- const CONFIG_CANDIDATES = [".staticbolt.ts", ".staticbolt.js"];
19
+ /** Iterated in order, so the first one that exists wins. */
20
+ const CONFIG_CANDIDATES = /* @__PURE__ */ new Set([".staticbolt.ts", ".staticbolt.js"]);
19
21
  async function findConfigFile(workspaceRoot) {
20
22
  for (const name of CONFIG_CANDIDATES) {
21
23
  const full = join(workspaceRoot, name);
@@ -47,7 +49,7 @@ var ConfigLoader = class extends EventEmitter {
47
49
  this.configPath = await findConfigFile(this.workspaceRoot);
48
50
  if (!this.configPath) return null;
49
51
  await this.load();
50
- this.watch(this.configPath);
52
+ this.watch();
51
53
  return this.current;
52
54
  }
53
55
  /** Stop watching and release all resources. */
@@ -69,9 +71,13 @@ var ConfigLoader = class extends EventEmitter {
69
71
  this.emit("error", error instanceof Error ? error : new Error(String(error)));
70
72
  }
71
73
  }
72
- watch(configPath) {
73
- this.watcher = watch(configPath, { persistent: false }, (event) => {
74
- if (event === "rename" || event === "change") this.scheduleReload();
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();
75
81
  });
76
82
  this.watcher.on("error", (error) => this.emit("error", error));
77
83
  }
@@ -79,11 +85,9 @@ var ConfigLoader = class extends EventEmitter {
79
85
  if (this.debounceTimer !== null) clearTimeout(this.debounceTimer);
80
86
  this.debounceTimer = setTimeout(async () => {
81
87
  this.debounceTimer = null;
82
- if (!this.configPath) return;
83
- try {
84
- await access(this.configPath, constants.R_OK);
85
- } catch {
86
- 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}`));
87
91
  return;
88
92
  }
89
93
  await this.load();
@@ -247,17 +251,92 @@ function getLanguageModelCache(maxEntries, cleanupIntervalTimeInSec, parse) {
247
251
  };
248
252
  }
249
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
+
250
324
  //#endregion
251
325
  //#region src/modes/embedded-support.ts
252
326
  const TokenType = vscodeHtml.TokenType;
253
- const Position = vscodeHtml.Position;
254
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;
255
335
  const regions = [];
256
- const scanner = languageService.createScanner(document.getText());
336
+ const scanner = languageService.createScanner(htmlText);
257
337
  let lastTagName = "";
258
338
  let lastAttributeName = null;
259
339
  let languageIdFromType;
260
- const importedScripts = [];
261
340
  let token = scanner.scan();
262
341
  while (token !== TokenType.EOS) {
263
342
  switch (token) {
@@ -284,11 +363,7 @@ function getDocumentRegions(languageService, document) {
284
363
  lastAttributeName = scanner.getTokenText();
285
364
  break;
286
365
  case TokenType.AttributeValue:
287
- if (lastAttributeName === "src" && lastTagName.toLowerCase() === "script") {
288
- let value = scanner.getTokenText();
289
- if (value[0] === "'" || value[0] === "\"") value = value.slice(1, -1);
290
- importedScripts.push(value);
291
- } else if (lastAttributeName === "type" && lastTagName.toLowerCase() === "script") {
366
+ if (lastAttributeName === "type" && lastTagName.toLowerCase() === "script") {
292
367
  const token = scanner.getTokenText();
293
368
  if (/["'](module|(text|application)\/(java|ecma)script|text\/babel)["']/.test(token) || token === "module") languageIdFromType = "javascript";
294
369
  else if (/["']text\/typescript["']/.test(token)) languageIdFromType = "typescript";
@@ -298,7 +373,7 @@ function getDocumentRegions(languageService, document) {
298
373
  if (attributeLanguageId) {
299
374
  let start = scanner.getTokenOffset();
300
375
  let end = scanner.getTokenEnd();
301
- const firstChar = document.getText()[start];
376
+ const firstChar = htmlText[start];
302
377
  if (firstChar === "'" || firstChar === "\"") {
303
378
  start++;
304
379
  end--;
@@ -306,8 +381,7 @@ function getDocumentRegions(languageService, document) {
306
381
  regions.push({
307
382
  languageId: attributeLanguageId,
308
383
  start,
309
- end,
310
- attributeValue: true
384
+ end
311
385
  });
312
386
  }
313
387
  }
@@ -315,58 +389,30 @@ function getDocumentRegions(languageService, document) {
315
389
  }
316
390
  token = scanner.scan();
317
391
  }
392
+ const allRegions = mergeRegions(regions, markdownRegions);
393
+ const htmlDocument = isMarkdown ? TextDocument.create(document.uri, "html", document.version, htmlText) : document;
318
394
  return {
319
- getLanguageRanges: (range) => getLanguageRanges(document, regions, range),
320
- getEmbeddedDocument: (languageId, shouldIgnoreAttributeValues) => getEmbeddedDocument(document, regions, languageId, shouldIgnoreAttributeValues),
321
- getLanguageAtPosition: (position) => getLanguageAtPosition(document, regions, position),
322
- getLanguagesInDocument: () => getLanguagesInDocument(document, regions),
323
- getImportedScripts: () => importedScripts
395
+ getLanguageAtPosition: (position) => getLanguageAtPosition(document, allRegions, position),
396
+ getLanguagesInDocument: () => getLanguagesInDocument(allRegions),
397
+ getHtmlDocument: () => htmlDocument
324
398
  };
325
399
  }
326
- function getLanguageRanges(document, regions, range) {
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);
327
404
  const result = [];
328
- let currentPos = range ? range.start : Position.create(0, 0);
329
- let currentOffset = range ? document.offsetAt(range.start) : 0;
330
- const endOffset = range ? document.offsetAt(range.end) : document.getText().length;
331
- for (const region of regions) {
332
- if (!(region.end > currentOffset && region.start < endOffset)) continue;
333
- const start = Math.max(region.start, currentOffset);
334
- const startPos = document.positionAt(start);
335
- if (currentOffset < region.start) result.push({
336
- start: currentPos,
337
- end: startPos,
338
- languageId: "html"
339
- });
340
- const end = Math.min(region.end, endOffset);
341
- const endPos = document.positionAt(end);
342
- if (end > region.start) result.push({
343
- start: startPos,
344
- end: endPos,
345
- languageId: region.languageId,
346
- attributeValue: region.attributeValue
347
- });
348
- currentOffset = end;
349
- currentPos = endPos;
350
- }
351
- if (currentOffset < endOffset) {
352
- const endPos = range ? range.end : document.positionAt(endOffset);
353
- result.push({
354
- start: currentPos,
355
- end: endPos,
356
- languageId: "html"
357
- });
405
+ for (const region of merged) {
406
+ const previous = result.at(-1);
407
+ if (previous && region.start < previous.end) continue;
408
+ result.push(region);
358
409
  }
359
410
  return result;
360
411
  }
361
- function getLanguagesInDocument(_document, regions) {
362
- const result = [];
363
- for (const region of regions) {
364
- if (!(region.languageId && !result.includes(region.languageId))) continue;
365
- result.push(region.languageId);
366
- if (result.length === 3) return result;
367
- }
368
- result.push("html");
369
- 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];
370
416
  }
371
417
  function getLanguageAtPosition(document, regions, position) {
372
418
  const offset = document.offsetAt(position);
@@ -375,66 +421,6 @@ function getLanguageAtPosition(document, regions, position) {
375
421
  } else break;
376
422
  return "html";
377
423
  }
378
- function getEmbeddedDocument(document, contents, languageId, shouldIgnoreAttributeValues) {
379
- let currentPos = 0;
380
- const oldContent = document.getText();
381
- let result = "";
382
- let lastSuffix = "";
383
- for (const c of contents) {
384
- if (!(c.languageId === languageId && (!shouldIgnoreAttributeValues || !c.attributeValue))) continue;
385
- result = substituteWithWhitespace(result, currentPos, c.start, oldContent, lastSuffix, getPrefix(c));
386
- result += updateContent(c, oldContent.slice(c.start, c.end));
387
- currentPos = c.end;
388
- lastSuffix = getSuffix(c);
389
- }
390
- result = substituteWithWhitespace(result, currentPos, oldContent.length, oldContent, lastSuffix, "");
391
- return TextDocument.create(document.uri, languageId, document.version, result);
392
- }
393
- function getPrefix(c) {
394
- if (c.attributeValue) switch (c.languageId) {
395
- case "css": return "__{";
396
- }
397
- return "";
398
- }
399
- function getSuffix(c) {
400
- if (c.attributeValue) switch (c.languageId) {
401
- case "css": return "}";
402
- case "javascript": return ";";
403
- }
404
- return "";
405
- }
406
- function updateContent(c, content) {
407
- if (!c.attributeValue && c.languageId === "javascript") return content.replace(`<!--`, `/* `).replace(`-->`, ` */`);
408
- if (c.languageId === "css") return content.replace(/(&quot;|&#34;)/g, (match, _, offset) => {
409
- const spaces = " ".repeat(match.length - 1);
410
- const afterChar = content[offset + match.length];
411
- if (!afterChar || afterChar.includes(" ")) return `${spaces}"`;
412
- return `"${spaces}`;
413
- });
414
- return content;
415
- }
416
- function substituteWithWhitespace(result, start, end, oldContent, before, after) {
417
- result += before;
418
- let accumulatedWS = -before.length;
419
- for (let index = start; index < end; index++) {
420
- const ch = oldContent[index];
421
- if (ch === "\n" || ch === "\r") {
422
- accumulatedWS = 0;
423
- result += ch;
424
- } else accumulatedWS++;
425
- }
426
- result = append(result, " ", accumulatedWS - after.length);
427
- result += after;
428
- return result;
429
- }
430
- function append(result, string_, n) {
431
- while (n > 0) {
432
- if (n & 1) result += string_;
433
- n >>= 1;
434
- string_ += string_;
435
- }
436
- return result;
437
- }
438
424
  function getAttributeLanguage(attributeName) {
439
425
  const match = attributeName.match(/^(style)$|^(on\w+)$/i);
440
426
  if (!match) return null;
@@ -672,6 +658,9 @@ function getHTMLMode(htmlLanguageService) {
672
658
 
673
659
  //#endregion
674
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
+ }
675
664
  const FILE_PROTOCOL = "staticbolt-server";
676
665
  function getLanguageModes(clientCapabilities, requestService) {
677
666
  const htmlLanguageService = vscodeHtml.getLanguageService({
@@ -713,20 +702,13 @@ function getLanguageModes(clientCapabilities, requestService) {
713
702
  async updateDataProviders(dataProviders) {
714
703
  htmlLanguageService.setDataProviders(true, dataProviders);
715
704
  },
705
+ getHtmlDocument(document) {
706
+ return documentRegions.get(document).getHtmlDocument();
707
+ },
716
708
  getModeAtPosition(document, position) {
717
709
  const languageId = documentRegions.get(document).getLanguageAtPosition(position);
718
710
  if (languageId) return modes[languageId];
719
711
  },
720
- getModesInRange(document, range) {
721
- return documentRegions.get(document).getLanguageRanges(range).map((r) => {
722
- return {
723
- start: r.start,
724
- end: r.end,
725
- mode: r.languageId ? modes[r.languageId] : void 0,
726
- attributeValue: r.attributeValue
727
- };
728
- });
729
- },
730
712
  getAllModesInDocument(document) {
731
713
  const result = [];
732
714
  for (const languageId of documentRegions.get(document).getLanguagesInDocument()) {
@@ -735,14 +717,6 @@ function getLanguageModes(clientCapabilities, requestService) {
735
717
  }
736
718
  return result;
737
719
  },
738
- getAllModes() {
739
- const result = [];
740
- for (const languageId in modes) {
741
- const mode = modes[languageId];
742
- if (mode) result.push(mode);
743
- }
744
- return result;
745
- },
746
720
  getMode(languageId) {
747
721
  return modes[languageId];
748
722
  },
@@ -884,10 +858,6 @@ function startServer(connection, runtime) {
884
858
  let workspaceFolders = [];
885
859
  let configManager;
886
860
  let languageModes;
887
- const documentSettings = {};
888
- documents.onDidClose((document) => {
889
- delete documentSettings[document.document.uri];
890
- });
891
861
  connection.onInitialize((parameters) => {
892
862
  if (Array.isArray(parameters.workspaceFolders)) lspSearchRoots = parameters.workspaceFolders;
893
863
  else {
@@ -942,8 +912,9 @@ function startServer(connection, runtime) {
942
912
  isIncomplete: true,
943
913
  items: []
944
914
  };
915
+ const htmlDocument = languageModes.getHtmlDocument(document);
945
916
  const documentContext = getDocumentContext(document.uri, workspaceFolders);
946
- return mode.doComplete(document, textDocumentPosition.position, documentContext, configManager.lspHtmlData);
917
+ return mode.doComplete(htmlDocument, textDocumentPosition.position, documentContext, configManager.lspHtmlData);
947
918
  }, null, `Error while computing completions for ${textDocumentPosition.textDocument.uri}`, token);
948
919
  });
949
920
  connection.onCompletionResolve((item, token) => {
@@ -954,7 +925,7 @@ function startServer(connection, runtime) {
954
925
  if (!document) return item;
955
926
  const mode = languageModes.getMode(data.languageId);
956
927
  if (!mode?.doResolve) return item;
957
- return mode.doResolve(document, item);
928
+ return mode.doResolve(languageModes.getHtmlDocument(document), item);
958
929
  }, item, `Error while resolving completion proposal`, token);
959
930
  });
960
931
  connection.onHover((textDocumentPosition, token) => {
@@ -966,7 +937,7 @@ function startServer(connection, runtime) {
966
937
  const mode = languageModes.getModeAtPosition(document, textDocumentPosition.position);
967
938
  if (!mode?.doHover) return null;
968
939
  if (!await configManager.get(projectRoot)) return null;
969
- return mode.doHover(document, textDocumentPosition.position, configManager.lspHtmlData);
940
+ return mode.doHover(languageModes.getHtmlDocument(document), textDocumentPosition.position, configManager.lspHtmlData);
970
941
  }, null, `Error while computing hover for ${textDocumentPosition.textDocument.uri}`, token);
971
942
  });
972
943
  connection.onDocumentLinks((documentLinkParameter, token) => {
@@ -976,16 +947,14 @@ function startServer(connection, runtime) {
976
947
  const document = documents.get(documentLinkParameter.textDocument.uri);
977
948
  if (!document) return [];
978
949
  const links = [];
950
+ const htmlDocument = languageModes.getHtmlDocument(document);
979
951
  const documentContext = getDocumentContext(document.uri, workspaceFolders);
980
- for (const mode of languageModes.getAllModesInDocument(document)) if (mode.findDocumentLinks) pushAll(links, await mode.findDocumentLinks(document, documentContext, projectRoot));
952
+ for (const mode of languageModes.getAllModesInDocument(document)) if (mode.findDocumentLinks) pushAll(links, await mode.findDocumentLinks(htmlDocument, documentContext, projectRoot));
981
953
  return links;
982
954
  }, [], `Error while document links for ${documentLinkParameter.textDocument.uri}`, token);
983
955
  });
984
956
  connection.listen();
985
957
  }
986
- function isCompletionItemData(value) {
987
- return value && typeof value.languageId === "string" && typeof value.uri === "string" && typeof value.offset === "number";
988
- }
989
958
 
990
959
  //#endregion
991
960
  //#region src/utils/node-fs.ts