How to Check Broken Links in Hugo
Hugo hat so eine Link-Prüfen Funktion “aus Gründen” nicht eingebaut: Hugo rühmt sich der schnellste Webpage-Builder wer Welt zu sein und das Prüfen von kaputten Links würde diesen Prozess nicht wirklich beschleunigen. Ich hab auf meiner Webseite über 3000 externe und 4000 interne Links. Die kann ich unmöglich von Hand prüfen. Was also tun? Ich hab das Problem folgendermaßen gelöst:
Installieren
You must install the following in your Hugo project:
- node
- typescript](https://www.npmjs.com/package/typescript)
- tsx](https://www.npmjs.com/package/tsx)
- md-curcuma](https://www.npmjs.com/package/md-curcuma)
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"
}
}
Das Skript bietet ne Abkürzung zum laufen lassen: npm run ts:links
Das Broken Links Checker Skript
- Wird so abgelegt:
my-transport-scripts/links-check.ts
. - Pfad und Dateiname müssen natürlich ggfs. angepasst werden.
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);
Das Script scannt alle internen und externen Links der Webseite, und legt dabei zwei Dateien im data
-Verzeichnis an, die Hugo dann auf verschieden Arten auswertet:
- external.json
- internal.json
So schaut es darin aus:
[
{
"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/Binary_Voids",
"state": "BROKEN",
"status": 400,
"scantime": "2025-05-15 13:53:611",
"parent": "http://192.168.178.91:81/projects/binary-voids/"
}],
"links_skipped": [
{
"url": "who: Carsten Nichte - The project guide What: Photographer, Author/Publisher, Webworker Where: Earth%E2%80%89 external link / Europe%E2%80%89 external link / Germany%E2%80%89 external link / NRW%E2%80%89 external link / Cologne%E2%80%89 external link / Bergisch Gladbach%E2%80%89 external link Welcome to my virtual identitiy. Here you find all most of my stuff%E2%80%A6 :-)",
"state": "SKIPPED",
"status": 0,
"scantime": "2025-05-15 13:55:014",
"parent": "http://192.168.178.91:81/linktree/"
}]
}
]
Eine Statistik ausgeben
Um eine Statistik aus zu geben gibt es natürlich einen Shortcode. So schaut das dann zB. aus .
{{/* 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 }}
Links kennzeichnen
Um dem Besucher der Webseite die Navigation angenehmer zu machen, möchte ich die Links entsprechend kennzeichnen:
- Externe Links mit einem Icon
- Broken-Links durchgestrichen
Das macht der Shortcode render-link.html
der im Verzeichnis layouts/_default/_markup/
abgelegt wird.
{{ $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>
Links reparieren
Um Links zu reparieren, muss ich wissen, in welcher Datei sie stecken. Dazu gibt es eine private Übersicht, die nur auf Localhost aufrufbar, und vom Production-Build ausgeschlossen ist:

http://localhost:1313/internal/link-check/
Ausschließen der Seite vom Production Build:
- Verzeichnis
/content/internal/
anlegen. - Darin eine Datei
_index.md
mit dem folgenden Inhalt
---
title: Internal
cascade:
- _target:
environment: production
build:
list: never
render: never
---
In das Verzeichns kommt jetzt die Datei link-check.md
mit folgendem Inhalt:
---
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" >}}
Der Shortcode broken-links.html
dazu wird im Verzeichis layouts/shortcodes
abgelegt:
{{- $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 */}}
Das ist alles. Viel Erfolg beim Reparieren deiner Links.