dead-links 0.1.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/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +746 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luis Angel Martínez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# dead-links
|
|
2
|
+
|
|
3
|
+
A CLI tool to scan your Obsidian vault for broken links, orphan notes, and connection graphs.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g dead-links
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or use without installing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx dead-links scan --vault ./my-vault
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
dead-links scan --vault ./my-vault
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That's it. You'll see broken links, orphan notes, and graph stats.
|
|
24
|
+
|
|
25
|
+
## Commands
|
|
26
|
+
|
|
27
|
+
| Command | Description |
|
|
28
|
+
|---------|-------------|
|
|
29
|
+
| `dead-links scan` | Full scan: broken links + orphan notes + graph |
|
|
30
|
+
| `dead-links broken` | Show only broken links |
|
|
31
|
+
| `dead-links orphans` | Show only orphan notes |
|
|
32
|
+
| `dead-links graph` | Show connection graph |
|
|
33
|
+
| `dead-links report` | Generate a `.md` report file |
|
|
34
|
+
|
|
35
|
+
## Options
|
|
36
|
+
|
|
37
|
+
| Flag | Description |
|
|
38
|
+
|------|-------------|
|
|
39
|
+
| `--vault <path>` | Path to the vault (default: current directory) |
|
|
40
|
+
| `--format <type>` | Output format: `text`, `json`, or `markdown` (default: `text`) |
|
|
41
|
+
| `--output <file>` | Save output to a file |
|
|
42
|
+
| `--ignore <patterns...>` | Glob patterns to ignore |
|
|
43
|
+
| `--attachments` | Also check if images/PDFs/files exist |
|
|
44
|
+
| `--ignore-folders <folders...>` | Exclude folders from orphan report |
|
|
45
|
+
| `--quiet` | Only show issues, no stats or summary |
|
|
46
|
+
| `--no-color` | Disable colored output |
|
|
47
|
+
| `--verbose` | Show line numbers and full details |
|
|
48
|
+
|
|
49
|
+
## Examples
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Scan vault
|
|
53
|
+
dead-links scan --vault ./my-vault
|
|
54
|
+
|
|
55
|
+
# Check for broken links only
|
|
56
|
+
dead-links broken --vault ./my-vault
|
|
57
|
+
|
|
58
|
+
# Include attachments in the check
|
|
59
|
+
dead-links scan --vault ./my-vault --attachments
|
|
60
|
+
|
|
61
|
+
# Ignore noisy folders from orphan report
|
|
62
|
+
dead-links scan --vault ./my-vault --ignore-folders Films Trunk Diary
|
|
63
|
+
|
|
64
|
+
# JSON output for scripts/CI
|
|
65
|
+
dead-links scan --vault ./my-vault --format json
|
|
66
|
+
|
|
67
|
+
# Generate markdown report
|
|
68
|
+
dead-links report --vault ./my-vault --output report.md
|
|
69
|
+
|
|
70
|
+
# Ignore patterns
|
|
71
|
+
dead-links scan --vault ./my-vault --ignore "**/Templates/**" "**/.trash/**"
|
|
72
|
+
|
|
73
|
+
# Quiet mode (only issues)
|
|
74
|
+
dead-links scan --vault ./my-vault --quiet
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Output Format
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
Notes 368
|
|
81
|
+
Links 943
|
|
82
|
+
Broken links 5
|
|
83
|
+
Orphan notes 222
|
|
84
|
+
Components 248
|
|
85
|
+
|
|
86
|
+
✖ Broken links (5)
|
|
87
|
+
|
|
88
|
+
✖ 2 Universidad/S1/Primer Semestre.md
|
|
89
|
+
[Missing Note 1]
|
|
90
|
+
[Missing Note 2]
|
|
91
|
+
|
|
92
|
+
✖ 1 Notes/My Note.md
|
|
93
|
+
[Another Missing]
|
|
94
|
+
|
|
95
|
+
⚠ Orphan notes (222 total)
|
|
96
|
+
|
|
97
|
+
Notes/ ██████████ 29
|
|
98
|
+
Dev/Projects/ ███░░░░░░░ 8
|
|
99
|
+
|
|
100
|
+
Completed in 234ms
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
Create a `.deadlinksrc.json` in your vault root for persistent settings:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"ignore": ["**/Templates/**", "**/.trash/**"],
|
|
110
|
+
"ignoreFolders": ["Films", "Trunk", "Diary", "Books", "Games"],
|
|
111
|
+
"checkAttachments": false
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## CI/CD
|
|
116
|
+
|
|
117
|
+
Exit code is `0` if no issues found, `1` if issues exist. Perfect for CI pipelines.
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
# GitHub Actions
|
|
121
|
+
- run: npx dead-links scan --vault . --format json > report.json
|
|
122
|
+
|
|
123
|
+
# Fail pipeline if broken links exist
|
|
124
|
+
- run: npx dead-links scan --vault . --quiet && exit 1 || true
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Links Detected
|
|
128
|
+
|
|
129
|
+
- `[[Note]]` - Wiki links
|
|
130
|
+
- `[[Note|Alias]]` - Wiki links with alias
|
|
131
|
+
- `[[Note#Section]]` - Wiki links with heading
|
|
132
|
+
- `[[Note#^blockid]]` - Wiki links with block reference
|
|
133
|
+
- `![[image.png]]` - Embeds
|
|
134
|
+
- `[text](file.md)` - Markdown links
|
|
135
|
+
|
|
136
|
+
## Development
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
pnpm install
|
|
140
|
+
pnpm run build
|
|
141
|
+
pnpm test
|
|
142
|
+
pnpm run lint
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { writeFile } from "fs/promises";
|
|
6
|
+
import ora from "ora";
|
|
7
|
+
|
|
8
|
+
// src/core/vault-loader.ts
|
|
9
|
+
import { readFile } from "fs/promises";
|
|
10
|
+
import { resolve } from "path";
|
|
11
|
+
import fg from "fast-glob";
|
|
12
|
+
import matter from "gray-matter";
|
|
13
|
+
var DEFAULT_IGNORE = [
|
|
14
|
+
"**/node_modules/**",
|
|
15
|
+
"**/.git/**",
|
|
16
|
+
"**/.obsidian/**"
|
|
17
|
+
];
|
|
18
|
+
async function loadVault(vaultPath, customIgnore = []) {
|
|
19
|
+
const absolutePath = resolve(vaultPath);
|
|
20
|
+
const files = await fg(["**/*.md", "**/*.canvas"], {
|
|
21
|
+
cwd: absolutePath,
|
|
22
|
+
ignore: [...DEFAULT_IGNORE, ...customIgnore]
|
|
23
|
+
});
|
|
24
|
+
const notes = [];
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
const fullPath = resolve(absolutePath, file);
|
|
27
|
+
const content = await readFile(fullPath, "utf-8");
|
|
28
|
+
const parsed = matter(content);
|
|
29
|
+
const aliases = [];
|
|
30
|
+
if (parsed.data.aliases) {
|
|
31
|
+
if (Array.isArray(parsed.data.aliases)) {
|
|
32
|
+
aliases.push(...parsed.data.aliases.map(String));
|
|
33
|
+
} else if (typeof parsed.data.aliases === "string") {
|
|
34
|
+
aliases.push(parsed.data.aliases);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
notes.push({
|
|
38
|
+
path: fullPath,
|
|
39
|
+
relativePath: file,
|
|
40
|
+
content: parsed.content,
|
|
41
|
+
aliases,
|
|
42
|
+
links: []
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return notes;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/core/link-parser.ts
|
|
49
|
+
var wikiLinkRegex = /!?\[\[([^\]]+)\]\]/g;
|
|
50
|
+
var markdownLinkRegex = /\[([^\]]*)\]\(([^)]+)\)/g;
|
|
51
|
+
function parseLinks(content) {
|
|
52
|
+
const links = [];
|
|
53
|
+
const lines = content.split("\n");
|
|
54
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
55
|
+
const line = lines[lineNum];
|
|
56
|
+
for (const match of line.matchAll(wikiLinkRegex)) {
|
|
57
|
+
const raw = match[0];
|
|
58
|
+
const inner = match[1];
|
|
59
|
+
const isEmbed = raw.startsWith("!");
|
|
60
|
+
const link = parseWikiLink(inner, raw, isEmbed, lineNum + 1);
|
|
61
|
+
links.push(link);
|
|
62
|
+
}
|
|
63
|
+
for (const match of line.matchAll(markdownLinkRegex)) {
|
|
64
|
+
const raw = match[0];
|
|
65
|
+
const target = match[2];
|
|
66
|
+
if (target.startsWith("#")) continue;
|
|
67
|
+
const link = {
|
|
68
|
+
raw,
|
|
69
|
+
target: target.split("#")[0].split("|")[0],
|
|
70
|
+
heading: target.includes("#") ? target.split("#")[1] : void 0,
|
|
71
|
+
type: "markdown",
|
|
72
|
+
line: lineNum + 1
|
|
73
|
+
};
|
|
74
|
+
links.push(link);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return links;
|
|
78
|
+
}
|
|
79
|
+
function parseWikiLink(inner, raw, isEmbed, line) {
|
|
80
|
+
const [targetPart, aliasPart] = inner.split("|");
|
|
81
|
+
const [target, ...rest] = targetPart.split("#");
|
|
82
|
+
let heading;
|
|
83
|
+
let blockId;
|
|
84
|
+
if (rest.length > 0) {
|
|
85
|
+
const remainder = rest.join("#");
|
|
86
|
+
if (remainder.startsWith("^")) {
|
|
87
|
+
blockId = remainder.slice(1);
|
|
88
|
+
} else {
|
|
89
|
+
heading = remainder;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
raw,
|
|
94
|
+
target: target.trim(),
|
|
95
|
+
alias: aliasPart?.trim(),
|
|
96
|
+
heading,
|
|
97
|
+
blockId,
|
|
98
|
+
type: isEmbed ? "embed" : "wiki",
|
|
99
|
+
line
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/core/analyzer.ts
|
|
104
|
+
import { existsSync } from "fs";
|
|
105
|
+
|
|
106
|
+
// src/core/alias-resolver.ts
|
|
107
|
+
var AliasResolver = class {
|
|
108
|
+
aliasToNote = /* @__PURE__ */ new Map();
|
|
109
|
+
fileNameToNote = /* @__PURE__ */ new Map();
|
|
110
|
+
basenameToNote = /* @__PURE__ */ new Map();
|
|
111
|
+
constructor(notes) {
|
|
112
|
+
for (const note of notes) {
|
|
113
|
+
const fileName = note.relativePath.replace(/\.(md|canvas)$/, "");
|
|
114
|
+
const basename = fileName.split("/").pop();
|
|
115
|
+
this.fileNameToNote.set(fileName.toLowerCase(), note.path);
|
|
116
|
+
this.basenameToNote.set(basename.toLowerCase(), note.path);
|
|
117
|
+
try {
|
|
118
|
+
this.fileNameToNote.set(decodeURIComponent(fileName).toLowerCase(), note.path);
|
|
119
|
+
this.basenameToNote.set(decodeURIComponent(basename).toLowerCase(), note.path);
|
|
120
|
+
} catch {
|
|
121
|
+
}
|
|
122
|
+
for (const alias of note.aliases) {
|
|
123
|
+
this.aliasToNote.set(alias.toLowerCase(), note.path);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
resolve(target) {
|
|
128
|
+
const normalized = target.toLowerCase().trim();
|
|
129
|
+
if (this.aliasToNote.has(normalized)) {
|
|
130
|
+
return this.aliasToNote.get(normalized);
|
|
131
|
+
}
|
|
132
|
+
if (this.fileNameToNote.has(normalized)) {
|
|
133
|
+
return this.fileNameToNote.get(normalized);
|
|
134
|
+
}
|
|
135
|
+
if (this.basenameToNote.has(normalized)) {
|
|
136
|
+
return this.basenameToNote.get(normalized);
|
|
137
|
+
}
|
|
138
|
+
const decoded = (() => {
|
|
139
|
+
try {
|
|
140
|
+
return decodeURIComponent(normalized);
|
|
141
|
+
} catch {
|
|
142
|
+
return normalized;
|
|
143
|
+
}
|
|
144
|
+
})();
|
|
145
|
+
if (this.aliasToNote.has(decoded)) {
|
|
146
|
+
return this.aliasToNote.get(decoded);
|
|
147
|
+
}
|
|
148
|
+
if (this.fileNameToNote.has(decoded)) {
|
|
149
|
+
return this.fileNameToNote.get(decoded);
|
|
150
|
+
}
|
|
151
|
+
if (this.basenameToNote.has(decoded)) {
|
|
152
|
+
return this.basenameToNote.get(decoded);
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// src/core/graph-builder.ts
|
|
159
|
+
function buildGraph(notes, resolver) {
|
|
160
|
+
const notePathMap = /* @__PURE__ */ new Map();
|
|
161
|
+
for (const note of notes) {
|
|
162
|
+
notePathMap.set(note.path, {
|
|
163
|
+
note,
|
|
164
|
+
incoming: [],
|
|
165
|
+
outgoing: []
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
for (const note of notes) {
|
|
169
|
+
const node = notePathMap.get(note.path);
|
|
170
|
+
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);
|
|
175
|
+
if (resolvedPath && notePathMap.has(resolvedPath)) {
|
|
176
|
+
if (!node.outgoing.includes(resolvedPath)) {
|
|
177
|
+
node.outgoing.push(resolvedPath);
|
|
178
|
+
}
|
|
179
|
+
const targetNode = notePathMap.get(resolvedPath);
|
|
180
|
+
if (!targetNode.incoming.includes(note.path)) {
|
|
181
|
+
targetNode.incoming.push(note.path);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return Array.from(notePathMap.values());
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/core/analyzer.ts
|
|
190
|
+
var ATTACHMENT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
191
|
+
"png",
|
|
192
|
+
"jpg",
|
|
193
|
+
"jpeg",
|
|
194
|
+
"gif",
|
|
195
|
+
"svg",
|
|
196
|
+
"webp",
|
|
197
|
+
"bmp",
|
|
198
|
+
"ico",
|
|
199
|
+
"pdf",
|
|
200
|
+
"doc",
|
|
201
|
+
"docx",
|
|
202
|
+
"xls",
|
|
203
|
+
"xlsx",
|
|
204
|
+
"ppt",
|
|
205
|
+
"pptx",
|
|
206
|
+
"mp3",
|
|
207
|
+
"mp4",
|
|
208
|
+
"wav",
|
|
209
|
+
"avi",
|
|
210
|
+
"mov",
|
|
211
|
+
"webm",
|
|
212
|
+
"zip",
|
|
213
|
+
"rar",
|
|
214
|
+
"7z",
|
|
215
|
+
"tar",
|
|
216
|
+
"gz",
|
|
217
|
+
"exe",
|
|
218
|
+
"dmg",
|
|
219
|
+
"apk",
|
|
220
|
+
"css",
|
|
221
|
+
"js",
|
|
222
|
+
"ts",
|
|
223
|
+
"html",
|
|
224
|
+
"txt",
|
|
225
|
+
"csv",
|
|
226
|
+
"log",
|
|
227
|
+
"woff",
|
|
228
|
+
"woff2",
|
|
229
|
+
"ttf",
|
|
230
|
+
"eot"
|
|
231
|
+
]);
|
|
232
|
+
function isAttachment(target) {
|
|
233
|
+
const ext = target.split(".").pop()?.toLowerCase();
|
|
234
|
+
return ext ? ATTACHMENT_EXTENSIONS.has(ext) : false;
|
|
235
|
+
}
|
|
236
|
+
function analyze(notes, vaultPath, checkAttachments = false) {
|
|
237
|
+
const resolver = new AliasResolver(notes);
|
|
238
|
+
const graph = buildGraph(notes, resolver);
|
|
239
|
+
const brokenLinks = [];
|
|
240
|
+
const orphanNotes = [];
|
|
241
|
+
for (const node of graph) {
|
|
242
|
+
if (node.incoming.length === 0 && node.outgoing.length === 0) {
|
|
243
|
+
orphanNotes.push(node.note);
|
|
244
|
+
}
|
|
245
|
+
for (const link of node.note.links) {
|
|
246
|
+
if (link.type === "markdown" && !link.target.endsWith(".md")) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (!checkAttachments && isAttachment(link.target)) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const resolvedPath = resolver.resolve(link.target);
|
|
253
|
+
if (resolvedPath === node.note.path) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (!resolvedPath) {
|
|
257
|
+
brokenLinks.push({
|
|
258
|
+
source: node.note.relativePath,
|
|
259
|
+
link,
|
|
260
|
+
reason: `Target "${link.target}" not found in vault`
|
|
261
|
+
});
|
|
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
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const connectedComponents = countConnectedComponents(graph);
|
|
274
|
+
const totalLinks = notes.reduce((sum, n) => sum + n.links.length, 0);
|
|
275
|
+
return {
|
|
276
|
+
brokenLinks,
|
|
277
|
+
orphanNotes,
|
|
278
|
+
graph,
|
|
279
|
+
stats: {
|
|
280
|
+
totalNotes: notes.length,
|
|
281
|
+
totalLinks,
|
|
282
|
+
brokenCount: brokenLinks.length,
|
|
283
|
+
orphanCount: orphanNotes.length,
|
|
284
|
+
connectedComponents
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function countConnectedComponents(graph) {
|
|
289
|
+
if (graph.length === 0) return 0;
|
|
290
|
+
const visited = /* @__PURE__ */ new Set();
|
|
291
|
+
let components = 0;
|
|
292
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
293
|
+
for (const node of graph) {
|
|
294
|
+
adjacency.set(node.note.path, [...node.incoming, ...node.outgoing]);
|
|
295
|
+
}
|
|
296
|
+
for (const node of graph) {
|
|
297
|
+
if (visited.has(node.note.path)) continue;
|
|
298
|
+
components++;
|
|
299
|
+
const stack = [node.note.path];
|
|
300
|
+
while (stack.length > 0) {
|
|
301
|
+
const current = stack.pop();
|
|
302
|
+
if (visited.has(current)) continue;
|
|
303
|
+
visited.add(current);
|
|
304
|
+
const neighbors = adjacency.get(current) || [];
|
|
305
|
+
for (const neighbor of neighbors) {
|
|
306
|
+
if (!visited.has(neighbor)) {
|
|
307
|
+
stack.push(neighbor);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return components;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/utils/engine.ts
|
|
316
|
+
var DeadLinksEngine = class {
|
|
317
|
+
async analyze(vaultPath, config = {}) {
|
|
318
|
+
const startTime = Date.now();
|
|
319
|
+
const notes = await loadVault(vaultPath, config.ignore);
|
|
320
|
+
for (const note of notes) {
|
|
321
|
+
note.links = parseLinks(note.content);
|
|
322
|
+
}
|
|
323
|
+
const report = analyze(notes, vaultPath, config.checkAttachments);
|
|
324
|
+
report.duration = Date.now() - startTime;
|
|
325
|
+
return report;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// src/utils/config-loader.ts
|
|
330
|
+
import { readFile as readFile2, access } from "fs/promises";
|
|
331
|
+
import { resolve as resolve2, join } from "path";
|
|
332
|
+
var CONFIG_FILES = [".deadlinksrc.json", ".deadlinksrc", "dead-links.config.json"];
|
|
333
|
+
var ConfigLoader = class {
|
|
334
|
+
async loadConfig(vaultPath) {
|
|
335
|
+
const absolutePath = resolve2(vaultPath);
|
|
336
|
+
for (const configFile of CONFIG_FILES) {
|
|
337
|
+
const configPath = join(absolutePath, configFile);
|
|
338
|
+
try {
|
|
339
|
+
await access(configPath);
|
|
340
|
+
const content = await readFile2(configPath, "utf-8");
|
|
341
|
+
const parsed = JSON.parse(content);
|
|
342
|
+
if (parsed.ignore && !Array.isArray(parsed.ignore)) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (parsed.ignoreFolders && !Array.isArray(parsed.ignoreFolders)) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
return parsed;
|
|
349
|
+
} catch {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return {};
|
|
354
|
+
}
|
|
355
|
+
mergeConfig(fileConfig, cliOptions) {
|
|
356
|
+
return {
|
|
357
|
+
ignore: cliOptions.ignore || fileConfig.ignore,
|
|
358
|
+
checkAttachments: cliOptions.attachments ?? fileConfig.checkAttachments ?? false,
|
|
359
|
+
ignoreFolders: cliOptions.ignoreFolders || fileConfig.ignoreFolders
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// src/reporters/terminal-reporter.ts
|
|
365
|
+
import chalk from "chalk";
|
|
366
|
+
var TerminalReporter = class {
|
|
367
|
+
ignoreFolders;
|
|
368
|
+
quiet;
|
|
369
|
+
constructor(ignoreFolders = [], quiet = false) {
|
|
370
|
+
this.ignoreFolders = ignoreFolders;
|
|
371
|
+
this.quiet = quiet;
|
|
372
|
+
}
|
|
373
|
+
report(result) {
|
|
374
|
+
if (this.quiet) {
|
|
375
|
+
return this.reportQuiet(result);
|
|
376
|
+
}
|
|
377
|
+
let output = "";
|
|
378
|
+
output += this.printStats(result);
|
|
379
|
+
output += this.printBrokenLinks(result.brokenLinks);
|
|
380
|
+
output += this.printOrphanNotes(result.orphanNotes);
|
|
381
|
+
output += this.printDuration(result.duration);
|
|
382
|
+
return output;
|
|
383
|
+
}
|
|
384
|
+
reportQuiet(result) {
|
|
385
|
+
const lines = [];
|
|
386
|
+
for (const broken of result.brokenLinks) {
|
|
387
|
+
lines.push(`${broken.source} -> ${broken.link.target}`);
|
|
388
|
+
}
|
|
389
|
+
for (const note of result.orphanNotes) {
|
|
390
|
+
const folder = note.relativePath.includes("/") ? note.relativePath.split("/").slice(0, -1).join("/") + "/" : "./";
|
|
391
|
+
if (this.ignoreFolders.some((f) => folder.startsWith(f + "/") || folder === f + "/")) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
lines.push(`orphan: ${note.relativePath}`);
|
|
395
|
+
}
|
|
396
|
+
return lines.join("\n");
|
|
397
|
+
}
|
|
398
|
+
printStats(report) {
|
|
399
|
+
const pad = 14;
|
|
400
|
+
let out = "\n";
|
|
401
|
+
out += this.statLine("Notes".padEnd(pad), String(report.stats.totalNotes));
|
|
402
|
+
out += this.statLine("Links".padEnd(pad), String(report.stats.totalLinks));
|
|
403
|
+
out += this.statLine("Broken links".padEnd(pad), String(report.stats.brokenCount), "red");
|
|
404
|
+
out += this.statLine("Orphan notes".padEnd(pad), String(report.stats.orphanCount), "yellow");
|
|
405
|
+
out += this.statLine("Components".padEnd(pad), String(report.stats.connectedComponents));
|
|
406
|
+
out += "\n";
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
statLine(label, value, color = "white") {
|
|
410
|
+
return ` ${chalk.gray(label)} ${color === "red" ? chalk.red.bold(value) : color === "yellow" ? chalk.yellow.bold(value) : chalk.white.bold(value)}
|
|
411
|
+
`;
|
|
412
|
+
}
|
|
413
|
+
printBrokenLinks(brokenLinks) {
|
|
414
|
+
if (brokenLinks.length === 0) {
|
|
415
|
+
return chalk.green(" \u2713 No broken links\n\n");
|
|
416
|
+
}
|
|
417
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
418
|
+
for (const broken of brokenLinks) {
|
|
419
|
+
const existing = grouped.get(broken.source) || [];
|
|
420
|
+
existing.push(broken);
|
|
421
|
+
grouped.set(broken.source, existing);
|
|
422
|
+
}
|
|
423
|
+
const sorted = [...grouped.entries()].sort((a, b) => b[1].length - a[1].length);
|
|
424
|
+
let out = chalk.bold.red(`
|
|
425
|
+
\u2716 Broken links (${brokenLinks.length})
|
|
426
|
+
|
|
427
|
+
`);
|
|
428
|
+
for (const [source, links] of sorted) {
|
|
429
|
+
const count = links.length;
|
|
430
|
+
const isMultiple = count >= 2;
|
|
431
|
+
const countStr = isMultiple ? chalk.red.bold(`\u2716 ${count}`) : chalk.yellow.bold(`\u2716 ${count}`);
|
|
432
|
+
out += ` ${countStr} ${chalk.white.bold(source)}
|
|
433
|
+
`;
|
|
434
|
+
for (const link of links) {
|
|
435
|
+
const pill2 = isMultiple ? chalk.redBright(`[${link.link.target}]`) : chalk.yellowBright(`[${link.link.target}]`);
|
|
436
|
+
out += ` ${pill2}
|
|
437
|
+
`;
|
|
438
|
+
}
|
|
439
|
+
out += "\n";
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
printOrphanNotes(orphanNotes) {
|
|
444
|
+
if (orphanNotes.length === 0) {
|
|
445
|
+
return chalk.green(" \u2713 No orphan notes\n\n");
|
|
446
|
+
}
|
|
447
|
+
const byFolder = /* @__PURE__ */ new Map();
|
|
448
|
+
for (const note of orphanNotes) {
|
|
449
|
+
const folder = note.relativePath.includes("/") ? note.relativePath.split("/").slice(0, -1).join("/") + "/" : "./";
|
|
450
|
+
if (this.ignoreFolders.some((f) => folder.startsWith(f + "/") || folder === f + "/")) {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
byFolder.set(folder, (byFolder.get(folder) || 0) + 1);
|
|
454
|
+
}
|
|
455
|
+
const sorted = [...byFolder.entries()].sort((a, b) => b[1] - a[1]);
|
|
456
|
+
if (sorted.length === 0) {
|
|
457
|
+
return chalk.green(" \u2713 No orphan notes (all in ignored folders)\n\n");
|
|
458
|
+
}
|
|
459
|
+
const maxCount = sorted[0][1];
|
|
460
|
+
const barWidth = 10;
|
|
461
|
+
let out = chalk.bold.yellow(`
|
|
462
|
+
\u26A0 Orphan notes (${orphanNotes.length} total)
|
|
463
|
+
|
|
464
|
+
`);
|
|
465
|
+
for (const [folder, count] of sorted) {
|
|
466
|
+
const filled = Math.round(count / maxCount * barWidth);
|
|
467
|
+
const empty = barWidth - filled;
|
|
468
|
+
const bar2 = chalk.gray("\u2588".repeat(filled) + "\u2591".repeat(empty));
|
|
469
|
+
out += ` ${chalk.gray(folder.padEnd(40))} ${bar2} ${chalk.white.bold(String(count))}
|
|
470
|
+
`;
|
|
471
|
+
}
|
|
472
|
+
out += "\n";
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
printDuration(ms) {
|
|
476
|
+
return chalk.dim(` Completed in ${ms}ms
|
|
477
|
+
`);
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// src/reporters/json-reporter.ts
|
|
482
|
+
var JsonReporter = class {
|
|
483
|
+
report(result) {
|
|
484
|
+
return JSON.stringify(
|
|
485
|
+
{
|
|
486
|
+
stats: result.stats,
|
|
487
|
+
brokenLinks: result.brokenLinks,
|
|
488
|
+
orphanNotes: result.orphanNotes.map((n) => ({
|
|
489
|
+
path: n.relativePath
|
|
490
|
+
})),
|
|
491
|
+
graph: result.graph.map((n) => ({
|
|
492
|
+
note: n.note.relativePath,
|
|
493
|
+
incoming: n.incoming.length,
|
|
494
|
+
outgoing: n.outgoing.length
|
|
495
|
+
})),
|
|
496
|
+
duration: result.duration
|
|
497
|
+
},
|
|
498
|
+
null,
|
|
499
|
+
2
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// src/utils/markdown-report.ts
|
|
505
|
+
function pill(target, isMultiple) {
|
|
506
|
+
return isMultiple ? `[${target}]` : `[${target}]`;
|
|
507
|
+
}
|
|
508
|
+
function bar(count, max, width) {
|
|
509
|
+
const filled = Math.round(count / max * width);
|
|
510
|
+
const empty = width - filled;
|
|
511
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(empty);
|
|
512
|
+
}
|
|
513
|
+
function generateMarkdownReport(report) {
|
|
514
|
+
const lines = [];
|
|
515
|
+
lines.push("# Dead Links Report");
|
|
516
|
+
lines.push("");
|
|
517
|
+
lines.push(`Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
518
|
+
lines.push(`Completed in ${report.duration}ms`);
|
|
519
|
+
lines.push("");
|
|
520
|
+
lines.push("## Stats");
|
|
521
|
+
lines.push("");
|
|
522
|
+
lines.push(`| Metric | Value |`);
|
|
523
|
+
lines.push(`|--------|-------|`);
|
|
524
|
+
lines.push(`| Notes | ${report.stats.totalNotes} |`);
|
|
525
|
+
lines.push(`| Links | ${report.stats.totalLinks} |`);
|
|
526
|
+
lines.push(`| Broken | ${report.stats.brokenCount} |`);
|
|
527
|
+
lines.push(`| Orphans | ${report.stats.orphanCount} |`);
|
|
528
|
+
lines.push(`| Components | ${report.stats.connectedComponents} |`);
|
|
529
|
+
lines.push("");
|
|
530
|
+
lines.push("## Broken Links");
|
|
531
|
+
lines.push("");
|
|
532
|
+
if (report.brokenLinks.length === 0) {
|
|
533
|
+
lines.push("No broken links found.");
|
|
534
|
+
} else {
|
|
535
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
536
|
+
for (const b of report.brokenLinks) {
|
|
537
|
+
const existing = grouped.get(b.source) || [];
|
|
538
|
+
existing.push(b);
|
|
539
|
+
grouped.set(b.source, existing);
|
|
540
|
+
}
|
|
541
|
+
const sorted = [...grouped.entries()].sort((a, b) => b[1].length - a[1].length);
|
|
542
|
+
for (const [source, links] of sorted) {
|
|
543
|
+
const count = links.length;
|
|
544
|
+
lines.push(`### ${count > 1 ? "\u2716" : "\u2716"} ${count} ${source}`);
|
|
545
|
+
lines.push("");
|
|
546
|
+
for (const link of links) {
|
|
547
|
+
lines.push(`- ${pill(link.link.target, count > 1)}`);
|
|
548
|
+
}
|
|
549
|
+
lines.push("");
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
lines.push("## Orphan Notes");
|
|
553
|
+
lines.push("");
|
|
554
|
+
if (report.orphanNotes.length === 0) {
|
|
555
|
+
lines.push("No orphan notes found.");
|
|
556
|
+
} else {
|
|
557
|
+
const byFolder = /* @__PURE__ */ new Map();
|
|
558
|
+
for (const note of report.orphanNotes) {
|
|
559
|
+
const folder = note.relativePath.includes("/") ? note.relativePath.split("/").slice(0, -1).join("/") + "/" : "./";
|
|
560
|
+
byFolder.set(folder, (byFolder.get(folder) || 0) + 1);
|
|
561
|
+
}
|
|
562
|
+
const sorted = [...byFolder.entries()].sort((a, b) => b[1] - a[1]);
|
|
563
|
+
const maxCount = sorted[0]?.[1] ?? 1;
|
|
564
|
+
for (const [folder, count] of sorted) {
|
|
565
|
+
lines.push(`- ${folder.padEnd(40)} ${bar(count, maxCount, 10)} ${count}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
lines.push("");
|
|
569
|
+
return lines.join("\n");
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// src/index.ts
|
|
573
|
+
var program = new Command();
|
|
574
|
+
program.name("dead-links").description("Scan Obsidian vaults for broken links, orphan notes, and connection graphs").version("0.1.0");
|
|
575
|
+
async function loadConfigAndAnalyze(vaultPath, cliOptions) {
|
|
576
|
+
const configLoader = new ConfigLoader();
|
|
577
|
+
const fileConfig = await configLoader.loadConfig(vaultPath);
|
|
578
|
+
const config = configLoader.mergeConfig(fileConfig, cliOptions);
|
|
579
|
+
const engine = new DeadLinksEngine();
|
|
580
|
+
return engine.analyze(vaultPath, config);
|
|
581
|
+
}
|
|
582
|
+
function printBrokenVerbose(brokenLinks) {
|
|
583
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
584
|
+
for (const b of brokenLinks) {
|
|
585
|
+
const existing = grouped.get(b.source) || [];
|
|
586
|
+
existing.push(b);
|
|
587
|
+
grouped.set(b.source, existing);
|
|
588
|
+
}
|
|
589
|
+
for (const [source, links] of grouped) {
|
|
590
|
+
for (const link of links) {
|
|
591
|
+
console.log(` ${source} \u2192 ${link.link.target} (line ${link.link.line})`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
function printOrphansVerbose(orphanNotes) {
|
|
596
|
+
for (const note of orphanNotes) {
|
|
597
|
+
console.log(` ${note.relativePath}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
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", "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").action(async (options) => {
|
|
601
|
+
const spinner = ora("Scanning vault...").start();
|
|
602
|
+
try {
|
|
603
|
+
const report = await loadConfigAndAnalyze(options.vault, options);
|
|
604
|
+
spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);
|
|
605
|
+
if (options.format === "json") {
|
|
606
|
+
const reporter = new JsonReporter();
|
|
607
|
+
const output = reporter.report(report);
|
|
608
|
+
if (options.output) {
|
|
609
|
+
await writeFile(options.output, output);
|
|
610
|
+
console.log(`JSON report written to ${options.output}`);
|
|
611
|
+
} else {
|
|
612
|
+
console.log(output);
|
|
613
|
+
}
|
|
614
|
+
} else if (options.format === "markdown") {
|
|
615
|
+
const output = generateMarkdownReport(report);
|
|
616
|
+
if (options.output) {
|
|
617
|
+
await writeFile(options.output, output);
|
|
618
|
+
console.log(`Markdown report written to ${options.output}`);
|
|
619
|
+
} else {
|
|
620
|
+
console.log(output);
|
|
621
|
+
}
|
|
622
|
+
} else {
|
|
623
|
+
const reporter = new TerminalReporter(options.ignoreFolders || [], options.quiet || false);
|
|
624
|
+
console.log(reporter.report(report));
|
|
625
|
+
}
|
|
626
|
+
process.exit(report.stats.brokenCount > 0 || report.stats.orphanCount > 0 ? 1 : 0);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
spinner.fail("Scan failed");
|
|
629
|
+
console.error(error instanceof Error ? error.message : error);
|
|
630
|
+
process.exit(1);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
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").action(async (options) => {
|
|
634
|
+
const spinner = ora("Scanning vault...").start();
|
|
635
|
+
try {
|
|
636
|
+
const report = await loadConfigAndAnalyze(options.vault, options);
|
|
637
|
+
spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);
|
|
638
|
+
if (report.brokenLinks.length === 0) {
|
|
639
|
+
console.log("No broken links found.");
|
|
640
|
+
} else {
|
|
641
|
+
console.log(`
|
|
642
|
+
Broken links (${report.brokenLinks.length})
|
|
643
|
+
`);
|
|
644
|
+
if (options.verbose) {
|
|
645
|
+
printBrokenVerbose(report.brokenLinks);
|
|
646
|
+
} else {
|
|
647
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
648
|
+
for (const b of report.brokenLinks) {
|
|
649
|
+
const existing = grouped.get(b.source) || [];
|
|
650
|
+
existing.push(b);
|
|
651
|
+
grouped.set(b.source, existing);
|
|
652
|
+
}
|
|
653
|
+
for (const [source, links] of grouped) {
|
|
654
|
+
const targets = links.map((l) => l.link.target).join(", ");
|
|
655
|
+
console.log(` ${source} \u2192 ${targets}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
console.log();
|
|
659
|
+
}
|
|
660
|
+
process.exit(report.stats.brokenCount > 0 ? 1 : 0);
|
|
661
|
+
} catch (error) {
|
|
662
|
+
spinner.fail("Scan failed");
|
|
663
|
+
console.error(error instanceof Error ? error.message : error);
|
|
664
|
+
process.exit(1);
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
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) => {
|
|
668
|
+
const spinner = ora("Scanning vault...").start();
|
|
669
|
+
try {
|
|
670
|
+
const report = await loadConfigAndAnalyze(options.vault, options);
|
|
671
|
+
spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);
|
|
672
|
+
if (report.orphanNotes.length === 0) {
|
|
673
|
+
console.log("No orphan notes found.");
|
|
674
|
+
} else {
|
|
675
|
+
console.log(`
|
|
676
|
+
Orphan notes (${report.orphanNotes.length})
|
|
677
|
+
`);
|
|
678
|
+
if (options.verbose) {
|
|
679
|
+
printOrphansVerbose(report.orphanNotes);
|
|
680
|
+
} else {
|
|
681
|
+
const ignoreFolders = options.ignoreFolders || [];
|
|
682
|
+
const byFolder = /* @__PURE__ */ new Map();
|
|
683
|
+
for (const note of report.orphanNotes) {
|
|
684
|
+
const folder = note.relativePath.includes("/") ? note.relativePath.split("/").slice(0, -1).join("/") + "/" : "./";
|
|
685
|
+
if (ignoreFolders.some((f) => folder.startsWith(f + "/") || folder === f + "/")) {
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
byFolder.set(folder, (byFolder.get(folder) || 0) + 1);
|
|
689
|
+
}
|
|
690
|
+
for (const [folder, count] of [...byFolder.entries()].sort((a, b) => b[1] - a[1])) {
|
|
691
|
+
console.log(` ${folder} ${count}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
console.log();
|
|
695
|
+
}
|
|
696
|
+
process.exit(report.stats.orphanCount > 0 ? 1 : 0);
|
|
697
|
+
} catch (error) {
|
|
698
|
+
spinner.fail("Scan failed");
|
|
699
|
+
console.error(error instanceof Error ? error.message : error);
|
|
700
|
+
process.exit(1);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
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) => {
|
|
704
|
+
const spinner = ora("Scanning vault...").start();
|
|
705
|
+
try {
|
|
706
|
+
const report = await loadConfigAndAnalyze(options.vault, options);
|
|
707
|
+
spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);
|
|
708
|
+
if (options.format === "json") {
|
|
709
|
+
console.log(JSON.stringify(report.graph, null, 2));
|
|
710
|
+
} else {
|
|
711
|
+
console.log(
|
|
712
|
+
`Graph: ${report.stats.totalNotes} notes, ${report.stats.totalLinks} links (${report.duration}ms)
|
|
713
|
+
`
|
|
714
|
+
);
|
|
715
|
+
const topConnected = [...report.graph].sort((a, b) => b.incoming.length - a.incoming.length).slice(0, 10).filter((n) => n.incoming.length > 0);
|
|
716
|
+
if (topConnected.length > 0) {
|
|
717
|
+
console.log("Most linked-to notes:");
|
|
718
|
+
for (const node of topConnected) {
|
|
719
|
+
console.log(
|
|
720
|
+
` ${node.note.relativePath} (${node.incoming.length} incoming, ${node.outgoing.length} outgoing)`
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
} catch (error) {
|
|
726
|
+
spinner.fail("Scan failed");
|
|
727
|
+
console.error(error instanceof Error ? error.message : error);
|
|
728
|
+
process.exit(1);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
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) => {
|
|
732
|
+
const spinner = ora("Scanning vault...").start();
|
|
733
|
+
try {
|
|
734
|
+
const report = await loadConfigAndAnalyze(options.vault, options);
|
|
735
|
+
spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);
|
|
736
|
+
const markdown = generateMarkdownReport(report);
|
|
737
|
+
await writeFile(options.output, markdown);
|
|
738
|
+
console.log(`Report written to ${options.output}`);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
spinner.fail("Scan failed");
|
|
741
|
+
console.error(error instanceof Error ? error.message : error);
|
|
742
|
+
process.exit(1);
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
program.parse();
|
|
746
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/core/vault-loader.ts","../src/core/link-parser.ts","../src/core/analyzer.ts","../src/core/alias-resolver.ts","../src/core/graph-builder.ts","../src/utils/engine.ts","../src/utils/config-loader.ts","../src/reporters/terminal-reporter.ts","../src/reporters/json-reporter.ts","../src/utils/markdown-report.ts"],"sourcesContent":["import { Command } from 'commander';\nimport { writeFile } from 'node:fs/promises';\nimport ora from 'ora';\nimport { DeadLinksEngine } from './utils/engine.js';\nimport { ConfigLoader } from './utils/config-loader.js';\nimport { TerminalReporter } from './reporters/terminal-reporter.js';\nimport { JsonReporter } from './reporters/json-reporter.js';\nimport { generateMarkdownReport } from './utils/markdown-report.js';\nimport type { BrokenLink, Note } from './types/index.js';\n\nconst program = new Command();\n\nprogram\n .name('dead-links')\n .description('Scan Obsidian vaults for broken links, orphan notes, and connection graphs')\n .version('0.1.0');\n\nasync function loadConfigAndAnalyze(\n vaultPath: string,\n cliOptions: Record<string, unknown>,\n) {\n const configLoader = new ConfigLoader();\n const fileConfig = await configLoader.loadConfig(vaultPath);\n const config = configLoader.mergeConfig(fileConfig, cliOptions);\n\n const engine = new DeadLinksEngine();\n return engine.analyze(vaultPath, config);\n}\n\nfunction printBrokenVerbose(brokenLinks: BrokenLink[]) {\n const grouped = new Map<string, BrokenLink[]>();\n for (const b of brokenLinks) {\n const existing = grouped.get(b.source) || [];\n existing.push(b);\n grouped.set(b.source, existing);\n }\n\n for (const [source, links] of grouped) {\n for (const link of links) {\n console.log(` ${source} → ${link.link.target} (line ${link.link.line})`);\n }\n }\n}\n\nfunction printOrphansVerbose(orphanNotes: Note[]) {\n for (const note of orphanNotes) {\n console.log(` ${note.relativePath}`);\n }\n}\n\nprogram\n .command('scan')\n .description('Scan the vault for all issues')\n .option('--vault <path>', 'Path to the Obsidian vault', process.cwd())\n .option('--format <type>', 'Output format: text, json, markdown', 'text')\n .option('--output <file>', 'Output file path')\n .option('--ignore <patterns...>', 'Ignore patterns (glob)')\n .option('--attachments', 'Also check embedded attachments (images, PDFs, etc.)')\n .option('--ignore-folders <folders...>', 'Exclude folders from orphan report')\n .option('--quiet', 'Only show issues, no stats or summary')\n .option('--no-color', 'Disable colored output')\n .action(async (options) => {\n const spinner = ora('Scanning vault...').start();\n\n try {\n const report = await loadConfigAndAnalyze(options.vault, options);\n spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);\n\n if (options.format === 'json') {\n const reporter = new JsonReporter();\n const output = reporter.report(report);\n if (options.output) {\n await writeFile(options.output, output);\n console.log(`JSON report written to ${options.output}`);\n } else {\n console.log(output);\n }\n } else if (options.format === 'markdown') {\n const output = generateMarkdownReport(report);\n if (options.output) {\n await writeFile(options.output, output);\n console.log(`Markdown report written to ${options.output}`);\n } else {\n console.log(output);\n }\n } else {\n const reporter = new TerminalReporter(options.ignoreFolders || [], options.quiet || false);\n console.log(reporter.report(report));\n }\n\n process.exit(report.stats.brokenCount > 0 || report.stats.orphanCount > 0 ? 1 : 0);\n } catch (error) {\n spinner.fail('Scan failed');\n console.error(error instanceof Error ? error.message : error);\n process.exit(1);\n }\n });\n\nprogram\n .command('broken')\n .description('Show only broken links')\n .option('--vault <path>', 'Path to the Obsidian vault', process.cwd())\n .option('--ignore <patterns...>', 'Ignore patterns (glob)')\n .option('--attachments', 'Also check embedded attachments (images, PDFs, etc.)')\n .option('--verbose', 'Show line numbers and full details')\n .action(async (options) => {\n const spinner = ora('Scanning vault...').start();\n\n try {\n const report = await loadConfigAndAnalyze(options.vault, options);\n spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);\n\n if (report.brokenLinks.length === 0) {\n console.log('No broken links found.');\n } else {\n console.log(`\\nBroken links (${report.brokenLinks.length})\\n`);\n if (options.verbose) {\n printBrokenVerbose(report.brokenLinks);\n } else {\n const grouped = new Map<string, BrokenLink[]>();\n for (const b of report.brokenLinks) {\n const existing = grouped.get(b.source) || [];\n existing.push(b);\n grouped.set(b.source, existing);\n }\n for (const [source, links] of grouped) {\n const targets = links.map((l: BrokenLink) => l.link.target).join(', ');\n console.log(` ${source} → ${targets}`);\n }\n }\n console.log();\n }\n\n process.exit(report.stats.brokenCount > 0 ? 1 : 0);\n } catch (error) {\n spinner.fail('Scan failed');\n console.error(error instanceof Error ? error.message : error);\n process.exit(1);\n }\n });\n\nprogram\n .command('orphans')\n .description('Show only orphan notes')\n .option('--vault <path>', 'Path to the Obsidian vault', process.cwd())\n .option('--ignore <patterns...>', 'Ignore patterns (glob)')\n .option('--ignore-folders <folders...>', 'Exclude folders from report')\n .option('--verbose', 'Show full list of orphan notes')\n .action(async (options) => {\n const spinner = ora('Scanning vault...').start();\n\n try {\n const report = await loadConfigAndAnalyze(options.vault, options);\n spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);\n\n if (report.orphanNotes.length === 0) {\n console.log('No orphan notes found.');\n } else {\n console.log(`\\nOrphan notes (${report.orphanNotes.length})\\n`);\n if (options.verbose) {\n printOrphansVerbose(report.orphanNotes);\n } else {\n const ignoreFolders = options.ignoreFolders || [];\n const byFolder = new Map<string, number>();\n for (const note of report.orphanNotes) {\n const folder = note.relativePath.includes('/')\n ? note.relativePath.split('/').slice(0, -1).join('/') + '/'\n : './';\n if (ignoreFolders.some((f: string) => folder.startsWith(f + '/') || folder === f + '/')) {\n continue;\n }\n byFolder.set(folder, (byFolder.get(folder) || 0) + 1);\n }\n for (const [folder, count] of [...byFolder.entries()].sort((a, b) => b[1] - a[1])) {\n console.log(` ${folder} ${count}`);\n }\n }\n console.log();\n }\n\n process.exit(report.stats.orphanCount > 0 ? 1 : 0);\n } catch (error) {\n spinner.fail('Scan failed');\n console.error(error instanceof Error ? error.message : error);\n process.exit(1);\n }\n });\n\nprogram\n .command('graph')\n .description('Show connection graph')\n .option('--vault <path>', 'Path to the Obsidian vault', process.cwd())\n .option('--format <type>', 'Output format: text, json', 'text')\n .option('--ignore <patterns...>', 'Ignore patterns (glob)')\n .action(async (options) => {\n const spinner = ora('Scanning vault...').start();\n\n try {\n const report = await loadConfigAndAnalyze(options.vault, options);\n spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);\n\n if (options.format === 'json') {\n console.log(JSON.stringify(report.graph, null, 2));\n } else {\n console.log(\n `Graph: ${report.stats.totalNotes} notes, ${report.stats.totalLinks} links (${report.duration}ms)\\n`,\n );\n\n const topConnected = [...report.graph]\n .sort((a, b) => b.incoming.length - a.incoming.length)\n .slice(0, 10)\n .filter((n) => n.incoming.length > 0);\n\n if (topConnected.length > 0) {\n console.log('Most linked-to notes:');\n for (const node of topConnected) {\n console.log(\n ` ${node.note.relativePath} (${node.incoming.length} incoming, ${node.outgoing.length} outgoing)`,\n );\n }\n }\n }\n } catch (error) {\n spinner.fail('Scan failed');\n console.error(error instanceof Error ? error.message : error);\n process.exit(1);\n }\n });\n\nprogram\n .command('report')\n .description('Generate a markdown report')\n .option('--vault <path>', 'Path to the Obsidian vault', process.cwd())\n .option('--output <file>', 'Output file path', 'dead-links-report.md')\n .option('--ignore <patterns...>', 'Ignore patterns (glob)')\n .action(async (options) => {\n const spinner = ora('Scanning vault...').start();\n\n try {\n const report = await loadConfigAndAnalyze(options.vault, options);\n spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);\n\n const markdown = generateMarkdownReport(report);\n await writeFile(options.output, markdown);\n console.log(`Report written to ${options.output}`);\n } catch (error) {\n spinner.fail('Scan failed');\n console.error(error instanceof Error ? error.message : error);\n process.exit(1);\n }\n });\n\nprogram.parse();","import { readFile } from 'node:fs/promises';\nimport { resolve } from 'node:path';\nimport fg from 'fast-glob';\nimport matter from 'gray-matter';\nimport type { Note } from '../types/index.js';\n\nconst DEFAULT_IGNORE = [\n '**/node_modules/**',\n '**/.git/**',\n '**/.obsidian/**',\n];\n\nexport async function loadVault(\n vaultPath: string,\n customIgnore: string[] = [],\n): Promise<Note[]> {\n const absolutePath = resolve(vaultPath);\n\n const files = await fg(['**/*.md', '**/*.canvas'], {\n cwd: absolutePath,\n ignore: [...DEFAULT_IGNORE, ...customIgnore],\n });\n\n const notes: Note[] = [];\n\n for (const file of files) {\n const fullPath = resolve(absolutePath, file);\n const content = await readFile(fullPath, 'utf-8');\n const parsed = matter(content);\n\n const aliases: string[] = [];\n if (parsed.data.aliases) {\n if (Array.isArray(parsed.data.aliases)) {\n aliases.push(...parsed.data.aliases.map(String));\n } else if (typeof parsed.data.aliases === 'string') {\n aliases.push(parsed.data.aliases);\n }\n }\n\n notes.push({\n path: fullPath,\n relativePath: file,\n content: parsed.content,\n aliases,\n links: [],\n });\n }\n\n return notes;\n}\n\nexport function resolveFileName(notePath: string): string {\n const base = notePath.replace(/\\.(md|canvas)$/, '');\n const parts = base.split('/');\n return parts[parts.length - 1]!;\n}\n","import type { Link } from '../types/index.js';\n\nconst wikiLinkRegex = /!?\\[\\[([^\\]]+)\\]\\]/g;\nconst markdownLinkRegex = /\\[([^\\]]*)\\]\\(([^)]+)\\)/g;\n\nexport function parseLinks(content: string): Link[] {\n const links: Link[] = [];\n const lines = content.split('\\n');\n\n for (let lineNum = 0; lineNum < lines.length; lineNum++) {\n const line = lines[lineNum]!;\n\n for (const match of line.matchAll(wikiLinkRegex)) {\n const raw = match[0]!;\n const inner = match[1]!;\n const isEmbed = raw.startsWith('!');\n\n const link = parseWikiLink(inner, raw, isEmbed, lineNum + 1);\n links.push(link);\n }\n\n for (const match of line.matchAll(markdownLinkRegex)) {\n const raw = match[0]!;\n const target = match[2]!;\n\n if (target.startsWith('#')) continue;\n\n const link: Link = {\n raw,\n target: target.split('#')[0]!.split('|')[0]!,\n heading: target.includes('#') ? target.split('#')[1] : undefined,\n type: 'markdown',\n line: lineNum + 1,\n };\n links.push(link);\n }\n }\n\n return links;\n}\n\nfunction parseWikiLink(inner: string, raw: string, isEmbed: boolean, line: number): Link {\n const [targetPart, aliasPart] = inner.split('|');\n const [target, ...rest] = targetPart!.split('#');\n\n let heading: string | undefined;\n let blockId: string | undefined;\n\n if (rest.length > 0) {\n const remainder = rest.join('#');\n if (remainder.startsWith('^')) {\n blockId = remainder.slice(1);\n } else {\n heading = remainder;\n }\n }\n\n return {\n raw,\n target: target!.trim(),\n alias: aliasPart?.trim(),\n heading,\n blockId,\n type: isEmbed ? 'embed' : 'wiki',\n line,\n };\n}\n","import { existsSync } from 'node:fs';\nimport { resolve } from 'node:path';\nimport type { Note, GraphNode, BrokenLink, AnalysisReport } from '../types/index.js';\nimport { AliasResolver } from './alias-resolver.js';\nimport { buildGraph } from './graph-builder.js';\n\nconst ATTACHMENT_EXTENSIONS = new Set([\n 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico',\n 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',\n 'mp3', 'mp4', 'wav', 'avi', 'mov', 'webm',\n 'zip', 'rar', '7z', 'tar', 'gz',\n 'exe', 'dmg', 'apk',\n 'css', 'js', 'ts', 'html',\n 'txt', 'csv', 'log',\n 'woff', 'woff2', 'ttf', 'eot',\n]);\n\nfunction isAttachment(target: string): boolean {\n const ext = target.split('.').pop()?.toLowerCase();\n return ext ? ATTACHMENT_EXTENSIONS.has(ext) : false;\n}\n\nexport function analyze(\n notes: Note[],\n vaultPath: string,\n checkAttachments = false,\n): AnalysisReport {\n const resolver = new AliasResolver(notes);\n const graph = buildGraph(notes, resolver);\n\n const brokenLinks: BrokenLink[] = [];\n const orphanNotes: Note[] = [];\n\n for (const node of graph) {\n if (node.incoming.length === 0 && node.outgoing.length === 0) {\n orphanNotes.push(node.note);\n }\n\n for (const link of node.note.links) {\n if (link.type === 'markdown' && !link.target.endsWith('.md')) {\n continue;\n }\n\n if (!checkAttachments && isAttachment(link.target)) {\n continue;\n }\n\n const resolvedPath = resolver.resolve(link.target);\n\n if (resolvedPath === node.note.path) {\n continue;\n }\n\n if (!resolvedPath) {\n brokenLinks.push({\n source: node.note.relativePath,\n link,\n reason: `Target \"${link.target}\" not found in vault`,\n });\n } else if (link.type === 'embed' || link.type === 'markdown') {\n if (!existsSync(resolvedPath)) {\n brokenLinks.push({\n source: node.note.relativePath,\n link,\n reason: `File \"${link.target}\" does not exist`,\n });\n }\n }\n }\n }\n\n const connectedComponents = countConnectedComponents(graph);\n const totalLinks = notes.reduce((sum, n) => sum + n.links.length, 0);\n\n return {\n brokenLinks,\n orphanNotes,\n graph,\n stats: {\n totalNotes: notes.length,\n totalLinks,\n brokenCount: brokenLinks.length,\n orphanCount: orphanNotes.length,\n connectedComponents,\n },\n };\n}\n\nfunction countConnectedComponents(graph: GraphNode[]): number {\n if (graph.length === 0) return 0;\n\n const visited = new Set<string>();\n let components = 0;\n\n const adjacency = new Map<string, string[]>();\n for (const node of graph) {\n adjacency.set(node.note.path, [...node.incoming, ...node.outgoing]);\n }\n\n for (const node of graph) {\n if (visited.has(node.note.path)) continue;\n\n components++;\n const stack = [node.note.path];\n\n while (stack.length > 0) {\n const current = stack.pop()!;\n if (visited.has(current)) continue;\n visited.add(current);\n\n const neighbors = adjacency.get(current) || [];\n for (const neighbor of neighbors) {\n if (!visited.has(neighbor)) {\n stack.push(neighbor);\n }\n }\n }\n }\n\n return components;\n}\n","import type { Note } from '../types/index.js';\n\nexport class AliasResolver {\n private aliasToNote: Map<string, string> = new Map();\n private fileNameToNote: Map<string, string> = new Map();\n private basenameToNote: Map<string, string> = new Map();\n\n constructor(notes: Note[]) {\n for (const note of notes) {\n const fileName = note.relativePath.replace(/\\.(md|canvas)$/, '');\n const basename = fileName.split('/').pop()!;\n\n this.fileNameToNote.set(fileName.toLowerCase(), note.path);\n this.basenameToNote.set(basename.toLowerCase(), note.path);\n\n try {\n this.fileNameToNote.set(decodeURIComponent(fileName).toLowerCase(), note.path);\n this.basenameToNote.set(decodeURIComponent(basename).toLowerCase(), note.path);\n } catch {\n // skip malformed URIs\n }\n\n for (const alias of note.aliases) {\n this.aliasToNote.set(alias.toLowerCase(), note.path);\n }\n }\n }\n\n resolve(target: string): string | null {\n const normalized = target.toLowerCase().trim();\n\n if (this.aliasToNote.has(normalized)) {\n return this.aliasToNote.get(normalized)!;\n }\n\n if (this.fileNameToNote.has(normalized)) {\n return this.fileNameToNote.get(normalized)!;\n }\n\n if (this.basenameToNote.has(normalized)) {\n return this.basenameToNote.get(normalized)!;\n }\n\n const decoded = (() => {\n try {\n return decodeURIComponent(normalized);\n } catch {\n return normalized;\n }\n })();\n\n if (this.aliasToNote.has(decoded)) {\n return this.aliasToNote.get(decoded)!;\n }\n if (this.fileNameToNote.has(decoded)) {\n return this.fileNameToNote.get(decoded)!;\n }\n if (this.basenameToNote.has(decoded)) {\n return this.basenameToNote.get(decoded)!;\n }\n\n return null;\n }\n}\n","import type { Note, GraphNode, Link } from '../types/index.js';\nimport { AliasResolver } from './alias-resolver.js';\n\nexport function buildGraph(notes: Note[], resolver: AliasResolver): GraphNode[] {\n const notePathMap = new Map<string, GraphNode>();\n\n for (const note of notes) {\n notePathMap.set(note.path, {\n note,\n incoming: [],\n outgoing: [],\n });\n }\n\n for (const note of notes) {\n const node = notePathMap.get(note.path)!;\n\n for (const link of note.links) {\n if (link.type === 'markdown' && !link.target.endsWith('.md')) {\n continue;\n }\n\n const resolvedPath = resolver.resolve(link.target);\n\n if (resolvedPath && notePathMap.has(resolvedPath)) {\n if (!node.outgoing.includes(resolvedPath)) {\n node.outgoing.push(resolvedPath);\n }\n\n const targetNode = notePathMap.get(resolvedPath)!;\n if (!targetNode.incoming.includes(note.path)) {\n targetNode.incoming.push(note.path);\n }\n }\n }\n }\n\n return Array.from(notePathMap.values());\n}\n","import { loadVault } from '../core/vault-loader.js';\nimport { parseLinks } from '../core/link-parser.js';\nimport { analyze } from '../core/analyzer.js';\nimport type { AnalysisReport, DeadLinksConfig } from '../types/index.js';\n\nexport class DeadLinksEngine {\n async analyze(\n vaultPath: string,\n config: DeadLinksConfig = {},\n ): Promise<AnalysisReport> {\n const startTime = Date.now();\n\n const notes = await loadVault(vaultPath, config.ignore);\n\n for (const note of notes) {\n note.links = parseLinks(note.content);\n }\n\n const report = analyze(notes, vaultPath, config.checkAttachments);\n report.duration = Date.now() - startTime;\n\n return report;\n }\n}\n","import { readFile, access } from 'node:fs/promises';\nimport { resolve, join } from 'node:path';\nimport type { DeadLinksConfig } from '../types/index.js';\n\nconst CONFIG_FILES = ['.deadlinksrc.json', '.deadlinksrc', 'dead-links.config.json'];\n\nexport class ConfigLoader {\n async loadConfig(vaultPath: string): Promise<DeadLinksConfig> {\n const absolutePath = resolve(vaultPath);\n\n for (const configFile of CONFIG_FILES) {\n const configPath = join(absolutePath, configFile);\n\n try {\n await access(configPath);\n const content = await readFile(configPath, 'utf-8');\n const parsed = JSON.parse(content) as DeadLinksConfig;\n\n if (parsed.ignore && !Array.isArray(parsed.ignore)) {\n continue;\n }\n if (parsed.ignoreFolders && !Array.isArray(parsed.ignoreFolders)) {\n continue;\n }\n\n return parsed;\n } catch {\n continue;\n }\n }\n\n return {};\n }\n\n mergeConfig(\n fileConfig: DeadLinksConfig,\n cliOptions: Record<string, unknown>,\n ): DeadLinksConfig {\n return {\n ignore: (cliOptions.ignore as string[] | undefined) || fileConfig.ignore,\n checkAttachments:\n (cliOptions.attachments as boolean | undefined) ??\n fileConfig.checkAttachments ??\n false,\n ignoreFolders:\n (cliOptions.ignoreFolders as string[] | undefined) ||\n fileConfig.ignoreFolders,\n };\n }\n}\n","import chalk from 'chalk';\nimport type {\n AnalysisReport,\n BrokenLink,\n Note,\n} from '../types/index.js';\n\nexport class TerminalReporter {\n private ignoreFolders: string[];\n private quiet: boolean;\n\n constructor(ignoreFolders: string[] = [], quiet = false) {\n this.ignoreFolders = ignoreFolders;\n this.quiet = quiet;\n }\n\n report(result: AnalysisReport): string {\n if (this.quiet) {\n return this.reportQuiet(result);\n }\n\n let output = '';\n output += this.printStats(result);\n output += this.printBrokenLinks(result.brokenLinks);\n output += this.printOrphanNotes(result.orphanNotes);\n output += this.printDuration(result.duration);\n return output;\n }\n\n private reportQuiet(result: AnalysisReport): string {\n const lines: string[] = [];\n\n for (const broken of result.brokenLinks) {\n lines.push(`${broken.source} -> ${broken.link.target}`);\n }\n\n for (const note of result.orphanNotes) {\n const folder = note.relativePath.includes('/')\n ? note.relativePath.split('/').slice(0, -1).join('/') + '/'\n : './';\n if (this.ignoreFolders.some((f) => folder.startsWith(f + '/') || folder === f + '/')) {\n continue;\n }\n lines.push(`orphan: ${note.relativePath}`);\n }\n\n return lines.join('\\n');\n }\n\n private printStats(report: AnalysisReport): string {\n const pad = 14;\n let out = '\\n';\n out += this.statLine('Notes'.padEnd(pad), String(report.stats.totalNotes));\n out += this.statLine('Links'.padEnd(pad), String(report.stats.totalLinks));\n out += this.statLine('Broken links'.padEnd(pad), String(report.stats.brokenCount), 'red');\n out += this.statLine('Orphan notes'.padEnd(pad), String(report.stats.orphanCount), 'yellow');\n out += this.statLine('Components'.padEnd(pad), String(report.stats.connectedComponents));\n out += '\\n';\n return out;\n }\n\n private statLine(label: string, value: string, color: 'red' | 'yellow' | 'white' = 'white'): string {\n return ` ${chalk.gray(label)} ${color === 'red' ? chalk.red.bold(value) : color === 'yellow' ? chalk.yellow.bold(value) : chalk.white.bold(value)}\\n`;\n }\n\n private printBrokenLinks(brokenLinks: BrokenLink[]): string {\n if (brokenLinks.length === 0) {\n return chalk.green(' ✓ No broken links\\n\\n');\n }\n\n const grouped = new Map<string, BrokenLink[]>();\n for (const broken of brokenLinks) {\n const existing = grouped.get(broken.source) || [];\n existing.push(broken);\n grouped.set(broken.source, existing);\n }\n\n const sorted = [...grouped.entries()].sort((a, b) => b[1].length - a[1].length);\n\n let out = chalk.bold.red(`\\n✖ Broken links (${brokenLinks.length})\\n\\n`);\n\n for (const [source, links] of sorted) {\n const count = links.length;\n const isMultiple = count >= 2;\n\n const countStr = isMultiple ? chalk.red.bold(`✖ ${count}`) : chalk.yellow.bold(`✖ ${count}`);\n out += ` ${countStr} ${chalk.white.bold(source)}\\n`;\n\n for (const link of links) {\n const pill = isMultiple\n ? chalk.redBright(`[${link.link.target}]`)\n : chalk.yellowBright(`[${link.link.target}]`);\n out += ` ${pill}\\n`;\n }\n out += '\\n';\n }\n\n return out;\n }\n\n private printOrphanNotes(orphanNotes: Note[]): string {\n if (orphanNotes.length === 0) {\n return chalk.green(' ✓ No orphan notes\\n\\n');\n }\n\n const byFolder = new Map<string, number>();\n for (const note of orphanNotes) {\n const folder = note.relativePath.includes('/')\n ? note.relativePath.split('/').slice(0, -1).join('/') + '/'\n : './';\n\n if (this.ignoreFolders.some((f) => folder.startsWith(f + '/') || folder === f + '/')) {\n continue;\n }\n\n byFolder.set(folder, (byFolder.get(folder) || 0) + 1);\n }\n\n const sorted = [...byFolder.entries()].sort((a, b) => b[1] - a[1]);\n\n if (sorted.length === 0) {\n return chalk.green(' ✓ No orphan notes (all in ignored folders)\\n\\n');\n }\n\n const maxCount = sorted[0]![1];\n const barWidth = 10;\n\n let out = chalk.bold.yellow(`\\n⚠ Orphan notes (${orphanNotes.length} total)\\n\\n`);\n\n for (const [folder, count] of sorted) {\n const filled = Math.round((count / maxCount) * barWidth);\n const empty = barWidth - filled;\n const bar = chalk.gray('█'.repeat(filled) + '░'.repeat(empty));\n out += ` ${chalk.gray(folder.padEnd(40))} ${bar} ${chalk.white.bold(String(count))}\\n`;\n }\n\n out += '\\n';\n return out;\n }\n\n private printDuration(ms: number): string {\n return chalk.dim(` Completed in ${ms}ms\\n`);\n }\n}\n","import type { AnalysisReport } from '../types/index.js';\n\nexport class JsonReporter {\n report(result: AnalysisReport): string {\n return JSON.stringify(\n {\n stats: result.stats,\n brokenLinks: result.brokenLinks,\n orphanNotes: result.orphanNotes.map((n) => ({\n path: n.relativePath,\n })),\n graph: result.graph.map((n) => ({\n note: n.note.relativePath,\n incoming: n.incoming.length,\n outgoing: n.outgoing.length,\n })),\n duration: result.duration,\n },\n null,\n 2,\n );\n }\n}\n","import type { AnalysisReport } from '../types/index.js';\n\nfunction pill(target: string, isMultiple: boolean): string {\n return isMultiple ? `[${target}]` : `[${target}]`;\n}\n\nfunction bar(count: number, max: number, width: number): string {\n const filled = Math.round((count / max) * width);\n const empty = width - filled;\n return '█'.repeat(filled) + '░'.repeat(empty);\n}\n\nexport function generateMarkdownReport(report: AnalysisReport): string {\n const lines: string[] = [];\n\n lines.push('# Dead Links Report');\n lines.push('');\n lines.push(`Generated: ${new Date().toISOString()}`);\n lines.push(`Completed in ${report.duration}ms`);\n lines.push('');\n\n lines.push('## Stats');\n lines.push('');\n lines.push(`| Metric | Value |`);\n lines.push(`|--------|-------|`);\n lines.push(`| Notes | ${report.stats.totalNotes} |`);\n lines.push(`| Links | ${report.stats.totalLinks} |`);\n lines.push(`| Broken | ${report.stats.brokenCount} |`);\n lines.push(`| Orphans | ${report.stats.orphanCount} |`);\n lines.push(`| Components | ${report.stats.connectedComponents} |`);\n lines.push('');\n\n lines.push('## Broken Links');\n lines.push('');\n\n if (report.brokenLinks.length === 0) {\n lines.push('No broken links found.');\n } else {\n const grouped = new Map<string, typeof report.brokenLinks>();\n for (const b of report.brokenLinks) {\n const existing = grouped.get(b.source) || [];\n existing.push(b);\n grouped.set(b.source, existing);\n }\n\n const sorted = [...grouped.entries()].sort((a, b) => b[1].length - a[1].length);\n\n for (const [source, links] of sorted) {\n const count = links.length;\n lines.push(`### ${count > 1 ? '✖' : '✖'} ${count} ${source}`);\n lines.push('');\n for (const link of links) {\n lines.push(`- ${pill(link.link.target, count > 1)}`);\n }\n lines.push('');\n }\n }\n\n lines.push('## Orphan Notes');\n lines.push('');\n\n if (report.orphanNotes.length === 0) {\n lines.push('No orphan notes found.');\n } else {\n const byFolder = new Map<string, number>();\n for (const note of report.orphanNotes) {\n const folder = note.relativePath.includes('/')\n ? note.relativePath.split('/').slice(0, -1).join('/') + '/'\n : './';\n byFolder.set(folder, (byFolder.get(folder) || 0) + 1);\n }\n\n const sorted = [...byFolder.entries()].sort((a, b) => b[1] - a[1]);\n const maxCount = sorted[0]?.[1] ?? 1;\n\n for (const [folder, count] of sorted) {\n lines.push(`- ${folder.padEnd(40)} ${bar(count, maxCount, 10)} ${count}`);\n }\n }\n\n lines.push('');\n\n return lines.join('\\n');\n}\n"],"mappings":"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","names":["readFile","resolve","pill","bar"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dead-links",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI tool to scan Obsidian vaults for broken links, orphan notes, and connection graphs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"dead-links": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsup",
|
|
14
|
+
"dev": "tsup --watch",
|
|
15
|
+
"test": "vitest",
|
|
16
|
+
"lint": "eslint .",
|
|
17
|
+
"format": "prettier --write ."
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"obsidian",
|
|
21
|
+
"vault",
|
|
22
|
+
"broken-links",
|
|
23
|
+
"cli",
|
|
24
|
+
"markdown"
|
|
25
|
+
],
|
|
26
|
+
"author": {
|
|
27
|
+
"name": "Luis Angel Martinez",
|
|
28
|
+
"email": "offluisangel@gmail.com"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/offluisangel/dead-links.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/offluisangel/dead-links#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/offluisangel/dead-links/issues"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20.0.0"
|
|
41
|
+
},
|
|
42
|
+
"packageManager": "pnpm@11.0.8",
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"ora": "^9.4.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@eslint/js": "^10.0.1",
|
|
48
|
+
"@types/node": "^25.8.0",
|
|
49
|
+
"chalk": "^5.6.2",
|
|
50
|
+
"commander": "^14.0.3",
|
|
51
|
+
"eslint": "^10.4.0",
|
|
52
|
+
"eslint-config-prettier": "^10.1.8",
|
|
53
|
+
"fast-glob": "^3.3.3",
|
|
54
|
+
"globals": "^17.6.0",
|
|
55
|
+
"gray-matter": "^4.0.3",
|
|
56
|
+
"prettier": "^3.8.3",
|
|
57
|
+
"tsup": "^8.5.1",
|
|
58
|
+
"typescript": "^6.0.3",
|
|
59
|
+
"vitest": "^4.1.6"
|
|
60
|
+
}
|
|
61
|
+
}
|