diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2be1d545..eb40d470 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - - name: npm install - run: npm install + - name: Compile + run: npm install && npm run build - name: Run the linter run: ./lint diff --git a/.gitignore b/.gitignore index e2822c74..165f3da1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ # Ignore hidden Mac OS directory files **/.DS_Store node_modules/ +support/dist/ diff --git a/.prettierignore b/.prettierignore index 178135c2..fefc105e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1 @@ -/dist/ +/support/dist/ diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..ed6e5d3e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,99 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "./lint", + "detail": "Runs all supplied linters. This is the same check that runs on all pull requests.", + "type": "npm", + "script": "lint", + "problemMatcher": { + "fileLocation": ["relative", "${workspaceFolder}"], + "severity": "error", + "owner": "lint-owner", + "source": "lint-source", + "pattern": { + "regexp": "^(.*?):(\\d+)(?::(\\d+))? ((?:MD|VL)\\S+) (.*)$", + "file": 1, + "line": 2, + "column": 3, + "code": 4, + "message": 5 + } + } + }, + { + "label": "./lint (watch)", + "detail": "Runs all linters. This is the same check that runs on all pull requests.", + "type": "npm", + "script": "lint:watch", + "isBackground": true, + "problemMatcher": { + "fileLocation": ["relative", "${workspaceFolder}"], + "severity": "error", + "owner": "lint-owner", + "source": "lint-source", + "pattern": { + "regexp": "^(.*?):(\\d+)(?::(\\d+))? ((?:MD|VL)\\S+) (.*)$", + "file": 1, + "line": 2, + "column": 3, + "code": 4, + "message": 5 + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^lint: RUN$", + "endsPattern": "^lint: (PASS|FAIL)$" + } + } + }, + { + "label": "./lint validate-links", + "detail": "Check markdown for broken internal links & images", + "type": "npm", + "script": "lint:validate-links", + "problemMatcher": { + "fileLocation": ["relative", "${workspaceFolder}"], + "severity": "error", + "owner": "lint-owner", + "source": "lint-source", + "pattern": { + "regexp": "^(.*?):(\\d+)(?::(\\d+))? ((?:MD|VL)\\S+) (.*)$", + "file": 1, + "line": 2, + "column": 3, + "code": 4, + "message": 5 + } + } + }, + { + "label": "./lint validate-links (watch)", + "detail": "Check markdown for broken internal links & images", + "type": "npm", + "script": "lint:validate-links:watch", + "isBackground": true, + "problemMatcher": { + "fileLocation": ["relative", "${workspaceFolder}"], + "severity": "error", + "owner": "lint-owner", + "source": "lint-source", + "pattern": { + "regexp": "^(.*?):(\\d+)(?::(\\d+))? ((?:MD|VL)\\S+) (.*)$", + "file": 1, + "line": 2, + "column": 3, + "code": 4, + "message": 5 + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^validate-links: RUN$", + "endsPattern": "^validate-links: (PASS|FAIL)$" + } + } + } + ] +} diff --git a/dist/parse.js b/dist/parse.js deleted file mode 100644 index bfeee3d8..00000000 --- a/dist/parse.js +++ /dev/null @@ -1,35 +0,0 @@ -import mit from "markdown-it"; -export const parse = (content) => { - const parser = mit(); - const tokens = parser.parse(content, {}); - const parsedLinks = []; - const parsedImages = []; - const scan = (tokens) => { - tokens.forEach((token, index) => { - if (token.type === "link_open") { - const indexOfNextClose = tokens.findIndex((t2, i2) => i2 > index && t2.type === "link_close"); - if (indexOfNextClose > index) { - parsedLinks.push({ - target: token.attrGet("href"), - content: tokens - .slice(index + 1, indexOfNextClose) - .map((t) => t.content) - .join(""), - }); - } - } - if (token.type === "image") - parsedImages.push({ - src: token.attrGet("src"), - alt: token.content, - }); - if (token.children) - scan(token.children); - }); - }; - scan(tokens); - return { - links: parsedLinks, - images: parsedImages, - }; -}; diff --git a/dist/validateLinks.js b/dist/validateLinks.js deleted file mode 100644 index 018c7ea7..00000000 --- a/dist/validateLinks.js +++ /dev/null @@ -1,80 +0,0 @@ -import { exec } from "node:child_process"; -import { readFile } from "node:fs/promises"; -import { parse } from "./parse.js"; -import path, { dirname, normalize } from "node:path/posix"; -import { isAbsolute } from "node:path"; -const findAllFilesInGit = async () => { - return await new Promise((resolve, reject) => { - exec("git ls-files -z", (error, stdout, stderr) => { - if (error) - reject(error); - if (stderr) - reject(new Error(`git ls-files outputted on stderr: ${stderr}`)); - else - resolve(stdout.split("\0").filter(Boolean)); - }); - }); -}; -const findMarkdownFiles = (files) => { - const ignorePattern = /^(README|LICENSE|contributing\/)/; - return files.filter((f) => f.toLocaleLowerCase().endsWith(".md") && !ignorePattern.test(f)); -}; -const scanForLinks = async (filenames) => { - return Promise.all(filenames.map(async (filename) => { - const content = await readFile(filename, "utf-8"); - return { filename, ...parse(content) }; - })); -}; -const externalLinkPattern = /^\w+:/; -const isExternalLink = (t) => externalLinkPattern.test(t); -const main = async () => { - const gitFiles = await findAllFilesInGit(); - // For now, we assume that there are no case clashes - const lowercaseGitFiles = gitFiles.map((s) => s.toLocaleLowerCase()); - const markdownFilenames = findMarkdownFiles(gitFiles); - const parsedFiles = await scanForLinks(markdownFilenames); - let errors = 0; - for (const parsedFile of parsedFiles) { - for (const img of parsedFile.images) { - if (!isExternalLink(img.src)) { - const resolved = path.join(dirname(parsedFile.filename), img.src); - const exists = lowercaseGitFiles.includes(resolved.toLocaleLowerCase()); - if (!exists) { - console.log(`error BROKEN-INTERNAL-IMAGE ${parsedFile.filename}:0 Broken internal image reference ${img.src}`); - ++errors; - } - } - } - for (const link of parsedFile.links) { - if (link.target.startsWith("#")) { - // Already checked by the linter - continue; - } - if (!isExternalLink(link.target)) { - const target = link.target.split("#")[0]; - let resolved; - if (isAbsolute(target)) { - resolved = normalize(`./${target}`); - } - else { - resolved = normalize(path.join(dirname(parsedFile.filename), target)); - } - const isFile = lowercaseGitFiles.includes(resolved.toLocaleLowerCase()); - const resolvedWithTrailingSlash = resolved.endsWith("/") - ? resolved.toLocaleLowerCase() - : `${resolved.toLocaleLowerCase()}/`; - const isDirectory = lowercaseGitFiles.some((s) => s.startsWith(resolvedWithTrailingSlash)); - if (!isFile && !isDirectory) { - console.log(`error BROKEN-INTERNAL-LINK ${parsedFile.filename}:0 Link target does not exist: ${target}`); - ++errors; - } - } - } - } - if (errors > 0) - process.exit(1); -}; -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/lint b/lint index e0dae5d8..e031146b 100755 --- a/lint +++ b/lint @@ -2,41 +2,58 @@ set -eu -if [ "${1:-}" == "--fix" ] ; then - npm exec -- markdownlint --ignore 'node_modules/' --fix '**/*.md' - npm exec -- prettier --write . -else - trap ' - if [ $? -ne 0 ] ; then - echo "Try '\''./lint --fix'\'' to see if any automatic fixes are possible" +validate-links() { + echo "validate-links: RUN" + if npm exec -- node support/dist/validateLinks.js ; then + echo validate-links: PASS + else + echo validate-links: FAIL + return 1 fi - ' 0 +} - rc=0 - - echo "validateLinks: ..." - if npm exec -- node dist/validateLinks.js ; then - echo validateLinks: PASS - else - echo validateLinks: FAIL - rc=1 - fi - - echo "markdownlint: ..." +markdownlint() { + echo "markdownlint: RUN" if npm exec -- markdownlint --ignore 'node_modules/' '**/*.md' ; then echo "markdownlint: PASS" else echo "markdownlint: FAIL" - rc=1 + return 1 fi +} - echo "prettier: ..." +prettier() { + echo "prettier: RUN" if npm exec -- prettier --check . ; then echo "prettier: PASS" else echo "prettier: FAIL" - rc=1 + return 1 fi +} + +all() { + local rc=0 + echo "lint: RUN" + validate-links || rc=1 + markdownlint || rc=1 + prettier || rc=1 - exit $rc + if [ $rc -eq 0 ] ; then + echo "lint: PASS" + else + echo "lint: FAIL" + echo "Try './lint --fix' to see if any automatic fixes are possible" + fi + + return $rc +} + +if [ "${1:-}" == "--fix" ] ; then + npm exec -- markdownlint --ignore 'node_modules/' --fix '**/*.md' || : + npm exec -- prettier --write . || : +elif [ "${1:-}" == "validate-links" ] ; then + validate-links +else + all fi diff --git a/package.json b/package.json index 63d700b5..2e19c8d5 100644 --- a/package.json +++ b/package.json @@ -8,5 +8,13 @@ "@types/markdown-it": "^14.1.2", "@types/node": "^22.14.1", "typescript": "^5.8.3" + }, + "scripts": { + "build": "tsc -p support/tsconfig.json", + "lint": "./lint", + "lint:fix": "./lint --fix", + "lint:validate-links": "./lint validate-links", + "lint:validate-links:watch": "node support/dist/watch.js ./lint validate-links", + "lint:watch": "node support/dist/watch.js ./lint" } } diff --git a/parse.ts b/support/src/parse.ts similarity index 71% rename from parse.ts rename to support/src/parse.ts index 1857dc61..39fb75fc 100644 --- a/parse.ts +++ b/support/src/parse.ts @@ -1,14 +1,17 @@ import mit from "markdown-it"; import type { Token } from "markdown-it/index.js"; +import { sourceLocationOf, type SourceLocation } from "./sourceLocation.js"; export type ParsedLink = { readonly target: string; readonly content: string; + readonly sourceLocation: SourceLocation | null; }; export type ParsedImage = { readonly src: string; readonly alt: string; + readonly sourceLocation: SourceLocation | null; }; export type ParseResult = { @@ -31,21 +34,26 @@ export const parse = (content: string): ParseResult => { ); if (indexOfNextClose > index) { + const target = token.attrGet("href") as string; parsedLinks.push({ - target: token.attrGet("href") as string, + target, content: tokens .slice(index + 1, indexOfNextClose) .map((t) => t.content) .join(""), + sourceLocation: sourceLocationOf(`(${target})`, content), }); } } - if (token.type === "image") + if (token.type === "image") { + const src = token.attrGet("src") as string; parsedImages.push({ - src: token.attrGet("src") as string, + src, alt: token.content, + sourceLocation: sourceLocationOf(`(${src})`, content), }); + } if (token.children) scan(token.children); }); diff --git a/support/src/sourceLocation.ts b/support/src/sourceLocation.ts new file mode 100644 index 00000000..9eecdbb9 --- /dev/null +++ b/support/src/sourceLocation.ts @@ -0,0 +1,22 @@ +export type SourceLocation = { + readonly line0: number; + readonly column0: number; +}; + +export const sourceLocationOf = ( + needle: string, + haystack: string, +): SourceLocation | null => { + const index = haystack.indexOf(needle); + if (index < 0) return null; + + const lines = haystack.substring(0, index).split("\n"); + const lastLine = lines.pop(); + const line0 = lines.length; + const column0 = lastLine!.length; + + return { + line0, + column0, + }; +}; diff --git a/validateLinks.ts b/support/src/validateLinks.ts similarity index 67% rename from validateLinks.ts rename to support/src/validateLinks.ts index 2c4d3a53..968b57a2 100644 --- a/validateLinks.ts +++ b/support/src/validateLinks.ts @@ -4,17 +4,26 @@ import { readFile } from "node:fs/promises"; import { parse, type ParseResult } from "./parse.js"; import path, { dirname, normalize } from "node:path/posix"; import { isAbsolute } from "node:path"; +import type { SourceLocation } from "./sourceLocation.js"; type ParsedFile = ParseResult & { readonly filename: string }; -const findAllFilesInGit = async (): Promise => { +const findAllFiles = async (): Promise => { return await new Promise((resolve, reject) => { - exec("git ls-files -z", (error, stdout, stderr) => { - if (error) reject(error); - if (stderr) - reject(new Error(`git ls-files outputted on stderr: ${stderr}`)); - else resolve(stdout.split("\0").filter(Boolean)); - }); + exec( + "find . -mindepth 1 -name node_modules -prune -o -name .git -prune -o -print0", + (error, stdout, stderr) => { + if (error) reject(error); + if (stderr) reject(new Error(`'find' outputted on stderr: ${stderr}`)); + else + resolve( + stdout + .split("\0") + .filter(Boolean) + .map((s) => s.substring(2)), // Remove leading "./" + ); + }, + ); }); }; @@ -34,11 +43,23 @@ const scanForLinks = async (filenames: string[]): Promise => { ); }; +const showError = ( + filename: string, + sourceLocation: SourceLocation | null, + code: string, + message: string, +) => { + const src = sourceLocation + ? `${sourceLocation.line0 + 1}:${sourceLocation.column0 + 1}` + : "0"; + console.log(`${filename}:${src} ${code} ${message}`); +}; + const externalLinkPattern = /^\w+:/; const isExternalLink = (t: string) => externalLinkPattern.test(t); const main = async () => { - const gitFiles = await findAllFilesInGit(); + const gitFiles = await findAllFiles(); // For now, we assume that there are no case clashes const lowercaseGitFiles = gitFiles.map((s) => s.toLocaleLowerCase()); @@ -56,8 +77,11 @@ const main = async () => { const exists = lowercaseGitFiles.includes(resolved.toLocaleLowerCase()); if (!exists) { - console.log( - `error BROKEN-INTERNAL-IMAGE ${parsedFile.filename}:0 Broken internal image reference ${img.src}`, + showError( + parsedFile.filename, + img.sourceLocation, + "VL002/missing-image-target", + `Image source does not exist: ${img.src}`, ); ++errors; } @@ -90,8 +114,11 @@ const main = async () => { ); if (!isFile && !isDirectory) { - console.log( - `error BROKEN-INTERNAL-LINK ${parsedFile.filename}:0 Link target does not exist: ${target}`, + showError( + parsedFile.filename, + link.sourceLocation, + "VL001/missing-link-target", + `Link target does not exist: ${target}`, ); ++errors; } diff --git a/support/src/watch.ts b/support/src/watch.ts new file mode 100644 index 00000000..7f4ed5b5 --- /dev/null +++ b/support/src/watch.ts @@ -0,0 +1,81 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { watch } from "node:fs"; + +const args = process.argv.slice(2); +if (args.length === 0) { + console.error("Usage: node watch.js COMMAND [ARGS...]"); + process.exit(1); +} + +type State = + | { + readonly tag: "waiting"; + } + | { + readonly tag: "delaying"; + } + | { + readonly tag: "running-clean"; + readonly child: ChildProcess; + } + | { + readonly tag: "running-dirty"; + readonly child: ChildProcess; + }; + +const cmdAndArgs = args; +let state: State = { tag: "waiting" }; + +const markAsDirty = () => { + if (state.tag === "waiting") { + setTimeout(startRun, 200); + state = { tag: "delaying" }; + } else if (state.tag === "running-clean") { + state = { + ...state, + tag: "running-dirty", + }; + } else if (state.tag === "running-dirty") { + // + } +}; + +const startRun = () => { + console.log("Spawning child process"); + const child = spawn(cmdAndArgs[0], cmdAndArgs.slice(1), { + stdio: ["ignore", "inherit", "inherit"], + }); + + child.on("close", (code, signal) => { + if (code) { + console.log(`Child process failed: exited with code ${code}`); + } else if (signal) { + console.log(`Child process failed: killed by ${signal}`); + } else { + console.log(`Child process succeeded`); + } + + if (state.tag === "running-clean") { + console.log("Waiting..."); + state = { tag: "waiting" }; + } else if (state.tag === "running-dirty") { + startRun(); + } + }); + + state = { + tag: "running-clean", + child, + }; +}; + +const listener = (event: string, filename: string | null) => { + console.log(`Watcher event: ${event} ${filename}`); + if (filename?.startsWith(".git/")) return; + if (filename?.startsWith("node_modules/")) return; + markAsDirty(); +}; + +watch(".", { recursive: true, encoding: "binary" }, listener); +console.log("Initiating first run"); +startRun(); diff --git a/tsconfig.json b/support/tsconfig.json similarity index 91% rename from tsconfig.json rename to support/tsconfig.json index beb9c155..8560e8c6 100644 --- a/tsconfig.json +++ b/support/tsconfig.json @@ -3,6 +3,7 @@ "target": "ESNext", "module": "NodeNext", "moduleResolution": "nodenext", + "rootDir": "./src/", "outDir": "./dist/", "esModuleInterop": true, "forceConsistentCasingInFileNames": true,