Experiment: Obsidian-style Wikilinks in Hugo (looking for feedback)

Hey everyone :waving_hand:,

I’ve been experimenting with adding Obsidian-style wikilinks ([[Page]], [[Page|Alias]], ![[Image]]) into Hugo.

The idea is:

  • Mask inline code and fenced code blocks so wikilinks there don’t get parsed.

  • Convert image wikilinks first, then normal wikilinks.

  • Map wikilinks to Hugo pages, aliases, or paths.

  • Render them as internal/external links (with fallback to a “broken link” style if unresolved).

Here are the core templates I’ve got so far:

:link: Wikilink dictionary & rendering logic

render-link.html

{{/* --- Utility: build dictionary for wikilink resolution --- */}}
{{- $wikilinkMap := dict -}}   {{/* Create an empty dictionary to store link lookups */}}
{{- range $p := union site.RegularPages site.Sections }}   {{/* Loop through all regular pages + section pages */}}
  {{- if $p.File }}   {{/* Only process if the page has an associated file */}}
    {{- $base := lower $p.File.BaseFileName }}   {{/* Get file base name, lowercase (e.g. "Note.md" -> "note") */}}
    {{- $path := lower (replace (replace $p.File.Path "\\" "/") ".md" "") }}   
    {{/* Normalize path: replace backslashes with slashes, remove .md, lowercase */}}

    {{/* Save aliases into the lookup dictionary */}}
    {{- $wikilinkMap = merge $wikilinkMap (dict $base $p) -}}   {{/* Allow lookup by base filename */}}
    {{- $wikilinkMap = merge $wikilinkMap (dict $path $p) -}}   {{/* Allow lookup by normalized path */}}
    {{- range $p.Aliases }}   {{/* If page has defined aliases, also store them */}}
      {{- $wikilinkMap = merge $wikilinkMap (dict (lower .) $p) -}}
    {{- end -}}
  {{- end }}
{{- end }}

{{/* --- Templates for different link rendering cases --- */}}
{{ define "render-link-internal" }}
  <a class="link link--internal" href="{{ .href | safeURL }}">{{ .text }}</a>
{{ end }}

{{ define "render-external-link" }}
  <a class="link link--external"
     href="{{ .href | safeURL }}"
     rel="external noopener noreferrer nofollow"   {{/* Security: prevent leaking referrer and opener */}}
     target="_blank"                               {{/* Open external links in new tab */}}
     {{- with .title }}title="{{ . }}"{{ end -}}>
    {{- with .text }}{{ . }}{{ end -}}
  </a>
{{ end }}

{{ define "render-broken" }}
  <span class="link link--broken" title="Link to {{ .text }} is missing">{{ .text }}</span>
{{ end }}

{{/* --- Main link resolution logic --- */}}
{{- $destination := .Destination | default "" -}}   {{/* Destination (href) provided by Markdown parser */}}
{{- $text := .Text | safeHTML | default "" -}}      {{/* Text inside the link */}}
{{- $title := .Title | default "" -}}               {{/* Optional title attribute */}}

{{- $isWikilink := eq $destination "wikilink" -}}   {{/* Check if this link is a custom "wikilink" type */}}
{{- $isExternal := or
  (strings.HasPrefix $destination "http://")
  (strings.HasPrefix $destination "https://")
  (strings.HasPrefix $destination "mailto:")
-}}   {{/* Detect if the link is external (http/https/mailto) */}}

{{ if $isWikilink }}
  {{ $rawTitle := cond (ne $title "") $title $text }}   {{/* Prefer title if provided, else use text */}}
  {{ $parts := split $rawTitle "#" }}                   {{/* Split on "#" to separate anchor */}}
  {{ $targetRaw := index $parts 0 }}                    {{/* First part = target page */}}
  {{ $anchor := cond (gt (len $parts) 1) (printf "#%s" (index $parts 1)) "" }}   
  {{/* If anchor exists, prepend "#" */}}

  {{/* Normalize target: lowercase and strip ".md" extension */}}
  {{ $target := lower (replace $targetRaw ".md" "") }}

  {{ $page := index $wikilinkMap $target }}   {{/* Try to find page from dictionary */}}

  {{ if $page }}
    {{/* If found, render as internal link */}}
    {{ template "render-link-internal" (dict "href" (print $page.RelPermalink $anchor) "text" $text) }}
  {{ else }}
    {{/* If not found, mark as broken */}}
    {{ template "render-broken" (dict "text" $text) }}
  {{ end }}
{{ else if $isExternal }}
  {{/* Render as external link */}}
  {{ template "render-external-link" (dict "href" $destination "text" $text "title" $title) }}
{{ else }}
  {{- $u := urls.Parse $destination -}}   {{/* Parse the URL for further checks */}}
  {{ if $u.IsAbs }}
    {{/* Absolute URL: treat as external */}}
    {{ template "render-external-link" (dict "href" $destination "text" $text "title" $title) }}
  {{ else }}
    {{/* Otherwise, treat as internal (relative link) */}}
    {{ template "render-link-internal" (dict "href" $destination "text" $text) }}
  {{ end }}
{{ end }}


:link: _partials/_wikilink-cache.html

{{- $regularPages := where site.RegularPages "Lang" site.Language.Lang -}}
{{- $sections := where site.Sections "Lang" site.Language.Lang -}}
{{- $allPages := union $regularPages $sections -}}

{{- $wikilinks := dict -}}
{{- range $allPages }}
  {{- if .File }}
    {{- $slug := .File.BaseFileName -}}
    {{- $wikilinks = merge $wikilinks (dict $slug .) -}}
    {{- $wikilinks = merge $wikilinks (dict .File.TranslationBaseName .) -}}
    {{- range .Aliases }}
      {{- $wikilinks = merge $wikilinks (dict . $) -}}
    {{- end }}
  {{- end }}
{{- end }}

