-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlink-guard.plugin.ts
More file actions
138 lines (124 loc) · 4.72 KB
/
Copy pathlink-guard.plugin.ts
File metadata and controls
138 lines (124 loc) · 4.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
import {readFileSync, statSync} from 'node:fs';
import {join, relative} from 'node:path';
import type {Plugin} from 'vite';
import {routeFromPagePath, slugify, walkContentFiles, walkPageFiles} from './plugin-utils';
/**
* Build-time guard that errors on broken internal links inside markdown files.
*
* Validates three cases:
* - `[text](#fragment)` — fragment must be a real heading slug in the same file
* - `[text](/path)` — `/path` must be a known route
* - `[text](/path#fragment)` — both the route and the heading slug must exist
*
* Routes are discovered by walking `src/content/**\/*.md` (each markdown
* file's path under content/ becomes its route) and `src/app/pages/**\/*.page.ts`.
* External (`http(s)://`), mail (`mailto:`), and relative (`./foo`) links are
* skipped; the existing externalLinkGuard covers raw HTML external anchors.
*
* Heading slugs are computed with the same algorithm the rendered TOC uses
* (see `plugin-utils.slugify`), so dev-time and runtime stay in sync.
*/
function extractHeadings(markdown: string): Set<string> {
const slugs = new Set<string>();
const headingRe = /^#{1,6}\s+(.+?)\s*$/gm;
let m;
while ((m = headingRe.exec(markdown)) !== null) {
slugs.add(slugify(m[1]));
}
return slugs;
}
export function internalLinkGuard(): Plugin {
let root = process.cwd();
// route → headings, populated lazily on first transform() call
const headingsByRoute = new Map<string, Set<string>>();
// route → source file (relative path)
const routes = new Map<string, string>();
let primed = false;
function prime(): void {
if (primed) return;
primed = true;
// .md → route (walk src/content/ tree)
const contentDir = join(root, 'src/content');
try {
statSync(contentDir);
for (const [rel, route] of walkContentFiles(contentDir, root)) {
const full = join(root, rel);
routes.set(route, rel);
headingsByRoute.set(route, extractHeadings(readFileSync(full, 'utf8')));
}
} catch {
// src/content missing — skip
}
// .page.ts → route (no heading scrape; just makes the route resolvable)
const pagesDir = join(root, 'src/app/pages');
try {
const pageFiles = walkPageFiles(pagesDir, root);
for (const rel of pageFiles) {
const route = routeFromPagePath(rel);
if (!route) continue;
if (!routes.has(route)) routes.set(route, rel);
}
} catch {
// src/app/pages missing — fine for non-app projects
}
}
return {
name: 'ngmd-internal-link-guard',
enforce: 'pre',
configResolved(cfg) {
root = cfg.root;
},
transform(_code, id) {
// Vite may append `?import` / `?raw` query suffixes
const cleanId = id.split('?')[0];
if (!cleanId.endsWith('.md')) return null;
prime();
const file = cleanId;
const content = readFileSync(file, 'utf8');
const ownSlugs = extractHeadings(content);
const issues: string[] = [];
const validate = (href: string, label: string) => {
if (!href) return;
// external / mail / relative — skip
if (/^(https?:|mailto:|tel:|#)/.test(href) === false && !href.startsWith('/')) return;
if (/^(https?:|mailto:|tel:)/.test(href)) return;
const [path, fragment] = href.split('#');
if (path === '') {
// in-page fragment: must exist in this file
if (fragment && !ownSlugs.has(fragment)) {
issues.push(` ${label} → "#${fragment}" has no matching heading in this file`);
}
return;
}
// absolute route: must be a known route
if (!routes.has(path)) {
issues.push(` ${label} → "${path}" is not a known route`);
return;
}
if (fragment) {
const targetSlugs = headingsByRoute.get(path);
if (targetSlugs && !targetSlugs.has(fragment)) {
issues.push(` ${label} → "${path}#${fragment}" — fragment not found in target page`);
}
// if targetSlugs is undefined (e.g. .page.ts route), skip fragment check
}
};
const mdLinkRe = /\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
const htmlAnchorRe = /<a\s[^>]*href=["']([^"']+)["']/g;
let m: RegExpExecArray | null;
while ((m = mdLinkRe.exec(content)) !== null) {
validate(m[2], `[${m[1]}](${m[2]})`);
}
while ((m = htmlAnchorRe.exec(content)) !== null) {
validate(m[1], `<a href="${m[1]}">`);
}
if (issues.length > 0) {
this.error(
`[ngmd] Broken internal links in ${relative(root, file)}:\n${issues.join('\n')}\n` +
`Fix the link target, or update the heading slug it points to.`,
);
}
return null;
},
};
}