@riebeckite/plugin-diagnostics 0.0.1
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 +201 -0
- package/README.md +126 -0
- package/README_ja.md +98 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +1168 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +978 -0
- package/dist/src/analyze.d.ts +3 -0
- package/dist/src/checks/assets.d.ts +4 -0
- package/dist/src/checks/excluded_public.d.ts +4 -0
- package/dist/src/checks/frontmatter.d.ts +4 -0
- package/dist/src/checks/markdown_references.d.ts +4 -0
- package/dist/src/checks/note_metadata.d.ts +5 -0
- package/dist/src/checks/orphans.d.ts +4 -0
- package/dist/src/checks/publish_boundary.d.ts +11 -0
- package/dist/src/checks/shared.d.ts +20 -0
- package/dist/src/checks/wikilinks.d.ts +4 -0
- package/dist/src/frontmatter.d.ts +7 -0
- package/dist/src/location.d.ts +9 -0
- package/dist/src/markdown_links.d.ts +7 -0
- package/dist/src/report.d.ts +13 -0
- package/dist/src/types.d.ts +32 -0
- package/dist/src/vault.d.ts +48 -0
- package/package.json +61 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
// bin.ts
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
|
+
import { resolveConfigModule } from "@riebeckite/core";
|
|
4
|
+
|
|
5
|
+
// index.ts
|
|
6
|
+
import { definePlugin, FileSystemContentSource as FileSystemContentSource2 } from "@riebeckite/core";
|
|
7
|
+
|
|
8
|
+
// src/checks/shared.ts
|
|
9
|
+
var PLUGIN_NAME = "diagnostics";
|
|
10
|
+
var DEFAULT_SEVERITY = {
|
|
11
|
+
"broken-wikilink": "error",
|
|
12
|
+
"broken-image": "error",
|
|
13
|
+
"broken-link": "error",
|
|
14
|
+
"unused-asset": "warning",
|
|
15
|
+
"orphan-note": "info",
|
|
16
|
+
"missing-frontmatter": "warning",
|
|
17
|
+
"publish-conflict": "warning",
|
|
18
|
+
"duplicate-title": "warning",
|
|
19
|
+
"slug-collision": "error",
|
|
20
|
+
"excluded-public": "warning",
|
|
21
|
+
"publish-boundary": "warning",
|
|
22
|
+
"internal-error": "error"
|
|
23
|
+
};
|
|
24
|
+
function createAnalysisState() {
|
|
25
|
+
return {
|
|
26
|
+
referencedAssets: /* @__PURE__ */ new Set(),
|
|
27
|
+
incoming: /* @__PURE__ */ new Map()
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function normalizeOptions(options) {
|
|
31
|
+
return {
|
|
32
|
+
reportUnusedAssets: options.reportUnusedAssets ?? false,
|
|
33
|
+
reportOrphans: options.reportOrphans ?? false,
|
|
34
|
+
requiredFrontmatter: options.requiredFrontmatter ?? [],
|
|
35
|
+
severity: options.severity ?? {}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function addIncoming(state, target, source) {
|
|
39
|
+
const sources = state.incoming.get(target) ?? /* @__PURE__ */ new Set();
|
|
40
|
+
sources.add(source);
|
|
41
|
+
state.incoming.set(target, sources);
|
|
42
|
+
}
|
|
43
|
+
function pushDiagnostic(diagnostics, options, location, code, message, target, suggestion, line, column) {
|
|
44
|
+
diagnostics.push({
|
|
45
|
+
pluginName: PLUGIN_NAME,
|
|
46
|
+
code,
|
|
47
|
+
severity: options.severity[code] ?? DEFAULT_SEVERITY[code] ?? "warning",
|
|
48
|
+
message,
|
|
49
|
+
slug: location.slug,
|
|
50
|
+
filePath: location.filePath,
|
|
51
|
+
line,
|
|
52
|
+
column,
|
|
53
|
+
target,
|
|
54
|
+
suggestion
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/checks/assets.ts
|
|
59
|
+
function checkUnusedAssets(source, state, options, diagnostics) {
|
|
60
|
+
if (!options.reportUnusedAssets) return;
|
|
61
|
+
for (const asset of source.assets) {
|
|
62
|
+
if (!state.referencedAssets.has(asset.relativePath))
|
|
63
|
+
pushDiagnostic(
|
|
64
|
+
diagnostics,
|
|
65
|
+
options,
|
|
66
|
+
{ filePath: asset.relativePath },
|
|
67
|
+
"unused-asset",
|
|
68
|
+
`image "${asset.relativePath}" is never referenced by any note`,
|
|
69
|
+
asset.relativePath,
|
|
70
|
+
"reference the image from a note or remove it"
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/checks/excluded_public.ts
|
|
76
|
+
function checkExcludedPublic(source, options, diagnostics) {
|
|
77
|
+
for (const note of source.excludedNotes) {
|
|
78
|
+
if (note.fm.values.publish === true)
|
|
79
|
+
pushDiagnostic(
|
|
80
|
+
diagnostics,
|
|
81
|
+
options,
|
|
82
|
+
{ slug: note.slug, filePath: note.relativePath },
|
|
83
|
+
"excluded-public",
|
|
84
|
+
`excluded note "${note.slug}" is marked publish: true`,
|
|
85
|
+
note.slug,
|
|
86
|
+
"unset publish or move the note outside of the excluded directory"
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/frontmatter.ts
|
|
92
|
+
function parseFrontmatter(markdown) {
|
|
93
|
+
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
94
|
+
if (!match?.[1]) return { hasFrontmatter: false, values: {} };
|
|
95
|
+
return { hasFrontmatter: true, values: parseFrontmatterBlock(match[1]) };
|
|
96
|
+
}
|
|
97
|
+
function hasField(values, field) {
|
|
98
|
+
if (!(field in values)) return false;
|
|
99
|
+
const value = values[field];
|
|
100
|
+
if (value === null || value === void 0) return false;
|
|
101
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
102
|
+
if (typeof value === "string") return value.trim() !== "";
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
function getNoteTitle(values, slug) {
|
|
106
|
+
const title = values.title;
|
|
107
|
+
return typeof title === "string" && title.trim() ? title : slug;
|
|
108
|
+
}
|
|
109
|
+
function parseFrontmatterBlock(body) {
|
|
110
|
+
const values = {};
|
|
111
|
+
const lines = body.split(/\r?\n/);
|
|
112
|
+
let openKey = null;
|
|
113
|
+
const pending = [];
|
|
114
|
+
const closeList = () => {
|
|
115
|
+
if (openKey !== null) {
|
|
116
|
+
values[openKey] = pending.length > 0 ? [...pending] : [];
|
|
117
|
+
}
|
|
118
|
+
openKey = null;
|
|
119
|
+
pending.length = 0;
|
|
120
|
+
};
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
123
|
+
if (keyMatch) {
|
|
124
|
+
closeList();
|
|
125
|
+
const [, key, rest] = keyMatch;
|
|
126
|
+
const trimmed = rest.trim();
|
|
127
|
+
if (trimmed === "") {
|
|
128
|
+
openKey = key;
|
|
129
|
+
} else if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
130
|
+
values[key] = trimmed.slice(1, -1).split(",").map((item) => stripQuotes(item.trim())).filter(Boolean);
|
|
131
|
+
} else {
|
|
132
|
+
values[key] = parseScalar(trimmed);
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const itemMatch = line.match(/^\s*-\s+(.+)$/);
|
|
137
|
+
if (itemMatch) {
|
|
138
|
+
if (openKey !== null) {
|
|
139
|
+
pending.push(stripQuotes(itemMatch[1].trim()));
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (line.trim() === "") continue;
|
|
144
|
+
closeList();
|
|
145
|
+
}
|
|
146
|
+
closeList();
|
|
147
|
+
return values;
|
|
148
|
+
}
|
|
149
|
+
function parseScalar(value) {
|
|
150
|
+
if (/^true$/i.test(value)) return true;
|
|
151
|
+
if (/^false$/i.test(value)) return false;
|
|
152
|
+
return stripQuotes(value);
|
|
153
|
+
}
|
|
154
|
+
function stripQuotes(value) {
|
|
155
|
+
return value.trim().replace(/^['"]|['"]$/g, "");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/vault.ts
|
|
159
|
+
import {
|
|
160
|
+
FileSystemContentSource,
|
|
161
|
+
IMAGE_EXTENSIONS,
|
|
162
|
+
isAttachmentPath,
|
|
163
|
+
isExcluded,
|
|
164
|
+
isPublishable
|
|
165
|
+
} from "@riebeckite/core";
|
|
166
|
+
var NOTE_EXTENSION = "md";
|
|
167
|
+
var WIKILINK_TARGET_PATTERN = /(!)?\[\[([^\]|#^]+)(?:#(\^?[^\]|]+))?(?:\|[^\]]+)?\]\]/g;
|
|
168
|
+
async function scanVault(config) {
|
|
169
|
+
const source = config.source ?? new FileSystemContentSource(config.directory, config.exclude);
|
|
170
|
+
const targetIndex = /* @__PURE__ */ new Map();
|
|
171
|
+
const filePaths = /* @__PURE__ */ new Set();
|
|
172
|
+
const noteSlugs = /* @__PURE__ */ new Set();
|
|
173
|
+
const noteBySlug = /* @__PURE__ */ new Map();
|
|
174
|
+
const assetPaths = /* @__PURE__ */ new Set();
|
|
175
|
+
const assets = [];
|
|
176
|
+
const includedNotes = [];
|
|
177
|
+
const excludedNotes = [];
|
|
178
|
+
const entries = await source.scan();
|
|
179
|
+
for (const entry of entries) {
|
|
180
|
+
const relative = entry.path;
|
|
181
|
+
filePaths.add(relative);
|
|
182
|
+
const excluded = isExcluded(config.exclude, relative);
|
|
183
|
+
const extension = getExtension(relative);
|
|
184
|
+
if (extension === NOTE_EXTENSION) {
|
|
185
|
+
const slug = relative.slice(0, -NOTE_EXTENSION.length - 1);
|
|
186
|
+
const markdown = normalizeMarkdown(await readMarkdown(source, entry));
|
|
187
|
+
const fm = parseFrontmatter(markdown);
|
|
188
|
+
const note = {
|
|
189
|
+
relativePath: relative,
|
|
190
|
+
slug,
|
|
191
|
+
markdown,
|
|
192
|
+
fm,
|
|
193
|
+
headings: extractHeadings(markdown),
|
|
194
|
+
blockIds: extractBlockIds(markdown),
|
|
195
|
+
excluded,
|
|
196
|
+
published: isPublishable(config.publishStrategy, fm.values)
|
|
197
|
+
};
|
|
198
|
+
if (excluded) {
|
|
199
|
+
excludedNotes.push(note);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
includedNotes.push(note);
|
|
203
|
+
noteSlugs.add(slug);
|
|
204
|
+
noteBySlug.set(slug, note);
|
|
205
|
+
addToIndex(targetIndex, slug, "note", fm);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (excluded) continue;
|
|
209
|
+
addToIndex(
|
|
210
|
+
targetIndex,
|
|
211
|
+
relative,
|
|
212
|
+
IMAGE_EXTENSIONS.includes(extension) ? "image" : "attachment"
|
|
213
|
+
);
|
|
214
|
+
if (IMAGE_EXTENSIONS.includes(extension)) {
|
|
215
|
+
assetPaths.add(relative);
|
|
216
|
+
assets.push({ relativePath: relative, extension });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
includedNotes,
|
|
221
|
+
excludedNotes,
|
|
222
|
+
noteSlugs,
|
|
223
|
+
noteBySlug,
|
|
224
|
+
assetPaths,
|
|
225
|
+
assets,
|
|
226
|
+
filePaths,
|
|
227
|
+
targetIndex
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function getWikilinkMatches(markdown) {
|
|
231
|
+
WIKILINK_TARGET_PATTERN.lastIndex = 0;
|
|
232
|
+
const matches = [];
|
|
233
|
+
for (let match = WIKILINK_TARGET_PATTERN.exec(markdown); match !== null; match = WIKILINK_TARGET_PATTERN.exec(markdown)) {
|
|
234
|
+
const target = match[2]?.trim();
|
|
235
|
+
if (!target) continue;
|
|
236
|
+
const fragment = match[3]?.trim() || null;
|
|
237
|
+
matches.push({
|
|
238
|
+
embed: match[1] === "!",
|
|
239
|
+
target,
|
|
240
|
+
fragment,
|
|
241
|
+
index: match.index
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return matches;
|
|
245
|
+
}
|
|
246
|
+
function resolveWikilinkTarget(target, targetIndex) {
|
|
247
|
+
let key = target.trim().toLowerCase();
|
|
248
|
+
if (key.endsWith(`.${NOTE_EXTENSION}`)) {
|
|
249
|
+
key = key.slice(0, -NOTE_EXTENSION.length - 1);
|
|
250
|
+
}
|
|
251
|
+
return targetIndex.get(key) ?? null;
|
|
252
|
+
}
|
|
253
|
+
function isImageTarget(target) {
|
|
254
|
+
const extension = getExtension(target);
|
|
255
|
+
return IMAGE_EXTENSIONS.includes(extension);
|
|
256
|
+
}
|
|
257
|
+
function isAssetTarget(target) {
|
|
258
|
+
return isImageTarget(target) || isAttachmentPath(target);
|
|
259
|
+
}
|
|
260
|
+
function resolveLocalReference(noteSlug, url) {
|
|
261
|
+
if (!url) return null;
|
|
262
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return null;
|
|
263
|
+
if (url.startsWith("#")) return null;
|
|
264
|
+
if (url.startsWith("/")) return null;
|
|
265
|
+
const withoutAnchor = url.split(/[?#]/)[0];
|
|
266
|
+
if (!withoutAnchor) return null;
|
|
267
|
+
let decoded;
|
|
268
|
+
try {
|
|
269
|
+
decoded = decodeURIComponent(withoutAnchor);
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
const base = noteSlug.split("/").slice(0, -1);
|
|
274
|
+
const resolved = normalizeRelativePath([...base, ...decoded.split("/")]);
|
|
275
|
+
if (resolved === ".." || resolved.startsWith("../")) return null;
|
|
276
|
+
return resolved;
|
|
277
|
+
}
|
|
278
|
+
function resolveVaultRelative(value) {
|
|
279
|
+
if (!value) return null;
|
|
280
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
|
|
281
|
+
if (value.startsWith("/")) return null;
|
|
282
|
+
const withoutAnchor = value.split(/[?#]/)[0];
|
|
283
|
+
if (!withoutAnchor) return null;
|
|
284
|
+
let decoded;
|
|
285
|
+
try {
|
|
286
|
+
decoded = decodeURIComponent(withoutAnchor);
|
|
287
|
+
} catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
return normalizeRelativePath(decoded.split("/"));
|
|
291
|
+
}
|
|
292
|
+
function addToIndex(targetIndex, value, kind, fm) {
|
|
293
|
+
const setKey = (key) => {
|
|
294
|
+
const existing = targetIndex.get(key);
|
|
295
|
+
if (existing && !(existing.kind !== "note" && kind === "note")) return;
|
|
296
|
+
targetIndex.set(key, { kind, value });
|
|
297
|
+
};
|
|
298
|
+
const parts = value.split("/");
|
|
299
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
300
|
+
setKey(parts.slice(i).join("/").toLowerCase());
|
|
301
|
+
}
|
|
302
|
+
if (fm) {
|
|
303
|
+
const aliases = fm.values.aliases;
|
|
304
|
+
const aliasList = Array.isArray(aliases) ? aliases.filter((alias) => typeof alias === "string") : [];
|
|
305
|
+
for (const alias of aliasList) {
|
|
306
|
+
const key = alias.trim().toLowerCase();
|
|
307
|
+
if (key) setKey(key);
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const stemValue = value.replace(/\.[^/.]+$/, "");
|
|
312
|
+
if (stemValue !== value) {
|
|
313
|
+
const stemParts = stemValue.split("/");
|
|
314
|
+
for (let i = stemParts.length - 1; i >= 0; i--) {
|
|
315
|
+
setKey(stemParts.slice(i).join("/").toLowerCase());
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function extractHeadings(markdown) {
|
|
320
|
+
const headings = /* @__PURE__ */ new Set();
|
|
321
|
+
for (const line of markdown.split("\n")) {
|
|
322
|
+
const match = line.match(/^#{1,6}\s+(.+?)\s*#*\s*$/);
|
|
323
|
+
const heading = match?.[1]?.trim();
|
|
324
|
+
if (heading) headings.add(heading.toLowerCase());
|
|
325
|
+
}
|
|
326
|
+
return headings;
|
|
327
|
+
}
|
|
328
|
+
function extractBlockIds(markdown) {
|
|
329
|
+
const blockIds = /* @__PURE__ */ new Set();
|
|
330
|
+
for (const line of markdown.split("\n")) {
|
|
331
|
+
const match = line.match(/(?:^|\s)\^([A-Za-z0-9_-]+)\s*$/);
|
|
332
|
+
const blockId = match?.[1];
|
|
333
|
+
if (blockId) blockIds.add(blockId);
|
|
334
|
+
}
|
|
335
|
+
return blockIds;
|
|
336
|
+
}
|
|
337
|
+
function fragmentExists(note, fragment) {
|
|
338
|
+
const raw = fragment.trim();
|
|
339
|
+
if (raw.startsWith("^")) {
|
|
340
|
+
const blockId = raw.slice(1).trim();
|
|
341
|
+
return blockId !== "" && note.blockIds.has(blockId);
|
|
342
|
+
}
|
|
343
|
+
const heading = raw.toLowerCase();
|
|
344
|
+
return heading !== "" && note.headings.has(heading);
|
|
345
|
+
}
|
|
346
|
+
function normalizeMarkdown(markdown) {
|
|
347
|
+
return markdown.replace(/\r\n/g, "\n");
|
|
348
|
+
}
|
|
349
|
+
function getExtension(filePath) {
|
|
350
|
+
const lastSegment = filePath.split("/").pop() ?? filePath;
|
|
351
|
+
const dotIndex = lastSegment.lastIndexOf(".");
|
|
352
|
+
if (dotIndex < 0) return "";
|
|
353
|
+
return lastSegment.slice(dotIndex + 1).toLowerCase();
|
|
354
|
+
}
|
|
355
|
+
async function readMarkdown(source, entry) {
|
|
356
|
+
const content = await source.read(entry);
|
|
357
|
+
return typeof content === "string" ? content : new TextDecoder().decode(content);
|
|
358
|
+
}
|
|
359
|
+
function normalizeRelativePath(parts) {
|
|
360
|
+
const normalized = [];
|
|
361
|
+
for (const part of parts) {
|
|
362
|
+
if (!part || part === ".") continue;
|
|
363
|
+
if (part === "..") {
|
|
364
|
+
if (normalized.length === 0) return "..";
|
|
365
|
+
normalized.pop();
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
normalized.push(part);
|
|
369
|
+
}
|
|
370
|
+
return normalized.join("/");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// src/checks/frontmatter.ts
|
|
374
|
+
function checkFrontmatter(note, source, state, options, diagnostics) {
|
|
375
|
+
const location = { slug: note.slug, filePath: note.relativePath };
|
|
376
|
+
const values = note.fm.values;
|
|
377
|
+
if (!note.fm.hasFrontmatter) {
|
|
378
|
+
pushDiagnostic(
|
|
379
|
+
diagnostics,
|
|
380
|
+
options,
|
|
381
|
+
location,
|
|
382
|
+
"missing-frontmatter",
|
|
383
|
+
"note has no frontmatter",
|
|
384
|
+
note.slug,
|
|
385
|
+
"add frontmatter with the required fields"
|
|
386
|
+
);
|
|
387
|
+
} else {
|
|
388
|
+
for (const field of options.requiredFrontmatter) {
|
|
389
|
+
if (!hasField(values, field))
|
|
390
|
+
pushDiagnostic(
|
|
391
|
+
diagnostics,
|
|
392
|
+
options,
|
|
393
|
+
location,
|
|
394
|
+
"missing-frontmatter",
|
|
395
|
+
`missing required frontmatter field "${field}"`,
|
|
396
|
+
field,
|
|
397
|
+
`add "${field}" to the frontmatter`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (values.publish === true && values.draft === true)
|
|
402
|
+
pushDiagnostic(
|
|
403
|
+
diagnostics,
|
|
404
|
+
options,
|
|
405
|
+
location,
|
|
406
|
+
"publish-conflict",
|
|
407
|
+
"frontmatter sets both publish: true and draft: true",
|
|
408
|
+
note.slug,
|
|
409
|
+
"set only one of publish or draft"
|
|
410
|
+
);
|
|
411
|
+
if (values.publish === true && values.private === true)
|
|
412
|
+
pushDiagnostic(
|
|
413
|
+
diagnostics,
|
|
414
|
+
options,
|
|
415
|
+
location,
|
|
416
|
+
"publish-conflict",
|
|
417
|
+
"frontmatter sets both publish: true and private: true",
|
|
418
|
+
note.slug,
|
|
419
|
+
"set only one of publish or private"
|
|
420
|
+
);
|
|
421
|
+
for (const field of ["image", "ogImage"]) {
|
|
422
|
+
const raw = values[field];
|
|
423
|
+
if (typeof raw !== "string" || !raw.trim()) continue;
|
|
424
|
+
const resolved = resolveLocalReference(note.slug, raw) ?? resolveVaultRelative(raw);
|
|
425
|
+
if (resolved && source.assetPaths.has(resolved))
|
|
426
|
+
state.referencedAssets.add(resolved);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// src/markdown_links.ts
|
|
431
|
+
import remarkParse from "remark-parse";
|
|
432
|
+
import { unified } from "unified";
|
|
433
|
+
import { visit } from "unist-util-visit";
|
|
434
|
+
function extractMarkdownReferences(markdown) {
|
|
435
|
+
const tree = unified().use(remarkParse).parse(markdown);
|
|
436
|
+
const references = [];
|
|
437
|
+
visit(tree, (node) => {
|
|
438
|
+
if (node.type !== "link" && node.type !== "image") return;
|
|
439
|
+
const url = node.url;
|
|
440
|
+
if (!url) return;
|
|
441
|
+
const start = node.position?.start;
|
|
442
|
+
references.push({
|
|
443
|
+
kind: node.type,
|
|
444
|
+
url,
|
|
445
|
+
line: start?.line ?? 0,
|
|
446
|
+
column: start?.column ?? 0
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
return references;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/checks/markdown_references.ts
|
|
453
|
+
function checkMarkdownReferences(note, source, state, options, diagnostics) {
|
|
454
|
+
const location = { slug: note.slug, filePath: note.relativePath };
|
|
455
|
+
for (const reference of extractMarkdownReferences(note.markdown)) {
|
|
456
|
+
const resolved = resolveLocalReference(note.slug, reference.url);
|
|
457
|
+
if (!resolved) continue;
|
|
458
|
+
const extension = getExtension(resolved);
|
|
459
|
+
if (reference.kind === "image") {
|
|
460
|
+
if (source.assetPaths.has(resolved) || source.filePaths.has(resolved))
|
|
461
|
+
state.referencedAssets.add(resolved);
|
|
462
|
+
else
|
|
463
|
+
pushDiagnostic(
|
|
464
|
+
diagnostics,
|
|
465
|
+
options,
|
|
466
|
+
location,
|
|
467
|
+
"broken-image",
|
|
468
|
+
`image "${reference.url}" does not exist`,
|
|
469
|
+
reference.url,
|
|
470
|
+
"fix the image path or add the image to the vault",
|
|
471
|
+
reference.line,
|
|
472
|
+
reference.column
|
|
473
|
+
);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (extension === "md") {
|
|
477
|
+
const targetSlug = resolved.slice(0, -3);
|
|
478
|
+
if (source.noteSlugs.has(targetSlug)) {
|
|
479
|
+
if (targetSlug !== note.slug) addIncoming(state, targetSlug, note.slug);
|
|
480
|
+
} else if (source.filePaths.has(resolved)) {
|
|
481
|
+
pushDiagnostic(
|
|
482
|
+
diagnostics,
|
|
483
|
+
options,
|
|
484
|
+
location,
|
|
485
|
+
"broken-link",
|
|
486
|
+
`link "${reference.url}" points to an excluded note that will not be published`,
|
|
487
|
+
reference.url,
|
|
488
|
+
"link to a published note or remove the link",
|
|
489
|
+
reference.line,
|
|
490
|
+
reference.column
|
|
491
|
+
);
|
|
492
|
+
} else {
|
|
493
|
+
pushDiagnostic(
|
|
494
|
+
diagnostics,
|
|
495
|
+
options,
|
|
496
|
+
location,
|
|
497
|
+
"broken-link",
|
|
498
|
+
`link "${reference.url}" points to a missing note`,
|
|
499
|
+
reference.url,
|
|
500
|
+
"fix the link to an existing note",
|
|
501
|
+
reference.line,
|
|
502
|
+
reference.column
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (isImageTarget(resolved)) {
|
|
508
|
+
if (source.assetPaths.has(resolved)) state.referencedAssets.add(resolved);
|
|
509
|
+
else
|
|
510
|
+
pushDiagnostic(
|
|
511
|
+
diagnostics,
|
|
512
|
+
options,
|
|
513
|
+
location,
|
|
514
|
+
"broken-image",
|
|
515
|
+
`image link "${reference.url}" does not exist`,
|
|
516
|
+
reference.url,
|
|
517
|
+
"fix the image path or add the image to the vault",
|
|
518
|
+
reference.line,
|
|
519
|
+
reference.column
|
|
520
|
+
);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (!source.filePaths.has(resolved))
|
|
524
|
+
pushDiagnostic(
|
|
525
|
+
diagnostics,
|
|
526
|
+
options,
|
|
527
|
+
location,
|
|
528
|
+
"broken-link",
|
|
529
|
+
`link "${reference.url}" points to a missing file`,
|
|
530
|
+
reference.url,
|
|
531
|
+
"fix the link to an existing file",
|
|
532
|
+
reference.line,
|
|
533
|
+
reference.column
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/checks/note_metadata.ts
|
|
539
|
+
function checkSlugCollisions(source, options, diagnostics) {
|
|
540
|
+
const byKey = groupNotes(
|
|
541
|
+
source.includedNotes,
|
|
542
|
+
(note) => note.slug.toLowerCase()
|
|
543
|
+
);
|
|
544
|
+
for (const [key, notes] of byKey) {
|
|
545
|
+
if (notes.length < 2) continue;
|
|
546
|
+
const others = notes.map((note) => note.slug).join(", ");
|
|
547
|
+
for (const note of notes)
|
|
548
|
+
pushDiagnostic(
|
|
549
|
+
diagnostics,
|
|
550
|
+
options,
|
|
551
|
+
{ slug: note.slug, filePath: note.relativePath },
|
|
552
|
+
"slug-collision",
|
|
553
|
+
`slug "${note.slug}" collides with ${others} case-insensitively`,
|
|
554
|
+
key,
|
|
555
|
+
"rename one of the notes to avoid the collision"
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
function checkDuplicateTitles(source, options, diagnostics) {
|
|
560
|
+
const byTitle = groupNotes(
|
|
561
|
+
source.includedNotes.filter((note) => note.published),
|
|
562
|
+
(note) => getNoteTitle(note.fm.values, note.slug).toLowerCase()
|
|
563
|
+
);
|
|
564
|
+
for (const [title, notes] of byTitle) {
|
|
565
|
+
if (notes.length < 2) continue;
|
|
566
|
+
const slugs = notes.map((note) => note.slug).join(", ");
|
|
567
|
+
for (const note of notes)
|
|
568
|
+
pushDiagnostic(
|
|
569
|
+
diagnostics,
|
|
570
|
+
options,
|
|
571
|
+
{ slug: note.slug, filePath: note.relativePath },
|
|
572
|
+
"duplicate-title",
|
|
573
|
+
`title "${title}" is used by multiple published notes: ${slugs}`,
|
|
574
|
+
typeof notes[0]?.fm.values.title === "string" ? notes[0].fm.values.title : title,
|
|
575
|
+
"rename the title or the note"
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
function groupNotes(notes, keyFor) {
|
|
580
|
+
const groups = /* @__PURE__ */ new Map();
|
|
581
|
+
for (const note of notes) {
|
|
582
|
+
const key = keyFor(note);
|
|
583
|
+
const group = groups.get(key) ?? [];
|
|
584
|
+
group.push(note);
|
|
585
|
+
groups.set(key, group);
|
|
586
|
+
}
|
|
587
|
+
return groups;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// src/checks/orphans.ts
|
|
591
|
+
function checkOrphans(source, state, options, diagnostics) {
|
|
592
|
+
if (!options.reportOrphans) return;
|
|
593
|
+
for (const note of source.includedNotes) {
|
|
594
|
+
if (!note.published || note.slug === "index" || (state.incoming.get(note.slug)?.size ?? 0) > 0)
|
|
595
|
+
continue;
|
|
596
|
+
pushDiagnostic(
|
|
597
|
+
diagnostics,
|
|
598
|
+
options,
|
|
599
|
+
{ slug: note.slug, filePath: note.relativePath },
|
|
600
|
+
"orphan-note",
|
|
601
|
+
"published note has no incoming links",
|
|
602
|
+
note.slug,
|
|
603
|
+
"link to this note from another note"
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/location.ts
|
|
609
|
+
var LineMapper = class {
|
|
610
|
+
starts;
|
|
611
|
+
constructor(text) {
|
|
612
|
+
const starts = [0];
|
|
613
|
+
for (let i = 0; i < text.length; i++) {
|
|
614
|
+
if (text[i] === "\n") starts.push(i + 1);
|
|
615
|
+
}
|
|
616
|
+
this.starts = starts;
|
|
617
|
+
}
|
|
618
|
+
positionAt(index) {
|
|
619
|
+
const starts = this.starts;
|
|
620
|
+
let low = 0;
|
|
621
|
+
let high = starts.length - 1;
|
|
622
|
+
while (low < high) {
|
|
623
|
+
const mid = Math.ceil((low + high) / 2);
|
|
624
|
+
if ((starts[mid] ?? 0) <= index) {
|
|
625
|
+
low = mid;
|
|
626
|
+
} else {
|
|
627
|
+
high = mid - 1;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return { line: low + 1, column: index - (starts[low] ?? 0) + 1 };
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
// src/checks/publish_boundary.ts
|
|
635
|
+
function checkPublishBoundary(source, options, diagnostics) {
|
|
636
|
+
for (const note of source.includedNotes) {
|
|
637
|
+
if (!note.published) continue;
|
|
638
|
+
const location = {
|
|
639
|
+
slug: note.slug,
|
|
640
|
+
filePath: note.relativePath
|
|
641
|
+
};
|
|
642
|
+
const position = new LineMapper(note.markdown);
|
|
643
|
+
for (const match of getWikilinkMatches(note.markdown)) {
|
|
644
|
+
const resolved = resolveWikilinkTarget(match.target, source.targetIndex);
|
|
645
|
+
if (!resolved || resolved.kind !== "note") continue;
|
|
646
|
+
const { line, column } = position.positionAt(match.index);
|
|
647
|
+
reportBoundaryReference(
|
|
648
|
+
source,
|
|
649
|
+
options,
|
|
650
|
+
diagnostics,
|
|
651
|
+
location,
|
|
652
|
+
resolved.value,
|
|
653
|
+
match.embed ? "embeds" : "links to",
|
|
654
|
+
line,
|
|
655
|
+
column
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
for (const reference of extractMarkdownReferences(note.markdown)) {
|
|
659
|
+
const resolved = resolveLocalReference(note.slug, reference.url);
|
|
660
|
+
if (!resolved || getExtension(resolved) !== "md") continue;
|
|
661
|
+
reportBoundaryReference(
|
|
662
|
+
source,
|
|
663
|
+
options,
|
|
664
|
+
diagnostics,
|
|
665
|
+
location,
|
|
666
|
+
resolved.slice(0, -3),
|
|
667
|
+
"links to",
|
|
668
|
+
reference.line,
|
|
669
|
+
reference.column
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function reportBoundaryReference(source, options, diagnostics, location, targetSlug, verb, line, column) {
|
|
675
|
+
if (targetSlug === location.slug) return;
|
|
676
|
+
const target = source.noteBySlug.get(targetSlug);
|
|
677
|
+
if (!target || target.published) return;
|
|
678
|
+
pushDiagnostic(
|
|
679
|
+
diagnostics,
|
|
680
|
+
options,
|
|
681
|
+
location,
|
|
682
|
+
"publish-boundary",
|
|
683
|
+
`published note "${location.slug}" ${verb} non-published note "${targetSlug}"`,
|
|
684
|
+
targetSlug,
|
|
685
|
+
"publish the target note or remove the reference from published content",
|
|
686
|
+
line,
|
|
687
|
+
column
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// src/checks/wikilinks.ts
|
|
692
|
+
function checkWikilinks(note, source, state, options, diagnostics) {
|
|
693
|
+
const position = new LineMapper(note.markdown);
|
|
694
|
+
const location = { slug: note.slug, filePath: note.relativePath };
|
|
695
|
+
for (const match of getWikilinkMatches(note.markdown)) {
|
|
696
|
+
const { line, column } = position.positionAt(match.index);
|
|
697
|
+
const rawTarget = match.target;
|
|
698
|
+
const resolved = resolveWikilinkTarget(rawTarget, source.targetIndex);
|
|
699
|
+
if (!resolved) {
|
|
700
|
+
const targetIsImage = isImageTarget(rawTarget);
|
|
701
|
+
const targetIsAsset = isAssetTarget(rawTarget);
|
|
702
|
+
const code = targetIsImage || match.embed ? "broken-image" : "broken-wikilink";
|
|
703
|
+
const verb = targetIsAsset || match.embed ? "embed" : "link";
|
|
704
|
+
pushDiagnostic(
|
|
705
|
+
diagnostics,
|
|
706
|
+
options,
|
|
707
|
+
location,
|
|
708
|
+
code,
|
|
709
|
+
`wikilink ${verb} "[[${rawTarget}]]" does not resolve to any published note or asset`,
|
|
710
|
+
rawTarget,
|
|
711
|
+
targetIsAsset || match.embed ? "fix the asset path or add the asset to the vault" : "create the target note or fix the link",
|
|
712
|
+
line,
|
|
713
|
+
column
|
|
714
|
+
);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
if (resolved.kind === "note") {
|
|
718
|
+
if (match.fragment) {
|
|
719
|
+
const targetNote = source.noteBySlug.get(resolved.value);
|
|
720
|
+
if (targetNote && !fragmentExists(targetNote, match.fragment)) {
|
|
721
|
+
pushDiagnostic(
|
|
722
|
+
diagnostics,
|
|
723
|
+
options,
|
|
724
|
+
location,
|
|
725
|
+
"broken-wikilink",
|
|
726
|
+
`wikilink fragment "#${match.fragment}" does not exist in "${resolved.value}"`,
|
|
727
|
+
`${resolved.value}#${match.fragment}`,
|
|
728
|
+
"fix the fragment to an existing heading or block id",
|
|
729
|
+
line,
|
|
730
|
+
column
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (resolved.value !== note.slug)
|
|
735
|
+
addIncoming(state, resolved.value, note.slug);
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (resolved.kind === "image" || resolved.kind === "attachment") {
|
|
739
|
+
state.referencedAssets.add(resolved.value);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// src/analyze.ts
|
|
745
|
+
async function analyzeContent(config, options = {}) {
|
|
746
|
+
const source = await scanVault(config);
|
|
747
|
+
const normalizedOptions = normalizeOptions(options);
|
|
748
|
+
const state = createAnalysisState();
|
|
749
|
+
const diagnostics = [];
|
|
750
|
+
for (const note of source.includedNotes) {
|
|
751
|
+
checkWikilinks(note, source, state, normalizedOptions, diagnostics);
|
|
752
|
+
checkMarkdownReferences(
|
|
753
|
+
note,
|
|
754
|
+
source,
|
|
755
|
+
state,
|
|
756
|
+
normalizedOptions,
|
|
757
|
+
diagnostics
|
|
758
|
+
);
|
|
759
|
+
checkFrontmatter(note, source, state, normalizedOptions, diagnostics);
|
|
760
|
+
}
|
|
761
|
+
checkSlugCollisions(source, normalizedOptions, diagnostics);
|
|
762
|
+
checkDuplicateTitles(source, normalizedOptions, diagnostics);
|
|
763
|
+
checkOrphans(source, state, normalizedOptions, diagnostics);
|
|
764
|
+
checkUnusedAssets(source, state, normalizedOptions, diagnostics);
|
|
765
|
+
checkExcludedPublic(source, normalizedOptions, diagnostics);
|
|
766
|
+
checkPublishBoundary(source, normalizedOptions, diagnostics);
|
|
767
|
+
return diagnostics;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/report.ts
|
|
771
|
+
var SEVERITY_ORDER = {
|
|
772
|
+
error: 0,
|
|
773
|
+
warning: 1,
|
|
774
|
+
info: 2
|
|
775
|
+
};
|
|
776
|
+
function buildReport(diagnostics) {
|
|
777
|
+
const errors = [];
|
|
778
|
+
const warnings = [];
|
|
779
|
+
const infos = [];
|
|
780
|
+
for (const diagnostic of diagnostics) {
|
|
781
|
+
switch (diagnostic.severity) {
|
|
782
|
+
case "error":
|
|
783
|
+
errors.push(diagnostic);
|
|
784
|
+
break;
|
|
785
|
+
case "warning":
|
|
786
|
+
warnings.push(diagnostic);
|
|
787
|
+
break;
|
|
788
|
+
default:
|
|
789
|
+
infos.push(diagnostic);
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
diagnostics,
|
|
795
|
+
errors,
|
|
796
|
+
warnings,
|
|
797
|
+
infos,
|
|
798
|
+
hasErrors: errors.length > 0,
|
|
799
|
+
hasWarnings: warnings.length > 0,
|
|
800
|
+
summary: summarize(diagnostics),
|
|
801
|
+
byCode: groupByCode(diagnostics)
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
function summarize(diagnostics) {
|
|
805
|
+
let error = 0;
|
|
806
|
+
let warning = 0;
|
|
807
|
+
let info = 0;
|
|
808
|
+
for (const diagnostic of diagnostics) {
|
|
809
|
+
switch (diagnostic.severity) {
|
|
810
|
+
case "error":
|
|
811
|
+
error++;
|
|
812
|
+
break;
|
|
813
|
+
case "warning":
|
|
814
|
+
warning++;
|
|
815
|
+
break;
|
|
816
|
+
default:
|
|
817
|
+
info++;
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return { total: diagnostics.length, error, warning, info };
|
|
822
|
+
}
|
|
823
|
+
function groupByCode(diagnostics) {
|
|
824
|
+
const byCode = /* @__PURE__ */ new Map();
|
|
825
|
+
for (const diagnostic of diagnostics) {
|
|
826
|
+
const group = byCode.get(diagnostic.code) ?? [];
|
|
827
|
+
group.push(diagnostic);
|
|
828
|
+
byCode.set(diagnostic.code, group);
|
|
829
|
+
}
|
|
830
|
+
return byCode;
|
|
831
|
+
}
|
|
832
|
+
function formatDiagnostics(input, options = {}) {
|
|
833
|
+
const report = Array.isArray(input) ? buildReport(input) : input;
|
|
834
|
+
if (report.diagnostics.length === 0) return "No diagnostics found.";
|
|
835
|
+
const useColor = options.color ?? hasColorSupport();
|
|
836
|
+
const color = useColor ? ansiColors : noColors;
|
|
837
|
+
const lines = [];
|
|
838
|
+
const summary = report.summary;
|
|
839
|
+
lines.push(
|
|
840
|
+
`${color.bold}${color.error}${summary.error} errors${color.reset}, ${color.yellow}${summary.warning} warnings${color.reset}, ${color.cyan}${summary.info} infos${color.reset} ${color.dim}(${summary.total} total)${color.reset}`
|
|
841
|
+
);
|
|
842
|
+
lines.push("");
|
|
843
|
+
const ordered = [...report.diagnostics].sort(compareDiagnostics);
|
|
844
|
+
for (const diagnostic of ordered) {
|
|
845
|
+
const icon = {
|
|
846
|
+
error: `${color.error}✖`,
|
|
847
|
+
warning: `${color.yellow}⚠`,
|
|
848
|
+
info: `${color.cyan}ℹ`
|
|
849
|
+
}[diagnostic.severity];
|
|
850
|
+
const location = [
|
|
851
|
+
diagnostic.filePath ?? "",
|
|
852
|
+
diagnostic.line != null ? `:${diagnostic.line}${diagnostic.column != null ? `:${diagnostic.column}` : ""}` : ""
|
|
853
|
+
].join("");
|
|
854
|
+
lines.push(`${icon} ${color.reset}[${diagnostic.code}] ${location}`);
|
|
855
|
+
lines.push(` ${diagnostic.message}`);
|
|
856
|
+
if (diagnostic.target) {
|
|
857
|
+
lines.push(` ${color.dim}target: ${diagnostic.target}${color.reset}`);
|
|
858
|
+
}
|
|
859
|
+
if (diagnostic.suggestion) {
|
|
860
|
+
lines.push(` ${color.cyan}→ ${diagnostic.suggestion}${color.reset}`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return lines.join("\n");
|
|
864
|
+
}
|
|
865
|
+
function compareDiagnostics(a, b) {
|
|
866
|
+
const severityDiff = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
|
|
867
|
+
if (severityDiff !== 0) return severityDiff;
|
|
868
|
+
const fileDiff = (a.filePath ?? "").localeCompare(b.filePath ?? "");
|
|
869
|
+
if (fileDiff !== 0) return fileDiff;
|
|
870
|
+
const lineDiff = (a.line ?? 0) - (b.line ?? 0);
|
|
871
|
+
if (lineDiff !== 0) return lineDiff;
|
|
872
|
+
return (a.column ?? 0) - (b.column ?? 0);
|
|
873
|
+
}
|
|
874
|
+
function hasColorSupport() {
|
|
875
|
+
if (typeof process === "undefined") return false;
|
|
876
|
+
return Boolean(process.stdout?.isTTY);
|
|
877
|
+
}
|
|
878
|
+
var ansiColors = {
|
|
879
|
+
reset: "\x1B[0m",
|
|
880
|
+
bold: "\x1B[1m",
|
|
881
|
+
dim: "\x1B[2m",
|
|
882
|
+
error: "\x1B[31m",
|
|
883
|
+
yellow: "\x1B[33m",
|
|
884
|
+
cyan: "\x1B[36m"
|
|
885
|
+
};
|
|
886
|
+
var noColors = {
|
|
887
|
+
reset: "",
|
|
888
|
+
bold: "",
|
|
889
|
+
dim: "",
|
|
890
|
+
error: "",
|
|
891
|
+
yellow: "",
|
|
892
|
+
cyan: ""
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
// index.ts
|
|
896
|
+
var PLUGIN_NAME2 = "diagnostics";
|
|
897
|
+
async function runDiagnostics(target, options = {}) {
|
|
898
|
+
const config = toAnalyzerConfig(target, options);
|
|
899
|
+
try {
|
|
900
|
+
return buildReport(await analyzeContent(config, options));
|
|
901
|
+
} catch (error) {
|
|
902
|
+
return buildReport([createInternalError(error)]);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
function createInternalError(error) {
|
|
906
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
907
|
+
return {
|
|
908
|
+
pluginName: PLUGIN_NAME2,
|
|
909
|
+
code: "internal-error",
|
|
910
|
+
severity: "error",
|
|
911
|
+
message: `content analysis failed: ${message}`
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
function toAnalyzerConfig(target, options) {
|
|
915
|
+
if (typeof target === "string") {
|
|
916
|
+
return {
|
|
917
|
+
directory: target,
|
|
918
|
+
exclude: options.exclude ?? [],
|
|
919
|
+
publishStrategy: options.publishStrategy ?? "selective"
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
return {
|
|
923
|
+
directory: target.content.directory,
|
|
924
|
+
source: target.content.source ?? new FileSystemContentSource2(
|
|
925
|
+
target.content.directory,
|
|
926
|
+
target.content.exclude
|
|
927
|
+
),
|
|
928
|
+
exclude: [...target.content.exclude, ...options.exclude ?? []],
|
|
929
|
+
publishStrategy: target.content.filters.publishStrategy
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// bin.ts
|
|
934
|
+
var HELP = `riebeckite-diagnostics
|
|
935
|
+
|
|
936
|
+
Check an Obsidian vault / Riebeckite content directory for content problems:
|
|
937
|
+
broken wikilinks, broken images, broken markdown links, unused assets,
|
|
938
|
+
orphan notes, missing frontmatter, publish conflicts, duplicate titles,
|
|
939
|
+
slug collisions, and excluded-but-public notes.
|
|
940
|
+
|
|
941
|
+
Usage:
|
|
942
|
+
riebeckite-diagnostics [options]
|
|
943
|
+
|
|
944
|
+
Options:
|
|
945
|
+
--config <path> Path to riebeckite.config.ts (loaded under tsx)
|
|
946
|
+
--content <dir> Content directory to analyze (default: .)
|
|
947
|
+
--exclude <glob> Extra exclude glob (repeatable)
|
|
948
|
+
--publish-strategy <mode> explicit | selective (default: selective)
|
|
949
|
+
--report-unused-assets Report images never referenced by any note
|
|
950
|
+
--report-orphans Report published notes with no incoming links
|
|
951
|
+
--required-frontmatter <f> Comma-separated required frontmatter fields
|
|
952
|
+
--fail-on-error Exit with code 1 when errors are found (default)
|
|
953
|
+
--exit-on <severity> Exit with code 1 at/above severity (info|warning|error)
|
|
954
|
+
--format <text|json> Output format (default: text)
|
|
955
|
+
--no-color Disable ANSI colors
|
|
956
|
+
-h, --help Show this help
|
|
957
|
+
|
|
958
|
+
Exit codes:
|
|
959
|
+
0 no errors
|
|
960
|
+
1 one or more errors reported (or the --exit-on threshold is met)
|
|
961
|
+
2 invalid arguments
|
|
962
|
+
`;
|
|
963
|
+
async function main(argv) {
|
|
964
|
+
const args = parseArgs(argv);
|
|
965
|
+
if (args.help) {
|
|
966
|
+
process.stdout.write(HELP);
|
|
967
|
+
return 0;
|
|
968
|
+
}
|
|
969
|
+
if (!args.config && !args.content) {
|
|
970
|
+
process.stderr.write("error: set --config <path> or --content <dir>\n\n");
|
|
971
|
+
process.stderr.write(HELP);
|
|
972
|
+
return 2;
|
|
973
|
+
}
|
|
974
|
+
const config = args.config ? await loadConfigFile(args.config) : null;
|
|
975
|
+
const options = {
|
|
976
|
+
...config ? diagnosticsOptionsFromConfig(config) : {},
|
|
977
|
+
...args.explicit.has("reportUnusedAssets") ? { reportUnusedAssets: args.reportUnusedAssets } : {},
|
|
978
|
+
...args.explicit.has("reportOrphans") ? { reportOrphans: args.reportOrphans } : {},
|
|
979
|
+
...args.explicit.has("requiredFrontmatter") ? { requiredFrontmatter: args.requiredFrontmatter } : {},
|
|
980
|
+
...args.explicit.has("publishStrategy") ? { publishStrategy: args.publishStrategy ?? void 0 } : {},
|
|
981
|
+
exclude: [
|
|
982
|
+
...config ? diagnosticsOptionsFromConfig(config).exclude ?? [] : [],
|
|
983
|
+
...args.exclude
|
|
984
|
+
]
|
|
985
|
+
};
|
|
986
|
+
let target;
|
|
987
|
+
if (config) {
|
|
988
|
+
target = config;
|
|
989
|
+
} else {
|
|
990
|
+
target = args.content ?? ".";
|
|
991
|
+
}
|
|
992
|
+
const report = await runDiagnostics(target, options);
|
|
993
|
+
writeReport(report, args);
|
|
994
|
+
if (args.exitOn) {
|
|
995
|
+
if (countAtOrAbove(report, args.exitOn) > 0) return 1;
|
|
996
|
+
return 0;
|
|
997
|
+
}
|
|
998
|
+
return report.hasErrors ? 1 : 0;
|
|
999
|
+
}
|
|
1000
|
+
function diagnosticsOptionsFromConfig(config) {
|
|
1001
|
+
const plugin = config.plugins.find(
|
|
1002
|
+
(candidate) => candidate.name === "diagnostics"
|
|
1003
|
+
);
|
|
1004
|
+
const options = plugin?.options;
|
|
1005
|
+
return options && typeof options === "object" ? options : {};
|
|
1006
|
+
}
|
|
1007
|
+
function writeReport(report, args) {
|
|
1008
|
+
if (args.format === "json") {
|
|
1009
|
+
process.stdout.write(
|
|
1010
|
+
`${JSON.stringify(
|
|
1011
|
+
{
|
|
1012
|
+
summary: report.summary,
|
|
1013
|
+
hasErrors: report.hasErrors,
|
|
1014
|
+
hasWarnings: report.hasWarnings,
|
|
1015
|
+
diagnostics: report.diagnostics.map((diagnostic) => ({
|
|
1016
|
+
code: diagnostic.code,
|
|
1017
|
+
severity: diagnostic.severity,
|
|
1018
|
+
message: diagnostic.message,
|
|
1019
|
+
filePath: diagnostic.filePath,
|
|
1020
|
+
slug: diagnostic.slug,
|
|
1021
|
+
line: diagnostic.line,
|
|
1022
|
+
column: diagnostic.column,
|
|
1023
|
+
target: diagnostic.target,
|
|
1024
|
+
suggestion: diagnostic.suggestion
|
|
1025
|
+
}))
|
|
1026
|
+
},
|
|
1027
|
+
null,
|
|
1028
|
+
2
|
|
1029
|
+
)}
|
|
1030
|
+
`
|
|
1031
|
+
);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
process.stdout.write(
|
|
1035
|
+
`${formatDiagnostics(report, { color: !args.noColor })}
|
|
1036
|
+
`
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
function countAtOrAbove(report, severity) {
|
|
1040
|
+
switch (severity) {
|
|
1041
|
+
case "error":
|
|
1042
|
+
return report.summary.error;
|
|
1043
|
+
case "warning":
|
|
1044
|
+
return report.summary.error + report.summary.warning;
|
|
1045
|
+
default:
|
|
1046
|
+
return report.summary.total;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
async function loadConfigFile(configPath) {
|
|
1050
|
+
const absoluteUrl = pathToFileURL(configPath);
|
|
1051
|
+
let module;
|
|
1052
|
+
try {
|
|
1053
|
+
module = await import(absoluteUrl.href);
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
throw new Error(
|
|
1056
|
+
`failed to load config "${configPath}": ${error instanceof Error ? error.message : String(error)}`
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
const resolved = resolveConfigModule(module);
|
|
1060
|
+
const directory = fileURLToPath(
|
|
1061
|
+
isAbsoluteFilePath(resolved.content.directory) ? pathToFileURL(resolved.content.directory) : new URL(resolved.content.directory, new URL("./", absoluteUrl))
|
|
1062
|
+
);
|
|
1063
|
+
return {
|
|
1064
|
+
...resolved,
|
|
1065
|
+
content: {
|
|
1066
|
+
...resolved.content,
|
|
1067
|
+
directory
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
function parseArgs(argv) {
|
|
1072
|
+
const args = {
|
|
1073
|
+
help: false,
|
|
1074
|
+
noColor: false,
|
|
1075
|
+
failOnError: false,
|
|
1076
|
+
format: "text",
|
|
1077
|
+
exitOn: null,
|
|
1078
|
+
config: null,
|
|
1079
|
+
content: null,
|
|
1080
|
+
exclude: [],
|
|
1081
|
+
requiredFrontmatter: [],
|
|
1082
|
+
reportUnusedAssets: false,
|
|
1083
|
+
reportOrphans: false,
|
|
1084
|
+
publishStrategy: null,
|
|
1085
|
+
explicit: /* @__PURE__ */ new Set()
|
|
1086
|
+
};
|
|
1087
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1088
|
+
const arg = argv[i];
|
|
1089
|
+
switch (arg) {
|
|
1090
|
+
case "-h":
|
|
1091
|
+
case "--help":
|
|
1092
|
+
args.help = true;
|
|
1093
|
+
break;
|
|
1094
|
+
case "--no-color":
|
|
1095
|
+
args.noColor = true;
|
|
1096
|
+
break;
|
|
1097
|
+
case "--fail-on-error":
|
|
1098
|
+
args.failOnError = true;
|
|
1099
|
+
break;
|
|
1100
|
+
case "--report-unused-assets":
|
|
1101
|
+
args.reportUnusedAssets = true;
|
|
1102
|
+
args.explicit.add("reportUnusedAssets");
|
|
1103
|
+
break;
|
|
1104
|
+
case "--report-orphans":
|
|
1105
|
+
args.reportOrphans = true;
|
|
1106
|
+
args.explicit.add("reportOrphans");
|
|
1107
|
+
break;
|
|
1108
|
+
case "--format":
|
|
1109
|
+
args.format = (argv[++i] ?? "text") === "json" ? "json" : "text";
|
|
1110
|
+
break;
|
|
1111
|
+
case "--exit-on": {
|
|
1112
|
+
const value = argv[++i];
|
|
1113
|
+
args.exitOn = value === "error" || value === "warning" || value === "info" ? value : null;
|
|
1114
|
+
break;
|
|
1115
|
+
}
|
|
1116
|
+
case "--config":
|
|
1117
|
+
args.config = argv[++i] ?? "";
|
|
1118
|
+
break;
|
|
1119
|
+
case "--content":
|
|
1120
|
+
args.content = argv[++i] ?? "";
|
|
1121
|
+
break;
|
|
1122
|
+
case "--publish-strategy": {
|
|
1123
|
+
const value = argv[++i];
|
|
1124
|
+
args.publishStrategy = value === "explicit" || value === "selective" ? value : null;
|
|
1125
|
+
if (args.publishStrategy) args.explicit.add("publishStrategy");
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
case "--exclude":
|
|
1129
|
+
if (argv[i + 1] && !argv[i + 1].startsWith("--")) {
|
|
1130
|
+
args.exclude.push(argv[++i]);
|
|
1131
|
+
}
|
|
1132
|
+
break;
|
|
1133
|
+
case "--required-frontmatter": {
|
|
1134
|
+
args.explicit.add("requiredFrontmatter");
|
|
1135
|
+
if (argv[i + 1] && !argv[i + 1].startsWith("--")) {
|
|
1136
|
+
args.requiredFrontmatter.push(
|
|
1137
|
+
...(argv[++i] ?? "").split(",").map((value) => value.trim()).filter(Boolean)
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
break;
|
|
1141
|
+
}
|
|
1142
|
+
default:
|
|
1143
|
+
break;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
return args;
|
|
1147
|
+
}
|
|
1148
|
+
var isMain = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
|
|
1149
|
+
function isAbsoluteFilePath(value) {
|
|
1150
|
+
return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
|
|
1151
|
+
}
|
|
1152
|
+
if (isMain) {
|
|
1153
|
+
main(process.argv.slice(2)).then(
|
|
1154
|
+
(code) => {
|
|
1155
|
+
process.exitCode = code;
|
|
1156
|
+
},
|
|
1157
|
+
(error) => {
|
|
1158
|
+
process.stderr.write(
|
|
1159
|
+
`error: ${error instanceof Error ? error.message : String(error)}
|
|
1160
|
+
`
|
|
1161
|
+
);
|
|
1162
|
+
process.exitCode = 1;
|
|
1163
|
+
}
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
export {
|
|
1167
|
+
main
|
|
1168
|
+
};
|