Tags & Search

Two separate systems

The site has two independent search/filter mechanisms that coexist on the /tags/ page:

SystemWhereWhat it does
Tag filter/tags/Client-side JS filter over a Hugo-generated array
Pagefind search/posts/ and /tags/Full-text search over a pre-built index

They don’t interact — typing in the search box uses Pagefind, clicking a tag button uses the JS filter.


Tag filter

How tags become pages

Hugo processes tags: from frontmatter and auto-creates taxonomy pages:

tags: ["Linux", "LPIC-2", "Networking"]
/tags/linux/        ← lists all posts tagged "Linux"
/tags/lpic-2/       ← lists all posts tagged "LPIC-2"
/tags/networking/

The tag value is urlized: "LPIC-2"lpic-2, "Networking"networking. This urlized value is what’s stored in data-tag attributes and the JS POSTS array.

Template: taxonomy/tag.html

This single template renders all tag pages — both the tag index (/tags/) and individual tag pages.

Tag buttons (rendered by Hugo at build time)

<!-- "All" button — data-tag="" means show everything -->
<button class="tag tag-lg tag-filter active" data-tag="">
  All <span class="count">{{ len .Site.RegularPages }}</span>
</button>

<!-- One button per tag, sorted by post count (most → least) -->
{{ range .Site.Taxonomies.tags.ByCount }}
<button class="tag tag-lg tag-filter" data-tag="{{ .Page.Title | urlize }}">
  {{ .Page.Title }} <span class="count">{{ .Count }}</span>
</button>
{{ end }}

.Site.Taxonomies.tags.ByCount — Hugo’s built-in: returns all tags sorted by number of posts, descending.

POSTS array (embedded by Hugo at build time)

The entire post list is serialized into a JS array during the Hugo build:

const POSTS = [
  {
    url:       "https://maks.top/posts/lpic2-200-1.../",
    title:     "LPIC-2 200.1 — Capacity Planning",
    date:      "2026-04-10",
    tags:      ["linux", "lpic-2", "monitoring"],    // urlized — for filtering
    tagLabels: ["Linux", "LPIC-2", "Monitoring"],    // original — for display
    summary:   "CPU, memory, disk I/O monitoring..."
  },
  // ... all posts
];

Two separate tag arrays per post:

  • tags — urlized values used for === comparison in the filter
  • tagLabels — original display values shown in the card

Russian posts are excluded — the template skips pages where page_lang === "ru":

{{ range .Site.RegularPages }}{{ if ne .Params.page_lang "ru" }}

Client-side filter logic

Filter logic lives in static/js/taxonomy.js (external cacheable file). The inline <script> in tag.html only defines the data:

// Inline in tag.html (Hugo-generated):
const POSTS = [ ... ];         // full post list, EN only
const currentTag = "linux";   // "" if on /tags/ root, urlized slug otherwise

Then the template loads:

<script src="/js/taxonomy.js"></script>

taxonomy.js reads those two variables and handles all UI — tag grid, article grid, active pill, filter logic. See JavaScript for function details.

Filter flow:

  1. Page loads → renderArticles() runs with activeTag = currentTag (pre-selects tag if arriving via /tags/linux/)
  2. User clicks a tag button → activeTag updated → renderArticles() rerenders #tagArticles
  3. No page reload — POSTS array is already in memory

Note: only the first 2 tags per post are shown in the card (tagLabels.slice(0, 2)).


What Pagefind is

Pagefind is a static search library that:

  1. Crawls the built public/ HTML files after hugo --minify
  2. Builds a binary search index into public/pagefind/
  3. Provides a JS API (pagefind.js) that queries the index client-side

No server needed — the index is served as static files alongside the site.

Build step

# In CI (deploy.yml):
pagefind --site public

# Locally (via dev.sh):
hugo && npx pagefind --site public && hugo server --disableFastRender

This creates:

public/
  pagefind/
    pagefind.js        ← client API
    pagefind-*.pclf    ← binary index shards
    pagefind.en.pclf   ← language-specific index

Pagefind is not available during hugo server -D (without first running hugo). The search input will silently show no results if the index doesn’t exist.

Where Pagefind search appears

PageTemplateNotes
/posts/posts/list.htmlFull-text search only
/tags/taxonomy/tag.htmlFull-text search + tag filter (independent)

Lazy loading

Pagefind is not loaded when the page opens — it loads on the first keypress:

let pf = null;

async function loadPagefind() {
  if (pf) return;   // already loaded — do nothing
  try {
    pf = await import('/pagefind/pagefind.js');
    await pf.init();
  } catch(e) {
    console.warn('Pagefind not ready');
  }
}

searchInput.addEventListener('input', async function() {
  const q = this.value.trim();
  if (!q) { searchResults.style.display = 'none'; return; }
  await loadPagefind();   // loads on first keystroke only
  ...
});

This saves bandwidth — users who don’t search don’t download the index.

Search flow

User types "nginx"
        
        
loadPagefind()         dynamic import('/pagefind/pagefind.js') if not loaded
        
        
pf.search("nginx")     returns array of result objects (lazy, no data yet)
        
        
Promise.all(results.slice(0, 8).map(r => r.data()))
                       fetches actual data for top 8 results (async, parallel)
        
        
Render into #searchResults
  - item.meta.title    page title
  - item.url           page URL
  - item.excerpt       context snippet with match highlighted

.data() is lazy — Pagefind doesn’t fetch full result data until you call it. slice(0, 8) limits to 8 results before fetching, saving bandwidth.

Result dropdown

The results container is created dynamically, not in the HTML template:

const searchResults = document.createElement('div');
searchResults.id = 'searchResults';
searchResults.style.cssText = 'position:absolute;top:calc(100% + 8px);...';
document.querySelector('.search-wrap').appendChild(searchResults);

.search-wrap has position: relative — the dropdown positions itself relative to the input.

Closing the dropdown:

document.addEventListener('click', e => {
  if (!e.target.closest('.search-wrap')) searchResults.style.display = 'none';
});

Any click outside .search-wrap hides results.

Excluding pages from the index

To prevent a page from being indexed by Pagefind, add to frontmatter:

pagefind_ignore: true

The baseof.html template reads this and adds data-pagefind-ignore="all" to <body>:

<body{{ if .Params.pagefind_ignore }} data-pagefind-ignore="all"{{ end }}>

Data flow summary

Build time (Hugo):
  content/posts/*.md
    └── frontmatter.tags → Hugo taxonomy → /tags/{tag}/ pages
    └── all post data    → POSTS[] array embedded in tag.html JS

Build time (Pagefind):
  public/**/*.html → pagefind --site public → public/pagefind/ index

Runtime (browser):
  User clicks tag button
    → JS filter on POSTS[] → re-render #tagArticles (no network)

  User types in search box
    → dynamic import pagefind.js (first time only)
    → pf.search(query) → fetch top 8 results
    → render #searchResults dropdown