@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/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
+ };