I have over 3000 external and 4000 internal links on my website. I can’t possibly check them manually. So what to do? I solved the problem as follows:
Install
You must install the following in (or beside) your Hugo project:
Your package.json should then look something like this:
{
  "name": "...",
  "scripts": {
    "ts:links": "tsx my-transport-scripts/links-check.ts",
  },
  "dependencies": {
    "md-curcuma": "^2.0.11",
    "tsx": "^4.19.4"
  },
  "devDependencies": {
    "@types/node": "^22.10.6",
    "typescript": "^5.7.3",
  },
  "engines": {
    "node": ">=20.11.0"
  }
}
The Broken Links Checker script
- Is stored like this: 
my-transport-scripts/links-check.ts. - The path and file name must of course be adapted if necessary.
 
import { Broken_Link_Checker, BLC_Parameter } from "md-curcuma"
// const url = 'http://localhost:1313/';
const url: string = 'http://192.168.178.91:81';
let external_links: BLC_Parameter = new BLC_Parameter();
external_links.scan_source = url;
external_links.write_to = 'data/links_checked/external.json';
external_links.mode = 'extern';
Broken_Link_Checker.run(external_links);
let internal_links: BLC_Parameter = new BLC_Parameter();
internal_links.scan_source = url;
internal_links.write_to = 'data/links_checked/internal.json';
internal_links.mode = 'intern';
Broken_Link_Checker.run(internal_links);
The script scans all internal and external links of the website, and creates two files in the data directory, which Hugo then evaluates in different ways:
- external.json
 - internal.json
 
This is what it looks like:
[
    {
        "scan_source": "http://192.168.178.91:81",
        "mode": "extern",
        "special_excludes": [
            "data:image/webp",
            "blog:",
            "troubleshooting:",
            "mailto:"
        ],
        "lastrun": "2025-05-15 13:53:510",
        "runtime": 11.558675909033335,
        "runtime_unit": "min",
        "found": 7717,
        "dropped": 4396,
        "finished": false,
        "total": 3169,
        "ok": 2810,
        "broken": 355,
        "skipped": 4,
        "links_ok": [
            {
                "url": "https://github.com/nextapps-de/flexsearch",
                "state": "OK",
                "status": 200,
                "scantime": "2025-05-15 13:53:708",
                "parent": "http://192.168.178.91:81/"
            }],
        "links_broken": [
            {
                "url": "https://twitter.com/handle",
                "state": "BROKEN",
                "status": 400,
                "scantime": "2025-05-15 13:53:611",
                "parent": "http://192.168.178.91:81/projects/xyw/"
            }],
        "links_skipped": [
            {
                "url": "The project guide",
                "state": "SKIPPED",
                "status": 0,
                "scantime": "2025-05-15 13:55:014",
                "parent": "http://192.168.178.91:81/linktree/"
            }]
    }
]
Output a statistic
To output statistics, there is of course a shortcode. This is what it looks like.
{{/* Broken links */}}
{{ $scratch_ExLinks := newScratch }}
{{ $scratch_InLinks := newScratch }}
{{ $lc_extern := site.Data.links_checked.external }} {{/* all links_broken */}}
{{ range $lc_extern }} {{/* Only one */}}
{{ $scratch_ExLinks.Add "total" .total }}
{{ $scratch_ExLinks.Add "ok" .ok }}
{{ $scratch_ExLinks.Add "broken" .broken }}
{{ $scratch_ExLinks.Add "skipped" .skipped }}
{{ $p := mul (float .broken) 100 }}
{{ $p = div (float $p) (float .total) }}
{{ $scratch_ExLinks.Add "percent-broken" ((float $p) | lang.FormatPercent 2) }}
{{ $p1 := mul (float .ok) 100 }}
{{ $p1 = div (float $p1) (float .total) }}
{{ $scratch_ExLinks.Add "percent-ok" ((float $p1) | lang.FormatPercent 2) }}
{{ end }}
{{ $lc_intern := site.Data.links_checked.internal }} {{/* all links_broken */}}
{{ range $lc_intern }} {{/* Only one */}}
{{ $scratch_InLinks.Add "total" .total }}
{{ $scratch_InLinks.Add "ok" .ok }}
{{ $scratch_InLinks.Add "broken" .broken }}
{{ $scratch_InLinks.Add "skipped" .skipped }}
{{ $p := mul (float .broken) 100 }}
{{ $p = div (float $p) (float .total) }}
{{ $scratch_InLinks.Add "percent-broken" ((float $p) | lang.FormatPercent 2) }}
{{ $p1 := mul (float .ok) 100 }}
{{ $p1 = div (float $p1) (float .total) }}
{{ $scratch_InLinks.Add "percent-ok" ((float $p1) | lang.FormatPercent 2) }}
{{ end }}
<span>Stats:</span>
	<span>
		{{ ($scratch_ExLinks.Get "total") }} external links, of which<br>{{ ($scratch_ExLinks.Get "ok") }} okay ({{ ($scratch_ExLinks.Get "percent-ok") }}), and {{ ($scratch_ExLinks.Get "broken") }} broken ({{ ($scratch_ExLinks.Get "percent-broken") }}).
	<br/>{{ ($scratch_InLinks.Get "total") }} internal links, of which<br>{{ ($scratch_InLinks.Get "ok") }} okay ({{ ($scratch_InLinks.Get "percent-ok") }}), and {{ ($scratch_InLinks.Get "broken") }} broken ({{ ($scratch_InLinks.Get "percent-broken") }}).
	<br/>Broken links are formatted with a strikethrough. I will correct them in time.
