Home Blog Certs Knowledge Base About

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

let activeTag = '';

function renderArticles() {
  const filtered = activeTag
    ? POSTS.filter(p => p.tags.includes(activeTag))
    : POSTS;

  document.getElementById('tagArticles').innerHTML = filtered.map(p => `
    <a href="${p.url}" class="post-card">
      <div class="post-card-meta">
        <span class="post-date">${p.date}</span>
        ${p.tagLabels.slice(0, 2).map(l => `<span class="tag">${l}</span>`).join('')}
      </div>
      <div class="post-card-title">${p.title}</div>
      ${p.summary ? `<div class="post-card-desc">${p.summary}</div>` : ''}
    </a>
  `).join('');
}

// Button click handler
document.querySelectorAll('.tag-filter').forEach(btn => {
  btn.addEventListener('click', () => {
    document.querySelectorAll('.tag-filter').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    activeTag = btn.dataset.tag;   // "" for "All", urlized tag for others
    renderArticles();
  });
});

// Initial render on page load (shows all posts)
renderArticles();

Filter flow:

  1. Page loads โ†’ renderArticles() runs with activeTag = '' โ†’ all posts shown
  2. User clicks “Linux” button โ†’ activeTag = 'linux'
  3. renderArticles() filters POSTS where p.tags.includes('linux')
  4. #tagArticles innerHTML replaced with filtered results โ€” no page reload

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