next-validate-link
Version:
An utility to validate links in markdown file
427 lines (413 loc) • 13.2 kB
JavaScript
// src/presets/next.ts
import * as path from "node:path";
import fg from "fast-glob";
// src/utils/fs.ts
import { stat } from "node:fs/promises";
function isDirExists(dir) {
return stat(dir).then((res) => res.isDirectory()).catch(() => false);
}
// src/presets/shared.ts
var defaultPopulate = [{}];
var OPTIONAL_CATCH_ALL = /^\[\[\.\.\.(.+)\]\]$/;
var CALCH_ALL = /^\[\.\.\.(.+)\]$/;
function parseSegments(segments) {
const path6 = [];
const params = [];
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (segment.startsWith("(") && segment.endsWith(")")) continue;
let match = OPTIONAL_CATCH_ALL.exec(segment);
if (match) {
params.push("optional");
path6.push(match[1]);
continue;
}
match = CALCH_ALL.exec(segment);
if (match) {
params.push("required");
path6.push(match[1]);
continue;
}
if (segment.startsWith("[") && segment.endsWith("]")) {
params.push("required");
path6.push(segment.slice(1, -1));
continue;
}
params.push(null);
path6.push(segment);
}
return { path: path6, params };
}
function populate(segments, options) {
const parsed = parseSegments(segments);
const countParams = parsed.params.filter((param) => param !== null).length;
if (countParams === 0) {
const meta = options.meta?.[segments.length === 0 ? "/" : segments.join("/")];
return [
{
url: `/${parsed.path.join("/")}`,
meta
}
];
}
const out = [];
let params;
if (options.populate) {
params = options.populate["/"];
const searchPath = [...segments];
while (!params && searchPath.length > 0) {
params = options.populate[searchPath.join("/")];
searchPath.pop();
}
}
params ??= defaultPopulate;
for (const param of params) {
let url = [...parsed.path];
if (countParams > 1 && (Array.isArray(param.value) || typeof param.value === "string")) {
console.warn(
`path ${segments.join("/")} requires multiple params, an object value for populate is expected.`
);
}
let isFallback = false;
for (let i = 0; i < parsed.params.length; i++) {
if (parsed.params[i] === null) continue;
const name = parsed.path[i];
let value;
if (Array.isArray(param.value) || typeof param.value === "string") {
value = param.value;
} else if (param.value && name in param.value) {
value = param.value[name];
}
if (value) {
url[i] = typeof value === "string" ? value : value.join("/");
continue;
}
if (parsed.params[i] === "optional") {
if (i !== parsed.params.length - 1) {
throw new Error("Invalid position of optional catch-all");
}
out.push({
url: `/${url.slice(0, -1).join("/")}`,
meta: param
});
}
url[i] = "(.+)";
isFallback = true;
}
url = url.filter(Boolean);
out.push({
url: isFallback ? new RegExp(`^\\/${url.join("\\/")}$`) : `/${url.join("/")}`,
meta: param
});
}
return out;
}
function populateToScanResult(segments, options, result) {
const out = populate(segments, options);
for (const entry of out) {
if (typeof entry.url === "string") {
result.urls.set(entry.url, entry.meta ?? {});
continue;
}
result.fallbackUrls.push({
url: entry.url,
meta: entry.meta ?? {}
});
}
}
// src/presets/next.ts
async function scanURLs(options = {}) {
const ext = options.extensions ?? ["js", "jsx", "tsx", "md", "mdx"];
const cwd = options.cwd ?? process.cwd();
async function getFiles() {
const suffix = ext.length > 0 ? `.{${ext.join(",")}}` : "";
const appFiles = await fg(`**/page${suffix}`, {
cwd: await isDirExists(path.join(cwd, "src/app")) ? path.join(cwd, "src/app") : path.join(cwd, "app")
});
const pagesFiles = await fg(`**/*${suffix}`, {
cwd: await isDirExists(path.join(cwd, "src/pages")) ? path.join(cwd, "src/pages") : path.join(cwd, "pages")
});
if (options.pages) appFiles.push(...options.pages);
return [
...appFiles.map((file) => {
const dir = path.dirname(file);
return dir === "." ? "" : dir;
}),
...pagesFiles.map((file) => {
const parsed = path.parse(file);
if (parsed.name === "index") return parsed.dir;
return path.join(parsed.dir, parsed.name);
})
];
}
if (options.meta)
for (const key of Object.keys(options.meta)) {
if (!key.endsWith("page.tsx")) continue;
let newKey = path.dirname(key);
if (newKey === ".") newKey = "/";
options.meta[newKey] = options.meta[key];
delete options.meta[key];
}
const result = { urls: /* @__PURE__ */ new Map(), fallbackUrls: [] };
const files = await getFiles();
for (const file of files) {
populateToScanResult(
file.length === 0 ? [] : file.split(path.sep),
options,
result
);
}
return result;
}
// src/presets/astro.ts
import * as path2 from "node:path";
import fg2 from "fast-glob";
async function scanURLs2(options = {}) {
const ext = options.extensions ?? ["astro", "md", "mdx"];
const cwd = options.cwd ?? process.cwd();
async function getFiles() {
const suffix = ext.length > 0 ? `.{${ext.join(",")}}` : "";
const pagesFiles = await fg2(`**/*${suffix}`, {
cwd: path2.join(cwd, "src/pages")
});
return pagesFiles.map((file) => {
const parsed = path2.parse(file);
if (parsed.name === "index") return parsed.dir;
return path2.join(parsed.dir, parsed.name);
});
}
const result = { urls: /* @__PURE__ */ new Map(), fallbackUrls: [] };
const files = options.pages ?? await getFiles();
for (const file of files) {
populateToScanResult(file.split(path2.sep), options, result);
}
return result;
}
// src/presets/nuxt.ts
import * as path3 from "node:path";
import fg3 from "fast-glob";
async function scanURLs3(options = {}) {
const ext = options.extensions ?? ["vue", "md", "mdx"];
const cwd = options.cwd ?? process.cwd();
async function getFiles() {
const suffix = ext.length > 0 ? `.{${ext.join(",")}}` : "";
const pagesFiles = await fg3(`**/*${suffix}`, {
cwd: await isDirExists(path3.join(cwd, "src/pages")) ? path3.join(cwd, "src/pages") : path3.join(cwd, "pages")
});
return pagesFiles.map((file) => {
const parsed = path3.parse(file);
if (parsed.name === "index") return parsed.dir;
return path3.join(parsed.dir, parsed.name);
});
}
const result = { urls: /* @__PURE__ */ new Map(), fallbackUrls: [] };
const files = options.pages ?? await getFiles();
for (const file of files) {
populateToScanResult(file.split(path3.sep), options, result);
}
return result;
}
// src/scan.ts
async function scanURLs4({
preset,
...options
} = {}) {
if (preset === "astro") return scanURLs2(options);
if (preset === "nuxt") return scanURLs3(options);
return scanURLs(options);
}
// src/validate.ts
import { remark } from "remark";
import { visit } from "unist-util-visit";
import * as path5 from "node:path";
// src/check-external-url.ts
async function checkExternalUrl(url) {
const parsed = new URL(url);
if (parsed.hostname === "localhost") return;
const res = await fetch(parsed, {
method: "HEAD"
}).catch(() => void 0);
if (!res) return "not-found";
if (!res.ok) {
if (res.status === 404) return "not-found";
console.warn(`${url} responded status ${res.status}, is it expected?`);
}
}
// src/validate.ts
import remarkGfm from "remark-gfm";
// src/sample.ts
import path4 from "node:path";
import fs from "node:fs/promises";
import FastGlob from "fast-glob";
import matter from "gray-matter";
async function readFileFromPath(file, pathToUrl) {
const content = await fs.readFile(path4.resolve(file)).then((res) => res.toString());
const parsed = matter(content);
return {
path: file,
data: parsed.data,
content: parsed.content,
url: pathToUrl ? pathToUrl(file) : void 0
};
}
async function readFiles(patterns, options = {}) {
const files = await FastGlob(patterns);
return await Promise.all(
files.map((file) => readFileFromPath(file, options.pathToUrl))
);
}
// src/utils/url.ts
function splitPath(path6) {
return path6.split("/").filter((p) => p.length > 0);
}
function resolveUrl(base, relative) {
const v1 = splitPath(base);
const v2 = splitPath(relative);
while (v2.length > 0) {
switch (v2[0]) {
case "..":
v1.pop();
break;
case ".":
break;
default:
v1.push(v2[0]);
}
v2.shift();
}
return v1.join("/");
}
// src/validate.ts
var processor = remark().use(remarkGfm);
async function validateFiles(files, config) {
const mdExtensions = [".md", ".mdx"];
async function run(file) {
const resolved = typeof file === "string" ? await readFileFromPath(file, config.pathToUrl) : file;
if (!mdExtensions.includes(path5.extname(resolved.path))) {
console.warn(
`format unsupported: ${resolved.path}, supported: ${mdExtensions.join(", ")}`
);
return { file: resolved.path, detected: [] };
}
return {
file: resolved.path,
detected: await validateMarkdown(resolved.content, {
...config,
baseUrl: resolved.url ? resolved.url.split("/").slice(0, -1).join("/") : config.baseUrl,
baseDir: path5.dirname(resolved.path)
})
};
}
return (await Promise.all(files.map(run))).filter(
(err) => err.detected.length > 0
);
}
async function validateMarkdown(content, config) {
const tree = processor.parse({ value: content });
const detected = [];
const tasks = [];
visit(tree, "link", (node) => {
if (!node.position) return;
const pos = node.position;
tasks.push(
detect(node.url, config).then((result) => {
if (result) {
detected.push([node.url, pos.start.line, pos.start.column, result]);
}
}).catch((err) => {
detected.push([node.url, pos.start.line, pos.start.column, err]);
})
);
});
await Promise.all(tasks);
return detected;
}
async function detect(href, config) {
const determinatePathname = config.determinatePathname ?? defaultDeterminatePathname;
if (href.startsWith("mailto:")) return;
if (href.match(/https?:\/\//)) {
if (config.checkExternal) {
return await checkExternalUrl(href);
}
return;
}
if (config.whitelist) {
if (Array.isArray(config.whitelist) && config.whitelist.includes(href))
return;
if (typeof config.whitelist === "function" && config.whitelist(href))
return;
}
const [pathnameWithQuery, fragment] = href.split("#", 2);
let [pathname, query] = pathnameWithQuery.split("?", 2);
if (pathname.length === 0 || pathname === "./") return;
const type = await determinatePathname(pathname, config);
if (type === "relative-url" && config.baseUrl) {
pathname = resolveUrl(config.baseUrl, pathname);
}
if (type === "relative-file-path" && config.pathToUrl) {
const filePath = path5.join(config.baseDir ?? "", pathname);
pathname = config.pathToUrl(filePath);
}
if (!pathname.startsWith("/")) pathname = "/" + pathname;
let meta = config.scanned.urls.get(pathname);
if (!meta) {
meta = config.scanned.fallbackUrls.find((fallbackUrl) => {
return fallbackUrl.url.test(pathname);
})?.meta;
}
if (!meta) return "not-found";
const validFragment = config.ignoreFragment || !fragment || !meta.hashes || meta.hashes.includes(fragment);
if (!validFragment) {
return "invalid-fragment";
}
const validQuery = config.ignoreQuery || !query || !meta.queries || meta.queries.some((item) => new URLSearchParams(item).toString() === query);
if (!validQuery) {
return "invalid-query";
}
}
async function defaultDeterminatePathname(pathname, config) {
if (!pathname.startsWith(".")) return "url";
if (config.pathToUrl && (pathname.endsWith(".md") || pathname.endsWith(".mdx"))) {
return "relative-file-path";
}
return "relative-url";
}
// src/print.ts
import picocolors from "picocolors";
function printErrors(errors, throwError = false) {
let totalErrors = 0;
const logs = [];
for (const error of errors) {
logs.push(
picocolors.bold(picocolors.redBright(`Invalid URLs in ${error.file}:`))
);
error.detected.forEach(([content, line, column, reason]) => {
logs.push(
`${picocolors.bold(content)}: ${reason instanceof Error ? reason.message : reason} at line ${line} column ${column}`
);
});
logs.push(picocolors.dim("------"));
totalErrors += error.detected.length;
}
const summary = `${errors.length} errored file, ${totalErrors} errors`;
logs.push(
picocolors.bold(
totalErrors > 0 ? picocolors.redBright(summary) : picocolors.greenBright(summary)
)
);
if (throwError && totalErrors > 0) {
console.error(logs.join("\n"));
process.exit(1);
} else {
console.log(logs.join("\n"));
}
}
export {
detect,
printErrors,
readFileFromPath,
readFiles,
scanURLs4 as scanURLs,
validateFiles,
validateMarkdown
};