</span>
Mark links
To make navigation easier for visitors to the website, I would like to mark the links accordingly:
- External links with an icon
 - Broken links crossed out
 
This is done by the shortcode render-link.html which is stored in the directory layouts/_default/_markup/.
{{ $link := .Destination | safeURL }}
{{ $url := urls.Parse $link }}
{{ $message := "The link ist okay (200)" }}
{{ $is_broken_link := false }}
{{ if $url.IsAbs }}
{{/* external link -> komplett überprüfen */}}
{{ $data := site.Data.links_checked.external }} {{/* all links_broken */}}
{{ $d := index $data 0 }} {{/* Only one */}}
{{ $links_broken_array := $d.links_broken }}
{{ range $links_broken_array }}
{{if strings.Contains $link .url }}
{{ if eq (string .status) "0" }} {{/* i have to cast .status to string to compare */}}
{{ $message = println "This link seems okay with status=" .status }}
{{ else if eq (string .status) "404" }}
{{ $message = println "Sorry, this link does no longer exist, status=" .status }}
{{ $is_broken_link = true }}
{{ else if eq (string .status) "403" }}
{{ $message = println "Sorry, access to this link seems forbidden, status=" .status }}
{{ $is_broken_link = true }}
{{ else }}
{{ $message = println "Sorry, this link seems broken with status=" .status }}
{{ $is_broken_link = true }}
{{ end }} {{/* if status */}}
{{ end }} {{/* if url */}}
{{ end }} {{/* range */}}
{{ else }} {{/* if $url.IsAbs */}}
{{/* internal link -> nur path-anteil überprüfen */}}
{{ $data := site.Data.links_checked.internal }} {{/* all links_broken */}}
{{ $d := index $data 0 }} {{/* Only one */}}
{{ $links_broken_array := $d.links_broken }}
{{ range $links_broken_array }}
{{ $url_test := urls.Parse .url }}
{{if strings.Contains $url.Path $url_test.Path }}
{{ if eq (string .status) "0" }}
{{ $message = println "This link seems to be okay, but with status=" .status }}
{{ else if eq (string .status) "404" }}
{{ $message = println "Sorry, this link does no longer exist, status=" .status }}
{{ $is_broken_link = true }}
{{ else if eq (string .status) "403" }}
{{ $message = println "Sorry, access to this link seems forbidden, status=" .status }}
{{ $is_broken_link = true }}
{{ else }}
{{ $message = println "Sorry, this link seems broken with status=" .status }}
{{ $is_broken_link = true }}
{{ end }} {{/* if status */}}
{{ end }} {{/* if url */}}
{{ end }} {{/* range */}}
{{ end }} {{/* if $url.IsAbs */}}
<a {{ if $is_broken_link }} class="brokenlink" title="{{ $message }}" {{end}} href="{{ $link }}" {{ with .Title}}
  title="{{ . }}" {{ end }}>{{ .Text | safeHTML }}{{ if strings.HasPrefix .Destination "http" }}<span
    style="white-space: nowrap;"> <svg style="margin-bottom: 5px" focusable="false"
      class="icon icon-tabler icon-tabler-external-link" role="img" xmlns="http://www.w3.org/2000/svg" width="14"
      height="14" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
      stroke-linejoin="round">
      <title>external link</title>
      <path stroke="none" d="M0 0h24v24H0z" fill="none" />
      <path d="M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6" />
      <path d="M11 13l9 -9" />
      <path d="M15 4h5v5" />
    </svg></span>{{ end }}</a>
