Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ tasks:
cmds:
- ./tools/check-yaml-schemas.sh

doc-links:
desc: Check every relative Markdown link resolves
cmds:
- ./tools/check-doc-links.sh

ai-integrations:
desc: Validate Codex, ChatGPT, and plugin discovery surfaces
cmds:
Expand All @@ -80,5 +85,6 @@ tasks:
- task: nushell
- task: nushell-demo
- task: yaml-schemas
- task: doc-links
- task: ai-integrations
- task: test
8 changes: 8 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ pre-commit:
glob: "*.{yml,yaml,cff}"
run: ./tools/check-yaml-schemas.sh

# Docs are the product here, so a dangling relative link is a defect. Runs
# on any markdown change; self-scans the whole tree because moving one file
# breaks links in files the commit never touched -- which is exactly how
# #18 shipped four of them.
doc-links:
glob: "*.md"
run: ./tools/check-doc-links.sh

# Codex/ChatGPT discovery surfaces. This is cheap and self-scans, so
# it runs on every pre-commit to catch extensionless repo-scope skill links.
ai-integrations:
Expand Down
2 changes: 1 addition & 1 deletion reference/nushell.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ the script — ideal for CI and pre-commit. This repo's `task nushell`
([nushell/nushell.github.io](https://github.com/nushell/nushell.github.io));
rules here are summarized, with links back to each source page.

See [`skills/nushell`](../skills/nushell/) for the agent-facing summary and
See [`skills/shell`](../skills/shell/) for the agent-facing summary and
[`examples/nushell/`](../examples/nushell/) for parse-clean fragments.

## Invoking `nu` from an agent or a script
Expand Down
2 changes: 1 addition & 1 deletion reference/shebang.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,4 @@ this guide exists for.
- Linux shebang doc fix, commit `f718c9fa87be` (Alan Urmancheev, [@alurm](https://github.com/alurm))
— <https://github.com/torvalds/linux/commit/f718c9fa87be>

See [`skills/shebang`](../skills/shebang/) and [`examples/shebang/`](../examples/shebang/).
See [`skills/shell`](../skills/shell/) and [`examples/shebang/`](../examples/shebang/).
2 changes: 1 addition & 1 deletion reference/streams.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,4 @@ assumes a green lint means the streams are right.
- Greg's Wiki — BashFAQ/001, BashPitfalls —
<https://mywiki.wooledge.org/BashFAQ/001>

See [`skills/streams`](../skills/streams/) and [`examples/streams/`](../examples/streams/).
See [`skills/shell`](../skills/shell/) and [`examples/streams/`](../examples/streams/).
2 changes: 1 addition & 1 deletion reference/zsh.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,4 @@ Three independent reasons `"${TTY}"` is correct and `$(tty)` is not:
[Powerlevel10k](https://github.com/romkatv/powerlevel10k),
[gitstatus](https://github.com/romkatv/gitstatus)

See [`skills/zsh`](../skills/zsh/) for the summary.
See [`skills/shell`](../skills/shell/) for the router summary.
51 changes: 51 additions & 0 deletions tools/check-doc-links.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
#
# check-doc-links.sh — every relative Markdown link must resolve.
#
# Why this exists: collapsing seven skills into one router (#18) deleted
# skills/{nushell,shebang,streams,zsh}/ but left four reference/*.md files
# linking to them. Every other gate stayed green -- lint, tests, examples,
# schemas, ai-integrations -- because none of them reads a link. In a repo
# whose product IS its documentation, a dangling link is a defect.
#
# Only relative targets are checked. External URLs are deliberately not
# fetched: that needs network, is slow, and turns someone else's outage into
# a red build.
#
# One grep+sed per file is a deliberate choice over a single clever pass. A
# rewrite that hoisted both out of the loop to avoid the subprocesses silently
# mangled its own field splitting and reported every link as broken. Two
# subprocesses per markdown file is cheap; a checker that lies is not.
set -euo pipefail

status=0
checked=0

while IFS= read -r file; do
dir=$(dirname "${file}")

while IFS= read -r target; do
[[ -z ${target} ]] && continue
case "${target}" in
http://* | https://* | mailto:* | '#'*) continue ;;
*) ;;
esac
Comment on lines +29 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current case statement only excludes a hardcoded list of protocols (http, https, mailto). It will fail on other valid URI schemes (like ftp://, news:, tel:, etc.) or protocol-relative URLs (like //example.com), incorrectly treating them as relative file paths and reporting them as broken.

We can make this much more robust and simpler by skipping any target that contains a colon : (which covers all URI schemes) or starts with // (protocol-relative).

Suggested change
case "${target}" in
http://* | https://* | mailto:* | '#'*) continue ;;
*) ;;
esac
case "${target}" in
*:* | //* | '#'*) continue ;;
*) ;;
esac


target=${target%%#*}
[[ -z ${target} ]] && continue

checked=$((checked + 1))
if [[ ! -e "${dir}/${target}" ]]; then
printf 'broken link: %s -> %s\n' "${file}" "${target}" >&2
status=1
fi
Comment on lines +38 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There are two issues with the path resolution here:

  1. URL-Encoded Links: If a relative link contains percent-encoded characters (e.g., %20 for spaces), -e will fail because it looks for the literal %20 on the filesystem instead of the decoded space.
  2. Root-Relative Links: If a link starts with / (referring to the repository root), resolving it as ${dir}/${target} will produce an incorrect path (e.g., reference//some-file.md instead of ./some-file.md).

We can resolve both issues by:

  • Decoding percent-encoded characters using a fast, pure-bash printf expansion.
  • Checking if the decoded target starts with / and resolving it relative to the repository root (.) instead of ${dir}.
Suggested change
if [[ ! -e "${dir}/${target}" ]]; then
printf 'broken link: %s -> %s\n' "${file}" "${target}" >&2
status=1
fi
# Decode percent-encoded characters (e.g., %20 -> space) for the file check
# while keeping the original target for the error message.
decoded_target=$(printf '%b' "${target//%/\\x}")
resolved_path="${dir}/${decoded_target}"
if [[ ${decoded_target} == /* ]]; then
resolved_path=".${decoded_target}"
fi
if [[ ! -e "${resolved_path}" ]]; then
printf 'broken link: %s -> %s\n' "${file}" "${target}" >&2
status=1
fi

# `[text](target)` with no spaces or parens inside the target, which is
# what every link in this repo looks like.
done < <(grep -o '\[[^][]*\]([^() ]*)' "${file}" | sed 's/.*(\(.*\))/\1/')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If a markdown file contains no links at all, grep -o will find no matches and exit with status 1. Under set -o pipefail, this causes the entire pipeline inside the process substitution to return 1. Depending on the bash version and environment, this can cause the script to exit prematurely or behave unpredictably under set -e.

Appending || true (or || :) to the pipeline inside the process substitution ensures it always exits successfully even when no links are found.

Suggested change
done < <(grep -o '\[[^][]*\]([^() ]*)' "${file}" | sed 's/.*(\(.*\))/\1/')
done < <(grep -o '\[[^][]*\]([^() ]*)' "${file}" | sed 's/.*(\(.*\))/\1/' || true)

done < <(git ls-files '*.md')

if [[ ${status} -eq 0 ]]; then
printf 'doc-links: OK (%s relative links resolve)\n' "${checked}"
fi

exit "${status}"
Loading