nuxt-link-checker 2.0.0-beta.5 → 2.0.0-beta.7

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 CHANGED
@@ -34,127 +34,38 @@ Find and magically fix links that may be negatively effecting your Nuxt sites SE
34
34
 
35
35
  ## Features
36
36
 
37
- - ✅ Discover broken links - 404s and internal redirects
38
- - 🚩 Warnings for bad practice links - absolute instead of relative and wrong trailing slash
39
- - 🕵️ Fail on build if broken links are found (optional)
37
+ - ✅ 7 SEO focused link inspections (more coming soon)
38
+ - See live inspections right in your Nuxt App
39
+ - 🧙 Magically fix them in Nuxt Dev Tools
40
+ - 🚩 Generate reports on build (html, markdown)
40
41
 
41
- ## Install
42
42
 
43
- ```bash
44
- npm install --save-dev nuxt-link-checker
45
-
46
- # Using yarn
47
- yarn add --dev nuxt-link-checker
48
- ```
49
-
50
- ## Setup
51
-
52
- _nuxt.config.ts_
53
-
54
- ```ts
55
- export default defineNuxtConfig({
56
- modules: [
57
- 'nuxt-link-checker',
58
- ],
59
- })
60
- ```
61
-
62
- To have routes scanned for broken links automatically, they need to be pre-rendered by Nitro.
63
-
64
- ```ts
65
- export default defineNuxtConfig({
66
- nitro: {
67
- prerender: {
68
- crawlLinks: true,
69
- routes: [
70
- '/',
71
- // any URLs that can't be discovered by crawler
72
- '/my-hidden-url'
73
- ]
74
- }
75
- }
76
- })
77
- ```
78
-
79
- ### Set host (optional)
80
-
81
- You'll need to provide the host of your site so that the crawler can resolve absolute URLs that may be internal.
82
-
83
- ```ts
84
- export default defineNuxtConfig({
85
- // Recommended
86
- runtimeConfig: {
87
- public: {
88
- siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://example.com',
89
- }
90
- },
91
- // OR
92
- linkChecker: {
93
- host: 'https://example.com',
94
- },
95
- })
96
- ```
97
-
98
- ### Exclude URLs from throwing errors
43
+ ## Installation
99
44
 
100
- You can exclude URLs from throwing errors by adding them to the `exclude` array.
45
+ 1. Install `nuxt-link-checker` dependency to your project:
101
46
 
102
- For example, if you have an `/admin` route that is a separate application, you can ignore all `/admin` links with:
103
47
 
104
- ```ts
105
- export default defineNuxtConfig({
106
- linkChecker: {
107
- exclude: [
108
- '/admin/**'
109
- ],
110
- },
111
- })
48
+ ```bash
49
+ #
50
+ yarn add -D nuxt-link-checker
51
+ #
52
+ npm install -D nuxt-link-checker
53
+ #
54
+ pnpm i -D nuxt-link-checker
112
55
  ```
113
56
 
114
- ### Disable errors on broken links
115
57
 
116
- You can disable errors on broken links by setting `failOn404` to `false`.
58
+ 2. Add it to your `modules` section in your `nuxt.config`:
117
59
 
118
60
  ```ts
119
61
  export default defineNuxtConfig({
120
- linkChecker: {
121
- failOn404: false,
122
- },
62
+ modules: ['nuxt-link-checker']
123
63
  })
124
64
  ```
125
65
 
126
- ## Module Config
127
-
128
- ### `failOn404`
129
-
130
- - Type: `boolean`
131
- - Default: `true`
132
-
133
- If set to `true`, the build will fail if any broken links are found.
66
+ # Documentation
134
67
 
135
- ### `exclude`
136
-
137
- - Type: `string[]`
138
- - Default: `[]`
139
-
140
- An array of URLs to exclude from the check.
141
-
142
- This can be useful if you have a route that is not pre-rendered, but you know it will be valid.
143
-
144
- ### `host`
145
-
146
- - Type: `string`
147
- - Default: `runtimeConfig.public.siteUrl || localhost`
148
- - Required: `false`
149
-
150
- The host of your site. This is required to validate absolute URLs which may be internal.
151
-
152
- ### `trailingSlash`
153
-
154
- - Type: `boolean`
155
- - Default: `false`
156
-
157
- Whether internal links should have a trailing slash or not.
68
+ [📖 Read the full documentation](https://nuxtseo.com/nuxt-link-checker/getting-started/installation) for more information.
158
69
 
159
70
  ## Sponsors
160
71
 
@@ -164,7 +75,6 @@ Whether internal links should have a trailing slash or not.
164
75
  </a>
165
76
  </p>
166
77
 
167
-
168
78
  ## License
169
79
 
170
80
  MIT License © 2023-PRESENT [Harlan Wilton](https://github.com/harlan-zw)
package/dist/module.d.ts CHANGED
@@ -1,14 +1,83 @@
1
1
  import * as _nuxt_schema from '@nuxt/schema';
2
+ import { FetchResponse } from 'ofetch';
3
+ import { SiteConfig } from 'nuxt-site-config-kit';
4
+ import Fuse from 'fuse.js';
5
+ import { ParsedURL } from 'ufo';
6
+
7
+ interface Rule {
8
+ test(ctx: RuleTestContext): void;
9
+ }
10
+ interface RuleTestContext {
11
+ link: string;
12
+ url: ParsedURL;
13
+ ids: string[];
14
+ fromPath: string;
15
+ response: FetchResponse<any>;
16
+ siteConfig: SiteConfig;
17
+ pageSearch?: Fuse<string>;
18
+ report: (report: RuleReport) => void;
19
+ skipInspections?: string[];
20
+ }
21
+ interface RuleReport {
22
+ name: string;
23
+ scope: 'error' | 'warning';
24
+ message: string;
25
+ fix?: string;
26
+ fixDescription?: string;
27
+ tip?: string;
28
+ canRetry?: boolean;
29
+ }
30
+
31
+ declare const DefaultInspections: {
32
+ readonly 'missing-hash': Rule;
33
+ readonly 'no-error-response': Rule;
34
+ readonly 'no-baseless': Rule;
35
+ readonly 'no-javascript': Rule;
36
+ readonly 'trailing-slash': Rule;
37
+ readonly 'absolute-site-urls': Rule;
38
+ readonly redirects: Rule;
39
+ };
2
40
 
3
41
  interface ModuleOptions {
4
42
  /**
5
43
  * Whether the build should fail when a 404 is encountered.
6
44
  */
7
- failOn404: boolean;
45
+ failOnError: boolean;
46
+ /**
47
+ * Skip specific inspections from running.
48
+ */
49
+ skipInspections: (Partial<keyof typeof DefaultInspections>)[];
50
+ /**
51
+ * The timeout for fetching a URL.
52
+ *
53
+ * @default 5000
54
+ */
55
+ fetchTimeout: number;
56
+ /**
57
+ * Links to ignore when running inspections.
58
+ */
59
+ excludeLinks: string[];
60
+ /**
61
+ * Generate a report when using nuxt build` or `nuxt generate`.
62
+ */
63
+ report?: {
64
+ /**
65
+ * Whether to output a HTML report.
66
+ */
67
+ html?: boolean;
68
+ /**
69
+ * Whether to output a JSON report.
70
+ */
71
+ markdown?: boolean;
72
+ };
73
+ /**
74
+ * Whether to show live inspections in your Nuxt app.
75
+ */
76
+ showLiveInspections: boolean;
8
77
  /**
9
- * Paths to ignore when checking links.
78
+ * Whether to run the module on `nuxt build` or `nuxt generate`.
10
79
  */
