@heroiclands/package-build 22.12.2 → 22.12.3
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/CHANGELOG.md +7 -0
- package/README.md +3 -0
- package/bin/heroiclands-content-language-server.mjs +8 -0
- package/docs/commands.md +5 -2
- package/docs/content-language-server.md +25 -0
- package/docs/getting-started.md +4 -3
- package/engine/content-language-server.mjs +439 -0
- package/package.json +3 -2
- package/types/engine/content-language-server.d.mts +64 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# @heroiclands/package-build
|
|
2
2
|
|
|
3
|
+
## 22.12.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Content authors can find notes by name, alias, tag, shortcode, or Address and follow references from an LSP-capable editor.
|
|
8
|
+
- Editor reference search works without ripgrep installed.
|
|
9
|
+
|
|
3
10
|
## 22.12.2
|
|
4
11
|
|
|
5
12
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -14,6 +14,9 @@ The content half is documented separately in **[CONTENT.md](CONTENT.md)** — th
|
|
|
14
14
|
note format, the pack pipeline, and the configuration contract a content tree
|
|
15
15
|
declares itself with.
|
|
16
16
|
|
|
17
|
+
Editor navigation through the content index is described in the
|
|
18
|
+
[content language server guide](docs/content-language-server.md).
|
|
19
|
+
|
|
17
20
|
## Install
|
|
18
21
|
|
|
19
22
|
```
|
package/docs/commands.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# Command reference
|
|
2
2
|
|
|
3
|
-
`@heroiclands/package-build` ships two binaries. `package-build` is the
|
|
3
|
+
`@heroiclands/package-build` ships two build binaries. `package-build` is the
|
|
4
4
|
packaging half — clean, stage, check, package, deploy, run a Foundry
|
|
5
5
|
container, drive the end-to-end suite. `content-build` is the content half —
|
|
6
6
|
compile a note tree into compendium packs, check it, publish it as a site or a
|
|
7
7
|
book, and manage the caches a build resolves other packages through.
|
|
8
8
|
|
|
9
|
+
The package also ships `heroiclands-content-language-server`, a stdio editor
|
|
10
|
+
service described in the [content language server guide](content-language-server.md).
|
|
11
|
+
|
|
9
12
|
Both read `package-build.config.yaml` from the repository root — see
|
|
10
13
|
[Configuration](configuration.md) for every key. Neither reads it for
|
|
11
14
|
`--version` or `--help`: those two answer in a directory with no
|
|
@@ -21,7 +24,7 @@ says so under **OPTIONS** rather than omitting the section.
|
|
|
21
24
|
|
|
22
25
|
**Every finding is `file:line:column: severity: message`**, the path starting
|
|
23
26
|
the line — the diagnostic contract this package's own build tooling emits and
|
|
24
|
-
that both binaries' content-side commands use for anything found in a
|
|
27
|
+
that both build binaries' content-side commands use for anything found in a
|
|
25
28
|
specific file. A field that cannot be known is dropped rather than guessed:
|
|
26
29
|
`file:line:` when the column means nothing, `file:` when only the file is
|
|
27
30
|
known. See [Diagnostics](diagnostics.md) for the full contract.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Content language server
|
|
2
|
+
|
|
3
|
+
`heroiclands-content-language-server` provides editor navigation for Markdown notes in a HeroicLands content project. It is a stdio Language Server Protocol process; an editor starts it from the project root. The command uses the same `package-build.config.yaml` and Address rules as `content-build`.
|
|
4
|
+
|
|
5
|
+
Build the content index in the project before starting navigation:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx content-build content-index
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The generated JSONL file under the configured `paths.contentIndex` is the source for note names, aliases, tags, shortcodes, Addresses, and destinations. Search covers the current project. Search and destinations reflect the saved index, so run the index command after changing note metadata. The language server reads a completed replacement index on its next request. An editor's unsaved buffer text is used to identify the Address at the cursor, but it does not add search results.
|
|
12
|
+
|
|
13
|
+
| LSP request | Behavior |
|
|
14
|
+
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
15
|
+
| `textDocument/definition` | Follows an Address or wikilink to the indexed note. An anchor lands on its indexed line. |
|
|
16
|
+
| `workspace/symbol` | Finds notes by name, alias, ASCII name, shortcode, or Address. `tag:myth` searches tags. One result appears per source note. |
|
|
17
|
+
| `textDocument/references` | Finds authored wikilinks, embeds, and declared frontmatter Address values or keys. Ordinary prose is excluded. |
|
|
18
|
+
|
|
19
|
+
The server reports a missing index with the `content-build content-index` command. Reference search scans Markdown notes in the configured content tree. The server writes only LSP messages to stdout, and it uses UTF-16 positions as required by the default LSP position encoding.
|
|
20
|
+
|
|
21
|
+
## Editor integration
|
|
22
|
+
|
|
23
|
+
The [HeroicLands Emacs package](https://github.com/HeroicLands/heroiclands-emacs) connects this command through Eglot in content Markdown buffers. Eglot maps `M-.` to definition, `C-M-.` to workspace search, `M-?` to references, and `M-,` to the Xref location stack.
|
|
24
|
+
|
|
25
|
+
An editor other than Emacs starts the executable from the package root and associates it with Markdown notes under the configured content directory. The process handles `initialize`, `shutdown`, `exit`, full and incremental document synchronization, definition, references, and workspace symbols. It does not advertise completion, diagnostics, rename, or document symbols.
|
package/docs/getting-started.md
CHANGED
|
@@ -132,9 +132,10 @@ repository.
|
|
|
132
132
|
npm install --save-dev @heroiclands/package-build
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
That installs
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
That installs `package-build`, the packaging half, and `content-build`, the
|
|
136
|
+
content half, into `node_modules/.bin`. It also installs the optional
|
|
137
|
+
`heroiclands-content-language-server` for editor navigation. Every build command
|
|
138
|
+
below uses one of the first two binaries. Run them through `npx`, or wire them into npm scripts as
|
|
138
139
|
[`project-setup.md`](project-setup.md) describes.
|
|
139
140
|
|
|
140
141
|
**How it worked:** `npx package-build --version` prints the installed version.
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
import YAML from "yaml";
|
|
9
|
+
import { parseAddress, renderAddress } from "./address.mjs";
|
|
10
|
+
import { addressPositions } from "./note-addresses.mjs";
|
|
11
|
+
import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
|
|
12
|
+
import { loadPackConfig } from "./pack-config.mjs";
|
|
13
|
+
import { noteFile } from "./index-records.mjs";
|
|
14
|
+
import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
|
|
15
|
+
import { metadataFileName } from "./packages.mjs";
|
|
16
|
+
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
17
|
+
import { embedsIn, EMBED_DEFAULT_TYPE } from "./content-embeds.mjs";
|
|
18
|
+
|
|
19
|
+
const EMPTY_RANGE = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
|
|
20
|
+
|
|
21
|
+
/** Return an LSP position for a UTF-16 offset in TEXT. */
|
|
22
|
+
function positionAt(text, offset) {
|
|
23
|
+
const before = text.slice(0, offset);
|
|
24
|
+
const line = before.split("\n").length - 1;
|
|
25
|
+
const lastNewline = before.lastIndexOf("\n");
|
|
26
|
+
return { line, character: offset - lastNewline - 1 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Return a UTF-16 offset for an LSP position in TEXT. */
|
|
30
|
+
function offsetAt(text, position) {
|
|
31
|
+
const lines = text.split("\n");
|
|
32
|
+
if (position.line < 0 || position.line >= lines.length) return -1;
|
|
33
|
+
let offset = 0;
|
|
34
|
+
for (let i = 0; i < position.line; i++) offset += lines[i].length + 1;
|
|
35
|
+
return offset + Math.min(position.character, lines[position.line].length);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function location(file, text, start, end) {
|
|
39
|
+
return {
|
|
40
|
+
uri: pathToFileURL(file).href,
|
|
41
|
+
range: { start: positionAt(text, start), end: positionAt(text, end) },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function noteName(record) {
|
|
46
|
+
return typeof record.name === "string" ? record.name : (record.name?.full ?? record.shortcode);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** An index read from the package's configured content tree. */
|
|
50
|
+
export class ContentWorkspace {
|
|
51
|
+
constructor(config = loadPackConfig()) {
|
|
52
|
+
this.config = config;
|
|
53
|
+
this.contentRoot = config.paths.content;
|
|
54
|
+
this.indexFile = path.join(
|
|
55
|
+
config.paths.contentIndex,
|
|
56
|
+
metadataFileName(config.contentPackage),
|
|
57
|
+
);
|
|
58
|
+
this.indexState = "";
|
|
59
|
+
this.records = [];
|
|
60
|
+
this.byAddress = new Map();
|
|
61
|
+
this.byFile = new Map();
|
|
62
|
+
this.types = new Set(Object.keys(NOTE_VOCABULARY));
|
|
63
|
+
this.documents = new Map();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
refresh() {
|
|
67
|
+
let stat;
|
|
68
|
+
try {
|
|
69
|
+
stat = fs.statSync(this.indexFile);
|
|
70
|
+
} catch {
|
|
71
|
+
this.indexState = "";
|
|
72
|
+
this.records = [];
|
|
73
|
+
this.byAddress.clear();
|
|
74
|
+
this.byFile.clear();
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const state = `${stat.mtimeMs}:${stat.size}`;
|
|
78
|
+
if (state === this.indexState) return true;
|
|
79
|
+
const records = fs
|
|
80
|
+
.readFileSync(this.indexFile, "utf8")
|
|
81
|
+
.split("\n")
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.map((line) => JSON.parse(line));
|
|
84
|
+
const byAddress = new Map();
|
|
85
|
+
const byFile = new Map();
|
|
86
|
+
const types = new Set(Object.keys(NOTE_VOCABULARY));
|
|
87
|
+
for (const record of records) {
|
|
88
|
+
if (record.type) types.add(record.type);
|
|
89
|
+
if (record.address?.canonical)
|
|
90
|
+
byAddress.set(record.address.canonical.toLowerCase(), record);
|
|
91
|
+
if (record.file?.path) {
|
|
92
|
+
const file = noteFile(this.contentRoot, record);
|
|
93
|
+
if (!byFile.has(file)) byFile.set(file, record);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
this.records = records;
|
|
97
|
+
this.byAddress = byAddress;
|
|
98
|
+
this.byFile = byFile;
|
|
99
|
+
this.types = types;
|
|
100
|
+
this.indexState = state;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
requireIndex() {
|
|
105
|
+
if (!this.refresh())
|
|
106
|
+
throw new Error(
|
|
107
|
+
`No content index at ${this.indexFile}; run content-build content-index`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fileFor(record) {
|
|
112
|
+
if (record.file?.path) return noteFile(this.contentRoot, record);
|
|
113
|
+
if (record.asset?.path)
|
|
114
|
+
return path.join(this.config.paths.assets, ...record.asset.path.split("/"));
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
text(uri) {
|
|
119
|
+
if (this.documents.has(uri)) return this.documents.get(uri);
|
|
120
|
+
try {
|
|
121
|
+
return fs.readFileSync(fileURLToPath(uri), "utf8");
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Resolve a written Address using the same tuple grammar as the build. */
|
|
128
|
+
resolve(value, defaults = {}) {
|
|
129
|
+
const tuple = parseAddress(value, {
|
|
130
|
+
package: this.config.contentPackage,
|
|
131
|
+
system: "none",
|
|
132
|
+
types: this.types,
|
|
133
|
+
packages: new Set([this.config.contentPackage]),
|
|
134
|
+
...defaults,
|
|
135
|
+
});
|
|
136
|
+
if (tuple.reason) return null;
|
|
137
|
+
return this.byAddress.get(renderAddress(tuple)) ?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Link and declared frontmatter targets, with exact source ranges. */
|
|
141
|
+
referencesInText(text, file) {
|
|
142
|
+
const found = [];
|
|
143
|
+
const bodyStart = text.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/)?.[0].length ?? 0;
|
|
144
|
+
const linkText = text.slice(bodyStart);
|
|
145
|
+
for (const match of matchAllOutsideCode(linkText, new RegExp(WIKILINK.source, "g"))) {
|
|
146
|
+
const parsed = parseWikilink(match[1]);
|
|
147
|
+
const record = parsed.target ? this.resolve(parsed.target) : this.byFile.get(file);
|
|
148
|
+
if (!record) continue;
|
|
149
|
+
const start = bodyStart + match.index + (parsed.target ? 2 : 3);
|
|
150
|
+
found.push({
|
|
151
|
+
record,
|
|
152
|
+
anchor: parsed.anchor,
|
|
153
|
+
location: location(
|
|
154
|
+
file,
|
|
155
|
+
text,
|
|
156
|
+
start,
|
|
157
|
+
start + (parsed.target || parsed.anchor).length,
|
|
158
|
+
),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
for (const embed of embedsIn(linkText)) {
|
|
162
|
+
const record = this.resolve(embed.written, { type: EMBED_DEFAULT_TYPE });
|
|
163
|
+
if (!record) continue;
|
|
164
|
+
const start = bodyStart + embed.index + 3;
|
|
165
|
+
found.push({
|
|
166
|
+
record,
|
|
167
|
+
anchor: "",
|
|
168
|
+
location: location(file, text, start, start + embed.written.length),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (!bodyStart) return found;
|
|
172
|
+
const yamlStart = text.indexOf("\n") + 1;
|
|
173
|
+
const yamlText = text.slice(yamlStart, bodyStart).replace(/\r?\n---(?:\r?\n)?$/, "");
|
|
174
|
+
let document;
|
|
175
|
+
try {
|
|
176
|
+
document = YAML.parseDocument(yamlText);
|
|
177
|
+
} catch {
|
|
178
|
+
return found;
|
|
179
|
+
}
|
|
180
|
+
if (document.errors.length) return found;
|
|
181
|
+
const frontmatter = document.toJS();
|
|
182
|
+
if (!frontmatter || typeof frontmatter !== "object") return found;
|
|
183
|
+
const visit = (node, segments, position) => {
|
|
184
|
+
if (!node) return;
|
|
185
|
+
if (segments.length) {
|
|
186
|
+
const [head, ...tail] = segments;
|
|
187
|
+
if (YAML.isMap(node)) {
|
|
188
|
+
for (const pair of node.items) {
|
|
189
|
+
if (head === "*" || String(pair.key?.value) === String(head))
|
|
190
|
+
visit(pair.value, tail, position);
|
|
191
|
+
}
|
|
192
|
+
} else if (YAML.isSeq(node)) {
|
|
193
|
+
node.items.forEach((child, index) => {
|
|
194
|
+
if (head === "*" || String(index) === String(head))
|
|
195
|
+
visit(child, tail, position);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const scalar = (part, value) => {
|
|
201
|
+
if (!YAML.isScalar(part) || typeof value !== "string" || !part.range) return;
|
|
202
|
+
const record = this.resolve(value, {
|
|
203
|
+
system: position.system ?? "none",
|
|
204
|
+
type: position.type,
|
|
205
|
+
});
|
|
206
|
+
if (!record) return;
|
|
207
|
+
const start = yamlStart + part.range[0];
|
|
208
|
+
found.push({
|
|
209
|
+
record,
|
|
210
|
+
anchor: "",
|
|
211
|
+
location: location(file, text, start, yamlStart + part.range[1]),
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
if (position.shape === "keys" && YAML.isMap(node)) {
|
|
215
|
+
for (const pair of node.items) scalar(pair.key, pair.key?.value);
|
|
216
|
+
} else if (position.shape === "list" && YAML.isSeq(node)) {
|
|
217
|
+
for (const child of node.items) scalar(child, child?.value);
|
|
218
|
+
} else if (position.shape === "scalar-or-map" && YAML.isMap(node)) {
|
|
219
|
+
for (const pair of node.items) scalar(pair.value, pair.value?.value);
|
|
220
|
+
} else {
|
|
221
|
+
scalar(node, node.value);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
for (const position of addressPositions(frontmatter))
|
|
225
|
+
visit(document.contents, position.path, position);
|
|
226
|
+
return found;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
targetAt(uri, position) {
|
|
230
|
+
const text = this.text(uri);
|
|
231
|
+
if (text == null) return null;
|
|
232
|
+
const offset = offsetAt(text, position);
|
|
233
|
+
if (offset < 0) return null;
|
|
234
|
+
const file = fileURLToPath(uri);
|
|
235
|
+
const reference = this.referencesInText(text, file).find(({ location: source }) => {
|
|
236
|
+
const start = offsetAt(text, source.range.start);
|
|
237
|
+
const end = offsetAt(text, source.range.end);
|
|
238
|
+
return start <= offset && offset <= end;
|
|
239
|
+
});
|
|
240
|
+
if (reference) return { record: reference.record, anchor: reference.anchor };
|
|
241
|
+
const own = this.byFile.get(file);
|
|
242
|
+
if (own && /^shortcode:\s*/.test(text.split("\n")[position.line] ?? ""))
|
|
243
|
+
return { record: own, anchor: "" };
|
|
244
|
+
const start = text.slice(0, offset).search(/[A-Za-z0-9./]+$/);
|
|
245
|
+
if (start < 0) return null;
|
|
246
|
+
const end = offset + (text.slice(offset).match(/^[A-Za-z0-9./]+/)?.[0].length ?? 0);
|
|
247
|
+
const record = this.resolve(text.slice(start, end));
|
|
248
|
+
return record ? { record, anchor: "" } : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
definition(uri, position) {
|
|
252
|
+
this.requireIndex();
|
|
253
|
+
const target = this.targetAt(uri, position);
|
|
254
|
+
if (!target) return null;
|
|
255
|
+
const { record, anchor } = target;
|
|
256
|
+
const file = this.fileFor(record);
|
|
257
|
+
if (!file) return null;
|
|
258
|
+
if (record.asset) return { uri: pathToFileURL(file).href, range: EMPTY_RANGE };
|
|
259
|
+
const line =
|
|
260
|
+
anchor ?
|
|
261
|
+
record.anchors?.find((entry) => entry.slug.toLowerCase() === anchor.toLowerCase())
|
|
262
|
+
?.line
|
|
263
|
+
: null;
|
|
264
|
+
if (anchor && !line) return null;
|
|
265
|
+
const targetText = fs.readFileSync(file, "utf8");
|
|
266
|
+
const offset = line ? offsetAt(targetText, { line: line - 1, character: 0 }) : 0;
|
|
267
|
+
return location(file, targetText, offset, offset);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
symbols(query) {
|
|
271
|
+
this.requireIndex();
|
|
272
|
+
const tag = /^tag:(.*)$/i.exec(query);
|
|
273
|
+
const needle = (tag ? tag[1] : query).trim().toLowerCase();
|
|
274
|
+
const found = new Map();
|
|
275
|
+
for (const record of this.records) {
|
|
276
|
+
if (!record.file?.path) continue;
|
|
277
|
+
const values =
|
|
278
|
+
tag ?
|
|
279
|
+
(record.tags ?? [])
|
|
280
|
+
: [
|
|
281
|
+
record.name?.full,
|
|
282
|
+
record.nameAscii,
|
|
283
|
+
...(record.name?.aliases ?? []),
|
|
284
|
+
...(record.aliasesAscii ?? []),
|
|
285
|
+
record.shortcode,
|
|
286
|
+
record.address?.slug,
|
|
287
|
+
record.address?.canonical,
|
|
288
|
+
];
|
|
289
|
+
const match = values.find(
|
|
290
|
+
(value) => typeof value === "string" && value.toLowerCase().includes(needle),
|
|
291
|
+
);
|
|
292
|
+
if (!match) continue;
|
|
293
|
+
const file = noteFile(this.contentRoot, record);
|
|
294
|
+
if (found.has(file)) continue;
|
|
295
|
+
found.set(file, {
|
|
296
|
+
name: String(noteName(record)),
|
|
297
|
+
kind: 1,
|
|
298
|
+
containerName: `${record.address?.slug ?? [record.type, record.shortcode].filter(Boolean).join(" ")} · ${tag ? `tag: ${match}` : match}`,
|
|
299
|
+
location: { uri: pathToFileURL(file).href, range: EMPTY_RANGE },
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return [...found.values()];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
references(uri, position) {
|
|
306
|
+
this.requireIndex();
|
|
307
|
+
const target = this.targetAt(uri, position)?.record;
|
|
308
|
+
if (!target?.address?.canonical) return [];
|
|
309
|
+
const locations = [];
|
|
310
|
+
const needle = target.shortcode.toLowerCase();
|
|
311
|
+
const visit = (directory) => {
|
|
312
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
313
|
+
if (entry.name.startsWith(".")) continue;
|
|
314
|
+
const file = path.join(directory, entry.name);
|
|
315
|
+
if (entry.isDirectory()) {
|
|
316
|
+
visit(file);
|
|
317
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
|
|
318
|
+
const savedText = fs.readFileSync(file, "utf8");
|
|
319
|
+
if (!savedText.toLowerCase().includes(needle)) continue;
|
|
320
|
+
const text = this.documents.get(pathToFileURL(file).href) ?? savedText;
|
|
321
|
+
for (const reference of this.referencesInText(text, file)) {
|
|
322
|
+
if (reference.record.address?.canonical === target.address.canonical)
|
|
323
|
+
locations.push(reference.location);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
visit(this.contentRoot);
|
|
329
|
+
return locations;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Process one JSON-RPC request without writing protocol bytes. */
|
|
334
|
+
export function respond(workspace, message) {
|
|
335
|
+
const { method, params = {} } = message;
|
|
336
|
+
switch (method) {
|
|
337
|
+
case "initialize":
|
|
338
|
+
return {
|
|
339
|
+
capabilities: {
|
|
340
|
+
positionEncoding: "utf-16",
|
|
341
|
+
textDocumentSync: 2,
|
|
342
|
+
definitionProvider: true,
|
|
343
|
+
referencesProvider: true,
|
|
344
|
+
workspaceSymbolProvider: true,
|
|
345
|
+
},
|
|
346
|
+
serverInfo: { name: "heroiclands-content" },
|
|
347
|
+
};
|
|
348
|
+
case "shutdown":
|
|
349
|
+
return null;
|
|
350
|
+
case "textDocument/didOpen":
|
|
351
|
+
workspace.documents.set(params.textDocument.uri, params.textDocument.text);
|
|
352
|
+
return undefined;
|
|
353
|
+
case "textDocument/didChange": {
|
|
354
|
+
const uri = params.textDocument.uri;
|
|
355
|
+
let text = workspace.text(uri) ?? "";
|
|
356
|
+
for (const change of params.contentChanges ?? []) {
|
|
357
|
+
if (!change.range) text = change.text;
|
|
358
|
+
else {
|
|
359
|
+
const start = offsetAt(text, change.range.start);
|
|
360
|
+
const end = offsetAt(text, change.range.end);
|
|
361
|
+
text = text.slice(0, start) + change.text + text.slice(end);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
workspace.documents.set(uri, text);
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
case "textDocument/didClose":
|
|
368
|
+
workspace.documents.delete(params.textDocument.uri);
|
|
369
|
+
return undefined;
|
|
370
|
+
case "textDocument/definition":
|
|
371
|
+
return workspace.definition(params.textDocument.uri, params.position);
|
|
372
|
+
case "textDocument/references":
|
|
373
|
+
return workspace.references(params.textDocument.uri, params.position);
|
|
374
|
+
case "workspace/symbol":
|
|
375
|
+
return workspace.symbols(params.query ?? "");
|
|
376
|
+
default:
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Run the stdio language server. */
|
|
382
|
+
export function runLanguageServer(
|
|
383
|
+
input = process.stdin,
|
|
384
|
+
output = process.stdout,
|
|
385
|
+
workspace = new ContentWorkspace(),
|
|
386
|
+
) {
|
|
387
|
+
let pending = Buffer.alloc(0);
|
|
388
|
+
const send = (message) => {
|
|
389
|
+
const body = Buffer.from(JSON.stringify(message));
|
|
390
|
+
output.write(`Content-Length: ${body.length}\r\n\r\n`);
|
|
391
|
+
output.write(body);
|
|
392
|
+
};
|
|
393
|
+
input.on("data", (chunk) => {
|
|
394
|
+
pending = Buffer.concat([pending, chunk]);
|
|
395
|
+
while (true) {
|
|
396
|
+
const separator = pending.indexOf("\r\n\r\n");
|
|
397
|
+
if (separator < 0) break;
|
|
398
|
+
const header = pending.subarray(0, separator).toString("ascii");
|
|
399
|
+
const length = /^content-length:\s*(\d+)\s*$/im.exec(header)?.[1];
|
|
400
|
+
if (!length) throw new Error("LSP message has no Content-Length");
|
|
401
|
+
const end = separator + 4 + Number(length);
|
|
402
|
+
if (pending.length < end) break;
|
|
403
|
+
const body = pending.subarray(separator + 4, end);
|
|
404
|
+
pending = pending.subarray(end);
|
|
405
|
+
let message;
|
|
406
|
+
try {
|
|
407
|
+
message = JSON.parse(body.toString("utf8"));
|
|
408
|
+
} catch (error) {
|
|
409
|
+
send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: String(error) } });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (message.method === "exit") {
|
|
413
|
+
process.exitCode = 0;
|
|
414
|
+
input.pause();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
const result = respond(workspace, message);
|
|
419
|
+
if (message.id !== undefined)
|
|
420
|
+
send(
|
|
421
|
+
result === undefined ?
|
|
422
|
+
{
|
|
423
|
+
jsonrpc: "2.0",
|
|
424
|
+
id: message.id,
|
|
425
|
+
error: { code: -32601, message: "Method not found" },
|
|
426
|
+
}
|
|
427
|
+
: { jsonrpc: "2.0", id: message.id, result },
|
|
428
|
+
);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (message.id !== undefined)
|
|
431
|
+
send({
|
|
432
|
+
jsonrpc: "2.0",
|
|
433
|
+
id: message.id,
|
|
434
|
+
error: { code: -32603, message: String(error) },
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "22.12.
|
|
3
|
+
"version": "22.12.3",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -96,7 +96,8 @@
|
|
|
96
96
|
},
|
|
97
97
|
"bin": {
|
|
98
98
|
"package-build": "./bin/package-build.mjs",
|
|
99
|
-
"content-build": "./bin/content-build.mjs"
|
|
99
|
+
"content-build": "./bin/content-build.mjs",
|
|
100
|
+
"heroiclands-content-language-server": "./bin/heroiclands-content-language-server.mjs"
|
|
100
101
|
},
|
|
101
102
|
"files": [
|
|
102
103
|
"CHANGELOG-content-build.md",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Process one JSON-RPC request without writing protocol bytes. */
|
|
2
|
+
export function respond(workspace: any, message: any): any;
|
|
3
|
+
/** Run the stdio language server. */
|
|
4
|
+
export function runLanguageServer(input?: NodeJS.ReadStream & {
|
|
5
|
+
fd: 0;
|
|
6
|
+
}, output?: NodeJS.WriteStream & {
|
|
7
|
+
fd: 1;
|
|
8
|
+
}, workspace?: ContentWorkspace): void;
|
|
9
|
+
/** An index read from the package's configured content tree. */
|
|
10
|
+
export class ContentWorkspace {
|
|
11
|
+
constructor(config?: import("../content-config.mjs").ContentBuildConfig);
|
|
12
|
+
config: import("../content-config.mjs").ContentBuildConfig;
|
|
13
|
+
contentRoot: string;
|
|
14
|
+
indexFile: string;
|
|
15
|
+
indexState: string;
|
|
16
|
+
records: any[];
|
|
17
|
+
byAddress: Map<any, any>;
|
|
18
|
+
byFile: Map<any, any>;
|
|
19
|
+
types: Set<string>;
|
|
20
|
+
documents: Map<any, any>;
|
|
21
|
+
refresh(): boolean;
|
|
22
|
+
requireIndex(): void;
|
|
23
|
+
fileFor(record: any): string | null;
|
|
24
|
+
text(uri: any): any;
|
|
25
|
+
/** Resolve a written Address using the same tuple grammar as the build. */
|
|
26
|
+
resolve(value: any, defaults?: {}): any;
|
|
27
|
+
/** Link and declared frontmatter targets, with exact source ranges. */
|
|
28
|
+
referencesInText(text: any, file: any): {
|
|
29
|
+
record: any;
|
|
30
|
+
anchor: string;
|
|
31
|
+
location: {
|
|
32
|
+
uri: string;
|
|
33
|
+
range: {
|
|
34
|
+
start: {
|
|
35
|
+
line: number;
|
|
36
|
+
character: number;
|
|
37
|
+
};
|
|
38
|
+
end: {
|
|
39
|
+
line: number;
|
|
40
|
+
character: number;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
}[];
|
|
45
|
+
targetAt(uri: any, position: any): {
|
|
46
|
+
record: any;
|
|
47
|
+
anchor: string;
|
|
48
|
+
} | null;
|
|
49
|
+
definition(uri: any, position: any): {
|
|
50
|
+
uri: string;
|
|
51
|
+
range: {
|
|
52
|
+
start: {
|
|
53
|
+
line: number;
|
|
54
|
+
character: number;
|
|
55
|
+
};
|
|
56
|
+
end: {
|
|
57
|
+
line: number;
|
|
58
|
+
character: number;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
} | null;
|
|
62
|
+
symbols(query: any): any[];
|
|
63
|
+
references(uri: any, position: any): any[];
|
|
64
|
+
}
|