UNPKG

next-validate-link

Version:

An utility to validate links in markdown file

427 lines (413 loc) 13.2 kB
// 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 };