11
- exclude: string[];
80
+ runOnBuild: boolean;
12
81
  /**
13
82
  * Whether the module is enabled.
14
83
  *
package/dist/module.json CHANGED
@@ -5,5 +5,5 @@
5
5
  "bridge": false
6
6
  },
7
7
  "configKey": "linkChecker",
8
- "version": "2.0.0-beta.5"
8
+ "version": "2.0.0-beta.7"
9
9
  }
package/dist/module.mjs CHANGED
@@ -1,49 +1,18 @@
1
1
  import { useNuxt, extendPages, defineNuxtModule, useLogger, createResolver, addPlugin, addServerHandler, addServerPlugin, hasNuxtModule } from '@nuxt/kit';
2
- import { installNuxtSiteConfig, updateSiteConfig } from 'nuxt-site-config-kit';
2
+ import { useSiteConfig, installNuxtSiteConfig, updateSiteConfig } from 'nuxt-site-config-kit';
3
+ import fs, { readFile, writeFile } from 'node:fs/promises';
3
4
  import chalk from 'chalk';
4
- import { parseURL, hasProtocol, joinURL } from 'ufo';
5
+ import Fuse from 'fuse.js';
6
+ import { resolve } from 'pathe';
5
7
  import { load } from 'cheerio';
6
8
  import { toRouteMatcher, createRouter } from 'radix3';
9
+ import { parseURL, joinURL } from 'ufo';
10
+ import { fixSlashes } from 'site-config-stack';
7
11
  import { existsSync } from 'node:fs';
8
- import { readFile, writeFile } from 'node:fs/promises';
9
12
  import { onDevToolsInitialized, extendServerRpc } from '@nuxt/devtools-kit';
10
13
  import { diffLines, diffArrays } from 'diff';
11
14
  import MagicString from 'magic-string';
12
15
 
13
- const linkMap = {};
14
- const EXT_REGEX = /\.[\da-z]+$/;
15
- const allowedExtensions = /* @__PURE__ */ new Set(["", ".json"]);
16
- function getExtension(path) {
17
- return (path.match(EXT_REGEX) || [])[0] || "";
18
- }
19
- function extractLinks(html, from, { host, trailingSlash }) {
20
- const links = [];
21
- const hostname = parseURL(host).host;
22
- const $ = load(html);
23
- $("body [href]").each((i, el) => {
24
- const href = $(el).attr("href");
25
- if (!href)
26
- return;
27
- const url = parseURL(href);
28
- if (hasProtocol(href) && !href.startsWith("/") && url.host !== hostname)
29
- return;
30
- if (href.startsWith("#"))
31
- return;
32
- if (el.tagName === "link" && el.attribs.rel === "canonical")
33
- return;
34
- if (!allowedExtensions.has(getExtension(href)))
35
- return;
36
- links.push({
37
- pathname: url.pathname || "/",
38
- url,
39
- badAbsolute: Boolean(hostname) && hostname === url.host,
40
- badTrailingSlash: url.pathname !== "/" && !url.pathname.split("/").at(-1).includes(".") && (trailingSlash && !url.pathname.endsWith("/") || !trailingSlash && url.pathname.endsWith("/")),
41
- element: $.html(el) || ""
42
- });
43
- });
44
- return links;
45
- }
46
-
47
16
  function createFilter(options = {}) {
48
17
  const include = options.include || [];
49
18
  const exclude = options.exclude || [];
@@ -71,65 +40,365 @@ function createFilter(options = {}) {
71
40
  };
72
41
  }
73
42
 