{{- return $wikilinks -}}

:gear: Content preprocessing (mask → convert → restore)

{{- /* Initialize content and placeholder storage */ -}}
{{- $content := .RawContent -}}          {{/* Get the raw Markdown content */}}
{{- $placeholders := slice -}}           {{/* Store masked sections (inline/code blocks) */}}
{{- $i := 0 -}}                          {{/* Counter for unique placeholder IDs */}}

{{- /* ------------------------------------ */ -}}
{{- /* 1. Mask inline code containing [[...]] or ![[...]] */ -}}
{{- $inlineMatches := findRE "`!?\\[\\[[^\\[\\]]+\\]\\]`" $content -}}  
{{/* Regex: match inline code wrapped in backticks that contains wikilink or image wikilink */}}
{{- range $inlineMatches -}}
  {{- $placeholder := printf "WIKILINK_PLACEHOLDER_%d" $i -}}   {{/* Create placeholder name */}}
  {{- $placeholders = $placeholders | append (dict "original" . "placeholder" $placeholder) -}}  
  {{/* Save mapping: original text → placeholder */}}
  {{- $content = replace $content . $placeholder -}}            {{/* Replace inline code with placeholder */}}
  {{- $i = add $i 1 -}}                                         {{/* Increment placeholder index */}}
{{- end -}}

{{- /* ------------------------------------ */ -}}
{{- /* 2. Mask entire code blocks containing [[...]] or ![[...]] */ -}}
{{- $codeBlockMatches := findRE "(?s)```.*?```" $content -}}    {{/* Regex: match fenced code blocks (triple backticks, multiline) */}}
{{- range $codeBlockMatches -}}
  {{- if or (findRE "\\[\\[.*?\\]\\]" .) (findRE "!\\[\\[.*?\\]\\]" .) -}}  
  {{/* Only replace if wikilink syntax is found inside the code block */}}
    {{- $placeholder := printf "WIKILINK_PLACEHOLDER_%d" $i -}}
    {{- $placeholders = $placeholders | append (dict "original" . "placeholder" $placeholder) -}}
    {{- $content = replace $content . $placeholder -}}   {{/* Mask the code block */}}
    {{- $i = add $i 1 -}}
  {{- end -}}
{{- end -}}

{{- /* ------------------------------------ */ -}}
{{- /* 3. Convert Image Wikilinks first */ -}}
{{- $content = replaceRE `!\[\[([^\|\]]+)\|([^\|\]]+)\|([^\]]+)\]\]` `![$2|$3](imageWikiLink "$1")` $content -}}  
{{/* Pattern: ![[file|alt|title]] → ![alt|title](imageWikiLink "file") */}}
{{- $content = replaceRE `!\[\[([^\|\]]+)\|([^\]]+)\]\]`         `![$2](imageWikiLink "$1")` $content -}}  
{{/* Pattern: ![[file|alt]] → ![alt](imageWikiLink "file") */}}
{{- $content = replaceRE `!\[\[([^\]]+)\]\]`                     `![$1](imageWikiLink "$1")` $content -}}  
{{/* Pattern: ![[file]] → ![file](imageWikiLink "file") */}}

{{- /* 4. Convert Regular Wikilinks to Markdown Links */ -}}
{{- $content = replaceRE `\[\[(https?://[^\|\]]+)\|([^\]]+)\]\]` `[$2]($1)` $content -}}  
{{/* [[https://url|text]] → [text](https://url) */}}
{{- $content = replaceRE `\[\[(https?://[^\]]+)\]\]`             `[$1]($1)` $content -}}  
{{/* [[https://url]] → [url](https://url) */}}
{{- $content = replaceRE `\[\[([^\|\]]+)\|([^\]]+)\]\]`          `[$2](wikilink "$1")` $content -}}  
{{/* [[page|text]] → [text](wikilink "page") */}}
{{- $content = replaceRE `\[\[([^\]]+)\]\]`                      `[$1](wikilink "$1")` $content -}}  
{{/* [[page]] → [page](wikilink "page") */}}

{{- /* ------------------------------------ */ -}}
{{- /* 5. Restore all placeholders to original content */ -}}
{{- range $placeholders -}}
  {{- $content = replace $content .placeholder .original -}}
{{- end -}}

{{- /* 6. Render the final processed content */ -}}
{{- .RenderString $content -}}   {{/* Pass the modified content back into Hugo’s Markdown renderer */}}



Example input (Markdown):

[[Page One]]
[[Page Two|Custom Text]]
![[image.png|Alt Text]]
`inline code with [[notalink]]`

Output (HTML):

<a class="link link--internal" href="/page-one/">Page One</a>
<a class="link link--internal" href="/page-two/">Custom Text</a>
<img alt="Alt Text" src="/image.png">
<code>inline code with [[notalink]]</code>


:warning: Disclaimer:
This code was originally generated with ChatGPT (and then I tweaked it a bit). I’m sharing here mainly to get feedback, best practices, or improvements from the community. :folded_hands:


Would love to hear your thoughts — e.g.

  • Is this approach efficient enough?

  • Any edge cases I might be missing?

  • Better regex / Hugo idioms to simplify this?

Thanks in advance! :rocket:

I did something closely similar (parsing arbitrary content with regex) some 20 years ago. I read about my use case and my solution to that before I started coding. It was discouraged everywhere. I did not hear and coded it anyways. It was a bad idea. Lessons learned.

Been there, done that.

1 Like

What is the best step for you to take? If you say it’s a bad idea, well, maybe after I think about it, there’s some truth to that. It’s just that the factor of necessity forced it yesterday, hehe.