@ferrule-io/ok-fine 0.0.0-stage → 0.3.6
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/LICENSE +202 -0
- package/README.md +92 -2
- package/dist/auth/discovery.js +56 -0
- package/dist/auth/http-auth.js +50 -0
- package/dist/auth/verifier.js +133 -0
- package/dist/cli-options.js +99 -0
- package/dist/cli.js +115 -0
- package/dist/config.js +196 -0
- package/dist/data-dir-lock.js +60 -0
- package/dist/errors.js +13 -0
- package/dist/http/mcp-route.js +32 -0
- package/dist/http/rest.js +243 -0
- package/dist/main.js +32 -0
- package/dist/mcp/server.js +202 -0
- package/dist/okf/concept.js +99 -0
- package/dist/okf/frontmatter.js +180 -0
- package/dist/okf/index-file.js +74 -0
- package/dist/okf/lint.js +460 -0
- package/dist/okf/log-file.js +74 -0
- package/dist/okf/markdown.js +106 -0
- package/dist/okf/paths.js +99 -0
- package/dist/okf/repository.js +53 -0
- package/dist/okf/semantics.js +121 -0
- package/dist/server.js +234 -0
- package/dist/service/feedback.js +40 -0
- package/dist/service/knowledge-service.js +819 -0
- package/dist/service/principal.js +22 -0
- package/dist/store/archive.js +108 -0
- package/dist/store/backend.js +4 -0
- package/dist/store/bundle.js +250 -0
- package/dist/store/catalog.js +295 -0
- package/dist/store/fs-util.js +65 -0
- package/dist/store/git-backend.js +564 -0
- package/dist/store/git.js +201 -0
- package/dist/store/mutex.js +19 -0
- package/dist/store/path-index.js +54 -0
- package/dist/store/repo.js +471 -0
- package/dist/version.js +2 -0
- package/package.json +56 -4
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { OkfError } from "../errors.js";
|
|
2
|
+
import { parseActor } from "../okf/semantics.js";
|
|
3
|
+
export function checkActor(actor, p) {
|
|
4
|
+
const parsed = parseActor(actor);
|
|
5
|
+
if (!parsed) {
|
|
6
|
+
throw new OkfError("invalid_actor", 400, "actor must be <producer>/<version>, human:<id>, or process:<id>");
|
|
7
|
+
}
|
|
8
|
+
if (parsed.kind === "human") {
|
|
9
|
+
if (p.identity === null) {
|
|
10
|
+
throw new OkfError("forbidden_actor", 403, "this token carries no identity claim; human: actors are not allowed");
|
|
11
|
+
}
|
|
12
|
+
// Email identities compare case-insensitively; any other identity must match exactly.
|
|
13
|
+
const matches = p.identity.includes("@")
|
|
14
|
+
? parsed.id.toLowerCase() === p.identity.toLowerCase()
|
|
15
|
+
: parsed.id === p.identity;
|
|
16
|
+
if (!matches) {
|
|
17
|
+
throw new OkfError("forbidden_actor", 403, `this token may only act as human:${p.identity}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return actor;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=principal.js.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import * as tar from "tar";
|
|
3
|
+
import { OkfError } from "../errors.js";
|
|
4
|
+
const IGNORED_ENTRY_TYPES = new Set([
|
|
5
|
+
"GlobalExtendedHeader",
|
|
6
|
+
"ExtendedHeader",
|
|
7
|
+
"NextFileHasLongPath",
|
|
8
|
+
"NextFileHasLongLinkpath",
|
|
9
|
+
"OldGnuLongPath",
|
|
10
|
+
]);
|
|
11
|
+
const ALLOWED_ENTRY_TYPES = new Set(["File", "OldFile", "Directory"]);
|
|
12
|
+
export async function extractBundleArchive(buffer, destDir, limits) {
|
|
13
|
+
// Pass 1: Listing and validation
|
|
14
|
+
const { promise: listPromise, resolve: listResolve, reject: listReject } = Promise.withResolvers();
|
|
15
|
+
let validationError = null;
|
|
16
|
+
let entryCount = 0;
|
|
17
|
+
let totalBytes = 0;
|
|
18
|
+
const topDirs = new Set();
|
|
19
|
+
const listStream = tar.t({
|
|
20
|
+
onentry: (entry) => {
|
|
21
|
+
if (validationError) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const type = entry.type;
|
|
25
|
+
if (IGNORED_ENTRY_TYPES.has(type)) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (!ALLOWED_ENTRY_TYPES.has(type)) {
|
|
29
|
+
validationError = new OkfError("invalid_archive", 400, `archive contains unsupported entry type: ${type}`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (entry.path.startsWith("/")) {
|
|
33
|
+
validationError = new OkfError("invalid_archive", 400, "archive cannot contain absolute paths");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const segments = entry.path.split("/").filter((s) => s.length > 0);
|
|
37
|
+
if (segments.length === 0) {
|
|
38
|
+
validationError = new OkfError("invalid_archive", 400, "archive contains empty entry path");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (segments.includes("..")) {
|
|
42
|
+
validationError = new OkfError("invalid_archive", 400, "archive cannot contain path traversal (..) segments");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (type === "File" || type === "OldFile") {
|
|
46
|
+
if (segments.length < 2) {
|
|
47
|
+
validationError = new OkfError("archive_layout", 400, "archive must contain a single top-level directory, as produced by export");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const topDir = segments[0];
|
|
52
|
+
if (topDir) {
|
|
53
|
+
topDirs.add(topDir);
|
|
54
|
+
if (topDirs.size > 1) {
|
|
55
|
+
validationError = new OkfError("archive_layout", 400, "archive must contain a single top-level directory, as produced by export");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (entry.size != null && entry.size > limits.maxFileBytes) {
|
|
60
|
+
validationError = new OkfError("payload_too_large", 413, `archive entry "${entry.path}" exceeds maximum file size (${limits.maxFileBytes} bytes)`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
totalBytes += entry.size ?? 0;
|
|
64
|
+
if (totalBytes > 5 * limits.maxArchiveBytes) {
|
|
65
|
+
validationError = new OkfError("payload_too_large", 413, `uncompressed archive exceeds 5x max archive bytes limit (${5 * limits.maxArchiveBytes} bytes)`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
entryCount++;
|
|
69
|
+
if (entryCount > 20000) {
|
|
70
|
+
validationError = new OkfError("payload_too_large", 413, "archive contains more than 20,000 entries");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
listStream.on("end", () => {
|
|
76
|
+
if (validationError) {
|
|
77
|
+
listReject(validationError);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (topDirs.size !== 1) {
|
|
81
|
+
listReject(new OkfError("archive_layout", 400, "archive must contain a single top-level directory, as produced by export"));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
listResolve();
|
|
85
|
+
});
|
|
86
|
+
listStream.on("error", (err) => {
|
|
87
|
+
listReject(new OkfError("invalid_archive", 400, `failed to read archive: ${err.message}`));
|
|
88
|
+
});
|
|
89
|
+
Readable.from(buffer).pipe(listStream);
|
|
90
|
+
await listPromise;
|
|
91
|
+
// Pass 2: Extract with strip: 1 and skip dotfiles
|
|
92
|
+
const { promise: extractPromise, resolve: extractResolve, reject: extractReject } = Promise.withResolvers();
|
|
93
|
+
const extractStream = tar.x({
|
|
94
|
+
cwd: destDir,
|
|
95
|
+
strip: 1,
|
|
96
|
+
filter: (entryPath) => {
|
|
97
|
+
const parts = entryPath.split("/");
|
|
98
|
+
return !parts.some((p) => p.startsWith("."));
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
extractStream.on("end", extractResolve);
|
|
102
|
+
extractStream.on("error", (err) => {
|
|
103
|
+
extractReject(new OkfError("invalid_archive", 400, `failed to extract archive: ${err.message}`));
|
|
104
|
+
});
|
|
105
|
+
Readable.from(buffer).pipe(extractStream);
|
|
106
|
+
await extractPromise;
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=archive.js.map
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { renderIndex } from "../okf/index-file.js";
|
|
3
|
+
import { lintConceptFile, lintIndexFile, lintLogFile } from "../okf/lint.js";
|
|
4
|
+
import { isReservedName } from "../okf/paths.js";
|
|
5
|
+
import { displayTitle } from "../okf/semantics.js";
|
|
6
|
+
/** True for non-reserved `.md` files, i.e. files that hold a concept. */
|
|
7
|
+
export function isConceptPath(path) {
|
|
8
|
+
const name = posix.basename(path);
|
|
9
|
+
return !isReservedName(name) && name.toLowerCase().endsWith(".md");
|
|
10
|
+
}
|
|
11
|
+
const NO_ENTRIES = Object.freeze({ files: [], dirs: [] });
|
|
12
|
+
const NO_COUNTS = Object.freeze({ conceptCount: 0, fileCount: 0 });
|
|
13
|
+
function parentDir(path) {
|
|
14
|
+
const parent = posix.dirname(path);
|
|
15
|
+
return parent === "." ? "" : parent;
|
|
16
|
+
}
|
|
17
|
+
function normalizeDir(dir) {
|
|
18
|
+
return dir === "." || dir === "/" ? "" : dir.replace(/^\/+|\/+$/g, "");
|
|
19
|
+
}
|
|
20
|
+
/** Immutable file/directory model of one bundle, built from its file paths (including index.md/log.md). */
|
|
21
|
+
export class BundleTree {
|
|
22
|
+
static EMPTY = new BundleTree([]);
|
|
23
|
+
paths;
|
|
24
|
+
conceptFiles;
|
|
25
|
+
otherFiles;
|
|
26
|
+
dirs;
|
|
27
|
+
fileSet;
|
|
28
|
+
dirSet;
|
|
29
|
+
dirEntries = new Map();
|
|
30
|
+
dirCounts = new Map();
|
|
31
|
+
constructor(paths) {
|
|
32
|
+
const sorted = Array.from(new Set(paths)).sort();
|
|
33
|
+
const conceptFiles = [];
|
|
34
|
+
const otherFiles = [];
|
|
35
|
+
const dirSet = new Set();
|
|
36
|
+
for (const path of sorted) {
|
|
37
|
+
const name = posix.basename(path);
|
|
38
|
+
const reserved = isReservedName(name);
|
|
39
|
+
const isConcept = !reserved && name.toLowerCase().endsWith(".md");
|
|
40
|
+
if (isConcept) {
|
|
41
|
+
conceptFiles.push(path);
|
|
42
|
+
}
|
|
43
|
+
else if (!reserved) {
|
|
44
|
+
otherFiles.push(path);
|
|
45
|
+
}
|
|
46
|
+
let dir = parentDir(path);
|
|
47
|
+
this.entriesFor(dir).files.push(name);
|
|
48
|
+
while (true) {
|
|
49
|
+
if (!reserved) {
|
|
50
|
+
const counts = this.dirCounts.get(dir) ?? { conceptCount: 0, fileCount: 0 };
|
|
51
|
+
if (isConcept) {
|
|
52
|
+
counts.conceptCount++;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
counts.fileCount++;
|
|
56
|
+
}
|
|
57
|
+
this.dirCounts.set(dir, counts);
|
|
58
|
+
}
|
|
59
|
+
if (dir === "") {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
if (!dirSet.has(dir)) {
|
|
63
|
+
// First sighting: register dir as a child of its parent.
|
|
64
|
+
dirSet.add(dir);
|
|
65
|
+
this.entriesFor(parentDir(dir)).dirs.push(posix.basename(dir));
|
|
66
|
+
}
|
|
67
|
+
dir = parentDir(dir);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
this.paths = sorted;
|
|
71
|
+
this.conceptFiles = conceptFiles;
|
|
72
|
+
this.otherFiles = otherFiles;
|
|
73
|
+
this.fileSet = new Set(sorted);
|
|
74
|
+
this.dirSet = dirSet;
|
|
75
|
+
this.dirs = Array.from(dirSet).sort();
|
|
76
|
+
}
|
|
77
|
+
entriesFor(dir) {
|
|
78
|
+
let entries = this.dirEntries.get(dir);
|
|
79
|
+
if (!entries) {
|
|
80
|
+
entries = { files: [], dirs: [] };
|
|
81
|
+
this.dirEntries.set(dir, entries);
|
|
82
|
+
}
|
|
83
|
+
return entries;
|
|
84
|
+
}
|
|
85
|
+
get isEmpty() {
|
|
86
|
+
return this.paths.length === 0;
|
|
87
|
+
}
|
|
88
|
+
hasFile(path) {
|
|
89
|
+
return this.fileSet.has(path);
|
|
90
|
+
}
|
|
91
|
+
hasDir(dir) {
|
|
92
|
+
return dir === "" ? !this.isEmpty : this.dirSet.has(dir);
|
|
93
|
+
}
|
|
94
|
+
/** Direct-child basenames of `dir` ("" = root). */
|
|
95
|
+
entries(dir) {
|
|
96
|
+
return this.dirEntries.get(dir) ?? NO_ENTRIES;
|
|
97
|
+
}
|
|
98
|
+
/** Recursive counts of non-reserved files below `dir`. */
|
|
99
|
+
counts(dir) {
|
|
100
|
+
return this.dirCounts.get(dir) ?? NO_COUNTS;
|
|
101
|
+
}
|
|
102
|
+
/** True for an existing file or directory addressed by a bundle path that stays inside the bundle. */
|
|
103
|
+
exists(bundlePath) {
|
|
104
|
+
let normalized = posix.normalize(bundlePath.replace(/^\/+/, ""));
|
|
105
|
+
if (normalized === ".." || normalized.startsWith("../"))
|
|
106
|
+
return false;
|
|
107
|
+
if (normalized === "." || normalized === "./")
|
|
108
|
+
return !this.isEmpty;
|
|
109
|
+
normalized = normalized.replace(/\/+$/, "");
|
|
110
|
+
return this.fileSet.has(normalized) || this.dirSet.has(normalized);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function buildDirListing(tree, relDir, catalogLookup) {
|
|
114
|
+
const normRelDir = normalizeDir(relDir);
|
|
115
|
+
const isRoot = normRelDir === "";
|
|
116
|
+
const entries = tree.entries(normRelDir);
|
|
117
|
+
const concepts = [];
|
|
118
|
+
const files = [];
|
|
119
|
+
const dirs = [];
|
|
120
|
+
for (const name of entries.files) {
|
|
121
|
+
if (isReservedName(name)) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const itemRel = isRoot ? name : posix.join(normRelDir, name);
|
|
125
|
+
if (name.toLowerCase().endsWith(".md")) {
|
|
126
|
+
const conceptId = itemRel.slice(0, -3);
|
|
127
|
+
const record = catalogLookup(conceptId);
|
|
128
|
+
concepts.push({
|
|
129
|
+
file: name,
|
|
130
|
+
title: record?.title ?? displayTitle(null, conceptId),
|
|
131
|
+
description: record?.description ?? null,
|
|
132
|
+
type: record?.type ?? null,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
files.push(name);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const name of entries.dirs) {
|
|
140
|
+
const counts = tree.counts(isRoot ? name : posix.join(normRelDir, name));
|
|
141
|
+
// Dirs without non-reserved files are pruned by index regeneration, so they are never listed.
|
|
142
|
+
if (counts.conceptCount + counts.fileCount === 0) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
dirs.push({ name, conceptCount: counts.conceptCount, fileCount: counts.fileCount });
|
|
146
|
+
}
|
|
147
|
+
return { isRoot, concepts, files, dirs };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Plans index.md changes for `dirs` (plus their ancestors) or every dir. `content: null` means delete.
|
|
151
|
+
* Ordered deepest-first so a child's deletion (which prunes its dir) precedes its parent.
|
|
152
|
+
*/
|
|
153
|
+
export function planIndexes(tree, dirs, lookup) {
|
|
154
|
+
if (tree.isEmpty) {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
let targetDirs;
|
|
158
|
+
if (dirs === "all") {
|
|
159
|
+
targetDirs = new Set(["", ...tree.dirs]);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
targetDirs = new Set([""]);
|
|
163
|
+
for (const d of dirs) {
|
|
164
|
+
let current = normalizeDir(d);
|
|
165
|
+
while (current !== "") {
|
|
166
|
+
targetDirs.add(current);
|
|
167
|
+
current = parentDir(current);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const sorted = Array.from(targetDirs).sort((a, b) => {
|
|
172
|
+
const depthA = a === "" ? 0 : a.split("/").length;
|
|
173
|
+
const depthB = b === "" ? 0 : b.split("/").length;
|
|
174
|
+
if (depthB !== depthA) {
|
|
175
|
+
return depthB - depthA;
|
|
176
|
+
}
|
|
177
|
+
return b.localeCompare(a);
|
|
178
|
+
});
|
|
179
|
+
const plan = [];
|
|
180
|
+
for (const dir of sorted) {
|
|
181
|
+
const indexPath = dir === "" ? "index.md" : `${dir}/index.md`;
|
|
182
|
+
if (dir !== "") {
|
|
183
|
+
if (!tree.hasDir(dir)) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const counts = tree.counts(dir);
|
|
187
|
+
if (counts.conceptCount + counts.fileCount === 0) {
|
|
188
|
+
if (tree.hasFile(indexPath)) {
|
|
189
|
+
plan.push({ path: indexPath, content: null });
|
|
190
|
+
}
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
plan.push({ path: indexPath, content: renderIndex(buildDirListing(tree, dir, lookup)) });
|
|
195
|
+
}
|
|
196
|
+
return plan;
|
|
197
|
+
}
|
|
198
|
+
export async function lintBundle(source, now) {
|
|
199
|
+
const issues = [];
|
|
200
|
+
const tree = new BundleTree(source.paths);
|
|
201
|
+
const conceptIdSet = new Set(tree.conceptFiles.map((f) => f.slice(0, -3)));
|
|
202
|
+
const allBundleFiles = new Set([...tree.conceptFiles, ...tree.otherFiles]);
|
|
203
|
+
const ctx = {
|
|
204
|
+
now,
|
|
205
|
+
conceptExists: (id) => conceptIdSet.has(id),
|
|
206
|
+
fileExists: (relPath) => allBundleFiles.has(relPath),
|
|
207
|
+
};
|
|
208
|
+
const readText = async (path) => {
|
|
209
|
+
const buf = await source.read(path);
|
|
210
|
+
return buf === null ? null : buf.toString("utf8");
|
|
211
|
+
};
|
|
212
|
+
for (const cFile of tree.conceptFiles) {
|
|
213
|
+
const text = await readText(cFile);
|
|
214
|
+
if (text !== null) {
|
|
215
|
+
issues.push(...lintConceptFile(cFile, text, ctx));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (tree.hasFile("index.md")) {
|
|
219
|
+
const text = await readText("index.md");
|
|
220
|
+
if (text !== null) {
|
|
221
|
+
issues.push(...lintIndexFile("index.md", text, true));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
issues.push({
|
|
226
|
+
severity: "info",
|
|
227
|
+
code: "missing_root_index",
|
|
228
|
+
path: "index.md",
|
|
229
|
+
message: "bundle is missing root index.md",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
for (const d of tree.dirs) {
|
|
233
|
+
const subIndex = `${d}/index.md`;
|
|
234
|
+
if (tree.hasFile(subIndex)) {
|
|
235
|
+
const text = await readText(subIndex);
|
|
236
|
+
if (text !== null) {
|
|
237
|
+
issues.push(...lintIndexFile(subIndex, text, false));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (tree.hasFile("log.md")) {
|
|
242
|
+
const text = await readText("log.md");
|
|
243
|
+
if (text !== null) {
|
|
244
|
+
issues.push(...lintLogFile("log.md", text));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const conformant = !issues.some((i) => i.severity === "error");
|
|
248
|
+
return { conformant, issues };
|
|
249
|
+
}
|
|
250
|
+
//# sourceMappingURL=bundle.js.map
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import MiniSearch from "minisearch";
|
|
2
|
+
import { parseConcept } from "../okf/concept.js";
|
|
3
|
+
import { isStale } from "../okf/semantics.js";
|
|
4
|
+
import { isConceptPath } from "./bundle.js";
|
|
5
|
+
function tierRank(tier) {
|
|
6
|
+
if (tier === "human-reviewed")
|
|
7
|
+
return 3;
|
|
8
|
+
if (tier === "machine-confirmed")
|
|
9
|
+
return 2;
|
|
10
|
+
return 1;
|
|
11
|
+
}
|
|
12
|
+
function extractSnippet(body, terms, description) {
|
|
13
|
+
if (terms.length > 0) {
|
|
14
|
+
const lowerTerms = terms.map((t) => t.toLowerCase()).filter((t) => t.length > 0);
|
|
15
|
+
const lines = body.split(/\r?\n/);
|
|
16
|
+
let inCode = false;
|
|
17
|
+
for (const rawLine of lines) {
|
|
18
|
+
const trimmed = rawLine.trim();
|
|
19
|
+
if (trimmed.startsWith("```")) {
|
|
20
|
+
inCode = !inCode;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (inCode || trimmed.startsWith("#") || trimmed.length === 0) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const lowerLine = trimmed.toLowerCase();
|
|
27
|
+
if (lowerTerms.some((t) => lowerLine.includes(t))) {
|
|
28
|
+
if (trimmed.length > 240) {
|
|
29
|
+
return `${trimmed.slice(0, 240)}…`;
|
|
30
|
+
}
|
|
31
|
+
return trimmed;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return description ?? null;
|
|
36
|
+
}
|
|
37
|
+
export class Catalog {
|
|
38
|
+
miniSearch;
|
|
39
|
+
// project -> (id -> ConceptRecord)
|
|
40
|
+
projectRecords = new Map();
|
|
41
|
+
// project -> (targetId -> Set<sourceId>)
|
|
42
|
+
inboundLinks = new Map();
|
|
43
|
+
constructor() {
|
|
44
|
+
this.miniSearch = new MiniSearch({
|
|
45
|
+
idField: "key",
|
|
46
|
+
fields: ["title", "description", "tags", "type", "idText", "body"],
|
|
47
|
+
storeFields: ["project", "id"],
|
|
48
|
+
searchOptions: {
|
|
49
|
+
boost: { title: 3, description: 2, tags: 2, idText: 1.5, type: 1, body: 1 },
|
|
50
|
+
prefix: (t) => t.length >= 3,
|
|
51
|
+
fuzzy: (t) => (t.length >= 5 ? 0.2 : 0),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async rebuildProject(project, source) {
|
|
56
|
+
this.removeProject(project);
|
|
57
|
+
for (const file of source.paths) {
|
|
58
|
+
if (!isConceptPath(file)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const buf = await source.read(file);
|
|
63
|
+
if (buf === null) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const id = file.endsWith(".md") ? file.slice(0, -3) : file;
|
|
67
|
+
this.storeRecord(parseConcept(project, id, file, buf));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// ignore unreadable file during rebuild
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
removeProject(project) {
|
|
75
|
+
const pMap = this.projectRecords.get(project);
|
|
76
|
+
if (pMap) {
|
|
77
|
+
for (const id of pMap.keys()) {
|
|
78
|
+
const key = `${project}/${id}`;
|
|
79
|
+
if (this.miniSearch.has(key)) {
|
|
80
|
+
this.miniSearch.discard(key);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.projectRecords.delete(project);
|
|
84
|
+
}
|
|
85
|
+
this.inboundLinks.delete(project);
|
|
86
|
+
}
|
|
87
|
+
upsert(record) {
|
|
88
|
+
this.storeRecord(record);
|
|
89
|
+
}
|
|
90
|
+
storeRecord(record) {
|
|
91
|
+
const { project, id } = record;
|
|
92
|
+
let pMap = this.projectRecords.get(project);
|
|
93
|
+
if (!pMap) {
|
|
94
|
+
pMap = new Map();
|
|
95
|
+
this.projectRecords.set(project, pMap);
|
|
96
|
+
}
|
|
97
|
+
let pInbound = this.inboundLinks.get(project);
|
|
98
|
+
if (!pInbound) {
|
|
99
|
+
pInbound = new Map();
|
|
100
|
+
this.inboundLinks.set(project, pInbound);
|
|
101
|
+
}
|
|
102
|
+
const prev = pMap.get(id);
|
|
103
|
+
if (prev) {
|
|
104
|
+
for (const target of prev.outbound) {
|
|
105
|
+
pInbound.get(target)?.delete(id);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
pMap.set(id, record);
|
|
109
|
+
for (const target of record.outbound) {
|
|
110
|
+
let sources = pInbound.get(target);
|
|
111
|
+
if (!sources) {
|
|
112
|
+
sources = new Set();
|
|
113
|
+
pInbound.set(target, sources);
|
|
114
|
+
}
|
|
115
|
+
sources.add(id);
|
|
116
|
+
}
|
|
117
|
+
const key = `${project}/${id}`;
|
|
118
|
+
if (record.parseError) {
|
|
119
|
+
if (this.miniSearch.has(key)) {
|
|
120
|
+
this.miniSearch.discard(key);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const doc = {
|
|
125
|
+
key,
|
|
126
|
+
project,
|
|
127
|
+
id,
|
|
128
|
+
idText: id.replace(/[/\-_]/g, " "),
|
|
129
|
+
title: record.title,
|
|
130
|
+
description: record.description ?? "",
|
|
131
|
+
tags: record.tags.join(" "),
|
|
132
|
+
type: record.type ?? "",
|
|
133
|
+
body: record.body,
|
|
134
|
+
};
|
|
135
|
+
if (this.miniSearch.has(key)) {
|
|
136
|
+
this.miniSearch.replace(doc);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
this.miniSearch.add(doc);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
remove(project, id) {
|
|
144
|
+
const pMap = this.projectRecords.get(project);
|
|
145
|
+
if (pMap) {
|
|
146
|
+
const prev = pMap.get(id);
|
|
147
|
+
if (prev) {
|
|
148
|
+
const pInbound = this.inboundLinks.get(project);
|
|
149
|
+
if (pInbound) {
|
|
150
|
+
for (const target of prev.outbound) {
|
|
151
|
+
pInbound.get(target)?.delete(id);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
pMap.delete(id);
|
|
156
|
+
}
|
|
157
|
+
const key = `${project}/${id}`;
|
|
158
|
+
if (this.miniSearch.has(key)) {
|
|
159
|
+
this.miniSearch.discard(key);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
get(project, id) {
|
|
163
|
+
return this.projectRecords.get(project)?.get(id);
|
|
164
|
+
}
|
|
165
|
+
records(project) {
|
|
166
|
+
const pMap = this.projectRecords.get(project);
|
|
167
|
+
return pMap ? Array.from(pMap.values()) : [];
|
|
168
|
+
}
|
|
169
|
+
projects() {
|
|
170
|
+
return Array.from(this.projectRecords.keys()).sort((a, b) => a.localeCompare(b, "en"));
|
|
171
|
+
}
|
|
172
|
+
inbound(project, id) {
|
|
173
|
+
const sources = this.inboundLinks.get(project)?.get(id);
|
|
174
|
+
if (!sources || sources.size === 0) {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
return Array.from(sources).sort((a, b) => a.localeCompare(b, "en"));
|
|
178
|
+
}
|
|
179
|
+
search(params) {
|
|
180
|
+
const limit = Math.max(1, Math.min(100, params.limit ?? 20));
|
|
181
|
+
const now = new Date();
|
|
182
|
+
const matchesFilter = (record) => {
|
|
183
|
+
if (record.parseError) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
if (params.project && record.project !== params.project) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
if (params.type && record.type?.toLowerCase() !== params.type.toLowerCase()) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
if (params.tags && params.tags.length > 0) {
|
|
193
|
+
const lowerRecordTags = record.tags.map((t) => t.toLowerCase());
|
|
194
|
+
const hasAllTags = params.tags.every((t) => lowerRecordTags.includes(t.toLowerCase()));
|
|
195
|
+
if (!hasAllTags) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (params.status != null) {
|
|
200
|
+
if (record.status !== params.status) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
// If status is omitted, exclude deprecated
|
|
206
|
+
if (record.status === "deprecated") {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (params.trustTier != null && record.trustTier !== params.trustTier) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
if (params.stale !== undefined) {
|
|
214
|
+
const staleState = isStale(record.frontmatter, now);
|
|
215
|
+
if (staleState !== params.stale) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
};
|
|
221
|
+
const q = params.query?.trim() ?? "";
|
|
222
|
+
if (q.length > 0) {
|
|
223
|
+
const rawHits = this.miniSearch.search(q);
|
|
224
|
+
const results = [];
|
|
225
|
+
for (const hit of rawHits) {
|
|
226
|
+
const record = this.get(hit.project, hit.id);
|
|
227
|
+
if (!record || !matchesFilter(record)) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const snippet = extractSnippet(record.body, hit.terms, record.description);
|
|
231
|
+
results.push({
|
|
232
|
+
project: record.project,
|
|
233
|
+
id: record.id,
|
|
234
|
+
title: record.title,
|
|
235
|
+
description: record.description,
|
|
236
|
+
type: record.type,
|
|
237
|
+
tags: record.tags,
|
|
238
|
+
status: record.status,
|
|
239
|
+
trustTier: record.trustTier,
|
|
240
|
+
stale: isStale(record.frontmatter, now),
|
|
241
|
+
score: hit.score,
|
|
242
|
+
snippet,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
results.sort((a, b) => {
|
|
246
|
+
const scoreDiff = (b.score ?? 0) - (a.score ?? 0);
|
|
247
|
+
if (Math.abs(scoreDiff) > 1e-9) {
|
|
248
|
+
return scoreDiff;
|
|
249
|
+
}
|
|
250
|
+
const tierDiff = tierRank(b.trustTier) - tierRank(a.trustTier);
|
|
251
|
+
if (tierDiff !== 0) {
|
|
252
|
+
return tierDiff;
|
|
253
|
+
}
|
|
254
|
+
const keyA = `${a.project}/${a.id}`;
|
|
255
|
+
const keyB = `${b.project}/${b.id}`;
|
|
256
|
+
return keyA.localeCompare(keyB, "en");
|
|
257
|
+
});
|
|
258
|
+
return results.slice(0, limit);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const results = [];
|
|
262
|
+
const projectList = params.project ? [params.project] : this.projects();
|
|
263
|
+
for (const p of projectList) {
|
|
264
|
+
const pRecords = this.records(p);
|
|
265
|
+
for (const record of pRecords) {
|
|
266
|
+
if (!matchesFilter(record)) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
results.push({
|
|
270
|
+
project: record.project,
|
|
271
|
+
id: record.id,
|
|
272
|
+
title: record.title,
|
|
273
|
+
description: record.description,
|
|
274
|
+
type: record.type,
|
|
275
|
+
tags: record.tags,
|
|
276
|
+
status: record.status,
|
|
277
|
+
trustTier: record.trustTier,
|
|
278
|
+
stale: isStale(record.frontmatter, now),
|
|
279
|
+
score: null,
|
|
280
|
+
snippet: record.description,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
results.sort((a, b) => {
|
|
285
|
+
const pDiff = a.project.localeCompare(b.project, "en");
|
|
286
|
+
if (pDiff !== 0) {
|
|
287
|
+
return pDiff;
|
|
288
|
+
}
|
|
289
|
+
return a.id.localeCompare(b.id, "en");
|
|
290
|
+
});
|
|
291
|
+
return results.slice(0, limit);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
//# sourceMappingURL=catalog.js.map
|