74
- const invalidStatusCodes = [404, 302, 301, 307, 303];
43
+ function defineRule(rule) {
44
+ return rule;
45
+ }
46
+ function isInvalidLinkProtocol(link) {
47
+ return link.startsWith("javascript:") || link.startsWith("blob:") || link.startsWith("data:");
48
+ }
49
+
50
+ function RuleTrailingSlash() {
51
+ return defineRule({
52
+ test({ report, link, siteConfig }) {
53
+ if (!link.startsWith("/") && !link.startsWith(siteConfig.url))
54
+ return;
55
+ const $url = parseURL(link);
56
+ if ($url.pathname === "/")
57
+ return;
58
+ const fix = fixSlashes(siteConfig.trailingSlash, link);
59
+ if (!$url.pathname.endsWith("/") && siteConfig.trailingSlash) {
60
+ report({
61
+ name: "trailing-slash",
62
+ scope: "warning",
63
+ message: "Should have a trailing slash.",
64
+ tip: "Incorrect trailing slashes can cause duplicate pages in search engines and waste crawl budget.",
65
+ fix,
66
+ fixDescription: "Add trailing slash."
67
+ });
68
+ } else if ($url.pathname.endsWith("/") && !siteConfig.trailingSlash) {
69
+ report({
70
+ name: "trailing-slash",
71
+ scope: "warning",
72
+ message: "Should not have a trailing slash.",
73
+ tip: "Incorrect trailing slashes can cause duplicate pages in search engines and waste crawl budget.",
74
+ fix,
75
+ fixDescription: "Removing trailing slash."
76
+ });
77
+ }
78
+ }
79
+ });
80
+ }
81
+
82
+ function RuleMissingHash() {
83
+ return defineRule({
84
+ test({ link, report, ids, fromPath }) {
85
+ const [path, hash] = link.split("#");
86
+ if (!link.includes("#") || fixSlashes(false, path) !== fromPath)
87
+ return;
88
+ if (ids.includes(hash))
89
+ return;
90
+ const fuse = new Fuse(ids, {
91
+ threshold: 0.6
92
+ });
93
+ const fixedHash = fuse.search(hash.replace("#", ""))?.[0]?.item;
94
+ const payload = {
95
+ name: "missing-hash",
96
+ scope: "error",
97
+ message: `No element with id "${hash}" found.`
98
+ };
99
+ if (fixedHash) {
100
+ payload.fix = `${link.split("#")[0]}#${fixedHash}`;
101
+ payload.fixDescription = `Did you mean ${payload.fix}?`;
102
+ }
103
+ report(payload);
104
+ }
105
+ });
106
+ }
107
+
108
+ function RuleNoBaseLess() {
109
+ return defineRule({
110
+ test({ link, fromPath, report }) {
111
+ if (link.startsWith("/") || link.startsWith("http") || isInvalidLinkProtocol(link) || link.startsWith("#"))
112
+ return;
113
+ report({
114
+ name: "no-baseless",
115
+ scope: "warning",
116
+ message: "Should not have a base.",
117
+ fix: `${joinURL(fromPath, link)}`,
118
+ fixDescription: `Add base ${fromPath}.`
119
+ });
120
+ }
121
+ });
122
+ }
123
+
124
+ function RuleNoJavascript() {
125
+ return defineRule({
126
+ test({ link, report }) {
127
+ if (link.startsWith("javascript:")) {
128
+ report({
129
+ name: "no-javascript",
130
+ scope: "error",
131
+ tip: 'Using a <button type="button"> instead as a better practice.',
132
+ message: "Should not use JavaScript"
133
+ });
134
+ }
135
+ }
136
+ });
137
+ }
138
+
139
+ function RuleAbsoluteSiteUrls() {
140
+ return defineRule({
141
+ test({ report, link, siteConfig }) {
142
+ if (!link.startsWith(siteConfig.url))
143
+ return;
144
+ const $url = parseURL(link);
145
+ report({
146
+ name: "absolute-site-urls",
147
+ scope: "warning",
148
+ message: "Internal links should be relative.",
149
+ tip: "Using internal links that start with / is recommended to avoid issues when deploying your site to different domain names",
150
+ fix: $url.pathname,
151
+ fixDescription: `Remove ${siteConfig.url}.`
152
+ });
153
+ }
154
+ });
155
+ }
156
+
157
+ function RuleRedirects() {
158
+ return defineRule({
159
+ test({ report, response }) {
160
+ if (response.status !== 301 && response.status !== 302)
161
+ return;
162
+ const payload = {
163
+ name: "redirects",
164
+ scope: "warning",
165
+ message: "Should not redirect.",
166
+ tip: "Redirects use up your crawl budget and increase loading times, it's recommended to avoid them when possible."
167
+ };
168
+ const fix = typeof response.headers?.get === "function" ? response.headers.get("location") : response.headers?.location || false;
169
+ if (fix) {
170
+ payload.fix = fix;
171
+ payload.fixDescription = `Set to redirect URL ${fix}.`;
172
+ }
173
+ report(payload);
174
+ }
175
+ });
176
+ }
177
+
178
+ function RuleNoErrorResponse() {
179
+ return defineRule({
180
+ test({ link, response, report, pageSearch }) {
181
+ if (response.status.toString().startsWith("2") || response.status.toString().startsWith("3") || isInvalidLinkProtocol(link) || link.startsWith("#"))
182
+ return;
183
+ const payload = {
184
+ name: "no-error-response",
185
+ scope: "error",
186
+ message: `Should not respond with ${response.status} ${response.statusText}.`
187
+ };
188
+ if (link.startsWith("/") && pageSearch) {
189
+ const fix = pageSearch.search(link)?.[0]?.item;
190
+ if (fix && fix !== link) {
191
+ payload.fix = fix;
192
+ payload.fixDescription = `Did you mean ${fix}?`;
193
+ }
194
+ } else {
195
+ payload.canRetry = true;
196
+ }
197
+ report(payload);
198
+ }
199
+ });
200
+ }
201
+
202
+ const DefaultInspections = {
203
+ "missing-hash": RuleMissingHash(),
204
+ "no-error-response": RuleNoErrorResponse(),
205
+ "no-baseless": RuleNoBaseLess(),
206
+ "no-javascript": RuleNoJavascript(),
207
+ "trailing-slash": RuleTrailingSlash(),
208
+ "absolute-site-urls": RuleAbsoluteSiteUrls(),
209
+ "redirects": RuleRedirects()
210
+ };
211
+ function inspect(ctx, rules = DefaultInspections) {
212
+ const res = { error: [], warning: [], fix: ctx.link, link: ctx.link };
213
+ let link = ctx.link;
214
+ const url = parseURL(link);
215
+ if (!url.pathname && !url.protocol && !url.host && !link.startsWith("javascript:")) {
216
+ res.error.push({
217
+ name: "invalid-url",
218
+ scope: "error",
219
+ message: `Invalid URL: ${link}`
220
+ });
221
+ return res;
222
+ }
223
+ const validInspections = Object.entries(rules).filter(([name]) => !ctx.skipInspections || !ctx.skipInspections.includes(name)).map(([, rule]) => rule);
224
+ for (const rule of validInspections) {
225
+ rule.test({
226
+ ...ctx,
227
+ link,
228
+ url,
229
+ report(obj) {
230
+ res[obj.scope].push(obj);
231
+ if (obj.fix)
232
+ link = obj.fix;
233
+ }
234
+ });
235
+ }
236
+ res.passes = !res.error?.length && !res.warning?.length;
237
+ res.fix = link;
238
+ return res;
239
+ }
240
+
241
+ async function crawlFetch(link, options = {}) {
242
+ const fetch = options.fetch || globalThis.fetch;
243
+ const timeout = options.timeout || 5e3;
244
+ const timeoutController = new AbortController();
245
+ const abortRequestTimeout = setTimeout(() => timeoutController.abort(), timeout);
246
+ return await fetch(link, {
247
+ method: "HEAD",
248
+ signal: timeoutController.signal,
249
+ headers: {
250
+ "user-agent": "Nuxt Link Checker"
251
+ }
252
+ }).catch(() => ({ status: 404, statusText: "Not Found", headers: {} })).finally(() => clearTimeout(abortRequestTimeout));
253
+ }
254
+
255
+ const responses = {};
256
+ const linkMap = {};
257
+ function extractPayload(html) {
258
+ const $ = load(html);
259
+ const ids = $("#__nuxt [id]").map((i, el) => $(el).attr("id")).get();
260
+ const links = $("#__nuxt a[href]").map((i, el) => $(el).attr("href")).get();
261
+ return { ids, links };
262
+ }
263
+ async function getLinkResponse(link, timeout) {
264
+ const response = responses[link];
265
+ if (!response) {
266
+ responses[link] = crawlFetch(link, { timeout });
267
+ }
268
+ return responses[link];
269
+ }
75
270
  function prerender(config, nuxt = useNuxt()) {
76
271
  const urlFilter = createFilter({
77
- exclude: config.exclude
272
+ exclude: config.excludeLinks
78
273
  });
79
274
  nuxt.hooks.hook("nitro:init", async (nitro) => {
80
- const invalidRoutes = {};
275
+ const siteConfig = useSiteConfig();
81
276
  nitro.hooks.hook("prerender:generate", async (ctx) => {
82
- if (ctx.contents && ctx.fileName?.endsWith(".html"))
83
- linkMap[ctx.route] = extractLinks(ctx.contents, ctx.route, config);
84
- if (ctx.error?.statusCode && invalidStatusCodes.includes(Number(ctx.error?.statusCode)))
85
- invalidRoutes[ctx.route] = ctx.error.statusCode;
277
+ if (ctx.contents && !ctx.error && ctx.fileName?.endsWith(".html") && !ctx.route.endsWith(".html") && urlFilter(ctx.route)) {
278
+ linkMap[ctx.route] = extractPayload(ctx.contents);
279
+ linkMap[ctx.route].links.forEach((link) => {
280
+ getLinkResponse(link, config.fetchTimeout);
281
+ });
282
+ }
283
+ responses[ctx.route] = Promise.resolve({ status: Number(ctx.error?.statusCode) || 200, statusText: ctx.error?.statusMessage || "" });
86
284
  });
87
285
  nitro.hooks.hook("close", async () => {
88
- const links = Object.entries(linkMap);
89
- if (!links.length)
286
+ const payloads = Object.entries(linkMap);
287
+ if (!payloads.length)
90
288
  return;
91
- nitro.logger.info(`Scanning routes for broken links... ${chalk.gray(`trailingSlashes: ${config.trailingSlash ? "`true`" : "`false`"}`)}`);
289
+ const links = payloads.map(([, payloads2]) => payloads2.links).flat();
290
+ const pageSearcher = new Fuse(links, {
291
+ threshold: 0.5
292
+ });
293
+ nitro.logger.info("Running link inspections...");
92
294
  let routeCount = 0;
93
- let badLinkCount = 0;
94
- links.forEach(([route, routes]) => {
95
- const brokenLinks = routes.map((r) => {
96
- return {
97
- ...r,
98
- statusCode: invalidRoutes[r.pathname] || 200
99
- };
100
- }).filter((r) => r.statusCode !== 200 || r.badTrailingSlash || r.badAbsolute).filter((r) => urlFilter(r.pathname));
101
- if (brokenLinks.length) {
102
- nitro.logger.log(chalk.gray(
103
- ` ${Number(++routeCount) === links.length - 1 ? "\u2514\u2500" : "\u251C\u2500"} ${chalk.white(route)}`
104
- ));
105
- brokenLinks.forEach((link) => {
106
- badLinkCount++;
107
- nitro.logger.log("");
108
- if (link.statusCode !== 200) {
109
- nitro.logger.log(chalk.red(
110
- ` ${link.statusCode} ${link.statusCode === 404 ? "Not Found" : "Redirect"}`
111
- ));
112
- } else if (link.badAbsolute) {
113
- nitro.logger.log(chalk.yellow(
114
- " Absolute link, should be relative"
115
- ));
116
- } else if (link.badTrailingSlash) {
117
- nitro.logger.log(chalk.yellow(
118
- " Incorrect trailing slash"
119
- ));
120
- }
121
- nitro.logger.log(` ${chalk.gray(link.element)}`);
295
+ let errorCount = 0;
296
+ await Promise.all(payloads.map(async ([route, payload]) => {
297
+ const reports = await Promise.all(payload.links.map(async (link) => {
298
+ const response = await getLinkResponse(link);
299
+ return inspect({
300
+ ids: linkMap[route].ids,
301
+ fromPath: route,
302
+ pageSearch: pageSearcher,
303
+ siteConfig,
304
+ link,
305
+ response,
306
+ skipInspections: config.skipInspections
122
307
  });
308
+ }));
309
+ const valid = !reports.filter((r) => !r.passes).length;
310
+ if (valid)
311
+ return;
312
+ const errors = reports.filter((r) => r.error?.length).length;
313
+ errorCount += errors;
314
+ const warnings = reports.filter((r) => r.warning?.length).length;
315
+ const statusString = [
316
+ errors > 0 ? chalk.red(`${errors} error${errors > 1 ? "s" : ""}`) : false,
317
+ warnings > 0 ? chalk.yellow(`${warnings} warning${warnings > 1 ? "s" : ""}`) : false
318
+ ].filter(Boolean).join(chalk.gray(", "));
319
+ nitro.logger.log(chalk.gray(
320
+ ` ${Number(++routeCount) === payload.links.length - 1 ? "\u2514\u2500" : "\u251C\u2500"} ${chalk.white(route)} ${chalk.gray("[")}${statusString}${chalk.gray("]")}`
321
+ ));
322
+ reports.forEach((report) => {
323
+ if (!report.passes) {
324
+ nitro.logger.log(chalk.gray(` ${report.link}`));
325
+ report.error?.forEach((error) => {
326
+ nitro.logger.log(chalk.red(` \u2716 ${error.message}`) + chalk.gray(` (${error.name})`));
327
+ });
328
+ report.warning?.forEach((warning) => {
329
+ nitro.logger.log(chalk.yellow(` \u26A0 ${warning.message}`) + chalk.gray(` (${warning.name})`));
330
+ });
331
+ }
332
+ });
333
+ if (config.report?.html) {
334
+ if (reports.length) {
335
+ const reportHtml = reports.map((r) => {
336
+ const errors2 = r.error?.map((error) => {
337
+ return `<li class="error">${error.message} (${error.name})</li>`;
338
+ }).join("");
339
+ const warnings2 = r.warning?.map((warning) => {
340
+ return `<li class="warning">${warning.message} (${warning.name})</li>`;
341
+ }).join("");
342
+ return `<li class="link"><a href="${r.link}">${r.link}</a><ul>${errors2}${warnings2}</ul></li>`;
343
+ }).join("");
344
+ const html = `
345
+ <html>
346
+ <head>
347
+ <title>Link Checker Report</title>
348
+ <style>
349
+ body {
350
+ font-family: sans-serif;
351
+ }
352
+ .link {
353
+ margin-bottom: 1rem;
354
+ }
355
+ .error {
356
+ color: red;
357
+ }
358
+ .warning {
359
+ color: yellow;
360
+ }
361
+ </style>
362
+ </head>
363
+ <body>
364
+ <h1>Link Checker Report</h1>
365
+ <ul>
366
+ ${reportHtml}
367
+ </ul>
368
+ </body>
369
+ </html>
370
+ `;
371
+ await fs.writeFile(resolve(nitro.options.output.dir, "link-checker-report.html"), html);
372
+ nitro.logger.info(`Nuxt Link Checker Report written to ${resolve(nitro.options.output.dir, "link-checker-report.html")}`);
373
+ }
123
374
  }
124
- });
125
- if (badLinkCount > 0) {
126
- nitro.logger[config.failOn404 ? "error" : "warn"](`Found ${badLinkCount} broken links.`);
127
- if (config.failOn404) {
128
- nitro.logger.log(chalk.gray('You can disable this by setting "linkChecker: { failOn404: false }" in your nuxt.config.ts.'));
129
- process.exit(1);
375
+ if (config.report?.markdown) {
376
+ if (reports.length) {
377
+ const reportMarkdown = reports.map((r) => {
378
+ const errors2 = r.error?.map((error) => {
379
+ return `| ${r.link} | ${error.message} (${error.name}) |`;
380
+ }).join("");
381
+ const warnings2 = r.warning?.map((warning) => {
382
+ return `| ${r.link} | ${warning.message} (${warning.name}) |`;
383
+ }).join("");
384
+ return `${errors2}${warnings2}`;
385
+ }).join("");
386
+ const markdown = [
387
+ "# Link Checker Report",
388
+ "",
389
+ "| Link | Message |",
390
+ "| --- | --- |",
391
+ reportMarkdown
392
+ ].join("\n");
393
+ await fs.writeFile(resolve(nitro.options.output.dir, "link-checker-report.md"), markdown);
394
+ nitro.logger.info(`Nuxt Link Checker Report written to ${resolve(nitro.options.output.dir, "link-checker-report.md")}`);
395
+ }
130
396
  }
131
- } else {
132
- nitro.logger.success("Looks good! No broken links found.");
397
+ }));
398
+ if (errorCount > 0 && config.failOnError) {
399
+ nitro.logger.error(`Nuxt Link Checker found ${errorCount} errors, failing build.`);
400
+ nitro.logger.log(chalk.gray('You can disable this by setting "linkChecker: { failOn404: false }" in your nuxt.config.ts.'));
401
+ process.exit(1);
133
402
  }
134
403
  });
135
404
  });
@@ -298,10 +567,14 @@ const module = defineNuxtModule({
298
567
  configKey: "linkChecker"
299
568
  },
300
569
  defaults: {
570
+ runOnBuild: true,
301
571
  debug: false,
572
+ showLiveInspections: true,
302
573
  enabled: true,
303
- failOn404: true,
304
- exclude: []
574
+ fetchTimeout: 5e3,
575
+ failOnError: false,
576
+ excludeLinks: [],
577
+ skipInspections: []
305
578
  },
306
579
  async setup(config, nuxt) {
307
580
  const logger = useLogger("nuxt-link-checker");
@@ -337,13 +610,18 @@ const module = defineNuxtModule({
337
610
  handler: resolve("./runtime/server/api/links")
338
611
  });
339
612
  }
340
- nuxt.options.runtimeConfig["nuxt-link-checker"] = {
613
+ nuxt.options.runtimeConfig.public["nuxt-link-checker"] = {
341
614
  hasSitemapModule: hasNuxtModule("nuxt-simple-sitemap"),
342
- hasLinksEndpoint
615
+ hasLinksEndpoint,
616
+ excludeLinks: config.excludeLinks,
617
+ skipInspections: config.skipInspections,
618
+ fetchTimeout: config.fetchTimeout,
619
+ showLiveInspections: config.showLiveInspections
343
620
  };
344
621
  setupDevToolsUI(config, resolve);
345
622
  }
346
- prerender(config);
623
+ if (config.runOnBuild)
624
+ prerender(config);
347
625
  }
348
626
  });
349
627
 
@@ -1,2 +1,19 @@
1
1
  import type { LinkInspectionResult, Rule, RuleTestContext } from './types';
2
- export declare function inspect(ctx: RuleTestContext, rules?: Rule[]): Partial<LinkInspectionResult>;
2
+ export declare const DefaultInspections: {
3
+ readonly 'missing-hash': Rule;
4
+ readonly 'no-error-response': Rule;
5
+ readonly 'no-baseless': Rule;
6
+ readonly 'no-javascript': Rule;
7
+ readonly 'trailing-slash': Rule;
8
+ readonly 'absolute-site-urls': Rule;
9
+ readonly redirects: Rule;
10
+ };
11
+ export declare function inspect(ctx: RuleTestContext, rules?: {
12
+ readonly 'missing-hash': Rule;
13
+ readonly 'no-error-response': Rule;
14
+ readonly 'no-baseless': Rule;
15
+ readonly 'no-javascript': Rule;
16
+ readonly 'trailing-slash': Rule;
17
+ readonly 'absolute-site-urls': Rule;
18
+ readonly redirects: Rule;
19
+ }): Partial<LinkInspectionResult>;
@@ -1,6 +1,4 @@
1
- import { getHeader } from "h3";
2
1
  import { parseURL } from "ufo";
3
- import { fixSlashes } from "site-config-stack";
4
2
  import RuleTrailingSlash from "./inspections/trailing-slash.mjs";
5
3
  import RuleMissingHash from "./inspections/missing-hash.mjs";
6
4
  import RuleNoBaseLess from "./inspections/no-baseless.mjs";
@@ -8,22 +6,20 @@ import RuleNoJavascript from "./inspections/no-javascript.mjs";
8
6
  import RuleAbsoluteSiteUrls from "./inspections/absolute-site-urls.mjs";
9
7
  import RuleRedirects from "./inspections/redirects.mjs";
10
8
  import RuleNoErrorResponse from "./inspections/no-error-response-status.mjs";
11
- const inspection = [
12
- RuleMissingHash(),
13
- RuleNoErrorResponse(),
14
- RuleNoBaseLess(),
15
- RuleNoJavascript(),
16
- RuleTrailingSlash(),
17
- RuleAbsoluteSiteUrls(),
18
- RuleRedirects()
19
- ];
20
- export function inspect(ctx, rules) {
21
- if (!rules)
22
- rules = inspection;
9
+ export const DefaultInspections = {
10
+ "missing-hash": RuleMissingHash(),
11
+ "no-error-response": RuleNoErrorResponse(),
12
+ "no-baseless": RuleNoBaseLess(),
13
+ "no-javascript": RuleNoJavascript(),
14
+ "trailing-slash": RuleTrailingSlash(),
15
+ "absolute-site-urls": RuleAbsoluteSiteUrls(),
16
+ "redirects": RuleRedirects()
17
+ };
18
+ export function inspect(ctx, rules = DefaultInspections) {
23
19
  const res = { error: [], warning: [], fix: ctx.link, link: ctx.link };
24
20
  let link = ctx.link;
25
21
  const url = parseURL(link);
26
- if (!url.pathname && !url.protocol && !url.host) {
22
+ if (!url.pathname && !url.protocol && !url.host && !link.startsWith("javascript:")) {
27
23
  res.error.push({
28
24
  name: "invalid-url",
29
25
  scope: "error",
@@ -31,13 +27,12 @@ export function inspect(ctx, rules) {
31
27
  });
32
28
  return res;
33
29
  }
34
- const fromPath = fixSlashes(false, parseURL(getHeader(ctx.e, "referer") || "/").pathname);
35
- for (const rule of rules) {
30
+ const validInspections = Object.entries(rules).filter(([name]) => !ctx.skipInspections || !ctx.skipInspections.includes(name)).map(([, rule]) => rule);
31
+ for (const rule of validInspections) {
36
32
  rule.test({
37
33
  ...ctx,
38
34
  link,
39
35
  url,
40
- fromPath,
41
36
  report(obj) {
42
37
  res[obj.scope].push(obj);
43
38
  if (obj.fix)
@@ -1,12 +1,10 @@
1
1
  import { joinURL } from "ufo";
2
- import { getHeader } from "h3";
3
2
  import { defineRule, isInvalidLinkProtocol } from "./util.mjs";
4
3
  export default function RuleNoBaseLess() {
5
4
  return defineRule({
6
- test({ link, e, report }) {
5
+ test({ link, fromPath, report }) {
7
6
  if (link.startsWith("/") || link.startsWith("http") || isInvalidLinkProtocol(link) || link.startsWith("#"))
8
7
  return;
9
- const fromPath = getHeader(e, "referer") || "";
10
8
  report({
11
9
  name: "no-baseless",
12
10
  scope: "warning",
@@ -9,7 +9,7 @@ export default function RuleNoErrorResponse() {
9
9
  scope: "error",
10
10
  message: `Should not respond with ${response.status} ${response.statusText}.`
11
11
  };
12
- if (link.startsWith("/")) {
12
+ if (link.startsWith("/") && pageSearch) {
13
13
  const fix = pageSearch.search(link)?.[0]?.item;
14
14
  if (fix && fix !== link) {
15
15
  payload.fix = fix;
@@ -10,10 +10,10 @@ export default function RuleRedirects() {
10
10
  message: "Should not redirect.",
11
11
  tip: "Redirects use up your crawl budget and increase loading times, it's recommended to avoid them when possible."
12
12
  };
13
- const fix = response.headers.get("location");
13
+ const fix = typeof response.headers?.get === "function" ? response.headers.get("location") : response.headers?.location || false;
14
14
  if (fix) {
15
15
  payload.fix = fix;
16
- payload.fixDescription = `Set to redirected URL ${fix}.`;
16
+ payload.fixDescription = `Set to redirect URL ${fix}.`;
17
17
  }
18
18
  report(payload);
19
19
  }
@@ -2,7 +2,7 @@ import Fuse from "fuse.js";
2
2
  import { defineNitroPlugin } from "nitropack/dist/runtime/plugin";
3
3
  import { useRuntimeConfig } from "#imports";
4
4
  export default defineNitroPlugin(async (nitro) => {
5
- const runtimeConfig = useRuntimeConfig()["nuxt-link-checker"];
5
+ const runtimeConfig = useRuntimeConfig().public["nuxt-link-checker"];
6
6
  const pages = runtimeConfig.hasLinksEndpoint ? await $fetch("/api/__link_checker__/links") : [];
7
7
  nitro._linkCheckerPageSearch = new Fuse(pages, {
8
8
  threshold: 0.5
@@ -1,7 +1,8 @@
1
1
  import { computed, createApp, h, ref, shallowReactive, unref } from "vue";
2
+ import { createFilter } from "../../../urlFilter";
2
3
  import Main from "./Main.vue";
3
4
  import { linkDb } from "./state.mjs";
4
- import { useRoute } from "#imports";
5
+ import { useRoute, useRuntimeConfig } from "#imports";
5
6
  function resolveDevtoolsIframe() {
6
7
  return document.querySelector("#nuxt-devtools-iframe")?.contentWindow?.__NUXT_DEVTOOLS__;
7
8
  }
@@ -25,17 +26,23 @@ export async function setupLinkCheckerClient({ nuxt }) {
25
26
  let devtoolsClient;
26
27
  let isOpeningDevtools = false;
27
28
  const route = useRoute();
28
- let startQueueId;
29
- let domUpdateTimer;
29
+ let startQueueIdleId;
30
+ let startQueueTimeoutId;
31
+ const runtimeConfig = useRuntimeConfig().public["nuxt-link-checker"];
32
+ const filter = createFilter({
33
+ exclude: runtimeConfig.excludeLinks
34
+ });
30
35
  const client = shallowReactive({
31
36
  isWorkingQueue: false,
32
37
  scanLinks() {
33
38
  elMap = {};
34
39
  visibleLinks.clear();
35
40
  const ids = [...new Set([...document.querySelectorAll("#__nuxt [id]")].map((el) => el.id))];
36
- [...document.querySelectorAll("#__nuxt a")].map((el) => ({ el, link: el.getAttribute("href") })).forEach(({ el, link }) => {
41
+ [...document.querySelectorAll("#__nuxt a[href]")].map((el) => ({ el, link: el.getAttribute("href") })).forEach(({ el, link }) => {
37
42
  if (!link)
38
43
  return;
44
+ if (!filter(link))
45
+ return;
39
46
  visibleLinks.add(link);
40
47
  elMap[link] = elMap[link] || [];
41
48
  if (elMap[link].includes(el))
@@ -88,7 +95,7 @@ export async function setupLinkCheckerClient({ nuxt }) {
88
95
  workQueue();
89
96
  },
90
97
  maybeAttachEls(payload) {
91
- if (!payload || payload.passes)
98
+ if (!payload || payload.passes || !runtimeConfig.showLiveInspections)
92
99
  return;
93
100
  const els = elMap?.[payload.link] || [];
94
101
  for (const el of els)
@@ -144,11 +151,16 @@ export async function setupLinkCheckerClient({ nuxt }) {
144
151
  client.restart();
145
152
  },
146
153
  restart() {
147
- startQueueId && cancelIdleCallback(startQueueId);
148
- startQueueId = requestIdleCallback(() => {
154
+ startQueueIdleId && cancelIdleCallback(startQueueIdleId);
155
+ startQueueIdleId = requestIdleCallback(() => {
149
156
  client.stopQueueWorker();
150
- client.scanLinks();
151
- client.startQueueWorker();
157
+ if (!startQueueTimeoutId) {
158
+ startQueueTimeoutId = setTimeout(() => {
159
+ client.scanLinks();
160
+ client.startQueueWorker();
161
+ startQueueTimeoutId = false;
162
+ }, 250);
163
+ }
152
164
  });
153
165
  },
154
166
  start() {
@@ -157,18 +169,19 @@ export async function setupLinkCheckerClient({ nuxt }) {
157
169
  client.reset(true);
158
170
  });
159
171
  import.meta.hot.on("vite:afterUpdate", (ctx) => {
160
- console.log("vite after update", ctx.type, ctx.updates);
161
172
  if (ctx.updates.some((c) => c.type === "js-update"))
162
173
  client.reset(true);
163
174
  });
164
175
  }
165
176
  const observer = new MutationObserver(() => {
166
- domUpdateTimer && clearTimeout(domUpdateTimer);
167
- domUpdateTimer = setTimeout(() => {
168
- client.reset(false);
169
- }, 500);
177
+ client.reset(false);
178
+ });
179
+ observer.observe(document.querySelector("#__nuxt"), {
180
+ childList: true,
181
+ subtree: true,
182
+ // we only care if links are added, removed or updated
183
+ attributeFilter: ["href"]
170
184
  });
171
- observer.observe(document.querySelector("#__nuxt"), { childList: true, subtree: true });
172
185
  if (nuxt.vueApp._instance)
173
186
  nuxt.vueApp._instance.appContext.provides.linkChecker = client;
174
187
  const holder = document.createElement("div");
@@ -1,36 +1,36 @@
1
- import { defineEventHandler, getQuery, readBody } from "h3";
1
+ import { defineEventHandler, getHeader, getQuery, readBody } from "h3";
2
+ import { fixSlashes } from "site-config-stack";
3
+ import { parseURL } from "ufo";
2
4
  import { inspect } from "../../inspect.mjs";
3
5
  import { generateFileLinkDiff, generateFileLinkPreviews } from "../util.mjs";
4
6
  import { isInvalidLinkProtocol } from "../../inspections/util.mjs";
5
- import { useNitroApp, useSiteConfig } from "#imports";
7
+ import { crawlFetch } from "../../sharedUtils.mjs";
8
+ import { useNitroApp, useRuntimeConfig, useSiteConfig } from "#imports";
6
9
  export default defineEventHandler(async (e) => {
7
10
  const link = decodeURIComponent(getQuery(e).link);
8
11
  const body = await readBody(e);
9
12
  const { ids, paths } = body;
10
13
  const partialCtx = {
11
14
  ids,
12
- e,
15
+ fromPath: fixSlashes(false, parseURL(getHeader(e, "referer") || "/").pathname),
13
16
  siteConfig: useSiteConfig(e)
14
17
  };
18
+ const runtimeConfig = useRuntimeConfig().public["nuxt-link-checker"];
15
19
  let response;
16
20
  if (isInvalidLinkProtocol(link) || link.startsWith("#")) {
17
21
  response = { status: 200, statusText: "OK", headers: {} };
18
22
  } else {
19
- const timeoutController = new AbortController();
20
- const abortRequestTimeout = setTimeout(() => timeoutController.abort(), 5e3);
21
- response = await $fetch.raw(link, {
22
- method: "HEAD",
23
- signal: timeoutController.signal,
24
- headers: {
25
- "user-agent": "Nuxt Link Checker"
26
- }
27
- }).catch(() => ({ status: 404, statusText: "Not Found", headers: {} })).finally(() => clearTimeout(abortRequestTimeout));
23
+ response = await crawlFetch(link, {
24
+ fetch: $fetch.raw,
25
+ timeout: runtimeConfig.timeout
26
+ });
28
27
  }
29
28
  const result = inspect({
30
29
  ...partialCtx,
31
30
  link,
32
31
  pageSearch: useNitroApp()._linkCheckerPageSearch,
33
- response
32
+ response,
33
+ skipInspections: runtimeConfig.skipInspections
34
34
  });
35
35
  const filePaths = paths.map((p) => {
36
36
  const [filepath] = p.split(":");
@@ -1,7 +1,7 @@
1
1
  import { defineEventHandler } from "h3";
2
2
  import { useRuntimeConfig } from "#imports";
3
3
  export default defineEventHandler(async () => {
4
- const runtimeConfig = useRuntimeConfig()["nuxt-link-checker"];
4
+ const runtimeConfig = useRuntimeConfig().public["nuxt-link-checker"];
5
5
  const linkDb = [];
6
6
  if (runtimeConfig.hasSitemapModule) {
7
7
  const sitemapDebug = await $fetch("/api/__sitemap__/debug");
@@ -0,0 +1,8 @@
1
+ export declare function crawlFetch(link: string, options?: {
2
+ fetch?: typeof globalThis.fetch;
3
+ timeout?: number;
4
+ }): Promise<Response | {
5
+ status: number;
6
+ statusText: string;
7
+ headers: {};
8
+ }>;
@@ -0,0 +1,13 @@
1
+ export async function crawlFetch(link, options = {}) {
2
+ const fetch = options.fetch || globalThis.fetch;
3
+ const timeout = options.timeout || 5e3;
4
+ const timeoutController = new AbortController();
5
+ const abortRequestTimeout = setTimeout(() => timeoutController.abort(), timeout);
6
+ return await fetch(link, {
7
+ method: "HEAD",
8
+ signal: timeoutController.signal,
9
+ headers: {
10
+ "user-agent": "Nuxt Link Checker"
11
+ }
12
+ }).catch(() => ({ status: 404, statusText: "Not Found", headers: {} })).finally(() => clearTimeout(abortRequestTimeout));
13
+ }
@@ -1,5 +1,4 @@
1
1
  import type { FetchResponse } from 'ofetch';
2
- import type { H3Event } from 'h3';
3
2
  import type { SiteConfig } from 'nuxt-site-config-kit';
4
3
  import type Fuse from 'fuse.js';
5
4
  import type { ComputedRef, Ref } from 'vue';
@@ -13,10 +12,10 @@ export interface RuleTestContext {
13
12
  ids: string[];
14
13
  fromPath: string;
15
14
  response: FetchResponse<any>;
16
- e: H3Event;
17
15
  siteConfig: SiteConfig;
18
- pageSearch: Fuse<string>;
16
+ pageSearch?: Fuse<string>;
19
17
  report: (report: RuleReport) => void;
18
+ skipInspections?: string[];
20
19
  }
21
20
  export interface RuleReport {
22
21
  name: string;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nuxt-link-checker",
3
3
  "type": "module",
4
- "version": "2.0.0-beta.5",
5
- "packageManager": "pnpm@8.6.10",
4
+ "version": "2.0.0-beta.7",
5
+ "packageManager": "pnpm@8.6.11",
6
6
  "license": "MIT",
7
7
  "funding": "https://github.com/sponsors/harlan-zw",
8
8
  "homepage": "https://github.com/harlan-zw/nuxt-link-checker#readme",
@@ -20,6 +20,11 @@
20
20
  "import": "./dist/module.mjs"
21
21
  }
22
22
  },
23
+ "build": {
24
+ "externals": [
25
+ "ofetch"
26
+ ]
27
+ },
23
28
  "main": "./dist/module.cjs",
24
29
  "types": "./dist/types.d.ts",
25
30
  "files": [
@@ -41,6 +46,7 @@
41
46
  "radix3": "^1.0.1",
42
47
  "shiki-es": "^0.14.0",
43
48
  "sirv": "^2.0.3",
49
+ "site-config-stack": "^1.0.9",
44
50
  "ufo": "^1.2.0"
45
51
  },
46
52
  "devDependencies": {