chapterhouse 0.3.17 → 0.3.19
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/dist/api/server-runtime.js +0 -16
- package/dist/api/server.js +21 -14
- package/dist/api/server.test.js +57 -26
- package/dist/copilot/skills.test.js +4 -0
- package/dist/copilot/tools.js +32 -0
- package/dist/copilot/tools.wiki.test.js +46 -0
- package/dist/wiki/fix.js +335 -0
- package/dist/wiki/fix.test.js +350 -0
- package/dist/wiki/frontmatter.js +105 -0
- package/dist/wiki/frontmatter.test.js +120 -0
- package/dist/wiki/fs.js +16 -0
- package/dist/wiki/fs.test.js +19 -1
- package/dist/wiki/index-manager.test.js +13 -1
- package/dist/wiki/lint.test.js +17 -20
- package/dist/wiki/topic-structure.js +3 -1
- package/dist/wiki/topic-structure.test.js +11 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BYuMgJ36.js → index-9We9vWBC.js} +63 -63
- package/web/dist/assets/index-9We9vWBC.js.map +1 -0
- package/web/dist/assets/{index-lvHFM_ut.css → index-DYx2idiH.css} +1 -1
- package/web/dist/index.html +2 -2
- package/skills/squad/SKILL.md +0 -76
- package/web/dist/assets/index-BYuMgJ36.js.map +0 -1
|
@@ -46,20 +46,4 @@ export function shouldServeSpaPath(pathname) {
|
|
|
46
46
|
export function getDisplayHost(host) {
|
|
47
47
|
return host === "0.0.0.0" || host === "::" || host === "127.0.0.1" || host === "::1" ? "localhost" : host;
|
|
48
48
|
}
|
|
49
|
-
export function buildHistoryEntries(pageIds, options) {
|
|
50
|
-
const resolveWikiPath = options?.resolveWikiPath ?? ((pageId) => pageId);
|
|
51
|
-
const stat = options?.stat;
|
|
52
|
-
return pageIds
|
|
53
|
-
.map((pageId) => {
|
|
54
|
-
try {
|
|
55
|
-
const fullPath = resolveWikiPath(pageId);
|
|
56
|
-
const mtime = stat ? stat(fullPath).mtimeMs : 0;
|
|
57
|
-
return { path: pageId, mtime };
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
return { path: pageId, mtime: 0 };
|
|
61
|
-
}
|
|
62
|
-
})
|
|
63
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
64
|
-
}
|
|
65
49
|
//# sourceMappingURL=server-runtime.js.map
|
package/dist/api/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import cors from "cors";
|
|
2
2
|
import express from "express";
|
|
3
3
|
import helmet from "helmet";
|
|
4
|
-
import { existsSync
|
|
4
|
+
import { existsSync } from "fs";
|
|
5
5
|
import { join, dirname } from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { z } from "zod";
|
|
@@ -19,13 +19,13 @@ import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
|
|
|
19
19
|
import { withWikiWrite } from "../wiki/lock.js";
|
|
20
20
|
import { listSkills, removeSkill } from "../copilot/skills.js";
|
|
21
21
|
import { restartDaemon } from "../daemon.js";
|
|
22
|
-
import { API_TOKEN_PATH
|
|
22
|
+
import { API_TOKEN_PATH } from "../paths.js";
|
|
23
23
|
import { getDb, getSessionMessages, getTaskEvents } from "../store/db.js";
|
|
24
24
|
import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
|
|
25
25
|
import { subscribeSession, getSessionEventsFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
|
|
26
26
|
import { getStatus, onStatusChange } from "../status.js";
|
|
27
27
|
import { formatSseData, formatSseEvent } from "./sse.js";
|
|
28
|
-
import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload,
|
|
28
|
+
import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
|
|
29
29
|
import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
|
|
30
30
|
import { childLogger } from "../util/logger.js";
|
|
31
31
|
const log = childLogger("server");
|
|
@@ -203,6 +203,20 @@ function assertValidPagePath(path) {
|
|
|
203
203
|
function getWikiPageScope(path) {
|
|
204
204
|
return teamWikiSync.isTeamPath(path) ? "team" : "personal";
|
|
205
205
|
}
|
|
206
|
+
function getEmptyWikiWelcomeContent(today = new Date()) {
|
|
207
|
+
return `---
|
|
208
|
+
title: Wiki
|
|
209
|
+
summary: Empty wiki — get started.
|
|
210
|
+
updated: ${today.toISOString().slice(0, 10)}
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
# Wiki
|
|
214
|
+
|
|
215
|
+
Your wiki is empty. Pages are organized by category — projects, people, tools, topics, areas, orgs, facts, preferences, routines.
|
|
216
|
+
|
|
217
|
+
Create your first page via the wiki UI or by editing files under \`pages/\`.
|
|
218
|
+
`;
|
|
219
|
+
}
|
|
206
220
|
// Active SSE connections
|
|
207
221
|
const sseClients = new Map();
|
|
208
222
|
const pendingSseMessages = [];
|
|
@@ -746,6 +760,10 @@ app.get("/api/wiki/page", async (req, res) => {
|
|
|
746
760
|
: undefined;
|
|
747
761
|
const content = await readWikiPage(path, { authorizationHeader });
|
|
748
762
|
if (content === undefined) {
|
|
763
|
+
if (path === "pages/index.md") {
|
|
764
|
+
res.json({ path, content: getEmptyWikiWelcomeContent() });
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
749
767
|
throw new NotFoundError("Page not found");
|
|
750
768
|
}
|
|
751
769
|
res.json({ path, content });
|
|
@@ -766,17 +784,6 @@ app.delete("/api/wiki/page", async (req, res) => {
|
|
|
766
784
|
res.json({ ok: removed, path });
|
|
767
785
|
});
|
|
768
786
|
// ---------------------------------------------------------------------------
|
|
769
|
-
// History — past conversation summaries auto-written to pages/conversations/
|
|
770
|
-
// ---------------------------------------------------------------------------
|
|
771
|
-
app.get("/api/history", (_req, res) => {
|
|
772
|
-
ensureWikiStructure();
|
|
773
|
-
const entries = buildHistoryEntries(listPages().filter((p) => p.startsWith("pages/conversations/")), {
|
|
774
|
-
resolveWikiPath: resolveWikiRelativePath,
|
|
775
|
-
stat: statSync,
|
|
776
|
-
});
|
|
777
|
-
res.json(entries);
|
|
778
|
-
});
|
|
779
|
-
// ---------------------------------------------------------------------------
|
|
780
787
|
// Skills
|
|
781
788
|
// ---------------------------------------------------------------------------
|
|
782
789
|
app.get("/api/skills", (_req, res) => {
|
package/dist/api/server.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
@@ -66,31 +66,6 @@ test("formats named SSE status events", async () => {
|
|
|
66
66
|
assert.equal(typeof sse.formatSseEvent, "function", "formatSseEvent should be exported");
|
|
67
67
|
assert.equal(sse.formatSseEvent("status", { status: "dreaming", message: "Consolidating memories..." }), 'event: status\ndata: {"status":"dreaming","message":"Consolidating memories..."}\n\n');
|
|
68
68
|
});
|
|
69
|
-
test("buildHistoryEntries resolves wiki files through the shared path resolver", async () => {
|
|
70
|
-
const runtime = await import("./server-runtime.js");
|
|
71
|
-
assert.equal(typeof runtime.buildHistoryEntries, "function", "buildHistoryEntries should be exported");
|
|
72
|
-
const seen = [];
|
|
73
|
-
const entries = runtime.buildHistoryEntries([
|
|
74
|
-
"pages/conversations/older.md",
|
|
75
|
-
"pages/conversations/newer.md",
|
|
76
|
-
], {
|
|
77
|
-
resolveWikiPath: (pageId) => {
|
|
78
|
-
seen.push(pageId);
|
|
79
|
-
return `/wiki/${pageId}`;
|
|
80
|
-
},
|
|
81
|
-
stat: (fullPath) => ({
|
|
82
|
-
mtimeMs: fullPath.endsWith("newer.md") ? 200 : 100,
|
|
83
|
-
}),
|
|
84
|
-
});
|
|
85
|
-
assert.deepEqual(seen, [
|
|
86
|
-
"pages/conversations/older.md",
|
|
87
|
-
"pages/conversations/newer.md",
|
|
88
|
-
]);
|
|
89
|
-
assert.deepEqual(entries, [
|
|
90
|
-
{ path: "pages/conversations/newer.md", mtime: 200 },
|
|
91
|
-
{ path: "pages/conversations/older.md", mtime: 100 },
|
|
92
|
-
]);
|
|
93
|
-
});
|
|
94
69
|
const repoRoot = process.cwd();
|
|
95
70
|
const serverTestRoot = join(repoRoot, ".test-work", `server-routes-${process.pid}`);
|
|
96
71
|
async function getFreePort() {
|
|
@@ -293,6 +268,62 @@ test("server wiki routes support authenticated CRUD", async () => {
|
|
|
293
268
|
assert.deepEqual(await missingResponse.json(), { error: "Page not found" });
|
|
294
269
|
});
|
|
295
270
|
});
|
|
271
|
+
test("server wiki route synthesizes a welcome page when pages/index.md is missing", async () => {
|
|
272
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
273
|
+
rmSync(join(serverTestRoot, ".chapterhouse", "wiki", "pages", "index.md"), { force: true });
|
|
274
|
+
const response = await fetch(`${baseUrl}/api/wiki/page?path=pages/index.md`, {
|
|
275
|
+
headers: { authorization: authHeader },
|
|
276
|
+
});
|
|
277
|
+
assert.equal(response.status, 200);
|
|
278
|
+
assert.deepEqual(await response.json(), {
|
|
279
|
+
path: "pages/index.md",
|
|
280
|
+
content: `---
|
|
281
|
+
title: Wiki
|
|
282
|
+
summary: Empty wiki — get started.
|
|
283
|
+
updated: ${new Date().toISOString().slice(0, 10)}
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
# Wiki
|
|
287
|
+
|
|
288
|
+
Your wiki is empty. Pages are organized by category — projects, people, tools, topics, areas, orgs, facts, preferences, routines.
|
|
289
|
+
|
|
290
|
+
Create your first page via the wiki UI or by editing files under \`pages/\`.
|
|
291
|
+
`,
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
test("server wiki route returns stored content for pages/index.md when it exists", async () => {
|
|
296
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
297
|
+
const indexPath = join(serverTestRoot, ".chapterhouse", "wiki", "pages", "index.md");
|
|
298
|
+
const content = `---
|
|
299
|
+
title: Wiki
|
|
300
|
+
summary: Existing home page.
|
|
301
|
+
updated: 2026-05-12
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
# Custom Wiki Home
|
|
305
|
+
`;
|
|
306
|
+
mkdirSync(join(serverTestRoot, ".chapterhouse", "wiki", "pages"), { recursive: true });
|
|
307
|
+
writeFileSync(indexPath, content, "utf-8");
|
|
308
|
+
const response = await fetch(`${baseUrl}/api/wiki/page?path=pages/index.md`, {
|
|
309
|
+
headers: { authorization: authHeader },
|
|
310
|
+
});
|
|
311
|
+
assert.equal(response.status, 200);
|
|
312
|
+
assert.deepEqual(await response.json(), {
|
|
313
|
+
path: "pages/index.md",
|
|
314
|
+
content,
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
test("server wiki route still returns 404 for other missing wiki pages", async () => {
|
|
319
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
320
|
+
const response = await fetch(`${baseUrl}/api/wiki/page?path=pages/projects/nonexistent/index.md`, {
|
|
321
|
+
headers: { authorization: authHeader },
|
|
322
|
+
});
|
|
323
|
+
assert.equal(response.status, 404);
|
|
324
|
+
assert.deepEqual(await response.json(), { error: "Page not found" });
|
|
325
|
+
});
|
|
326
|
+
});
|
|
296
327
|
test("server message route validates the SSE connection id", async () => {
|
|
297
328
|
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
298
329
|
const response = await fetch(`${baseUrl}/api/message`, {
|
|
@@ -7,4 +7,8 @@ test("listSkills includes bundled wiki-conventions skill metadata", () => {
|
|
|
7
7
|
assert.equal(skill.name, "wiki-conventions");
|
|
8
8
|
assert.match(skill.description, /creating, editing, linting, restructuring, or reviewing Chapterhouse wiki content/i);
|
|
9
9
|
});
|
|
10
|
+
test("listSkills does not include a bundled squad skill", () => {
|
|
11
|
+
const skill = listSkills().find((entry) => entry.slug === "squad" && entry.source === "bundled");
|
|
12
|
+
assert.equal(skill, undefined);
|
|
13
|
+
});
|
|
10
14
|
//# sourceMappingURL=skills.test.js.map
|
package/dist/copilot/tools.js
CHANGED
|
@@ -11,7 +11,9 @@ import { getRouterConfig, updateRouterConfig } from "./router.js";
|
|
|
11
11
|
import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
|
|
12
12
|
import { searchIndex, addToIndex, removeFromIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
|
|
13
13
|
import { validateWikiFrontmatter } from "../wiki/frontmatter.js";
|
|
14
|
+
import { fixWiki } from "../wiki/fix.js";
|
|
14
15
|
import { lintWiki, renderWikiLintReport } from "../wiki/lint.js";
|
|
16
|
+
import { rebuildIndexFromPages } from "../wiki/index-manager.js";
|
|
15
17
|
import { appendLog } from "../wiki/log-manager.js";
|
|
16
18
|
import { loadTaxonomy } from "../wiki/taxonomy.js";
|
|
17
19
|
import { getCategoryDir, topicPagePath, slugify, entityCategories, FLAT_CATEGORIES } from "../wiki/topic-structure.js";
|
|
@@ -1088,6 +1090,36 @@ export function createTools(deps) {
|
|
|
1088
1090
|
});
|
|
1089
1091
|
},
|
|
1090
1092
|
}),
|
|
1093
|
+
defineTool("wiki_fix", {
|
|
1094
|
+
description: "Auto-repair common wiki inconsistencies. Defaults to dry-run preview mode and returns a per-file report " +
|
|
1095
|
+
"plus unified diff. Supports frontmatter backfill, taxonomy tag normalization, and autostub marking.",
|
|
1096
|
+
parameters: z.object({
|
|
1097
|
+
dry_run: z.boolean().optional().describe("Preview only when true (default). Set false to apply fixes."),
|
|
1098
|
+
fixes: z.array(z.enum(["frontmatter-backfill", "tag-normalize", "autostub-mark"])).optional()
|
|
1099
|
+
.describe("Optional subset of fixes to apply. Defaults to all fixes."),
|
|
1100
|
+
path_glob: z.string().optional().describe("Optional glob to limit which wiki page paths are considered."),
|
|
1101
|
+
}),
|
|
1102
|
+
handler: async (args) => {
|
|
1103
|
+
ensureWikiStructure();
|
|
1104
|
+
const dryRun = args.dry_run ?? true;
|
|
1105
|
+
const runFixer = () => {
|
|
1106
|
+
const report = fixWiki({
|
|
1107
|
+
dryRun,
|
|
1108
|
+
fixes: args.fixes,
|
|
1109
|
+
pathGlob: args.path_glob,
|
|
1110
|
+
logAction: dryRun ? undefined : (type, path) => appendLog(type, path),
|
|
1111
|
+
});
|
|
1112
|
+
if (!dryRun && report.changedFiles > 0) {
|
|
1113
|
+
rebuildIndexFromPages();
|
|
1114
|
+
}
|
|
1115
|
+
return report;
|
|
1116
|
+
};
|
|
1117
|
+
if (dryRun) {
|
|
1118
|
+
return runFixer();
|
|
1119
|
+
}
|
|
1120
|
+
return withWikiWrite(runFixer);
|
|
1121
|
+
},
|
|
1122
|
+
}),
|
|
1091
1123
|
defineTool("restart_chapterhouse", {
|
|
1092
1124
|
description: "Restart the Chapterhouse daemon process. Use when the user asks Chapterhouse to restart himself, " +
|
|
1093
1125
|
"or when a restart is needed to pick up configuration changes. " +
|
|
@@ -140,4 +140,50 @@ Runtime notes with enough content to avoid incidental lint noise in the audit-lo
|
|
|
140
140
|
assert.match(log, /rebuild-index \| wiki_rebuild_index: rebuilt \d+ entries from pages on disk \| tools-test-agent/);
|
|
141
141
|
assert.match(log, /delete \| forget: deleted page pages\/shared\/chapterhouse\.md \| tools-test-agent/);
|
|
142
142
|
});
|
|
143
|
+
test("wiki_fix defaults to dry-run previews and logs applied fixes in write mode", async () => {
|
|
144
|
+
const toolsModule = await loadToolsModule();
|
|
145
|
+
const tools = toolsModule.createTools({
|
|
146
|
+
client: { async listModels() { return []; } },
|
|
147
|
+
onAgentTaskComplete: () => { },
|
|
148
|
+
});
|
|
149
|
+
const wikiFix = tools.find((entry) => entry.name === "wiki_fix");
|
|
150
|
+
assert.ok(wikiFix);
|
|
151
|
+
const wikiFs = await readWikiArtifacts();
|
|
152
|
+
wikiFs.writePage("pages/_meta/taxonomy.md", `## Process
|
|
153
|
+
- runbook
|
|
154
|
+
`);
|
|
155
|
+
wikiFs.writePage("pages/projects/chapterhouse/index.md", `---
|
|
156
|
+
title: Chapterhouse
|
|
157
|
+
summary: Runtime notes
|
|
158
|
+
updated: 2026-05-12
|
|
159
|
+
tags: [Run Book]
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
# Chapterhouse
|
|
163
|
+
|
|
164
|
+
Runtime notes with enough content to avoid incidental stub handling.
|
|
165
|
+
|
|
166
|
+
## Details
|
|
167
|
+
|
|
168
|
+
One
|
|
169
|
+
Two
|
|
170
|
+
Three
|
|
171
|
+
Four
|
|
172
|
+
Five
|
|
173
|
+
Six
|
|
174
|
+
Seven
|
|
175
|
+
Eight
|
|
176
|
+
`);
|
|
177
|
+
const before = wikiFs.readPage("pages/projects/chapterhouse/index.md");
|
|
178
|
+
const preview = await wikiFix.handler({});
|
|
179
|
+
assert.equal(typeof preview, "object");
|
|
180
|
+
assert.equal(preview.dryRun, true);
|
|
181
|
+
assert.equal(preview.changedFiles, 1);
|
|
182
|
+
assert.match(preview.diff, /--- a\/pages\/projects\/chapterhouse\/index\.md/);
|
|
183
|
+
assert.equal(wikiFs.readPage("pages/projects/chapterhouse/index.md"), before);
|
|
184
|
+
const applied = await wikiFix.handler({ dry_run: false });
|
|
185
|
+
assert.equal(applied.dryRun, false);
|
|
186
|
+
assert.match(wikiFs.readPage("pages/projects/chapterhouse/index.md") ?? "", /tags: \[runbook\]/);
|
|
187
|
+
assert.match(wikiFs.readLogFile(), /fix-tags \| pages\/projects\/chapterhouse\/index\.md \| tools-test-agent/);
|
|
188
|
+
});
|
|
143
189
|
//# sourceMappingURL=tools.wiki.test.js.map
|
package/dist/wiki/fix.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname } from "node:path";
|
|
3
|
+
import { resolveWikiRelativePath } from "../paths.js";
|
|
4
|
+
import { parseWikiFrontmatter } from "./frontmatter.js";
|
|
5
|
+
import { ensureWikiStructure, listPages, readPage, writePage } from "./fs.js";
|
|
6
|
+
import { loadTaxonomy } from "./taxonomy.js";
|
|
7
|
+
const DEFAULT_FIXES = [
|
|
8
|
+
"frontmatter-backfill",
|
|
9
|
+
"tag-normalize",
|
|
10
|
+
"autostub-mark",
|
|
11
|
+
];
|
|
12
|
+
const KNOWN_FIELD_ORDER = [
|
|
13
|
+
"title",
|
|
14
|
+
"summary",
|
|
15
|
+
"updated",
|
|
16
|
+
"tags",
|
|
17
|
+
"autostub",
|
|
18
|
+
"confidence",
|
|
19
|
+
"contested",
|
|
20
|
+
"contradictions",
|
|
21
|
+
"related",
|
|
22
|
+
];
|
|
23
|
+
export function fixWiki(options = {}) {
|
|
24
|
+
ensureWikiStructure();
|
|
25
|
+
const dryRun = options.dryRun ?? true;
|
|
26
|
+
const enabledFixes = new Set(options.fixes?.length ? options.fixes : DEFAULT_FIXES);
|
|
27
|
+
const pathFilter = options.pathGlob ? compileGlob(options.pathGlob) : undefined;
|
|
28
|
+
const allowedTags = enabledFixes.has("tag-normalize") ? loadTaxonomy() : [];
|
|
29
|
+
const canonicalTags = new Map(allowedTags.map((tag) => [normalizeTagKey(tag), tag]));
|
|
30
|
+
const reports = [];
|
|
31
|
+
const diffs = [];
|
|
32
|
+
let changedFiles = 0;
|
|
33
|
+
const pages = listPages()
|
|
34
|
+
.filter(isFixablePage)
|
|
35
|
+
.filter((path) => !pathFilter || pathFilter.test(path))
|
|
36
|
+
.sort();
|
|
37
|
+
for (const path of pages) {
|
|
38
|
+
const content = readPage(path);
|
|
39
|
+
if (!content)
|
|
40
|
+
continue;
|
|
41
|
+
const parsedResult = parseWikiFrontmatter(content);
|
|
42
|
+
if (parsedResult.parsed.metadata.autofix === false) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const nextFrontmatter = cloneFrontmatter(parsedResult.parsed);
|
|
46
|
+
const body = parsedResult.body;
|
|
47
|
+
const changes = [];
|
|
48
|
+
let unknownTagsForFile = [];
|
|
49
|
+
if (enabledFixes.has("frontmatter-backfill")) {
|
|
50
|
+
let addedTitle = false;
|
|
51
|
+
let addedSummary = false;
|
|
52
|
+
let addedUpdated = false;
|
|
53
|
+
let addedAutostub = false;
|
|
54
|
+
if (!nextFrontmatter.title?.trim()) {
|
|
55
|
+
nextFrontmatter.title = inferTitle(path, body);
|
|
56
|
+
addedTitle = true;
|
|
57
|
+
}
|
|
58
|
+
if (!nextFrontmatter.summary?.trim()) {
|
|
59
|
+
const summary = inferSummary(body);
|
|
60
|
+
nextFrontmatter.summary = summary.text;
|
|
61
|
+
addedSummary = true;
|
|
62
|
+
if (summary.shouldMarkAutostub && nextFrontmatter.autostub !== true && nextFrontmatter.autostub === undefined) {
|
|
63
|
+
nextFrontmatter.autostub = true;
|
|
64
|
+
addedAutostub = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!nextFrontmatter.updated?.trim()) {
|
|
68
|
+
nextFrontmatter.updated = formatDate(statSync(resolveWikiRelativePath(path)).mtime);
|
|
69
|
+
addedUpdated = true;
|
|
70
|
+
}
|
|
71
|
+
const details = [
|
|
72
|
+
...(addedTitle ? ["title"] : []),
|
|
73
|
+
...(addedSummary ? ["summary"] : []),
|
|
74
|
+
...(addedUpdated ? ["updated"] : []),
|
|
75
|
+
...(addedAutostub ? ["autostub"] : []),
|
|
76
|
+
];
|
|
77
|
+
if (details.length > 0) {
|
|
78
|
+
changes.push({ rule: "frontmatter-backfill", details });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (enabledFixes.has("tag-normalize") && nextFrontmatter.tags?.length) {
|
|
82
|
+
const normalizedTags = [];
|
|
83
|
+
const details = [];
|
|
84
|
+
const unknownTags = [];
|
|
85
|
+
for (const tag of nextFrontmatter.tags) {
|
|
86
|
+
const normalized = canonicalTags.get(normalizeTagKey(tag));
|
|
87
|
+
if (normalized) {
|
|
88
|
+
normalizedTags.push(normalized);
|
|
89
|
+
if (normalized !== tag) {
|
|
90
|
+
details.push(`${tag} -> ${normalized}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
normalizedTags.push(tag);
|
|
95
|
+
unknownTags.push(tag);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (details.length > 0) {
|
|
99
|
+
nextFrontmatter.tags = normalizedTags;
|
|
100
|
+
changes.push({ rule: "tag-normalize", details });
|
|
101
|
+
}
|
|
102
|
+
unknownTagsForFile = uniqueStrings(unknownTags);
|
|
103
|
+
}
|
|
104
|
+
if (enabledFixes.has("autostub-mark") && nextFrontmatter.autostub !== true && isStubBody(body)) {
|
|
105
|
+
nextFrontmatter.autostub = true;
|
|
106
|
+
changes.push({ rule: "autostub-mark", details: ["autostub"] });
|
|
107
|
+
}
|
|
108
|
+
if (changes.length === 0 && unknownTagsForFile.length === 0) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const fileReport = {
|
|
112
|
+
path,
|
|
113
|
+
changes,
|
|
114
|
+
};
|
|
115
|
+
if (unknownTagsForFile.length > 0) {
|
|
116
|
+
fileReport.unknownTags = unknownTagsForFile;
|
|
117
|
+
}
|
|
118
|
+
reports.push(fileReport);
|
|
119
|
+
if (changes.length === 0) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
changedFiles += 1;
|
|
123
|
+
const nextContent = renderPage(nextFrontmatter, body);
|
|
124
|
+
if (dryRun) {
|
|
125
|
+
diffs.push(renderUnifiedDiff(path, content, nextContent));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
writePage(path, nextContent);
|
|
129
|
+
for (const change of changes) {
|
|
130
|
+
const logType = logTypeForRule(change.rule);
|
|
131
|
+
if (logType) {
|
|
132
|
+
options.logAction?.(logType, path);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
dryRun,
|
|
138
|
+
scannedFiles: pages.length,
|
|
139
|
+
changedFiles,
|
|
140
|
+
files: reports,
|
|
141
|
+
diff: dryRun ? diffs.join("\n") : "",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function cloneFrontmatter(frontmatter) {
|
|
145
|
+
return {
|
|
146
|
+
...frontmatter,
|
|
147
|
+
tags: frontmatter.tags ? [...frontmatter.tags] : undefined,
|
|
148
|
+
contradictions: frontmatter.contradictions ? [...frontmatter.contradictions] : undefined,
|
|
149
|
+
related: frontmatter.related ? [...frontmatter.related] : undefined,
|
|
150
|
+
metadata: { ...frontmatter.metadata },
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function inferTitle(path, body) {
|
|
154
|
+
const heading = firstHeading(body);
|
|
155
|
+
if (heading) {
|
|
156
|
+
return stripMarkdown(heading);
|
|
157
|
+
}
|
|
158
|
+
return titleFromPath(path);
|
|
159
|
+
}
|
|
160
|
+
function inferSummary(body) {
|
|
161
|
+
const lines = body.split("\n");
|
|
162
|
+
const headingIndex = lines.findIndex((line) => /^#\s+/.test(line.trim()));
|
|
163
|
+
const startIndex = headingIndex >= 0 ? headingIndex + 1 : 0;
|
|
164
|
+
const paragraph = [];
|
|
165
|
+
for (let index = startIndex; index < lines.length; index += 1) {
|
|
166
|
+
const line = lines[index]?.trim() ?? "";
|
|
167
|
+
if (!line) {
|
|
168
|
+
if (paragraph.length > 0)
|
|
169
|
+
break;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (line.startsWith("#")) {
|
|
173
|
+
if (paragraph.length > 0)
|
|
174
|
+
break;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
paragraph.push(line);
|
|
178
|
+
}
|
|
179
|
+
const summary = stripMarkdown(paragraph.join(" ")).trim();
|
|
180
|
+
if (!summary) {
|
|
181
|
+
return {
|
|
182
|
+
text: "(no summary yet)",
|
|
183
|
+
shouldMarkAutostub: true,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
text: truncate(summary, 200),
|
|
188
|
+
shouldMarkAutostub: false,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function firstHeading(body) {
|
|
192
|
+
for (const rawLine of body.split("\n")) {
|
|
193
|
+
const match = rawLine.trim().match(/^#\s+(.+)$/);
|
|
194
|
+
if (match) {
|
|
195
|
+
return match[1].trim();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
function titleFromPath(path) {
|
|
201
|
+
const file = basename(path, ".md");
|
|
202
|
+
const stem = file === "index" ? basename(dirname(path)) : file;
|
|
203
|
+
return stem
|
|
204
|
+
.split(/[-_]+/)
|
|
205
|
+
.filter(Boolean)
|
|
206
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
207
|
+
.join(" ");
|
|
208
|
+
}
|
|
209
|
+
function stripMarkdown(value) {
|
|
210
|
+
return value
|
|
211
|
+
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
|
|
212
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
213
|
+
.replace(/[`*_~>#]/g, "")
|
|
214
|
+
.replace(/\s+/g, " ")
|
|
215
|
+
.trim();
|
|
216
|
+
}
|
|
217
|
+
function truncate(value, maxLength) {
|
|
218
|
+
if (value.length <= maxLength) {
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
return value.slice(0, maxLength).trimEnd();
|
|
222
|
+
}
|
|
223
|
+
function formatDate(date) {
|
|
224
|
+
return date.toISOString().slice(0, 10);
|
|
225
|
+
}
|
|
226
|
+
function normalizeTagKey(tag) {
|
|
227
|
+
return tag.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
228
|
+
}
|
|
229
|
+
function isStubBody(body) {
|
|
230
|
+
const meaningfulLines = body
|
|
231
|
+
.split("\n")
|
|
232
|
+
.map((line) => line.trim())
|
|
233
|
+
.filter(Boolean);
|
|
234
|
+
return meaningfulLines.length < 10;
|
|
235
|
+
}
|
|
236
|
+
function renderPage(frontmatter, body) {
|
|
237
|
+
const lines = ["---"];
|
|
238
|
+
for (const field of KNOWN_FIELD_ORDER) {
|
|
239
|
+
const value = frontmatter[field];
|
|
240
|
+
if (value === undefined)
|
|
241
|
+
continue;
|
|
242
|
+
lines.push(formatFrontmatterLine(field, value));
|
|
243
|
+
}
|
|
244
|
+
for (const [key, value] of Object.entries(frontmatter.metadata)) {
|
|
245
|
+
lines.push(formatFrontmatterLine(key, value));
|
|
246
|
+
}
|
|
247
|
+
lines.push("---");
|
|
248
|
+
const normalizedBody = body.replace(/^\n+/, "");
|
|
249
|
+
if (!normalizedBody) {
|
|
250
|
+
return `${lines.join("\n")}\n`;
|
|
251
|
+
}
|
|
252
|
+
return `${lines.join("\n")}\n\n${normalizedBody.endsWith("\n") ? normalizedBody : `${normalizedBody}\n`}`;
|
|
253
|
+
}
|
|
254
|
+
function formatFrontmatterLine(key, value) {
|
|
255
|
+
if (Array.isArray(value)) {
|
|
256
|
+
return `${key}: [${value.map(formatInlineValue).join(", ")}]`;
|
|
257
|
+
}
|
|
258
|
+
if (typeof value === "boolean") {
|
|
259
|
+
return `${key}: ${value ? "true" : "false"}`;
|
|
260
|
+
}
|
|
261
|
+
return `${key}: ${formatScalar(value)}`;
|
|
262
|
+
}
|
|
263
|
+
function formatInlineValue(value) {
|
|
264
|
+
return /^[A-Za-z0-9_./()-]+$/.test(value) ? value : formatScalar(value);
|
|
265
|
+
}
|
|
266
|
+
function formatScalar(value) {
|
|
267
|
+
if (value === "" || /[:#[\]{}'",]|^\s|\s$/.test(value)) {
|
|
268
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
269
|
+
}
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
function uniqueStrings(values) {
|
|
273
|
+
return [...new Set(values)];
|
|
274
|
+
}
|
|
275
|
+
function renderUnifiedDiff(path, before, after) {
|
|
276
|
+
const beforeLines = splitLines(before);
|
|
277
|
+
const afterLines = splitLines(after);
|
|
278
|
+
const beforeCount = beforeLines.length;
|
|
279
|
+
const afterCount = afterLines.length;
|
|
280
|
+
const diffLines = [
|
|
281
|
+
`--- a/${path}`,
|
|
282
|
+
`+++ b/${path}`,
|
|
283
|
+
`@@ -1,${beforeCount} +1,${afterCount} @@`,
|
|
284
|
+
...beforeLines.map((line) => `-${line}`),
|
|
285
|
+
...afterLines.map((line) => `+${line}`),
|
|
286
|
+
];
|
|
287
|
+
return diffLines.join("\n");
|
|
288
|
+
}
|
|
289
|
+
function splitLines(content) {
|
|
290
|
+
return content.endsWith("\n")
|
|
291
|
+
? content.slice(0, -1).split("\n")
|
|
292
|
+
: content.split("\n");
|
|
293
|
+
}
|
|
294
|
+
function compileGlob(pattern) {
|
|
295
|
+
let regex = "^";
|
|
296
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
297
|
+
const char = pattern[index];
|
|
298
|
+
const next = pattern[index + 1];
|
|
299
|
+
if (char === "*" && next === "*") {
|
|
300
|
+
regex += ".*";
|
|
301
|
+
index += 1;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (char === "*") {
|
|
305
|
+
regex += "[^/]*";
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (char === "?") {
|
|
309
|
+
regex += "[^/]";
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
regex += escapeRegex(char);
|
|
313
|
+
}
|
|
314
|
+
regex += "$";
|
|
315
|
+
return new RegExp(regex);
|
|
316
|
+
}
|
|
317
|
+
function escapeRegex(value) {
|
|
318
|
+
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
319
|
+
}
|
|
320
|
+
function logTypeForRule(rule) {
|
|
321
|
+
switch (rule) {
|
|
322
|
+
case "frontmatter-backfill":
|
|
323
|
+
return "fix-frontmatter";
|
|
324
|
+
case "tag-normalize":
|
|
325
|
+
return "fix-tags";
|
|
326
|
+
case "autostub-mark":
|
|
327
|
+
return "fix-autostub";
|
|
328
|
+
default:
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function isFixablePage(path) {
|
|
333
|
+
return !path.startsWith("pages/_meta/") && path !== "pages/index.md";
|
|
334
|
+
}
|
|
335
|
+
//# sourceMappingURL=fix.js.map
|