@staticbolt/lsp 1.0.0-beta.30 → 1.0.0-beta.32
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/README.md +31 -0
- package/lib/index.mjs +1440 -748
- package/lib/index.mjs.map +1 -1
- package/package.json +8 -4
- package/src/helpers/document-context.ts +45 -0
- package/src/helpers/document-elements.ts +119 -0
- package/src/helpers/document-info.ts +29 -0
- package/src/helpers/inferred-project.ts +28 -0
- package/src/{modes → helpers}/markdown-regions.ts +25 -35
- package/src/helpers/merge-html-data.ts +79 -33
- package/src/helpers/regions.ts +101 -0
- package/src/helpers/syntax-tokens.ts +327 -0
- package/src/helpers/validation.ts +94 -0
- package/src/helpers/virtual-document.ts +111 -0
- package/src/index.ts +158 -25
- package/src/language-plugin.ts +67 -0
- package/src/projects.ts +305 -0
- package/src/services/staticbolt-service.ts +257 -0
- package/src/services/syntax-tokens-service.ts +127 -0
- package/src/services/typescript-service.ts +20 -0
- package/src/virtual-code.ts +196 -0
- package/src/helpers/config-loader.ts +0 -244
- package/src/helpers/find-projects.ts +0 -23
- package/src/html-server.ts +0 -191
- package/src/language-model-cache.ts +0 -91
- package/src/modes/embedded-support.ts +0 -150
- package/src/modes/html-mode.ts +0 -83
- package/src/modes/language-modes.ts +0 -156
- package/src/requests.ts +0 -72
- package/src/utils/arrays.ts +0 -73
- package/src/utils/document-context.ts +0 -44
- package/src/utils/find-project-root.ts +0 -24
- package/src/utils/node-fs.ts +0 -77
- package/src/utils/runner.ts +0 -56
- package/src/utils/strings.ts +0 -76
package/src/index.ts
CHANGED
|
@@ -1,15 +1,45 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { format, stripVTControlCharacters } from "node:util";
|
|
2
|
-
import
|
|
4
|
+
import { createConnection, createServer, createTypeScriptProject, loadTsdkByPath } from "@volar/language-server/node.js";
|
|
3
5
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { useModuleScripts } from "./helpers/inferred-project.ts";
|
|
7
|
+
import { createLanguagePlugin } from "./language-plugin.ts";
|
|
8
|
+
import { findProjectRoots, Projects } from "./projects.ts";
|
|
9
|
+
import { createStaticboltService } from "./services/staticbolt-service.ts";
|
|
10
|
+
import { createSyntaxTokensService } from "./services/syntax-tokens-service.ts";
|
|
11
|
+
import { createTypeScriptServices } from "./services/typescript-service.ts";
|
|
7
12
|
|
|
8
|
-
import type {
|
|
9
|
-
import type { Connection, Disposable } from "vscode-languageserver/node";
|
|
13
|
+
import type { InitializeParams, WorkspaceFolder } from "@volar/language-server/node.js";
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
/** What an editor may pass as `initializationOptions`. */
|
|
16
|
+
interface InitializationOptions {
|
|
17
|
+
/** Where TypeScript is. */
|
|
18
|
+
typescript?: {
|
|
19
|
+
/** The directory holding `typescript.js`, `node_modules/typescript/lib` say. */
|
|
20
|
+
tsdk?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Whether the editor maps the scoped token types to grammar scopes, so the keywords are sent as those. */
|
|
24
|
+
scopedTokens?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The initialization options, with anything that is not an object read as none. */
|
|
28
|
+
function initializationOptionsOf(parameters: InitializeParams): InitializationOptions {
|
|
29
|
+
const options: unknown = parameters.initializationOptions;
|
|
30
|
+
|
|
31
|
+
if (typeof options !== "object" || options === null) {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return options;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The LSP connection to the editor, over stdio. */
|
|
39
|
+
const connection = createConnection();
|
|
40
|
+
|
|
41
|
+
/** Volar's server on top of the connection: documents, projects and the language features. */
|
|
42
|
+
const server = createServer(connection);
|
|
13
43
|
|
|
14
44
|
/**
|
|
15
45
|
* `RemoteConsole` takes a single string, but the shared logger calls `console` with several arguments and colours them with
|
|
@@ -17,7 +47,11 @@ const connection: Connection = vs.createConnection();
|
|
|
17
47
|
* so format the arguments the way `console` would, then strip the escapes.
|
|
18
48
|
*/
|
|
19
49
|
function forward(write: (message: string) => void) {
|
|
20
|
-
return (...messages: unknown[]) =>
|
|
50
|
+
return (...messages: unknown[]) => {
|
|
51
|
+
const text = stripVTControlCharacters(format(...messages));
|
|
52
|
+
|
|
53
|
+
write(text);
|
|
54
|
+
};
|
|
21
55
|
}
|
|
22
56
|
|
|
23
57
|
console.log = forward(connection.console.log.bind(connection.console));
|
|
@@ -26,24 +60,123 @@ console.warn = forward(connection.console.warn.bind(connection.console));
|
|
|
26
60
|
console.error = forward(connection.console.error.bind(connection.console));
|
|
27
61
|
|
|
28
62
|
process.on("unhandledRejection", (error: unknown) => {
|
|
29
|
-
|
|
63
|
+
console.error("[staticbolt] unhandled rejection:", error);
|
|
30
64
|
});
|
|
31
65
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
66
|
+
/** Whether a directory holds TypeScript with its API, which the native builds do not ship. */
|
|
67
|
+
function hasTypeScriptApi(tsdk: string): boolean {
|
|
68
|
+
return existsSync(path.join(tsdk, "typescript.js"));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The directory of the TypeScript to run: the editor's choice from the initialization options, the `--tsdk` argument, or the
|
|
73
|
+
* nearest `typescript` package with an API installed above the working directory.
|
|
74
|
+
*/
|
|
75
|
+
function findTsdk(parameters: InitializeParams): string | undefined {
|
|
76
|
+
const options = initializationOptionsOf(parameters);
|
|
77
|
+
if (options.typescript?.tsdk) {
|
|
78
|
+
return options.typescript.tsdk;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const argument = process.argv.find(value => value.startsWith("--tsdk="));
|
|
82
|
+
if (argument) {
|
|
83
|
+
return argument.slice("--tsdk=".length);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let directory = process.cwd();
|
|
38
87
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
88
|
+
while (true) {
|
|
89
|
+
const tsdk = path.join(directory, "node_modules", "typescript", "lib");
|
|
90
|
+
if (hasTypeScriptApi(tsdk)) {
|
|
91
|
+
return tsdk;
|
|
92
|
+
}
|
|
44
93
|
|
|
45
|
-
|
|
46
|
-
|
|
94
|
+
const parent = path.dirname(directory);
|
|
95
|
+
if (parent === directory) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
47
98
|
|
|
48
|
-
|
|
49
|
-
|
|
99
|
+
directory = parent;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The workspace folders, or the root uri of an editor that has no folders. */
|
|
104
|
+
function foldersOf(parameters: InitializeParams): WorkspaceFolder[] {
|
|
105
|
+
if (parameters.workspaceFolders) {
|
|
106
|
+
return parameters.workspaceFolders;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (parameters.rootUri) {
|
|
110
|
+
return [{ name: "", uri: parameters.rootUri }];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The staticbolt projects of the workspace, from initialization on. */
|
|
117
|
+
let projects: Projects | undefined;
|
|
118
|
+
|
|
119
|
+
connection.listen();
|
|
120
|
+
|
|
121
|
+
connection.onInitialize(async parameters => {
|
|
122
|
+
const tsdk = findTsdk(parameters);
|
|
123
|
+
if (tsdk === undefined || !hasTypeScriptApi(tsdk)) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`[staticbolt] no TypeScript with an API ${tsdk ? `at ${tsdk}` : "found"}; point typescript.tsdk or --tsdk at one, 6.x say`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const { typescript, diagnosticMessages } = loadTsdkByPath(tsdk, parameters.locale);
|
|
130
|
+
console.log(`[staticbolt] TypeScript ${typescript.version} from ${tsdk}`);
|
|
131
|
+
|
|
132
|
+
// Every project's plugins are asked what they contribute before the first document is served
|
|
133
|
+
let isInitialized = false;
|
|
134
|
+
const workspace = new Projects(connection.console, () => {
|
|
135
|
+
if (!isInitialized) return;
|
|
136
|
+
|
|
137
|
+
server.project.reload();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
projects = workspace;
|
|
141
|
+
|
|
142
|
+
const roots = findProjectRoots(foldersOf(parameters));
|
|
143
|
+
console.log(`[staticbolt] discovered projects:\n${roots.map(root => ` - ${root}`).join("\n")}`);
|
|
144
|
+
await Promise.all(roots.map(root => workspace.get(root)));
|
|
145
|
+
isInitialized = true;
|
|
146
|
+
|
|
147
|
+
const languagePlugin = createLanguagePlugin(typescript, workspace);
|
|
148
|
+
const project = createTypeScriptProject(typescript, diagnosticMessages, ({ configFileName, projectHost }) => {
|
|
149
|
+
if (configFileName === undefined) {
|
|
150
|
+
useModuleScripts(typescript, projectHost);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { languagePlugins: [languagePlugin] };
|
|
154
|
+
});
|
|
155
|
+
const services = [
|
|
156
|
+
createStaticboltService(),
|
|
157
|
+
createSyntaxTokensService(typescript, initializationOptionsOf(parameters).scopedTokens === true),
|
|
158
|
+
...createTypeScriptServices(typescript),
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
return server.initialize(parameters, project, services);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/** A TypeScript config, or one of those a config extends. */
|
|
165
|
+
const TS_CONFIG = /\/(?:tsconfig|jsconfig)[^/]*\.json$/;
|
|
166
|
+
|
|
167
|
+
connection.onInitialized(() => {
|
|
168
|
+
server.initialized();
|
|
169
|
+
|
|
170
|
+
// The editor reports the changes; Volar follows the source files itself, a config is reloaded whole since it may be extended
|
|
171
|
+
void server.fileWatcher.watchFiles(["**/*.{ts,mts,cts,js,mjs,cjs}", "**/{tsconfig,jsconfig}*.json"]);
|
|
172
|
+
|
|
173
|
+
server.fileWatcher.onDidChangeWatchedFiles(({ changes }) => {
|
|
174
|
+
if (changes.every(change => !TS_CONFIG.test(change.uri))) return;
|
|
175
|
+
|
|
176
|
+
server.project.reload();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
connection.onShutdown(async () => {
|
|
180
|
+
await projects?.dispose();
|
|
181
|
+
server.shutdown();
|
|
182
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { forEachEmbeddedCode } from "@volar/language-core";
|
|
3
|
+
|
|
4
|
+
import { embeddedFileName, StaticboltCode } from "./virtual-code.ts";
|
|
5
|
+
|
|
6
|
+
import type { Projects } from "./projects.ts";
|
|
7
|
+
import type { LanguagePlugin } from "@volar/language-core";
|
|
8
|
+
import type * as ts from "typescript";
|
|
9
|
+
import type { URI } from "vscode-uri";
|
|
10
|
+
|
|
11
|
+
/** The language ids by file extension. */
|
|
12
|
+
const LANGUAGE_IDS: Record<string, string | undefined> = {
|
|
13
|
+
".html": "html",
|
|
14
|
+
".md": "markdown",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Tells Volar what an HTML or markdown document is: the document itself, and a TypeScript file per plugin language, named after
|
|
19
|
+
* the document and the language and served by the project's TypeScript next to it.
|
|
20
|
+
*/
|
|
21
|
+
export function createLanguagePlugin(typescript: typeof ts, projects: Projects): LanguagePlugin<URI, StaticboltCode> {
|
|
22
|
+
return {
|
|
23
|
+
getLanguageId(uri) {
|
|
24
|
+
return LANGUAGE_IDS[path.extname(uri.path).toLowerCase()];
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
createVirtualCode(uri, languageId, snapshot) {
|
|
28
|
+
if (uri.scheme !== "file") {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (languageId !== "html" && languageId !== "markdown") {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return new StaticboltCode(typescript, uri, languageId, snapshot, projects.of(uri.toString()));
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
typescript: {
|
|
40
|
+
extraFileExtensions: [
|
|
41
|
+
{ extension: "html", isMixedContent: true, scriptKind: typescript.ScriptKind.Deferred },
|
|
42
|
+
{ extension: "md", isMixedContent: true, scriptKind: typescript.ScriptKind.Deferred },
|
|
43
|
+
],
|
|
44
|
+
|
|
45
|
+
getServiceScript() {
|
|
46
|
+
return;
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
getExtraServiceScripts(fileName, root) {
|
|
50
|
+
const scripts = [];
|
|
51
|
+
|
|
52
|
+
for (const code of forEachEmbeddedCode(root)) {
|
|
53
|
+
if (code.languageId !== "typescript") continue;
|
|
54
|
+
|
|
55
|
+
scripts.push({
|
|
56
|
+
fileName: embeddedFileName(fileName, code.id),
|
|
57
|
+
code,
|
|
58
|
+
extension: ".ts",
|
|
59
|
+
scriptKind: typescript.ScriptKind.TS,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return scripts;
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/projects.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { existsSync, globSync, watch } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { Resolver } from "@staticbolt/core";
|
|
5
|
+
import * as vscodeUri from "vscode-uri";
|
|
6
|
+
|
|
7
|
+
import type { FSWatcher } from "node:fs";
|
|
8
|
+
import type { AppConfig, EmbeddedLanguage, Plugin } from "@staticbolt/core";
|
|
9
|
+
import type { HTMLDataV1, IAttributeData, MarkupContent } from "vscode-html-languageservice";
|
|
10
|
+
import type { WorkspaceFolder } from "vscode-languageserver-protocol";
|
|
11
|
+
|
|
12
|
+
/** Where the projects log. */
|
|
13
|
+
type RemoteConsole = Pick<Console, "log" | "error">;
|
|
14
|
+
|
|
15
|
+
/** In order, so the first one that exists wins. */
|
|
16
|
+
const CONFIG_NAMES = [".staticbolt.ts", ".staticbolt.js"];
|
|
17
|
+
|
|
18
|
+
/** A save may come as several writes; the config is loaded once they have stopped. */
|
|
19
|
+
const RELOAD_DELAY_MS = 150;
|
|
20
|
+
|
|
21
|
+
/** A plugin's document check, with the plugin's name to label what it reports. */
|
|
22
|
+
export interface Validator {
|
|
23
|
+
/** The plugin's name. */
|
|
24
|
+
name: string;
|
|
25
|
+
|
|
26
|
+
/** The plugin's `lspValidate` hook. */
|
|
27
|
+
validate: NonNullable<Plugin["lspValidate"]>;
|
|
28
|
+
|
|
29
|
+
/** Whether the hook threw already, so it is logged once; the next config load starts over. */
|
|
30
|
+
hasFailed: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The directory of the nearest config file above a directory, which is the project it belongs to. */
|
|
34
|
+
function findProjectRoot(directory: string): string | undefined {
|
|
35
|
+
while (true) {
|
|
36
|
+
const hasConfig = CONFIG_NAMES.some(name => existsSync(path.join(directory, name)));
|
|
37
|
+
if (hasConfig) {
|
|
38
|
+
return directory;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const parent = path.dirname(directory);
|
|
42
|
+
if (parent === directory) return;
|
|
43
|
+
|
|
44
|
+
directory = parent;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A resolver for a root that does not log missing files, with the config's aliases on top of the tsconfig's. */
|
|
49
|
+
function createResolver(root: string, aliases?: Record<string, string>): Resolver {
|
|
50
|
+
return new Resolver(root, false, aliases, false);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every project under the workspace folders, for the startup log. */
|
|
54
|
+
export function findProjectRoots(folders: WorkspaceFolder[]): string[] {
|
|
55
|
+
return folders.flatMap(folder => {
|
|
56
|
+
const cwd = vscodeUri.URI.parse(folder.uri).fsPath;
|
|
57
|
+
const configs = globSync(`**/{${CONFIG_NAMES.join(",")}}`, { cwd, exclude: ["**/node_modules/**", "**/.git/**"] });
|
|
58
|
+
|
|
59
|
+
return configs.map(config => path.join(cwd, path.dirname(config)));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A staticbolt project as the server sees it: its config, kept current while the config file changes, and what its plugins
|
|
65
|
+
* contribute to the editor.
|
|
66
|
+
*/
|
|
67
|
+
export class Project {
|
|
68
|
+
/** The directory holding the config file. */
|
|
69
|
+
readonly root: string;
|
|
70
|
+
|
|
71
|
+
/** The last config that loaded, or nothing when none did yet. */
|
|
72
|
+
config: AppConfig | undefined;
|
|
73
|
+
|
|
74
|
+
/** The tags and attributes the plugins contribute to HTML. A new array on every load, so consumers may cache on its identity. */
|
|
75
|
+
htmlData: HTMLDataV1[] = [];
|
|
76
|
+
|
|
77
|
+
/** The languages the plugins embed in HTML. */
|
|
78
|
+
embeddedLanguages: EmbeddedLanguage[] = [];
|
|
79
|
+
|
|
80
|
+
/** The plugins that check documents, by name. */
|
|
81
|
+
validators: Validator[] = [];
|
|
82
|
+
|
|
83
|
+
/** Resolves paths the way the project's build does: its tsconfig paths and config aliases. New on every load. */
|
|
84
|
+
resolver: Resolver;
|
|
85
|
+
|
|
86
|
+
/** Where to log. */
|
|
87
|
+
readonly #console: RemoteConsole;
|
|
88
|
+
|
|
89
|
+
/** Follows the root directory for saves of the config file. */
|
|
90
|
+
#watcher: FSWatcher | undefined;
|
|
91
|
+
|
|
92
|
+
/** The reload waiting for the save to finish. */
|
|
93
|
+
#reload: NodeJS.Timeout | undefined;
|
|
94
|
+
|
|
95
|
+
/** Told whenever the config loaded. */
|
|
96
|
+
readonly #onLoad: () => void;
|
|
97
|
+
|
|
98
|
+
/** Nothing is loaded until `start` is called. */
|
|
99
|
+
constructor(root: string, console: RemoteConsole, onLoad: () => void) {
|
|
100
|
+
this.root = root;
|
|
101
|
+
this.resolver = createResolver(root);
|
|
102
|
+
this.#console = console;
|
|
103
|
+
this.#onLoad = onLoad;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Loads the config and starts following the config file. */
|
|
107
|
+
async start(): Promise<void> {
|
|
108
|
+
// The directory rather than the file: editors save through a temporary file and a rename, which a watch on the file misses
|
|
109
|
+
this.#watcher = watch(this.root, { persistent: false }, (_event, filename) => {
|
|
110
|
+
if (typeof filename !== "string") return;
|
|
111
|
+
if (!CONFIG_NAMES.includes(filename)) return;
|
|
112
|
+
this.#scheduleReload();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
this.#watcher.on("error", error => this.#console.error(`[staticbolt] watching ${this.root}: ${error.message}`));
|
|
116
|
+
|
|
117
|
+
await this.#load();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Stops following the config file. */
|
|
121
|
+
dispose(): void {
|
|
122
|
+
clearTimeout(this.#reload);
|
|
123
|
+
this.#watcher?.close();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Loads the config again once the save has stopped writing. */
|
|
127
|
+
#scheduleReload(): void {
|
|
128
|
+
clearTimeout(this.#reload);
|
|
129
|
+
this.#reload = setTimeout(() => void this.#load(), RELOAD_DELAY_MS);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Loads the config file and takes what its plugins contribute; a failure is logged and leaves the last config in place. */
|
|
133
|
+
async #load(): Promise<void> {
|
|
134
|
+
const configPath = CONFIG_NAMES.map(name => path.join(this.root, name)).find(candidate => existsSync(candidate));
|
|
135
|
+
|
|
136
|
+
if (!configPath) {
|
|
137
|
+
this.#console.error(`[staticbolt] the config file of ${this.root} is gone`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
// A fresh URL each time, since a module is only ever evaluated once
|
|
143
|
+
const module = (await import(`${pathToFileURL(configPath).href}?t=${Date.now()}`)) as { default?: AppConfig };
|
|
144
|
+
if (!module.default) {
|
|
145
|
+
throw new Error("it has no default export, use `export default { … }`");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await this.#collectPluginData(module.default);
|
|
149
|
+
this.config = module.default;
|
|
150
|
+
this.resolver = createResolver(this.root, module.default.aliases);
|
|
151
|
+
this.#onLoad();
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
154
|
+
|
|
155
|
+
this.#console.error(`[staticbolt] failed to load ${configPath}: ${reason}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Asks every plugin of the config what it contributes to the editor. */
|
|
160
|
+
async #collectPluginData(config: AppConfig): Promise<void> {
|
|
161
|
+
const plugins = (config.plugins ?? []).flat();
|
|
162
|
+
const embeddedLanguages: EmbeddedLanguage[] = [];
|
|
163
|
+
const validators: Validator[] = [];
|
|
164
|
+
const htmlData: HTMLDataV1[] = [];
|
|
165
|
+
|
|
166
|
+
for (const plugin of plugins) {
|
|
167
|
+
embeddedLanguages.push(...(plugin.lspEmbeddedLanguages?.() ?? []));
|
|
168
|
+
|
|
169
|
+
if (plugin.lspValidate) {
|
|
170
|
+
validators.push({ name: plugin.name, validate: plugin.lspValidate, hasFailed: false });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const data = await plugin.lspHtmlData?.();
|
|
174
|
+
if (data) {
|
|
175
|
+
htmlData.push(creditPlugin(data, plugin.name));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.embeddedLanguages = embeddedLanguages;
|
|
180
|
+
this.validators = validators;
|
|
181
|
+
this.htmlData = htmlData;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The projects the server has been asked about, each started on first request. */
|
|
186
|
+
export class Projects {
|
|
187
|
+
/** Where to log. */
|
|
188
|
+
readonly #console: RemoteConsole;
|
|
189
|
+
|
|
190
|
+
/** By root, from the moment a project was asked for. */
|
|
191
|
+
readonly #starting = new Map<string, Promise<Project>>();
|
|
192
|
+
|
|
193
|
+
/** By root, once the project's config loaded. */
|
|
194
|
+
readonly #loaded = new Map<string, Project>();
|
|
195
|
+
|
|
196
|
+
/** The project root of every directory a document was served from. */
|
|
197
|
+
readonly #roots = new Map<string, string>();
|
|
198
|
+
|
|
199
|
+
/** Told whenever any project's config loaded. */
|
|
200
|
+
readonly #onLoad: () => void;
|
|
201
|
+
|
|
202
|
+
/** Starts with no projects; each is started the first time `get` asks for it. */
|
|
203
|
+
constructor(console: RemoteConsole, onLoad: () => void) {
|
|
204
|
+
this.#console = console;
|
|
205
|
+
this.#onLoad = onLoad;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The project at a root, or nothing while its config cannot be loaded. The project keeps following its config file either way,
|
|
210
|
+
* so a fixed config loads on its own.
|
|
211
|
+
*/
|
|
212
|
+
async get(root: string): Promise<Project | undefined> {
|
|
213
|
+
let project = this.#starting.get(root);
|
|
214
|
+
|
|
215
|
+
if (!project) {
|
|
216
|
+
project = this.#start(root);
|
|
217
|
+
this.#starting.set(root, project);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const started = await project;
|
|
221
|
+
|
|
222
|
+
return started.config ? started : undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The project a document belongs to, if it has loaded already. One that has not is started, and the document is served again
|
|
227
|
+
* once it loads; a document outside any project gets nothing.
|
|
228
|
+
*/
|
|
229
|
+
of(documentUri: string): Project | undefined {
|
|
230
|
+
const directory = path.dirname(vscodeUri.URI.parse(documentUri).fsPath);
|
|
231
|
+
const root = this.#roots.get(directory) ?? findProjectRoot(directory);
|
|
232
|
+
if (root === undefined) {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this.#roots.set(directory, root);
|
|
237
|
+
|
|
238
|
+
const loaded = this.#loaded.get(root);
|
|
239
|
+
if (!loaded) {
|
|
240
|
+
void this.get(root);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return loaded;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Stops following every project. */
|
|
247
|
+
async dispose(): Promise<void> {
|
|
248
|
+
const projects = await Promise.all(this.#starting.values());
|
|
249
|
+
|
|
250
|
+
for (const project of projects) {
|
|
251
|
+
project.dispose();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
this.#starting.clear();
|
|
255
|
+
this.#loaded.clear();
|
|
256
|
+
this.#roots.clear();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A project at a root, counted as loaded from the first time its config loads. */
|
|
260
|
+
async #start(root: string): Promise<Project> {
|
|
261
|
+
const project = new Project(root, this.#console, () => {
|
|
262
|
+
this.#loaded.set(root, project);
|
|
263
|
+
this.#onLoad();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
await project.start();
|
|
267
|
+
|
|
268
|
+
return project;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The data with every description saying which plugin it comes from. */
|
|
273
|
+
function creditPlugin(htmlData: HTMLDataV1, pluginName: string): HTMLDataV1 {
|
|
274
|
+
const credit = `_Provided by **staticbolt** \`${pluginName}\` plugin._`;
|
|
275
|
+
|
|
276
|
+
const withCredit = (description: string | MarkupContent | undefined): string | MarkupContent => {
|
|
277
|
+
if (!description) {
|
|
278
|
+
return credit;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const text = typeof description === "string" ? description : description.value;
|
|
282
|
+
if (text.includes(credit)) {
|
|
283
|
+
return description;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const credited = `${text}\n\n${credit}`;
|
|
287
|
+
if (typeof description === "string") {
|
|
288
|
+
return credited;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { ...description, value: credited };
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const creditAttributes = (attributes: IAttributeData[]): IAttributeData[] => {
|
|
295
|
+
return attributes.map(attribute => ({ ...attribute, description: withCredit(attribute.description) }));
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const tags = htmlData.tags?.map(tag => {
|
|
299
|
+
return { ...tag, description: withCredit(tag.description), attributes: creditAttributes(tag.attributes) };
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const globalAttributes = htmlData.globalAttributes ? creditAttributes(htmlData.globalAttributes) : undefined;
|
|
303
|
+
|
|
304
|
+
return { ...htmlData, tags, globalAttributes };
|
|
305
|
+
}
|