Den døde lenken oppdager du aldri selv. Du klikket på den tre ganger mens du skrev, den virket da, og nå ligger den der og venter på den første leseren som bryr seg nok til å prøve.

Dette er ikke et KI-problem. Det er et «hadde noen bare kjørt en liten kontroll»-problem. Og kontrollen er kjedeligere enn du tror: et skript som sjekker det som kan sjekkes, og som avslutter med feilkode når noe ikke stemmer. Resten er menneskeøyne, og dem bør du spare til det maskinen ikke kan avgjøre.

Først: hva kan egentlig testes? #

Skillet avgjør hele oppsettet. «Deterministisk» betyr at samme input alltid gir samme svar, uten at noen må mene noe.

Det en maskin kan avgjøre sikkert:

  • Finnes alle påkrevde metadatafelt, og har de gyldig form? 30. februar er ikke en dato.
  • Er kategorien en av dem som faktisk finnes?
  • Finnes bildefilene det refereres til?
  • Peker de interne lenkene til en side som eksisterer?
  • Motsier statusen seg selv? Merket som utkast, men med publiseringsdato i fortiden.

Det bare du kan avgjøre:

  • Er tittelen sann, og holder teksten det ingressen lover?
  • Beskriver alt-teksten det bildet faktisk viser?
  • Går lenken til riktig side? En adresse som svarer med «200 OK» kan fortsatt være feil side.
  • Er artikkelen nyttig, forståelig og riktig?

Eksterne lenker havner i gråsonen. Teknisk kan de sjekkes automatisk, men nettverket er ikke deterministisk: en server som har en dårlig dag gir deg falsk alarm. Derfor får de et eget steg lenger ned.

Et skript på rundt femti linjer #

Forutsetninger: Python 3 (bare standardbiblioteket), innhold som Markdown eller MDX med frontmatter øverst, og bilder under public/. Stier, påkrevde felt og kategorier ligger øverst i filen, så tilpass dem til ditt prosjekt.

python
#!/usr/bin/env python3
"""preflight.py: stopper publisering ved feil. Kun standardbiblioteket."""
import datetime
import re
import sys
from pathlib import Path

CONTENT = Path("src/content/blog")
PUBLIC = Path("public")
KATEGORIER = {"automatisering", "ai-i-praksis", "bygg-og-drift", "verktoy", "publisering"}
KREVD = ("title", "description", "pubDate", "category", "heroImage", "heroAlt")


def les(fil):
    tekst = fil.read_text(encoding="utf-8")
    m = re.match(r"---\n(.*?)\n---\n", tekst, re.S)
    if not m:
        return None, ""
    felt = {}
    for linje in m.group(1).splitlines():
        nokkel, _, verdi = linje.partition(":")
        felt[nokkel.strip()] = verdi.strip().strip('"')
    kropp = re.sub(r"```.*?```", "", tekst[m.end():], flags=re.S)  # ignorer kodeblokker
    return felt, kropp


def sjekk(fil, idag):
    feil = []
    fm, kropp = les(fil)
    if fm is None:
        return [f"{fil.name}: mangler frontmatter"]
    feil += [f"{fil.name}: mangler {k}" for k in KREVD if not fm.get(k)]
    if fm.get("category") and fm["category"] not in KATEGORIER:
        feil.append(f"{fil.name}: ukjent kategori '{fm['category']}'")
    try:
        dato = datetime.date.fromisoformat(fm.get("pubDate", ""))
        if fm.get("draft") == "true" and dato <= idag:
            feil.append(f"{fil.name}: draft: true, men pubDate {dato} er passert")
    except ValueError:
        feil.append(f"{fil.name}: pubDate er ikke en gyldig dato (YYYY-MM-DD)")
    bilder = [fm["heroImage"]] if fm.get("heroImage") else []
    bilder += re.findall(r"!\[[^\]]*\]\(([^)\s]+)\)", kropp)
    for ref in bilder:
        if ref.startswith(("http://", "https://")):
            continue
        mal = PUBLIC / ref.lstrip("/") if ref.startswith("/") else fil.parent / ref
        if not mal.is_file():
            feil.append(f"{fil.name}: finner ikke bildet {ref}")
    for slug in re.findall(r"(?<!!)\[[^\]]*\]\(/blog/([^)#\s]+?)/?\)", kropp):
        if not any((CONTENT / f"{slug}{ext}").is_file() for ext in (".md", ".mdx")):
            feil.append(f"{fil.name}: intern lenke til /blog/{slug}/ har ingen fil")
    return feil


