From 2ebfd2ba9344d60ce329914610d8b8e839a5328a Mon Sep 17 00:00:00 2001 From: CirnoV Date: Tue, 7 May 2024 04:01:59 +0900 Subject: [PATCH] WIP --- eslint.config.js | 12 ++ package.json | 5 +- packages/lint-local-links-valid/package.json | 1 + packages/lint-local-links-valid/src/core.ts | 74 ------------ packages/lint-local-links-valid/src/eslint.ts | 110 +++++++++++++++--- packages/lint-local-links-valid/src/index.ts | 1 + packages/lint-local-links-valid/src/remark.ts | 53 ++++++++- packages/lint-local-links-valid/src/utils.ts | 23 ++++ .../tests/eslint.spec.ts | 20 +++- pnpm-lock.yaml | 6 + 10 files changed, 205 insertions(+), 100 deletions(-) delete mode 100644 packages/lint-local-links-valid/src/core.ts create mode 100644 packages/lint-local-links-valid/src/utils.ts diff --git a/eslint.config.js b/eslint.config.js index 3f0fb7c3d..85f205a53 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,6 +8,8 @@ import * as mdx from "eslint-plugin-mdx"; import prettierRecommended from "eslint-plugin-prettier/recommended"; import react from "eslint-plugin-react"; import sortImports from "eslint-plugin-simple-import-sort"; +import { eslintLintLocalLinksValid } from "lint-local-links-valid"; +import YAMLParser from "yaml-eslint-parser"; /** @type {import("eslint").Linter.RulesRecord} */ const tsRules = { @@ -142,4 +144,14 @@ export default [ "prettier/prettier": "off", }, }, + { + files: ["src/content/docs/_redir.yaml"], + ignores: [], + plugins: { + ...eslintLintLocalLinksValid + }, + languageOptions: { + parser: YAMLParser, + }, + }, ]; diff --git a/package.json b/package.json index 297b69321..5e1ef5cd8 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "httpsnippet-lite": "^3.0.5", "js-yaml": "^4.1.0", "json5": "^2.2.3", + "lint-local-links-valid": "workspace:^", "lodash-es": "^4.17.21", "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx": "^3.0.0", @@ -107,7 +108,6 @@ "remark-lint-list-item-content-indent": "^3.1.2", "remark-lint-list-item-indent": "^3.1.2", "remark-lint-list-item-spacing": "^4.1.2", - "lint-local-links-valid": "workspace:^", "remark-lint-maximum-line-length": "^3.1.3", "remark-lint-no-blockquote-without-marker": "^5.1.2", "remark-lint-no-consecutive-blank-lines": "^4.1.3", @@ -149,7 +149,8 @@ "unified": "^11.0.4", "unist-util-visit": "^5.0.0", "universal-cookie": "^7.1.4", - "unocss": "^0.59.4" + "unocss": "^0.59.4", + "yaml-eslint-parser": "^1.2.2" }, "packageManager": "pnpm@9.0.6", "pnpm": { diff --git a/packages/lint-local-links-valid/package.json b/packages/lint-local-links-valid/package.json index f43984e28..b93259da2 100644 --- a/packages/lint-local-links-valid/package.json +++ b/packages/lint-local-links-valid/package.json @@ -11,6 +11,7 @@ "dependencies": { "eslint": "^9.1.1", "eslint-plugin-yml": "^1.14.0", + "ts-pattern": "^5.1.1", "unified-lint-rule": "^3.0.0", "unist-util-visit": "^5.0.0", "yaml": "^2.4.2", diff --git a/packages/lint-local-links-valid/src/core.ts b/packages/lint-local-links-valid/src/core.ts deleted file mode 100644 index c96e58493..000000000 --- a/packages/lint-local-links-valid/src/core.ts +++ /dev/null @@ -1,74 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; - -interface Options { - baseDir: string; - excludePaths: string[]; - redirects: Record; -} - -export const initLinter = (workingDir: string, options: Partial) => { - if (!options.baseDir) { - throw new Error("Missing required option `baseDir`"); - } - const baseDir = path.resolve(options.baseDir); - const excludePaths = - options.excludePaths?.map((p) => path.join(baseDir, p)) ?? []; - const redirects = Object.fromEntries( - Object.entries(options.redirects ?? {}).map(([from, to]) => { - return [from, to].map((link) => - isLocalLink(link) ? path.join(baseDir, link) : link, - ); - }), - ) as Record; - return async function checkLink( - link: string, - message: (reason: string) => void, - ): Promise { - if (!isLocalLink(link)) { - return; - } - const url = link.split(/[#?]/)[0] ?? ""; - let absPath = ""; - if (path.isAbsolute(url)) { - absPath = path.join(baseDir, url); - } else { - absPath = path.join(workingDir, url); - } - const resolvedPath = resolveRedirect(redirects, absPath); - if (!isLocalLink(resolvedPath)) { - return; - } - if (excludePaths.some((p) => resolvedPath.startsWith(p))) { - return; - } - if (path.extname(resolvedPath) !== "") { - message("Local link should not have an extension"); - return; - } - const task = Promise.any( - [".md", ".mdx"].map((ext) => fs.access(resolvedPath + ext)), - ).catch(() => { - message(`File not found: ${resolvedPath}`); - }); - return task; - }; -}; -export function isLocalLink(url: string): boolean { - try { - new URL(url); - } catch { - return true; - } - return false; -} -const resolveRedirect = ( - redirects: Record, - url: string, -): string => { - let resolved = url; - while (redirects[resolved]) { - resolved = redirects[resolved]?.split(/[#?]/)[0] ?? resolved; - } - return resolved; -}; diff --git a/packages/lint-local-links-valid/src/eslint.ts b/packages/lint-local-links-valid/src/eslint.ts index 4c8a1d9ab..34378bc25 100644 --- a/packages/lint-local-links-valid/src/eslint.ts +++ b/packages/lint-local-links-valid/src/eslint.ts @@ -1,10 +1,12 @@ +import fs from "node:fs"; import path from "node:path"; import type { Rule } from "eslint"; import type { RuleListener } from "eslint-plugin-yml/lib/types.js"; +import { match, P } from "ts-pattern"; import type { AST } from "yaml-eslint-parser"; -import * as core from "./core.ts"; +import { isLocalLink } from "./utils.ts"; type Stack = { upper: Stack | null; @@ -14,8 +16,10 @@ type Stack = { | AST.YAMLMapping | AST.YAMLFlowMapping | AST.YAMLPair; - from?: string | null; - to?: string | null; + redirects: Map; + tasks: (() => void)[]; + from?: AST.YAMLPlainScalar; + to?: AST.YAMLPlainScalar; }; interface RuleModule { @@ -29,6 +33,10 @@ export const rule: RuleModule = { messages: { invalidKey: "Key should be 'old' or 'new'", invalidLocalLink: "Local link should start with '/'", + missingOldLink: "Missing 'old' link", + missingNewLink: "Missing 'new' link", + localLinkWithExtension: "Local link should not have an extension", + fileNotFound: "File not found: {{resolvedPath}}", }, schema: [], docs: { @@ -45,7 +53,12 @@ export const rule: RuleModule = { function downStack( node: AST.YAMLSequence | AST.YAMLMapping | AST.YAMLPair, ): void { - stack = { upper: stack, node }; + stack = { + upper: stack, + node, + redirects: stack?.redirects ?? new Map(), + tasks: stack?.tasks ?? [], + }; } function upStack(): void { stack = stack && stack.upper; @@ -68,7 +81,23 @@ export const rule: RuleModule = { ["old", "new"].includes(node.key.strValue) ) { if (isYAMLPlainScalar(node.value)) { - console.log(core.isLocalLink); + if (isLocalLink(node.value.strValue)) { + const link = node.value.strValue.split(/[#?]/)[0] ?? ""; + if (!path.isAbsolute(link)) { + context.report({ + loc: node.value.loc, + messageId: "invalidLocalLink", + }); + } + } + switch (node.key.strValue) { + case "old": + stack.from = node.value; + break; + case "new": + stack.to = node.value; + break; + } } else { context.report({ loc: node.value?.loc ?? node.loc, @@ -83,8 +112,63 @@ export const rule: RuleModule = { } } }, - "YAMLMapping:exit": upStack, - "YAMLSequence:exit": upStack, + "YAMLMapping:exit": (node: AST.YAMLMapping) => { + if (!stack) return; + const fromLink = stack?.from?.strValue; + const toLink = stack?.to?.strValue; + + match([stack, [fromLink, toLink]]) + .with( + [P._, [P.string, P.string]], + ([stack, [_fromLink, _toLink]]) => { + const fromLink = _fromLink.split(/[#?]/)[0] ?? ""; + const toLink = _toLink.split(/[#?]/)[0] ?? ""; + stack.redirects.set(fromLink, toLink); + stack.tasks.push(() => { + if (!isLocalLink(toLink)) return; + if (stack.redirects.has(toLink)) return; + const absPath = path.join(baseDir, toLink); + if (path.extname(absPath) !== "") { + context.report({ + loc: stack.node.loc, + messageId: "localLinkWithExtension", + }); + } + const exists = [".md", ".mdx"].some((ext) => + fs.existsSync(absPath + ext), + ); + if (!exists) { + context.report({ + loc: stack.node.loc, + messageId: "fileNotFound", + data: { resolvedPath: absPath }, + }); + } + }); + }, + ) + .with([P._, [P.any, P.any]], ([_, [fromLink, toLink]]) => { + if (!fromLink) { + context.report({ + loc: node.loc, + messageId: "missingOldLink", + }); + } + if (!toLink) { + context.report({ + loc: node.loc, + messageId: "missingNewLink", + }); + } + }) + .exhaustive(); + upStack(); + }, + "YAMLSequence:exit": () => { + if (!stack) return; + stack.tasks.forEach((task) => task()); + upStack(); + }, }; }, }; @@ -99,18 +183,6 @@ function isYAMLDocument( return node !== null && node !== undefined && node.type === "YAMLDocument"; } -function isYAMLSequence( - node: AST.YAMLNode | null | undefined, -): node is AST.YAMLSequence { - return node !== null && node !== undefined && node.type === "YAMLSequence"; -} - -function isYAMLMapping( - node: AST.YAMLNode | null | undefined, -): node is AST.YAMLMapping { - return node !== null && node !== undefined && node.type === "YAMLMapping"; -} - function isYAMLPlainScalar( node: AST.YAMLNode | null | undefined, ): node is AST.YAMLPlainScalar { diff --git a/packages/lint-local-links-valid/src/index.ts b/packages/lint-local-links-valid/src/index.ts index 6cbcb6831..6a6424b2c 100644 --- a/packages/lint-local-links-valid/src/index.ts +++ b/packages/lint-local-links-valid/src/index.ts @@ -1 +1,2 @@ +export { default as eslintLintLocalLinksValid } from "./eslint.ts"; export { default as remarkLintLocalLinksValid } from "./remark.ts"; diff --git a/packages/lint-local-links-valid/src/remark.ts b/packages/lint-local-links-valid/src/remark.ts index 347dba275..26d01f7b9 100644 --- a/packages/lint-local-links-valid/src/remark.ts +++ b/packages/lint-local-links-valid/src/remark.ts @@ -1,15 +1,16 @@ +import fs from "node:fs/promises"; import path from "node:path"; import { lintRule } from "unified-lint-rule"; import { visit } from "unist-util-visit"; import * as YAML from "yaml"; -import { initLinter } from "./core.ts"; +import { isLocalLink, resolveRedirect } from "./utils.ts"; interface Options { baseDir: string; excludePaths: string[]; - redirects: Record; + redirects: Map; } const remarkLintLocalLinksValid = lintRule( "remark-lint:local-links-valid", @@ -83,4 +84,52 @@ const remarkLintLocalLinksValid = lintRule( await Promise.all(tasks); }, ); +const initLinter = (workingDir: string, options: Partial) => { + if (!options.baseDir) { + throw new Error("Missing required option `baseDir`"); + } + const baseDir = path.resolve(options.baseDir); + const excludePaths = + options.excludePaths?.map((p) => path.join(baseDir, p)) ?? []; + const redirects = new Map( + Object.entries(options.redirects ?? {}).map(([from, to]) => { + return [String(from), String(to)].map((link) => + isLocalLink(link) ? path.join(baseDir, link) : link, + ); + }) as [string, string][], + ); + return async function checkLink( + link: string, + message: (reason: string) => void, + ): Promise { + if (!isLocalLink(link)) { + return; + } + const url = link.split(/[#?]/)[0] ?? ""; + let absPath = ""; + if (path.isAbsolute(url)) { + absPath = path.join(baseDir, url); + } else { + absPath = path.join(workingDir, url); + } + const resolvedPath = resolveRedirect(redirects, absPath); + if (!isLocalLink(resolvedPath)) { + return; + } + if (excludePaths.some((p) => resolvedPath.startsWith(p))) { + return; + } + if (path.extname(resolvedPath) !== "") { + message("Local link should not have an extension"); + return; + } + const task = Promise.any( + [".md", ".mdx"].map((ext) => fs.access(resolvedPath + ext)), + ).catch(() => { + message(`File not found: ${resolvedPath}`); + }); + return task; + }; +}; + export default remarkLintLocalLinksValid; diff --git a/packages/lint-local-links-valid/src/utils.ts b/packages/lint-local-links-valid/src/utils.ts new file mode 100644 index 000000000..babfe51cf --- /dev/null +++ b/packages/lint-local-links-valid/src/utils.ts @@ -0,0 +1,23 @@ +export function isLocalLink(url: string): boolean { + try { + new URL(url); + } catch { + return true; + } + return false; +} +export function resolveRedirect( + redirects: Map, + url: string, +): string { + let resolved = url; + const visited = new Set(); + while (redirects.has(resolved)) { + if (visited.has(resolved)) { + throw new Error("Redirect loop detected"); + } + visited.add(resolved); + resolved = redirects.get(resolved)?.split(/[#?]/)[0] ?? resolved; + } + return resolved; +} diff --git a/packages/lint-local-links-valid/tests/eslint.spec.ts b/packages/lint-local-links-valid/tests/eslint.spec.ts index f9ee90190..5c854ea2f 100644 --- a/packages/lint-local-links-valid/tests/eslint.spec.ts +++ b/packages/lint-local-links-valid/tests/eslint.spec.ts @@ -21,7 +21,7 @@ const ruleTester = new RuleTester({ }, }); -const redirYmlPath = path.resolve(__dirname, "__fixtures__/_redir.yml"); +const redirYamlPath = path.resolve(__dirname, "__fixtures__/_redir.yaml"); ruleTester.run("lint-local-links-valid", rule as unknown as Rule.RuleModule, { valid: [ @@ -32,7 +32,7 @@ ruleTester.run("lint-local-links-valid", rule as unknown as Rule.RuleModule, { - old: /a/b/test2 new: /a/b/c `, - filename: redirYmlPath, + filename: redirYamlPath, }, ], invalid: [ @@ -41,7 +41,7 @@ ruleTester.run("lint-local-links-valid", rule as unknown as Rule.RuleModule, { - title: Test url: test `, - filename: redirYmlPath, + filename: redirYamlPath, errors: [ { messageId: "invalidKey", @@ -50,6 +50,20 @@ ruleTester.run("lint-local-links-valid", rule as unknown as Rule.RuleModule, { column: 11, endColumn: 16, }, + { + messageId: "missingOldLink", + line: 2, + endLine: 3, + column: 11, + endColumn: 20, + }, + { + messageId: "missingNewLink", + line: 2, + endLine: 3, + column: 11, + endColumn: 20, + }, { messageId: "invalidKey", line: 3, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c52bb23c1..5819fa538 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -397,6 +397,9 @@ importers: unocss: specifier: ^0.59.4 version: 0.59.4(postcss@8.4.38)(rollup@4.13.0)(vite@5.2.6(@types/node@20.12.7)) + yaml-eslint-parser: + specifier: ^1.2.2 + version: 1.2.2 packages/lint-local-links-valid: dependencies: @@ -406,6 +409,9 @@ importers: eslint-plugin-yml: specifier: ^1.14.0 version: 1.14.0(eslint@9.1.1) + ts-pattern: + specifier: ^5.1.1 + version: 5.1.1 unified-lint-rule: specifier: ^3.0.0 version: 3.0.0