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.md
with 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.