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