astro-smart-links 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/README.zh-CN.md +7 -0
- package/dist/{chunk-67PNI3F6.js → chunk-JNP2MFPB.js} +36 -24
- package/dist/cli.cjs +417 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.js +4 -3
- package/dist/index.cjs +836 -0
- package/dist/index.d.cts +261 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +156 -90
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -12,6 +12,11 @@ No routes file, no second build. The integration validates links against the rea
|
|
|
12
12
|
|
|
13
13
|
> Migrating from `rehype-smart-links@0.x`? See [Migration](#migration-from-rehype-smart-links).
|
|
14
14
|
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Node.js 18.17 or newer.
|
|
18
|
+
- Astro 4 or newer. Both Astro 7's default Sätteri Markdown processor and the unified processor from `@astrojs/markdown-remark` are supported.
|
|
19
|
+
|
|
15
20
|
## Installation
|
|
16
21
|
|
|
17
22
|
```bash
|
|
@@ -113,6 +118,7 @@ npx astro-smart-links check --all --extensions html pdf zip
|
|
|
113
118
|
| `--json` | Print the report to stdout |
|
|
114
119
|
| `-a, --all` | Treat every file type as a valid route |
|
|
115
120
|
| `-e, --extensions <ext...>` | File extensions to include (default `html`) |
|
|
121
|
+
| `--skip-classes <class...>` | Skip links inside elements with these classes |
|
|
116
122
|
| `--fail-on-broken` | Exit with code 1 when broken links are found |
|
|
117
123
|
| `-q, --quiet` | Only print the summary |
|
|
118
124
|
|
|
@@ -128,6 +134,7 @@ npx astro-smart-links check --all --extensions html pdf zip
|
|
|
128
134
|
| `target` | `string \| null` | `'_blank'` | `target` attribute for external links |
|
|
129
135
|
| `rel` | `string \| null` | `'noopener noreferrer'` | `rel` attribute for external links |
|
|
130
136
|
| `ignore` | `(string \| RegExp)[]` | `[]` | Hrefs that are never processed (prefix match or RegExp) |
|
|
137
|
+
| `skipClasses` | `string[]` | `[]` | Skip links inside elements with these classes (e.g. `['not-content']`) |
|
|
131
138
|
| `routes` | `string[]` | — | Explicit route list for broken-link detection |
|
|
132
139
|
| `routesFile` | `string` | — | JSON file with a route list |
|
|
133
140
|
| `publicDir` | `string` | — | Directory to scan for routes |
|
package/README.zh-CN.md
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
> 从 `rehype-smart-links@0.x` 迁移?见 [迁移指南](#从-rehype-smart-links-迁移)。
|
|
14
14
|
|
|
15
|
+
## 环境要求
|
|
16
|
+
|
|
17
|
+
- Node.js 18.17 或更高版本。
|
|
18
|
+
- Astro 4 或更高版本。同时支持 Astro 7 默认的 Sätteri Markdown 处理器和 `@astrojs/markdown-remark` 的 unified 处理器。
|
|
19
|
+
|
|
15
20
|
## 安装
|
|
16
21
|
|
|
17
22
|
```bash
|
|
@@ -113,6 +118,7 @@ npx astro-smart-links check --all --extensions html pdf zip
|
|
|
113
118
|
| `--json` | 以 JSON 输出到 stdout |
|
|
114
119
|
| `-a, --all` | 将所有文件类型视为有效路由 |
|
|
115
120
|
| `-e, --extensions <ext...>` | 要包含的文件扩展名(默认 `html`) |
|
|
121
|
+
| `--skip-classes <class...>` | 跳过位于这些类名元素内的链接 |
|
|
116
122
|
| `--fail-on-broken` | 发现断链时以退出码 1 结束 |
|
|
117
123
|
| `-q, --quiet` | 只输出摘要 |
|
|
118
124
|
|
|
@@ -128,6 +134,7 @@ npx astro-smart-links check --all --extensions html pdf zip
|
|
|
128
134
|
| `target` | `string \| null` | `'_blank'` | 外部链接的 `target` |
|
|
129
135
|
| `rel` | `string \| null` | `'noopener noreferrer'` | 外部链接的 `rel` |
|
|
130
136
|
| `ignore` | `(string \| RegExp)[]` | `[]` | 不处理的链接(前缀匹配或正则) |
|
|
137
|
+
| `skipClasses` | `string[]` | `[]` | 跳过位于这些类名元素内的链接(如 `['not-content']`) |
|
|
131
138
|
| `routes` | `string[]` | — | 显式路由列表 |
|
|
132
139
|
| `routesFile` | `string` | — | 包含路由列表的 JSON 文件 |
|
|
133
140
|
| `publicDir` | `string` | — | 扫描路由的目录 |
|
|
@@ -188,7 +188,6 @@ import { join as join2, relative } from "path";
|
|
|
188
188
|
import rehypeParse from "rehype-parse";
|
|
189
189
|
import rehypeStringify from "rehype-stringify";
|
|
190
190
|
import { unified } from "unified";
|
|
191
|
-
import { visit } from "unist-util-visit";
|
|
192
191
|
function collectHtmlFiles(directory) {
|
|
193
192
|
const files = [];
|
|
194
193
|
const walk = (currentDir) => {
|
|
@@ -209,8 +208,14 @@ function routeFromHtmlFile(distDir, htmlFile) {
|
|
|
209
208
|
relativePath = relativePath.replace(/(?:^|\/)index$/i, "");
|
|
210
209
|
return normalizeRoute(relativePath);
|
|
211
210
|
}
|
|
211
|
+
function hasAnyClass(node, classes) {
|
|
212
|
+
const current = node.properties?.className;
|
|
213
|
+
const list = Array.isArray(current) ? current.map(String) : typeof current === "string" ? current.split(/\s+/) : [];
|
|
214
|
+
return classes.some((className) => list.includes(className));
|
|
215
|
+
}
|
|
212
216
|
function checkDirectory(directory, options = {}) {
|
|
213
217
|
const routes = options.routes ?? scanRoutes(directory, options);
|
|
218
|
+
const skipClasses = options.skipClasses ?? [];
|
|
214
219
|
const broken = [];
|
|
215
220
|
let checkedLinks = 0;
|
|
216
221
|
for (const htmlFile of collectHtmlFiles(directory)) {
|
|
@@ -221,30 +226,37 @@ function checkDirectory(directory, options = {}) {
|
|
|
221
226
|
const tree = processor.parse(source);
|
|
222
227
|
const pagePath = routeFromHtmlFile(directory, htmlFile);
|
|
223
228
|
let changed = false;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
229
|
+
const walk = (parent, insideSkipped) => {
|
|
230
|
+
for (const child of parent.children) {
|
|
231
|
+
if (child.type !== "element")
|
|
232
|
+
continue;
|
|
233
|
+
const skipped = insideSkipped || skipClasses.length > 0 && hasAnyClass(child, skipClasses);
|
|
234
|
+
if (child.tagName === "a" && !skipped && child.properties) {
|
|
235
|
+
const href = child.properties.href;
|
|
236
|
+
if (typeof href === "string" && href.length > 0) {
|
|
237
|
+
const classified = classifyHref(href, {
|
|
238
|
+
base: options.base,
|
|
239
|
+
routes,
|
|
240
|
+
ignore: options.ignore,
|
|
241
|
+
pagePath
|
|
242
|
+
});
|
|
243
|
+
if (classified.type !== "ignored") {
|
|
244
|
+
checkedLinks++;
|
|
245
|
+
if (classified.type === "broken") {
|
|
246
|
+
broken.push({ href, pathname: classified.pathname ?? href, sourceFile: htmlFile });
|
|
247
|
+
if (options.rewrite) {
|
|
248
|
+
removeClass(child, options.internalLinkClass);
|
|
249
|
+
addClass(child, options.brokenLinkClass);
|
|
250
|
+
changed = true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
walk(child, skipped);
|
|
246
257
|
}
|
|
247
|
-
}
|
|
258
|
+
};
|
|
259
|
+
walk(tree, false);
|
|
248
260
|
if (changed)
|
|
249
261
|
writeFileSync2(htmlFile, processor.stringify(tree));
|
|
250
262
|
}
|
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/cli.ts
|
|
27
|
+
var import_node_fs3 = require("fs");
|
|
28
|
+
var import_node_path3 = require("path");
|
|
29
|
+
var import_node_process = __toESM(require("process"), 1);
|
|
30
|
+
var import_commander = require("commander");
|
|
31
|
+
|
|
32
|
+
// src/core/check.ts
|
|
33
|
+
var import_node_fs2 = require("fs");
|
|
34
|
+
var import_node_path2 = require("path");
|
|
35
|
+
var import_rehype_parse = __toESM(require("rehype-parse"), 1);
|
|
36
|
+
var import_rehype_stringify = __toESM(require("rehype-stringify"), 1);
|
|
37
|
+
var import_unified = require("unified");
|
|
38
|
+
|
|
39
|
+
// src/core/normalize.ts
|
|
40
|
+
var SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
41
|
+
function getScheme(href) {
|
|
42
|
+
const match = SCHEME_RE.exec(href.trim());
|
|
43
|
+
return match ? match[0].slice(0, -1).toLowerCase() : void 0;
|
|
44
|
+
}
|
|
45
|
+
function isExternalHref(href) {
|
|
46
|
+
const trimmed = href.trim();
|
|
47
|
+
if (trimmed.startsWith("//"))
|
|
48
|
+
return true;
|
|
49
|
+
const scheme = getScheme(trimmed);
|
|
50
|
+
return scheme === "http" || scheme === "https";
|
|
51
|
+
}
|
|
52
|
+
function isIgnoredHref(href) {
|
|
53
|
+
const trimmed = href.trim();
|
|
54
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
55
|
+
return true;
|
|
56
|
+
const scheme = getScheme(trimmed);
|
|
57
|
+
return Boolean(scheme) && scheme !== "http" && scheme !== "https";
|
|
58
|
+
}
|
|
59
|
+
function normalizeBase(base) {
|
|
60
|
+
if (!base)
|
|
61
|
+
return "";
|
|
62
|
+
let normalized = base.trim();
|
|
63
|
+
if (!normalized.startsWith("/"))
|
|
64
|
+
normalized = `/${normalized}`;
|
|
65
|
+
normalized = normalized.replace(/\/+$/, "");
|
|
66
|
+
return normalized === "/" ? "" : normalized;
|
|
67
|
+
}
|
|
68
|
+
function normalizeRoute(input, base) {
|
|
69
|
+
let path = (input ?? "").trim();
|
|
70
|
+
path = path.split(/[?#]/, 1)[0] ?? "";
|
|
71
|
+
try {
|
|
72
|
+
path = decodeURI(path);
|
|
73
|
+
} catch {
|
|
74
|
+
}
|
|
75
|
+
const normalizedBase = normalizeBase(base);
|
|
76
|
+
if (normalizedBase) {
|
|
77
|
+
if (path === normalizedBase)
|
|
78
|
+
path = "/";
|
|
79
|
+
else if (path.startsWith(`${normalizedBase}/`))
|
|
80
|
+
path = path.slice(normalizedBase.length);
|
|
81
|
+
}
|
|
82
|
+
if (!path.startsWith("/"))
|
|
83
|
+
path = `/${path}`;
|
|
84
|
+
path = path.replace(/\/{2,}/g, "/");
|
|
85
|
+
path = path.replace(/\/index(?:\.html)?$/i, "/");
|
|
86
|
+
if (path.length > 1)
|
|
87
|
+
path = path.replace(/\/+$/, "");
|
|
88
|
+
return path || "/";
|
|
89
|
+
}
|
|
90
|
+
function resolveInternalHref(href, pagePath) {
|
|
91
|
+
const trimmed = href.trim();
|
|
92
|
+
if (trimmed.startsWith("/"))
|
|
93
|
+
return normalizeRoute(trimmed);
|
|
94
|
+
const page = pagePath && pagePath !== "/" ? pagePath : "/index";
|
|
95
|
+
try {
|
|
96
|
+
const url = new URL(trimmed, `https://smart-links.local${page}`);
|
|
97
|
+
return normalizeRoute(url.pathname);
|
|
98
|
+
} catch {
|
|
99
|
+
return normalizeRoute(trimmed);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function matchesIgnoreList(href, pathname, ignore) {
|
|
103
|
+
for (const pattern of ignore) {
|
|
104
|
+
if (typeof pattern === "string") {
|
|
105
|
+
if (href === pattern || href.startsWith(pattern) || pathname === pattern || pathname?.startsWith(pattern))
|
|
106
|
+
return true;
|
|
107
|
+
} else if (pattern.test(href) || (pathname ? pattern.test(pathname) : false)) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/core/classify.ts
|
|
115
|
+
function classifyHref(href, context = {}) {
|
|
116
|
+
if (isIgnoredHref(href))
|
|
117
|
+
return { type: "ignored", href };
|
|
118
|
+
if (isExternalHref(href))
|
|
119
|
+
return { type: "external", href };
|
|
120
|
+
const pathname = normalizeRoute(resolveInternalHref(href, context.pagePath), context.base);
|
|
121
|
+
if (matchesIgnoreList(href, pathname, context.ignore ?? []))
|
|
122
|
+
return { type: "ignored", href, pathname };
|
|
123
|
+
if (pathname === "/")
|
|
124
|
+
return { type: "internal", href, pathname };
|
|
125
|
+
if (context.routes && !context.routes.has(pathname))
|
|
126
|
+
return { type: "broken", href, pathname };
|
|
127
|
+
return { type: "internal", href, pathname };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/core/hast.ts
|
|
131
|
+
function addClass(node, className) {
|
|
132
|
+
if (!className)
|
|
133
|
+
return;
|
|
134
|
+
const current = node.properties?.className;
|
|
135
|
+
const classes = Array.isArray(current) ? [...current] : typeof current === "string" ? current.split(/\s+/).filter(Boolean) : [];
|
|
136
|
+
if (!classes.includes(className))
|
|
137
|
+
classes.push(className);
|
|
138
|
+
node.properties = { ...node.properties, className: classes };
|
|
139
|
+
}
|
|
140
|
+
function removeClass(node, className) {
|
|
141
|
+
if (!className || !node.properties?.className)
|
|
142
|
+
return;
|
|
143
|
+
const current = node.properties.className;
|
|
144
|
+
const classes = Array.isArray(current) ? current.filter((cls) => cls !== className) : typeof current === "string" ? current.split(/\s+/).filter((cls) => cls && cls !== className) : [];
|
|
145
|
+
if (classes.length > 0)
|
|
146
|
+
node.properties = { ...node.properties, className: classes };
|
|
147
|
+
else
|
|
148
|
+
delete node.properties.className;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/core/routes.ts
|
|
152
|
+
var import_node_fs = require("fs");
|
|
153
|
+
var import_node_path = require("path");
|
|
154
|
+
function scanRoutes(directory, options = {}) {
|
|
155
|
+
const routes = /* @__PURE__ */ new Set(["/"]);
|
|
156
|
+
const {
|
|
157
|
+
includeAllFiles = false,
|
|
158
|
+
includeFileExtensions = ["html"],
|
|
159
|
+
base
|
|
160
|
+
} = options;
|
|
161
|
+
if (!(0, import_node_fs.existsSync)(directory))
|
|
162
|
+
return routes;
|
|
163
|
+
const walk = (currentDir, basePath) => {
|
|
164
|
+
for (const entry of (0, import_node_fs.readdirSync)(currentDir, { withFileTypes: true })) {
|
|
165
|
+
const fullPath = (0, import_node_path.join)(currentDir, entry.name);
|
|
166
|
+
if (entry.isDirectory()) {
|
|
167
|
+
walk(fullPath, basePath ? `${basePath}/${entry.name}` : entry.name);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (!entry.isFile())
|
|
171
|
+
continue;
|
|
172
|
+
const extension = (0, import_node_path.extname)(entry.name).slice(1).toLowerCase();
|
|
173
|
+
const included = includeAllFiles || includeFileExtensions.includes(extension);
|
|
174
|
+
if (!included)
|
|
175
|
+
continue;
|
|
176
|
+
let route = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
177
|
+
if (extension === "html") {
|
|
178
|
+
route = route.replace(/\.html$/i, "");
|
|
179
|
+
route = route.replace(/(?:^|\/)index$/i, "");
|
|
180
|
+
}
|
|
181
|
+
routes.add(normalizeRoute(route, base));
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
walk(directory, "");
|
|
185
|
+
return routes;
|
|
186
|
+
}
|
|
187
|
+
function writeRoutesFile(routes, routesFilePath) {
|
|
188
|
+
const sorted = Array.from(new Set(routes)).sort();
|
|
189
|
+
(0, import_node_fs.writeFileSync)(routesFilePath, `${JSON.stringify(sorted, null, 2)}
|
|
190
|
+
`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/core/check.ts
|
|
194
|
+
function collectHtmlFiles(directory) {
|
|
195
|
+
const files = [];
|
|
196
|
+
const walk = (currentDir) => {
|
|
197
|
+
for (const entry of (0, import_node_fs2.readdirSync)(currentDir, { withFileTypes: true })) {
|
|
198
|
+
const fullPath = (0, import_node_path2.join)(currentDir, entry.name);
|
|
199
|
+
if (entry.isDirectory())
|
|
200
|
+
walk(fullPath);
|
|
201
|
+
else if (entry.isFile() && entry.name.endsWith(".html"))
|
|
202
|
+
files.push(fullPath);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
walk(directory);
|
|
206
|
+
return files;
|
|
207
|
+
}
|
|
208
|
+
function routeFromHtmlFile(distDir, htmlFile) {
|
|
209
|
+
let relativePath = (0, import_node_path2.relative)(distDir, htmlFile).replace(/\\/g, "/");
|
|
210
|
+
relativePath = relativePath.replace(/\.html$/i, "");
|
|
211
|
+
relativePath = relativePath.replace(/(?:^|\/)index$/i, "");
|
|
212
|
+
return normalizeRoute(relativePath);
|
|
213
|
+
}
|
|
214
|
+
function hasAnyClass(node, classes) {
|
|
215
|
+
const current = node.properties?.className;
|
|
216
|
+
const list = Array.isArray(current) ? current.map(String) : typeof current === "string" ? current.split(/\s+/) : [];
|
|
217
|
+
return classes.some((className) => list.includes(className));
|
|
218
|
+
}
|
|
219
|
+
function checkDirectory(directory, options = {}) {
|
|
220
|
+
const routes = options.routes ?? scanRoutes(directory, options);
|
|
221
|
+
const skipClasses = options.skipClasses ?? [];
|
|
222
|
+
const broken = [];
|
|
223
|
+
let checkedLinks = 0;
|
|
224
|
+
for (const htmlFile of collectHtmlFiles(directory)) {
|
|
225
|
+
const source = (0, import_node_fs2.readFileSync)(htmlFile, "utf-8");
|
|
226
|
+
if (!source.includes("<a"))
|
|
227
|
+
continue;
|
|
228
|
+
const processor = (0, import_unified.unified)().use(import_rehype_parse.default).use(import_rehype_stringify.default);
|
|
229
|
+
const tree = processor.parse(source);
|
|
230
|
+
const pagePath = routeFromHtmlFile(directory, htmlFile);
|
|
231
|
+
let changed = false;
|
|
232
|
+
const walk = (parent, insideSkipped) => {
|
|
233
|
+
for (const child of parent.children) {
|
|
234
|
+
if (child.type !== "element")
|
|
235
|
+
continue;
|
|
236
|
+
const skipped = insideSkipped || skipClasses.length > 0 && hasAnyClass(child, skipClasses);
|
|
237
|
+
if (child.tagName === "a" && !skipped && child.properties) {
|
|
238
|
+
const href = child.properties.href;
|
|
239
|
+
if (typeof href === "string" && href.length > 0) {
|
|
240
|
+
const classified = classifyHref(href, {
|
|
241
|
+
base: options.base,
|
|
242
|
+
routes,
|
|
243
|
+
ignore: options.ignore,
|
|
244
|
+
pagePath
|
|
245
|
+
});
|
|
246
|
+
if (classified.type !== "ignored") {
|
|
247
|
+
checkedLinks++;
|
|
248
|
+
if (classified.type === "broken") {
|
|
249
|
+
broken.push({ href, pathname: classified.pathname ?? href, sourceFile: htmlFile });
|
|
250
|
+
if (options.rewrite) {
|
|
251
|
+
removeClass(child, options.internalLinkClass);
|
|
252
|
+
addClass(child, options.brokenLinkClass);
|
|
253
|
+
changed = true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
walk(child, skipped);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
walk(tree, false);
|
|
263
|
+
if (changed)
|
|
264
|
+
(0, import_node_fs2.writeFileSync)(htmlFile, processor.stringify(tree));
|
|
265
|
+
}
|
|
266
|
+
return { routes, broken, checkedLinks };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/report.ts
|
|
270
|
+
function buildReport(input) {
|
|
271
|
+
const sourceMap = /* @__PURE__ */ new Map();
|
|
272
|
+
for (const record of input.records) {
|
|
273
|
+
if (!record.sourceFile)
|
|
274
|
+
continue;
|
|
275
|
+
const key = record.pathname ?? record.href;
|
|
276
|
+
const sources = sourceMap.get(key) ?? /* @__PURE__ */ new Set();
|
|
277
|
+
sources.add(record.sourceFile);
|
|
278
|
+
sourceMap.set(key, sources);
|
|
279
|
+
}
|
|
280
|
+
const brokenMap = /* @__PURE__ */ new Map();
|
|
281
|
+
for (const link of input.broken) {
|
|
282
|
+
const key = link.pathname || link.href;
|
|
283
|
+
const entry = brokenMap.get(key) ?? { href: link.href, pathname: link.pathname, sources: [] };
|
|
284
|
+
const sources = new Set(entry.sources);
|
|
285
|
+
if (link.sourceFile)
|
|
286
|
+
sources.add(link.sourceFile);
|
|
287
|
+
for (const source of sourceMap.get(key) ?? [])
|
|
288
|
+
sources.add(source);
|
|
289
|
+
entry.sources = Array.from(sources);
|
|
290
|
+
brokenMap.set(key, entry);
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
294
|
+
routes: input.routeCount,
|
|
295
|
+
links: {
|
|
296
|
+
internal: input.records.filter((record) => record.type === "internal").length,
|
|
297
|
+
external: input.records.filter((record) => record.type === "external").length,
|
|
298
|
+
broken: brokenMap.size
|
|
299
|
+
},
|
|
300
|
+
broken: Array.from(brokenMap.values()).sort((a, b) => a.pathname.localeCompare(b.pathname))
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function formatReport(report) {
|
|
304
|
+
const lines = [];
|
|
305
|
+
lines.push(`Smart links report (${report.generatedAt})`);
|
|
306
|
+
lines.push(`Routes scanned: ${report.routes}`);
|
|
307
|
+
lines.push(`Links: ${report.links.internal} internal, ${report.links.external} external, ${report.links.broken} broken`);
|
|
308
|
+
if (report.broken.length > 0) {
|
|
309
|
+
lines.push("");
|
|
310
|
+
lines.push("Broken internal links:");
|
|
311
|
+
for (const entry of report.broken) {
|
|
312
|
+
const sources = entry.sources.length > 0 ? ` (found in ${entry.sources.join(", ")})` : "";
|
|
313
|
+
lines.push(` ${entry.pathname || entry.href}${sources}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return lines.join("\n");
|
|
317
|
+
}
|
|
318
|
+
function reportToJSON(report) {
|
|
319
|
+
return `${JSON.stringify(report, null, 2)}
|
|
320
|
+
`;
|
|
321
|
+
}
|
|
322
|
+
function reportToHTML(report) {
|
|
323
|
+
const rows = report.broken.map((entry) => `
|
|
324
|
+
<tr>
|
|
325
|
+
<td><code>${escapeHtml(entry.pathname || entry.href)}</code></td>
|
|
326
|
+
<td><code>${escapeHtml(entry.href)}</code></td>
|
|
327
|
+
<td>${entry.sources.length > 0 ? entry.sources.map((source) => `<code>${escapeHtml(source)}</code>`).join("<br>") : "\u2014"}</td>
|
|
328
|
+
</tr>`).join("");
|
|
329
|
+
return `<!DOCTYPE html>
|
|
330
|
+
<html lang="en">
|
|
331
|
+
<head>
|
|
332
|
+
<meta charset="utf-8">
|
|
333
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
334
|
+
<title>astro-smart-links report</title>
|
|
335
|
+
<style>
|
|
336
|
+
:root { color-scheme: light dark; }
|
|
337
|
+
body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 960px; margin: 0 auto; padding: 2rem 1rem; line-height: 1.6; }
|
|
338
|
+
h1 { font-size: 1.5rem; }
|
|
339
|
+
table { border-collapse: collapse; width: 100%; }
|
|
340
|
+
th, td { border: 1px solid color-mix(in oklab, currentColor 20%, transparent); padding: 0.5rem 0.75rem; text-align: left; vertical-align: top; }
|
|
341
|
+
th { background: color-mix(in oklab, currentColor 8%, transparent); }
|
|
342
|
+
code { font-size: 0.9em; }
|
|
343
|
+
.ok { color: #16a34a; font-weight: 600; }
|
|
344
|
+
</style>
|
|
345
|
+
</head>
|
|
346
|
+
<body>
|
|
347
|
+
<h1>astro-smart-links report</h1>
|
|
348
|
+
<p>Generated at ${escapeHtml(report.generatedAt)} \xB7 Scanned ${report.routes} routes</p>
|
|
349
|
+
<p>${report.links.internal} internal \xB7 ${report.links.external} external \xB7 ${report.links.broken} broken</p>
|
|
350
|
+
${report.broken.length === 0 ? '<p class="ok">No broken internal links found.</p>' : `<table>
|
|
351
|
+
<thead><tr><th>Missing route</th><th>Href</th><th>Found in</th></tr></thead>
|
|
352
|
+
<tbody>${rows}
|
|
353
|
+
</tbody>
|
|
354
|
+
</table>`}
|
|
355
|
+
</body>
|
|
356
|
+
</html>
|
|
357
|
+
`;
|
|
358
|
+
}
|
|
359
|
+
function escapeHtml(value) {
|
|
360
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/cli.ts
|
|
364
|
+
var program = new import_commander.Command();
|
|
365
|
+
program.name("astro-smart-links").description("CLI utility for astro-smart-links").version("1.1.1");
|
|
366
|
+
program.command("check", { isDefault: true }).description("Check a build directory for broken internal links").option("-d, --dir <path>", "Build directory path", "./dist").option("-o, --output <path>", "Write the report to a file").option("--format <format>", "Report format: json or html", "json").option("--json", "Print the report to stdout as JSON", false).option("-a, --all", "Treat every file type as a valid route", false).option("-e, --extensions <ext...>", "File extensions to treat as routes", ["html"]).option("--skip-classes <class...>", "Skip links inside elements with these classes").option("--fail-on-broken", "Exit with code 1 when broken links are found", false).option("-q, --quiet", "Only print the summary line", false).action((options) => {
|
|
367
|
+
const buildDir = (0, import_node_path3.resolve)(import_node_process.default.cwd(), options.dir);
|
|
368
|
+
const routesFile = options.output ? (0, import_node_path3.resolve)(import_node_process.default.cwd(), options.output) : void 0;
|
|
369
|
+
if (!(0, import_node_fs3.existsSync)(buildDir)) {
|
|
370
|
+
console.error(`Error: build directory '${buildDir}' does not exist.`);
|
|
371
|
+
import_node_process.default.exit(2);
|
|
372
|
+
}
|
|
373
|
+
const { routes, broken, checkedLinks } = checkDirectory(buildDir, {
|
|
374
|
+
includeAllFiles: options.all,
|
|
375
|
+
includeFileExtensions: options.extensions,
|
|
376
|
+
skipClasses: options.skipClasses,
|
|
377
|
+
rewrite: false,
|
|
378
|
+
routes: scanRoutes(buildDir, {
|
|
379
|
+
includeAllFiles: options.all,
|
|
380
|
+
includeFileExtensions: options.extensions
|
|
381
|
+
})
|
|
382
|
+
});
|
|
383
|
+
const report = buildReport({ records: [], broken, routeCount: routes.size });
|
|
384
|
+
report.links.broken = broken.length;
|
|
385
|
+
if (options.json)
|
|
386
|
+
import_node_process.default.stdout.write(reportToJSON(report));
|
|
387
|
+
else if (!options.quiet)
|
|
388
|
+
console.warn(formatReport(report));
|
|
389
|
+
console.warn(
|
|
390
|
+
`Checked ${checkedLinks} links across ${routes.size} routes: ${broken.length} broken.`
|
|
391
|
+
);
|
|
392
|
+
if (routesFile) {
|
|
393
|
+
(0, import_node_fs3.writeFileSync)(routesFile, options.format === "html" ? reportToHTML(report) : reportToJSON(report));
|
|
394
|
+
console.warn(`Report written to ${routesFile}`);
|
|
395
|
+
}
|
|
396
|
+
if (options.failOnBroken && broken.length > 0)
|
|
397
|
+
import_node_process.default.exit(1);
|
|
398
|
+
import_node_process.default.exit(0);
|
|
399
|
+
});
|
|
400
|
+
program.command("build").description("[deprecated] Generate a routes file from a build directory").option("-d, --dir <path>", "Build directory path", "./dist").option("-o, --output <path>", "Output path for the routes file", "./.smart-links-routes.json").option("-a, --all", "Include all files", false).option("-e, --extensions <ext...>", "File extensions to include", ["html"]).action((options) => {
|
|
401
|
+
console.warn(
|
|
402
|
+
"[astro-smart-links] The `build` command is deprecated: the smartLinks() integration no longer needs a routes file."
|
|
403
|
+
);
|
|
404
|
+
const buildDir = (0, import_node_path3.resolve)(import_node_process.default.cwd(), options.dir);
|
|
405
|
+
if (!(0, import_node_fs3.existsSync)(buildDir)) {
|
|
406
|
+
console.error(`Error: build directory '${buildDir}' does not exist.`);
|
|
407
|
+
import_node_process.default.exit(2);
|
|
408
|
+
}
|
|
409
|
+
const routes = scanRoutes(buildDir, {
|
|
410
|
+
includeAllFiles: options.all,
|
|
411
|
+
includeFileExtensions: options.extensions
|
|
412
|
+
});
|
|
413
|
+
const routesFile = (0, import_node_path3.resolve)(import_node_process.default.cwd(), options.output);
|
|
414
|
+
writeRoutesFile(routes, routesFile);
|
|
415
|
+
console.warn(`Wrote ${routes.size} routes to ${routesFile}`);
|
|
416
|
+
});
|
|
417
|
+
program.parse(import_node_process.default.argv);
|
package/dist/cli.d.cts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
reportToJSON,
|
|
8
8
|
scanRoutes,
|
|
9
9
|
writeRoutesFile
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-JNP2MFPB.js";
|
|
11
11
|
|
|
12
12
|
// src/cli.ts
|
|
13
13
|
import { existsSync, writeFileSync } from "fs";
|
|
@@ -15,8 +15,8 @@ import { resolve } from "path";
|
|
|
15
15
|
import process from "process";
|
|
16
16
|
import { Command } from "commander";
|
|
17
17
|
var program = new Command();
|
|
18
|
-
program.name("astro-smart-links").description("CLI utility for astro-smart-links").version("1.
|
|
19
|
-
program.command("check", { isDefault: true }).description("Check a build directory for broken internal links").option("-d, --dir <path>", "Build directory path", "./dist").option("-o, --output <path>", "Write the report to a file").option("--format <format>", "Report format: json or html", "json").option("--json", "Print the report to stdout as JSON", false).option("-a, --all", "Treat every file type as a valid route", false).option("-e, --extensions <ext...>", "File extensions to treat as routes", ["html"]).option("--fail-on-broken", "Exit with code 1 when broken links are found", false).option("-q, --quiet", "Only print the summary line", false).action((options) => {
|
|
18
|
+
program.name("astro-smart-links").description("CLI utility for astro-smart-links").version("1.1.1");
|
|
19
|
+
program.command("check", { isDefault: true }).description("Check a build directory for broken internal links").option("-d, --dir <path>", "Build directory path", "./dist").option("-o, --output <path>", "Write the report to a file").option("--format <format>", "Report format: json or html", "json").option("--json", "Print the report to stdout as JSON", false).option("-a, --all", "Treat every file type as a valid route", false).option("-e, --extensions <ext...>", "File extensions to treat as routes", ["html"]).option("--skip-classes <class...>", "Skip links inside elements with these classes").option("--fail-on-broken", "Exit with code 1 when broken links are found", false).option("-q, --quiet", "Only print the summary line", false).action((options) => {
|
|
20
20
|
const buildDir = resolve(process.cwd(), options.dir);
|
|
21
21
|
const routesFile = options.output ? resolve(process.cwd(), options.output) : void 0;
|
|
22
22
|
if (!existsSync(buildDir)) {
|
|
@@ -26,6 +26,7 @@ program.command("check", { isDefault: true }).description("Check a build directo
|
|
|
26
26
|
const { routes, broken, checkedLinks } = checkDirectory(buildDir, {
|
|
27
27
|
includeAllFiles: options.all,
|
|
28
28
|
includeFileExtensions: options.extensions,
|
|
29
|
+
skipClasses: options.skipClasses,
|
|
29
30
|
rewrite: false,
|
|
30
31
|
routes: scanRoutes(buildDir, {
|
|
31
32
|
includeAllFiles: options.all,
|