Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
CirnoV committed May 6, 2024
1 parent a5ae303 commit 2ebfd2b
Show file tree
Hide file tree
Showing 10 changed files with 205 additions and 100 deletions.
12 changes: 12 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -142,4 +144,14 @@ export default [
"prettier/prettier": "off",
},
},
{
files: ["src/content/docs/_redir.yaml"],
ignores: [],
plugins: {
...eslintLintLocalLinksValid
},
languageOptions: {
parser: YAMLParser,
},
},
];
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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": "[email protected]",
"pnpm": {
Expand Down
1 change: 1 addition & 0 deletions packages/lint-local-links-valid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
74 changes: 0 additions & 74 deletions packages/lint-local-links-valid/src/core.ts

This file was deleted.

110 changes: 91 additions & 19 deletions packages/lint-local-links-valid/src/eslint.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,8 +16,10 @@ type Stack = {
| AST.YAMLMapping
| AST.YAMLFlowMapping
| AST.YAMLPair;
from?: string | null;
to?: string | null;
redirects: Map<string, string>;
tasks: (() => void)[];
from?: AST.YAMLPlainScalar;
to?: AST.YAMLPlainScalar;
};

interface RuleModule {
Expand All @@ -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: {
Expand All @@ -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<string, string>(),
tasks: stack?.tasks ?? [],
};
}
function upStack(): void {
stack = stack && stack.upper;
Expand All @@ -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,
Expand All @@ -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();
},
};
},
};
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/lint-local-links-valid/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default as eslintLintLocalLinksValid } from "./eslint.ts";
export { default as remarkLintLocalLinksValid } from "./remark.ts";
53 changes: 51 additions & 2 deletions packages/lint-local-links-valid/src/remark.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
redirects: Map<string, string>;
}
const remarkLintLocalLinksValid = lintRule(
"remark-lint:local-links-valid",
Expand Down Expand Up @@ -83,4 +84,52 @@ const remarkLintLocalLinksValid = lintRule(
await Promise.all(tasks);
},
);
const initLinter = (workingDir: string, options: Partial<Options>) => {
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<void> {
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;
23 changes: 23 additions & 0 deletions packages/lint-local-links-valid/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export function isLocalLink(url: string): boolean {
try {
new URL(url);
} catch {
return true;
}
return false;
}
export function resolveRedirect(
redirects: Map<string, string>,
url: string,
): string {
let resolved = url;
const visited = new Set<string>();
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;
}
Loading

0 comments on commit 2ebfd2b

Please sign in to comment.