if __name__ == "__main__":
    idag = datetime.date.today()
    filer = sorted(CONTENT.glob("*.md*"))
    alle = [f for fil in filer for f in sjekk(fil, idag)]
    print("\n".join(alle) or f"OK: {len(filer)} filer sjekket")
    sys.exit(1 if alle else 0)

Begrensninger, helt åpent: parseren leser bare enkle nøkkel: verdi-linjer på én linje. Lister og nøstede felt i frontmatter forstår den ikke, og da bør du over på et ordentlig YAML-bibliotek. Den sjekker heller ikke at en lenke til en intern side har riktig innhold, bare at filen finnes.

Jeg kjørte skriptet mot en liten testmappe med én ren fil, én full av feil og ett utkast som lekker. Mappen med feil ga dette, og avsluttet med feilkode 1:

text
dritt.mdx: ukjent kategori 'nyheter'
dritt.mdx: pubDate er ikke en gyldig dato (YYYY-MM-DD)
dritt.mdx: finner ikke bildet /images/2026-10/mangler.webp
dritt.mdx: intern lenke til /blog/finnes-ikke/ har ingen fil
utkast.mdx: mangler heroAlt
utkast.mdx: draft: true, men pubDate 2026-09-01 er passert

Den rene mappen ga OK: 1 filer sjekket og feilkode 0. Skriptet er ikke testet mot din prosjektstruktur. Kjør det først, tilpass forutsetningene, og fremprovoser en feil med vilje. En sjekk du aldri har sett feile, vet du ikke om virker.

Eksterne lenker: eget steg, egen toleranse #

For eksterne lenker finnes ferdige verktøy. lychee er en lenkesjekker skrevet i Rust som finner døde lenker i blant annet Markdown og HTML. Ifølge prosjektets dokumentasjon avslutter den med kode 2 når minst én lenke som ikke er ekskludert har feilet. Alt du ikke vil sjekke, kan du ekskludere med --exclude (regulære uttrykk) eller en .lycheeignore-fil.

Min vurdering, ikke en regel: start med å la lenkesjekken være en advarsel som ikke blokkerer, og skjerp den først når du har sett hvor ofte den roper ulv. En utilgjengelig server klokken tre en tirsdag er ikke grunn til å utsette en artikkel.

Slik stopper du faktisk publiseringen #

En sjekk som skriver feil til en terminal, stopper ingenting. Det som stopper noe, er at kjeden rundt sjekken ser på avslutningskoden. Tre lag, fra billigst:

  1. Lokalt, foran bygget. Skriv python3 preflight.py && npm run build, så kjører ikke bygget hvis sjekken feiler. I et npm-prosjekt kan du i stedet legge sjekken som prebuild-skript i package.json.
  2. I CI. En jobb som bare kjører skriptet. Eksempel for GitHub Actions:
yaml
name: preflight
on: pull_request
jobs:
  preflight:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4   # bruk gjeldende hovedversjon
      - run: python3 preflight.py
  1. Som sperre. Gjør jobben til et påkrevd statussjekk-krav i grenbeskyttelsen for hovedgrenen. Da kan ikke en pull request flettes inn før sjekken er grønn.

To fallgruver i GitHubs dokumentasjon som er verdt å kjenne:

  • Påkrevde sjekker godtas også med status «skipped» eller «neutral». Hvis jobben din hoppes over, for eksempel på grunn av et filtervalg i arbeidsflyten, slipper flettingen gjennom uten at noe er testet.
  • Administratorer kan som standard gå utenom grenbeskyttelsen. Er du den eneste administratoren, kan du altså overstyre deg selv midt i en travel kveld. Det finnes en innstilling for å la reglene gjelde også for administratorer. Slå den på, eller velg bevisst å la den stå av.

Det som fortsatt er ditt #

Skriptet fjerner de kjedelige feilene, så menneskeøynene kan brukes der de faktisk gjør nytte. En rutine på to minutter før du trykker publiser:

  • Åpne forhåndsvisningen av det ferdige bygget, ikke redigeringsvisningen.
  • Klikk på hver lenke som bærer en påstand. Spørsmålet er ikke «svarer den?», men «sier siden det jeg påstår?».
  • Les alt-teksten mot bildet.
  • Les tittel og beskrivelse høyt, uten brødteksten. Lover de noe teksten ikke holder?
  • Se på dato og status: skal denne ut nå, eller senere?

Ikke bygg flere regler enn du trenger. Legg til en ny sjekk den dagen en feil faktisk slapp gjennom, ikke før. Da vokser kontrollen med virkeligheten, ikke med fantasien din.