diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index bcab00dd..77f41f32 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -71,15 +71,19 @@ lint: tools: [lychee] commands: - name: lint - run: lychee --config .trunk/configs/.lychee.toml --root-dir . ${target} - output: pass_fail + run: node scripts/trunk-lychee.mjs ${target} + output: regex + parse_regex: "(?P.*):(?P\\d+):(?P\\d+):\\s*(?P\\w+):\\s*(?P.*)" success_codes: [0, 1] read_output_from: stdout direct_configs: - .trunk/configs/.lychee.toml + - scripts/trunk-lychee.mjs cache_results: false environment: - name: GITHUB_TOKEN + - name: PATH + list: ["${env.PATH}"] - name: frontmatter-linter files: [mdx] commands: diff --git a/docs/getting_started/01_installation.mdx b/docs/getting_started/01_installation.mdx index b48482d6..6bd4adc2 100644 --- a/docs/getting_started/01_installation.mdx +++ b/docs/getting_started/01_installation.mdx @@ -209,7 +209,7 @@ The module transparently and automatically compiles sourced scripts and lists of | Service | URL | | :--------- | ---------------------------------------------------------------------- | | Short URL | https://init.zshell.dev | -| GitHub RAW | https://raw.githubusercontent.com/z-shell/src/main/lib/zsh/init.zsh | +| GitHub RAW | https://raw.githubusercontent.com/z-shell/src/main/public/zsh/init.zsh | {/* end-of-file */} {/* links */} @@ -222,7 +222,7 @@ The module transparently and automatically compiles sourced scripts and lists of {/* external */} -[checksum-txt]: https://raw.githubusercontent.com/z-shell/src/main/lib/checksum.txt +[checksum-txt]: https://raw.githubusercontent.com/z-shell/src/main/public/checksum.txt [completion-system]: https://zsh.sourceforge.io/Doc/Release/Completion-System.html#Use-of-compinit [discuss]: https://github.com/orgs/z-shell/discussions/new [dockerfile]: https://github.com/robobenklein/configs/blob/master/Dockerfile diff --git a/scripts/trunk-lychee.mjs b/scripts/trunk-lychee.mjs new file mode 100644 index 00000000..863b313e --- /dev/null +++ b/scripts/trunk-lychee.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node + +import {spawnSync} from "node:child_process"; +import {realpathSync} from "node:fs"; +import {isAbsolute, relative} from "node:path"; +import {fileURLToPath, pathToFileURL} from "node:url"; + +function displayPath(path, cwd) { + return (isAbsolute(path) ? relative(cwd, path) : path).replaceAll("\\", "/"); +} + +export function formatLycheeIssues(report, cwd = process.cwd()) { + const issues = []; + const errorMap = report?.error_map; + if (!errorMap || typeof errorMap !== "object" || Array.isArray(errorMap)) { + return issues; + } + + for (const [path, errors] of Object.entries(errorMap).sort(([left], [right]) => left.localeCompare(right))) { + if (!Array.isArray(errors)) { + continue; + } + for (const error of errors) { + const code = error?.status?.code ?? "error"; + const status = String(error?.status?.text ?? "Link check failed") + .replace(/\s+/g, " ") + .trim(); + const url = String(error?.url ?? "unknown URL"); + issues.push(`${displayPath(path, cwd)}:1:1: error: [${code}] ${url} | ${status}`); + } + } + + return issues; +} + +function main() { + const targets = process.argv.slice(2); + if (targets.length === 0) { + console.error("trunk-lychee: expected at least one target"); + process.exitCode = 2; + return; + } + + const result = spawnSync( + "lychee", + ["--config", ".trunk/configs/.lychee.toml", "--root-dir", ".", "--format", "json", ...targets], + { + cwd: process.cwd(), + encoding: "utf8", + env: process.env, + maxBuffer: 16 * 1024 * 1024, + }, + ); + + if (result.error) { + console.error(`trunk-lychee: ${result.error.message}`); + process.exitCode = 2; + return; + } + + let report; + try { + report = JSON.parse(result.stdout); + } catch (error) { + console.error(result.stderr.trim()); + console.error(`trunk-lychee: invalid JSON output: ${error instanceof Error ? error.message : error}`); + process.exitCode = 2; + return; + } + + const issues = formatLycheeIssues(report); + if (issues.length > 0) { + console.log(issues.join("\n")); + process.exitCode = 1; + return; + } + + if (result.status !== 0) { + console.error(result.stderr.trim() || `trunk-lychee: lychee exited with status ${result.status}`); + process.exitCode = 2; + } +} + +if ( + process.argv[1] && + pathToFileURL(realpathSync(fileURLToPath(import.meta.url))).href === pathToFileURL(realpathSync(process.argv[1])).href +) { + main(); +} diff --git a/scripts/trunk-lychee.test.mjs b/scripts/trunk-lychee.test.mjs new file mode 100644 index 00000000..82d0a2a4 --- /dev/null +++ b/scripts/trunk-lychee.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import {spawnSync} from "node:child_process"; +import {mkdtempSync, rmSync, symlinkSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import test from "node:test"; +import {fileURLToPath} from "node:url"; + +import {formatLycheeIssues} from "./trunk-lychee.mjs"; + +test("formats Lychee JSON failures as Trunk issues", () => { + const report = { + error_map: { + "/repo/docs/install.mdx": [ + { + url: "https://example.com/missing", + status: {code: 404, text: "Rejected status code: 404 Not Found"}, + }, + ], + }, + }; + + assert.deepEqual(formatLycheeIssues(report, "/repo"), [ + "docs/install.mdx:1:1: error: [404] https://example.com/missing | Rejected status code: 404 Not Found", + ]); +}); + +test("returns no issues for a clean Lychee report", () => { + assert.deepEqual(formatLycheeIssues({error_map: {}}, "/repo"), []); +}); + +test("executes the CLI when invoked through a sandbox symlink", () => { + const directory = mkdtempSync(join(tmpdir(), "trunk-lychee-")); + try { + const symlink = join(directory, "trunk-lychee.mjs"); + symlinkSync(fileURLToPath(new URL("./trunk-lychee.mjs", import.meta.url)), symlink); + const result = spawnSync(process.execPath, [symlink], {encoding: "utf8"}); + + assert.equal(result.status, 2); + assert.match(result.stderr, /expected at least one target/); + } finally { + rmSync(directory, {recursive: true, force: true}); + } +});