Site Navigation

Technical reference for the search, mindmap, and word cloud navigation on this site. The design approach is in Thinking: Site Navigation — Beyond the Menu.


Architecture overview

Three navigation modes, one data source:

Hugo build
  └── list.json.json template
        └── /public-notes/index.json   ← built once
              ├── search.js (Fuse.js)
              ├── mindmap.js (D3.js)
              └── wordcloud.js (wordcloud2.js)

All three run entirely client-side. No server, no external search service. Hugo generates a JSON index at build time; JavaScript fetches it and does the rest.


The JSON index

Hugo outputs a JSON file for the public-notes section via a custom output format. The _index.md for the section declares both HTML and JSON outputs:

outputs:
  - HTML
  - JSON

The template at layouts/public-notes/list.json.json iterates all published pages recursively:

{{- $pages := where .RegularPagesRecursive "Draft" false -}}
[
{{- range $i, $page := $pages -}}
{{- if $i }},{{ end }}
{"title":{{ $page.Title | jsonify }},
 "url":{{ $page.RelPermalink | jsonify }},
 "section":{{ $page.Section | jsonify }},
 "subsection":{{ index (split $page.RelPermalink "/") 2 | jsonify }},
 "tags":{{ $page.Params.tags | default slice | jsonify }},
 "summary":{{ $page.Summary | plainify | truncate 160 | jsonify }}}
{{- end }}
]

Available at /public-notes/index.json after build. Each entry:

FieldContent
titlePage title
urlRelative URL
sectionTop-level section (public-notes)
subsectionSecond path segment (e.g. infra-as-code)
tagsArray from front matter, empty array if none
summaryAuto-generated from first ~160 chars of content

Search — Fuse.js

Fuse.js is a lightweight fuzzy-search library. It runs entirely in the browser against the JSON index — no server, no indexing step at build time beyond what Hugo already produces.

Initialisation:

fetch('/public-notes/index.json')
  .then(r => r.json())
  .then(data => {
    const fuse = new Fuse(data, {
      keys: [
        { name: 'title',      weight: 3 },
        { name: 'tags',       weight: 2 },
        { name: 'subsection', weight: 1.5 },
        { name: 'summary',    weight: 1 }
      ],
      threshold: 0.35,   // 0 = exact, 1 = match anything
      includeScore: true
    });
  });

Title matches rank highest, followed by tags, then section, then summary text. threshold: 0.35 allows minor typos and partial matches without returning garbage.

Trade-offs vs alternatives:

  • Fuse.js — zero build-time overhead, smallest setup, good enough for ~100 notes. Gets slower with very large corpora.
  • Pagefind — generates its own index at build time, better at full-text search, needs a build step integration. Worth switching to if the notes grow large enough that Fuse becomes slow.
  • Lunr.js — similar to Fuse, slightly more powerful query syntax, heavier.

Implemented as a Hugo shortcode (layouts/shortcodes/search-notes.html) — the CDN script, input, results container, and JS logic are all inline, same pattern as the carousel shortcode.


Mindmap — D3.js force simulation

D3.js force simulation lays out notes as nodes connected by edges. Nodes are coloured by subsection. Edges connect notes that share tags (strong) or belong to the same subsection (light).

Data model:

// nodes: one per page
const nodes = data.map(p => ({
  id: p.url, title: p.title,
  section: p.subsection, tags: p.tags
}));

// edges: shared tags (strength 0.8) or same subsection (strength 0.15)
const links = [];
for (let i = 0; i < nodes.length; i++) {
  for (let j = i+1; j < nodes.length; j++) {
    const sharedTags = nodes[i].tags.filter(t => nodes[j].tags.includes(t));
    if (sharedTags.length) {
      links.push({ source: nodes[i].id, target: nodes[j].id, strength: 0.8 });
    } else if (nodes[i].section === nodes[j].section) {
      links.push({ source: nodes[i].id, target: nodes[j].id, strength: 0.15 });
    }
  }
}

Forces:

d3.forceSimulation(nodes)
  .force('link',      d3.forceLink(links).id(d => d.id)
                        .strength(l => l.strength).distance(80))
  .force('charge',    d3.forceManyBody().strength(-120))
  .force('center',    d3.forceCenter(W/2, H/2))
  .force('collision', d3.forceCollide().radius(18))

Interaction: click a node to highlight its direct neighbours and hide unrelated edges. Click the background to reset. Drag nodes to rearrange. Scroll to zoom (zoom is applied to a <g> transform via d3.zoom).

Current limitation: most notes do not have tags in their front matter, so edges are mostly section-based. The graph becomes more useful as tags are added. Adding tags: to front matter of any note immediately creates cross-section connections in the mindmap.


Word cloud — wordcloud2.js

wordcloud2.js renders a canvas-based word cloud. Words are sections and tags from the index, sized by frequency.

Frequency counting:

const counts = {}, sectionOf = {};
data.forEach(page => {
  const sub = page.subsection || page.section;
  counts[sub] = (counts[sub] || 0) + 3;  // sections weight more
  sectionOf[sub] = sub;
  page.tags.forEach(tag => {
    counts[tag] = (counts[tag] || 0) + 1;
    if (!sectionOf[tag]) sectionOf[tag] = sub;
  });
});

Sections are weighted 3× per page so they appear prominently even with sparse tags. Tags accumulate naturally as notes get tagged.

Click behaviour: clicking a word feeds it into the search input on the search page (if present on the same page) or navigates to /public-notes/search/.


Section colour map

Both mindmap and word cloud use the same colour map:

SectionColour
infra-as-code#4CAF50 green
cloud-infrastructure#2196F3 blue
cicd#FF9800 amber
languages#9C27B0 purple
networking#00BCD4 cyan
security#F44336 red
observability#FF5722 deep orange
frameworks-tools#795548 brown
dev-environment#607D8B blue-grey
docs-as-code#E91E63 pink
ai#673AB7 deep purple
hardware#8BC34A light green

When a new section is added, add it to the colour map in both shortcodes (mindmap-notes.html and wordcloud-notes.html).


Adding tags to notes

Tags are the key input that makes cross-section connections appear in the mindmap. Add them to any note’s front matter:

---
title: "Terraform"
tags: ["iac", "terraform", "hcl", "state-management"]
---

Notes that share tags will be connected across section boundaries in the mindmap. The word cloud will show the tags sized by how many notes carry them.

No rebuild of the shortcode or template is needed — the JSON template picks up tags automatically at build time.

Built with Hugo
Theme Stack designed by Jimmy