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.
@@ -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
@@ -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, statSync } from "fs";
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, resolveWikiRelativePath } from "../paths.js";
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, buildHistoryEntries, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
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) => {
@@ -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
@@ -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
@@ -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