Showing preview only (275K chars total). Download the full file or copy to clipboard to get everything.
Repository: hahwul/deadfinder
Branch: main
Commit: a8d3f5e6f12c
Files: 97
Total size: 250.9 KB
Directory structure:
gitextract_ukz2km7y/
├── .dockerignore
├── .github/
│ ├── FUNDING.yml
│ ├── dependabot.yml
│ ├── labeler.yml
│ └── workflows/
│ ├── ci.yml
│ ├── compat.yml
│ ├── contributors.yml
│ ├── crystal-release.yml
│ ├── docker-build.yml
│ ├── docker-ghcr.yml
│ ├── docs.yml
│ ├── goyo-update.yml
│ ├── labeler.yml
│ ├── publish-snapcraft.yml
│ ├── release-apk.yml
│ ├── release-aur.yml
│ ├── release-deb.yml
│ ├── release-major-tag.yml
│ ├── release-rpm.yml
│ └── release-sbom.yml
├── .gitignore
├── AGENTS.md
├── CHANGELOG.md
├── Dockerfile
├── LICENSE
├── README.md
├── SECURITY.md
├── action.yml
├── aur/
│ └── PKGBUILD
├── docs/
│ ├── AGENTS.md
│ ├── config.toml
│ ├── content/
│ │ ├── about.md
│ │ ├── docs/
│ │ │ ├── _index.md
│ │ │ ├── getting-started/
│ │ │ │ ├── _index.md
│ │ │ │ ├── installation.md
│ │ │ │ └── quickstart.md
│ │ │ ├── integration/
│ │ │ │ ├── _index.md
│ │ │ │ ├── docker.md
│ │ │ │ └── github-action.md
│ │ │ ├── reference/
│ │ │ │ ├── _index.md
│ │ │ │ └── cli-flags.md
│ │ │ └── usage/
│ │ │ ├── _index.md
│ │ │ ├── filtering.md
│ │ │ ├── output-formats.md
│ │ │ └── subcommands.md
│ │ └── index.md
│ ├── static/
│ │ ├── CNAME
│ │ ├── css/
│ │ │ └── style.css
│ │ ├── icons/
│ │ │ └── site.webmanifest
│ │ └── js/
│ │ └── search.js
│ └── templates/
│ ├── 404.html
│ ├── footer.html
│ ├── header.html
│ ├── page.html
│ ├── section.html
│ ├── shortcodes/
│ │ └── alert.html
│ ├── taxonomy.html
│ └── taxonomy_term.html
├── flake.nix
├── github-action/
│ └── README.md
├── justfile
├── scripts/
│ ├── version_check.cr
│ └── version_update.cr
├── shard.yml
├── shards.nix
├── snap/
│ └── snapcraft.yaml
├── spec/
│ ├── compat/
│ │ ├── README.md
│ │ ├── fixtures/
│ │ │ └── server.rb
│ │ ├── golden/
│ │ │ ├── file_json.json
│ │ │ ├── pipe_json.json
│ │ │ ├── url_csv.csv
│ │ │ ├── url_json.json
│ │ │ ├── url_json_include30x.json
│ │ │ ├── url_toml.toml
│ │ │ └── url_yaml.yaml
│ │ └── run.rb
│ ├── deadfinder/
│ │ ├── cli_spec.cr
│ │ ├── http_client_spec.cr
│ │ ├── logger_spec.cr
│ │ ├── runner_spec.cr
│ │ ├── url_pattern_matcher_spec.cr
│ │ ├── utils_spec.cr
│ │ └── visualizer_spec.cr
│ ├── deadfinder_spec.cr
│ └── spec_helper.cr
└── src/
├── cli_main.cr
├── deadfinder/
│ ├── cli.cr
│ ├── completion.cr
│ ├── http_client.cr
│ ├── logger.cr
│ ├── runner.cr
│ ├── types.cr
│ ├── url_pattern_matcher.cr
│ ├── utils.cr
│ ├── version.cr
│ └── visualizer.cr
└── deadfinder.cr
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
.git
.github
docs
examples
github-action
spec
tmp
coverage
lib
deadfinder
AGENTS.md
README.md
SECURITY.md
action.yml
================================================
FILE: .github/FUNDING.yml
================================================
github: hahwul
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
- package-ecosystem: bundler
directory: "/"
schedule:
interval: weekly
target-branch: "main"
================================================
FILE: .github/labeler.yml
================================================
---
config:
- changed-files:
- any-glob-to-any-file:
- shard.yml
- shard.lock
- .github/labeler.yml
dependencies:
- changed-files:
- any-glob-to-any-file:
- shard.yml
- shard.lock
workflow:
- changed-files:
- any-glob-to-any-file:
- .github/workflows/**
- .github/labeler.yml
github-action:
- changed-files:
- any-glob-to-any-file:
- action.yml
docker:
- changed-files:
- any-glob-to-any-file:
- Dockerfile
- .dockerignore
- .github/workflows/docker-ghcr.yml
- .github/workflows/docker-build.yml
code:
- changed-files:
- any-glob-to-any-file:
- src/**
- spec/**
documentation:
- changed-files:
- any-glob-to-any-file:
- README.md
- CHANGELOG.md
- AGENTS.md
- SECURITY.md
- docs/**
================================================
FILE: .github/workflows/ci.yml
================================================
---
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
spec:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
crystal-version: ["1.19.1", "1.20.0"]
steps:
- uses: actions/checkout@v6
- name: Set up Crystal ${{ matrix.crystal-version }}
uses: crystal-lang/install-crystal@v1
with:
crystal: ${{ matrix.crystal-version }}
- name: Install cmake (lexbor dependency)
run: sudo apt-get update && sudo apt-get install -y cmake
- name: Install shards
run: shards install
- name: Run crystal spec
run: crystal spec
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: crystal-lang/install-crystal@v1
- name: Check formatting
run: crystal tool format --check src spec
================================================
FILE: .github/workflows/compat.yml
================================================
---
name: Compat Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
compat:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Ruby (harness driver)
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: false
- name: Install harness Ruby deps
run: gem install --no-document toml-rb
- name: Set up Crystal
uses: crystal-lang/install-crystal@v1
- name: Install cmake (for lexbor)
run: sudo apt-get update && sudo apt-get install -y cmake
- name: Build Crystal binary
run: |
shards install
crystal build src/cli_main.cr -o deadfinder --release
- name: Compat — Crystal implementation
env:
BIN: ./deadfinder
run: ruby spec/compat/run.rb
================================================
FILE: .github/workflows/contributors.yml
================================================
---
name: Contributors
on:
push:
branches: [main]
workflow_dispatch:
inputs:
logLevel:
description: manual run
required: false
default: ''
permissions:
contents: write
pull-requests: write
jobs:
contributors:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: wow-actions/contributors-list@v1.2.1
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
round: false
includeBots: false
svgPath: docs/static/images/CONTRIBUTORS.svg
noCommit: true
- uses: peter-evans/create-pull-request@v8.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore: update contributors"
title: "chore: update contributors"
body: Automated update of `docs/static/images/CONTRIBUTORS.svg`.
branch: chore/update-contributors
delete-branch: true
================================================
FILE: .github/workflows/crystal-release.yml
================================================
---
name: Crystal Release Builds
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: write
env:
CRYSTAL_BUILD_IMAGE: crystallang/crystal:1.19.1-alpine
jobs:
build-linux:
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runs-on: ubuntu-latest
- arch: aarch64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v6
- name: Build static binary (Alpine / musl)
run: |
docker run --rm -v "$PWD":/workspace -w /workspace \
${{ env.CRYSTAL_BUILD_IMAGE }} \
sh -c 'apk add --no-cache cmake make g++ \
&& shards install \
&& crystal build src/cli_main.cr -o deadfinder --release --static --no-debug'
- name: Package
run: |
# Docker container ran as root, so the binary lands as root-owned.
# Reclaim ownership before chmod, otherwise it fails with EPERM.
sudo chown "$(id -u):$(id -g)" deadfinder
chmod +x deadfinder
tar czf deadfinder-linux-${{ matrix.arch }}.tar.gz deadfinder
sha256sum deadfinder-linux-${{ matrix.arch }}.tar.gz > deadfinder-linux-${{ matrix.arch }}.tar.gz.sha256
- name: Upload to release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v3
with:
files: |
deadfinder-linux-${{ matrix.arch }}.tar.gz
deadfinder-linux-${{ matrix.arch }}.tar.gz.sha256
- name: Upload as workflow artifact
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v7
with:
name: deadfinder-linux-${{ matrix.arch }}
path: |
deadfinder-linux-${{ matrix.arch }}.tar.gz
deadfinder-linux-${{ matrix.arch }}.tar.gz.sha256
build-macos:
# macOS x86_64 (macos-13) is no longer built — Apple's Intel transition
# has shrunk GitHub's macos-13 runner pool to the point where releases
# routinely sit in the queue indefinitely. Apple Silicon binaries cover
# current macOS users; Intel users can `brew install` from source or run
# the Apple Silicon binary under Rosetta.
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
runs-on: macos-latest
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v6
- name: Install Crystal and cmake
run: brew install crystal cmake
- name: Build release binary
run: |
shards install
crystal build src/cli_main.cr -o deadfinder --release --no-debug
- name: Package
run: |
chmod +x deadfinder
tar czf deadfinder-macos-${{ matrix.arch }}.tar.gz deadfinder
shasum -a 256 deadfinder-macos-${{ matrix.arch }}.tar.gz > deadfinder-macos-${{ matrix.arch }}.tar.gz.sha256
- name: Upload to release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v3
with:
files: |
deadfinder-macos-${{ matrix.arch }}.tar.gz
deadfinder-macos-${{ matrix.arch }}.tar.gz.sha256
- name: Upload as workflow artifact
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v7
with:
name: deadfinder-macos-${{ matrix.arch }}
path: |
deadfinder-macos-${{ matrix.arch }}.tar.gz
deadfinder-macos-${{ matrix.arch }}.tar.gz.sha256
================================================
FILE: .github/workflows/docker-build.yml
================================================
---
name: Docker Build CI
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
jobs:
build-docker:
strategy:
fail-fast: false
matrix:
include:
- arch: linux/amd64
runner: ubuntu-latest
- arch: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v6
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v4
- name: Prepare platform slug
id: platform
run: echo "slug=$(echo '${{ matrix.arch }}' | tr '/' '-')" >> "$GITHUB_OUTPUT"
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
- name: Build Docker image
uses: docker/build-push-action@v7
with:
context: .
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: ${{ matrix.arch }}
cache-from: type=gha,scope=build-${{ steps.platform.outputs.slug }}
cache-to: type=gha,mode=max,scope=build-${{ steps.platform.outputs.slug }}
================================================
FILE: .github/workflows/docker-ghcr.yml
================================================
---
name: GHCR Publish
on:
push:
branches: [main]
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: Version to build and tag (e.g., 2.0.0)
required: true
type: string
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
strategy:
fail-fast: false
matrix:
platform: [linux/amd64, linux/arm64]
steps:
- uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v4
- name: Log into ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Prepare platform slug
id: platform
run: echo "slug=$(echo '${{ matrix.platform }}' | tr '/' '-')" >> "$GITHUB_OUTPUT"
- name: Build and push by digest
id: build
uses: docker/build-push-action@v7
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=ghcr-${{ steps.platform.outputs.slug }}
cache-to: type=gha,mode=max,scope=ghcr-${{ steps.platform.outputs.slug }}
# push-by-digest only pushes the image manifest; provenance wraps it in
# a manifest list, so the reported digest would point at a list that
# was never pushed and the merge step fails with "not found".
provenance: false
sbom: false
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v7
with:
name: digests-${{ steps.platform.outputs.slug }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v4
- name: Log into ${{ env.REGISTRY }}
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Normalize dispatch version
if: github.event_name == 'workflow_dispatch'
id: normalize
run: |
RAW_VERSION="${{ inputs.version }}"
VERSION="${RAW_VERSION#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Extract Docker metadata (tags)
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'release' }}
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
type=raw,value=${{ steps.normalize.outputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
cleanup:
runs-on: ubuntu-latest
needs: [build, merge]
if: always() && needs.build.result == 'success'
permissions:
packages: write
steps:
# The build matrix pushes per-platform digests with push-by-digest=true,
# which leaves untagged manifests in GHCR after the merge job assembles
# the multi-arch manifest list. Prune them so only tagged versions
# (main, latest, semver) remain — run even if merge fails so orphaned
# per-platform digests don't accumulate in the package listing.
- name: Delete untagged GHCR versions
uses: actions/delete-package-versions@v5
with:
package-name: deadfinder
package-type: container
delete-only-untagged-versions: 'true'
min-versions-to-keep: 0
================================================
FILE: .github/workflows/docs.yml
================================================
---
name: Docs CI/CD
on:
push:
branches: [main]
paths:
- "docs/**"
- ".github/workflows/docs.yml"
pull_request:
branches: [main]
paths:
- "docs/**"
- ".github/workflows/docs.yml"
workflow_dispatch:
permissions:
contents: write
jobs:
build:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build (no deploy)
uses: hahwul/hwaro@main
with:
build_dir: "docs"
build_only: true
deploy:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build and deploy to GitHub Pages
uses: hahwul/hwaro@main
with:
build_dir: "docs"
token: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .github/workflows/goyo-update.yml
================================================
name: Update Goyo Theme
on:
schedule:
# Run every Monday at 9:00 AM UTC
- cron: "0 9 * * 1"
workflow_dispatch: # Allow manual trigger
env:
GIT_USER_NAME: "hahwul"
GIT_USER_EMAIL: "hahwul@gmail.com"
THEME_PATH: "docs/themes/goyo"
jobs:
update-theme:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
submodules: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update Goyo submodule
id: update
run: |
git config user.name "${{ env.GIT_USER_NAME }}"
git config user.email "${{ env.GIT_USER_EMAIL }}"
# Get current commit hash
OLD_COMMIT=$(git rev-parse HEAD:${{ env.THEME_PATH }})
# Update submodule to latest
git submodule update --remote ${{ env.THEME_PATH }}
git add ${{ env.THEME_PATH }}
# Get new commit hash
NEW_COMMIT=$(git --git-dir=${{ env.THEME_PATH }}/.git rev-parse HEAD)
# Check if there are changes
if [ "$OLD_COMMIT" != "$NEW_COMMIT" ]; then
echo "updated=true" >> $GITHUB_OUTPUT
echo "old_commit=$OLD_COMMIT" >> $GITHUB_OUTPUT
echo "new_commit=$NEW_COMMIT" >> $GITHUB_OUTPUT
else
echo "updated=false" >> $GITHUB_OUTPUT
fi
- name: Create Pull Request
if: steps.update.outputs.updated == 'true'
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "Update Goyo theme to latest version"
title: "Update Goyo theme"
body: |
This PR updates the Goyo theme to the latest version.
**Changes:** ${{ steps.update.outputs.old_commit }} → ${{ steps.update.outputs.new_commit }}
Please review the [Goyo changelog](https://github.com/hahwul/goyo/releases) for details on what's new.
---
*This PR was automatically created by the Update Goyo Theme workflow.*
branch: update-goyo-theme
delete-branch: true
labels: dependencies, documentation
================================================
FILE: .github/workflows/labeler.yml
================================================
---
name: Pull Request Labeler
on: [pull_request_target]
jobs:
labeler:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
================================================
FILE: .github/workflows/publish-snapcraft.yml
================================================
---
name: Snapcraft Publish
on:
release:
types: [published]
workflow_dispatch:
inputs:
logLevel:
description: Log level
required: true
default: warning
tags:
description: Test scenario tags
jobs:
snapcraft-releaser:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
platform:
- amd64
steps:
- uses: actions/checkout@v6
- name: Build snap
id: build
uses: canonical/action-build@v1
- name: Publish snap to the stable channel
if: github.event_name == 'release'
uses: snapcore/action-publish@master
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_STORE_LOGIN }}
with:
snap: ${{ steps.build.outputs.snap }}
release: stable
- name: Upload snap as workflow artifact
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v7
with:
name: deadfinder-snap-${{ matrix.platform }}
path: ${{ steps.build.outputs.snap }}
================================================
FILE: .github/workflows/release-apk.yml
================================================
---
name: Build and Release .apk Package
on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g., 2.0.0)"
required: true
type: string
upload_to_release:
description: "Upload .apk to GitHub Release (requires existing tag)"
required: false
type: boolean
default: false
workflow_run:
workflows: ["Crystal Release Builds"]
types: [completed]
permissions:
contents: write
jobs:
build-apk:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'release')
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
asset_arch: x86_64
- arch: aarch64
asset_arch: aarch64
runs-on: ubuntu-latest
container:
image: alpine:latest
steps:
- name: Install build tools
run: apk add --no-cache alpine-sdk sudo github-cli git
- name: Trust workspace
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.event.workflow_run.head_branch }}
- name: Resolve version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
RAW="${{ github.event.inputs.version }}"
else
RAW="${{ github.event.workflow_run.head_branch }}"
fi
VERSION="${RAW#v}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Download prebuilt binary
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release download "${{ env.VERSION }}" \
--pattern "deadfinder-linux-${{ matrix.asset_arch }}.tar.gz" \
--output deadfinder.tar.gz
tar xzf deadfinder.tar.gz
chmod +x deadfinder
- name: Setup abuild
run: |
adduser -D builder
addgroup builder abuild
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
sudo -u builder abuild-keygen -ain
- name: Create APKBUILD
run: |
mkdir -p /home/builder/deadfinder
cp deadfinder /home/builder/deadfinder/
cp LICENSE /home/builder/deadfinder/
cat > /home/builder/deadfinder/APKBUILD <<APKEOF
# Maintainer: HAHWUL <hahwul@gmail.com>
pkgname=deadfinder
pkgver=${{ env.VERSION }}
pkgrel=0
pkgdesc="Find dead (broken) links in web pages, URL lists, and sitemaps."
url="https://github.com/hahwul/deadfinder"
arch="${{ matrix.arch }}"
license="MIT"
source=""
options="!check !strip !tracedeps"
package() {
install -Dm755 "\$srcdir/../deadfinder" "\$pkgdir/usr/bin/deadfinder"
install -Dm644 "\$srcdir/../LICENSE" "\$pkgdir/usr/share/licenses/\$pkgname/LICENSE"
}
APKEOF
sed -i 's/^ //' /home/builder/deadfinder/APKBUILD
chown -R builder:builder /home/builder/deadfinder
- name: Build .apk
run: |
cd /home/builder/deadfinder
sudo -u builder -H CARCH=${{ matrix.arch }} abuild -F checksum
sudo -u builder -H CARCH=${{ matrix.arch }} abuild -Fr
- name: Collect artifacts
run: |
mkdir -p output
# abuild emits deadfinder-${VERSION}-r${pkgrel}.apk without arch in
# the filename, so rename it to include the arch and avoid x86_64 /
# aarch64 jobs overwriting each other on the GitHub Release.
for src in $(find /home/builder/packages -name "*.apk" ! -name "APKINDEX*"); do
cp "$src" "output/deadfinder-${{ env.VERSION }}-${{ matrix.arch }}.apk"
done
ls -la output/
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: deadfinder-${{ env.VERSION }}-${{ matrix.arch }}.apk
path: output/*.apk
- name: Upload .apk to Release
if: github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && inputs.upload_to_release)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for f in output/*.apk; do
gh release upload "${{ env.VERSION }}" "$f" --clobber
done
================================================
FILE: .github/workflows/release-aur.yml
================================================
---
name: Publish AUR Package
on:
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g., 2.0.0)"
required: true
type: string
workflow_run:
workflows: ["Crystal Release Builds"]
types: [completed]
jobs:
publish-aur:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'release')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.event.workflow_run.head_branch }}
- name: Resolve version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
RAW="${{ github.event.inputs.version }}"
else
RAW="${{ github.event.workflow_run.head_branch }}"
fi
VERSION="${RAW#v}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Update PKGBUILD
run: |
sed -i "s/^pkgver=.*/pkgver=${{ env.VERSION }}/" aur/PKGBUILD
sed -i "s/^pkgrel=.*/pkgrel=1/" aur/PKGBUILD
cat aur/PKGBUILD
- name: Publish to AUR
uses: KSXGitHub/github-actions-deploy-aur@v4.1.3
with:
pkgname: deadfinder
pkgbuild: aur/PKGBUILD
commit_username: hahwul
commit_email: hahwul@gmail.com
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
================================================
FILE: .github/workflows/release-deb.yml
================================================
---
name: Build and Release .deb Package
on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g., 2.0.0)"
required: true
type: string
upload_to_release:
description: "Upload .deb to GitHub Release (requires existing tag)"
required: false
type: boolean
default: false
workflow_run:
workflows: ["Crystal Release Builds"]
types: [completed]
permissions:
contents: write
jobs:
build-deb:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'release')
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
asset_arch: x86_64
- arch: arm64
asset_arch: aarch64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.event.workflow_run.head_branch }}
- name: Resolve version
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
RAW="${{ github.event.inputs.version }}"
else
RAW="${{ github.event.workflow_run.head_branch }}"
fi
VERSION="${RAW#v}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "Resolved: $VERSION"
- name: Download prebuilt binary
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release download "${{ env.VERSION }}" \
--pattern "deadfinder-linux-${{ matrix.asset_arch }}.tar.gz" \
--output deadfinder.tar.gz
tar xzf deadfinder.tar.gz
chmod +x deadfinder
- name: Build Debian package layout
run: |
PKGDIR="deadfinder_${{ env.VERSION }}_${{ matrix.arch }}"
mkdir -p "$PKGDIR/DEBIAN" "$PKGDIR/usr/bin" "$PKGDIR/usr/share/doc/deadfinder"
cp deadfinder "$PKGDIR/usr/bin/"
cp README.md "$PKGDIR/usr/share/doc/deadfinder/"
cp LICENSE "$PKGDIR/usr/share/doc/deadfinder/"
cat > "$PKGDIR/DEBIAN/control" <<EOF
Package: deadfinder
Version: ${{ env.VERSION }}
Architecture: ${{ matrix.arch }}
Maintainer: HAHWUL <hahwul@gmail.com>
Description: Find dead (broken) links in web pages, URL lists, and sitemaps.
EOF
dpkg-deb --build "$PKGDIR"
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: deadfinder_${{ env.VERSION }}_${{ matrix.arch }}.deb
path: deadfinder_${{ env.VERSION }}_${{ matrix.arch }}.deb
- name: Upload .deb to Release
if: github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && inputs.upload_to_release)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ env.VERSION }}" \
"deadfinder_${{ env.VERSION }}_${{ matrix.arch }}.deb" --clobber
================================================
FILE: .github/workflows/release-major-tag.yml
================================================
---
name: Update Major Version Tag
on:
release:
types: [published]
permissions:
contents: write
# Force-update the floating `v<major>` tag (e.g. `v2`) to point at the
# latest published <major>.<minor>.<patch> release. Lets callers pin
# `uses: hahwul/deadfinder@v2` and receive bug-fix patches automatically.
# The `v` prefix is required — GitHub Actions rejects bare `2` as a
# "shortened commit SHA". Skips pre-releases so RC tags don't displace
# the stable pointer.
jobs:
bump-major-tag:
if: github.event.release.prerelease == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Move v<major> tag to release commit
env:
TAG: ${{ github.event.release.tag_name }}
run: |
set -e
stripped="${TAG#v}"
major="${stripped%%.*}"
if ! [[ "$major" =~ ^[0-9]+$ ]]; then
echo "Skipping: derived major '$major' from tag '$TAG' is not numeric."
exit 0
fi
movable="v${major}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -f "$movable" "$TAG"
git push origin "refs/tags/$movable" --force
echo "Moved tag '$movable' → '$TAG'."
================================================
FILE: .github/workflows/release-rpm.yml
================================================
---
name: Build and Release .rpm Package
on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g., 2.0.0)"
required: true
type: string
upload_to_release:
description: "Upload .rpm to GitHub Release (requires existing tag)"
required: false
type: boolean
default: false
workflow_run:
workflows: ["Crystal Release Builds"]
types: [completed]
permissions:
contents: write
jobs:
build-rpm:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'release')
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
asset_arch: x86_64
- arch: aarch64
asset_arch: aarch64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.event.workflow_run.head_branch }}
- name: Resolve version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
RAW="${{ github.event.inputs.version }}"
else
RAW="${{ github.event.workflow_run.head_branch }}"
fi
VERSION="${RAW#v}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Download prebuilt binary
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release download "${{ env.VERSION }}" \
--pattern "deadfinder-linux-${{ matrix.asset_arch }}.tar.gz" \
--output deadfinder.tar.gz
tar xzf deadfinder.tar.gz
chmod +x deadfinder
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "stable"
- name: Install nfpm
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest
- name: Build .rpm
run: |
cat > nfpm.yaml <<EOF
name: deadfinder
arch: ${{ matrix.arch }}
version: ${{ env.VERSION }}
maintainer: HAHWUL <hahwul@gmail.com>
description: "Find dead (broken) links in web pages, URL lists, and sitemaps."
license: MIT
contents:
- src: deadfinder
dst: /usr/bin/deadfinder
file_info:
mode: 0755
- src: LICENSE
dst: /usr/share/licenses/deadfinder/LICENSE
file_info:
mode: 0644
EOF
nfpm package --packager rpm --target deadfinder-${{ env.VERSION }}.${{ matrix.arch }}.rpm
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: deadfinder-${{ env.VERSION }}.${{ matrix.arch }}.rpm
path: deadfinder-${{ env.VERSION }}.${{ matrix.arch }}.rpm
- name: Upload .rpm to Release
if: github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && inputs.upload_to_release)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ env.VERSION }}" \
"deadfinder-${{ env.VERSION }}.${{ matrix.arch }}.rpm" --clobber
================================================
FILE: .github/workflows/release-sbom.yml
================================================
---
name: Generate and Upload SBOM
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: write
jobs:
generate-sbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Generate SBOM (CycloneDX, Crystal)
uses: hahwul/cyclonedx-cr@v1.3.0
with:
shard_file: ./shard.yml
lock_file: ./shard.lock
output_file: ./sbom.xml
output_format: xml
spec_version: 1.6
- name: Upload SBOM to Release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v3
with:
files: ./sbom.xml
token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SBOM as workflow artifact
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v7
with:
name: sbom
path: ./sbom.xml
================================================
FILE: .gitignore
================================================
/lib/
/.shards/
*.dwarf
# Built binary
/deadfinder
# Release artifacts
/deadfinder-*.tar.gz
/deadfinder-*.tar.gz.sha256
# Nix
/result
/result-*
.direnv/
# macOS
.DS_Store
# Hwaro docs site
docs/public/*
================================================
FILE: AGENTS.md
================================================
# DeadFinder — Agent Guide
DeadFinder is a CLI tool that finds broken links in web pages, sitemaps, and URL lists. It is written in **Crystal** (v2.x+). The legacy Ruby v1.x implementation lives on the `legacy/v1` branch.
Reference this file first; fall back to the source only when something here is stale.
## Prerequisites
- Crystal >= 1.19.1
- cmake, make, g++ (for building the `lexbor` HTML parser)
## Bootstrap
```bash
shards install
```
## Build
```bash
# Debug (fast compile, slower binary)
crystal build src/cli_main.cr -o deadfinder
# Release (slow compile, fast binary)
crystal build src/cli_main.cr -o deadfinder --release --no-debug
```
## Test
```bash
# Unit specs
crystal spec
# Cross-implementation compat harness (golden files from v1 Ruby output)
BIN="./deadfinder" ruby spec/compat/run.rb
```
The compat harness requires `toml-rb` (`gem install toml-rb`) and spins up a local fixture HTTP server on a random port.
## Run
```bash
./deadfinder url https://example.com
./deadfinder file urls.txt
cat urls.txt | ./deadfinder pipe
./deadfinder sitemap https://example.com/sitemap.xml
```
Full flag list lives in `src/deadfinder/cli.cr` (the `OptionParser` block).
## Layout
```
src/
├── cli_main.cr # binary entry
├── deadfinder.cr # module root (run_* dispatchers, output serialization)
└── deadfinder/
├── cli.cr # OptionParser + subcommand routing
├── types.cr # Options + coverage structs
├── runner.cr # fiber workers, link extraction, HTTP calls
├── http_client.cr # HTTP::Client wrapper (proxy, CONNECT tunneling)
├── utils.cr # URL resolution helpers
├── url_pattern_matcher.cr # match/ignore regex with 1s timeout
├── logger.cr # silent/verbose/debug gating
├── completion.cr # bash/zsh/fish completion generators
├── visualizer.cr # PNG coverage chart (stumpy_png)
└── version.cr
spec/
├── deadfinder_spec.cr
├── spec_helper.cr
├── deadfinder/ # unit specs per module
└── compat/ # black-box harness (v1 golden files)
```
## Conventions
- Output surface is stable: CLI flags, subcommands, and JSON/YAML/TOML/CSV shapes match v1 Ruby. The golden files in `spec/compat/golden/` lock this contract.
- Resolved URLs must preserve the base URL's port (see `utils.cr::origin`). This was a v1 pain point; don't regress.
- Silent default is `false` — the CLI emits logs by default. `-s` / `--silent` opts in.
## CI
- `.github/workflows/compat.yml` — Crystal build + compat harness on every PR
- `.github/workflows/crystal-release.yml` — release-triggered builds for linux x86_64/aarch64 and macOS arm64; uploads tar.gz + sha256 as release assets
- `.github/workflows/docker-build.yml` / `docker-ghcr.yml` — multi-arch image builds (Crystal static binary in Alpine)
## Distribution channels
| Channel | How it picks up a new release |
|---|---|
| GitHub Release binaries | `crystal-release.yml` auto-uploads on `release: published` |
| Docker (`ghcr.io/hahwul/deadfinder`) | `docker-ghcr.yml` on push to main / release |
| Homebrew (homebrew-core) | Manual PR via `brew bump-formula-pr` after tagging |
| GitHub Action (`hahwul/deadfinder@<tag>`) | `action.yml` in repo root; downloads the release binary |
## Legacy (Ruby v1) branch
Gem releases still happen on `legacy/v1`. Bug-fix and security updates only — no new features. Do not port v1 changes to main unless they are true behavioral fixes that should also apply to Crystal.
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows [SemVer](https://semver.org/).
## [Unreleased]
## [2.0.2]
### Fixed
- `action.yml`: save the downloaded release tarball under its real filename (`deadfinder-linux-x86_64.tar.gz` etc.) instead of a generic `deadfinder.tar.gz`, so `sha256sum -c` can resolve the path referenced inside the sidecar. Composite-action callers hit `sha256sum: deadfinder-linux-x86_64.tar.gz: No such file or directory` right after a successful download — the earlier 2.0.0 YAML parser error was masking this. Surfaced by owasp-noir/noir run #24651380673.
## [2.0.1]
### Fixed
- `action.yml`: quote the `version` input description so its embedded `(default: latest)` doesn't trip strict YAML parsers used by the GitHub Actions runner. Caller workflows on `uses: hahwul/deadfinder@2.0.0` saw `Mapping values are not allowed in this context.` and failed at job startup.
- `scripts/version_update.cr`: constrain `^version:\s*.+$/m` patterns with `[^\n]+` — Crystal's `m` flag enables both line-anchor and DOTALL semantics, so `.+$` greedily ate the rest of the file and truncated `shard.yml`/`snap/snapcraft.yaml`/`aur/PKGBUILD` on the first 2.0.1 bump attempt.
## [2.0.0] — Crystal rewrite
### Added
- Crystal implementation (fiber-based concurrency via `spawn` + `Channel`) replaces the Ruby gem as the supported runtime.
- Multi-platform release binaries auto-attached on every GitHub Release: linux x86_64/aarch64 (static/musl), macOS arm64. Each tarball ships alongside a `.sha256` sidecar. (Intel macOS isn't shipped as a prebuilt — see [installation docs](https://hahwul.github.io/deadfinder/docs/getting-started/installation/) for source/Rosetta options.)
- Cross-implementation compatibility harness (`spec/compat/`) — black-box golden files captured from v1 Ruby output, locking the CLI/output contract for Crystal.
- GitHub Action migrated to a composite action that downloads the release binary and verifies its sha256 before running. The `version` input (defaulting to `latest`) lets callers pin a specific release. `worker_headers` is now a first-class input.
- Docker image rebuilt on Crystal static binary (`alpine:3.21` runtime, `<15 MB`). OCI labels, semver tags (`2.0.0` / `2.0` / `latest`), and keyless cosign signatures on every published tag.
### Changed
- Repository layout: Crystal at the root. `src/`, `spec/`, `shard.yml`, `shard.lock` live at the top level; the old `crystal/` subdirectory is gone.
- CLI flag behavior aligns with Ruby v1 exactly — the compat harness enforces this. No user-visible flag renames.
- `--silent` default remains `false`; `-s` opts in. (An earlier Crystal port defaulted silent to `true`; that regression was fixed before the 2.0.0 cut.)
- `--user_agent`, `--proxy_auth`, `--worker_headers` use underscores (as implemented). Prior dashed forms never worked reliably in the old Docker-based action; the new composite action passes the correct names.
### Fixed
- Resolved URLs preserve the base URL's non-default port for both `href="/path"` and `href="relative/path"` shapes (was dropping the port in the Crystal port).
- Docker-based GitHub Action chain: previously relied on a Ruby-gem image and a brittle entrypoint.sh; replaced with a composite action that downloads the release binary directly.
### Removed
- Ruby gem publishing from `main`. The gem continues on the [`legacy/v1`](https://github.com/hahwul/deadfinder/tree/legacy/v1) branch for bug-fix and security releases only.
- `lib/`, `bin/`, `Gemfile`, `Gemfile.lock`, `Rakefile`, `deadfinder.gemspec`, `gemset.nix`, `.rubocop.yml`, `ruby-version`, Ruby-based `flake.nix`, and the legacy Ruby spec suite.
- `github-action/Dockerfile` + `entrypoint.sh` (replaced by composite action in `action.yml`).
### Migration from v1
| You had | Switch to |
|---|---|
| `gem install deadfinder` | `brew install deadfinder` or prebuilt binary from the release |
| `bundle exec deadfinder …` | Same binary on `PATH`, no bundler |
| Docker image (same name) | No change — the image now ships the Crystal binary |
| `uses: hahwul/deadfinder@…` | No change — the action now uses the Crystal binary under the hood |
| `require 'deadfinder'` | Library usage is gone from main. If you depend on it, pin to a v1 gem release or use the CLI. |
If you need a bugfix in v1, open an issue/PR against the [`legacy/v1`](https://github.com/hahwul/deadfinder/tree/legacy/v1) branch.
---
History prior to 2.0.0 was not maintained in this file. See [GitHub Releases](https://github.com/hahwul/deadfinder/releases?q=prerelease%3Afalse) and the [`legacy/v1`](https://github.com/hahwul/deadfinder/tree/legacy/v1) branch for v1 release history.
[Unreleased]: https://github.com/hahwul/deadfinder/compare/2.0.2...HEAD
[2.0.2]: https://github.com/hahwul/deadfinder/releases/tag/2.0.2
[2.0.1]: https://github.com/hahwul/deadfinder/releases/tag/2.0.1
[2.0.0]: https://github.com/hahwul/deadfinder/releases/tag/2.0.0
================================================
FILE: Dockerfile
================================================
FROM crystallang/crystal:1.20.2-alpine AS builder
RUN apk add --no-cache cmake make g++ git
WORKDIR /build
COPY shard.yml shard.lock ./
COPY src/ ./src/
RUN shards install
RUN crystal build src/cli_main.cr -o /build/deadfinder --release --static --no-debug
FROM alpine:3.23
LABEL org.opencontainers.image.title="DeadFinder"
LABEL org.opencontainers.image.description="Find dead links (broken links)."
LABEL org.opencontainers.image.authors="HAHWUL <hahwul@gmail.com>"
LABEL org.opencontainers.image.source="https://github.com/hahwul/deadfinder"
LABEL org.opencontainers.image.documentation="https://github.com/hahwul/deadfinder"
LABEL org.opencontainers.image.licenses="MIT"
LABEL "com.github.actions.name"="DeadFinder"
LABEL "com.github.actions.description"="Find dead (broken) links in files, URLs, or sitemaps"
LABEL "com.github.actions.icon"="link"
LABEL "com.github.actions.color"="red"
ENV LC_ALL=C.UTF-8
RUN apk add --no-cache ca-certificates
COPY --from=builder /build/deadfinder /usr/local/bin/deadfinder
CMD ["deadfinder"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2026 hahwul <hahwul@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
<div align="center">
<img alt="DeadFinder Logo" src="docs/static/images/deadfinder.webp" width="200px;">
<p>Find dead-links (broken links)</p>
</div>
<p align="center">
<a href="https://github.com/hahwul/deadfinder/releases">
<img src="https://img.shields.io/github/v/release/hahwul/deadfinder?style=for-the-badge&color=black&labelColor=black&logo=web"></a>
<a href="https://crystal-lang.org">
<img src="https://img.shields.io/badge/Crystal-000000?style=for-the-badge&logo=crystal&logoColor=white"></a>
</p>
<p align="center">
<a href="https://deadfinder.hahwul.com">Documentation</a> •
<a href="https://deadfinder.hahwul.com/docs/getting-started/installation/">Installation</a> •
<a href="https://deadfinder.hahwul.com/docs/integration/github-action/">Github Action</a> •
<a href="#contributing">Contributing</a> •
<a href="CHANGELOG.md">Changelog</a>
</p>
Dead link (broken link) means a link within a web page that cannot be connected. These links can have a negative impact to SEO and Security. This tool makes it easy to identify and modify.

> **Looking for v1 (Ruby gem)?** It now lives on the [`legacy/v1`](https://github.com/hahwul/deadfinder/tree/legacy/v1) branch and continues to publish the `deadfinder` gem for bug-fix and security releases. `main` hosts the Crystal rewrite (v2+).
## Installation
### Homebrew
```bash
brew install deadfinder
# https://formulae.brew.sh/formula/deadfinder
```
### Docker
```bash
docker run ghcr.io/hahwul/deadfinder:latest deadfinder url https://example.com
```
### Prebuilt binary
Download the archive for your platform from the [latest release](https://github.com/hahwul/deadfinder/releases/latest), extract, and place `deadfinder` on your `PATH`.
### Nix
```bash
nix run github:hahwul/deadfinder
nix profile install github:hahwul/deadfinder
nix develop github:hahwul/deadfinder
```
### Build from source
Requires Crystal >= 1.19.1 and `cmake` (for the `lexbor` HTML parser's postinstall — without it `shards install` fails with `'cmake': No such file or directory`).
```bash
# macOS
brew install crystal cmake
# Debian / Ubuntu
sudo apt install crystal cmake
```
```bash
shards install
crystal build src/cli_main.cr -o deadfinder --release
# or: just build
```
## Using In
### CLI
```bash
deadfinder sitemap https://www.hahwul.com/sitemap.xml
```
### GitHub Action
Pin a specific release tag. `@latest` is **not** a valid Actions ref.
```yml
steps:
- name: Run DeadFinder
uses: hahwul/deadfinder@v2 # tracks the latest 2.x — pin a specific tag (e.g. @2.0.2) for stricter reproducibility
id: broken-link
with:
command: sitemap # url / file / sitemap / pipe
target: https://www.hahwul.com/sitemap.xml
# timeout: 10
# concurrency: 50
# silent: false
# headers: "X-API-Key: 123444"
# worker_headers: "User-Agent: Deadfinder Bot"
# include30x: false
# user_agent: "Apple"
# proxy: "http://localhost:8070"
# proxy_auth: "id:pw"
# match: ""
# ignore: ""
# coverage: true
# visualize: report.png
- name: Output Handling
run: echo '${{ steps.broken-link.outputs.output }}'
```
If you have found a Dead Link and want to automatically add it as an issue, please refer to the "[Automating Dead Link Detection](https://www.hahwul.com/2024/10/20/automating-dead-link-detection/)" article.
## Usage
```
Usage: deadfinder <command> [options]
Commands:
pipe Scan the URLs from STDIN
file <FILE> Scan the URLs from File
url <URL> Scan the Single URL
sitemap <SITEMAP-URL> Scan the URLs from sitemap
completion <SHELL> Generate completion script (bash/zsh/fish)
version Show version
Options:
-r, --include30x Include 30x redirections as dead links
-c, --concurrency=N Number of concurrent workers (default: 50)
-t, --timeout=N Timeout in seconds (default: 10)
-o, --output=FILE File to write results
-f, --output_format=FORMAT Output format: json, yaml, toml, csv, sarif (default: json)
-H, --headers=HEADER Custom HTTP headers for initial request
--worker_headers=HEADER Custom HTTP headers for worker requests
--user_agent=UA User-Agent string
-p, --proxy=PROXY Proxy server (HTTP and HTTPS CONNECT)
--proxy_auth=USER:PASS Proxy authentication
-m, --match=PATTERN Match URL pattern (regex)
-i, --ignore=PATTERN Ignore URL pattern (regex)
-s, --silent Silent mode
-v, --verbose Verbose mode
--debug Debug mode
--limit=N Limit number of URLs to scan
--coverage Enable coverage tracking and reporting
--visualize=PATH Generate visualization PNG
```
## Modes
```bash
# Scan the URLs from STDIN (multiple URLs)
cat urls.txt | deadfinder pipe
# Scan the URLs from a file
deadfinder file urls.txt
# Scan a single URL
deadfinder url https://www.hahwul.com
# Scan the URLs from a sitemap
deadfinder sitemap https://www.hahwul.com/sitemap.xml
```
## JSON Handling
```bash
deadfinder sitemap https://www.hahwul.com/sitemap.xml -o output.json
cat output.json | jq
```
```json
{
"Target URL": [
"DeadLink URL",
"DeadLink URL",
"DeadLink URL"
]
}
```
With `--coverage`:
```bash
deadfinder sitemap https://www.hahwul.com/sitemap.xml --coverage -o output.json
```
```json
{
"dead_links": {
"Target URL": ["DeadLink URL 1", "DeadLink URL 2"]
},
"coverage": {
"targets": {
"Target URL": {
"total_tested": 14,
"dead_links": 7,
"coverage_percentage": 50.0
}
},
"summary": {
"total_tested": 14,
"total_dead": 7,
"overall_coverage_percentage": 50.0
}
}
}
```
## Shell Completion
```bash
deadfinder completion bash > /etc/bash_completion.d/deadfinder
deadfinder completion zsh > ~/.zsh/completion/_deadfinder
deadfinder completion fish > ~/.config/fish/completions/deadfinder.fish
```
## Contributing
Contributions are welcome! If you have an idea for an improvement or want to report a bug:
- **Fork the repository.**
- **Create a new branch** for your feature or bug fix (e.g., `feature/awesome-feature` or `bugfix/annoying-bug`).
- **Make your changes.**
- **Commit your changes** with a clear message.
- **Push** to the branch.
- **Submit a Pull Request (PR)** to our `main` branch.
### Contributors

================================================
FILE: SECURITY.md
================================================
# Security Policy
## Reporting a Vulnerability
Found a security issue? Let us know so we can fix it.
### How to Report
* **For general security concerns**, please open a [GitHub issue](https://github.com/hahwul/deadfinder/issues). Use the `security` label and describe the issue in as much detail as you can. This helps us to understand and address the problem more effectively.
* **For sensitive matters**, we encourage you to directly report it on our [GitHub security page](https://github.com/hahwul/deadfinder/security). Handling these issues discreetly is vital for everyone's safety.
## Conclusion
Your vigilance and willingness to report security issues are what help keep our project robust and secure. We appreciate the time and effort you put into making our community a safer place. Remember, no concern is too small; we're here to listen and act. Together, we can ensure a secure environment for all our users and contributors. Thank you for being an essential part of our project's security.
Thank you for your support in maintaining the security and integrity of our project!
================================================
FILE: action.yml
================================================
---
name: DeadFinder Action
description: A GitHub Action to find and report dead (broken) links in files, URLs, or sitemaps.
branding:
icon: link
color: red
inputs:
command:
description: The type of command to execute (e.g.,file, url, sitemap)
required: true
target:
description: The target resource for the command (e.g., file path, URL, or sitemap URL)
required: true
timeout:
description: The maximum time to wait for each request, in seconds
required: false
default: ""
concurrency:
description: The number of concurrent requests to make
required: false
default: ""
silent:
description: Enable silent mode to suppress output
required: false
default: "false"
headers:
description: Custom HTTP headers to include in requests, separated by commas
required: false
default: ""
worker_headers:
description: Custom HTTP headers for worker requests, separated by commas
required: false
default: ""
verbose:
description: Enable verbose mode for detailed logging
required: false
default: "false"
include30x:
description: Include HTTP 30x status codes in the results
required: false
default: "false"
user_agent:
description: User-Agent string to use for requests
required: false
default: ""
proxy:
description: Proxy server to use for requests
required: false
default: ""
proxy_auth:
description: Proxy server authentication credentials
required: false
default: ""
match:
description: Match the URL with the given pattern
required: false
default: ""
ignore:
description: Ignore the URL with the given pattern
required: false
default: ""
coverage:
description: Enable coverage reporting to show dead link ratios
required: false
default: "false"
visualize:
description: Generate a visualization of the scan results (e.g., report.png)
required: false
default: ""
version:
description: "DeadFinder release tag to download (default: latest)"
required: false
default: "latest"
outputs:
output:
description: JSON formatted result of the dead-link check
value: ${{ steps.scan.outputs.output }}
runs:
using: composite
steps:
- name: Detect platform
id: platform
shell: bash
run: |
case "${RUNNER_OS}-${RUNNER_ARCH}" in
Linux-X64) asset="deadfinder-linux-x86_64.tar.gz" ;;
Linux-ARM64) asset="deadfinder-linux-aarch64.tar.gz" ;;
macOS-ARM64) asset="deadfinder-macos-arm64.tar.gz" ;;
macOS-X64)
echo "::error title=macOS Intel not supported::DeadFinder no longer ships a macOS x86_64 prebuilt binary. Use an Apple Silicon (macos-latest) runner, or install from source via 'brew install deadfinder'."
exit 1
;;
*) echo "::error::Unsupported platform: ${RUNNER_OS}-${RUNNER_ARCH}"; exit 1 ;;
esac
echo "asset=${asset}" >> "$GITHUB_OUTPUT"
- name: Download deadfinder binary
shell: bash
run: |
set -e
version="${{ inputs.version }}"
asset="${{ steps.platform.outputs.asset }}"
if [ "${version}" = "latest" ]; then
base_url="https://github.com/hahwul/deadfinder/releases/latest/download"
else
base_url="https://github.com/hahwul/deadfinder/releases/download/${version}"
fi
echo "Downloading ${base_url}/${asset}"
# The sha256 sidecar was generated with the tarball's real filename
# (deadfinder-linux-x86_64.tar.gz etc.), so we must save the download
# under the same name for `sha256sum -c` to resolve it.
if ! curl -fsSL "${base_url}/${asset}" -o "/tmp/${asset}"; then
echo "::error title=DeadFinder binary not found::Failed to download ${base_url}/${asset}"
echo "::error::Common causes:"
echo "::error:: 1. Using 'uses: hahwul/deadfinder@main' or '@latest' — neither resolves to a release."
echo "::error:: → Pin a released ref instead: uses: hahwul/deadfinder@v2 (latest 2.x) or @2.0.2 (exact)."
echo "::error:: 2. Requested version (input: version=${version}) is not a published release tag."
echo "::error:: → See https://github.com/hahwul/deadfinder/releases for available tags."
echo "::error:: 3. Using a v1.x workflow with a v2 ref — v1 users should pin hahwul/deadfinder@1.10.0."
exit 1
fi
if ! curl -fsSL "${base_url}/${asset}.sha256" -o "/tmp/${asset}.sha256"; then
echo "::error::Downloaded ${asset} but its .sha256 sidecar is missing at ${base_url}/${asset}.sha256"
exit 1
fi
cd /tmp
# macOS runners ship `shasum`, Linux ships `sha256sum`.
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -c "${asset}.sha256"
else
shasum -a 256 -c "${asset}.sha256"
fi
tar xzf "${asset}"
chmod +x deadfinder
./deadfinder version
- name: Run deadfinder
id: scan
shell: bash
env:
DF_COMMAND: ${{ inputs.command }}
DF_TARGET: ${{ inputs.target }}
DF_TIMEOUT: ${{ inputs.timeout }}
DF_CONCURRENCY: ${{ inputs.concurrency }}
DF_SILENT: ${{ inputs.silent }}
DF_HEADERS: ${{ inputs.headers }}
DF_WORKER_HEADERS: ${{ inputs.worker_headers }}
DF_VERBOSE: ${{ inputs.verbose }}
DF_INCLUDE30X: ${{ inputs.include30x }}
DF_USER_AGENT: ${{ inputs.user_agent }}
DF_PROXY: ${{ inputs.proxy }}
DF_PROXY_AUTH: ${{ inputs.proxy_auth }}
DF_MATCH: ${{ inputs.match }}
DF_IGNORE: ${{ inputs.ignore }}
DF_COVERAGE: ${{ inputs.coverage }}
DF_VISUALIZE: ${{ inputs.visualize }}
run: |
set -e
args=( "${DF_COMMAND}" "${DF_TARGET}" -o /tmp/output.json -f json )
[ -n "${DF_TIMEOUT}" ] && args+=( --timeout="${DF_TIMEOUT}" )
[ -n "${DF_CONCURRENCY}" ] && args+=( --concurrency="${DF_CONCURRENCY}" )
[ "${DF_SILENT}" = "true" ] && args+=( --silent )
[ "${DF_VERBOSE}" = "true" ] && args+=( --verbose )
[ "${DF_INCLUDE30X}" = "true" ] && args+=( --include30x )
[ -n "${DF_USER_AGENT}" ] && args+=( --user_agent="${DF_USER_AGENT}" )
[ -n "${DF_PROXY}" ] && args+=( --proxy="${DF_PROXY}" )
[ -n "${DF_PROXY_AUTH}" ] && args+=( --proxy_auth="${DF_PROXY_AUTH}" )
[ -n "${DF_MATCH}" ] && args+=( --match="${DF_MATCH}" )
[ -n "${DF_IGNORE}" ] && args+=( --ignore="${DF_IGNORE}" )
[ "${DF_COVERAGE}" = "true" ] && args+=( --coverage )
[ -n "${DF_VISUALIZE}" ] && args+=( --visualize="${DF_VISUALIZE}" )
if [ -n "${DF_HEADERS}" ]; then
IFS=',' read -ra hdrs <<< "${DF_HEADERS}"
for h in "${hdrs[@]}"; do
[ -n "${h}" ] && args+=( -H "${h}" )
done
fi
if [ -n "${DF_WORKER_HEADERS}" ]; then
IFS=',' read -ra whdrs <<< "${DF_WORKER_HEADERS}"
for h in "${whdrs[@]}"; do
[ -n "${h}" ] && args+=( --worker_headers="${h}" )
done
fi
/tmp/deadfinder "${args[@]}"
if [ ! -f /tmp/output.json ]; then
echo "::error::/tmp/output.json was not produced"
exit 1
fi
if command -v jq >/dev/null 2>&1; then
encoded=$(jq -c . /tmp/output.json)
else
encoded=$(tr -d '\n' < /tmp/output.json)
fi
echo "output=${encoded}" >> "$GITHUB_OUTPUT"
================================================
FILE: aur/PKGBUILD
================================================
# Maintainer: HAHWUL <hahwul@gmail.com>
pkgname=deadfinder
pkgver=2.0.2
pkgrel=1
pkgdesc="Find dead (broken) links in web pages, URL lists, and sitemaps"
arch=('x86_64' 'aarch64')
url="https://github.com/hahwul/deadfinder"
license=('MIT')
source_x86_64=("${pkgname}-${pkgver}-x86_64.tar.gz::${url}/releases/download/${pkgver}/deadfinder-linux-x86_64.tar.gz")
source_aarch64=("${pkgname}-${pkgver}-aarch64.tar.gz::${url}/releases/download/${pkgver}/deadfinder-linux-aarch64.tar.gz")
source=("LICENSE-${pkgver}::https://raw.githubusercontent.com/hahwul/deadfinder/${pkgver}/LICENSE")
sha256sums=('SKIP')
sha256sums_x86_64=('SKIP')
sha256sums_aarch64=('SKIP')
package() {
install -Dm755 "${srcdir}/deadfinder" "${pkgdir}/usr/bin/${pkgname}"
install -Dm644 "${srcdir}/LICENSE-${pkgver}" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
================================================
FILE: docs/AGENTS.md
================================================
# AGENTS.md - AI Agent Instructions for Hwaro Site
This document provides instructions for AI agents working on this Hwaro-generated website.
## Project Overview
This is a static website built with [Hwaro](https://github.com/hahwul/hwaro), a fast and lightweight static site generator written in Crystal.
## Essential Commands
| Command | Description |
|---------|-------------|
| `hwaro build` | Build the site to `public/` directory |
| `hwaro serve` | Start development server with live reload |
| `hwaro new <path>` | Create new content from archetype |
| `hwaro deploy` | Deploy the site (requires configuration) |
| `hwaro build --drafts` | Include draft content |
| `hwaro serve -p 8080` | Serve on custom port (default: 3000) |
| `hwaro build --base-url "https://example.com"` | Set base URL for production |
## Directory Structure
```
.
├── config.toml # Site configuration
├── content/ # Markdown content files
│ ├── _index.md # Homepage content
│ └── blog/ # Blog section
│ ├── _index.md # Section listing page
│ └── *.md # Individual pages
├── templates/ # Jinja2 templates (Crinja)
│ ├── base.html # Base layout (optional)
│ ├── page.html # Page template
│ ├── section.html # Section listing template
│ └── shortcodes/ # Shortcode templates
├── static/ # Static assets (copied as-is)
└── archetypes/ # Content templates for `hwaro new`
```
## Notes for AI Agents
1. **Front matter is TOML** (`+++`), not YAML (`---`).
2. **Rendered content** is `{{ content | safe }}`, not `{{ page.content }}`.
3. **Custom metadata** is `page.extra.field`, not `page.params.field`.
4. **Always preview** with `hwaro serve` before committing.
5. **Validate TOML syntax** in config.toml and front matter after edits.
6. **Use `{{ base_url }}` prefix** for URLs in templates.
7. **Escape user content** with `{{ value | escape }}` in templates.
## Full Reference
For detailed documentation on content, templates, configuration, and more:
- [Hwaro Documentation](https://hwaro.hahwul.com)
- [Configuration Guide](https://hwaro.hahwul.com/start/config/)
- [Full LLM Reference](https://hwaro.hahwul.com/llms-full.txt) — comprehensive reference optimized for AI agents
To generate the full embedded AGENTS.md locally, run:
```
hwaro tool agents-md --local --write
```
## Site-Specific Instructions
<!-- Add your site-specific rules and conventions below -->
================================================
FILE: docs/config.toml
================================================
# =============================================================================
# Site Configuration
# =============================================================================
title = "DeadFinder"
description = "Find dead (broken) links in web pages, URL lists, and sitemaps."
base_url = "https://deadfinder.hahwul.com"
# =============================================================================
# Plugins
# =============================================================================
[plugins]
processors = ["markdown"]
# =============================================================================
# Content Files
# =============================================================================
[content.files]
allow_extensions = ["jpg", "jpeg", "png", "gif", "svg", "webp"]
# =============================================================================
# Syntax Highlighting
# =============================================================================
[highlight]
enabled = true
theme = "monokai"
use_cdn = true
# =============================================================================
# Taxonomies
# =============================================================================
[[taxonomies]]
name = "tags"
feed = true
sitemap = false
# =============================================================================
# Sitemap
# =============================================================================
[sitemap]
enabled = true
filename = "sitemap.xml"
changefreq = "weekly"
priority = 0.5
# =============================================================================
# Markdown Configuration
# =============================================================================
[markdown]
safe = false
lazy_loading = false
emoji = false
# =============================================================================
# Search (client-side, Fuse.js)
# =============================================================================
[search]
enabled = true
format = "fuse_json"
fields = ["title", "content", "description"]
filename = "search.json"
# =============================================================================
# OpenGraph & Twitter Cards
# =============================================================================
# Default meta tags for social sharing. Page-level front matter overrides.
[og]
type = "website"
twitter_card = "summary_large_image"
# twitter_site = "@hahwul"
# twitter_creator = "@hahwul"
# =============================================================================
# Auto OG Images
# =============================================================================
# Auto-generate 1200x630 OG preview images for pages without a custom `image`.
# https://hwaro.hahwul.com/features/og-images/
[og.auto_image]
enabled = true
format = "png"
background = "#0a0f0a"
text_color = "#e8ede8"
accent_color = "#22c55e"
font_size = 52
style = "dots"
pattern_opacity = 0.12
pattern_scale = 1.0
logo = "static/images/deadfinder.webp"
logo_position = "bottom-left"
output_dir = "og-images"
show_title = true
# =============================================================================
# Pagination (Optional)
# =============================================================================
# [pagination]
# enabled = false
# per_page = 10
# =============================================================================
# Series (Optional)
# =============================================================================
# Group posts into ordered series
# [series]
# enabled = true
# =============================================================================
# Related Posts (Optional)
# =============================================================================
# Recommend related content based on shared taxonomy terms
# [related]
# enabled = true
# limit = 5
# taxonomies = ["tags"]
# =============================================================================
# Robots.txt
# =============================================================================
# Controls search engine crawler access
[robots]
enabled = true
filename = "robots.txt"
rules = [
{ user_agent = "*", allow = ["/"] }
]
# =============================================================================
# LLMs.txt
# =============================================================================
# Instructions for AI/LLM crawlers
[llms]
enabled = true
filename = "llms.txt"
instructions = "This is documentation for DeadFinder, an open-source CLI that finds broken links in web pages, URL lists, and sitemaps. Content is MIT-licensed."
full_enabled = true
full_filename = "llms-full.txt"
# =============================================================================
# RSS/Atom Feeds
# =============================================================================
# Generates RSS or Atom feed for content syndication
# [feeds]
# enabled = true
# type = "rss"
# limit = 10
# full_content = true
# sections = []
# =============================================================================
# Build Hooks (Optional)
# =============================================================================
# Run custom shell commands before/after build process
# [build]
# hooks.pre = ["npm install"]
# hooks.post = ["npm run minify"]
# =============================================================================
# Permalinks (Optional)
# =============================================================================
# Override the output path for specific sections or taxonomies
# [permalinks]
# posts = "/posts/:year/:month/:slug/"
# tags = "/topic/:slug/"
# =============================================================================
# Auto Includes (Optional)
# =============================================================================
# Automatically load CSS/JS files from static directories
# [auto_includes]
# enabled = true
# dirs = ["assets/css", "assets/js"]
# =============================================================================
# Asset Pipeline (Optional)
# =============================================================================
# [assets]
# enabled = true
# minify = true
# fingerprint = true
# =============================================================================
# Deployment (Optional)
# =============================================================================
# [deployment]
# target = "prod"
# source_dir = "public"
#
# [[deployment.targets]]
# name = "prod"
# url = "file://./out"
# =============================================================================
# Image Processing (Optional)
# =============================================================================
# Automatic image resizing and LQIP (Low-Quality Image Placeholder) generation
# Uses vendored stb libraries — no external tools required.
# Use resize_image() in templates to generate responsive variants.
# [image_processing]
# enabled = true
# widths = [320, 640, 1024, 1280]
# quality = 85
#
# [image_processing.lqip]
# enabled = true
# width = 32 # Placeholder width in pixels (8-128)
# quality = 20 # JPEG quality for placeholder (1-100, lower = smaller)
# =============================================================================
# PWA (Progressive Web App) (Optional)
# =============================================================================
# Generate manifest.json and service worker for offline access
# [pwa]
# enabled = true
# name = "My Site"
# short_name = "Site"
# theme_color = "#ffffff"
# background_color = "#ffffff"
# display = "standalone"
# icons = ["static/icon-192.png", "static/icon-512.png"]
# =============================================================================
# AMP (Accelerated Mobile Pages) (Optional)
# =============================================================================
# Generate AMP-compliant versions of content pages
# [amp]
# enabled = true
# path_prefix = "amp"
# sections = ["posts"]
================================================
FILE: docs/content/about.md
================================================
+++
title = "About"
description = "About DeadFinder"
+++
DeadFinder detects broken links — 4xx, 5xx, optionally 3xx — on any page, URL list, or sitemap. It's built for automation: one static binary, machine-readable output, and a GitHub Action wrapper so CI pipelines can gate on link health.
## Status
- **Current line**: 2.x, Crystal rewrite.
- **Legacy**: 1.x, original Ruby gem — frozen except for bug fixes on the [`legacy/v1`](https://github.com/hahwul/deadfinder/tree/legacy/v1) branch.
## Source
- Repository: [github.com/hahwul/deadfinder](https://github.com/hahwul/deadfinder)
- License: MIT
- Maintainer: [HAHWUL](https://www.hahwul.com)
## Reporting issues
Please use the [GitHub issue tracker](https://github.com/hahwul/deadfinder/issues). Security-sensitive reports go through the [GitHub security page](https://github.com/hahwul/deadfinder/security).
================================================
FILE: docs/content/docs/_index.md
================================================
+++
title = "Documentation"
description = "DeadFinder documentation"
sort_by = "weight"
+++
Start with [Installation](/docs/getting-started/installation/) and the [Quick Start](/docs/getting-started/quickstart/). The **Usage** section covers every subcommand, output format, and filter. **Integration** shows how to call DeadFinder from GitHub Actions or Docker. **Reference** is the full CLI flag table.
================================================
FILE: docs/content/docs/getting-started/_index.md
================================================
+++
title = "Getting Started"
description = "Install DeadFinder and run your first scan."
weight = 1
sort_by = "weight"
+++
Two steps:
1. [Install](/docs/getting-started/installation/) the binary.
2. [Run your first scan](/docs/getting-started/quickstart/).
================================================
FILE: docs/content/docs/getting-started/installation.md
================================================
+++
title = "Installation"
description = "Install DeadFinder via Homebrew, Docker, prebuilt binary, Nix, or from source."
weight = 1
+++
Pick the channel that fits your environment. All paths produce the same CLI.
## Homebrew (macOS / Linux)
```bash
brew install deadfinder
```
## Docker
Image: [`ghcr.io/hahwul/deadfinder`](https://github.com/hahwul/deadfinder/pkgs/container/deadfinder). Multi-arch (linux/amd64, linux/arm64). Each published tag is cosign-signed.
```bash
docker run ghcr.io/hahwul/deadfinder:latest deadfinder url https://example.com
```
## Prebuilt binary
Download the tarball for your platform from [Releases](https://github.com/hahwul/deadfinder/releases/latest) (a `.sha256` sidecar ships alongside each tarball):
| OS | Arch | Asset |
|---|---|---|
| Linux | x86_64 | `deadfinder-linux-x86_64.tar.gz` |
| Linux | aarch64 | `deadfinder-linux-aarch64.tar.gz` |
| macOS | arm64 | `deadfinder-macos-arm64.tar.gz` |
> Intel macOS (`x86_64`) doesn't have a prebuilt binary — use `brew install deadfinder` (builds from source) or run the Apple Silicon binary under Rosetta.
Extract and put `deadfinder` on your `PATH`:
```bash
curl -fsSL https://github.com/hahwul/deadfinder/releases/latest/download/deadfinder-linux-x86_64.tar.gz \
| tar xz
sudo mv deadfinder /usr/local/bin/
```
## Linux package managers
| Distro | Package |
|---|---|
| Debian / Ubuntu | `deadfinder_X.Y.Z_{amd64,arm64}.deb` from Releases |
| RHEL / Fedora | `deadfinder-X.Y.Z.{x86_64,aarch64}.rpm` from Releases |
| Alpine | `deadfinder-X.Y.Z-r0.{x86_64,aarch64}.apk` from Releases |
| Arch Linux | `yay -S deadfinder` (AUR) |
| Snap | `sudo snap install deadfinder` |
## Nix
```bash
nix run github:hahwul/deadfinder
nix profile install github:hahwul/deadfinder
nix develop github:hahwul/deadfinder
```
## Build from source
Prerequisites:
- Crystal >= 1.19.1
- `cmake` — required by the `lexbor` HTML parser's postinstall step. Without it, `shards install` fails with `Error executing process: 'cmake': No such file or directory`.
```bash
# macOS
brew install crystal cmake
# Debian / Ubuntu
sudo apt install crystal cmake
# Arch Linux
sudo pacman -S crystal cmake
```
Then build:
```bash
git clone https://github.com/hahwul/deadfinder
cd deadfinder
shards install
crystal build src/cli_main.cr -o deadfinder --release --no-debug
```
Or use the [`justfile`](https://github.com/hahwul/deadfinder/blob/main/justfile) recipes:
```bash
just build # release binary
just build-debug # fast debug build
just test # run specs
```
================================================
FILE: docs/content/docs/getting-started/quickstart.md
================================================
+++
title = "Quick Start"
description = "Run your first DeadFinder scan and read its output."
weight = 2
+++
## Scan a single URL
```bash
deadfinder url https://www.example.com
```
The terminal shows discovered links and their status:
```
▶ Fetching https://www.example.com
● Discovered 12 URLs, currently checking them. [anchor:8 / link:4]
├── ✓ [200] https://www.example.com/about
├── ✘ [404] https://www.example.com/old-page
└── ● Task completed
```
Exit code is `0` even when dead links exist — parse the output to make a build pass/fail decision.
## Structured output
Write JSON to a file:
```bash
deadfinder url https://www.example.com -o output.json
cat output.json
```
```json
{
"https://www.example.com": [
"https://www.example.com/old-page"
]
}
```
YAML, TOML, CSV, and SARIF are available via `-f <format>`. See [Output formats](/docs/usage/output-formats/).
## Scan a sitemap
```bash
deadfinder sitemap https://www.example.com/sitemap.xml -o results.json
```
## Scan many URLs
From a file:
```bash
cat > urls.txt <<'EOF'
https://www.example.com
https://docs.example.com
EOF
deadfinder file urls.txt -o results.json
```
From STDIN:
```bash
printf 'https://www.example.com\nhttps://docs.example.com\n' \
| deadfinder pipe -o results.json
```
## Coverage report
`--coverage` adds a per-target summary with dead-link percentage:
```bash
deadfinder sitemap https://www.example.com/sitemap.xml --coverage -o results.json
```
Optionally render a PNG chart:
```bash
deadfinder sitemap https://www.example.com/sitemap.xml --coverage --visualize report.png
```
## Next
- [Subcommands](/docs/usage/subcommands/)
- [Output formats](/docs/usage/output-formats/)
- [CLI flags reference](/docs/reference/cli-flags/)
================================================
FILE: docs/content/docs/integration/_index.md
================================================
+++
title = "Integration"
description = "Run DeadFinder from GitHub Actions or Docker."
weight = 3
sort_by = "weight"
+++
- [GitHub Action](/docs/integration/github-action/) — official composite action that downloads the release binary and verifies its sha256.
- [Docker](/docs/integration/docker/) — multi-arch image with cosign-signed tags.
================================================
FILE: docs/content/docs/integration/docker.md
================================================
+++
title = "Docker"
description = "ghcr.io/hahwul/deadfinder — multi-arch, cosign-signed, tiny Alpine base."
weight = 2
+++
Image: [`ghcr.io/hahwul/deadfinder`](https://github.com/hahwul/deadfinder/pkgs/container/deadfinder)
- Multi-arch: `linux/amd64`, `linux/arm64`
- Runtime base: `alpine:3.21` + static binary (~15 MB total)
- Tags on release: `<VERSION>`, `<MAJOR>.<MINOR>`, `latest`
- Every published tag is **cosign-signed** (keyless, Sigstore)
## Run
The image's `CMD` is `["deadfinder"]`. Append arguments after the image name — `docker run` passes them through:
```bash
docker run ghcr.io/hahwul/deadfinder:latest deadfinder url https://www.example.com
docker run ghcr.io/hahwul/deadfinder:latest deadfinder sitemap https://www.example.com/sitemap.xml
```
Writing results out? Bind-mount a host directory:
```bash
docker run --rm -v "$PWD":/out \
ghcr.io/hahwul/deadfinder:latest \
deadfinder url https://www.example.com -o /out/results.json -s
```
## Pin a version
```bash
docker pull ghcr.io/hahwul/deadfinder:2.0.0
docker pull ghcr.io/hahwul/deadfinder:2.0
docker pull ghcr.io/hahwul/deadfinder:latest
```
## Verify the signature
```bash
cosign verify ghcr.io/hahwul/deadfinder:2.0.0 \
--certificate-identity-regexp 'https://github.com/hahwul/deadfinder/.+' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com'
```
Substitute the tag you pulled. The command succeeds only if the image was signed by this repo's GitHub Actions.
================================================
FILE: docs/content/docs/integration/github-action.md
================================================
+++
title = "GitHub Action"
description = "hahwul/deadfinder composite action — inputs, outputs, examples."
weight = 1
+++
`hahwul/deadfinder` is a composite action that downloads the matching release binary, verifies its sha256, and executes the scan. Runs on Linux (x86_64/aarch64) and macOS (arm64). Intel macOS runners (`macos-13`) are not supported — use `macos-latest`.
## Pin a version
Always pin a released ref. `@latest` is **not** a valid Actions ref (GitHub has no auto-resolver for it).
```yaml
- uses: hahwul/deadfinder@v2 # tracks latest 2.x — gets bug-fix patches automatically
# or
- uses: hahwul/deadfinder@2.0.2 # exact pin — fully reproducible
```
The `version` input can override the binary independently of the action ref:
```yaml
- uses: hahwul/deadfinder@v2
with:
version: "2.0.2" # download binary from this release tag
```
## Full example
```yaml
steps:
- name: Run DeadFinder
uses: hahwul/deadfinder@v2
id: scan
with:
command: sitemap
target: https://www.example.com/sitemap.xml
# Optional:
# timeout: 10
# concurrency: 50
# include30x: false
# headers: "X-API-Key: secret"
# worker_headers: "User-Agent: Deadfinder Bot"
# user_agent: "MyBot/1.0"
# proxy: "http://localhost:8080"
# proxy_auth: "user:pass"
# match: "^https://example\\.com/"
# ignore: "\\.png$"
# coverage: true
# visualize: report.png
# silent: false
# verbose: false
- name: Handle results
run: echo '${{ steps.scan.outputs.output }}' | jq '.'
```
## Inputs
| Input | Required | Default | Notes |
|---|---|---|---|
| `command` | ✓ | — | `url` / `file` / `pipe` / `sitemap` |
| `target` | ✓ | — | URL, file path, or sitemap URL |
| `version` | | `latest` | Release tag; `latest` resolves to most recent release |
| `timeout` | | `10` | seconds |
| `concurrency` | | `50` | workers |
| `silent` | | `false` | string `"true"` to enable |
| `verbose` | | `false` | |
| `include30x` | | `false` | |
| `headers` | | `""` | comma-separated `"Key: Value"` pairs |
| `worker_headers` | | `""` | headers for link-check requests |
| `user_agent` | | `""` | overrides default UA |
| `proxy` | | `""` | HTTP/HTTPS proxy URL |
| `proxy_auth` | | `""` | `user:pass` |
| `match` | | `""` | regex |
| `ignore` | | `""` | regex |
| `coverage` | | `false` | |
| `visualize` | | `""` | file path (implies coverage) |
## Outputs
| Output | Shape |
|---|---|
| `output` | Compact JSON string of the scan result (same shape as `-f json` output). |
Consume with `fromJSON()`:
```yaml
- run: |
echo "Dead links: ${{ fromJSON(steps.scan.outputs.output).summary }}"
```
## Migrating from v1
The v1 action was Docker-based and bundled the Ruby gem. v2 is a composite action that downloads the Crystal binary directly. All v1 inputs are preserved. `worker_headers` was previously undeclared but wired through args — it's now a formal input. `version` is new. No inputs were renamed or removed.
Pin to `@1.10.0` to keep the v1 behavior; use `@v2` (or pin a specific 2.x tag like `@2.0.2`) for v2.
================================================
FILE: docs/content/docs/reference/_index.md
================================================
+++
title = "Reference"
description = "CLI flag reference."
weight = 4
sort_by = "weight"
+++
- [CLI flags](/docs/reference/cli-flags/) — every option accepted by `deadfinder`.
================================================
FILE: docs/content/docs/reference/cli-flags.md
================================================
+++
title = "CLI Flags"
description = "Complete reference for every deadfinder option."
weight = 1
+++
Run `deadfinder --help` for the live help text. This page is the documented contract.
## Synopsis
```
deadfinder <command> [options]
Commands:
pipe Scan the URLs from STDIN
file <FILE> Scan the URLs from File
url <URL> Scan the Single URL
sitemap <SITEMAP-URL> Scan the URLs from sitemap
completion <SHELL> Generate completion script (bash/zsh/fish)
version Show version
```
## Options
| Short | Long | Default | Description |
|---|---|---|---|
| `-r` | `--include30x` | `false` | Treat 3xx responses as dead links. |
| `-c` | `--concurrency=N` | `50` | Number of concurrent workers. |
| `-t` | `--timeout=N` | `10` | Per-request timeout (seconds). |
| `-o` | `--output=FILE` | `""` | Write structured results to FILE. |
| `-f` | `--output_format=FORMAT` | `json` | `json` / `yaml` / `toml` / `csv` / `sarif`. |
| `-H` | `--headers=HEADER` | `[]` | Header for the **initial** page fetch. Repeat for multiple. Format: `"Name: Value"`. |
| | `--worker_headers=HEADER` | `[]` | Header for every **link-check** request. Repeat for multiple. |
| | `--user_agent=UA` | `Mozilla/5.0 (compatible; DeadFinder/<VERSION>;)` | Override User-Agent. |
| `-p` | `--proxy=URL` | `""` | HTTP/HTTPS proxy (HTTPS uses CONNECT tunneling). |
| | `--proxy_auth=USER:PASS` | `""` | Proxy credentials (Basic). |
| `-m` | `--match=PATTERN` | `""` | Regex: only scan URLs that match. |
| `-i` | `--ignore=PATTERN` | `""` | Regex: skip URLs that match. |
| `-s` | `--silent` | `false` | Suppress the live log on stdout. |
| `-v` | `--verbose` | `false` | Log every checked URL, not just dead ones. |
| | `--debug` | `false` | Internal state / cache diagnostics. |
| | `--limit=N` | `0` | Cap input URLs (`0` = unlimited). |
| | `--coverage` | `false` | Emit per-target coverage stats. |
| | `--visualize=PATH` | `""` | Write a PNG status-code chart (implies `--coverage`). |
## Notes
- Structured output is **file-only**: you must set `-o`. stdout is reserved for the live log.
- `match` / `ignore` regexes each run under a 1-second timeout to block ReDoS.
- The initial page fetch receives `--headers`; worker link-check requests receive `--worker_headers`. `--user_agent` applies to both.
- `--visualize` auto-enables `--coverage`.
================================================
FILE: docs/content/docs/usage/_index.md
================================================
+++
title = "Usage"
description = "Subcommands, output formats, and filters."
weight = 2
sort_by = "weight"
+++
DeadFinder is a single CLI with four scan subcommands and a handful of global flags.
- [Subcommands](/docs/usage/subcommands/) — `url`, `file`, `pipe`, `sitemap`, plus `completion` and `version`.
- [Output formats](/docs/usage/output-formats/) — JSON / YAML / TOML / CSV / SARIF, coverage, PNG visualization.
- [Filtering](/docs/usage/filtering/) — `--match` / `--ignore` regex, `--include30x`, `--limit`.
================================================
FILE: docs/content/docs/usage/filtering.md
================================================
+++
title = "Filtering"
description = "Regex match/ignore, 3xx inclusion, URL limit."
weight = 3
+++
## `--match=PATTERN` / `--ignore=PATTERN`
Regex applied to every discovered URL before it's fetched. Each pattern has a 1-second timeout to prevent ReDoS.
```bash
# Only check internal links
deadfinder sitemap https://www.example.com/sitemap.xml \
--match='^https://(www\.)?example\.com/'
# Skip media files
deadfinder url https://www.example.com \
--ignore='\.(png|jpg|gif|webp|mp4)$'
```
Using both: `--match` is applied first, then `--ignore`.
## `--include30x`
By default, 3xx redirects are treated as healthy (the destination is what matters). Enable this flag to mark them as dead too:
```bash
deadfinder url https://www.example.com --include30x
```
Use this when your policy is "redirects are technical debt" rather than "follow the redirect chain".
## `--limit=N`
Cap the number of URLs scanned per invocation (useful for quick smoke tests of a large sitemap):
```bash
deadfinder sitemap https://www.example.com/sitemap.xml --limit=50
```
Applies to the input list (file lines, STDIN lines, or sitemap `<loc>` entries). Not to discovered child links on each page.
## `--concurrency=N` / `--timeout=N`
Not filters per se, but the other knobs you'll reach for:
- `--concurrency=50` (default) — number of parallel workers.
- `--timeout=10` (default, seconds) — per-request connect + read timeout.
Ramp concurrency down on rate-limited targets; up on fast internal scans.
================================================
FILE: docs/content/docs/usage/output-formats.md
================================================
+++
title = "Output Formats"
description = "JSON, YAML, TOML, CSV, SARIF, coverage reports, and PNG visualization."
weight = 2
+++
DeadFinder writes results only when `-o <FILE>` is set (stdout stays human-readable log). Pick the format with `-f <format>`.
| Flag | Format |
|---|---|
| `-f json` (default) | pretty JSON |
| `-f yaml` / `-f yml` | YAML |
| `-f toml` | TOML |
| `-f csv` | CSV with `target,url` columns |
| `-f sarif` | SARIF 2.1.0 JSON (one `DEAD_LINK` result per broken URL) |
## Basic shape
Same across JSON / YAML / TOML:
```json
{
"https://www.example.com": [
"https://www.example.com/broken-link-1",
"https://www.example.com/broken-link-2"
]
}
```
CSV:
```csv
target,url
https://www.example.com,https://www.example.com/broken-link-1
https://www.example.com,https://www.example.com/broken-link-2
```
## Coverage mode
Add `--coverage` to include per-target statistics:
```bash
deadfinder sitemap https://www.example.com/sitemap.xml --coverage -o out.json
```
```json
{
"dead_links": {
"https://www.example.com": ["https://www.example.com/broken-link-1"]
},
"coverage": {
"targets": {
"https://www.example.com": {
"total_tested": 100,
"dead_links": 5,
"coverage_percentage": 5.0,
"status_counts": {"404": 3, "500": 2}
}
},
"summary": {
"total_tested": 100,
"total_dead": 5,
"overall_coverage_percentage": 5.0,
"overall_status_counts": {"404": 3, "500": 2}
}
}
}
```
## SARIF
`-f sarif` produces a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) document you can upload to GitHub code scanning (`github/codeql-action/upload-sarif`) or feed into any SARIF-aware tooling:
```bash
deadfinder sitemap https://www.example.com/sitemap.xml -f sarif -o deadfinder.sarif
```
Each dead link becomes a `result` under the `DEAD_LINK` rule. The broken URL is the primary location; the page it was discovered on is attached as a related location.
## PNG visualization
```bash
deadfinder sitemap https://www.example.com/sitemap.xml --visualize report.png
```
`--visualize` implies `--coverage`. Output is a stacked bar chart of status codes per target.
## Stdout vs file
Structured output requires `-o`. Without it the tool emits a live log to stdout only. Use `-s` / `--silent` to suppress the log entirely (for example when you're only interested in the file output).
```bash
deadfinder url https://www.example.com -o out.json -s
```
================================================
FILE: docs/content/docs/usage/subcommands.md
================================================
+++
title = "Subcommands"
description = "url / file / pipe / sitemap / completion / version"
weight = 1
+++
## `url <URL>`
Scan a single page. Extract links from the HTML and check each one.
```bash
deadfinder url https://www.example.com
```
## `file <FILE>`
Read newline-separated URLs from a file and scan each one. Each URL is scanned independently; results are keyed by the source URL.
```bash
deadfinder file urls.txt
```
## `pipe`
Read URLs from STDIN (one per line). Useful in shell pipelines.
```bash
grep '^https://' access.log | sort -u | deadfinder pipe
```
## `sitemap <SITEMAP-URL>`
Parse an XML sitemap, follow sitemap indexes recursively, and scan every `<loc>`.
```bash
deadfinder sitemap https://www.example.com/sitemap.xml
```
## `completion <SHELL>`
Emit shell completion for bash, zsh, or fish.
```bash
# Bash
deadfinder completion bash > /etc/bash_completion.d/deadfinder
# Zsh
deadfinder completion zsh > ~/.zsh/completion/_deadfinder
# Fish
deadfinder completion fish > ~/.config/fish/completions/deadfinder.fish
```
## `version`
Print the DeadFinder version.
```bash
deadfinder version
```
================================================
FILE: docs/content/index.md
================================================
+++
title = "DeadFinder"
description = "Find dead (broken) links in web pages, URL lists, and sitemaps."
+++
Find dead (broken) links in web pages, URL lists, and sitemaps. Fast native CLI written in Crystal with fiber-based concurrency.
## Why DeadFinder
- **Fast**: fiber-based concurrent workers scan hundreds of links in parallel.
- **Ergonomic**: one binary, no runtime dependencies.
- **Structured output**: JSON / YAML / TOML / CSV — or attach as a GitHub Action output.
- **Coverage report**: track dead-link ratio per target with `--coverage`.
## Install
```bash
# Homebrew
brew install deadfinder
# Docker
docker run ghcr.io/hahwul/deadfinder:latest deadfinder url https://example.com
# Prebuilt binary — pick your platform on the Releases page
# https://github.com/hahwul/deadfinder/releases/latest
```
See [Installation](/docs/getting-started/installation/) for every channel (Nix, build from source, etc).
## First scan
```bash
deadfinder url https://your-site.example
deadfinder sitemap https://your-site.example/sitemap.xml
cat urls.txt | deadfinder pipe
```
See [Quick Start](/docs/getting-started/quickstart/) for more.
## Continuous integration
Run DeadFinder on every push via the official GitHub Action:
```yaml
- uses: hahwul/deadfinder@v2
with:
command: sitemap
target: https://www.example.com/sitemap.xml
```
See [GitHub Action](/docs/integration/github-action/) for the full input reference.
---
DeadFinder 2.0+ is written in Crystal. v1.x (Ruby gem) lives on the [`legacy/v1`](https://github.com/hahwul/deadfinder/tree/legacy/v1) branch and receives bug-fix updates only.
================================================
FILE: docs/static/CNAME
================================================
deadfinder.hahwul.com
================================================
FILE: docs/static/css/style.css
================================================
:root {
--sidebar-w: 280px;
--toc-w: 220px;
--content-max: 720px;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--mono: 'Noto Sans Mono', ui-monospace, 'SFMono-Regular', Consolas, monospace;
--bg: #0a0f0a;
--bg-sidebar: #0f1a0f;
--text: #e8ede8;
--text-muted: #8fa38f;
--text-light: #5c6e5c;
--primary: #22c55e;
--primary-light: #0a1f0e;
--accent: #f59e0b;
--accent-light: #1a1500;
--border: #1a2e1a;
--border-light: #152515;
--code-bg: #0d160d;
--hover-bg: #122012;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
font-size: 15px;
line-height: 1.7;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
/* -- Top Bar -- */
.topbar {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
height: 52px;
padding: 0 1.25rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.topbar-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.topbar-logo {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--text);
font-weight: 700;
font-size: 1rem;
}
.topbar-logo svg { flex-shrink: 0; }
.topbar-logo:hover { color: var(--primary); }
.menu-btn {
display: none;
background: none;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 8px;
cursor: pointer;
color: var(--text-muted);
}
.menu-btn:hover { background: var(--hover-bg); }
.topbar-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.topbar-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 8px;
text-decoration: none;
transition: color 0.15s, border-color 0.15s;
}
.topbar-icon:hover {
color: var(--text);
border-color: var(--primary);
}
/* Search trigger (button in topbar) */
.topbar-search {
display: inline-flex;
align-items: center;
gap: 0.5rem;
width: 260px;
padding: 6px 8px 6px 10px;
font-family: var(--font);
font-size: 0.8rem;
background: var(--code-bg);
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, color 0.15s;
}
.topbar-search:hover {
border-color: var(--primary);
color: var(--text);
}
.topbar-search:focus-visible {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15);
}
.topbar-search svg {
flex-shrink: 0;
color: var(--text-light);
}
.topbar-search span {
flex: 1;
text-align: left;
}
.topbar-search kbd {
font-family: var(--mono);
font-size: 0.7rem;
padding: 2px 6px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
line-height: 1;
}
/* Search modal */
#search-modal {
position: fixed;
inset: 0;
z-index: 1000;
font-family: var(--font);
}
#search-modal[hidden] { display: none; }
.search-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.search-dialog {
position: absolute;
top: 12%;
left: 50%;
transform: translateX(-50%);
width: 92%;
max-width: 640px;
max-height: 70vh;
display: flex;
flex-direction: column;
background: var(--bg-sidebar);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
overflow: hidden;
}
.search-dialog-header {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.search-dialog-header svg {
flex-shrink: 0;
color: var(--text-light);
}
#search-input {
flex: 1;
font-family: var(--font);
font-size: 0.95rem;
background: transparent;
color: var(--text);
border: none;
outline: none;
padding: 4px 0;
}
#search-input::placeholder { color: var(--text-light); }
#search-close {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 2px 8px;
border-radius: 4px;
font-family: var(--mono);
font-size: 0.7rem;
cursor: pointer;
line-height: 1.4;
}
#search-close:hover { color: var(--text); border-color: var(--primary); }
#search-results {
flex: 1;
overflow-y: auto;
padding: 8px;
}
#search-results::-webkit-scrollbar { width: 6px; }
#search-results::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.search-result {
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.12s;
}
.search-result + .search-result { margin-top: 2px; }
.search-result:hover,
.search-result.selected { background: var(--hover-bg); }
.search-result-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--primary);
margin-bottom: 2px;
}
.search-result-description {
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.45;
}
.search-result-content {
font-size: 0.78rem;
color: var(--text-light);
margin-top: 4px;
line-height: 1.45;
font-family: var(--mono);
}
.search-result mark {
background: rgba(34, 197, 94, 0.22);
color: var(--text);
padding: 0 2px;
border-radius: 2px;
}
.search-empty {
padding: 1.5rem 1rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
/* -- Layout -- */
.layout {
display: flex;
min-height: calc(100vh - 52px);
}
/* -- Sidebar -- */
.sidebar {
position: sticky;
top: 52px;
width: var(--sidebar-w);
height: calc(100vh - 52px);
overflow-y: auto;
padding: 1.25rem 0;
border-right: 1px solid var(--border);
background: var(--bg-sidebar);
flex-shrink: 0;
}
.sidebar::-webkit-scrollbar { width: 4px; }
.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.sidebar::-webkit-scrollbar-track { background: transparent; }
.sidebar-section { margin-bottom: 0.25rem; }
.sidebar-heading {
display: block;
padding: 0.35rem 1.25rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.sidebar-nav { list-style: none; }
.sidebar-nav a {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 1.25rem 0.3rem 1.5rem;
font-size: 0.875rem;
color: var(--text-muted);
text-decoration: none;
border-left: 2px solid transparent;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.sidebar-nav a:hover {
color: var(--text);
background: var(--hover-bg);
}
.sidebar-nav a.active {
color: var(--primary);
font-weight: 500;
background: var(--primary-light);
border-left-color: var(--primary);
}
/* Nested nav */
.sidebar-nav .nested { list-style: none; }
.sidebar-nav .nested a {
padding-left: 2.25rem;
font-size: 0.825rem;
}
.sidebar-nav .nested .nested a {
padding-left: 3rem;
}
.sidebar-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
width: 100%;
padding: 0.3rem 1.25rem 0.3rem 1.5rem;
font-family: var(--font);
font-size: 0.875rem;
color: var(--text-muted);
background: none;
border: none;
border-left: 2px solid transparent;
cursor: pointer;
text-align: left;
transition: color 0.15s, background 0.15s;
}
.sidebar-toggle:hover {
color: var(--text);
background: var(--hover-bg);
}
.sidebar-toggle .arrow {
display: inline-block;
width: 16px;
text-align: center;
font-size: 0.7rem;
transition: transform 0.2s;
}
.sidebar-toggle.open .arrow { transform: rotate(90deg); }
/* -- Main Content -- */
.main {
flex: 1;
min-width: 0;
padding: 2rem 2.5rem;
max-width: calc(var(--content-max) + 5rem);
}
/* -- Prose -- */
.prose h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.75rem; line-height: 1.3; color: var(--text); }
.prose h2 { font-size: 1.35rem; font-weight: 600; margin: 2rem 0 0.5rem; padding-bottom: 0.35rem; border-bottom: 1px solid var(--border); line-height: 1.3; color: var(--text); }
.prose h3 { font-size: 1.1rem; font-weight: 600; margin: 1.5rem 0 0.4rem; line-height: 1.3; color: var(--text); }
.prose h4 { font-size: 0.95rem; font-weight: 600; margin: 1.25rem 0 0.35rem; color: var(--text); }
.prose p { margin: 0.75rem 0; color: var(--text); }
.prose a { color: var(--primary); text-decoration: none; }
.prose a:hover { text-decoration: underline; }
.prose strong { font-weight: 600; color: var(--text); }
.prose img { max-width: 100%; border-radius: 8px; margin: 1rem 0; }
.prose blockquote {
margin: 1rem 0;
padding: 0.5rem 1rem;
border-left: 3px solid var(--primary);
background: var(--primary-light);
border-radius: 0 6px 6px 0;
color: var(--text);
}
.prose blockquote p { margin: 0.25rem 0; }
.prose ul, .prose ol { margin: 0.75rem 0; padding-left: 1.5rem; }
.prose li { margin: 0.25rem 0; color: var(--text); }
.prose li::marker { color: var(--text-muted); }
.prose code {
font-family: var(--mono);
font-size: 0.85em;
background: var(--code-bg);
padding: 0.15rem 0.4rem;
border-radius: 4px;
border: 1px solid var(--border);
color: var(--primary);
}
.prose pre {
margin: 1rem 0;
padding: 1rem;
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
overflow-x: auto;
line-height: 1.5;
}
.prose pre code {
background: none;
border: none;
padding: 0;
font-size: 0.85rem;
color: var(--text);
}
.prose table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.9rem; }
.prose th, .prose td { padding: 0.5rem 0.75rem; border: 1px solid var(--border); text-align: left; }
.prose th { background: var(--code-bg); font-weight: 600; color: var(--text); }
.prose td { color: var(--text-muted); }
.prose hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
/* -- Page Navigation -- */
.page-nav {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.page-nav a {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.75rem 1rem;
text-decoration: none;
border: 1px solid var(--border);
border-radius: 8px;
flex: 1;
max-width: 50%;
transition: border-color 0.2s, box-shadow 0.2s;
}
.page-nav a:hover {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.08);
}
.page-nav a .label {
font-size: 0.75rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.page-nav a .title { font-size: 0.9rem; color: var(--primary); font-weight: 500; }
.page-nav .next { text-align: right; margin-left: auto; }
/* -- Section list -- */
ul.section-list { list-style: none; margin: 1rem 0; }
ul.section-list li {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
}
ul.section-list li:last-child { border-bottom: none; }
ul.section-list li a { color: var(--primary); text-decoration: none; font-weight: 500; }
ul.section-list li a:hover { text-decoration: underline; }
nav.pagination { margin: 1.5rem 0; }
nav.pagination .pagination-list { list-style: none; display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
nav.pagination a { display: inline-block; padding: 0.25rem 0.55rem; border-radius: 6px; border: 1px solid var(--border); color: var(--text-muted); text-decoration: none; font-size: 0.85rem; }
nav.pagination a:hover { color: var(--primary); border-color: var(--primary); }
.pagination-current span { display: inline-block; padding: 0.25rem 0.55rem; border-radius: 6px; border: 1px solid var(--primary); background: var(--primary-light); font-size: 0.85rem; }
.pagination-disabled span { display: inline-block; padding: 0.25rem 0.55rem; border-radius: 6px; border: 1px solid var(--border); color: var(--text-muted); opacity: 0.5; font-size: 0.85rem; }
/* -- Footer -- */
.site-footer {
padding: 1.5rem 2.5rem;
border-top: 1px solid var(--border);
color: var(--text-light);
font-size: 0.8rem;
}
.site-footer a { color: var(--text-muted); text-decoration: none; }
.site-footer a:hover { color: var(--primary); }
/* -- Alert shortcode -- */
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0; font-size: 0.9rem; border-left: 4px solid; }
.alert-info { background: var(--primary-light); border-color: var(--primary); color: var(--primary); }
.alert-warning { background: var(--accent-light); border-color: var(--accent); color: var(--accent); }
.alert-danger { background: #1a0508; border-color: #ef4444; color: #ef4444; }
.alert-tip { background: var(--primary-light); border-color: #22c55e; color: #22c55e; }
/* -- Hint shortcode -- */
.hint { padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0; font-size: 0.9rem; border-left: 4px solid; }
.hint-info { background: var(--primary-light); border-color: var(--primary); color: var(--primary); }
.hint-warning { background: var(--accent-light); border-color: var(--accent); color: var(--accent); }
.hint-danger { background: #1a0508; border-color: #ef4444; color: #ef4444; }
/* -- Responsive -- */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: -100%;
top: 52px;
z-index: 90;
width: 280px;
transition: left 0.25s ease;
box-shadow: none;
}
.sidebar.open {
left: 0;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4);
}
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
top: 52px;
z-index: 80;
background: rgba(0, 0, 0, 0.6);
}
.sidebar-overlay.open { display: block; }
.menu-btn { display: block; }
.main { padding: 1.5rem 1rem; }
.site-footer { padding: 1.5rem 1rem; }
.page-nav { flex-direction: column; }
.page-nav a { max-width: 100%; }
.topbar-search { width: auto; padding: 6px 10px; }
.topbar-search span,
.topbar-search kbd { display: none; }
}
================================================
FILE: docs/static/icons/site.webmanifest
================================================
{
"name": "DeadFinder",
"short_name": "DeadFinder",
"icons": [
{
"src": "/icons/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
================================================
FILE: docs/static/js/search.js
================================================
// Guard against double-load (auto-includes + explicit <script> both firing).
if (window.__deadfinderSearchLoaded) {
// already wired up
} else {
window.__deadfinderSearchLoaded = true;
if (typeof Fuse === "undefined") {
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min.js";
script.onload = initSearch;
document.head.appendChild(script);
} else {
initSearch();
}
let fuse;
let searchData = [];
function initSearch() {
const base = (window.__DF_BASE_URL || "").replace(/\/$/, "");
fetch(base + "/search.json")
.then((r) => r.json())
.then((data) => {
searchData = data;
fuse = new Fuse(data, {
keys: ["title", "content", "description"],
threshold: 0.3,
ignoreLocation: true,
includeMatches: true,
includeScore: true,
minMatchCharLength: 2,
});
})
.catch((error) => console.error("Error loading search data:", error));
}
// Build modal. Styling lives in style.css (keeps theming consistent).
const searchModal = document.createElement("div");
searchModal.id = "search-modal";
searchModal.hidden = true;
searchModal.innerHTML = `
<div class="search-overlay" id="search-overlay"></div>
<div class="search-dialog" role="dialog" aria-label="Search documentation">
<div class="search-dialog-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" id="search-input" placeholder="Search documentation…" autocomplete="off" spellcheck="false">
<button id="search-close" aria-label="Close search">ESC</button>
</div>
<div id="search-results"></div>
</div>
`;
document.body.appendChild(searchModal);
// Global shortcuts
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
showSearch();
return;
}
if (e.key === "Escape" && !searchModal.hidden) {
hideSearch();
return;
}
// Forward slash opens search when not typing in an input.
if (
e.key === "/" &&
!["INPUT", "TEXTAREA"].includes(
(document.activeElement && document.activeElement.tagName) || "",
)
) {
e.preventDefault();
showSearch();
}
});
document.getElementById("search-overlay").addEventListener("click", hideSearch);
document.getElementById("search-close").addEventListener("click", hideSearch);
const searchInput = document.getElementById("search-input");
let selectedIndex = -1;
searchInput.addEventListener("input", () => {
selectedIndex = -1;
performSearch();
});
searchInput.addEventListener("keydown", (e) => {
const results = document.querySelectorAll(".search-result");
if (results.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
selectedIndex = (selectedIndex + 1) % results.length;
updateSelection(results);
} else if (e.key === "ArrowUp") {
e.preventDefault();
selectedIndex = selectedIndex <= 0 ? results.length - 1 : selectedIndex - 1;
updateSelection(results);
} else if (e.key === "Enter") {
e.preventDefault();
const target = selectedIndex >= 0 ? results[selectedIndex] : results[0];
if (target) target.click();
}
});
// Anything tagged data-search-trigger opens the modal.
document.querySelectorAll("[data-search-trigger]").forEach((el) => {
el.addEventListener("click", (e) => {
e.preventDefault();
showSearch();
});
});
function updateSelection(results) {
results.forEach((result, index) => {
if (index === selectedIndex) {
result.classList.add("selected");
result.scrollIntoView({ block: "nearest" });
} else {
result.classList.remove("selected");
}
});
}
function showSearch() {
searchModal.hidden = false;
searchInput.focus();
searchInput.value = "";
document.getElementById("search-results").innerHTML = "";
selectedIndex = -1;
}
function hideSearch() {
searchModal.hidden = true;
selectedIndex = -1;
}
function performSearch() {
const query = searchInput.value.trim();
const resultsDiv = document.getElementById("search-results");
if (!query) {
resultsDiv.innerHTML = "";
return;
}
if (!fuse) {
resultsDiv.innerHTML = '<div class="search-empty">Loading search index…</div>';
return;
}
const results = fuse.search(query).slice(0, 10);
if (results.length === 0) {
resultsDiv.innerHTML = '<div class="search-empty">No results found</div>';
return;
}
resultsDiv.innerHTML = results
.map((result) => {
const item = result.item;
const contentMatch = result.matches.find((m) => m.key === "content");
const descriptionMatch = result.matches.find((m) => m.key === "description");
const titleMatch = result.matches.find((m) => m.key === "title");
let snippet = "";
if (item.description) {
snippet += `<div class="search-result-description">${highlightMatches(
item.description,
descriptionMatch,
)}</div>`;
}
if (contentMatch && contentMatch.indices && contentMatch.indices.length > 0) {
snippet += `<div class="search-result-content">${getContentSnippet(
item.content,
contentMatch,
)}</div>`;
}
return `
<div class="search-result" data-url="${escapeHtml(item.url)}">
<div class="search-result-title">${highlightMatches(item.title, titleMatch)}</div>
${snippet}
</div>
`;
})
.join("");
resultsDiv.querySelectorAll(".search-result").forEach((el) => {
el.addEventListener("click", () => {
window.location.href = el.getAttribute("data-url");
});
});
}
function getContentSnippet(text, match) {
if (!match || !match.indices || match.indices.length === 0) return "";
const best = match.indices.reduce((a, b) =>
b[1] - b[0] > a[1] - a[0] ? b : a,
);
const [start, end] = best;
const radius = 60;
const s = Math.max(0, start - radius);
const e = Math.min(text.length, end + 1 + radius);
let snippet = "";
if (s > 0) snippet += "…";
snippet += escapeHtml(text.slice(s, start));
snippet += "<mark>" + escapeHtml(text.slice(start, end + 1)) + "</mark>";
snippet += escapeHtml(text.slice(end + 1, e));
if (e < text.length) snippet += "…";
return snippet;
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function highlightMatches(text, match) {
if (!match || !match.indices) return escapeHtml(text);
let result = "";
let last = 0;
match.indices.forEach(([start, end]) => {
result += escapeHtml(text.slice(last, start));
result += "<mark>" + escapeHtml(text.slice(start, end + 1)) + "</mark>";
last = end + 1;
});
result += escapeHtml(text.slice(last));
return result;
}
} // end double-load guard
================================================
FILE: docs/templates/404.html
================================================
{% include "header.html" %}
<article class="prose">
<h1>404 Not Found</h1>
<p>The page you are looking for does not exist.</p>
<p><a href="{{ base_url }}/">Return to Home</a></p>
</article>
{% include "footer.html" %}
================================================
FILE: docs/templates/footer.html
================================================
</div><!-- .main -->
</div><!-- .layout -->
<footer class="site-footer">
Powered by <a href="https://github.com/hahwul/hwaro" target="_blank" rel="noopener">Hwaro</a>
</footer>
{{ highlight_js }}
{{ auto_includes_js }}
<script src="{{ base_url }}/js/search.js" defer></script>
<script>
// Mobile sidebar toggle
const menuBtn = document.getElementById('menu-btn');
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
if (menuBtn) {
menuBtn.addEventListener('click', () => {
sidebar.classList.toggle('open');
overlay.classList.toggle('open');
});
}
if (overlay) {
overlay.addEventListener('click', () => {
sidebar.classList.remove('open');
overlay.classList.remove('open');
});
}
// Highlight active sidebar link
const currentPath = window.location.pathname.replace(/\/$/, '') || '/';
document.querySelectorAll('.sidebar-nav a').forEach(link => {
const href = link.getAttribute('href').replace(/\/$/, '') || '/';
if (currentPath === href) {
link.classList.add('active');
}
});
// Collapsible sidebar sections
document.querySelectorAll('.sidebar-toggle').forEach(btn => {
btn.addEventListener('click', () => {
btn.classList.toggle('open');
const nested = btn.nextElementSibling;
if (nested) nested.style.display = nested.style.display === 'none' ? '' : 'none';
});
});
</script>
</body>
</html>
================================================
FILE: docs/templates/header.html
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{{ page.description }}" />
<title>{{ page.title }} - {{ site.title }}</title>
{{ og_all_tags }}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="icon" type="image/png" href="/icons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/icons/favicon.svg" />
<link rel="shortcut icon" href="/icons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
<link rel="manifest" href="/icons/site.webmanifest" />
<link rel="stylesheet" href="{{ base_url }}/css/style.css" />
{{ highlight_css }} {{ auto_includes_css }}
<script>
window.__DF_BASE_URL = "{{ base_url }}";
</script>
</head>
<body data-section="{{ page.section }}">
<!-- Top bar -->
<div class="topbar">
<div class="topbar-left">
<button class="menu-btn" id="menu-btn" aria-label="Toggle menu">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<path d="M3 12h18M3 6h18M3 18h18" />
</svg>
</button>
<a href="{{ base_url }}/" class="topbar-logo">
<img src="/images/deadfinder.webp" style="width: 50px;">
</a>
</div>
<div class="topbar-right">
<a
href="https://github.com/hahwul/deadfinder"
class="topbar-icon"
target="_blank"
rel="noopener"
aria-label="GitHub repository"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path
d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.55 0-.27-.01-.99-.02-1.95-3.2.7-3.87-1.54-3.87-1.54-.52-1.32-1.27-1.67-1.27-1.67-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.02 1.75 2.68 1.24 3.34.95.1-.74.4-1.24.73-1.53-2.55-.29-5.24-1.28-5.24-5.7 0-1.26.45-2.29 1.18-3.1-.12-.29-.51-1.46.11-3.04 0 0 .96-.31 3.15 1.18.91-.25 1.89-.38 2.86-.39.97.01 1.95.14 2.86.39 2.18-1.49 3.14-1.18 3.14-1.18.62 1.58.23 2.75.11 3.04.74.81 1.18 1.84 1.18 3.1 0 4.43-2.69 5.41-5.26 5.69.41.36.77 1.06.77 2.14 0 1.55-.01 2.8-.01 3.18 0 .31.21.67.8.55C20.21 21.39 23.5 17.08 23.5 12 23.5 5.65 18.35.5 12 .5z"
/>
</svg>
</a>
<button
type="button"
class="topbar-search"
data-search-trigger
aria-label="Search documentation"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<span>Search...</span>
<kbd>⌘K</kbd>
</button>
</div>
</div>
<!-- Sidebar overlay (mobile) -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<div class="layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-section">
<span class="sidebar-heading">Getting Started</span>
<ul class="sidebar-nav">
<li>
<a href="{{ base_url }}/docs/getting-started/"
>Overview</a
>
</li>
<li>
<a
href="{{ base_url }}/docs/getting-started/installation/"
>Installation</a
>
</li>
<li>
<a
href="{{ base_url }}/docs/getting-started/quickstart/"
>Quick Start</a
>
</li>
</ul>
</div>
<div class="sidebar-section">
<span class="sidebar-heading">Usage</span>
<ul class="sidebar-nav">
<li>
<a href="{{ base_url }}/docs/usage/">Overview</a>
</li>
<li>
<a href="{{ base_url }}/docs/usage/subcommands/"
>Subcommands</a
>
</li>
<li>
<a href="{{ base_url }}/docs/usage/output-formats/"
>Output Formats</a
>
</li>
<li>
<a href="{{ base_url }}/docs/usage/filtering/"
>Filtering</a
>
</li>
</ul>
</div>
<div class="sidebar-section">
<span class="sidebar-heading">Integration</span>
<ul class="sidebar-nav">
<li>
<a href="{{ base_url }}/docs/integration/"
>Overview</a
>
</li>
<li>
<a
href="{{ base_url }}/docs/integration/github-action/"
>GitHub Action</a
>
</li>
<li>
<a href="{{ base_url }}/docs/integration/docker/"
>Docker</a
>
</li>
</ul>
</div>
<div class="sidebar-section">
<span class="sidebar-heading">Reference</span>
<ul class="sidebar-nav">
<li>
<a href="{{ base_url }}/docs/reference/"
>Overview</a
>
</li>
<li>
<a href="{{ base_url }}/docs/reference/cli-flags/"
>CLI Flags</a
>
</li>
</ul>
</div>
</aside>
<!-- Main content area -->
<div class="main">
================================================
FILE: docs/templates/page.html
================================================
{% include "header.html" %}
<article class="prose">
<h1>{{ page.title }}</h1>
{{ content }}
</article>
{% include "footer.html" %}
================================================
FILE: docs/templates/section.html
================================================
{% include "header.html" %}
<article class="prose">
<h1>{{ page.title }}</h1>
{{ content }}
<ul class="section-list">
{{ section.list }}
</ul>
{{ pagination }}
</article>
{% include "footer.html" %}
================================================
FILE: docs/templates/shortcodes/alert.html
================================================
<div class="alert alert-{{ type }}">
<strong>{{ type | upper }}:</strong> {{ message }}
</div>
================================================
FILE: docs/templates/taxonomy.html
================================================
{% include "header.html" %}
<article class="prose">
<h1>{{ page.title }}</h1>
<p>Browse all terms in this taxonomy:</p>
{{ content }}
</article>
{% include "footer.html" %}
================================================
FILE: docs/templates/taxonomy_term.html
================================================
{% include "header.html" %}
<article class="prose">
<h1>{{ page.title }}</h1>
<p>Pages tagged with this term:</p>
{{ content }}
</article>
{% include "footer.html" %}
================================================
FILE: flake.nix
================================================
{
description = "DeadFinder — find dead (broken) links in web pages, URL lists, and sitemaps";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
# lexbor.cr's postinstall hook clones the upstream lexbor C library
# from GitHub at a pinned commit (lib/lexbor/src/ext/revision) and
# builds it via cmake. The Nix sandbox blocks network access, so
# pre-fetch the source as a fixed-output derivation and drop it
# into place during preBuild — then cmake runs normally.
lexborCSrc = pkgs.fetchgit {
url = "https://github.com/lexbor/lexbor.git";
rev = "971faf11a5f45433b9193a143e2897d8c0fd5611";
sha256 = "0v3ka5dhgz2jkmigdjcjm3vmxlc9yv4hks6pz13xzgagxxfwlw7s";
};
deadfinder = pkgs.crystal.buildCrystalPackage rec {
pname = "deadfinder";
version = "2.0.0";
src = ./.;
# Generate with: crystal2nix > shards.nix
shardsFile = ./shards.nix;
nativeBuildInputs = with pkgs; [ crystal shards cmake pkg-config ];
buildInputs = [ ];
# lexbor.cr's postinstall hook (build_ext.cr) clones the lexbor C
# library at a pinned commit and builds it via cmake. The Nix
# sandbox blocks network, so we (a) replace the read-only shard
# symlink with a writable copy, (b) drop in the pre-fetched C
# source, and (c) run cmake directly here — bypassing build_ext.cr.
preBuild = ''
cp -RL lib/lexbor lib/lexbor.rw
chmod -R u+w lib/lexbor.rw
rm lib/lexbor
mv lib/lexbor.rw lib/lexbor
cp -r ${lexborCSrc} lib/lexbor/src/ext/lexbor-c
chmod -R u+w lib/lexbor/src/ext/lexbor-c
mkdir -p lib/lexbor/src/ext/lexbor-c/build
( cd lib/lexbor/src/ext/lexbor-c/build \
&& cmake .. \
-DCMAKE_BUILD_TYPE=Release \
-DLEXBOR_BUILD_TESTS_CPP=OFF \
-DLEXBOR_INSTALL_HEADERS=OFF \
-DLEXBOR_BUILD_SHARED=ON \
-G "Unix Makefiles" \
&& cmake --build . --config Release -j $NIX_BUILD_CORES )
'';
buildPhase = ''
runHook preBuild
shards build --release --no-debug
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/bin
cp bin/deadfinder $out/bin/deadfinder
runHook postInstall
'';
doCheck = false;
meta = with pkgs.lib; {
description = "Find dead (broken) links in web pages, URL lists, and sitemaps";
homepage = "https://github.com/hahwul/deadfinder";
license = licenses.mit;
maintainers = [ "hahwul" ];
mainProgram = "deadfinder";
};
};
in
{
packages.default = deadfinder;
packages.deadfinder = deadfinder;
devShells.default = pkgs.mkShell {
inputsFrom = [ deadfinder ];
nativeBuildInputs = with pkgs; [ crystal shards crystal2nix cmake pkg-config just ];
shellHook = ''
echo "deadfinder development environment (Nix)"
[ -d lib ] || shards install
'';
};
});
}
================================================
FILE: github-action/README.md
================================================
## DeadFinder Github Action
================================================
FILE: justfile
================================================
default:
@just --list
# Install shard dependencies
deps:
shards install
# Build a release binary at ./deadfinder
build:
shards install
crystal build src/cli_main.cr -o deadfinder --release --no-debug
# Build a debug binary at ./deadfinder (fast compile)
build-debug:
shards install
crystal build src/cli_main.cr -o deadfinder
# Run unit specs
test:
crystal spec
# Run cross-implementation compat harness (requires built binary)
compat: build
BIN=./deadfinder ruby spec/compat/run.rb
# Format sources
fix:
crystal tool format src spec
# Check formatting without modifying
check-format:
crystal tool format --check src spec
# Verify version consistency across shard.yml and src/deadfinder/version.cr
alias vc := version-check
version-check:
crystal run scripts/version_check.cr
# Update version in all tracked files
alias vu := version-update
version-update VERSION:
crystal run scripts/version_update.cr -- {{VERSION}}
# Clean build artifacts and dependencies
clean:
rm -f deadfinder *.dwarf
rm -rf lib/ .shards/
================================================
FILE: scripts/version_check.cr
================================================
require "yaml"
# Cross-file version consistency check. Prints each discovered version
# string and exits non-zero if any tracked file disagrees (files that
# don't exist yet are skipped silently so the script works on branches
# that haven't landed the snap/aur packaging yet).
SHARD_YML = "shard.yml"
VERSION_CR = "src/deadfinder/version.cr"
SPEC_TOP = "spec/deadfinder_spec.cr"
SPEC_CLI = "spec/deadfinder/cli_spec.cr"
SNAPCRAFT = "snap/snapcraft.yaml"
PKGBUILD = "aur/PKGBUILD"
def shard_version(path : String) : String?
YAML.parse(File.read(path))["version"].as_s
rescue
nil
end
def match_pattern(path : String, pattern : Regex) : String?
content = File.read(path)
m = content.match(pattern)
m ? m[1] : nil
rescue
nil
end
# Matches both `VERSION = "X"` and `VERSION.should eq "X"` (with or without parens).
CR_VERSION_RE = /VERSION\s*(?:=|\.should\s+eq\(?)\s*"([^"]+)"/
# PKGBUILD: pkgver=X.Y.Z
PKGBUILD_RE = /^pkgver=([^\s]+)/m
results = [] of {String, String}
results << {SHARD_YML, shard_version(SHARD_YML).not_nil!} if File.exists?(SHARD_YML)
results << {VERSION_CR, match_pattern(VERSION_CR, CR_VERSION_RE).not_nil!} if File.exists?(VERSION_CR)
results << {SPEC_TOP, match_pattern(SPEC_TOP, CR_VERSION_RE).not_nil!} if File.exists?(SPEC_TOP)
results << {SPEC_CLI, match_pattern(SPEC_CLI, CR_VERSION_RE).not_nil!} if File.exists?(SPEC_CLI)
results << {SNAPCRAFT, shard_version(SNAPCRAFT).not_nil!} if File.exists?(SNAPCRAFT)
results << {PKGBUILD, match_pattern(PKGBUILD, PKGBUILD_RE).not_nil!} if File.exists?(PKGBUILD)
if results.empty?
STDERR.puts "no tracked version files found"
exit 1
end
results.each { |path, v| puts "#{path}: #{v}" }
uniq = results.map { |_, v| v }.uniq
if uniq.size == 1
puts "OK: all files agree on #{uniq.first}"
else
STDERR.puts "MISMATCH: #{uniq.join(", ")}"
exit 1
end
================================================
FILE: scripts/version_update.cr
================================================
require "yaml"
# Bump the version string across every tracked file in one pass. Run:
#
# crystal run scripts/version_update.cr -- 2.1.0
#
# or via `just version-update 2.1.0`.
#
# Files that don't exist yet are skipped silently so the script works
# on branches that haven't landed the snap/aur packaging.
SHARD_YML = "shard.yml"
VERSION_CR = "src/deadfinder/version.cr"
SPEC_TOP = "spec/deadfinder_spec.cr"
SPEC_CLI = "spec/deadfinder/cli_spec.cr"
SNAPCRAFT = "snap/snapcraft.yaml"
PKGBUILD = "aur/PKGBUILD"
SEMVER = /\A\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\z/
def usage(code = 1)
STDERR.puts "usage: crystal run scripts/version_update.cr -- <NEW_VERSION>"
exit code
end
new_version = ARGV[0]?
usage unless new_version
unless new_version.as(String).matches?(SEMVER)
STDERR.puts "invalid semver: #{new_version}"
usage
end
nv = new_version.as(String)
def replace_in_file(path : String, pattern : Regex, replacement : String) : Bool
return true unless File.exists?(path)
src = File.read(path)
updated = src.sub(pattern, replacement)
if updated == src
STDERR.puts "#{path}: pattern not found"
return false
end
File.write(path, updated)
puts "#{path}: updated"
true
end
ok = true
# Crystal's `m` flag enables both line-anchor and DOTALL semantics, so a
# bare `.+$/m` swallows everything from the match start to end of file.
# Constrain to single-line content with `[^\n]+`.
ok &= replace_in_file(SHARD_YML, /^version:\s*[^\n]+$/m, "version: #{nv}")
ok &= replace_in_file(VERSION_CR, /VERSION\s*=\s*"[^"]+"/, %(VERSION = "#{nv}"))
ok &= replace_in_file(SPEC_TOP, /VERSION\.should\s+eq\s+"[^"]+"/, %(VERSION.should eq "#{nv}"))
ok &= replace_in_file(SPEC_CLI, /VERSION\.should\s+eq\s+"[^"]+"/, %(VERSION.should eq "#{nv}"))
ok &= replace_in_file(SNAPCRAFT, /^version:\s*[^\n]+$/m, "version: #{nv}")
ok &= replace_in_file(PKGBUILD, /^pkgver=[^\n]+$/m, "pkgver=#{nv}")
exit(ok ? 0 : 1)
================================================
FILE: shard.yml
================================================
name: deadfinder
version: 2.0.2
authors:
- hahwul <hahwul@gmail.com>
targets:
deadfinder:
main: src/cli_main.cr
dependencies:
lexbor:
github: kostya/lexbor
stumpy_png:
github: stumpycr/stumpy_png
version: "~> 5.0"
sarif:
github: hahwul/sarif.cr
version: "~> 0.2.0"
development_dependencies:
webmock:
github: manastech/webmock.cr
version: "~> 0.14"
crystal: '>= 1.19.1'
license: MIT
================================================
FILE: shards.nix
================================================
{
"lexbor" = {
url = "https://github.com/kostya/lexbor.git";
rev = "v3.4.2";
sha256 = "0bsncwsvqf5zns0c56va1l9gc7798pvl34i6yh8jf1syqxkvdb8a";
};
"stumpy_core" = {
url = "https://github.com/stumpycr/stumpy_core.git";
rev = "v1.9.1";
sha256 = "1sj5wr9zrxnihnjwq057lah09lsl9jq6j7giwwv3ds9wp9j9z903";
};
"stumpy_png" = {
url = "https://github.com/stumpycr/stumpy_png.git";
rev = "v5.0.1";
sha256 = "15wiawl0n3n596bdi0k9dd08nxln2smffba7mggdffw241mn89jc";
};
"webmock" = {
url = "https://github.com/manastech/webmock.cr.git";
rev = "v0.14.0";
sha256 = "1h008sx33xq0hha2lxd5dsh2wr7rzlv4nifgr4k5knpw5ahq1f88";
};
}
================================================
FILE: snap/snapcraft.yaml
================================================
name: deadfinder
base: core24
version: 2.0.2
summary: Find dead (broken) links in web pages, URL lists, and sitemaps.
description: |
DeadFinder is a fast CLI tool for detecting broken links on a page, a
list of URLs, or an entire sitemap. Written in Crystal for native
speed and fiber-based concurrency. Supports JSON/YAML/TOML/CSV output
and coverage reporting.
grade: stable
confinement: strict
license: MIT
apps:
deadfinder:
command: deadfinder
plugs:
- home
- removable-media
- network
- network-bind
parts:
deadfinder:
source: ./
plugin: nil
override-build: |
curl -fsSL https://crystal-lang.org/install.sh | bash
shards install --production
shards build --release --no-debug --production
cp ./bin/deadfinder $CRAFT_PART_INSTALL/
build-packages:
- git
- curl
- cmake
- make
- g++
- pkg-config
- libssl-dev
- libxml2-dev
- libz-dev
- libyaml-dev
- libpcre2-dev
- libevent-dev
- libgmp-dev
stage-packages:
- libxml2
- zlib1g
- libyaml-0-2
- ca-certificates
================================================
FILE: spec/compat/README.md
================================================
# Compatibility harness
Ruby 원본 v1의 출력을 **골든 파일로 동결**하고, Crystal 바이너리가 동일 출력을 내는지 검증하는 블랙박스 테스트다.
## 구조
```
spec/compat/
├── fixtures/
│ └── server.rb # 최소 HTTP fixture 서버 (Ruby stdlib only)
├── golden/
│ └── <case>.{json,yaml,toml,csv} # 기대 출력. {{BASE}} 플레이스홀더
├── run.rb # 드라이버: 서버 기동 → 바이너리 실행 → 비교
└── README.md
```
## 실행
```bash
shards install
crystal build src/cli_main.cr -o deadfinder --release
BIN="./deadfinder" ruby spec/compat/run.rb
```
## 케이스 추가
1. `fixtures/server.rb`의 `ROUTES`에 필요한 경로 추가
2. `golden/<name>.<format>`에 기대 출력 작성 (`{{BASE}}`로 origin 표현)
3. `run.rb` 맨 아래 `run_case(...)` 한 줄 추가
## 비교 규칙
- 배열은 정렬 후 비교 (링크 추출 순서 비결정성 흡수)
- `{{BASE}}` 플레이스홀더는 실행 시 동적 포트로 치환
- 출력은 `-o <tmpfile>`로 받아 파일에서 파싱
## 왜 Ruby 드라이버?
골든 파일은 v1 Ruby 출력의 스냅샷이고, 비교 로직에 `toml-rb` 같은 파서가 필요해서 그대로 Ruby 드라이버를 유지했다. Crystal로 포팅할 수도 있지만 CI 복잡도 대비 이득이 적다.
================================================
FILE: spec/compat/fixtures/server.rb
================================================
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'socket'
ROUTES = {
'/index.html' => {
status: 200,
content_type: 'text/html',
body: <<~HTML
<!DOCTYPE html>
<html><body>
<a href="ok">ok</a>
<a href="dead">dead</a>
<a href="redirect">redirect</a>
</body></html>
HTML
},
'/ok' => { status: 200, content_type: 'text/plain', body: 'OK' },
'/dead' => { status: 404, content_type: 'text/plain', body: 'Not Found' },
'/redirect' => { status: 301, content_type: 'text/plain', body: '', extra: { 'Location' => '/ok' } }
}.freeze
STATUS_TEXT = { 200 => 'OK', 301 => 'Moved Permanently', 404 => 'Not Found' }.freeze
server = TCPServer.new('127.0.0.1', 0)
puts server.addr[1]
STDOUT.flush
trap('TERM') { exit 0 }
trap('INT') { exit 0 }
loop do
client = server.accept
begin
request_line = client.gets
raw_path = request_line&.split(' ')&.dig(1) || '/'
path = raw_path.split('?').first
while (line = client.gets) && line.strip != ''; end
route = ROUTES[path]
if route
headers = {
'Content-Type' => route[:content_type],
'Content-Length' => route[:body].bytesize.to_s
}.merge(route[:extra] || {})
client.print "HTTP/1.1 #{route[:status]} #{STATUS_TEXT[route[:status]] || 'OK'}\r\n"
headers.each { |k, v| client.print "#{k}: #{v}\r\n" }
client.print "\r\n#{route[:body]}"
else
client.print "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"
end
rescue StandardError
# swallow: test fixture, keep accepting
ensure
client&.close
end
end
================================================
FILE: spec/compat/golden/file_json.json
================================================
{
"{{BASE}}/index.html": [
"{{BASE}}/dead"
]
}
================================================
FILE: spec/compat/golden/pipe_json.json
================================================
{
"{{BASE}}/index.html": [
"{{BASE}}/dead"
]
}
================================================
FILE: spec/compat/golden/url_csv.csv
================================================
target,url
{{BASE}}/index.html,{{BASE}}/dead
================================================
FILE: spec/compat/golden/url_json.json
================================================
{
"{{BASE}}/index.html": [
"{{BASE}}/dead"
]
}
================================================
FILE: spec/compat/golden/url_json_include30x.json
================================================
{
"{{BASE}}/index.html": [
"{{BASE}}/dead",
"{{BASE}}/redirect"
]
}
================================================
FILE: spec/compat/golden/url_toml.toml
================================================
"{{BASE}}/index.html" = ["{{BASE}}/dead"]
================================================
FILE: spec/compat/golden/url_yaml.yaml
================================================
---
{{BASE}}/index.html:
- {{BASE}}/dead
================================================
FILE: spec/compat/run.rb
================================================
#!/usr/bin/env ruby
# frozen_string_literal: true
# Black-box compatibility harness for the deadfinder Crystal binary.
#
# The golden files in this directory were captured from the v1 Ruby
# implementation and now act as the frozen contract the Crystal binary
# must match. The harness runs the binary under test against a local
# fixture server, writes the output to a temp file, and compares the
# parsed structure to the corresponding golden file (with `{{BASE}}`
# substituted for the dynamic fixture origin).
#
# Usage:
# BIN="./deadfinder" ruby spec/compat/run.rb
# BIN="/path/to/deadfinder" ruby spec/compat/run.rb
require 'csv'
require 'json'
require 'open3'
require 'tempfile'
require 'toml-rb'
require 'yaml'
HARNESS_ROOT = __dir__
BIN = ENV.fetch('BIN', './deadfinder')
def sort_arrays(obj)
case obj
when Hash then obj.transform_values { |v| sort_arrays(v) }
when Array then obj.map { |v| sort_arrays(v) }.sort_by(&:to_s)
else obj
end
end
def parse_output(path, format)
text = File.read(path)
case format
when 'json' then JSON.parse(text)
when 'yaml', 'yml' then YAML.safe_load(text)
when 'toml' then TomlRB.parse(text)
when 'csv' then CSV.parse(text)
else raise "unknown format: #{format}"
end
end
def substitute_base(text, base)
text.gsub('{{BASE}}', base)
end
def run_case(base, name:, args:, format:, golden:, stdin: nil, extra_files: {})
extra_files.each do |path, content|
File.write(path, substitute_base(content, base))
end
Tempfile.create(['deadfinder', ".#{format}"]) do |tmp|
resolved_args = substitute_base(args, base)
cmd = "#{BIN} #{resolved_args} -o #{tmp.path} -f #{format} -s"
stdout, stderr, status = Open3.capture3(cmd, stdin_data: stdin || '')
unless status.success?
warn "FAIL: #{name} — exit #{status.exitstatus}"
warn "CMD: #{cmd}"
warn "STDOUT: #{stdout}"
warn "STDERR: #{stderr}"
return false
end
expected_text = substitute_base(File.read(golden), base)
expected_path = Tempfile.new(['expected', ".#{format}"]).tap do |f|
f.write(expected_text)
f.close
end.path
expected = parse_output(expected_path, format)
actual = parse_output(tmp.path, format)
if sort_arrays(actual) == sort_arrays(expected)
true
else
warn "FAIL: #{name}"
warn "EXPECTED: #{expected.inspect}"
warn "ACTUAL: #{actual.inspect}"
false
end
end
ensure
extra_files.each_key { |path| FileUtils.rm_f(path) }
end
# --- Boot fixture server ----------------------------------------------------
server_io = IO.popen(['ruby', "#{HARNESS_ROOT}/fixtures/server.rb"], 'r')
port = server_io.gets&.strip
abort 'fixture server did not start' unless port && !port.empty?
base = "http://127.0.0.1:#{port}"
at_exit do
Process.kill('TERM', server_io.pid)
rescue Errno::ESRCH
# already gone
end
# --- Cases ------------------------------------------------------------------
urls_file = File.join(Dir.tmpdir, "deadfinder_compat_urls_#{Process.pid}.txt")
results = []
results << run_case(base,
name: 'url_json',
args: 'url {{BASE}}/index.html',
format: 'json',
golden: "#{HARNESS_ROOT}/golden/url_json.json")
results << run_case(base,
name: 'url_yaml',
args: 'url {{BASE}}/index.html',
format: 'yaml',
golden: "#{HARNESS_ROOT}/golden/url_yaml.yaml")
results << run_case(base,
name: 'url_toml',
args: 'url {{BASE}}/index.html',
format: 'toml',
golden: "#{HARNESS_ROOT}/golden/url_toml.toml")
results << run_case(base,
name: 'url_csv',
args: 'url {{BASE}}/index.html',
format: 'csv',
golden: "#{HARNESS_ROOT}/golden/url_csv.csv")
results << run_case(base,
name: 'url_json_include30x',
args: 'url {{BASE}}/index.html -r',
format: 'json',
golden: "#{HARNESS_ROOT}/golden/url_json_include30x.json")
results << run_case(base,
name: 'file_json',
args: "file #{urls_file}",
format: 'json',
golden: "#{HARNESS_ROOT}/golden/file_json.json",
extra_files: { urls_file => "{{BASE}}/index.html\n" })
results << run_case(base,
name: 'pipe_json',
args: 'pipe',
format: 'json',
golden: "#{HARNESS_ROOT}/golden/pipe_json.json",
stdin: substitute_base("{{BASE}}/index.html\n", base))
exit(results.all? ? 0 : 1)
================================================
FILE: spec/deadfinder/cli_spec.cr
================================================
require "../spec_helper"
describe Deadfinder::CLI do
before_each do
WebMock.reset
reset_deadfinder_state
end
describe "Options defaults" do
it "has correct default values" do
options = Deadfinder::Options.new
options.concurrency.should eq 50
options.timeout.should eq 10
options.output.should eq ""
options.output_format.should eq "json"
options.headers.should eq [] of String
options.worker_headers.should eq [] of String
options.silent.should be_false
options.verbose.should be_false
options.debug.should be_false
options.include30x.should be_false
options.proxy.should eq ""
options.proxy_auth.should eq ""
options.match.should eq ""
options.ignore.should eq ""
options.coverage.should be_false
options.visualize.should eq ""
options.limit.should eq 0
end
end
describe "completion scripts" do
it "generates bash completion script" do
script = Deadfinder::Completion.bash
script.should contain "_deadfinder_completions"
script.should contain "complete -F _deadfinder_completions deadfinder"
script.should contain "COMPREPLY"
end
it "generates zsh completion script" do
script = Deadfinder::Completion.zsh
script.should contain "#compdef deadfinder"
script.should contain "_arguments"
script.should contain "--include30x"
end
it "generates fish completion script" do
script = Deadfinder::Completion.fish
script.should contain "complete -c deadfinder -l include30x"
script.should contain "complete -c deadfinder -l debug -d 'Debug mode'"
script.should contain "complete -c deadfinder -l concurrency"
end
end
describe "version" do
it "has correct version" do
Deadfinder::VERSION.should eq "2.0.2"
end
end
end
================================================
FILE: spec/deadfinder/http_client_spec.cr
================================================
require "../spec_helper"
describe Deadfinder::HttpClient do
before_each do
reset_deadfinder_state
end
describe ".create" do
it "creates a basic HTTP client" do
uri = URI.parse("http://example.com")
options = default_test_options
client = Deadfinder::HttpClient.create(uri, options)
client.should be_a(HTTP::Client)
end
it "creates an HTTPS client with SSL" do
uri = URI.parse("https://example.com")
options = default_test_options
client = Deadfinder::HttpClient.create(uri, options)
client.should be_a(HTTP::Client)
end
it "creates client with custom timeout without error" do
uri = URI.parse("http://example.com")
options = default_test_options
options.timeout = 5
client = Deadfinder::HttpClient.create(uri, options)
client.should be_a(HTTP::Client)
end
it "falls back to direct connection when proxy has no host" do
uri = URI.parse("http://example.com")
options = default_test_options
options.proxy = "not-a-valid-proxy"
client = Deadfinder::HttpClient.create(uri, options)
client.should be_a(HTTP::Client)
end
it "creates client without proxy when proxy is empty" do
uri = URI.parse("http://example.com")
options = default_test_options
options.proxy = ""
client = Deadfinder::HttpClient.create(uri, options)
client.should be_a(HTTP::Client)
end
it "creates an HTTPS client when insecure flag is enabled" do
uri = URI.parse("https://example.com")
options = default_test_options
options.insecure = true
client = Deadfinder::HttpClient.create(uri, options)
client.should be_a(HTTP::Client)
end
it "creates an HTTPS client with verification enabled by default" do
uri = URI.parse("https://example.com")
options = default_test_options
options.insecure.should be_false
client = Deadfinder::HttpClient.create(uri, options)
client.should be_a(HTTP::Client)
end
end
describe ".proxy_configured?" do
it "returns false when proxy is empty" do
options = default_test_options
options.proxy = ""
Deadfinder::HttpClient.proxy_configured?(options).should be_false
end
it "returns true when proxy is set" do
options = default_test_options
options.proxy = "http://proxy.example.com:8080"
Deadfinder::HttpClient.proxy_configured?(options).should be_true
end
end
describe ".absolute_uri" do
it "returns the full URI string" do
uri = URI.parse("http://example.com/path?q=1")
Deadfinder::HttpClient.absolute_uri(uri).should eq("http://example.com/path?q=1")
end
end
end
================================================
FILE: spec/deadfinder/logger_spec.cr
================================================
require "../spec_helper"
describe Deadfinder::Logger do
before_each do
Deadfinder::Logger.unset_silent
Deadfinder::Logger.unset_verbose
Deadfinder::Logger.unset_debug
end
describe ".apply_options" do
it "sets silent mode when options has silent" do
options = Deadfinder::Options.new
options.silent = true
options.verbose = false
options.debug = false
Deadfinder::Logger.apply_options(options)
Deadfinder::Logger.silent?.should be_true
end
it "sets verbose mode when options has verbose" do
options = Deadfinder::Options.new
options.silent = false
options.verbose = true
options.debug = false
Deadfinder::Logger.apply_options(options)
Deadfinder::Logger.verbose?.should be_true
end
it "sets debug mode when options has debug" do
options = Deadfinder::Options.new
options.silent = false
options.verbose = false
options.debug = true
Deadfinder::Logger.apply_options(options)
Deadfinder::Logger.debug?.should be_true
end
it "sets multiple modes simultaneously" do
options = Deadfinder::Options.new
options.silent = true
options.verbose = true
options.debug = true
Deadfinder::Logger.apply_options(options)
Deadfinder::Logger.silent?.should be_true
Deadfinder::Logger.verbose?.should be_true
Deadfinder::Logger.debug?.should be_true
end
end
describe ".silent?" do
it "returns false by default" do
Deadfinder::Logger.silent?.should be_false
end
end
describe ".set_silent / .unset_silent" do
it "sets and unsets silent mode" do
Deadfinder::Logger.set_silent
Deadfinder::Logger.silent?.should be_true
Deadfinder::Logger.unset_silent
Deadfinder::Logger.silent?.should be_false
end
end
describe ".verbose?" do
it "returns false by default" do
Deadfinder::Logger.verbose?.should be_false
end
end
describe ".set_verbose / .unset_verbose" do
it "sets and unsets verbose mode" do
Deadfinder::Logger.set_verbose
Deadfinder::Logger.verbose?.should be_true
Deadfinder::Logger.unset_verbose
Deadfinder::Logger.verbose?.should be_false
end
end
describe ".debug?" do
it "returns false by default" do
Deadfinder::Logger.debug?.should be_false
end
end
describe ".set_debug / .unset_debug" do
it "sets and unsets debug mode" do
Deadfinder::Logger.set_debug
Deadfinder::Logger.debug?.should be_true
Deadfinder::Logger.unset_debug
Deadfinder::Logger.debug?.should be_false
end
end
describe "output suppression in silent mode" do
it "does not output when silent" do
Deadfinder::Logger.set_silent
# These should not raise and should produce no visible output
Deadfinder::Logger.info("test")
Deadfinder::Logger.error("test")
Deadfinder::Logger.target("test")
Deadfinder::Logger.sub_info("test")
Deadfinder::Logger.sub_complete("test")
Deadfinder::Logger.found("test")
end
end
end
================================================
FILE: spec/deadfinder/runner_spec.cr
================================================
require "../spec_helper"
describe Deadfinder::Runner do
before_each { WebMock.reset }
describe "#run" do
it "finds broken links (404)" do
target = "http://example.com"
html = <<-HTML
<html><body>
<a href="http://example.com/broken">Broken</a>
<a href="http://example.com/valid">Valid</a>
</body></html>
HTML
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://example.com/broken").to_return(status: 404)
WebMock.stub(:get, "http://example.com/valid").to_return(status: 200)
runner = Deadfinder::Runner.new
options = default_test_options
args = make_runner_args
runner.run(target, options, **args)
args[:output][target]?.should_not be_nil
args[:output][target].should contain "http://example.com/broken"
args[:output][target].should_not contain "http://example.com/valid"
end
it "finds multiple broken links" do
target = "http://example.com"
html = <<-HTML
<html><body>
<a href="http://example.com/dead1">D1</a>
<a href="http://example.com/dead2">D2</a>
<a href="http://example.com/ok">OK</a>
</body></html>
HTML
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://example.com/dead1").to_return(status: 404)
WebMock.stub(:get, "http://example.com/dead2").to_return(status: 500)
WebMock.stub(:get, "http://example.com/ok").to_return(status: 200)
runner = Deadfinder::Runner.new
options = default_test_options
args = make_runner_args
runner.run(target, options, **args)
args[:output][target].should contain "http://example.com/dead1"
args[:output][target].should contain "http://example.com/dead2"
args[:output][target].should_not contain "http://example.com/ok"
end
it "does not flag 3xx as dead by default" do
target = "http://example.com"
html = %(<html><body><a href="http://example.com/redirect">R</a></body></html>)
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://example.com/redirect").to_return(status: 301)
runner = Deadfinder::Runner.new
options = default_test_options
args = make_runner_args
runner.run(target, options, **args)
(args[:output][target]? || [] of String).should_not contain "http://example.com/redirect"
end
it "flags 3xx as dead when include30x is true" do
target = "http://example.com"
html = %(<html><body><a href="http://example.com/redirect">R</a></body></html>)
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://example.com/redirect").to_return(status: 301)
runner = Deadfinder::Runner.new
options = default_test_options
options.include30x = true
args = make_runner_args
runner.run(target, options, **args)
args[:output][target]?.should_not be_nil
args[:output][target].should contain "http://example.com/redirect"
end
it "respects match option - only checks matched URLs" do
target = "http://example.com"
html = <<-HTML
<html><body>
<a href="http://example.com/broken">Broken</a>
<a href="http://example.com/valid">Valid</a>
</body></html>
HTML
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://example.com/broken").to_return(status: 404)
# valid은 match 안 하므로 stub 불필요하지만 안전하게 추가
WebMock.stub(:get, "http://example.com/valid").to_return(status: 200)
runner = Deadfinder::Runner.new
options = default_test_options
options.match = "broken"
args = make_runner_args
runner.run(target, options, **args)
args[:output][target]?.should_not be_nil
args[:output][target].should contain "http://example.com/broken"
end
it "respects ignore option - skips ignored URLs" do
target = "http://example.com"
html = <<-HTML
<html><body>
<a href="http://example.com/broken">Broken</a>
<a href="http://example.com/valid">Valid</a>
</body></html>
HTML
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://example.com/broken").to_return(status: 404)
runner = Deadfinder::Runner.new
options = default_test_options
options.ignore = "valid"
args = make_runner_args
runner.run(target, options, **args)
args[:output][target]?.should_not be_nil
args[:output][target].should contain "http://example.com/broken"
args[:output][target].should_not contain "http://example.com/valid"
end
it "handles invalid match pattern gracefully" do
target = "http://example.com"
html = %(<html><body><a href="http://example.com/page">Link</a></body></html>)
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://example.com/page").to_return(status: 200)
runner = Deadfinder::Runner.new
options = default_test_options
options.match = "["
args = make_runner_args
# Should not raise - error is logged internally
runner.run(target, options, **args)
end
it "handles invalid ignore pattern gracefully" do
target = "http://example.com"
html = %(<html><body><a href="http://example.com/page">Link</a></body></html>)
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://example.com/page").to_return(status: 200)
runner = Deadfinder::Runner.new
options = default_test_options
options.ignore = "["
args = make_runner_args
# Should not raise
runner.run(target, options, **args)
end
it "handles target fetch failure gracefully" do
target = "http://unreachable.invalid"
WebMock.stub(:get, target).to_return(status: 500, body: "")
runner = Deadfinder::Runner.new
options = default_test_options
args = make_runner_args
# Should not raise
runner.run(target, options, **args)
end
it "extracts links from all 7 HTML element types" do
target = "http://example.com"
html = <<-HTML
<html>
<head>
<script src="http://example.com/script.js"></script>
<link href="http://example.com/style.css">
</head>
<body>
<a href="http://example.com/page">Link</a>
<iframe src="http://example.com/frame"></iframe>
<form action="http://example.com/submit"></form>
<object data="http://example.com/object.swf"></object>
<embed src="http://example.com/embed.swf">
</body></html>
HTML
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://example.com/script.js").to_return(status: 404)
WebMock.stub(:get, "http://example.com/style.css").to_return(status: 404)
WebMock.stub(:get, "http://example.com/page").to_return(status: 404)
WebMock.stub(:get, "http://example.com/frame").to_return(status: 404)
WebMock.stub(:get, "http://example.com/submit").to_return(status: 404)
WebMock.stub(:get, "http://example.com/object.swf").to_return(status: 404)
WebMock.stub(:get, "http://example.com/embed.swf").to_return(status: 404)
runner = Deadfinder::Runner.new
options = default_test_options
args = make_runner_args
runner.run(target, options, **args)
dead = args[:output][target]
dead.should contain "http://example.com/script.js"
dead.should contain "http://example.com/style.css"
dead.should contain "http://example.com/page"
dead.should contain "http://example.com/frame"
dead.should contain "http://example.com/submit"
dead.should contain "http://example.com/object.swf"
dead.should contain "http://example.com/embed.swf"
end
it "resolves relative URLs against target" do
target = "http://example.com/docs/"
html = %(<html><body><a href="/about">About</a><a href="page.html">Page</a></body></html>)
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://example.com/about").to_return(status: 404)
WebMock.stub(:get, "http://example.com/docs/page.html").to_return(status: 404)
runner = Deadfinder::Runner.new
options = default_test_options
args = make_runner_args
runner.run(target, options, **args)
dead = args[:output][target]
dead.should contain "http://example.com/about"
dead.should contain "http://example.com/docs/page.html"
end
it "skips mailto/tel/data scheme links" do
target = "http://example.com"
html = <<-HTML
<html><body>
<a href="mailto:test@example.com">Mail</a>
<a href="tel:1234567890">Tel</a>
<a href="data:text/plain,hello">Data</a>
<a href="http://example.com/real">Real</a>
</body></html>
HTML
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://example.com/real").to_return(status: 200)
runner = Deadfinder::Runner.new
options = default_test_options
args = make_runner_args
runner.run(target, options, **args)
# No dead links from special schemes, and no errors
dead = args[:output][target]? || [] of String
dead.should_not contain "mailto:test@example.com"
dead.should_not contain "tel:1234567890"
end
it "deduplicates URLs" do
target = "http://example.com"
html = <<-HTML
<html><body>
<a href="http://example.com/dup">Link1</a>
<a href="http://example.com/dup">Link2</a>
<a href="http://example.com/dup">Link3</a>
</body></html>
HTML
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://example.com/dup").to_return(status: 404)
runner = Deadfinder::Runner.new
options = default_test_options
args = make_runner_args
runner.run(target, options, **args)
# Should appear only once in output
args[:output][target].count("http://example.com/dup").should eq 1
end
it "tracks coverage data when coverage is enabled" do
target = "http://example.com"
html = <<-HTML
<html><body>
<a href="http://example.com/dead">Dead</a>
<a href="http://example.com/ok1">Ok1</a>
<a href="http://example.com/ok2">Ok2</a>
</body></html>
HTML
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://example.com/dead").to_return(status: 404)
WebMock.stub(:get, "http://example.com/ok1").to_return(status: 200)
WebMock.stub(:get, "http://example.com/ok2").to_return(status: 200)
runner = Deadfinder::Runner.new
options = default_test_options
options.coverage = true
args = make_runner_args
runner.run(target, options, **args)
cov = args[:coverage_data][target]
cov.total.should eq 3
cov.dead.should eq 1
cov.status_counts["404"].should eq 1
cov.status_counts["200"].should eq 2
end
it "does not track coverage when coverage is disabled" do
target = "http://example.com"
html = %(<html><body><a href="http://example.com/page">L</a></body></html>)
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://example.com/page").to_return(status: 404)
runner = Deadfinder::Runner.new
options = default_test_options
options.coverage = false
args = make_runner_args
runner.run(target, options, **args)
args[:coverage_data][target]?.should be_nil
end
it "handles empty HTML page with no links" do
target = "http://example.com"
WebMock.stub(:get, target).to_return(body: "<html><body></body></html>")
runner = Deadfinder::Runner.new
options = default_test_options
args = make_runner_args
runner.run(target, options, **args)
(args[:output][target]? || [] of String).should be_empty
end
end
describe "#worker" do
it "detects 404 as broken link" do
target = "http://example.com"
url = "http://example.com/broken"
WebMock.stub(:get, url).to_return(status: 404)
runner = Deadfinder::Runner.new
options = default_test_options
args = make_runner_args
jobs = Channel(String).new(10)
results = Channel(String).new(10)
jobs.send(url)
jobs.close
runner.worker(1, jobs, results, target, options, **args)
args[:output][target].should contain url
end
it "detects 500 as broken link" do
target = "http://example.com"
url = "http://example.com/error"
WebMock.stub(:get, url).to_return(status: 500)
runner = Deadfinder::Runner.new
options = default_test_options
args = make_runner_args
jobs = Channel(String).new(10)
results = Channel(String).new(10)
jobs.send(url)
jobs.close
runner.worker(1, jobs, results, target, options, **args)
args[:output][target].should contain url
end
it "does not flag 200 as broken" do
target = "http://example.com"
url = "http://example.com/ok"
WebMock.stub(:get, url).to_return(status: 200)
runner = Deadfinder::Runner.new
options = default_test_options
args = make_runner_args
jobs = Channel(String).new(10)
results = Channel(String).new(10)
jobs.send(url)
jobs.close
runner.worker(1, jobs, results, target, options, **args)
(args[:output][target]? || [] of String).should_not contain url
end
it "does not flag 301 as broken without include30x" do
target = "http://example.com"
url = "http://example.com/moved"
WebMock.stub(:get, url).to_return(status: 301)
runner = Deadfinder::Runner.new
options = default_test_options
options.include30x = false
args = make_runner_args
jobs = Channel(String).new(10)
results = Channel(String).new(10)
jobs.send(url)
jobs.close
runner.worker(1, jobs, results, target, options, **args)
(args[:output][target]? || [] of String).should_not contain url
end
it "flags 301 as broken with include30x" do
target = "http://example.com"
url = "http://example.com/moved"
WebMock.stub(:get, url).to_return(status: 301)
runner = Deadfinder::Runner.new
options = default_test_options
options.include30x = true
args = make_runner_args
jobs = Channel(String).new(10)
results = Channel(String).new(10)
jobs.send(url)
jobs.close
runner.worker(1, jobs, results, target, options, **args)
args[:output][target].should contain url
end
it "skips already cached URLs" do
target = "http://example.com"
url = "http://example.com/cached"
WebMock.stub(:get, url).to_return(status: 404)
runner = Deadfinder::Runner.new
options = default_test_options
args = make_runner_args
# Pre-populate cache
args[:cache_set][url] = true
jobs = Channel(String).new(10)
results = Channel(String).new(10)
jobs.send(url)
jobs.close
runner.worker(1, jobs, results, target, options, **args)
# Should NOT appear in output because it was cached
(args[:output][target]? || [] of String).should_not contain url
end
it "processes multiple jobs sequentially" do
target = "http://example.com"
WebMock.stub(:get, "http://example.com/a").to_return(status: 404)
WebMock.stub(:get, "http://example.com/b").to_return(status: 200)
WebMock.stub(:get, "http://example.com/c").to_return(status: 503)
runner = Deadfinder::Runner.new
options = default_test_options
args = make_runner_args
jobs = Channel(String).new(10)
results = Channel(String).new(10)
jobs.send("http://example.com/a")
jobs.send("http://example.com/b")
jobs.send("http://example.com/c")
jobs.close
runner.worker(1, jobs, results, target, options, **args)
dead = args[:output][target]
dead.should contain "http://example.com/a"
dead.should_not contain "http://example.com/b"
dead.should contain "http://example.com/c"
end
it "tracks coverage with status counts" do
target = "http://example.com"
WebMock.stub(:get, "http://example.com/ok").to_return(status: 200)
WebMock.stub(:get, "http://example.com/not-found").to_return(status: 404)
WebMock.stub(:get, "http://example.com/server-err").to_return(status: 500)
runner = Deadfinder::Runner.new
options = default_test_options
options.coverage = true
args = make_runner_args
jobs = Channel(String).new(10)
results = Channel(String).new(10)
jobs.send("http://example.com/ok")
jobs.send("http://example.com/not-found")
jobs.send("http://example.com/server-err")
jobs.close
runner.worker(1, jobs, results, target, options, **args)
cov = args[:coverage_data][target]
cov.total.should eq 3
cov.dead.should eq 2
cov.status_counts["200"].should eq 1
cov.status_counts["404"].should eq 1
cov.status_counts["500"].should eq 1
end
it "sends worker_headers with requests" do
target = "http://example.com"
url = "http://example.com/authed"
WebMock.stub(:get, url)
.with(headers: {"Authorization" => "Bearer token123"})
.to_return(status: 200)
runner = Deadfinder::Runner.new
options = default_test_options
options.worker_headers = ["Authorization: Bearer token123"]
args = make_runner_args
jobs = Channel(String).new(10)
results = Channel(String).new(10)
jobs.send(url)
jobs.close
runner.worker(1, jobs, results, target, options, **args)
# Should not be in dead links (200 response with correct headers)
(args[:output][target]? || [] of String).should_not contain url
end
end
end
================================================
FILE: spec/deadfinder/url_pattern_matcher_spec.cr
================================================
require "../spec_helper"
describe Deadfinder::UrlPatternMatcher do
describe ".match?" do
it "returns true when the URL matches the pattern" do
Deadfinder::UrlPatternMatcher.match?("http://example.com", "example").should be_true
end
it "returns false when the URL does not match the pattern" do
Deadfinder::UrlPatternMatcher.match?("http://example.com", "nonexistent").should be_false
end
it "raises an error when the pattern is an invalid regex" do
expect_raises(ArgumentError) do
Deadfinder::UrlPatternMatcher.match?("http://example.com", "[")
end
end
it "supports complex regex patterns" do
Deadfinder::UrlPatternMatcher.match?("http://example.com/path/to/page", "path/to/\\w+").should be_true
end
it "supports anchored patterns" do
Deadfinder::UrlPatternMatcher.match?("http://example.com", "^http://example").should be_true
Deadfinder::UrlPatternMatcher.match?("http://example.com", "^https://example").should be_false
end
it "matches query parameters" do
Deadfinder::UrlPatternMatcher.match?("http://example.com?foo=bar", "foo=bar").should be_true
end
end
describe ".ignore?" do
it "returns true when the URL matches the pattern" do
Deadfinder::UrlPatternMatcher.ignore?("http://example.com", "example").should be_true
end
it "returns false when the URL does not match the pattern" do
Deadfinder::UrlPatternMatcher.ignore?("http://example.com", "nonexistent").should be_false
end
it "raises an error when the pattern is an invalid regex" do
expect_raises(ArgumentError) do
Deadfinder::UrlPatternMatcher.ignore?("http://example.com", "[")
end
end
it "can ignore multiple URL patterns with alternation" do
Deadfinder::UrlPatternMatcher.ignore?("http://example.com/ads", "ads|tracking").should be_true
Deadfinder::UrlPatternMatcher.ignore?("http://example.com/tracking", "ads|tracking").should be_true
Deadfinder::UrlPatternMatcher.ignore?("http://example.com/page", "ads|tracking").should be_false
end
end
describe "ReDoS guardrails" do
before_each { Deadfinder::UrlPatternMatcher.clear_cache }
it "rejects patterns longer than MAX_PATTERN_LENGTH" do
long_pattern = "a" * (Deadfinder::UrlPatternMatcher::MAX_PATTERN_LENGTH + 1)
expect_raises(Deadfinder::UrlPatternMatcher::UnsafePatternError) do
Deadfinder::UrlPatternMatcher.match?("http://example.com", long_pattern)
end
end
it "rejects classic nested-quantifier ReDoS shapes like (a+)+" do
expect_raises(Deadfinder::UrlPatternMatcher::UnsafePatternError) do
Deadfinder::UrlPatternMatcher.match?("aaaa", "(a+)+")
end
end
it "rejects (a*)* " do
expect_raises(Deadfinder::UrlPatternMatcher::UnsafePatternError) do
Deadfinder::UrlPatternMatcher.ignore?("aaaa", "(a*)*")
end
end
it "rejects (.+){2,} bounded-repeat variant" do
expect_raises(Deadfinder::UrlPatternMatcher::UnsafePatternError) do
Deadfinder::UrlPatternMatcher.match?("aaaa", "(.+){2,}")
end
end
it "UnsafePatternError is-a ArgumentError so runner rescue still catches" do
(Deadfinder::UrlPatternMatcher::UnsafePatternError < ArgumentError).should be_true
end
it "does not flag patterns with escaped literal parens" do
# `\(a+\)+` = literal `(`, one-or-more `a`, literal `)`, one-or-more —
# there's no actual group being quantified, so no catastrophic backtracking.
Deadfinder::UrlPatternMatcher.match?("(aaa))))", "\\(a+\\)+").should be_true
end
end
describe "regex caching" do
before_each { Deadfinder::UrlPatternMatcher.clear_cache }
it "reuses the compiled regex across calls with the same pattern" do
pattern = "example"
Deadfinder::UrlPatternMatcher.match?("http://example.com", pattern)
Deadfinder::UrlPatternMatcher.match?("http://example.org", pattern)
Deadfinder::UrlPatternMatcher.match?("http://other.com", pattern)
# No public accessor to the cache map, but we at least exercise the
# hot path to confirm it does not blow up and returns consistent results.
Deadfinder::UrlPatternMatcher.match?("http://example.com", pattern).should be_true
end
end
end
================================================
FILE: spec/deadfinder/utils_spec.cr
================================================
require "../spec_helper"
describe "Deadfinder.generate_url" do
base_url = "http://example.com/base/"
it "returns the original URL if it starts with http://" do
Deadfinder.generate_url("http://example.com", base_url).should eq "http://example.com"
end
it "returns the original URL if it starts with https://" do
Deadfinder.generate_url("https://example.com", base_url).should eq "https://example.com"
end
it "prepends the scheme if the URL starts with //" do
Deadfinder.generate_url("//example.com", base_url).should eq "http://example.com"
end
it "prepends the scheme and host if the URL starts with /" do
Deadfinder.generate_url("/path", base_url).should eq "http://example.com/path"
end
it "returns nil if the URL should ignore the scheme" do
Deadfinder.generate_url("mailto:test@example.com", base_url).should be_nil
end
it "prepends the base directory if the URL is relative" do
Deadfinder.generate_url("relative/path", base_url).should eq "http://example.com/base/relative/path"
end
it "returns nil if base_url is invalid" do
Deadfinder.generate_url("relative/path", "://invalid").should be_nil
end
it "returns nil for empty text" do
Deadfinder.generate_url("", base_url).should be_nil
end
it "returns nil for whitespace-only text" do
Deadfinder.generate_url(" ", base_url).should be_nil
end
it "returns nil for javascript: scheme" do
Deadfinder.generate_url("javascript:void(0)", base_url).should be_nil
end
it "returns nil for data: scheme" do
Deadfinder.generate_url("data:text/plain,hello", base_url).should be_nil
end
it "returns nil for fragment-only (#) links" do
Deadfinder.generate_url("#section", base_url).should be_nil
end
it "handles protocol-relative URLs with https base" do
Deadfinder.generate_url("//cdn.example.com/lib.js", "https://example.com/").should eq "https://cdn.example.com/lib.js"
end
it "resolves relative URL when base path does not end with /" do
Deadfinder.generate_url("page.html", "http://example.com/dir/index.html").should eq "http://example.com/dir/page.html"
end
it "handles root-relative paths" do
Deadfinder.generate_url("/about", "https://example.com/some/deep/path").should eq "https://example.com/about"
end
it "preserves non-default port when resolving root-relative paths" do
Deadfinder.generate_url("/about", "http://127.0.0.1:8080/index.html").should eq "http://127.0.0.1:8080/about"
end
it "preserves non-default port when resolving relative paths" do
Deadfinder.generate_url("about", "http://127.0.0.1:8080/index.html").should eq "http://127.0.0.1:8080/about"
end
it "preserves non-default port when base path is a directory" do
Deadfinder.generate_url("page.html", "http://127.0.0.1:8080/dir/").should eq "http://127.0.0.1:8080/dir/page.html"
end
end
describe "Deadfinder.ignore_scheme?" do
it "returns true for mailto: URLs" do
Deadfinder.ignore_scheme?("mailto:test@example.com").should be_true
end
it "returns true for tel: URLs" do
Deadfinder.ignore_scheme?("tel:1234567890").should be_true
end
it "returns true for sms: URLs" do
Deadfinder.ignore_scheme?("sms:1234567890").should be_true
end
it "returns true for data: URLs" do
Deadfinder.ignore_scheme?("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==").should be_true
end
it "returns true for file: URLs" do
Deadfinder.ignore_scheme?("file:///path/to/file").should be_true
end
it "returns true for javascript: URLs" do
Deadfinder.ignore_scheme?("javascript:void(0)").should be_true
end
it "returns true for fragment-only links" do
Deadfinder.ignore_scheme?("#top").should be_true
end
it "returns false for http URLs" do
Deadfinder.ignore_scheme?("http://example.com").should be_false
end
it "returns false for https URLs" do
Deadfinder.ignore_scheme?("https://example.com").should be_false
end
it "returns false for relative paths" do
Deadfinder.ignore_scheme?("page.html").should be_false
end
end
================================================
FILE: spec/deadfinder/visualizer_spec.cr
================================================
require "../spec_helper"
require "stumpy_png"
require "file_utils"
describe Deadfinder::Visualizer do
describe ".generate" do
it "returns early when total_tested is zero" do
data = Deadfinder::CoverageResult.new(
targets: {} of String => Deadfinder::CoverageTarget,
summary: Deadfinder::CoverageSummary.new(
total_tested: 0,
total_dead: 0,
overall_coverage_percentage: 0.0,
overall_status_counts: {} of String => Int32
)
)
output_path = File.tempname("viz_test", ".png")
Deadfinder::Visualizer.generate(data, output_path)
File.exists?(output_path).should be_false
end
it "creates a valid 500x300 PNG with 200 status codes" do
data = Deadfinder::CoverageResult.new(
targets: {} of String => Deadfinder::CoverageTarget,
summary: Deadfinder::CoverageSummary.new(
total_tested: 10,
total_dead: 0,
overall_coverage_percentage: 0.0,
overall_status_counts: {"200" => 10}
)
)
output_path = File.tempname("viz_test", ".png")
begin
Deadfinder::Visualizer.generate(data, output_path)
File.exists?(output_path).should be_true
canvas = StumpyPNG.read(output_path)
canvas.width.should eq 500
canvas.height.should eq 300
# Check for green pixels (200 status = green)
green = StumpyPNG::RGBA.from_rgb8(0, 255, 0)
green_found = (110..180).any? { |y| canvas[250, y] == green }
green_found.should be_true
ensure
FileUtils.rm_rf(output_path)
end
end
it "draws orange bars for 3xx status codes" do
data = Deadfinder::CoverageResult.new(
targets: {} of String => Deadfinder::CoverageTarget,
summary: Deadfinder::CoverageSummary.new(
total_tested: 10,
total_dead: 10,
overall_coverage_percentage: 100.0,
overall_status_counts: {"301" => 10}
)
)
output_path = File.tempname("viz_test", ".png")
begin
Deadfinder::Visualizer.generate(data, output_path)
canvas = StumpyPNG.read(output_path)
orange = StumpyPNG::RGBA.from_rgb8(255, 165, 0)
orange_found = (110..180).any? { |y| canvas[250, y] == orange }
orange_found.should be_true
ensure
FileUtils.rm_rf(output_path)
end
end
it "draws red bars for 4xx status codes" do
data = Deadfinder::CoverageResult.new(
targets: {} of String => Deadfinder::CoverageTarget,
summary: Deadfinder::CoverageSummary.new(
total_tested: 10,
total_dead: 10,
overall_coverage_percentage: 100.0,
overall_status_counts: {"404" => 10}
)
)
output_path = File.tempname("viz_test", ".png")
begin
Deadfinder::Visualizer.generate(data, output_path)
canvas = StumpyPNG.read(output_path)
red = StumpyPNG::RGBA.from_rgb8(255, 0, 0)
red_found = (110..180).any? { |y| canvas[250, y] == red }
red_found.should be_true
ensure
FileUtils.rm_rf(output_path)
end
end
it "draws purple bars for 5xx status codes" do
data = Deadfinder::CoverageResult.new(
targets: {} of String => Deadfinder::CoverageTarget,
summary: Deadfinder::CoverageSummary.new(
total_tested: 10,
total_dead: 10,
overall_coverage_percentage: 100.0,
overall_status_counts: {"500" => 10}
)
)
output_path = File.tempname("viz_test", ".png")
begin
Deadfinder::Visualizer.generate(data, output_path)
canvas = StumpyPNG.read(output_path)
purple = StumpyPNG::RGBA.from_rgb8(128, 0, 128)
purple_found = (110..180).any? { |y| canvas[250, y] == purple }
purple_found.should be_true
ensure
FileUtils.rm_rf(output_path)
end
end
it "draws gray bars for error/unknown status codes" do
data = Deadfinder::CoverageResult.new(
targets: {} of String => Deadfinder::CoverageTarget,
summary: Deadfinder::CoverageSummary.new(
total_tested: 10,
total_dead: 10,
overall_coverage_percentage: 100.0,
overall_status_counts: {"error" => 10}
)
)
output_path = File.tempname("viz_test", ".png")
begin
Deadfinder::Visualizer.generate(data, output_path)
canvas = StumpyPNG.read(output_path)
gray = StumpyPNG::RGBA.from_rgb8(128, 128, 128)
gray_found = (110..180).any? { |y| canvas[250, y] == gray }
gray_found.should be_true
ensure
FileUtils.rm_rf(output_path)
end
end
it "creates PNG with mixed status codes" do
data = Deadfinder::CoverageResult.new(
targets: {} of String => Deadfinder::CoverageTarget,
summary: Deadfinder::CoverageSummary.new(
total_tested: 100,
total_dead: 60,
overall_coverage_percentage: 60.0,
overall_status_counts: {
"200" => 40, "301" => 20, "404" => 20, "500" => 10, "error" => 10,
}
)
)
output_path = File.tempname("viz_test", ".png")
begin
Deadfinder::Visualizer.generate(data, output_path)
File.exists?(output_path).should be_true
canvas = StumpyPNG.read(output_path)
canvas.width.should eq 500
canvas.height.should eq 300
ensure
FileUtils.rm_rf(output_path)
end
end
it "draws outline with semi-transparent black" do
data = Deadfinder::CoverageResult.new(
targets: {} of String => Deadfinder::CoverageTarget,
summary: Deadfinder::CoverageSummary.new(
total_tested: 10,
total_dead: 5,
overall_coverage_percentage: 50.0,
overall_status_counts: {"200" => 5, "404" => 5}
)
)
output_path = File.tempname("viz_test", ".png")
begin
Deadfinder::Visualizer.generate(data, output_path)
canvas = StumpyPNG.read(output_path)
outline = StumpyPNG::RGBA.new(0_u16, 0_u16, 0_u16, 32768_u16)
# Top line center
canvas[250, 100].should eq outline
# Bottom line center
canvas[250, 190].should eq outline
# Left line center
canvas[10, 145].should eq outline
# Right line center
canvas[490, 145].should eq outline
ensure
FileUtils.rm_rf(output_path)
end
end
it "skips zero-height bars" do
data = Deadfinder::CoverageResult.new(
targets: {} of String => Deadfinder::CoverageTarget,
summary: Deadfinder::CoverageSummary.new(
total_tested: 10_000,
total_dead: 0,
overall_coverage_percentage: 0.0,
overall_status_counts: {"200" => 1}
)
)
output_path = File.tempname("viz_test", ".png")
begin
Deadfinder::Visualizer.generate(data, output_path)
canvas = StumpyPNG.read(output_path)
# With 1/10000 * 70 = 0.007, height rounds to 0 so no green bars
green = StumpyPNG::RGBA.from_rgb8(0, 255, 0)
green_found = (110..180).any? { |y| canvas[250, y] == green }
green_found.should be_false
ensure
FileUtils.rm_rf(output_path)
end
end
it "handles empty status counts" do
data = Deadfinder::CoverageResult.new(
targets: {} of String => Deadfinder::CoverageTarget,
summary: Deadfinder::CoverageSummary.new(
total_tested: 10,
total_dead: 0,
overall_coverage_percentage: 0.0,
overall_status_counts: {} of String => Int32
)
)
output_path = File.tempname("viz_test", ".png")
begin
Deadfinder::Visualizer.generate(data, output_path)
File.exists?(output_path).should be_true
canvas = StumpyPNG.read(output_path)
canvas.width.should eq 500
canvas.height.should eq 300
ensure
FileUtils.rm_rf(output_path)
end
end
end
end
================================================
FILE: spec/deadfinder_spec.cr
================================================
require "./spec_helper"
describe Deadfinder do
before_each do
WebMock.reset
reset_deadfinder_state
end
describe "#version" do
it "returns the version number" do
Deadfinder::VERSION.should_not be_nil
Deadfinder::VERSION.should eq "2.0.2"
end
end
describe ".reset_state" do
it "clears output, coverage_data, and cache_set accumulators" do
Deadfinder.output["foo"] = ["bar"]
Deadfinder.coverage_data["foo"] = Deadfinder::TargetCoverage.new(total: 1, dead: 1)
Deadfinder.cache_set["foo"] = true
Deadfinder.reset_state
Deadfinder.output.should be_empty
Deadfinder.coverage_data.should be_empty
Deadfinder.cache_set.should be_empty
end
end
describe "#run_url" do
it "scans a single URL and collects broken links" do
target = "http://mock-site.test"
html = <<-HTML
<html><body>
<a href="http://mock-site.test/dead">Dead</a>
<a href="http://mock-site.test/alive">Alive</a>
</body></html>
HTML
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://mock-site.test/dead").to_return(status: 404)
WebMock.stub(:get, "http://mock-site.test/alive").to_return(status: 200)
options = default_test_options
Deadfinder.run_url(target, options)
Deadfinder.output[target]?.should_not be_nil
Deadfinder.output[target].should contain "http://mock-site.test/dead"
Deadfinder.output[target].should_not contain "http://mock-site.test/alive"
end
it "writes JSON output to file when output is specified" do
target = "http://mock-site.test"
html = %(<html><body><a href="http://mock-site.test/broken">X</a></body></html>)
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://mock-site.test/broken").to_return(status: 404)
tempfile = File.tempfile("deadfinder_run_url", ".json")
begin
options = default_test_options
options.output = tempfile.path
options.output_format = "json"
Deadfinder.run_url(target, options)
content = File.read(tempfile.path)
parsed = JSON.parse(content)
parsed[target].as_a.map(&.as_s).should contain "http://mock-site.test/broken"
ensure
tempfile.delete
end
end
end
describe "#run_file" do
it "scans URLs read from a file" do
target = "http://mock-file.test"
html = %(<html><body><a href="http://mock-file.test/dead">X</a></body></html>)
WebMock.stub(:get, target).to_return(body: html)
WebMock.stub(:get, "http://mock-file.test/dead").to_return(status: 404)
urlfile = File.tempfile("deadfinder_urls", ".txt")
begin
File.write(urlfile.path, "#{target}\n")
options = default_test_options
Deadfinder.run_file(urlfile.path, options)
Deadfinder.output[target]?.should_not be_nil
Deadfinder.output[target].should contain "http://mock-file.test/dead"
ensure
urlfile.delete
end
end
it "respects limit option" do
html1 = %(<html><body><a href="http://mock1.test/page">P</a></body></html>)
html2 = %(<html><body><a href="http://mock2.test/page">P</a></body></html>)
WebMock.stub(:get, "http://mock1.test").to_return(body: html1)
WebMock.stub(:get, "http://mock1.test/page").to_return(status: 200)
WebMock.stub(:get, "http://mock2.test").to_return(body: html2)
WebMock.stub(:get, "http://mock2.test/page").to_return(status: 200)
urlfile = File.tempfile("deadfinder_urls", ".txt")
begin
File.write(urlfile.path, "http://mock1.test\nhttp://mock2.test\n")
options = default_test_options
options.lim
gitextract_ukz2km7y/
├── .dockerignore
├── .github/
│ ├── FUNDING.yml
│ ├── dependabot.yml
│ ├── labeler.yml
│ └── workflows/
│ ├── ci.yml
│ ├── compat.yml
│ ├── contributors.yml
│ ├── crystal-release.yml
│ ├── docker-build.yml
│ ├── docker-ghcr.yml
│ ├── docs.yml
│ ├── goyo-update.yml
│ ├── labeler.yml
│ ├── publish-snapcraft.yml
│ ├── release-apk.yml
│ ├── release-aur.yml
│ ├── release-deb.yml
│ ├── release-major-tag.yml
│ ├── release-rpm.yml
│ └── release-sbom.yml
├── .gitignore
├── AGENTS.md
├── CHANGELOG.md
├── Dockerfile
├── LICENSE
├── README.md
├── SECURITY.md
├── action.yml
├── aur/
│ └── PKGBUILD
├── docs/
│ ├── AGENTS.md
│ ├── config.toml
│ ├── content/
│ │ ├── about.md
│ │ ├── docs/
│ │ │ ├── _index.md
│ │ │ ├── getting-started/
│ │ │ │ ├── _index.md
│ │ │ │ ├── installation.md
│ │ │ │ └── quickstart.md
│ │ │ ├── integration/
│ │ │ │ ├── _index.md
│ │ │ │ ├── docker.md
│ │ │ │ └── github-action.md
│ │ │ ├── reference/
│ │ │ │ ├── _index.md
│ │ │ │ └── cli-flags.md
│ │ │ └── usage/
│ │ │ ├── _index.md
│ │ │ ├── filtering.md
│ │ │ ├── output-formats.md
│ │ │ └── subcommands.md
│ │ └── index.md
│ ├── static/
│ │ ├── CNAME
│ │ ├── css/
│ │ │ └── style.css
│ │ ├── icons/
│ │ │ └── site.webmanifest
│ │ └── js/
│ │ └── search.js
│ └── templates/
│ ├── 404.html
│ ├── footer.html
│ ├── header.html
│ ├── page.html
│ ├── section.html
│ ├── shortcodes/
│ │ └── alert.html
│ ├── taxonomy.html
│ └── taxonomy_term.html
├── flake.nix
├── github-action/
│ └── README.md
├── justfile
├── scripts/
│ ├── version_check.cr
│ └── version_update.cr
├── shard.yml
├── shards.nix
├── snap/
│ └── snapcraft.yaml
├── spec/
│ ├── compat/
│ │ ├── README.md
│ │ ├── fixtures/
│ │ │ └── server.rb
│ │ ├── golden/
│ │ │ ├── file_json.json
│ │ │ ├── pipe_json.json
│ │ │ ├── url_csv.csv
│ │ │ ├── url_json.json
│ │ │ ├── url_json_include30x.json
│ │ │ ├── url_toml.toml
│ │ │ └── url_yaml.yaml
│ │ └── run.rb
│ ├── deadfinder/
│ │ ├── cli_spec.cr
│ │ ├── http_client_spec.cr
│ │ ├── logger_spec.cr
│ │ ├── runner_spec.cr
│ │ ├── url_pattern_matcher_spec.cr
│ │ ├── utils_spec.cr
│ │ └── visualizer_spec.cr
│ ├── deadfinder_spec.cr
│ └── spec_helper.cr
└── src/
├── cli_main.cr
├── deadfinder/
│ ├── cli.cr
│ ├── completion.cr
│ ├── http_client.cr
│ ├── logger.cr
│ ├── runner.cr
│ ├── types.cr
│ ├── url_pattern_matcher.cr
│ ├── utils.cr
│ ├── version.cr
│ └── visualizer.cr
└── deadfinder.cr
SYMBOL INDEX (12 symbols across 2 files)
FILE: docs/static/js/search.js
function initSearch (line 19) | function initSearch() {
function updateSelection (line 115) | function updateSelection(results) {
function showSearch (line 126) | function showSearch() {
function hideSearch (line 134) | function hideSearch() {
function performSearch (line 139) | function performSearch() {
function getContentSnippet (line 197) | function getContentSnippet(text, match) {
function escapeHtml (line 217) | function escapeHtml(text) {
function highlightMatches (line 223) | function highlightMatches(text, match) {
FILE: spec/compat/run.rb
function sort_arrays (line 28) | def sort_arrays(obj)
function parse_output (line 36) | def parse_output(path, format)
function substitute_base (line 47) | def substitute_base(text, base)
function run_case (line 51) | def run_case(base, name:, args:, format:, golden:, stdin: nil, extra_fil...
Condensed preview — 97 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (275K chars).
[
{
"path": ".dockerignore",
"chars": 117,
"preview": ".git\n.github\ndocs\nexamples\ngithub-action\nspec\ntmp\ncoverage\nlib\ndeadfinder\nAGENTS.md\nREADME.md\nSECURITY.md\naction.yml\n"
},
{
"path": ".github/FUNDING.yml",
"chars": 14,
"preview": "github: hahwul"
},
{
"path": ".github/dependabot.yml",
"chars": 311,
"preview": "version: 2\nupdates:\n - package-ecosystem: github-actions\n directory: /\n schedule:\n interval: weekly\n\n - pac"
},
{
"path": ".github/labeler.yml",
"chars": 927,
"preview": "---\nconfig:\n - changed-files:\n - any-glob-to-any-file:\n - shard.yml\n - shard.lock\n - .g"
},
{
"path": ".github/workflows/ci.yml",
"chars": 877,
"preview": "---\nname: CI\non:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n\njobs:\n spec:\n runs-on: ubuntu-la"
},
{
"path": ".github/workflows/compat.yml",
"chars": 870,
"preview": "---\nname: Compat Tests\non:\n push:\n branches: [main]\n pull_request:\n branches: [main]\njobs:\n compat:\n runs-on"
},
{
"path": ".github/workflows/contributors.yml",
"chars": 1072,
"preview": "---\n name: Contributors\n on:\n push:\n branches: [main]\n workflow_dispatch:\n inputs:\n "
},
{
"path": ".github/workflows/crystal-release.yml",
"chars": 3586,
"preview": "---\nname: Crystal Release Builds\non:\n release:\n types: [published]\n workflow_dispatch:\n\npermissions:\n contents: wr"
},
{
"path": ".github/workflows/docker-build.yml",
"chars": 1234,
"preview": "---\nname: Docker Build CI\n\non:\n pull_request:\n branches: [main]\n push:\n branches: [main]\n workflow_dispatch:\n\nj"
},
{
"path": ".github/workflows/docker-ghcr.yml",
"chars": 5361,
"preview": "---\nname: GHCR Publish\non:\n push:\n branches: [main]\n release:\n types: [published]\n workflow_dispatch:\n input"
},
{
"path": ".github/workflows/docs.yml",
"chars": 835,
"preview": "---\nname: Docs CI/CD\n\non:\n push:\n branches: [main]\n paths:\n - \"docs/**\"\n - \".github/workflows/docs.yml\""
},
{
"path": ".github/workflows/goyo-update.yml",
"chars": 2233,
"preview": "name: Update Goyo Theme\n\non:\n schedule:\n # Run every Monday at 9:00 AM UTC\n - cron: \"0 9 * * 1\"\n workflow_dispat"
},
{
"path": ".github/workflows/labeler.yml",
"chars": 249,
"preview": "---\n name: Pull Request Labeler\n on: [pull_request_target]\n jobs:\n labeler:\n permissions:\n "
},
{
"path": ".github/workflows/publish-snapcraft.yml",
"chars": 1087,
"preview": "---\nname: Snapcraft Publish\non:\n release:\n types: [published]\n workflow_dispatch:\n inputs:\n logLevel:\n "
},
{
"path": ".github/workflows/release-apk.yml",
"chars": 4493,
"preview": "---\nname: Build and Release .apk Package\non:\n workflow_dispatch:\n inputs:\n version:\n description: \"Versi"
},
{
"path": ".github/workflows/release-aur.yml",
"chars": 1523,
"preview": "---\nname: Publish AUR Package\non:\n workflow_dispatch:\n inputs:\n version:\n description: \"Version to publi"
},
{
"path": ".github/workflows/release-deb.yml",
"chars": 3122,
"preview": "---\nname: Build and Release .deb Package\non:\n workflow_dispatch:\n inputs:\n version:\n description: \"Versi"
},
{
"path": ".github/workflows/release-major-tag.yml",
"chars": 1355,
"preview": "---\nname: Update Major Version Tag\non:\n release:\n types: [published]\n\npermissions:\n contents: write\n\n# Force-update"
},
{
"path": ".github/workflows/release-rpm.yml",
"chars": 3278,
"preview": "---\nname: Build and Release .rpm Package\non:\n workflow_dispatch:\n inputs:\n version:\n description: \"Versi"
},
{
"path": ".github/workflows/release-sbom.yml",
"chars": 903,
"preview": "---\nname: Generate and Upload SBOM\non:\n release:\n types: [published]\n workflow_dispatch:\n\npermissions:\n contents: "
},
{
"path": ".gitignore",
"chars": 208,
"preview": "/lib/\n/.shards/\n*.dwarf\n\n# Built binary\n/deadfinder\n\n# Release artifacts\n/deadfinder-*.tar.gz\n/deadfinder-*.tar.gz.sha25"
},
{
"path": "AGENTS.md",
"chars": 3573,
"preview": "# DeadFinder — Agent Guide\n\nDeadFinder is a CLI tool that finds broken links in web pages, sitemaps, and URL lists. It i"
},
{
"path": "CHANGELOG.md",
"chars": 5028,
"preview": "# Changelog\n\nAll notable changes are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1."
},
{
"path": "Dockerfile",
"chars": 1041,
"preview": "FROM crystallang/crystal:1.20.2-alpine AS builder\n\nRUN apk add --no-cache cmake make g++ git\n\nWORKDIR /build\nCOPY shard."
},
{
"path": "LICENSE",
"chars": 1082,
"preview": "MIT License\n\nCopyright (c) 2026 hahwul <hahwul@gmail.com>\n\nPermission is hereby granted, free of charge, to any person o"
},
{
"path": "README.md",
"chars": 6698,
"preview": "<div align=\"center\">\n <img alt=\"DeadFinder Logo\" src=\"docs/static/images/deadfinder.webp\" width=\"200px;\">\n <p>Find"
},
{
"path": "SECURITY.md",
"chars": 1095,
"preview": "# Security Policy\n\n## Reporting a Vulnerability\n\nFound a security issue? Let us know so we can fix it.\n\n### How to Repor"
},
{
"path": "action.yml",
"chars": 7726,
"preview": "---\nname: DeadFinder Action\ndescription: A GitHub Action to find and report dead (broken) links in files, URLs, or sitem"
},
{
"path": "aur/PKGBUILD",
"chars": 841,
"preview": "# Maintainer: HAHWUL <hahwul@gmail.com>\npkgname=deadfinder\npkgver=2.0.2\npkgrel=1\npkgdesc=\"Find dead (broken) links in we"
},
{
"path": "docs/AGENTS.md",
"chars": 2491,
"preview": "# AGENTS.md - AI Agent Instructions for Hwaro Site\n\nThis document provides instructions for AI agents working on this Hw"
},
{
"path": "docs/config.toml",
"chars": 7963,
"preview": "# =============================================================================\n# Site Configuration\n# ================="
},
{
"path": "docs/content/about.md",
"chars": 873,
"preview": "+++\ntitle = \"About\"\ndescription = \"About DeadFinder\"\n+++\n\nDeadFinder detects broken links — 4xx, 5xx, optionally 3xx — o"
},
{
"path": "docs/content/docs/_index.md",
"chars": 406,
"preview": "+++\ntitle = \"Documentation\"\ndescription = \"DeadFinder documentation\"\nsort_by = \"weight\"\n+++\n\nStart with [Installation](/"
},
{
"path": "docs/content/docs/getting-started/_index.md",
"chars": 260,
"preview": "+++\ntitle = \"Getting Started\"\ndescription = \"Install DeadFinder and run your first scan.\"\nweight = 1\nsort_by = \"weight\"\n"
},
{
"path": "docs/content/docs/getting-started/installation.md",
"chars": 2554,
"preview": "+++\ntitle = \"Installation\"\ndescription = \"Install DeadFinder via Homebrew, Docker, prebuilt binary, Nix, or from source."
},
{
"path": "docs/content/docs/getting-started/quickstart.md",
"chars": 1756,
"preview": "+++\ntitle = \"Quick Start\"\ndescription = \"Run your first DeadFinder scan and read its output.\"\nweight = 2\n+++\n\n## Scan a "
},
{
"path": "docs/content/docs/integration/_index.md",
"chars": 344,
"preview": "+++\ntitle = \"Integration\"\ndescription = \"Run DeadFinder from GitHub Actions or Docker.\"\nweight = 3\nsort_by = \"weight\"\n++"
},
{
"path": "docs/content/docs/integration/docker.md",
"chars": 1482,
"preview": "+++\ntitle = \"Docker\"\ndescription = \"ghcr.io/hahwul/deadfinder — multi-arch, cosign-signed, tiny Alpine base.\"\nweight = 2"
},
{
"path": "docs/content/docs/integration/github-action.md",
"chars": 3122,
"preview": "+++\ntitle = \"GitHub Action\"\ndescription = \"hahwul/deadfinder composite action — inputs, outputs, examples.\"\nweight = 1\n+"
},
{
"path": "docs/content/docs/reference/_index.md",
"chars": 178,
"preview": "+++\ntitle = \"Reference\"\ndescription = \"CLI flag reference.\"\nweight = 4\nsort_by = \"weight\"\n+++\n\n- [CLI flags](/docs/refer"
},
{
"path": "docs/content/docs/reference/cli-flags.md",
"chars": 2422,
"preview": "+++\ntitle = \"CLI Flags\"\ndescription = \"Complete reference for every deadfinder option.\"\nweight = 1\n+++\n\nRun `deadfinder "
},
{
"path": "docs/content/docs/usage/_index.md",
"chars": 520,
"preview": "+++\ntitle = \"Usage\"\ndescription = \"Subcommands, output formats, and filters.\"\nweight = 2\nsort_by = \"weight\"\n+++\n\nDeadFin"
},
{
"path": "docs/content/docs/usage/filtering.md",
"chars": 1498,
"preview": "+++\ntitle = \"Filtering\"\ndescription = \"Regex match/ignore, 3xx inclusion, URL limit.\"\nweight = 3\n+++\n\n## `--match=PATTER"
},
{
"path": "docs/content/docs/usage/output-formats.md",
"chars": 2500,
"preview": "+++\ntitle = \"Output Formats\"\ndescription = \"JSON, YAML, TOML, CSV, SARIF, coverage reports, and PNG visualization.\"\nweig"
},
{
"path": "docs/content/docs/usage/subcommands.md",
"chars": 1134,
"preview": "+++\ntitle = \"Subcommands\"\ndescription = \"url / file / pipe / sitemap / completion / version\"\nweight = 1\n+++\n\n## `url <UR"
},
{
"path": "docs/content/index.md",
"chars": 1624,
"preview": "+++\ntitle = \"DeadFinder\"\ndescription = \"Find dead (broken) links in web pages, URL lists, and sitemaps.\"\n+++\n\nFind dead "
},
{
"path": "docs/static/CNAME",
"chars": 22,
"preview": "deadfinder.hahwul.com\n"
},
{
"path": "docs/static/css/style.css",
"chars": 13909,
"preview": ":root {\n --sidebar-w: 280px;\n --toc-w: 220px;\n --content-max: 720px;\n --font: 'Inter', -apple-system, BlinkMacSystem"
},
{
"path": "docs/static/icons/site.webmanifest",
"chars": 453,
"preview": "{\n \"name\": \"DeadFinder\",\n \"short_name\": \"DeadFinder\",\n \"icons\": [\n {\n \"src\": \"/icons/web-app-manifest-192x192"
},
{
"path": "docs/static/js/search.js",
"chars": 7404,
"preview": "// Guard against double-load (auto-includes + explicit <script> both firing).\nif (window.__deadfinderSearchLoaded) {\n /"
},
{
"path": "docs/templates/404.html",
"chars": 254,
"preview": "{% include \"header.html\" %}\n <article class=\"prose\">\n <h1>404 Not Found</h1>\n <p>The page you are loo"
},
{
"path": "docs/templates/footer.html",
"chars": 1560,
"preview": " </div><!-- .main -->\n </div><!-- .layout -->\n\n <footer class=\"site-footer\">\n Powered by <a href=\"https://github"
},
{
"path": "docs/templates/header.html",
"chars": 7972,
"preview": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"widt"
},
{
"path": "docs/templates/page.html",
"chars": 159,
"preview": "{% include \"header.html\" %}\n <article class=\"prose\">\n <h1>{{ page.title }}</h1>\n {{ content }}\n "
},
{
"path": "docs/templates/section.html",
"chars": 261,
"preview": "{% include \"header.html\" %}\n <article class=\"prose\">\n <h1>{{ page.title }}</h1>\n {{ content }}\n "
},
{
"path": "docs/templates/shortcodes/alert.html",
"chars": 97,
"preview": "<div class=\"alert alert-{{ type }}\">\n <strong>{{ type | upper }}:</strong> {{ message }}\n</div>\n"
},
{
"path": "docs/templates/taxonomy.html",
"chars": 209,
"preview": "{% include \"header.html\" %}\n <article class=\"prose\">\n <h1>{{ page.title }}</h1>\n <p>Browse all terms "
},
{
"path": "docs/templates/taxonomy_term.html",
"chars": 203,
"preview": "{% include \"header.html\" %}\n <article class=\"prose\">\n <h1>{{ page.title }}</h1>\n <p>Pages tagged with"
},
{
"path": "flake.nix",
"chars": 3565,
"preview": "{\n description = \"DeadFinder — find dead (broken) links in web pages, URL lists, and sitemaps\";\n\n inputs = {\n nixpk"
},
{
"path": "github-action/README.md",
"chars": 27,
"preview": "## DeadFinder Github Action"
},
{
"path": "justfile",
"chars": 1078,
"preview": "default:\n @just --list\n\n# Install shard dependencies\ndeps:\n shards install\n\n# Build a release binary at ./deadfind"
},
{
"path": "scripts/version_check.cr",
"chars": 1848,
"preview": "require \"yaml\"\n\n# Cross-file version consistency check. Prints each discovered version\n# string and exits non-zero if an"
},
{
"path": "scripts/version_update.cr",
"chars": 1926,
"preview": "require \"yaml\"\n\n# Bump the version string across every tracked file in one pass. Run:\n#\n# crystal run scripts/version_"
},
{
"path": "shard.yml",
"chars": 432,
"preview": "name: deadfinder\nversion: 2.0.2\n\nauthors:\n - hahwul <hahwul@gmail.com>\n\ntargets:\n deadfinder:\n main: src/cli_main.c"
},
{
"path": "shards.nix",
"chars": 671,
"preview": "{\n \"lexbor\" = {\n url = \"https://github.com/kostya/lexbor.git\";\n rev = \"v3.4.2\";\n sha256 = \"0bsncwsvqf5zns0c56v"
},
{
"path": "snap/snapcraft.yaml",
"chars": 1153,
"preview": "name: deadfinder\nbase: core24\nversion: 2.0.2\nsummary: Find dead (broken) links in web pages, URL lists, and sitemaps.\nde"
},
{
"path": "spec/compat/README.md",
"chars": 888,
"preview": "# Compatibility harness\n\nRuby 원본 v1의 출력을 **골든 파일로 동결**하고, Crystal 바이너리가 동일 출력을 내는지 검증하는 블랙박스 테스트다.\n\n## 구조\n\n```\nspec/comp"
},
{
"path": "spec/compat/fixtures/server.rb",
"chars": 1616,
"preview": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\nrequire 'socket'\n\nROUTES = {\n '/index.html' => {\n status: 200,\n "
},
{
"path": "spec/compat/golden/file_json.json",
"chars": 55,
"preview": "{\n \"{{BASE}}/index.html\": [\n \"{{BASE}}/dead\"\n ]\n}\n"
},
{
"path": "spec/compat/golden/pipe_json.json",
"chars": 55,
"preview": "{\n \"{{BASE}}/index.html\": [\n \"{{BASE}}/dead\"\n ]\n}\n"
},
{
"path": "spec/compat/golden/url_csv.csv",
"chars": 45,
"preview": "target,url\n{{BASE}}/index.html,{{BASE}}/dead\n"
},
{
"path": "spec/compat/golden/url_json.json",
"chars": 55,
"preview": "{\n \"{{BASE}}/index.html\": [\n \"{{BASE}}/dead\"\n ]\n}\n"
},
{
"path": "spec/compat/golden/url_json_include30x.json",
"chars": 80,
"preview": "{\n \"{{BASE}}/index.html\": [\n \"{{BASE}}/dead\",\n \"{{BASE}}/redirect\"\n ]\n}\n"
},
{
"path": "spec/compat/golden/url_toml.toml",
"chars": 42,
"preview": "\"{{BASE}}/index.html\" = [\"{{BASE}}/dead\"]\n"
},
{
"path": "spec/compat/golden/url_yaml.yaml",
"chars": 41,
"preview": "---\n{{BASE}}/index.html:\n- {{BASE}}/dead\n"
},
{
"path": "spec/compat/run.rb",
"chars": 4804,
"preview": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\n# Black-box compatibility harness for the deadfinder Crystal binary.\n"
},
{
"path": "spec/deadfinder/cli_spec.cr",
"chars": 1864,
"preview": "require \"../spec_helper\"\n\ndescribe Deadfinder::CLI do\n before_each do\n WebMock.reset\n reset_deadfinder_state\n en"
},
{
"path": "spec/deadfinder/http_client_spec.cr",
"chars": 2709,
"preview": "require \"../spec_helper\"\n\ndescribe Deadfinder::HttpClient do\n before_each do\n reset_deadfinder_state\n end\n\n descri"
},
{
"path": "spec/deadfinder/logger_spec.cr",
"chars": 3094,
"preview": "require \"../spec_helper\"\n\ndescribe Deadfinder::Logger do\n before_each do\n Deadfinder::Logger.unset_silent\n Deadfi"
},
{
"path": "spec/deadfinder/runner_spec.cr",
"chars": 18123,
"preview": "require \"../spec_helper\"\n\ndescribe Deadfinder::Runner do\n before_each { WebMock.reset }\n\n describe \"#run\" do\n it \"f"
},
{
"path": "spec/deadfinder/url_pattern_matcher_spec.cr",
"chars": 4318,
"preview": "require \"../spec_helper\"\n\ndescribe Deadfinder::UrlPatternMatcher do\n describe \".match?\" do\n it \"returns true when th"
},
{
"path": "spec/deadfinder/utils_spec.cr",
"chars": 4063,
"preview": "require \"../spec_helper\"\n\ndescribe \"Deadfinder.generate_url\" do\n base_url = \"http://example.com/base/\"\n\n it \"returns t"
},
{
"path": "spec/deadfinder/visualizer_spec.cr",
"chars": 8063,
"preview": "require \"../spec_helper\"\nrequire \"stumpy_png\"\nrequire \"file_utils\"\n\ndescribe Deadfinder::Visualizer do\n describe \".gene"
},
{
"path": "spec/deadfinder_spec.cr",
"chars": 22750,
"preview": "require \"./spec_helper\"\n\ndescribe Deadfinder do\n before_each do\n WebMock.reset\n reset_deadfinder_state\n end\n\n d"
},
{
"path": "spec/spec_helper.cr",
"chars": 681,
"preview": "require \"spec\"\nrequire \"webmock\"\nrequire \"../src/deadfinder\"\nrequire \"../src/deadfinder/cli\"\n\ndef reset_deadfinder_state"
},
{
"path": "src/cli_main.cr",
"chars": 71,
"preview": "require \"./deadfinder\"\nrequire \"./deadfinder/cli\"\n\nDeadfinder::CLI.run\n"
},
{
"path": "src/deadfinder/cli.cr",
"chars": 5135,
"preview": "require \"option_parser\"\n\nmodule Deadfinder\n module CLI\n def self.run(args = ARGV)\n options = Options.new\n\n "
},
{
"path": "src/deadfinder/completion.cr",
"chars": 2909,
"preview": "module Deadfinder\n module Completion\n def self.bash : String\n <<-BASH\n _deadfinder_completions()\n {\n "
},
{
"path": "src/deadfinder/http_client.cr",
"chars": 4398,
"preview": "require \"http/client\"\nrequire \"openssl\"\nrequire \"uri\"\nrequire \"base64\"\nrequire \"socket\"\n\nmodule Deadfinder\n module Http"
},
{
"path": "src/deadfinder/logger.cr",
"chars": 2850,
"preview": "require \"colorize\"\n\nmodule Deadfinder\n module Logger\n @@silent = false\n @@verbose = false\n @@debug = false\n "
},
{
"path": "src/deadfinder/runner.cr",
"chars": 8260,
"preview": "require \"http/client\"\nrequire \"uri\"\nrequire \"lexbor\"\n\nmodule Deadfinder\n class Runner\n LINK_SELECTORS = {\n \"anc"
},
{
"path": "src/deadfinder/types.cr",
"chars": 1840,
"preview": "module Deadfinder\n class Options\n property concurrency : Int32 = 50\n property timeout : Int32 = 10\n property o"
},
{
"path": "src/deadfinder/url_pattern_matcher.cr",
"chars": 2283,
"preview": "module Deadfinder\n module UrlPatternMatcher\n MAX_PATTERN_LENGTH = 1024\n\n # Inherits from ArgumentError so existin"
},
{
"path": "src/deadfinder/utils.cr",
"chars": 1701,
"preview": "module Deadfinder\n IGNORED_SCHEMES = [\"mailto:\", \"tel:\", \"sms:\", \"data:\", \"file:\", \"javascript:\", \"#\"]\n\n def self.igno"
},
{
"path": "src/deadfinder/version.cr",
"chars": 42,
"preview": "module Deadfinder\n VERSION = \"2.0.2\"\nend\n"
},
{
"path": "src/deadfinder/visualizer.cr",
"chars": 3226,
"preview": "require \"stumpy_png\"\n\nmodule Deadfinder\n module Visualizer\n def self.generate(data : CoverageResult, output_path : S"
},
{
"path": "src/deadfinder.cr",
"chars": 15815,
"preview": "require \"uri\"\nrequire \"json\"\nrequire \"yaml\"\nrequire \"csv\"\nrequire \"xml\"\nrequire \"sarif\"\nrequire \"./deadfinder/version\"\nr"
}
]
About this extraction
This page contains the full source code of the hahwul/deadfinder GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 97 files (250.9 KB), approximately 68.2k tokens, and a symbol index with 12 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.