dead-links 0.2.0 → 0.4.0

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 CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command } from "commander";
4
+ import { Command, Option } from "commander";
5
+ import { readFileSync } from "fs";
5
6
  import { writeFile } from "fs/promises";
6
7
  import ora from "ora";
7
8
 
@@ -24,6 +25,7 @@ async function loadVault(vaultPath, customIgnore = []) {
24
25
  const notes = [];
25
26
  for (const file of files) {
26
27
  const fullPath = resolve(absolutePath, file);
28
+ const relativePath = file.replace(/\\/g, "/");
27
29
  const content = await readFile(fullPath, "utf-8");
28
30
  const parsed = matter(content);
29
31
  const aliases = [];
@@ -36,7 +38,7 @@ async function loadVault(vaultPath, customIgnore = []) {
36
38
  }
37
39
  notes.push({
38
40
  path: fullPath,
39
- relativePath: file,
41
+ relativePath,
40
42
  content: parsed.content,
41
43
  aliases,
42
44
  links: []
@@ -155,8 +157,42 @@ var AliasResolver = class {
155
157
  }
156
158
  };
157
159
 
160
+ // src/core/link-resolver.ts
161
+ import { dirname, isAbsolute, normalize, resolve as resolve2 } from "path";
162
+ var REMOTE_TARGET = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
163
+ function isExternalTarget(target) {
164
+ return REMOTE_TARGET.test(target) || target.startsWith("#");
165
+ }
166
+ function resolveNoteLink(link, source, notes, aliases, vaultPath) {
167
+ if (isExternalTarget(link.target)) return null;
168
+ if (link.type === "markdown") {
169
+ const target = decodeTarget(link.target).replace(/\\/g, "/");
170
+ const sourceRelativePath = normalize(resolve2(dirname(source.path), target));
171
+ const exact = notes.find((note) => normalize(note.path) === sourceRelativePath);
172
+ if (exact) return exact.path;
173
+ const vaultRelativePath = normalize(resolve2(vaultPath, target.replace(/^\/+/, "")));
174
+ const vaultRelative = notes.find((note) => normalize(note.path) === vaultRelativePath);
175
+ if (vaultRelative) return vaultRelative.path;
176
+ }
177
+ return aliases.resolve(decodeTarget(link.target));
178
+ }
179
+ function resolveAttachmentPath(target, source, vaultPath) {
180
+ const decoded = decodeTarget(target).replace(/\\/g, "/");
181
+ if (isAbsolute(decoded)) return normalize(decoded);
182
+ const relativeToSource = normalize(resolve2(dirname(source.path), decoded));
183
+ if (relativeToSource.startsWith(normalize(vaultPath))) return relativeToSource;
184
+ return normalize(resolve2(vaultPath, decoded.replace(/^\/+/, "")));
185
+ }
186
+ function decodeTarget(target) {
187
+ try {
188
+ return decodeURIComponent(target);
189
+ } catch {
190
+ return target;
191
+ }
192
+ }
193
+
158
194
  // src/core/graph-builder.ts
159
- function buildGraph(notes, resolver) {
195
+ function buildGraph(notes, resolver, vaultPath) {
160
196
  const notePathMap = /* @__PURE__ */ new Map();
161
197
  for (const note of notes) {
162
198
  notePathMap.set(note.path, {
@@ -168,10 +204,8 @@ function buildGraph(notes, resolver) {
168
204
  for (const note of notes) {
169
205
  const node = notePathMap.get(note.path);
170
206
  for (const link of note.links) {
171
- if (link.type === "markdown" && !link.target.endsWith(".md")) {
172
- continue;
173
- }
174
- const resolvedPath = resolver.resolve(link.target);
207
+ if (isExternalTarget(link.target)) continue;
208
+ const resolvedPath = resolveNoteLink(link, note, notes, resolver, vaultPath);
175
209
  if (resolvedPath && notePathMap.has(resolvedPath)) {
176
210
  if (!node.outgoing.includes(resolvedPath)) {
177
211
  node.outgoing.push(resolvedPath);
@@ -233,23 +267,34 @@ function isAttachment(target) {
233
267
  const ext = target.split(".").pop()?.toLowerCase();
234
268
  return ext ? ATTACHMENT_EXTENSIONS.has(ext) : false;
235
269
  }
236
- function analyze(notes, vaultPath, checkAttachments = false) {
270
+ function analyze(notes, vaultPath, checkAttachments = false, ignoreFolders = []) {
237
271
  const resolver = new AliasResolver(notes);
238
- const graph = buildGraph(notes, resolver);
272
+ const graph = buildGraph(notes, resolver, vaultPath);
239
273
  const brokenLinks = [];
240
274
  const orphanNotes = [];
241
275
  for (const node of graph) {
242
276
  if (node.incoming.length === 0 && node.outgoing.length === 0) {
243
- orphanNotes.push(node.note);
277
+ if (!isIgnoredFolder(node.note.relativePath, ignoreFolders)) {
278
+ orphanNotes.push(node.note);
279
+ }
244
280
  }
245
281
  for (const link of node.note.links) {
246
- if (link.type === "markdown" && !link.target.endsWith(".md")) {
282
+ if (isExternalTarget(link.target)) {
247
283
  continue;
248
284
  }
249
- if (!checkAttachments && isAttachment(link.target)) {
285
+ if (isAttachment(link.target)) {
286
+ if (!checkAttachments) continue;
287
+ const attachmentPath = resolveAttachmentPath(link.target, node.note, vaultPath);
288
+ if (!existsSync(attachmentPath)) {
289
+ brokenLinks.push({
290
+ source: node.note.relativePath,
291
+ link,
292
+ reason: `File "${link.target}" does not exist`
293
+ });
294
+ }
250
295
  continue;
251
296
  }
252
- const resolvedPath = resolver.resolve(link.target);
297
+ const resolvedPath = resolveNoteLink(link, node.note, notes, resolver, vaultPath);
253
298
  if (resolvedPath === node.note.path) {
254
299
  continue;
255
300
  }
@@ -259,14 +304,6 @@ function analyze(notes, vaultPath, checkAttachments = false) {
259
304
  link,
260
305
  reason: `Target "${link.target}" not found in vault`
261
306
  });
262
- } else if (link.type === "embed" || link.type === "markdown") {
263
- if (!existsSync(resolvedPath)) {
264
- brokenLinks.push({
265
- source: node.note.relativePath,
266
- link,
267
- reason: `File "${link.target}" does not exist`
268
- });
269
- }
270
307
  }
271
308
  }
272
309
  }
@@ -285,6 +322,13 @@ function analyze(notes, vaultPath, checkAttachments = false) {
285
322
  }
286
323
  };
287
324
  }
325
+ function isIgnoredFolder(relativePath, ignoreFolders) {
326
+ const folder = relativePath.includes("/") ? relativePath.split("/").slice(0, -1).join("/") : "";
327
+ return ignoreFolders.some((ignored) => {
328
+ const normalized = ignored.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
329
+ return folder === normalized || folder.startsWith(`${normalized}/`);
330
+ });
331
+ }
288
332
  function countConnectedComponents(graph) {
289
333
  if (graph.length === 0) return 0;
290
334
  const visited = /* @__PURE__ */ new Set();
@@ -312,72 +356,6 @@ function countConnectedComponents(graph) {
312
356
  return components;
313
357
  }
314
358
 
315
- // src/utils/suggestions.ts
316
- function levenshteinDistance(a, b) {
317
- const matrix = Array(b.length + 1).fill(null).map(() => Array(a.length + 1).fill(0));
318
- for (let i = 0; i <= a.length; i++) matrix[0][i] = i;
319
- for (let j = 0; j <= b.length; j++) matrix[j][0] = j;
320
- for (let j = 1; j <= b.length; j++) {
321
- for (let i = 1; i <= a.length; i++) {
322
- if (a[i - 1] === b[j - 1]) {
323
- matrix[j][i] = matrix[j - 1][i - 1];
324
- } else {
325
- matrix[j][i] = Math.min(
326
- matrix[j][i - 1] + 1,
327
- matrix[j - 1][i] + 1,
328
- matrix[j - 1][i - 1] + 1
329
- );
330
- }
331
- }
332
- }
333
- return matrix[b.length][a.length];
334
- }
335
- function calculateSimilarity(a, b) {
336
- const distance = levenshteinDistance(
337
- a.toLowerCase(),
338
- b.toLowerCase()
339
- );
340
- const maxLen = Math.max(a.length, b.length);
341
- return Math.max(0, 1 - distance / maxLen);
342
- }
343
- function generateSuggestions(brokenTargets, allNotes, threshold = 0.6) {
344
- const availableNotes = allNotes.map((n) => ({
345
- name: n.relativePath.replace(/\.(md|canvas)$/, ""),
346
- basename: n.relativePath.split("/").pop()?.replace(/\.(md|canvas)$/, "") || "",
347
- aliases: n.aliases
348
- }));
349
- const suggestions = [];
350
- for (const broken of brokenTargets) {
351
- const brokenBasename = broken.split("/").pop() || broken;
352
- const candidates = [];
353
- for (const note of availableNotes) {
354
- const basenameScore = calculateSimilarity(brokenBasename, note.basename);
355
- const fullScore = calculateSimilarity(broken, note.name);
356
- for (const alias of note.aliases) {
357
- const aliasScore = calculateSimilarity(broken, alias);
358
- if (aliasScore > basenameScore && aliasScore > fullScore) {
359
- candidates.push({ suggestion: note.name, similarity: aliasScore });
360
- }
361
- }
362
- if (basenameScore >= fullScore && basenameScore > threshold) {
363
- candidates.push({ suggestion: note.name, similarity: basenameScore });
364
- } else if (fullScore > threshold) {
365
- candidates.push({ suggestion: note.name, similarity: fullScore });
366
- }
367
- }
368
- if (candidates.length > 0) {
369
- const best = candidates.sort((a, b) => b.similarity - a.similarity)[0];
370
- suggestions.push({
371
- broken,
372
- suggested: best.suggestion,
373
- similarity: best.similarity,
374
- source: broken
375
- });
376
- }
377
- }
378
- return suggestions.sort((a, b) => b.similarity - a.similarity);
379
- }
380
-
381
359
  // src/utils/engine.ts
382
360
  var DeadLinksEngine = class {
383
361
  async analyze(vaultPath, config = {}) {
@@ -389,13 +367,7 @@ var DeadLinksEngine = class {
389
367
  return Promise.resolve();
390
368
  })
391
369
  );
392
- const report = analyze(notes, vaultPath, config.checkAttachments);
393
- if (config.suggestions) {
394
- const brokenTargets = Array.from(
395
- new Set(report.brokenLinks.map((b) => b.link.target))
396
- );
397
- report.suggestions = generateSuggestions(brokenTargets, notes, 0.65);
398
- }
370
+ const report = analyze(notes, vaultPath, config.checkAttachments, config.ignoreFolders);
399
371
  report.duration = Date.now() - startTime;
400
372
  return report;
401
373
  }
@@ -403,11 +375,11 @@ var DeadLinksEngine = class {
403
375
 
404
376
  // src/utils/config-loader.ts
405
377
  import { readFile as readFile2, access } from "fs/promises";
406
- import { resolve as resolve2, join } from "path";
378
+ import { resolve as resolve3, join } from "path";
407
379
  var CONFIG_FILES = [".deadlinksrc.json", ".deadlinksrc", "dead-links.config.json"];
408
380
  var ConfigLoader = class {
409
381
  async loadConfig(vaultPath) {
410
- const absolutePath = resolve2(vaultPath);
382
+ const absolutePath = resolve3(vaultPath);
411
383
  for (const configFile of CONFIG_FILES) {
412
384
  const configPath = join(absolutePath, configFile);
413
385
  try {
@@ -432,21 +404,21 @@ var ConfigLoader = class {
432
404
  ignore: cliOptions.ignore || fileConfig.ignore,
433
405
  checkAttachments: cliOptions.attachments ?? fileConfig.checkAttachments ?? false,
434
406
  ignoreFolders: cliOptions.ignoreFolders || fileConfig.ignoreFolders,
435
- suggestions: cliOptions.suggestions ?? fileConfig.suggestions ?? false,
436
- debug: cliOptions.debug ?? fileConfig.debug ?? false,
437
- fix: cliOptions.fix ?? fileConfig.fix ?? false
407
+ debug: cliOptions.debug ?? fileConfig.debug ?? false
438
408
  };
439
409
  }
440
410
  };
441
411
 
442
412
  // src/reporters/terminal-reporter.ts
443
- import chalk from "chalk";
413
+ import { Chalk } from "chalk";
444
414
  var TerminalReporter = class {
445
415
  ignoreFolders;
446
416
  quiet;
447
- constructor(ignoreFolders = [], quiet = false) {
417
+ chalk;
418
+ constructor(ignoreFolders = [], quiet = false, noColor = false) {
448
419
  this.ignoreFolders = ignoreFolders;
449
420
  this.quiet = quiet;
421
+ this.chalk = new Chalk({ level: noColor ? 0 : void 0 });
450
422
  }
451
423
  report(result) {
452
424
  if (this.quiet) {
@@ -456,9 +428,6 @@ var TerminalReporter = class {
456
428
  output += this.printStats(result);
457
429
  output += this.printBrokenLinks(result.brokenLinks);
458
430
  output += this.printOrphanNotes(result.orphanNotes);
459
- if (result.suggestions && result.suggestions.length > 0) {
460
- output += this.printSuggestions(result.suggestions);
461
- }
462
431
  output += this.printDuration(result.duration);
463
432
  return output;
464
433
  }
@@ -488,12 +457,13 @@ var TerminalReporter = class {
488
457
  return out;
489
458
  }
490
459
  statLine(label, value, color = "white") {
491
- return ` ${chalk.gray(label)} ${color === "red" ? chalk.red.bold(value) : color === "yellow" ? chalk.yellow.bold(value) : chalk.white.bold(value)}
460
+ const chalk = this.chalk;
461
+ return ` ${this.chalk.gray(label)} ${color === "red" ? this.chalk.red.bold(value) : color === "yellow" ? this.chalk.yellow.bold(value) : this.chalk.white.bold(value)}
492
462
  `;
493
463
  }
494
464
  printBrokenLinks(brokenLinks) {
495
465
  if (brokenLinks.length === 0) {
496
- return chalk.green(" \u2713 No broken links\n\n");
466
+ return this.chalk.green(" \u2713 No broken links\n\n");
497
467
  }
498
468
  const grouped = /* @__PURE__ */ new Map();
499
469
  for (const broken of brokenLinks) {
@@ -502,18 +472,18 @@ var TerminalReporter = class {
502
472
  grouped.set(broken.source, existing);
503
473
  }
504
474
  const sorted = [...grouped.entries()].sort((a, b) => b[1].length - a[1].length);
505
- let out = chalk.bold.red(`
475
+ let out = this.chalk.bold.red(`
506
476
  \u2716 Broken links (${brokenLinks.length})
507
477
 
508
478
  `);
509
479
  for (const [source, links] of sorted) {
510
480
  const count = links.length;
511
481
  const isMultiple = count >= 2;
512
- const countStr = isMultiple ? chalk.red.bold(`\u2716 ${count}`) : chalk.yellow.bold(`\u2716 ${count}`);
513
- out += ` ${countStr} ${chalk.white.bold(source)}
482
+ const countStr = isMultiple ? this.chalk.red.bold(`\u2716 ${count}`) : this.chalk.yellow.bold(`\u2716 ${count}`);
483
+ out += ` ${countStr} ${this.chalk.white.bold(source)}
514
484
  `;
515
485
  for (const link of links) {
516
- const pill2 = isMultiple ? chalk.redBright(`[${link.link.target}]`) : chalk.yellowBright(`[${link.link.target}]`);
486
+ const pill2 = isMultiple ? this.chalk.redBright(`[${link.link.target}]`) : this.chalk.yellowBright(`[${link.link.target}]`);
517
487
  out += ` ${pill2}
518
488
  `;
519
489
  }
@@ -523,7 +493,7 @@ var TerminalReporter = class {
523
493
  }
524
494
  printOrphanNotes(orphanNotes) {
525
495
  if (orphanNotes.length === 0) {
526
- return chalk.green(" \u2713 No orphan notes\n\n");
496
+ return this.chalk.green(" \u2713 No orphan notes\n\n");
527
497
  }
528
498
  const byFolder = /* @__PURE__ */ new Map();
529
499
  for (const note of orphanNotes) {
@@ -535,43 +505,27 @@ var TerminalReporter = class {
535
505
  }
536
506
  const sorted = [...byFolder.entries()].sort((a, b) => b[1] - a[1]);
537
507
  if (sorted.length === 0) {
538
- return chalk.green(" \u2713 No orphan notes (all in ignored folders)\n\n");
508
+ return this.chalk.green(" \u2713 No orphan notes (all in ignored folders)\n\n");
539
509
  }
540
510
  const maxCount = sorted[0][1];
541
511
  const barWidth = 10;
542
- let out = chalk.bold.yellow(`
512
+ let out = this.chalk.bold.yellow(`
543
513
  \u26A0 Orphan notes (${orphanNotes.length} total)
544
514
 
545
515
  `);
546
516
  for (const [folder, count] of sorted) {
547
517
  const filled = Math.round(count / maxCount * barWidth);
548
518
  const empty = barWidth - filled;
549
- const bar2 = chalk.gray("\u2588".repeat(filled) + "\u2591".repeat(empty));
550
- out += ` ${chalk.gray(folder.padEnd(40))} ${bar2} ${chalk.white.bold(String(count))}
519
+ const bar2 = this.chalk.gray("\u2588".repeat(filled) + "\u2591".repeat(empty));
520
+ out += ` ${this.chalk.gray(folder.padEnd(40))} ${bar2} ${this.chalk.white.bold(String(count))}
551
521
  `;
552
522
  }
553
523
  out += "\n";
554
524
  return out;
555
525
  }
556
526
  printDuration(ms) {
557
- return chalk.dim(` Completed in ${ms}ms
558
- `);
559
- }
560
- printSuggestions(suggestions) {
561
- let out = chalk.bold.cyan(`
562
- \u{1F4A1} Suggestions (${suggestions.length})
563
-
527
+ return this.chalk.dim(` Completed in ${ms}ms
564
528
  `);
565
- for (const suggestion of suggestions.slice(0, 5)) {
566
- const confidence = Math.round(suggestion.similarity * 100);
567
- const bar2 = confidence >= 80 ? chalk.green("\u2588\u2588\u2588\u2588\u2588") : confidence >= 60 ? chalk.yellow("\u2588\u2588\u2588\u2588\u2588") : chalk.gray("\u2588\u2588\u2588\u2588\u2588");
568
- out += ` ${chalk.gray(suggestion.broken)} \u2192 ${chalk.cyan(suggestion.suggested)}
569
- `;
570
- out += ` ${bar2} ${confidence}%
571
-
572
- `;
573
- }
574
- return out;
575
529
  }
576
530
  };
577
531
 
@@ -639,194 +593,225 @@ var CsvReporter = class {
639
593
  // src/reporters/html-reporter.ts
640
594
  var HtmlReporter = class {
641
595
  report(result) {
596
+ const summaryHtml = this.renderSummary(result);
597
+ const statsHtml = this.renderStats(result);
642
598
  const brokenLinksHtml = this.renderBrokenLinks(result.brokenLinks);
643
599
  const orphanNotesHtml = this.renderOrphanNotes(result.orphanNotes);
644
600
  const graphHtml = this.renderGraph(result.graph);
645
- const statsHtml = this.renderStats(result.stats, result.duration);
646
601
  return `<!DOCTYPE html>
647
602
  <html lang="en">
648
603
  <head>
649
604
  <meta charset="UTF-8">
650
605
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
651
- <title>Dead Links Report</title>
606
+ <meta name="theme-color" content="#ECEBE8">
607
+ <title>Vault report | Dead Links</title>
652
608
  <style>
653
- * { margin: 0; padding: 0; box-sizing: border-box; }
609
+ :root {
610
+ color-scheme: light;
611
+ --canvas: #ECEBE8;
612
+ --surface: #FFFFFF;
613
+ --surface-raised: #F7F5FC;
614
+ --ink: #0A0A0A;
615
+ --body: #2A2620;
616
+ --muted: #5F5D57;
617
+ --accent: #7C5CFF;
618
+ --accent-ink: #4C3897;
619
+ --accent-wash: #F4F0FF;
620
+ --accent-border: #DCD3FF;
621
+ --border: #D9D6CD;
622
+ --border-soft: #EBE9E3;
623
+ --space-1: 4px;
624
+ --space-2: 8px;
625
+ --space-3: 12px;
626
+ --space-4: 16px;
627
+ --space-5: 20px;
628
+ --space-6: 24px;
629
+ --space-7: 32px;
630
+ --space-section: 40px;
631
+ --font-ui: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
632
+ --font-display: 'Instrument Serif', Georgia, serif;
633
+ --font-mono: 'JetBrains Mono', 'Courier New', monospace;
634
+ }
635
+
636
+ *, *::before, *::after { box-sizing: border-box; }
637
+ html { background: var(--canvas); scroll-behavior: smooth; }
654
638
  body {
655
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
656
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
657
- min-height: 100vh;
658
- padding: 20px;
639
+ margin: 0;
640
+ color: var(--body);
641
+ background: var(--canvas);
642
+ font-family: var(--font-ui);
643
+ font-size: 15px;
644
+ line-height: 1.6;
645
+ -webkit-font-smoothing: antialiased;
646
+ text-rendering: optimizeLegibility;
659
647
  }
660
- .container {
661
- max-width: 1400px;
662
- margin: 0 auto;
663
- background: white;
664
- border-radius: 12px;
665
- box-shadow: 0 20px 60px rgba(0,0,0,0.3);
666
- overflow: hidden;
648
+ ::selection { color: var(--surface); background: var(--accent); }
649
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
650
+ .page { width: min(100% - 48px, 1200px); margin: 0 auto; }
651
+ .masthead {
652
+ position: relative;
653
+ padding: 48px 0 32px;
654
+ border-bottom: 1px solid var(--border);
655
+ background-image:
656
+ radial-gradient(rgba(124, 92, 255, .13) 1px, transparent 1.5px),
657
+ radial-gradient(rgba(124, 92, 255, .06) 1px, transparent 1.5px);
658
+ background-position: 0 0, 8px 8px;
659
+ background-size: 16px 16px;
667
660
  }
668
- header {
669
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
670
- color: white;
671
- padding: 40px;
672
- text-align: center;
661
+ .masthead-inner { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; }
662
+ .masthead h1 {
663
+ margin: 0;
664
+ color: var(--ink);
665
+ font-family: var(--font-display);
666
+ font-size: 44px;
667
+ font-weight: 400;
668
+ line-height: 1.04;
669
+ letter-spacing: -.03em;
673
670
  }
674
- header h1 { font-size: 2.5em; margin-bottom: 10px; }
675
- header p { font-size: 1.1em; opacity: 0.9; }
676
- .content { padding: 40px; }
677
- .section {
678
- margin-bottom: 40px;
679
- border-left: 4px solid #667eea;
680
- padding-left: 20px;
671
+ .masthead p { max-width: 62ch; margin: 12px 0 0; color: var(--muted); }
672
+ main { padding: 32px 0 48px; }
673
+ .overview { margin-bottom: var(--space-section); }
674
+ .overview-intro { display: flex; align-items: baseline; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
675
+ .overview-intro h2, .section-heading h2 {
676
+ margin: 0;
677
+ color: var(--ink);
678
+ font-size: 18.4px;
679
+ font-weight: 600;
680
+ line-height: 1.2;
681
+ letter-spacing: -.02em;
681
682
  }
682
- .section h2 {
683
- font-size: 1.8em;
684
- margin-bottom: 20px;
685
- color: #333;
683
+ .overview-intro p { margin: 0; color: var(--muted); font-size: 12px; }
684
+ .summary {
685
+ margin: 0 0 20px;
686
+ padding: 16px 20px;
687
+ border: 1px solid var(--accent-border);
688
+ background: var(--accent-wash);
689
+ color: var(--ink);
690
+ font-weight: 500;
686
691
  }
687
- .stats-grid {
692
+ .metrics {
688
693
  display: grid;
689
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
690
- gap: 20px;
691
- margin-bottom: 40px;
692
- }
693
- .stat-card {
694
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
695
- color: white;
696
- padding: 20px;
697
- border-radius: 8px;
698
- text-align: center;
694
+ grid-template-columns: repeat(4, minmax(0, 1fr));
695
+ gap: 1px;
696
+ border: 1px solid var(--border);
697
+ background: var(--border);
699
698
  }
700
- .stat-card .value { font-size: 2.5em; font-weight: bold; }
701
- .stat-card .label { font-size: 0.9em; opacity: 0.9; margin-top: 10px; }
702
- table {
699
+ .metric { min-height: 110px; padding: 18px 20px; background: var(--surface); }
700
+ .metric-value { display: block; color: var(--ink); font-size: 28px; font-weight: 600; line-height: 1.15; font-variant-numeric: tabular-nums; letter-spacing: -.03em; }
701
+ .metric-label { display: block; margin-top: 8px; color: var(--muted); font-size: 12px; }
702
+ .metric:nth-child(3) .metric-value, .metric:nth-child(4) .metric-value { color: var(--accent-ink); }
703
+ .section { margin-top: var(--space-section); }
704
+ .section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
705
+ .section-count { color: var(--accent-ink); background: var(--accent-wash); padding: 3px 8px; font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
706
+ .data-surface { border: 1px solid var(--border); background: var(--surface); }
707
+ .filter-row { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border-soft); }
708
+ .filter-row label { flex: 0 0 auto; color: var(--muted); font-size: 12px; }
709
+ .filter-row input {
703
710
  width: 100%;
704
- border-collapse: collapse;
705
- margin-top: 20px;
711
+ min-width: 0;
712
+ min-height: 42px;
713
+ padding: 9px 12px;
714
+ border: 1px solid var(--border);
715
+ border-radius: 0;
716
+ color: var(--ink);
717
+ background: var(--surface);
718
+ font: inherit;
706
719
  }
720
+ .filter-row input::placeholder { color: var(--muted); opacity: 1; }
721
+ .filter-row input:focus-visible { border-color: var(--accent); outline-offset: 2px; }
722
+ .table-scroll { overflow-x: auto; }
723
+ .bounded-table-scroll {
724
+ max-height: min(55vh, 520px);
725
+ overflow: auto;
726
+ overscroll-behavior: contain;
727
+ scrollbar-color: var(--accent) var(--surface-raised);
728
+ scrollbar-width: thin;
729
+ }
730
+ .bounded-table-scroll:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
731
+ .bounded-table-scroll thead th { position: sticky; top: 0; z-index: 1; }
732
+ table { width: 100%; border-collapse: collapse; text-align: left; }
707
733
  th {
708
- background: #f5f5f5;
709
- padding: 12px;
710
- text-align: left;
711
- border-bottom: 2px solid #667eea;
734
+ padding: 11px 16px;
735
+ color: var(--muted);
736
+ background: var(--surface-raised);
737
+ border-bottom: 1px solid var(--border);
738
+ font-size: 11px;
712
739
  font-weight: 600;
740
+ letter-spacing: .04em;
741
+ text-transform: uppercase;
742
+ white-space: nowrap;
713
743
  }
714
- td {
715
- padding: 12px;
716
- border-bottom: 1px solid #eee;
717
- }
718
- tr:hover { background: #f9f9f9; }
719
- .badge {
720
- display: inline-block;
721
- padding: 4px 8px;
722
- border-radius: 4px;
723
- font-size: 0.85em;
724
- font-weight: 500;
725
- }
726
- .badge-wiki { background: #dbeafe; color: #1e40af; }
727
- .badge-embed { background: #fed7aa; color: #92400e; }
728
- .badge-markdown { background: #d1fae5; color: #065f46; }
729
- .error { color: #dc2626; }
730
- .success { color: #16a34a; }
731
- .tabs {
732
- display: flex;
733
- gap: 0;
734
- border-bottom: 2px solid #eee;
735
- margin-bottom: 20px;
736
- }
737
- .tab-button {
738
- padding: 12px 20px;
739
- border: none;
740
- background: none;
741
- cursor: pointer;
742
- font-size: 1em;
743
- border-bottom: 3px solid transparent;
744
- color: #666;
745
- transition: all 0.3s;
746
- }
747
- .tab-button.active {
748
- color: #667eea;
749
- border-bottom-color: #667eea;
750
- }
751
- .tab-content { display: none; }
752
- .tab-content.active { display: block; }
753
- .filter-box {
754
- margin: 20px 0;
755
- padding: 15px;
756
- background: #f9f9f9;
757
- border-radius: 8px;
744
+ td { padding: 13px 16px; border-bottom: 1px solid var(--border-soft); color: var(--body); vertical-align: top; }
745
+ tbody tr:last-child td { border-bottom: 0; }
746
+ tbody tr:hover { background: var(--surface-raised); }
747
+ td:first-child { color: var(--ink); font-weight: 500; }
748
+ .type-label { color: var(--muted); font-family: var(--font-mono); font-size: 11px; }
749
+ .numeric { font-variant-numeric: tabular-nums; white-space: nowrap; }
750
+ .empty-state { margin: 0; padding: 22px 16px; color: var(--muted); }
751
+ .empty-state strong { color: var(--ink); font-weight: 500; }
752
+ .connections-table td:not(:first-child), .connections-table th:not(:first-child) { text-align: right; }
753
+ footer { padding: 18px 0 28px; border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; }
754
+ footer time { font-variant-numeric: tabular-nums; }
755
+
756
+ @media (max-width: 768px) {
757
+ .page { width: min(100% - 32px, 600px); }
758
+ .masthead { padding-top: 32px; }
759
+ .masthead-inner { align-items: flex-start; }
760
+ .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
761
+ .section-heading { align-items: flex-start; }
762
+ th, td { padding-right: 12px; padding-left: 12px; }
758
763
  }
759
- .filter-box input {
760
- width: 100%;
761
- padding: 8px;
762
- border: 1px solid #ddd;
763
- border-radius: 4px;
764
- font-size: 1em;
764
+ @media (max-width: 420px) {
765
+ .page { width: min(100% - 24px, 600px); }
766
+ .masthead { padding: 28px 0 24px; }
767
+ .masthead h1 { font-size: 40px; }
768
+ main { padding-top: 24px; }
769
+ .overview-intro { display: block; }
770
+ .overview-intro p { margin-top: 5px; }
771
+ .metric { min-height: 96px; padding: 14px 12px; }
772
+ .metric-value { font-size: 18.4px; }
773
+ .filter-row { align-items: stretch; flex-direction: column; gap: 6px; }
765
774
  }
766
- footer {
767
- background: #f5f5f5;
768
- padding: 20px;
769
- text-align: center;
770
- color: #666;
771
- font-size: 0.9em;
775
+ @media (prefers-reduced-motion: reduce) {
776
+ html { scroll-behavior: auto; }
777
+ *, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
772
778
  }
773
779
  </style>
774
780
  </head>
775
781
  <body>
776
- <div class="container">
777
- <header>
778
- <h1>\u{1F50D} Dead Links Report</h1>
779
- <p>Obsidian Vault Analysis</p>
780
- </header>
781
-
782
- <div class="content">
783
- ${statsHtml}
784
- ${brokenLinksHtml}
785
- ${orphanNotesHtml}
786
- ${graphHtml}
782
+ <header class="masthead">
783
+ <div class="page masthead-inner">
784
+ <div>
785
+ <h1>Dead Links Report</h1>
786
+ <p>Obsidian Vault Analysis</p>
787
+ </div>
787
788
  </div>
788
-
789
- <footer>
790
- Generated on ${(/* @__PURE__ */ new Date()).toLocaleString()}
791
- </footer>
792
- </div>
793
-
789
+ </header>
790
+ <main class="page">
791
+ ${summaryHtml}
792
+ ${statsHtml}
793
+ ${brokenLinksHtml}
794
+ ${orphanNotesHtml}
795
+ ${graphHtml}
796
+ </main>
797
+ <footer>
798
+ <div class="page">Generated <time>${this.escapeHtml((/* @__PURE__ */ new Date()).toLocaleString())}</time> \xB7 Completed in ${result.duration}ms</div>
799
+ </footer>
794
800
  <script>
795
- function setupTabs() {
796
- const buttons = document.querySelectorAll('.tab-button');
797
- const contents = document.querySelectorAll('.tab-content');
798
-
799
- buttons.forEach((button, index) => {
800
- button.addEventListener('click', () => {
801
- buttons.forEach(b => b.classList.remove('active'));
802
- contents.forEach(c => c.classList.remove('active'));
803
- button.classList.add('active');
804
- contents[index].classList.add('active');
805
- });
806
- });
807
-
808
- // Activate first tab
809
- if (buttons.length > 0) buttons[0].classList.add('active');
810
- if (contents.length > 0) contents[0].classList.add('active');
811
- }
812
-
813
801
  function setupFilter(tableId, inputId) {
814
802
  const input = document.getElementById(inputId);
815
803
  const table = document.getElementById(tableId);
816
804
  if (!input || !table) return;
817
805
 
818
- input.addEventListener('keyup', (e) => {
819
- const filter = e.target.value.toLowerCase();
820
- const rows = table.querySelectorAll('tbody tr');
821
- rows.forEach(row => {
822
- const text = row.textContent.toLowerCase();
823
- row.style.display = text.includes(filter) ? '' : 'none';
806
+ input.addEventListener('input', (event) => {
807
+ const filter = event.currentTarget.value.toLocaleLowerCase();
808
+ table.querySelectorAll('tbody tr').forEach((row) => {
809
+ row.hidden = !row.textContent.toLocaleLowerCase().includes(filter);
824
810
  });
825
811
  });
826
812
  }
827
813
 
828
814
  document.addEventListener('DOMContentLoaded', () => {
829
- setupTabs();
830
815
  setupFilter('broken-table', 'broken-filter');
831
816
  setupFilter('orphan-table', 'orphan-filter');
832
817
  });
@@ -834,147 +819,124 @@ var HtmlReporter = class {
834
819
  </body>
835
820
  </html>`;
836
821
  }
837
- renderStats(stats, duration) {
838
- const healthColor = stats.brokenCount === 0 && stats.orphanCount === 0 ? "#16a34a" : "#dc2626";
822
+ renderSummary(result) {
823
+ const brokenCount = result.stats.brokenCount;
824
+ const orphanCount = result.stats.orphanCount;
825
+ const message = brokenCount === 0 && orphanCount === 0 ? "No broken links or orphan notes found." : `${brokenCount} broken link${brokenCount === 1 ? "" : "s"} \xB7 ${orphanCount} orphan note${orphanCount === 1 ? "" : "s"}`;
839
826
  return `
840
- <div class="section">
841
- <h2>\u{1F4CA} Overview</h2>
842
- <div class="stats-grid">
843
- <div class="stat-card">
844
- <div class="value">${stats.totalNotes}</div>
845
- <div class="label">Total Notes</div>
846
- </div>
847
- <div class="stat-card">
848
- <div class="value">${stats.totalLinks}</div>
849
- <div class="label">Total Links</div>
850
- </div>
851
- <div class="stat-card" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
852
- <div class="value">${stats.brokenCount}</div>
853
- <div class="label">Broken Links</div>
854
- </div>
855
- <div class="stat-card" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
856
- <div class="value">${stats.orphanCount}</div>
857
- <div class="label">Orphan Notes</div>
858
- </div>
859
- <div class="stat-card" style="background: linear-gradient(135deg, ${healthColor} 0%, ${healthColor === "#16a34a" ? "#15803d" : "#991b1b"} 100%);">
860
- <div class="value">${stats.connectedComponents}</div>
861
- <div class="label">Connected Components</div>
862
- </div>
863
- <div class="stat-card" style="background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);">
864
- <div class="value">${duration}ms</div>
865
- <div class="label">Scan Duration</div>
866
- </div>
827
+ <section class="overview" aria-labelledby="overview-title">
828
+ <div class="overview-intro">
829
+ <h2 id="overview-title">At a glance</h2>
830
+ <p>Scan completed in ${result.duration}ms</p>
867
831
  </div>
868
- </div>
832
+ <p class="summary">${this.escapeHtml(message)}</p>
833
+ </section>
834
+ `;
835
+ }
836
+ renderStats(result) {
837
+ const { stats } = result;
838
+ const averageLinks = stats.totalNotes === 0 ? 0 : stats.totalLinks / stats.totalNotes;
839
+ const healthScore = stats.totalLinks === 0 ? 100 : 100 - stats.brokenCount / stats.totalLinks * 100;
840
+ const metrics = [
841
+ ["Total Notes", String(stats.totalNotes)],
842
+ ["Links", String(stats.totalLinks)],
843
+ ["Broken links", String(stats.brokenCount)],
844
+ ["Orphan notes", String(stats.orphanCount)],
845
+ ["Avg. links per note", averageLinks.toFixed(2)],
846
+ ["Vault health", `${healthScore.toFixed(1)}%`],
847
+ ["Connected components", String(stats.connectedComponents)]
848
+ ];
849
+ return `
850
+ <section class="overview" aria-label="Vault statistics">
851
+ <div class="metrics">
852
+ ${metrics.map(([label, value]) => `
853
+ <div class="metric">
854
+ <span class="metric-value">${this.escapeHtml(value)}</span>
855
+ <span class="metric-label">${this.escapeHtml(label)}</span>
856
+ </div>`).join("")}
857
+ </div>
858
+ </section>
869
859
  `;
870
860
  }
871
861
  renderBrokenLinks(brokenLinks) {
872
- if (brokenLinks.length === 0) {
873
- return `
874
- <div class="section">
875
- <h2>\u2705 Broken Links</h2>
876
- <p class="success">No broken links found!</p>
877
- </div>
878
- `;
879
- }
880
- const rows = brokenLinks.map(
881
- (link) => `
862
+ const rows = brokenLinks.map((link) => `
882
863
  <tr>
883
864
  <td>${this.escapeHtml(link.source)}</td>
884
865
  <td>${this.escapeHtml(link.link.target)}</td>
885
- <td><span class="badge badge-${link.link.type}">${link.link.type}</span></td>
886
- <td>${link.link.line}</td>
866
+ <td><span class="type-label">${this.escapeHtml(link.link.type)}</span></td>
867
+ <td class="numeric">${link.link.line}</td>
887
868
  <td>${this.escapeHtml(link.reason)}</td>
888
869
  </tr>
889
- `
890
- ).join("");
870
+ `).join("");
891
871
  return `
892
- <div class="section">
893
- <h2>\u{1F534} Broken Links (${brokenLinks.length})</h2>
894
- <div class="filter-box">
895
- <input type="text" id="broken-filter" placeholder="Filter by source or target..." />
872
+ <section class="section" aria-labelledby="broken-title">
873
+ <div class="section-heading">
874
+ <h2 id="broken-title">Broken links</h2>
875
+ <span class="section-count">${brokenLinks.length} found</span>
896
876
  </div>
897
- <table id="broken-table">
898
- <thead>
899
- <tr>
900
- <th>Source</th>
901
- <th>Target</th>
902
- <th>Type</th>
903
- <th>Line</th>
904
- <th>Reason</th>
905
- </tr>
906
- </thead>
907
- <tbody>
908
- ${rows}
909
- </tbody>
910
- </table>
911
- </div>
877
+ <div class="data-surface">
878
+ ${brokenLinks.length === 0 ? '<p class="empty-state">No broken links found.</p>' : `<div class="filter-row">
879
+ <label for="broken-filter">Filter broken links</label>
880
+ <input type="search" id="broken-filter" aria-label="Filter broken links" placeholder="Search source, target, type, or reason">
881
+ </div>
882
+ <div class="table-scroll bounded-table-scroll" tabindex="0" role="region" aria-label="Scrollable broken links">
883
+ <table id="broken-table">
884
+ <thead><tr><th>Source</th><th>Target</th><th>Type</th><th>Line</th><th>Reason</th></tr></thead>
885
+ <tbody>${rows}</tbody>
886
+ </table>
887
+ </div>`}
888
+ </div>
889
+ </section>
912
890
  `;
913
891
  }
914
892
  renderOrphanNotes(orphanNotes) {
915
- if (orphanNotes.length === 0) {
916
- return `
917
- <div class="section">
918
- <h2>\u2705 Orphan Notes</h2>
919
- <p class="success">No orphan notes found!</p>
920
- </div>
921
- `;
922
- }
923
- const rows = orphanNotes.map(
924
- (note) => `
925
- <tr>
926
- <td>${this.escapeHtml(note.relativePath)}</td>
927
- </tr>
928
- `
929
- ).join("");
893
+ const rows = orphanNotes.map((note) => `
894
+ <tr><td>${this.escapeHtml(note.relativePath)}</td></tr>
895
+ `).join("");
930
896
  return `
931
- <div class="section">
932
- <h2>\u{1F7E1} Orphan Notes (${orphanNotes.length})</h2>
933
- <div class="filter-box">
934
- <input type="text" id="orphan-filter" placeholder="Filter by path..." />
897
+ <section class="section" aria-labelledby="orphans-title">
898
+ <div class="section-heading">
899
+ <h2 id="orphans-title">Orphan notes</h2>
900
+ <span class="section-count">${orphanNotes.length} found</span>
935
901
  </div>
936
- <table id="orphan-table">
937
- <thead>
938
- <tr>
939
- <th>Path</th>
940
- </tr>
941
- </thead>
942
- <tbody>
943
- ${rows}
944
- </tbody>
945
- </table>
946
- </div>
902
+ <div class="data-surface">
903
+ ${orphanNotes.length === 0 ? '<p class="empty-state">No orphan notes found!</p>' : `<div class="filter-row">
904
+ <label for="orphan-filter">Filter orphan notes</label>
905
+ <input type="search" id="orphan-filter" aria-label="Filter orphan notes" placeholder="Search note paths">
906
+ </div>
907
+ <div class="table-scroll bounded-table-scroll" tabindex="0" role="region" aria-label="Scrollable orphan notes">
908
+ <table id="orphan-table">
909
+ <thead><tr><th>Note path</th></tr></thead>
910
+ <tbody>${rows}</tbody>
911
+ </table>
912
+ </div>`}
913
+ </div>
914
+ </section>
947
915
  `;
948
916
  }
949
917
  renderGraph(graph) {
950
918
  const mostConnected = [...graph].sort((a, b) => b.incoming.length + b.outgoing.length - (a.incoming.length + a.outgoing.length)).slice(0, 5);
951
- const rows = mostConnected.map(
952
- (node) => `
919
+ const rows = mostConnected.map((node) => `
953
920
  <tr>
954
921
  <td>${this.escapeHtml(node.note.relativePath)}</td>
955
- <td>${node.incoming.length}</td>
956
- <td>${node.outgoing.length}</td>
957
- <td>${node.incoming.length + node.outgoing.length}</td>
922
+ <td class="numeric">${node.incoming.length}</td>
923
+ <td class="numeric">${node.outgoing.length}</td>
924
+ <td class="numeric">${node.incoming.length + node.outgoing.length}</td>
958
925
  </tr>
959
- `
960
- ).join("");
926
+ `).join("");
961
927
  return `
962
- <div class="section">
963
- <h2>\u{1F310} Most Connected Notes</h2>
964
- <table>
965
- <thead>
966
- <tr>
967
- <th>Note</th>
968
- <th>Incoming</th>
969
- <th>Outgoing</th>
970
- <th>Total</th>
971
- </tr>
972
- </thead>
973
- <tbody>
974
- ${rows}
975
- </tbody>
976
- </table>
977
- </div>
928
+ <section class="section" aria-labelledby="connections-title">
929
+ <div class="section-heading">
930
+ <h2 id="connections-title">Most connected notes</h2>
931
+ <span class="section-count">Top ${mostConnected.length}</span>
932
+ </div>
933
+ <div class="data-surface table-scroll">
934
+ ${mostConnected.length === 0 ? '<p class="empty-state">No connections to show.</p>' : `<table class="connections-table">
935
+ <thead><tr><th>Note</th><th>Incoming</th><th>Outgoing</th><th>Total</th></tr></thead>
936
+ <tbody>${rows}</tbody>
937
+ </table>`}
938
+ </div>
939
+ </section>
978
940
  `;
979
941
  }
980
942
  escapeHtml(text) {
@@ -982,123 +944,6 @@ var HtmlReporter = class {
982
944
  }
983
945
  };
984
946
 
985
- // src/reporters/mermaid-reporter.ts
986
- var MermaidReporter = class {
987
- report(result, maxNodes = 50) {
988
- let output = "graph LR\n";
989
- const nodesToShow = result.graph.slice(0, maxNodes);
990
- const nodeMap = new Map(nodesToShow.map((n) => [n.note.path, n.note.relativePath]));
991
- for (const node of nodesToShow) {
992
- const id = this.sanitizeId(node.note.relativePath);
993
- const label = this.escapeLabel(node.note.relativePath);
994
- if (result.orphanNotes.some((o) => o.path === node.note.path)) {
995
- output += ` ${id}["<b>\u{1F534} ${label}</b>"]:::orphan
996
- `;
997
- } else if (node.incoming.length === 0) {
998
- output += ` ${id}["\u{1F4C4} ${label}"]:::source
999
- `;
1000
- } else if (node.outgoing.length === 0) {
1001
- output += ` ${id}["\u{1F4CC} ${label}"]:::sink
1002
- `;
1003
- } else {
1004
- output += ` ${id}["${label}"]
1005
- `;
1006
- }
1007
- }
1008
- const addedEdges = /* @__PURE__ */ new Set();
1009
- for (const node of nodesToShow) {
1010
- const sourceId = this.sanitizeId(node.note.relativePath);
1011
- for (const targetPath of node.outgoing) {
1012
- const targetName = nodeMap.get(targetPath);
1013
- if (!targetName) continue;
1014
- const targetId = this.sanitizeId(targetName);
1015
- const edgeKey = `${sourceId}-${targetId}`;
1016
- if (!addedEdges.has(edgeKey)) {
1017
- output += ` ${sourceId} --> ${targetId}
1018
- `;
1019
- addedEdges.add(edgeKey);
1020
- }
1021
- }
1022
- }
1023
- output += "\n classDef orphan fill:#ff6b6b,stroke:#c92a2a,color:#fff\n";
1024
- output += " classDef source fill:#4dabf7,stroke:#1971c2,color:#fff\n";
1025
- output += " classDef sink fill:#51cf66,stroke:#2f9e44,color:#fff\n";
1026
- if (result.graph.length > maxNodes) {
1027
- output += `
1028
- note["Graph limited to ${maxNodes} of ${result.graph.length} nodes for readability"]
1029
- `;
1030
- }
1031
- return output;
1032
- }
1033
- sanitizeId(name) {
1034
- return name.replace(/\.(md|canvas)$/, "").replace(/[^a-zA-Z0-9_]/g, "_").substring(0, 30);
1035
- }
1036
- escapeLabel(name) {
1037
- return name.replace(/"/g, '\\"').substring(0, 50);
1038
- }
1039
- };
1040
-
1041
- // src/reporters/graphviz-reporter.ts
1042
- var GraphVizReporter = class {
1043
- report(result, maxNodes = 100) {
1044
- let output = "digraph VaultGraph {\n";
1045
- output += " rankdir=LR;\n";
1046
- output += " node [shape=box, style=rounded];\n";
1047
- output += " edge [arrowsize=0.5];\n\n";
1048
- const nodesToShow = result.graph.slice(0, maxNodes);
1049
- const nodeMap = new Map(nodesToShow.map((n) => [n.note.path, n.note.relativePath]));
1050
- for (const node of nodesToShow) {
1051
- const id = this.sanitizeId(node.note.relativePath);
1052
- const label = node.note.relativePath;
1053
- let style = "filled";
1054
- let color = "#ffffff";
1055
- let fontColor = "#000000";
1056
- let shape = "box";
1057
- if (result.orphanNotes.some((o) => o.path === node.note.path)) {
1058
- color = "#ff6b6b";
1059
- fontColor = "#ffffff";
1060
- shape = "box";
1061
- } else if (node.incoming.length === 0) {
1062
- color = "#4dabf7";
1063
- fontColor = "#ffffff";
1064
- } else if (node.outgoing.length === 0) {
1065
- color = "#51cf66";
1066
- fontColor = "#ffffff";
1067
- }
1068
- output += ` "${id}" [label="${label}", fillcolor="${color}", fontcolor="${fontColor}", shape=${shape}];
1069
- `;
1070
- }
1071
- output += "\n";
1072
- const addedEdges = /* @__PURE__ */ new Set();
1073
- for (const node of nodesToShow) {
1074
- const sourceId = this.sanitizeId(node.note.relativePath);
1075
- for (const targetPath of node.outgoing) {
1076
- const targetName = nodeMap.get(targetPath);
1077
- if (!targetName) continue;
1078
- const targetId = this.sanitizeId(targetName);
1079
- const edgeKey = `${sourceId}->${targetId}`;
1080
- if (!addedEdges.has(edgeKey)) {
1081
- output += ` "${sourceId}" -> "${targetId}";
1082
- `;
1083
- addedEdges.add(edgeKey);
1084
- }
1085
- }
1086
- }
1087
- output += "\n // Legend\n";
1088
- output += " subgraph legend {\n";
1089
- output += " rank=sink;\n";
1090
- output += ' orphan [label="\u{1F534} Orphan", fillcolor="#ff6b6b", fontcolor="white"];\n';
1091
- output += ' source [label="\u{1F4C4} Source (no incoming)", fillcolor="#4dabf7", fontcolor="white"];\n';
1092
- output += ' sink [label="\u{1F4CC} Sink (no outgoing)", fillcolor="#51cf66", fontcolor="white"];\n';
1093
- output += " }\n";
1094
- output += "}\n";
1095
- return output;
1096
- }
1097
- sanitizeId(name) {
1098
- return name.replace(/\.(md|canvas)$/, "").replace(/[^a-zA-Z0-9_]/g, "_");
1099
- }
1100
- };
1101
-
1102
947
  // src/utils/markdown-report.ts
1103
948
  function pill(target, isMultiple) {
1104
949
  return isMultiple ? `[${target}]` : `[${target}]`;
@@ -1121,6 +966,10 @@ function generateMarkdownReport(report) {
1121
966
  lines.push(`|--------|-------|`);
1122
967
  lines.push(`| Notes | ${report.stats.totalNotes} |`);
1123
968
  lines.push(`| Links | ${report.stats.totalLinks} |`);
969
+ const averageLinks = report.stats.totalNotes === 0 ? 0 : report.stats.totalLinks / report.stats.totalNotes;
970
+ const healthScore = report.stats.totalLinks === 0 ? 100 : 100 - report.stats.brokenCount / report.stats.totalLinks * 100;
971
+ lines.push(`| Avg links per note | ${averageLinks.toFixed(2)} |`);
972
+ lines.push(`| Health score | ${healthScore.toFixed(1)}% |`);
1124
973
  lines.push(`| Broken | ${report.stats.brokenCount} |`);
1125
974
  lines.push(`| Orphans | ${report.stats.orphanCount} |`);
1126
975
  lines.push(`| Components | ${report.stats.connectedComponents} |`);
@@ -1164,12 +1013,30 @@ function generateMarkdownReport(report) {
1164
1013
  }
1165
1014
  }
1166
1015
  lines.push("");
1016
+ lines.push("## Connections");
1017
+ lines.push("");
1018
+ lines.push("| Note | Incoming | Outgoing | Total |");
1019
+ lines.push("|------|----------|----------|-------|");
1020
+ const mostConnected = [...report.graph].sort((a, b) => b.incoming.length + b.outgoing.length - (a.incoming.length + a.outgoing.length)).slice(0, 10);
1021
+ if (mostConnected.length === 0) {
1022
+ lines.push("| No connected notes | 0 | 0 | 0 |");
1023
+ } else {
1024
+ for (const node of mostConnected) {
1025
+ const incoming = node.incoming.length;
1026
+ const outgoing = node.outgoing.length;
1027
+ lines.push(`| ${node.note.relativePath} | ${incoming} | ${outgoing} | ${incoming + outgoing} |`);
1028
+ }
1029
+ }
1030
+ lines.push("");
1167
1031
  return lines.join("\n");
1168
1032
  }
1169
1033
 
1170
1034
  // src/index.ts
1035
+ var packageMetadata = JSON.parse(
1036
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
1037
+ );
1171
1038
  var program = new Command();
1172
- program.name("dead-links").description("Scan Obsidian vaults for broken links, orphan notes, and connection graphs").version("0.2.0");
1039
+ program.name("dead-links").description("Scan Obsidian vaults for broken links, orphan notes, and connection graphs").version(packageMetadata.version);
1173
1040
  async function loadConfigAndAnalyze(vaultPath, cliOptions) {
1174
1041
  const configLoader = new ConfigLoader();
1175
1042
  const fileConfig = await configLoader.loadConfig(vaultPath);
@@ -1177,25 +1044,9 @@ async function loadConfigAndAnalyze(vaultPath, cliOptions) {
1177
1044
  const engine = new DeadLinksEngine();
1178
1045
  return engine.analyze(vaultPath, config);
1179
1046
  }
1180
- function printBrokenVerbose(brokenLinks) {
1181
- const grouped = /* @__PURE__ */ new Map();
1182
- for (const b of brokenLinks) {
1183
- const existing = grouped.get(b.source) || [];
1184
- existing.push(b);
1185
- grouped.set(b.source, existing);
1186
- }
1187
- for (const [source, links] of grouped) {
1188
- for (const link of links) {
1189
- console.log(` ${source} \u2192 ${link.link.target} (line ${link.link.line})`);
1190
- }
1191
- }
1192
- }
1193
- function printOrphansVerbose(orphanNotes) {
1194
- for (const note of orphanNotes) {
1195
- console.log(` ${note.relativePath}`);
1196
- }
1197
- }
1198
- program.command("scan").description("Scan the vault for all issues").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--format <type>", "Output format: text, json, markdown, csv, html, mermaid, dot", "text").option("--output <file>", "Output file path").option("--ignore <patterns...>", "Ignore patterns (glob)").option("--attachments", "Also check embedded attachments (images, PDFs, etc.)").option("--ignore-folders <folders...>", "Exclude folders from orphan report").option("--quiet", "Only show issues, no stats or summary").option("--no-color", "Disable colored output").option("--suggestions", "Show smart suggestions for broken links").option("--debug", "Enable debug mode with verbose output").option("--fix", "Automatically fix broken links (requires suggestions)").action(async (options) => {
1047
+ program.command("scan").description("Scan the vault for all issues").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).addOption(
1048
+ new Option("--format <type>", "Output format: text, json, markdown, csv, html").choices(["text", "json", "markdown", "csv", "html"]).default("text")
1049
+ ).option("--output <file>", "Output file path").option("--ignore <patterns...>", "Ignore patterns (glob)").option("--attachments", "Also check embedded attachments (images, PDFs, etc.)").option("--ignore-folders <folders...>", "Exclude folders from orphan report").option("--quiet", "Only show issues, no stats or summary").option("--no-color", "Disable colored output").option("--debug", "Enable debug mode with verbose output").action(async (options) => {
1199
1050
  const spinner = ora("Scanning vault...").start();
1200
1051
  try {
1201
1052
  const report = await loadConfigAndAnalyze(options.vault, options);
@@ -1218,21 +1069,17 @@ program.command("scan").description("Scan the vault for all issues").option("--v
1218
1069
  } else if (options.format === "html") {
1219
1070
  const reporter = new HtmlReporter();
1220
1071
  output = reporter.report(report);
1221
- } else if (options.format === "mermaid") {
1222
- const reporter = new MermaidReporter();
1223
- output = reporter.report(report);
1224
- } else if (options.format === "dot") {
1225
- const reporter = new GraphVizReporter();
1226
- output = reporter.report(report);
1227
1072
  } else {
1228
- const reporter = new TerminalReporter(options.ignoreFolders || [], options.quiet || false);
1073
+ const reporter = new TerminalReporter(
1074
+ options.ignoreFolders || [],
1075
+ options.quiet || false,
1076
+ options.color === false
1077
+ );
1229
1078
  output = reporter.report(report);
1230
1079
  }
1231
1080
  if (options.output) {
1232
1081
  await writeFile(options.output, output);
1233
1082
  console.log(`Report written to ${options.output}`);
1234
- } else if (options.format === "text") {
1235
- console.log(output);
1236
1083
  } else {
1237
1084
  console.log(output);
1238
1085
  }
@@ -1243,112 +1090,6 @@ program.command("scan").description("Scan the vault for all issues").option("--v
1243
1090
  process.exit(1);
1244
1091
  }
1245
1092
  });
1246
- program.command("broken").description("Show only broken links").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--ignore <patterns...>", "Ignore patterns (glob)").option("--attachments", "Also check embedded attachments (images, PDFs, etc.)").option("--verbose", "Show line numbers and full details").option("--suggestions", "Show smart suggestions for broken links").option("--debug", "Enable debug mode with verbose output").action(async (options) => {
1247
- const spinner = ora("Scanning vault...").start();
1248
- try {
1249
- const report = await loadConfigAndAnalyze(options.vault, options);
1250
- spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);
1251
- if (report.brokenLinks.length === 0) {
1252
- console.log("No broken links found.");
1253
- } else {
1254
- console.log(`
1255
- Broken links (${report.brokenLinks.length})
1256
- `);
1257
- if (options.verbose) {
1258
- printBrokenVerbose(report.brokenLinks);
1259
- } else {
1260
- const grouped = /* @__PURE__ */ new Map();
1261
- for (const b of report.brokenLinks) {
1262
- const existing = grouped.get(b.source) || [];
1263
- existing.push(b);
1264
- grouped.set(b.source, existing);
1265
- }
1266
- for (const [source, links] of grouped) {
1267
- const targets = links.map((l) => l.link.target).join(", ");
1268
- console.log(` ${source} \u2192 ${targets}`);
1269
- }
1270
- }
1271
- console.log();
1272
- if (report.suggestions && report.suggestions.length > 0) {
1273
- console.log("\u{1F4A1} Suggestions:");
1274
- for (const suggestion of report.suggestions.slice(0, 5)) {
1275
- const confidence = Math.round(suggestion.similarity * 100);
1276
- console.log(` ${suggestion.broken} \u2192 ${suggestion.suggested} (${confidence}%)`);
1277
- }
1278
- console.log();
1279
- }
1280
- }
1281
- process.exit(report.stats.brokenCount > 0 ? 1 : 0);
1282
- } catch (error) {
1283
- spinner.fail("Scan failed");
1284
- console.error(error instanceof Error ? error.message : error);
1285
- process.exit(1);
1286
- }
1287
- });
1288
- program.command("orphans").description("Show only orphan notes").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--ignore <patterns...>", "Ignore patterns (glob)").option("--ignore-folders <folders...>", "Exclude folders from report").option("--verbose", "Show full list of orphan notes").action(async (options) => {
1289
- const spinner = ora("Scanning vault...").start();
1290
- try {
1291
- const report = await loadConfigAndAnalyze(options.vault, options);
1292
- spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);
1293
- if (report.orphanNotes.length === 0) {
1294
- console.log("No orphan notes found.");
1295
- } else {
1296
- console.log(`
1297
- Orphan notes (${report.orphanNotes.length})
1298
- `);
1299
- if (options.verbose) {
1300
- printOrphansVerbose(report.orphanNotes);
1301
- } else {
1302
- const ignoreFolders = options.ignoreFolders || [];
1303
- const byFolder = /* @__PURE__ */ new Map();
1304
- for (const note of report.orphanNotes) {
1305
- const folder = note.relativePath.includes("/") ? note.relativePath.split("/").slice(0, -1).join("/") + "/" : "./";
1306
- if (ignoreFolders.some((f) => folder.startsWith(f + "/") || folder === f + "/")) {
1307
- continue;
1308
- }
1309
- byFolder.set(folder, (byFolder.get(folder) || 0) + 1);
1310
- }
1311
- for (const [folder, count] of [...byFolder.entries()].sort((a, b) => b[1] - a[1])) {
1312
- console.log(` ${folder} ${count}`);
1313
- }
1314
- }
1315
- console.log();
1316
- }
1317
- process.exit(report.stats.orphanCount > 0 ? 1 : 0);
1318
- } catch (error) {
1319
- spinner.fail("Scan failed");
1320
- console.error(error instanceof Error ? error.message : error);
1321
- process.exit(1);
1322
- }
1323
- });
1324
- program.command("graph").description("Show connection graph").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--format <type>", "Output format: text, json", "text").option("--ignore <patterns...>", "Ignore patterns (glob)").action(async (options) => {
1325
- const spinner = ora("Scanning vault...").start();
1326
- try {
1327
- const report = await loadConfigAndAnalyze(options.vault, options);
1328
- spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);
1329
- if (options.format === "json") {
1330
- console.log(JSON.stringify(report.graph, null, 2));
1331
- } else {
1332
- console.log(
1333
- `Graph: ${report.stats.totalNotes} notes, ${report.stats.totalLinks} links (${report.duration}ms)
1334
- `
1335
- );
1336
- const topConnected = [...report.graph].sort((a, b) => b.incoming.length - a.incoming.length).slice(0, 10).filter((n) => n.incoming.length > 0);
1337
- if (topConnected.length > 0) {
1338
- console.log("Most linked-to notes:");
1339
- for (const node of topConnected) {
1340
- console.log(
1341
- ` ${node.note.relativePath} (${node.incoming.length} incoming, ${node.outgoing.length} outgoing)`
1342
- );
1343
- }
1344
- }
1345
- }
1346
- } catch (error) {
1347
- spinner.fail("Scan failed");
1348
- console.error(error instanceof Error ? error.message : error);
1349
- process.exit(1);
1350
- }
1351
- });
1352
1093
  program.command("report").description("Generate a markdown report").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--output <file>", "Output file path", "dead-links-report.md").option("--ignore <patterns...>", "Ignore patterns (glob)").action(async (options) => {
1353
1094
  const spinner = ora("Scanning vault...").start();
1354
1095
  try {
@@ -1363,38 +1104,5 @@ program.command("report").description("Generate a markdown report").option("--va
1363
1104
  process.exit(1);
1364
1105
  }
1365
1106
  });
1366
- program.command("stats").description("Show detailed vault statistics").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--ignore <patterns...>", "Ignore patterns (glob)").option("--debug", "Enable debug mode").action(async (options) => {
1367
- const spinner = ora("Analyzing vault...").start();
1368
- try {
1369
- const report = await loadConfigAndAnalyze(options.vault, options);
1370
- spinner.succeed(`Analysis complete in ${report.duration}ms`);
1371
- console.log("\n\u{1F4CA} Vault Statistics\n");
1372
- console.log(` Total Notes: ${report.stats.totalNotes}`);
1373
- console.log(` Total Links: ${report.stats.totalLinks}`);
1374
- console.log(` Avg Links per Note: ${(report.stats.totalLinks / report.stats.totalNotes).toFixed(2)}`);
1375
- console.log(` Broken Links: ${report.stats.brokenCount}`);
1376
- console.log(` Orphan Notes: ${report.stats.orphanCount}`);
1377
- console.log(` Connected Components: ${report.stats.connectedComponents}`);
1378
- console.log(` Health Score: ${(100 - report.stats.brokenCount / report.stats.totalLinks * 100).toFixed(1)}%`);
1379
- const mostConnected = [...report.graph].sort((a, b) => b.incoming.length + b.outgoing.length - (a.incoming.length + a.outgoing.length)).slice(0, 5);
1380
- console.log("\n\u{1F310} Most Connected Notes:");
1381
- for (const node of mostConnected) {
1382
- const total = node.incoming.length + node.outgoing.length;
1383
- console.log(` ${node.note.relativePath} (${total} connections: ${node.incoming.length} in, ${node.outgoing.length} out)`);
1384
- }
1385
- const lonelyNotes = report.graph.filter((n) => n.incoming.length === 0 && n.outgoing.length === 0).slice(0, 5);
1386
- if (lonelyNotes.length > 0) {
1387
- console.log("\n\u{1F534} Orphan Notes:");
1388
- for (const node of lonelyNotes) {
1389
- console.log(` ${node.note.relativePath}`);
1390
- }
1391
- }
1392
- console.log();
1393
- } catch (error) {
1394
- spinner.fail("Analysis failed");
1395
- console.error(error instanceof Error ? error.message : error);
1396
- process.exit(1);
1397
- }
1398
- });
1399
1107
  program.parse();
1400
1108
  //# sourceMappingURL=index.js.map