Repair links
To repair links, I need to know which file they are in. There is a private overview that can only be accessed on localhost and is excluded from the production build:
http://localhost:1313/internal/link-check/
Exclude the page from the production build:
- create directory 
/content/internal/. - create a file 
_index.mdwith the following content 
---
title: Internal
cascade:
- _target:
environment: production
build:
list: never
render: never
---
The file link-check.md with the following content is now added to the directory:
---
title: "Link Check"
description: ""
summary: ""
date: 2024-01-18T16:53:00+00:00
draft: false
weight: 60
---
## Summary
| External | Internal |
|---|---|
| {{</* broken-links state="INFO" mode="extern" >}} | {{< broken-links state="INFO" mode="intern" */>}} |
## Broken External
{{</* broken-links state="BROKEN" mode="extern" */>}}
## Skipped External
{{</* broken-links state="SKIPPED" mode="extern" */>}}
## Broken Internal
{{</* broken-links state="BROKEN" mode="intern" */>}}
## Skipped Internal
{{</* broken-links state="SKIPPED" mode="intern" */>}}
## OKAY Internal
{{</* broken-links state="OK" mode="intern" */>}}
The shortcode broken-links.html for this is stored in the directory layouts/shortcodes:
{{- $my_state := "" -}} {{/* INFO, BROKEN, SKIPPED, OK */}}
{{- $my_mode := "" -}} {{/* intern, extern, all */}}
{{ $list := slice -}}
{{/* Defekte Links, siehe auch: link-check.md, broken-links.html, render-link.html, links-check.ts */}}
{{- if .IsNamedParams -}}
{{- $my_state = .Get "state" | default "BROKEN" -}}
{{- $my_mode = .Get "mode" | default "intern" -}} {{/* intern extern all*/}}
{{- else -}}
{{ errorf "Shortcode zitate.html: No Named Parameters provided!" }}
{{- end -}}
{{ $data := slice }}
{{ if eq $my_mode "intern"}}
{{ $data = site.Data.links_checked.internal }}
{{ else if eq $my_mode "extern"}}
{{ $data = site.Data.links_checked.external }}
{{ else }} {{/* all */}}
{{ $data = site.Data.links_checked }}
{{ end }}
{{/* TODO: .Destination | safeURL */}}
{{/* https://discourse.gohugo.io/t/iterate-through-an-array-of-nested-maps-json-objects/15028 */}}
{{ if eq $my_state "INFO"}} {{/* INFO or LINKS */}}
{{/* OUPTUT INFO */}}
<ul>
  {{ range $data }} {{/* Only one */}}
  <li>scanned: {{ .scan_source }}</li>
  <li>lastrun: {{ .lastrun }}</li>
  <li>runtime: {{ math.Round .runtime }} {{ .runtime_unit }} ({{ .runtime }})</li>
  <li>finished: {{ .finished }}</li>
  <li>found: {{ .found }}</li>
  <li>dropped: {{ .dropped }}</li>
  <li>total: {{ .total }}</li>
  <li>ok: {{ .ok }}</li>
  <li>broken: {{ .broken }}</li>
  <li>skipped: {{ .skipped }}</li>
  {{ end }}
</ul>
{{ else }} {{/* INFO OR LINKS */}}
{{/* OUPTUT LINKS */}}
{{/* Alle Links sammeln die zu einem parent gehören */}}
{{ $groups := slice }}
{{ range $data }}
{{ range .links_broken }}
{{ $groups = $groups | append .parent }}
{{ end }}
{{ end }}
{{ $groups = $groups | uniq | sort }}
<ul>
  {{ range $groups }}
  {{ $u := urls.Parse . }}
  <li><a href="{{.}}">{{ $u.Path }}</a>
    <ul>
      {{ $d := index $data 0 }} {{/* Only one */}}
      {{ $source := $d.links_broken }}
      {{/* TODO immer andere Quelle: $d.links_broken ja nach $my_state "BROKEN" */}}
      {{ if eq $my_state "BROKEN"}}
      {{ $source = $d.links_broken }}
      {{ else if eq $my_state "SKIPPED"}}
      {{ $source = $d.links_skipped }}
      {{ else if eq $my_state "OK"}}
      {{ $source = $d.links_ok }}
      {{ end }}
      {{ range where $source "parent" . }}
      {{/* shorten links for display: hallo-welt.de/../slug-ende/ */}}
      {{/* https://gohugo.io/functions/urls/parse/ */}}
      {{ if eq $my_state .state }}
      {{ $u := urls.Parse .url }}
      {{ $path := ""}}
      {{ if ne $u.Path "/"}}
      {{ $path = $u.Path }}
      {{else}}
      {{ $path = $u.Path }}
      {{ end }}
      <li>
        <a href="{{.url}}">
          {{ if eq .state "BROKEN" }}
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
            stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
            <path d="M9 15l3 -3m2 -2l1 -1"></path>
            <path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464"></path>
            <path d="M3 3l18 18"></path>
            <path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"></path>
          </svg>
          {{ else if eq .state "OK"}}
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
            stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
            <path d="M9 15l6 -6"></path>
            <path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464"></path>
            <path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"></path>
          </svg>
          {{ else if eq .state "SKIPPED"}}
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
            stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
            <path d="M9 15l6 -6"></path>
            <path d="M11 6l.463 -.536a5 5 0 1 1 7.071 7.072l-.534 .464"></path>
            <path d="M12.603 18.534a5.07 5.07 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"></path>
            <path d="M16 19h6"></path>
          </svg>
          {{ end }}
        </a>├ {{ $u.Hostname }} ┼ {{ $path }}
      </li>
      {{ end }} {{/* eq my_State .state */}}
      {{ end }} {{/* range where $d.links_broken "parent" . */}}
    </ul>
  </li>
  {{ end }}
</ul>
{{end }} {{/* INFO ORE LINKS */}}
That is all. Good luck with fixing your links.
