@heroiclands/content-language-server 0.1.0

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 ADDED
@@ -0,0 +1,11 @@
1
+ # HeroicLands content language server
2
+
3
+ `@heroiclands/content-language-server` provides definition, reference, and workspace search for Markdown notes in HeroicLands content projects. It reads authored notes and project configuration through one exact `@heroiclands/package-build` dependency.
4
+
5
+ Start its `heroiclands-content-language-server` executable from the content project root. The server builds a private index when it starts and after saves. The index lives in the platform cache outside the project's `build/` directory, so a clean or package build does not replace editor navigation data.
6
+
7
+ Install an exact released package version in a directory managed by your editor integration. Configure an LSP client to launch that installation's `node_modules/.bin/heroiclands-content-language-server` executable with the content project as its working directory and Markdown notes as its document scope. The package does not depend on any particular editor.
8
+
9
+ See the [language server guide](docs/content-language-server.md) for LSP requests, cache locations, error handling, and manual recovery commands.
10
+
11
+ Maintainers use the [publishing guide](docs/publishing.md) for the first npm release and trusted publisher configuration.
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ import { ContentWorkspace, runLanguageServer } from "../engine/content-language-server.mjs";
7
+
8
+ if (process.argv[2] === "--print-index-path") {
9
+ console.log(new ContentWorkspace().indexFile);
10
+ } else if (process.argv[2] === "--rebuild-index") {
11
+ const workspace = new ContentWorkspace(undefined, {
12
+ onStatus: (status) => {
13
+ if (status) console.error(status);
14
+ },
15
+ });
16
+ if (!workspace.rebuild()) process.exitCode = 1;
17
+ else console.log(workspace.indexFile);
18
+ } else if (process.argv.length === 2) {
19
+ runLanguageServer();
20
+ } else {
21
+ console.error(
22
+ "Usage: heroiclands-content-language-server [--print-index-path | --rebuild-index]",
23
+ );
24
+ process.exitCode = 2;
25
+ }
@@ -0,0 +1,30 @@
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. Start it from the project root so it can read `package-build.config.yaml` and the saved content tree.
4
+
5
+ The server builds a private JSONL index during initialization, before answering navigation requests. It rebuilds after nearby save notifications settle. Unsaved buffer text identifies an Address under the cursor, but workspace search uses saved metadata. A new, renamed, or deleted note enters the index when the editor sends a save or file-operation notification. A successful rebuild replaces the complete snapshot; a failed rebuild reports an editor message and keeps the last complete snapshot available with a stale-results warning.
6
+
7
+ The index belongs to the editor, outside the project. On macOS it is `~/Library/Caches/HeroicLands/content-language-server/<project-root-hash>/metadata.jsonl`. Linux uses `$XDG_CACHE_HOME` or `~/.cache`; Windows uses `%LOCALAPPDATA%` or the user's `AppData/Local` directory. The hash comes from the canonical project root and stays the same across server versions. `metadata.json` records the package identity, generator version, and checksum. Startup rebuilds even when a cache exists, and an older server cannot replace an index from a newer generator. Cache files are disposable.
8
+
9
+ Install an exact `@heroiclands/content-language-server` version in an editor-managed directory and launch its executable. This package declares an exact `@heroiclands/package-build` dependency, so the server and index generator come from the same installation. The project supplies its configuration and saved notes. The build's `content-build content-index` command writes its own artifact under `build/` for build consumers; the language server does not read that artifact.
10
+
11
+ Run these commands from the project root using the editor-managed executable:
12
+
13
+ ```sh
14
+ heroiclands-content-language-server --print-index-path
15
+ heroiclands-content-language-server --rebuild-index
16
+ ```
17
+
18
+ The first prints the private JSONL path. The second is manual recovery when a file operation did not trigger a rebuild; it prints the path on success and exits nonzero on failure. Restarting the server also rebuilds from saved source. A failed rebuild leaves the complete prior snapshot in place. If there is no valid prior snapshot, navigation reports that no index is available.
19
+
20
+ | LSP request | Behavior |
21
+ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
22
+ | `textDocument/definition` | Follows an Address or wikilink to the indexed note. An anchor lands on its indexed line. |
23
+ | `workspace/symbol` | Finds notes by name, alias, ASCII name, shortcode, or Address. `tag:myth` searches tags. One result appears per source note. |
24
+ | `textDocument/references` | Finds authored wikilinks, embeds, and declared frontmatter Address values or keys. Ordinary prose is excluded. |
25
+
26
+ Reference search scans saved Markdown notes in the configured content tree. The server writes only LSP messages to stdout and uses UTF-16 positions.
27
+
28
+ ## Editor integration
29
+
30
+ An LSP client starts the executable with the content project as its working directory and associates it with Markdown notes under the configured content directory. The process handles `initialize`, `shutdown`, `exit`, full and incremental document synchronization, save and file-operation notifications, definition, references, and workspace symbols. It does not advertise completion, diagnostics, rename, or document symbols. An editor integration supplies its own installation, project discovery, and UI commands.
@@ -0,0 +1,17 @@
1
+ # Publishing
2
+
3
+ The package releases through `.github/workflows/release.yml`. Merged changesets open a version pull request; merging that pull request publishes the version to npm. The GitHub Actions workflow uses npm trusted publishing with OIDC.
4
+
5
+ ## First publication
6
+
7
+ A new package name has no npm package settings for a trusted publisher. The maintainer publishes the first version from a clean `main` checkout with an npm account that can create packages under `@heroiclands`:
8
+
9
+ ```sh
10
+ npm ci
11
+ npm test
12
+ npm publish --access public --provenance=false
13
+ ```
14
+
15
+ The initial local publish has no CI provenance attestation. The repository's release workflow recognizes an absent package and leaves the first publication to the maintainer.
16
+
17
+ On npmjs.com, open the package's settings and add a GitHub Actions trusted publisher with organization `HeroicLands`, repository `content-language-server`, workflow filename `release.yml`, and direct `npm publish` permitted. The workflow uses GitHub-hosted runners and `id-token: write`. Subsequent releases use that connection and receive automatic provenance.
@@ -0,0 +1,155 @@
1
+ /*
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ */
4
+
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import crypto from "node:crypto";
9
+ import {
10
+ indexRecordsFor,
11
+ serializeContentIndex,
12
+ } from "@heroiclands/package-build/engine/content-index";
13
+ import packageBuild from "@heroiclands/package-build/package.json" with { type: "json" };
14
+
15
+ /** Version of the server package that generates editor index records. */
16
+ export const generatorVersion = packageBuild.version;
17
+
18
+ function defaultCacheBase() {
19
+ if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Caches");
20
+ if (process.platform === "win32")
21
+ return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local");
22
+ return process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), ".cache");
23
+ }
24
+
25
+ /** Keep one cache directory for each canonical project root across server versions. */
26
+ export function languageIndexDirectory(config, cacheBase = defaultCacheBase()) {
27
+ const root = fs.realpathSync(config.rootDir ?? path.dirname(config.paths.assets));
28
+ const hash = crypto.createHash("sha256").update(root).digest("hex");
29
+ return path.join(cacheBase, "HeroicLands", "content-language-server", hash);
30
+ }
31
+
32
+ function checksum(text) {
33
+ return crypto.createHash("sha256").update(text).digest("hex");
34
+ }
35
+
36
+ function compareVersions(left, right) {
37
+ const a = left.split(".").map(Number);
38
+ const b = right.split(".").map(Number);
39
+ for (let i = 0; i < 3; i++) {
40
+ if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) - (b[i] ?? 0);
41
+ }
42
+ return 0;
43
+ }
44
+
45
+ function withProjectLock(directory, action) {
46
+ fs.mkdirSync(directory, { recursive: true });
47
+ const lock = path.join(directory, ".rebuild-lock");
48
+ const deadline = Date.now() + 30000;
49
+ while (true) {
50
+ try {
51
+ fs.mkdirSync(lock);
52
+ break;
53
+ } catch (error) {
54
+ if (error.code !== "EEXIST") throw error;
55
+ const stat = fs.statSync(lock, { throwIfNoEntry: false });
56
+ if (!stat) continue;
57
+ if (Date.now() - stat.mtimeMs > 300000) {
58
+ fs.rmSync(lock, { recursive: true, force: true });
59
+ continue;
60
+ }
61
+ if (Date.now() >= deadline)
62
+ throw new Error(`Timed out waiting for index lock at ${lock}`);
63
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
64
+ }
65
+ }
66
+ try {
67
+ return action();
68
+ } finally {
69
+ fs.rmdirSync(lock);
70
+ }
71
+ }
72
+
73
+ function readPair(indexFile, manifestFile, contentPackage) {
74
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
75
+ if (manifest.package !== contentPackage || manifest.generatorVersion !== generatorVersion)
76
+ throw new Error(`Incompatible editor index at ${indexFile}`);
77
+ const text = fs.readFileSync(indexFile, "utf8");
78
+ if (checksum(text) !== manifest.sha256) throw new Error(`Corrupt editor index at ${indexFile}`);
79
+ return text
80
+ .split("\n")
81
+ .filter(Boolean)
82
+ .map((line) => JSON.parse(line));
83
+ }
84
+
85
+ /** Read only a complete snapshot produced by this generator and package. */
86
+ export function readLanguageIndex(directory, contentPackage) {
87
+ const indexFile = path.join(directory, "metadata.jsonl");
88
+ const manifestFile = path.join(directory, "metadata.json");
89
+ try {
90
+ return readPair(indexFile, manifestFile, contentPackage);
91
+ } catch (error) {
92
+ try {
93
+ return readPair(`${indexFile}.previous`, `${manifestFile}.previous`, contentPackage);
94
+ } catch {
95
+ throw error;
96
+ }
97
+ }
98
+ }
99
+
100
+ /** Rebuild from saved inputs, publishing complete files while holding the project lock. */
101
+ export function rebuildLanguageIndex(config, directory) {
102
+ return withProjectLock(directory, () => {
103
+ let existing;
104
+ try {
105
+ existing = JSON.parse(fs.readFileSync(path.join(directory, "metadata.json"), "utf8"));
106
+ } catch {
107
+ existing = null;
108
+ }
109
+ if (
110
+ existing?.generatorVersion &&
111
+ compareVersions(existing.generatorVersion, generatorVersion) > 0
112
+ )
113
+ throw new Error(
114
+ `Editor index uses newer generator ${existing.generatorVersion}; upgrade this language server`,
115
+ );
116
+ const records = indexRecordsFor({ config });
117
+ if (records.length === 0)
118
+ throw new Error(`${config.paths.content} yielded no index records`);
119
+ const text = serializeContentIndex(records);
120
+ const manifest = JSON.stringify({
121
+ package: config.contentPackage,
122
+ generatorVersion,
123
+ sha256: checksum(text),
124
+ });
125
+ const suffix = `${process.pid}-${crypto.randomUUID()}`;
126
+ const indexTemp = path.join(directory, `metadata.jsonl.${suffix}.tmp`);
127
+ const manifestTemp = path.join(directory, `metadata.json.${suffix}.tmp`);
128
+ const indexFile = path.join(directory, "metadata.jsonl");
129
+ const manifestFile = path.join(directory, "metadata.json");
130
+ const priorIndex = `${indexFile}.previous`;
131
+ const priorManifest = `${manifestFile}.previous`;
132
+ try {
133
+ fs.writeFileSync(indexTemp, text);
134
+ fs.writeFileSync(manifestTemp, manifest);
135
+ try {
136
+ readPair(indexFile, manifestFile, config.contentPackage);
137
+ fs.copyFileSync(indexFile, priorIndex);
138
+ fs.copyFileSync(manifestFile, priorManifest);
139
+ } catch {
140
+ // A recovery snapshot, if present, stays available during publication.
141
+ }
142
+ fs.renameSync(indexTemp, indexFile);
143
+ fs.renameSync(manifestTemp, manifestFile);
144
+ fs.rmSync(priorIndex, { force: true });
145
+ fs.rmSync(priorManifest, { force: true });
146
+ } finally {
147
+ fs.rmSync(indexTemp, { force: true });
148
+ fs.rmSync(manifestTemp, { force: true });
149
+ }
150
+ return text
151
+ .split("\n")
152
+ .filter(Boolean)
153
+ .map((line) => JSON.parse(line));
154
+ });
155
+ }