astro-smart-links 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs ADDED
@@ -0,0 +1,404 @@
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
+ var import_unist_util_visit = require("unist-util-visit");
39
+
40
+ // src/core/normalize.ts
41
+ var SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
42
+ function getScheme(href) {
43
+ const match = SCHEME_RE.exec(href.trim());
44
+ return match ? match[0].slice(0, -1).toLowerCase() : void 0;
45
+ }
46
+ function isExternalHref(href) {
47
+ const trimmed = href.trim();
48
+ if (trimmed.startsWith("//"))
49
+ return true;
50
+ const scheme = getScheme(trimmed);
51
+ return scheme === "http" || scheme === "https";
52
+ }
53
+ function isIgnoredHref(href) {
54
+ const trimmed = href.trim();
55
+ if (!trimmed || trimmed.startsWith("#"))
56
+ return true;
57
+ const scheme = getScheme(trimmed);
58
+ return Boolean(scheme) && scheme !== "http" && scheme !== "https";
59
+ }
60
+ function normalizeBase(base) {
61
+ if (!base)
62
+ return "";
63
+ let normalized = base.trim();
64
+ if (!normalized.startsWith("/"))
65
+ normalized = `/${normalized}`;
66
+ normalized = normalized.replace(/\/+$/, "");
67
+ return normalized === "/" ? "" : normalized;
68
+ }
69
+ function normalizeRoute(input, base) {
70
+ let path = (input ?? "").trim();
71
+ path = path.split(/[?#]/, 1)[0] ?? "";
72
+ try {
73
+ path = decodeURI(path);
74
+ } catch {
75
+ }
76
+ const normalizedBase = normalizeBase(base);
77
+ if (normalizedBase) {
78
+ if (path === normalizedBase)
79
+ path = "/";
80
+ else if (path.startsWith(`${normalizedBase}/`))
81
+ path = path.slice(normalizedBase.length);
82
+ }
83
+ if (!path.startsWith("/"))
84
+ path = `/${path}`;
85
+ path = path.replace(/\/{2,}/g, "/");
86
+ path = path.replace(/\/index(?:\.html)?$/i, "/");
87
+ if (path.length > 1)
88
+ path = path.replace(/\/+$/, "");
89
+ return path || "/";
90
+ }
91
+ function resolveInternalHref(href, pagePath) {
92
+ const trimmed = href.trim();
93
+ if (trimmed.startsWith("/"))
94
+ return normalizeRoute(trimmed);
95
+ const page = pagePath && pagePath !== "/" ? pagePath : "/index";
96
+ try {
97
+ const url = new URL(trimmed, `https://smart-links.local${page}`);
98
+ return normalizeRoute(url.pathname);
99
+ } catch {
100
+ return normalizeRoute(trimmed);
101
+ }
102
+ }
103
+ function matchesIgnoreList(href, pathname, ignore) {
104
+ for (const pattern of ignore) {
105
+ if (typeof pattern === "string") {
106
+ if (href === pattern || href.startsWith(pattern) || pathname === pattern || pathname?.startsWith(pattern))
107
+ return true;
108
+ } else if (pattern.test(href) || (pathname ? pattern.test(pathname) : false)) {
109
+ return true;
110
+ }
111
+ }
112
+ return false;
113
+ }
114
+
115
+ // src/core/classify.ts
116
+ function classifyHref(href, context = {}) {
117
+ if (isIgnoredHref(href))
118
+ return { type: "ignored", href };
119
+ if (isExternalHref(href))
120
+ return { type: "external", href };
121
+ const pathname = normalizeRoute(resolveInternalHref(href, context.pagePath), context.base);
122
+ if (matchesIgnoreList(href, pathname, context.ignore ?? []))
123
+ return { type: "ignored", href, pathname };
124
+ if (pathname === "/")
125
+ return { type: "internal", href, pathname };
126
+ if (context.routes && !context.routes.has(pathname))
127
+ return { type: "broken", href, pathname };
128
+ return { type: "internal", href, pathname };
129
+ }
130
+
131
+ // src/core/hast.ts
132
+ function addClass(node, className) {
133
+ if (!className)
134
+ return;
135
+ const current = node.properties?.className;
136
+ const classes = Array.isArray(current) ? [...current] : typeof current === "string" ? current.split(/\s+/).filter(Boolean) : [];
137
+ if (!classes.includes(className))
138
+ classes.push(className);
139
+ node.properties = { ...node.properties, className: classes };
140
+ }
141
+ function removeClass(node, className) {
142
+ if (!className || !node.properties?.className)
143
+ return;
144
+ const current = node.properties.className;
145
+ const classes = Array.isArray(current) ? current.filter((cls) => cls !== className) : typeof current === "string" ? current.split(/\s+/).filter((cls) => cls && cls !== className) : [];
146
+ if (classes.length > 0)
147
+ node.properties = { ...node.properties, className: classes };
148
+ else
149
+ delete node.properties.className;
150
+ }
151
+
152
+ // src/core/routes.ts
153
+ var import_node_fs = require("fs");
154
+ var import_node_path = require("path");
155
+ function scanRoutes(directory, options = {}) {
156
+ const routes = /* @__PURE__ */ new Set(["/"]);
157
+ const {
158
+ includeAllFiles = false,
159
+ includeFileExtensions = ["html"],
160
+ base
161
+ } = options;
162
+ if (!(0, import_node_fs.existsSync)(directory))
163
+ return routes;
164
+ const walk = (currentDir, basePath) => {
165
+ for (const entry of (0, import_node_fs.readdirSync)(currentDir, { withFileTypes: true })) {
166
+ const fullPath = (0, import_node_path.join)(currentDir, entry.name);
167
+ if (entry.isDirectory()) {
168
+ walk(fullPath, basePath ? `${basePath}/${entry.name}` : entry.name);
169
+ continue;
170
+ }
171
+ if (!entry.isFile())
172
+ continue;
173
+ const extension = (0, import_node_path.extname)(entry.name).slice(1).toLowerCase();
174
+ const included = includeAllFiles || includeFileExtensions.includes(extension);
175
+ if (!included)
176
+ continue;
177
+ let route = basePath ? `${basePath}/${entry.name}` : entry.name;
178
+ if (extension === "html") {
179
+ route = route.replace(/\.html$/i, "");
180
+ route = route.replace(/(?:^|\/)index$/i, "");
181
+ }
182
+ routes.add(normalizeRoute(route, base));
183
+ }
184
+ };
185
+ walk(directory, "");
186
+ return routes;
187
+ }
188
+ function writeRoutesFile(routes, routesFilePath) {
189
+ const sorted = Array.from(new Set(routes)).sort();
190
+ (0, import_node_fs.writeFileSync)(routesFilePath, `${JSON.stringify(sorted, null, 2)}
191
+ `);
192
+ }
193
+
194
+ // src/core/check.ts
195
+ function collectHtmlFiles(directory) {
196
+ const files = [];
197
+ const walk = (currentDir) => {
198
+ for (const entry of (0, import_node_fs2.readdirSync)(currentDir, { withFileTypes: true })) {
199
+ const fullPath = (0, import_node_path2.join)(currentDir, entry.name);
200
+ if (entry.isDirectory())
201
+ walk(fullPath);
202
+ else if (entry.isFile() && entry.name.endsWith(".html"))
203
+ files.push(fullPath);
204
+ }
205
+ };
206
+ walk(directory);
207
+ return files;
208
+ }
209
+ function routeFromHtmlFile(distDir, htmlFile) {
210
+ let relativePath = (0, import_node_path2.relative)(distDir, htmlFile).replace(/\\/g, "/");
211
+ relativePath = relativePath.replace(/\.html$/i, "");
212
+ relativePath = relativePath.replace(/(?:^|\/)index$/i, "");
213
+ return normalizeRoute(relativePath);
214
+ }
215
+ function checkDirectory(directory, options = {}) {
216
+ const routes = options.routes ?? scanRoutes(directory, options);
217
+ const broken = [];
218
+ let checkedLinks = 0;
219
+ for (const htmlFile of collectHtmlFiles(directory)) {
220
+ const source = (0, import_node_fs2.readFileSync)(htmlFile, "utf-8");
221
+ if (!source.includes("<a"))
222
+ continue;
223
+ const processor = (0, import_unified.unified)().use(import_rehype_parse.default).use(import_rehype_stringify.default);
224
+ const tree = processor.parse(source);
225
+ const pagePath = routeFromHtmlFile(directory, htmlFile);
226
+ let changed = false;
227
+ (0, import_unist_util_visit.visit)(tree, "element", (node) => {
228
+ if (node.tagName !== "a" || !node.properties)
229
+ return;
230
+ const href = node.properties.href;
231
+ if (typeof href !== "string" || href.length === 0)
232
+ return;
233
+ const classified = classifyHref(href, {
234
+ base: options.base,
235
+ routes,
236
+ ignore: options.ignore,
237
+ pagePath
238
+ });
239
+ if (classified.type === "ignored")
240
+ return;
241
+ checkedLinks++;
242
+ if (classified.type !== "broken")
243
+ return;
244
+ broken.push({ href, pathname: classified.pathname ?? href, sourceFile: htmlFile });
245
+ if (options.rewrite) {
246
+ removeClass(node, options.internalLinkClass);
247
+ addClass(node, options.brokenLinkClass);
248
+ changed = true;
249
+ }
250
+ });
251
+ if (changed)
252
+ (0, import_node_fs2.writeFileSync)(htmlFile, processor.stringify(tree));
253
+ }
254
+ return { routes, broken, checkedLinks };
255
+ }
256
+
257
+ // src/report.ts
258
+ function buildReport(input) {
259
+ const sourceMap = /* @__PURE__ */ new Map();
260
+ for (const record of input.records) {
261
+ if (!record.sourceFile)
262
+ continue;
263
+ const key = record.pathname ?? record.href;
264
+ const sources = sourceMap.get(key) ?? /* @__PURE__ */ new Set();
265
+ sources.add(record.sourceFile);
266
+ sourceMap.set(key, sources);
267
+ }
268
+ const brokenMap = /* @__PURE__ */ new Map();
269
+ for (const link of input.broken) {
270
+ const key = link.pathname || link.href;
271
+ const entry = brokenMap.get(key) ?? { href: link.href, pathname: link.pathname, sources: [] };
272
+ const sources = new Set(entry.sources);
273
+ if (link.sourceFile)
274
+ sources.add(link.sourceFile);
275
+ for (const source of sourceMap.get(key) ?? [])
276
+ sources.add(source);
277
+ entry.sources = Array.from(sources);
278
+ brokenMap.set(key, entry);
279
+ }
280
+ return {
281
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
282
+ routes: input.routeCount,
283
+ links: {
284
+ internal: input.records.filter((record) => record.type === "internal").length,
285
+ external: input.records.filter((record) => record.type === "external").length,
286
+ broken: brokenMap.size
287
+ },
288
+ broken: Array.from(brokenMap.values()).sort((a, b) => a.pathname.localeCompare(b.pathname))
289
+ };
290
+ }
291
+ function formatReport(report) {
292
+ const lines = [];
293
+ lines.push(`Smart links report (${report.generatedAt})`);
294
+ lines.push(`Routes scanned: ${report.routes}`);
295
+ lines.push(`Links: ${report.links.internal} internal, ${report.links.external} external, ${report.links.broken} broken`);
296
+ if (report.broken.length > 0) {
297
+ lines.push("");
298
+ lines.push("Broken internal links:");
299
+ for (const entry of report.broken) {
300
+ const sources = entry.sources.length > 0 ? ` (found in ${entry.sources.join(", ")})` : "";
301
+ lines.push(` ${entry.pathname || entry.href}${sources}`);
302
+ }
303
+ }
304
+ return lines.join("\n");
305
+ }
306
+ function reportToJSON(report) {
307
+ return `${JSON.stringify(report, null, 2)}
308
+ `;
309
+ }
310
+ function reportToHTML(report) {
311
+ const rows = report.broken.map((entry) => `
312
+ <tr>
313
+ <td><code>${escapeHtml(entry.pathname || entry.href)}</code></td>
314
+ <td><code>${escapeHtml(entry.href)}</code></td>
315
+ <td>${entry.sources.length > 0 ? entry.sources.map((source) => `<code>${escapeHtml(source)}</code>`).join("<br>") : "\u2014"}</td>
316
+ </tr>`).join("");
317
+ return `<!DOCTYPE html>
318
+ <html lang="en">
319
+ <head>
320
+ <meta charset="utf-8">
321
+ <meta name="viewport" content="width=device-width, initial-scale=1">
322
+ <title>astro-smart-links report</title>
323
+ <style>
324
+ :root { color-scheme: light dark; }
325
+ body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 960px; margin: 0 auto; padding: 2rem 1rem; line-height: 1.6; }
326
+ h1 { font-size: 1.5rem; }
327
+ table { border-collapse: collapse; width: 100%; }
328
+ th, td { border: 1px solid color-mix(in oklab, currentColor 20%, transparent); padding: 0.5rem 0.75rem; text-align: left; vertical-align: top; }
329
+ th { background: color-mix(in oklab, currentColor 8%, transparent); }
330
+ code { font-size: 0.9em; }
331
+ .ok { color: #16a34a; font-weight: 600; }
332
+ </style>
333
+ </head>
334
+ <body>
335
+ <h1>astro-smart-links report</h1>
336
+ <p>Generated at ${escapeHtml(report.generatedAt)} \xB7 Scanned ${report.routes} routes</p>
337
+ <p>${report.links.internal} internal \xB7 ${report.links.external} external \xB7 ${report.links.broken} broken</p>
338
+ ${report.broken.length === 0 ? '<p class="ok">No broken internal links found.</p>' : `<table>
339
+ <thead><tr><th>Missing route</th><th>Href</th><th>Found in</th></tr></thead>
340
+ <tbody>${rows}
341
+ </tbody>
342
+ </table>`}
343
+ </body>
344
+ </html>
345
+ `;
346
+ }
347
+ function escapeHtml(value) {
348
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
349
+ }
350
+
351
+ // src/cli.ts
352
+ var program = new import_commander.Command();
353
+ program.name("astro-smart-links").description("CLI utility for astro-smart-links").version("1.0.1");
354
+ 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) => {
355
+ const buildDir = (0, import_node_path3.resolve)(import_node_process.default.cwd(), options.dir);
356
+ const routesFile = options.output ? (0, import_node_path3.resolve)(import_node_process.default.cwd(), options.output) : void 0;
357
+ if (!(0, import_node_fs3.existsSync)(buildDir)) {
358
+ console.error(`Error: build directory '${buildDir}' does not exist.`);
359
+ import_node_process.default.exit(2);
360
+ }
361
+ const { routes, broken, checkedLinks } = checkDirectory(buildDir, {
362
+ includeAllFiles: options.all,
363
+ includeFileExtensions: options.extensions,
364
+ rewrite: false,
365
+ routes: scanRoutes(buildDir, {
366
+ includeAllFiles: options.all,
367
+ includeFileExtensions: options.extensions
368
+ })
369
+ });
370
+ const report = buildReport({ records: [], broken, routeCount: routes.size });
371
+ report.links.broken = broken.length;
372
+ if (options.json)
373
+ import_node_process.default.stdout.write(reportToJSON(report));
374
+ else if (!options.quiet)
375
+ console.warn(formatReport(report));
376
+ console.warn(
377
+ `Checked ${checkedLinks} links across ${routes.size} routes: ${broken.length} broken.`
378
+ );
379
+ if (routesFile) {
380
+ (0, import_node_fs3.writeFileSync)(routesFile, options.format === "html" ? reportToHTML(report) : reportToJSON(report));
381
+ console.warn(`Report written to ${routesFile}`);
382
+ }
383
+ if (options.failOnBroken && broken.length > 0)
384
+ import_node_process.default.exit(1);
385
+ import_node_process.default.exit(0);
386
+ });
387
+ 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) => {
388
+ console.warn(
389
+ "[astro-smart-links] The `build` command is deprecated: the smartLinks() integration no longer needs a routes file."
390
+ );
391
+ const buildDir = (0, import_node_path3.resolve)(import_node_process.default.cwd(), options.dir);
392
+ if (!(0, import_node_fs3.existsSync)(buildDir)) {
393
+ console.error(`Error: build directory '${buildDir}' does not exist.`);
394
+ import_node_process.default.exit(2);
395
+ }
396
+ const routes = scanRoutes(buildDir, {
397
+ includeAllFiles: options.all,
398
+ includeFileExtensions: options.extensions
399
+ });
400
+ const routesFile = (0, import_node_path3.resolve)(import_node_process.default.cwd(), options.output);
401
+ writeRoutesFile(routes, routesFile);
402
+ console.warn(`Wrote ${routes.size} routes to ${routesFile}`);
403
+ });
404
+ 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
@@ -15,7 +15,7 @@ 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.0.0");
18
+ program.name("astro-smart-links").description("CLI utility for astro-smart-links").version("1.0.1");
19
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) => {
20
20
  const buildDir = resolve(process.cwd(), options.dir);
21
21
  const routesFile = options.output ? resolve(process.cwd(), options.output) : void 0;