Full Code of hahwul/deadfinder for AI

main a8d3f5e6f12c cached
97 files
250.9 KB
68.2k tokens
12 symbols
1 requests
Download .txt
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.

![](https://github.com/user-attachments/assets/92129de9-90c6-41e0-a424-883fe30858f6)

> **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

![](docs/static/images/CONTRIBUTORS.svg)


================================================
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
Download .txt
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
Download .txt
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.

Copied to clipboard!