MacOS

The following script will update Hugo if you already have it installed via brew.
  1. Open your terminal.
  2. Copy and paste the following:
    brew install hugo
    site_name="milodocs_$(date +%s%N | md5sum | head -c 8)"
    hugo new site "$site_name"
    cd "$site_name"
    git clone https://github.com/lbliii/milodocs themes/milodocs
    echo 'theme = "milodocs"' >> hugo.toml
    git init
    git add .
    git commit -m "Initial commit"
    hugo server -D -p 1313
  3. Open localhost:1313.

The CSS used in the MiloDocs theme is powered by TailwindCSS.

Before You Start

Before making any changes, let’s deploy locally and activate style monitoring.

  1. Run pnpm start to deploy locally.
  2. Navigate into /themes/milo.
  3. Run pnpm watch-tw. This enables monitoring for CSS changes.

Modify Templates

You can change the TailwindCSS classes assigned within the Hugo templates by doing the following:

  1. Navigate into /themes/milo/layouts.
  2. Choose which kind of template you wish to update.
    • _default: high-level content type templates (baseof, home, list, section, single, glossary, tutorial)
    • partials: flexible components used in any default template (tiles, next/prev, etc)
    • shortcodes: markdown-file compatible components used inline with your documentation copy
  3. Make changes.
  4. Verify changes were successful.
  5. Save.

Modify Global Theme Extensions

You can modify the default extensions the Milo Docs theme has set for TailwindCSS. This is useful when you’d like to change the branded fonts or change Tailwind’s default font sizes.

Updating Fonts?

If you are updating the fonts, make sure that you:

  1. Install your font files at /static/fonts.
  2. Add them to your CSS at /themes/milo/assets/css/src/input.css.
  1. Navigate into /themes/milo/.
  2. Open the tailwind.config.js file.
  3. Update the theme.extends entries.

Modify Stylesheets

Hugo can reference CSS from multiple locations (e.g., static and assets). The MiloDocs theme keeps all CSS in the assets folder since we want to process the files in a variety of ways — such as concatenating many modular CSS files together into a bundled output that we can minify for production deployments.

fonts.css

The fonts.css file is where you can add/replace the font references to adhere to your brand aesthetic.

/* Rubik Variable Font */
@font-face {
  font-family: 'Rubik';
  src: url('/fonts/Rubik-VariableFont_wght.ttf') format('truetype');
  font-weight: 300 900; /* Range of weights available in the variable font */
  font-display: swap;
}

/* Rubik Italic Variable Font */
@font-face {
  font-family: 'Rubik';
  src: url('/fonts/Rubik-Italic-VariableFont_wght.ttf') format('truetype');
  font-style: italic;
  font-weight: 300 900; /* Range of weights available in the variable font */
  font-display: swap;
}

colors.css

The colors.css file is where you can replace the color references to adhere to your brand aesthetic.

:root {
    --primary-gradient-color: #ffffff; 
    --secondary-gradient-color: rgb(138, 193, 149, 0.500); 
    --icon-color: #000000; /* Black */

    /* HPE Brand Colors */

    --color-brand:#8ac195; /*primary color*/
    --color-brand-1:rgba(131, 122, 117, 0.667); /*secondary color*/
    --color-brand-2:#acc18a; /*tertiary color*/
    --color-brand-3:#86a6cf; /*note color*/
    --color-brand-4:#ADD9F4; /*tip color */
    --color-brand-5:#dbd985; /*security color */
    --color-brand-6:#d4ac84; /*warning color */
    --color-brand-7:#F3B3A6; /*danger color */
}

.dark {
    --primary-gradient-color: rgb(138, 193, 149, 0.300); /* Change this to your dark mode color */
    --secondary-gradient-color: #202020; /* Change this to your dark mode color */
    --icon-color: #ffffff; /* Change this to your dark mode color */
}

 #articleContent [id]:target {
    scroll-margin-top: 50px;
}

main.css

The main.css file is generated from the src/input.css file. All of the core styles that are brand agnostic live in these files.

Do not directly update the main.css file; instead, edit the src/input.css file while pnpm watch-tw is active.

This partial layout concatenates all of the modular CSS files found in themes/milo/assets/css and outputs a bundle.css file for deployment use. It also minifies and fingerprints the output.

Source Code


{{- $cssResources := slice (resources.Get "css/main.css") }}
{{- $cssResources = $cssResources | append (resources.Get "css/fonts.css") }}
{{- $cssResources = $cssResources | append (resources.Get "css/colors.css") }}



{{- if eq hugo.Environment "development" }}
  {{- $cssBundle := $cssResources | resources.Concat "css/bundle.css" }}
  <link rel="stylesheet" href="{{ $cssBundle.RelPermalink }}">
{{- else }}
  {{- $opts := dict "minify" true }}
  {{- $cssBundle := $cssResources | resources.Concat "css/bundle.css" | minify | fingerprint }}
  <link rel="stylesheet" href="{{ $cssBundle.RelPermalink }}" integrity="{{ $cssBundle.Data.Integrity }}" crossorigin="anonymous">
{{- end }}

Before You Start

  • This section assumes that you have experience with terminals, CLIs, and IDEs (e.g., VS Code)
  • You should ideally have a git tool (e.g., GitHub) to store and manage your site repo

Why use Hugo?

  • Affordable: Technical Writers usually have a razor-thin budget; you can deploy docs with Hugo + Netlify (or Render, Vercel) for free in most cases (Startup, Open Source)
  • Scalable: Hugo is the fastest SSG, supports localization, is un-opinionated in terms of style, and is easy to evolve alongside your product
  • Ergonomic: The drafting UX is markdown focused with near-instant local previews; for non-tehchnical contributors, you can plug into CMS interfaces (e.g., Frontmatter)
  • Agnostic: You’ll always own your docs, and transforming content into JSON or XML is as easy as defining an output template (great for search tools like Algolia!)

Why use This Theme?

  • No Manual Menus: All sections are auto-sorted based on either a weight value or a-z title order
  • Deep Section Nesting: Tech docs tend to need sub-sub-sub sections, y’know?
  • Discovery UX Components: Algolia Search & ChatGPT UIs are OOTB for easy hookup
  • Battle Tested Shortcodes: I’ve been deploying Hugo for tech docs for 5+ years; this is my personal collection of need-to-haves, made as agnostically as possible
  • TailwindCSS + VanillaJS: You’ll be able to modify this theme to your liking using the basics with minimal dependencies
  • Brandable: Colors and fonts have their own CSS files; the TailwindCSS extensions mapped to these styles use generic names like font-brand, font-semibold, and brand-color-1.

The head.html partial layout houses:

  • all of the metadata that needs to be generated for every article
  • a link to the bundled & minified CSS (@head/css.html)
  • a link to the bundled & minified JS (@head/js.html)

How it Works

  1. This partial is fed into the baseof.html default layout.
  2. Each individual page is passed through this template as context {{ partial "head.html" . }}
  3. All metadata is populated from page frontmatter.
  4. All assets are applied.

Source Code

<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta name="title" content="{{with .Parent}}{{.Title}}{{end}}: {{ .Title }}" />
<meta name="description" content="{{with .Description}}{{.}}{{else}}{{.Summary}}{{end}}" />
<meta name="keywords" content="{{ .Keywords }}" />
<meta name="author" content="{{ .Params.author }}" />
{{ if .IsHome }}{{ hugo.Generator }}{{ end }}

<title>{{ if .IsHome }}{{ site.Title }}{{ else }}{{ printf "%s | %s" .Title site.Title }}{{ end }}</title>
{{ partialCached "head/css.html" . }}
{{ partialCached "head/js.html" . }}

Getting from 0 to 1 takes ~5 minutes.

1. Install Hugo

brew install hugo

See Hugo Docs for more options:

2. Create a New Site

hugo new site <siteName>

3. Install This Project

  1. Open your <siteName> project directory.

  2. Navigate to the themes/ directory.

  3. Run the following command:

    gh repo clone lbliii/milodocs
submodule sandbox
You can install this theme as a submodule, however the default content files will not be removable. I may separate these in the future depending on what people would like.

4. Add Theme to Config

baseURL = 'https://example.org/'
languageCode = 'en-us'
title = 'My New Hugo Site'
theme = 'milodocs'

5. Init Repo

Time to start saving your progress!

  1. Run the following:
    git init
  2. Add a comment.
  3. Push your new site and theme to your remote git repo.

6. Deploy locally

  1. Navigate into the siteName repo.
  2. Run the following:
    hugo server
  3. Open localhost (typically localhost:1313).
yay! you did it!
You’ve done the hardest part: installing and deploying Hugo with a theme. See the next page to learn how to clear out my default content and start drafting.

This partial layout concatenates all of the modular VanillaJS files found in themes/milo/assets/js and outputs a bundle.js file for deployment use. It also minifies and fingerprints the output.

Source Code

{{- $jsResources := slice }}
{{- $jsResources = $jsResources | append (resources.Get "js/main.js") }}
{{- $jsResources = $jsResources | append (resources.Get "js/chat.js") }}
{{- $jsResources = $jsResources | append (resources.Get "js/darkmode.js") }}
{{- $jsResources = $jsResources | append (resources.Get "js/search.js") }}
{{- $jsResources = $jsResources | append (resources.Get "js/tiles.js") }}
{{- $jsResources = $jsResources | append (resources.Get "js/tabs.js") }}
{{- $jsResources = $jsResources | append (resources.Get "js/glossary.js") }}
{{- $jsResources = $jsResources | append (resources.Get "js/toc.js") }}
{{- $jsResources = $jsResources | append (resources.Get "js/sidebar-left.js") }}
{{- $jsResources = $jsResources | append (resources.Get "js/chatTocToggle.js") }}

{{- if eq hugo.Environment "development" }}
  {{- $jsBundle := $jsResources | resources.Concat "js/bundle.js" | js.Build }}
  <script src="{{ $jsBundle.RelPermalink }}"></script>
{{- else }}
  {{- $opts := dict "minify" true }}
  {{- $jsBundle := $jsResources | resources.Concat "js/bundle.js" | js.Build $opts | fingerprint }}
  <script src="{{ $jsBundle.RelPermalink }}" integrity="{{ $jsBundle.Data.Integrity }}" crossorigin="anonymous"></script>
{{- end }}

This theme uses a variety of shortcodes to enable authors of technical documentation.

By default, content stored in the theme’s content directory (themes/milo/content/) is served alongside content stored in the site’s content directory (content/). You’re going to want to clear that out once you’re ready to write your own docs.

Test Feature Config

The default documentation is great for testing out different feature configs, such as:

  • disabling next/prev
  • changing truncation values
  • updating the number of child articles to displayed in a tile.

Before You Start

  • You should have completed the install steps.

How to Clear out Default content

  1. Open your siteName repo.
  2. Navigate to themes/milo.
  3. Delete the content/ directory.

That’s it! Now start drafting in your top-level content/ directory.

feedback encouraged
I’m by no means a JS expert; if you feel these files can be optimized or re-written in any way, don’t hesitate to reach out on GitHub / submit a pull request.

Layouts in the _default folder define the major content types and outputs of your Hugo site.

Default Layouts

The following layouts are typically found in all Hugo sites and likely come with a fresh Hugo theme (hugo theme new themeName).

template description BundleType
baseof.html provides a global “shell” all other templates inherit from. branch
list.html renders taxonomy lists (e.g., articles with tags). branch
terms.html renders taxonomy terms (e.g., tags). branch
section.html renders markdown files in a directory (dir/_index.md). branch
home.html renders the / page of your site; overrides content/index.md if present. leaf
single.html renders single pages (e.g., articles). leaf

Added Layouts

The following layouts are added by the Milo Docs theme.

template description BundleType frontmatter
glossary.html renders markdown files as a stacked list in a directory (dir/_index.md). branch layout: glossary
tutorial.html renders markdown files as a wizard with steps (dir/_index.md). branch layout: tutorial
tutorialstep.html renders a child markdown file as a tutorial step (tutorial/step.md). leaf layout: tutStep

Hugo supports JSON outputs out of the box. But to utilize it, you should define an output template based on the data available to your page kinds and any frontmatter included in the markdown file.

This partial is used in the following default layouts:

Each of these outputs can be found by adding /index.json to the path of the home, section, or single page.

How it Works

By itself, this partial doesn’t do anything. When piped reference from a few of our default layouts, it acts as a standardized blueprint for how each article should look like in json. Updating this file will cascade to all outputs where this is referenced.

Source Code

{
    "id": {{- with .File }}{{- .UniqueID | jsonify }}{{- else}}""{{- end }},
    "title": "{{- if .Title}}{{- .Title }}{{else}}{{- humanize .File.TranslationBaseName -}}{{- end}}",
    "description":{{- with .Description}} {{. | jsonify}}{{- else }}"no description"{{- end}},
    "lastCommit": "{{ .GitInfo.AuthorDate }}",
    "version": "{{- if .Page.Params.version.isLatest}}latest{{else}}{{- .Page.Params.version.major }}.{{- .Page.Params.version.minor }}.{{- .Page.Params.version.patch }}{{- end}}",
    "section": {{- with .Section }}{{. | jsonify}} {{- else }}"no section"{{- end}},
    "parent": "{{with .Parent}}{{- .Title }}{{- else }}no parent{{end}}",
    "isPage": {{- .IsPage | jsonify }},
    "isSection": {{- .IsSection | jsonify }},
    "pageKind": {{- .Kind  | jsonify }},
    "bundleType": {{- .BundleType | jsonify }},
    "uri": "{{- .Permalink }}",
    "relURI": "{{- .RelPermalink }}",
    "body": {{ .Plain | jsonify }},
    {{- if .Page.Params.hidden}}"hidden": "{{- .Params.hidden}}",{{- end}}
    "tags": [{{- range $tindex, $tag := .Params.tags }}{{- if $tindex }}, {{- end }}"{{- $tag| htmlEscape -}}"{{- end }}]
}

For now, I’ve left the footer as a blank slate — do with it what you wish!

How it Works

This partial is referenced from the baseof.html default layout.

Source Code


<!-- put anything you want here; it's a blank slate -->

<script src="https://cdn.jsdelivr.net/npm/algoliasearch@latest/dist/algoliasearch-lite.umd.js" defer></script>

Quisque consequat consectetur quam at consequat. Integer efficitur quam id eros auctor, et malesuada justo congue. Fusce non feugiat tellus. Proin a eros in dolor bibendum malesuada ut et elit. Sed sollicitudin, ipsum et fermentum facilisis, urna nisl vehicula neque, in malesuada dolor orci et dolor. Etiam in libero ut turpis cursus tincidunt. Integer in libero non ante laoreet vestibulum in sed orci. Vivamus luctus bibendum velit, at egestas justo fermentum in.

name partial refs default refs
next-prev.html single.html, section.html
tiles.html home.html, section.html
chat.html sidebar-right.html baseof.html
toc.html sidebar-right.html baseof.html

Quisque consequat consectetur quam at consequat. Integer efficitur quam id eros auctor, et malesuada justo congue. Fusce non feugiat tellus. Proin a eros in dolor bibendum malesuada ut et elit. Sed sollicitudin, ipsum et fermentum facilisis, urna nisl vehicula neque, in malesuada dolor orci et dolor. Etiam in libero ut turpis cursus tincidunt. Integer in libero non ante laoreet vestibulum in sed orci. Vivamus luctus bibendum velit, at egestas justo fermentum in.

The baseof.html template is the centralization point that glues the site theme together. All other templates defined in the theme are embedded into this one at build – meaning that global logic and stylings defined here.

Source Code

<!DOCTYPE html>
<html lang="{{ or site.Language.LanguageCode site.Language.Lang }}" dir="{{ or site.Language.LanguageDirection `ltr` }}">
<head>
  {{ partial "head.html" . }}
</head>
<body class="bg-white">
  <header>
    {{ partial "header.html" . }}
  </header>
  {{partial "navigation/top.html"}}
  <main class="max-w-screen-xl 2xl:max-w-screen-2xl mx-auto flex">
    {{partial "navigation/sidebar-left.html" . }}
    <div id="pageContainer" class="w-full lg:w-3/5">
      {{- if .IsHome}}{{ block "home" . }}{{ end }}{{else}}{{ block "main" . }}{{ end }}{{- end}}
    </div>
    <div id="searchResultsContainer" class="hidden w-full lg:w-3/5 p-4">
      <!-- populated by JS -->
    </div>
    {{partial "navigation/sidebar-right.html" . }}
  </main>
  <footer>
    {{ partial "footer.html" . }}
  </footer>
</body>
</html>

This is an embedded section

tes test test.

Using an LLM to enhance the discoverability of your content is quickly becoming a baseline requirement for documentation. Thankfully, it’s not too hard to do thanks to Hugo’s output to JSON.

At a high level, you’ll need to provide some server-side code in Python or JS that routes user questions to chatGPT after being passed some embeddings (created from your docs JSON) for context.

How it Works

This partial sends an API request to a GCP cloud function you’ll need to set up that uses Flask (built in) to:

  1. Search a Pinecone vector database filled with embeddings created from your documentation.
  2. Perform a similarity search and return the 4 most relevant chunks.
  3. Forward those chunks to the OpenAI API via LangChain to perform RAG services.
  4. Return an answer based on the question and content provided.
have it your way
There are several ways to implement a RAG LLM UX — this is just the way that currently works for me. It seems like in the future people may shift from LangChain to the official Assistant API. Hopefully sharing this implementation helps you achieve yours!

Set Up

To use this feature, you’re going to need to:

  1. Set up a Vector DB (doesn’t have to be Pinecone, LangChain supports multiple options).
  2. Convert your site index.json into embeddings and save them to the DB.
  3. Deploy a cloud function that can accept and route questions.
python 3.12
The tiktoken requirement runs into issues on Python 3.12; for now, I recommend using 3.10 if deploying with a GCP function.

Create & Store Embeddings

import os
from dotenv import load_dotenv
import time

from langchain.document_loaders import JSONLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.vectorstores import Pinecone 
from langchain.embeddings.openai import OpenAIEmbeddings
import pinecone 


load_dotenv()
openai_key = os.environ.get('OPENAI_API_KEY')
pinecone_key = os.environ.get('PINECONE_API_KEY')
pinecone_environment = os.environ.get('PINECONE_ENVIRONMENT')
pinecone_index = os.environ.get('PINECONE_INDEX')

docs_index_path = "./docs.json" 
docs_index_schema = ".[]" # [{"body:..."}] -> .[].body; see JSONLoader docs for more info
embeddings = OpenAIEmbeddings(openai_api_key=openai_key)
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=0,)

def chunks(lst, n):
    """Yield successive n-sized chunks from lst."""
    for i in range(0, len(lst), n):
        yield lst[i:i + n]

def metadata_func(record: dict, metadata: dict) -> dict:
    metadata["title"] = record.get("title")
    metadata["relURI"] = record.get("relURI")
    return metadata

loader = JSONLoader(docs_index_path, jq_schema=docs_index_schema, metadata_func=metadata_func, content_key="body") 

data = loader.load()
texts = text_splitter.split_documents(data) 

pinecone.init(
    api_key=pinecone_key,
    environment=pinecone_environment,
)

if pinecone_index in pinecone.list_indexes():
    print(f'The {pinecone_index} index already exists! We need to replace it with a new one.')
    print("Erasing existing index...")
    pinecone.delete_index(pinecone_index) 

time.sleep(60)
print("Recreating index...")
# wait a minute for the index to be deleted
pinecone.create_index(pinecone_index, metric="cosine", dimension=1536, pods=1, pod_type="p1") 


if pinecone_index in pinecone.list_indexes():

    print(f"Loading {len(texts)} texts to index {pinecone_index}... \n This may take a while. Here's a preview of the first text: \n {texts[0].metadata} \n {texts[0].page_content}")

    for chunk in chunks(texts, 25):
        for doc in chunk:
            if doc.page_content.strip(): 
                print(f"Indexing: {doc.metadata['title']}")
                print(f"Content: {doc.page_content}")
                Pinecone.from_texts([doc.page_content], embedding=embeddings, index_name=pinecone_index, metadatas=[doc.metadata])
            else:
                print("Ignoring blank document")
    print("Done!")  

Deploy Cloud Function

import os
import functions_framework

from langchain.llms import OpenAI 
from langchain.chains.question_answering import load_qa_chain
from langchain.embeddings.openai import OpenAIEmbeddings
from langchain.vectorstores import Pinecone 
from langchain.memory import ConversationBufferMemory

openai_key = os.environ.get('OPENAI_API_KEY')
pinecone_key = os.environ.get('PINECONE_API_KEY')
pinecone_environment = os.environ.get('PINECONE_ENVIRONMENT')
pinecone_index = os.environ.get('PINECONE_INDEX')


def convert_to_document(message):
    class Document:
        def __init__(self, page_content, metadata):
            self.page_content = page_content
            self.metadata = metadata
    return Document(page_content=message, metadata={})


def answer_question(question: str, vs, chain, memory):
    relevant_docs = vs.similarity_search(question)
    conversation_history = memory.load_memory_variables(inputs={})["history"]
    context_window = conversation_history.split("\n")[-3:] 
    conversation_document = convert_to_document(context_window)
    input_documents = relevant_docs + [conversation_document]

    answer = chain.run(input_documents=input_documents, question=question)
    memory.save_context(inputs={"question": question}, outputs={"answer": answer})
    docs_metadata = []
    for doc in relevant_docs:
        metadata = doc.metadata
        if metadata is not None:
            doc_metadata = {
                "title": metadata.get('title', None),
                "relURI": metadata.get('relURI', None)
            }
            docs_metadata.append(doc_metadata)

    return {"answer": answer, "docs": docs_metadata}

llm = OpenAI(temperature=1, openai_api_key=openai_key, max_tokens=-1, streaming=True) 
chain = load_qa_chain(llm, chain_type="stuff")
embeddings = OpenAIEmbeddings(openai_api_key=openai_key)
docsearch = Pinecone.from_existing_index(pinecone_index, embeddings)
memory = ConversationBufferMemory()

import functions_framework

@functions_framework.http
def start(request):
    # For more information about CORS and CORS preflight requests, see:
    # https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request

    # Set CORS headers for the preflight request
    if request.method == 'OPTIONS':
        # Allows GET requests from any origin with the Content-Type
        # header and caches preflight response for an 3600s
        headers = {
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': 'GET',
            'Access-Control-Allow-Headers': 'Content-Type',
            'Access-Control-Max-Age': '3600'
        }

        return ('', 204, headers)

    # Set CORS headers for the main request
    headers = {
        'Access-Control-Allow-Origin': '*'
    }

    request_json = request.get_json(silent=True)
    request_args = request.args

    if request_json and 'query' in request_json:
        question = request_json['query']

    elif request_args and 'query' in request_args:
        question = request_args['query']
    else:
        question = 'What is MiloDocs?'


    return (answer_question(question=question, vs=docsearch, chain=chain, memory=memory), 200, headers)

Source Code

Help Wanted
If you know how to successfully separate this JS into its own file in assets/js, please submit a PR. It doesn’t work for me!
<div id="chatContainer" class="hidden sticky top-16 h-[calc(100vh-5rem)] flex flex-col flex justify-end">
    <div id="chat-messages" class="flex flex-col overflow-y-auto text-base">
    </div>
    <div id="chat-controls" class="flex flex-row text-xs mt-2">
        <form onsubmit="submitQuestion(event)" class="flex flex-row">
            <input id="question" type="text" aria-label="Question Input" placeholder="Ask the docs" class="h-10 border rounded-lg p-1 mr-1 focus:outline-none focus:ring-2 focus:ring-brand" />
            <button id="sendButton" aria-label="Send" class="flex items-center bg-brand my-1  hover:bg-black text-white p-1 mr-1 rounded-lg shadow-lg transition duration-300"><img src="/icons/send.svg" alt="Send" class="w-5 h-5"></button>
        </form>
        <button id="clearAll" aria-label="Delete All" onclick="clearConversation()" class="flex items-center bg-black my-1 hover:bg-red-600 text-white p-1 rounded-lg shadow-lg transition duration-300"><img src="/icons/delete.svg" alt="Delete" class="w-5 h-5"></button>
    </div>
</div>

<script>
    // Define a function to handle form submission
function submitQuestion(event) {
    event.preventDefault();
    const questionInput = document.getElementById('question');
    const questionText = questionInput.value.trim();
    if (!questionText) return;  // Exit if the question is empty
    questionInput.value = '';  // Clear the input field
    addChatBubble(questionText, 'user');
    fetchAnswer(questionText);
}

// Define a function to fetch answer from the API
async function fetchAnswer(question) {
    const response = await fetch(`https://milodocs-lc4762co7a-uc.a.run.app/?query=${encodeURIComponent(question)}`);
    const data = await response.json();
    const answer = data.answer || 'Sorry, I could not fetch the answer.';
    addChatBubble(answer, 'bot');
}

// Define a function to add chat bubble
function addChatBubble(text, sender) {
    const chatMessages = document.getElementById('chat-messages');
    let pair = chatMessages.lastElementChild;
    if (!pair || !pair.classList.contains('chat-pair') || sender === 'user') {
        pair = document.createElement('div');
        pair.className = 'chat-pair bg-zinc-100 flex flex-col  my-2 p-2 rounded-lg';
        chatMessages.appendChild(pair);
    }
    const bubble = document.createElement('div');
    bubble.className = `chat-bubble ${sender} p-2 rounded-lg text-black ${sender === 'user' ? 'font-brand font-semibold' : 'font-brand font-regular'}`;
    bubble.innerText = text;
    pair.appendChild(bubble);
    if (sender === 'user') {
        bubble.classList.add('animate-pulse');  // Add pulsing animation to user bubble
    } else {
        const userBubble = pair.querySelector('.user');
        if (userBubble) userBubble.classList.remove('animate-pulse');  // Remove pulsing animation when bot responds
        const deleteButtonWrapper = document.createElement('div');
        deleteButtonWrapper.className = 'w-full flex justify-end';

        const deleteButton = document.createElement('button');
        deleteButton.className = 'w-fit p-2 rounded bg-zinc-200 text-xs lowercase hover:bg-red-600 hover:text-white transition duration-300 text-black';
        deleteButton.innerText = 'Delete';
        deleteButton.addEventListener('click', () => {
            chatMessages.removeChild(pair);
            saveChatHistory();
        });

        deleteButtonWrapper.appendChild(deleteButton);
        pair.appendChild(deleteButtonWrapper);
    }
    
    // Scroll to the bottom of the chat container
    chatMessages.scrollTop = chatMessages.scrollHeight;

    saveChatHistory();
}

// Define a function to clear conversation
function clearConversation() {
    const chatMessages = document.getElementById('chat-messages');
    chatMessages.innerHTML = '';
    saveChatHistory();
}

// Define a function to save chat history
function saveChatHistory() {
    const chatMessages = Array.from(document.getElementById('chat-messages').children);
    const chatHistory = chatMessages.map(pair => {
        const bubbles = Array.from(pair.children);
        const texts = bubbles.map(bubble => bubble.innerText);
        return {
            user: texts[0],
            bot: texts[1]
        };
    });
    localStorage.setItem('chatHistory', JSON.stringify(chatHistory));
}

// Define a function to load chat history
function loadChatHistory() {
    const chatHistory = JSON.parse(localStorage.getItem('chatHistory'));
    if (chatHistory) {
        const chatMessages = document.getElementById('chat-messages');
        chatMessages.innerHTML = '';  // Clear any existing messages
        for (const pair of chatHistory) {
            addChatBubble(pair.user, 'user');
            addChatBubble(pair.bot, 'bot');
        }
    }
}

// Load chat history on page load
document.addEventListener('DOMContentLoaded', loadChatHistory);

</script>

The chatTocToggle.js file is used to manage the user’s discovery preference globally across the site and is associated with the following partial layouts:

How it Works

This script defaults to displaying the chatGPT UX experience initially. When a user selects the toggle, the ToC UX is activated and will persist site-wide.

Source Code

document.addEventListener("DOMContentLoaded", function (event) {
  const chatTocToggle = document.getElementById("chatTocToggle");
  const chatContainer = document.getElementById("chatContainer");
  const tocContainer = document.getElementById("tocContainer");

  // Check if chatTocSettings in user's local storage is set; if not set or value is 'chat', toggle hidden on chatContainer; if value is 'toc', toggle the tocContainer
  const chatTocSettings = localStorage.getItem("chatTocSettings");
  if (chatTocSettings === null || chatTocSettings === "chat") {
    chatContainer.classList.remove("hidden");
    tocContainer.classList.add("hidden");
  } else if (chatTocSettings === "toc") {
    chatContainer.classList.add("hidden");
    tocContainer.classList.remove("hidden");
  }

  // Update the button content based on the visibility of chatContainer
  updateButtonContent();

  // Add a click event listener to the chatTocToggle button
  chatTocToggle.addEventListener("click", function () {
    // Toggle both the chatContainer and tocContainer visibility
    chatContainer.classList.toggle("hidden");
    tocContainer.classList.toggle("hidden");

    // Update the preference and button content based on the visibility of chatContainer
    if (!chatContainer.classList.contains("hidden")) {
      localStorage.setItem("chatTocSettings", "chat");
    } else {
      localStorage.setItem("chatTocSettings", "toc");
    }

    // Update the button content after toggling
    updateButtonContent();
  });

  // Function to update the button content based on the visibility of chatContainer
  function updateButtonContent() {
    const isChatVisible = !chatContainer.classList.contains("hidden");
    chatTocToggle.innerHTML = isChatVisible
      ? '<img src="/icons/toggle-right.svg" alt="toggle" class="mr-4">'
      : '<img src="/icons/toggle-left.svg" alt="toggle" class="mr-4">';
  }
});

Quisque consequat consectetur quam at consequat. Integer efficitur quam id eros auctor, et malesuada justo congue. Fusce non feugiat tellus. Proin a eros in dolor bibendum malesuada ut et elit. Sed sollicitudin, ipsum et fermentum facilisis, urna nisl vehicula neque, in malesuada dolor orci et dolor. Etiam in libero ut turpis cursus tincidunt. Integer in libero non ante laoreet vestibulum in sed orci. Vivamus luctus bibendum velit, at egestas justo fermentum in.

The darkmode.js file is used to manage the user’s theme preference and is associated with the navigation/top.html partial layout.

                <div class="flex items-center space-x-4">
                    <button id="chatTocToggle" aria-label="Toggle Chat" class="hidden md:block"><img src="/icons/toggle-left.svg" class="w-5 h-5" alt="left/right toggle"></button>
                    <button id="darkModeToggle" aria-label="Toggle Darkmode" class=""><img src="/icons/light.svg" class="w-5 h-5" alt="sun/moon toggle"></button>
                </div>
            </div>
        </div>
    </div>
</nav>

How it Works

  1. This script checks to if the user has darkmode saved in their local storage.
  2. If not, when toggled it:
    • Saves the setting to local storage
    • Adds the dark class to the html element
    • Swaps the path for all image elements associated with the icon class from /icons/light/ to /icons/dark/
You can find the .dark class styling overrides in the assets/css/src/input.css file.

Source Code

document.addEventListener("DOMContentLoaded", function (event) {
  const darkModeToggle = document.getElementById("darkModeToggle");

  // Load saved theme preference
  const savedTheme = localStorage.getItem("theme-mode");
  if (savedTheme) {
    document.documentElement.classList.toggle("dark", savedTheme === "dark");
    updateButtonText();
    updateSectionIcons();
  }

  darkModeToggle.addEventListener("click", () => {
    const isDarkMode = document.documentElement.classList.contains("dark");
    document.documentElement.classList.toggle("dark", !isDarkMode);

    // Update the theme mode and button text
    localStorage.setItem(
      "theme-mode",
      document.documentElement.classList.contains("dark") ? "dark" : "light"
    );
    updateButtonText();

    // Update the section icons
    updateSectionIcons();
  });

  function updateButtonText() {
    const isDarkMode = document.documentElement.classList.contains("dark");

    darkModeToggle.innerHTML = isDarkMode
      ? '<img src="/icons/dark.svg" aria-label="activate lightmode" class="w-5" alt="moon">'
      : '<img src="/icons/light.svg" aria-label="activate darkmode" class="w-5" alt="sun">';
  }

  function updateSectionIcons() {
    const isDarkMode = document.documentElement.classList.contains("dark");
    const sectionIcons = document.querySelectorAll(".icon");

    sectionIcons.forEach((icon) => {
      const src = icon.getAttribute("src");
      const newSrc = isDarkMode
        ? src.replace("/icons/light/", "/icons/dark/")
        : src.replace("/icons/dark/", "/icons/light/");
      icon.setAttribute("src", newSrc);
    });
  }
});

Quisque consequat consectetur quam at consequat. Integer efficitur quam id eros auctor, et malesuada justo congue. Fusce non feugiat tellus. Proin a eros in dolor bibendum malesuada ut et elit. Sed sollicitudin, ipsum et fermentum facilisis, urna nisl vehicula neque, in malesuada dolor orci et dolor. Etiam in libero ut turpis cursus tincidunt. Integer in libero non ante laoreet vestibulum in sed orci. Vivamus luctus bibendum velit, at egestas justo fermentum in.

Quisque consequat consectetur quam at consequat. Integer efficitur quam id eros auctor, et malesuada justo congue. Fusce non feugiat tellus. Proin a eros in dolor bibendum malesuada ut et elit. Sed sollicitudin, ipsum et fermentum facilisis, urna nisl vehicula neque, in malesuada dolor orci et dolor. Etiam in libero ut turpis cursus tincidunt. Integer in libero non ante laoreet vestibulum in sed orci. Vivamus luctus bibendum velit, at egestas justo fermentum in.

It’s common to want to build out a glossary of terms that are either unique to your product or essential to understanding the larger industry it lives in.

Creating a glossary is an essential part of your strategy for bolstering organic inbound traffic through authoritative SEO content.

How it Works

Source Code

{{ define "main" }}
<div class="flex">
{{ partial "navigation/glossary.html" . }}
  <article id="article-container" class="flex flex-col w-full hide-scrollbar mx-2">
    {{ partial "glossary/entries.html" . }}
  </article>
</div>
{{ end }}

It’s common to want to build out a glossary of terms that are either unique to your product or essential to understanding the larger industry it lives in.

Creating a glossary is an essential part of your strategy for bolstering organic inbound traffic through authoritative SEO content.

The glossary.js file is used to manage the visual “spotlight effect” experience on the glossary entries found in the glossary.html partial layout.

How it Works

This functionality is nearly identical to the js/tiles.js functionality – with the exception of the utilized class name, glossary-entry. This is so that you can more easily customize each experience on your own if you wish.

If the page has elements with the glossary-entry class, this script updates their background radial gradient values to mirror the position of the mouse.

The color of the spotlight effect is determined by css/colors.css, specifically:

  • inside: var(--primary-gradient-color)
  • outside: var(--secondary-gradient-color)

Source Code


document.addEventListener('DOMContentLoaded', () => {
    const cards = document.querySelectorAll('.glossary-entry');
    const positions = { x: 50, y: 50 }; // Default to center

    function animate() {
        cards.forEach(card => {
            const rect = card.getBoundingClientRect();
            const mouseX = (positions.x - rect.left) / rect.width * 100;
            const mouseY = (positions.y - rect.top) / rect.height * 100;
            card.style.background = `radial-gradient(circle at ${mouseX}% ${mouseY}%, var(--primary-gradient-color), var(--secondary-gradient-color))`;
        });
        requestAnimationFrame(animate);
    }

    cards.forEach(card => {
        card.addEventListener('mousemove', (e) => {
            // Update gradient position
            positions.x = e.clientX;
            positions.y = e.clientY;
        });
        
        card.addEventListener('mouseover', () => {
            card.style.transform = 'translateY(-10px) scale(1.05)';
            card.style.boxShadow = '0 20px 30px #00000033';
        });
        
        card.addEventListener('mouseout', () => {
            card.style.transform = 'translateY(0) scale(1.0)';
            card.style.boxShadow = '';
        });
    });

    animate();
});

Source Code

HTML Output

{{ define "home" }}
    <div id="articleContent" class="text-black p-4">
      {{ .Content }}
    </div>
    <div class="p-4">
      {{partial "article/tiles.html" . }}
    </div>
    
{{ end }}

JSON Output

[
    {{- range $index, $page := .Pages }}
    {{- if ne $page.Type "json" }}
    {{- if and $index (gt $index 0) }},{{- end }}
    {{- partial "json.json" . }}
    {{- end }}
    {{- if eq .Kind "section" }}
        {{- template "section" . }}
    {{- end }}
    {{-  end }}
]
{{- define "section" }}
{{- range .Pages }}
    {{- if ne .Type "json" }}
    ,{{- partial "json.json" . }}
    {{- end }}
    {{- if eq .Kind "section"}}
        {{- template "section" . }}
    {{- end }}
{{- end }}
{{- end }}
  1. Run the following:
    hugo new site $siteName
    cd $siteName
    hugo new theme $siteNameTheme
    git init
    cd themes/$siteNameTheme
    pnpm init
    pnpm install tailwindcss
  2. Connect tailwind.config.js to content & layouts:
    // tailwind.config.js
    /** @type {import('tailwindcss').Config} */
    module.exports = {
      content: ["content/**/*.md", "layouts/**/*.html"],
      theme: {
        extend: {},
      },
      plugins: [],
    };
  3. Navigate to /themes/sitethemeName/assets/css.
  4. Add a src/input.css with the following:
    @tailwind base;
    @tailwind components;
    @tailwind utilities;
  5. Update package.json with the following scripts:
     "build-tw": "pnpm tailwindcss -i ./assets/css/src/input.css -o ./assets/css/main.css",
     "watch-tw": "pnpm tailwindcss -i ./assets/css/src/input.css -o ./assets/css/main.css -w --minify"
  6. Run pnpm run watch-tw from siteThemeName directory to enable watching for design changes.

You are ready to start creating and editing your theme.

Inline code samples are great — but code samples that are pulled from source files can be even better! This {{%include%}} shortcode is inspired by Sphinx’s .. literalinclude:: functionality.

This document is going to be a bit meta.

How it Works

The {{%include%}} shortcode accepts 3 positional args: lang, start, and stop. All are optional.

Don't forget to use %
This shortcode relies on Hugo’s markdown rendering to automatically handle code syntax highlighting. If you surround this shortcode with < > instead, it will break.

Examples

This File

---
title: include.html
description: learn how to use the literal shortcode
---
<!--start -->
Inline code samples are great --- but code samples that are pulled from source files can be even better! This `{{%/*include*/%}}` shortcode is inspired by Sphinx's `.. literalinclude::` functionality. 

{{<notice snack>}}
This document is going to be a bit meta. 
{{</notice>}}

## How it Works

The `{{%/*include*/%}}` shortcode accepts 3 **positional** args: `lang`, `start`, and `stop`. All are optional.

{{<notice warning "Don't forget to use %">}}
This shortcode relies on Hugo's markdown rendering to automatically handle code syntax highlighting. If you surround this shortcode with `<` `>` instead, it will break.
{{</notice>}}

### Examples 

### This File

{{%include "reference/layouts/shortcodes/include.md" "md" %}}

### Python File With Comments

{{%include "static/demo-package.py" "python" "# Start 1" "# End 1" %}}

## Source Code 

{{%include "layouts/shortcodes/include.html" "go" %}}

Python File With Comments


def demo_function(arg1, arg2):
    """Demo Function

    This function takes two arguments and returns their sum.

    Args:
        arg1 (int): The first argument.
        arg2 (int): The second argument.

    Returns:
        int: The sum of arg1 and arg2.
    """
    return arg1 + arg2

Source Code

{{ $path := .Get 0 }}
{{ $lang := .Get 1 | default "s" }}
{{ $start := .Get 2 | default nil }}
{{ $stop := .Get 3 | default nil}}
{{ $content := readFile $path }}
{{ $chunked := split $content "\n" }}
{{ $snippet := "" }}
{{ $capture := false }}
{{if eq $start nil }}
{{ $capture = true }}
{{end}}
{{- range $chunked }}
    {{ if and (not $capture) (in . $start) }}
        {{ $capture = true }}
    {{ else if and $capture (in . $stop) }}
        {{ $capture = false }}
    {{ else if $capture }}
        {{ $snippet = print $snippet . "\n" }}
    {{ end }}
{{- end }}

{{ printf "```%s\n%s\n```" $lang $snippet | safeHTML }}

Source Code

{{ define "main" }}
  <h1 class="">{{ .Title }}</h1>
  {{ .Content }}
  {{ range .Pages }}
    <h2><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></h2>
    {{ .Summary }}
  {{ end }}
{{ end }}

The main.js file isn’t really used at the moment, but I keep it around so people can see the default Hugo message.

Source Code

console.log('This site was generated by Hugo.');

Welcome to MiloDocs Theme

MiloDocs is a Hugo theme made for documentation engineers and technical writers, by one.

(Oh, and in case you are wondering — Milo is inspired by my Russian Blue cat.)

The next-prev.html partial layout defines the article progression experience and is located at the end of an article.

How it Works

  • Single pages:

    • The next article in the section is displayed on the left (next points up).
    • The previous article in the section is displayed on the right (prev points down).
  • Section pages:

    • The first child is displayed on the right (points down)
This experience can be disabled from the themes/milo/hugo.yaml config by setting Params.articles.nextPrev.display to false.

Source Code

{{- if .Site.Params.article.nextPrev.display -}}
{{$trunc := .Site.Params.article.nextPrev.trunc}}
<div class="mt-8 flex justify-between items-center">
    {{with .NextInSection }}
        <a href="{{.RelPermalink}}" class="tile text-black py-2 px-4 rounded-r-md transition duration-300"> {{.Title | truncate $trunc}} </a>
    {{else}}
    <span class="py-2 px-4"> <!-- Placeholder for alignment -->
    </span>
    {{ end }}

    {{with .PrevInSection }}
        <a href="{{.RelPermalink}}" class="tile text-black py-2 px-4 rounded-l-md transition duration-300"> {{.Title | truncate $trunc}} </a>
    {{else}}
        {{- range first 1 .Pages}}
        <a href="{{.RelPermalink}}" class="tile text-black py-2 px-4 rounded-l-md transition duration-300"> {{.Title | truncate $trunc}} </a>
        {{- end}}
    {{ end }}
</div>
{{- end -}}

Occasionally you might need to make admonitions, callouts, or notices in your documentation. Use the {{<notice>}} shortcode to display these.

How it Works

The {{<notice>}} shortcode accepts 2 positional args: type and title. Both are optional. If no type is set, the notice defaults to info.

Examples

without type
This is a default notice.
want a cookie?
This is a snack notice.
you don't have to add a title
This is a tip notice.
there's a lot of options
This is a note notice.
probably redundant with note
This is a info notice.
hugo is safe
This is a security notice.
don't use lightmode at night
This is a warning notice.
cats may destroy furniture
This is a danger notice.
{{< notice "" "without type" >}}
This is a **default** notice.
{{< /notice >}}

{{< notice snack "want a cookie?" >}}
This is a **snack** notice.
{{< /notice >}}

{{< notice tip "you don't have to add a title">}}
This is a **tip** notice.
{{< /notice >}}

{{< notice note "there's a lot of options" >}}
This is a **note** notice.
{{< /notice >}}

{{< notice info "probably redundant with note" >}}
This is a **info** notice.
{{< /notice >}}

{{< notice security "hugo is safe" >}}
This is a **security** notice.
{{< /notice >}}

{{< notice warning "don't use lightmode at night" >}}
This is a **warning** notice.
{{< /notice >}}

{{< notice danger "cats may destroy furniture" >}}
This is a **danger** notice.
{{< /notice >}}

Source Code

{{ $type := .Get 0 | default "info" }}
{{ $title := .Get 1 }}
<div class="notice p-4 mt-2 mb-2 rounded-md shadow-md 
    {{- with $type }}
        {{if eq . "danger" }} bg-brand-7
        {{else if eq . "warning" }} bg-brand-6
        {{else if eq . "security" }} bg-brand-5
        {{else if eq . "tip" }} bg-brand-4
        {{else if eq . "note" }} bg-brand-3 
        {{else if eq . "info" }} bg-brand-2 
        {{else if eq . "snack" }} bg-brand-1 
        {{ end }}
    {{- end }}
">
    <div class="flex">
        <div class="flex-shrink-0">
           <img src="/icons/light/
            {{- with $type}}
                {{- if eq . "danger" -}} danger
                {{- else if eq . "warning" -}} warning
                {{- else if eq . "security" -}} security
                {{- else if eq . "info" -}} info
                {{- else if eq . "note" -}} note
                {{- else if eq . "tip" -}} tip 
                {{- else if eq . "snack" -}} snack 
                {{- end -}}
            {{- end -}}
           .svg" class="icon w-5 h-5">
        </div>
        <div class="ml-3 w-full text-black text-sm">
            {{- if $title -}}
            <div class="font-brand font-bold mb-2">{{ $title }}</div>
            {{- end -}}
            {{ $.Page.RenderString .Inner }}
        </div>
    </div>
</div>

It’s common for developers to build packages in Python that need auto-generated code documentation based off of Python strings.

To better support integrating that collection to your larger docs site, I’ve built out a {{<pdoc>}} shortcode that enables you to automatically target links for:

  • Supermodules
  • Submodules
  • Functions
  • Classes
  • Methods
How complex is the package?
If you are integrating pdoc documentation for a package that has submodules, use the default pdoc shortcode. For simple packages without submodules, use pdoc-2.

How it Works

The {{<pdoc>}} shortcode accepts 3 positional args: type, target, and linktitle (optional). If linktitle is not set, it automatically configures the link text as show in the following sections.

pdoc

Type (arg0) Target (arg1) Result
supermodule ~pkg.super /references/pkg/super
submodule ~pkg.super.sub /references/pkg/super
function ~pkg.super.func /references/pkg/super/sub#package.super.sub.func
class ~pkg.super.sub.class /references/pkg/super/sub#package.super.sub.class
method ~pkg.super.sub.class.meth /references/pkg/super/sub#pkg.super.sub.class.meth

pdoc-2

Type (arg0) Target (arg1) Result
function ~pkg.super.func_name /references/pkg.html#pkg.func
class ~pkg.super.sub.class /references/pkg.html#pkg.class
method ~pkg.super.sub.class.method /references/pkg.html#pkg.class.meth

Examples

- {{< pdoc-2 "function" "~demo-package.demo_function" >}}
- {{< pdoc-2 "class" "~demo-package.DemoClass" >}}
- {{< pdoc-2 "method" "~demo-package.DemoClass.demo_method" >}}

Source Code

Want to change the main directory?
You can change the default directory where this shortcode looks for pdoc collections by updating the value of $baseurl. Alternatively, you could make this shortcode more advanced and remove that static baseurl piece altogether.
<!-- layouts/shortcodes/det.html -->
{{ $type := index (.Params) 0 }}
{{ $path := index (.Params) 1 }} 
{{ $title := index (.Params) 2}}

{{ $baseurl := "/references/" }}
{{ $anchor := "" }}
{{ $linkText := "" }}

{{ $trimmed_path := strings.TrimPrefix "~" $path }}
{{ $path_parts := split $trimmed_path "." }}  

{{ if eq $type "supermodule" }}
  {{/*  url pattern for supermodule: {baseurl}/directory/supermodule/  */}}
  {{ $baseurl = printf "%s%s/%s/" $baseurl (index $path_parts 0) (index $path_parts 1) }}
  {{ $linkText = (index $path_parts 1) }}
{{ else if eq $type "submodule" }}
  {{/*  url pattern for submodule: {baseurl}/directory/supermodule/submodule.html */}}
  {{ $baseurl = printf "%s%s/%s/%s.html" $baseurl (index $path_parts 0) (index $path_parts 1) (index $path_parts 2) }}
  {{ $linkText = printf "%s.%s" (index $path_parts 2) (index $path_parts 1) }}
{{ else if eq $type "class" }}
  {{/*  url pattern for class: {baseurl}/directory/supermodule/submodule.html#directory.supermodule.submodule.class */}}
  {{ $baseurl = printf "%s%s/%s/%s.html" $baseurl (index $path_parts 0) (index $path_parts 1) (index $path_parts 2) }}
  {{ $anchor = printf "#%s.%s.%s.%s" (index $path_parts 0) (index $path_parts 1) (index $path_parts 2) (index $path_parts 3) }}
  {{ $linkText = printf "%s.%s()" (index $path_parts 2) (index $path_parts 3) }}
{{ else if eq $type "function" }}
  {{/*  url pattern for function: {baseurl}/directory/supermodule/submodule.html#directory.supermodule.submodule.function */}}
  {{ $baseurl = printf "%s%s/%s/%s.html" $baseurl (index $path_parts 0) (index $path_parts 1) (index $path_parts 2) }}
  {{ $anchor = printf "#%s.%s.%s.%s" (index $path_parts 0) (index $path_parts 1) (index $path_parts 2) (index $path_parts 3) }}
  {{ $linkText = printf "%s.%s()" (index $path_parts 2) (index $path_parts 3) }}
{{ else if eq $type "method" }}
  {{/*  url pattern for method: {baseurl}/directory/supermodule/submodule.html#directory.supermodule.submodule.class.method */}}
  {{ $baseurl = printf "%s%s/%s/%s.html" $baseurl (index $path_parts 0) (index $path_parts 1) (index $path_parts 2) }}
  {{ $anchor = printf "#%s.%s.%s.%s.%s" (index $path_parts 0) (index $path_parts 1) (index $path_parts 2) (index $path_parts 3) (index $path_parts 4) }}
  {{ $linkText = printf "%s.%s.%s()" (index $path_parts 2) (index $path_parts 3) (index $path_parts 4) }}
  {{else}}
    {{ errorf "The %q shortcode requires a type parameter as arg 0 (module, submodule, class, function, method). See %s" .Name .Position }}
{{ end }}


<a href="{{ $baseurl }}{{ $anchor }}" class="font-bold underline">{{with $title}}{{.}}{{else}}{{ $linkText }}{{end}}</a>

Occasionally it is useful to use a variable that represents your product name — especially when documenting a project or startup product that is likely to undergo rebranding.

How it Works

The {{<prod>}} shortcode prints out a string for your main product name defined in your site configuration.

  1. Open your repo.
  2. Navigate to the themes/milo/hugo.yaml file.
  3. Update the following:
    # Theme Feature Settings
    params: 
      names:
        product: 'Milo Docs'
Header Constraints for TOCs

To ensure Hugo resolves this shortcode correctly in the Table of Contents of your articles, make sure that you use the % wrapper instead of < > in your headers.

Example Error Output: HAHAHUGOSHORTCOD...

Examples

This is the MiloDocs theme.

This is the {{<prod>}} theme.

Source Code

{{- with .Site.Params.names.product}}{{.}}{{end}}

The search.js file is used to manage the Algolia search integration and experience.

How it Works

This script automatically toggles the view of a regular page versus the search page when a user inputs a search string. With every added letter, a new search is performed against the index.

Results returned are grouped by parent article and then provided as a stacked series of links.

free search limit
Algolia typically allows 10,000 free monthly searches — though this is subject to change.

Set Up

  1. Create an Algolia account.
  2. Provide your App ID and Search Only API Key to the searchClient (these are safe to reveal; the Admin API Key is not.).
  3. Push or upload your site’s index, found at /index.json.

That’s it! Start searching.

I personally just download this file and upload it per release; it’s a manual process — but super easy. You are welcome to integrate the Algolia API with your Admin API Key to push auto updates.

have it your way

There are several ways to implement Algolia; DocSearch is popular and free. I personally like to integrate the style of the UX more, but this can require more knowledge of InstantSearch.js.

If you like the default implementation but wish to style the search hits differently, you can do so in the performAlgoliaSearch(query) function.

Source Code

document.addEventListener("DOMContentLoaded", function () {
  const searchInput = document.getElementById("searchInput");
  const pageContainer = document.getElementById("pageContainer");
  const searchResultsContainer = document.getElementById(
    "searchResultsContainer"
  );

  // Algolia configuration
  const searchClient = algoliasearch(
    "4TYL7GJO66", // APP ID 
    "4b6a7e6e3a2cf663b3e4f8a372e8453a" // Search Only API Key
  );
  const searchIndex = searchClient.initIndex("default"); // Replace 'default' with your Algolia index name

  // Function to group search results by parent
  function groupResultsByParent(hits) {
    const groupedResults = {};

    hits.forEach((hit) => {
      const parent = hit.parent;
      if (!groupedResults[parent]) {
        groupedResults[parent] = [];
      }
      groupedResults[parent].push(hit);
    });

    return groupedResults;
  }

  // Function to perform Algolia search and update results with more details
  function performAlgoliaSearch(query) {
    searchIndex
      .search(query)
      .then(({ hits }) => {
        // Group search results by parent
        const groupedResults = groupResultsByParent(hits);

        // Display grouped search results in the search results container
        const resultsHTML = Object.keys(groupedResults).map((parent) => {
          const parentResults = groupedResults[parent];

          const parentHTML = parentResults
            .map((hit) => {
              return `
                <a href="${hit.relURI}">
                <div class="mb-4 text-black hover:bg-brand hover:text-white rounded-lg p-4 my-2 bg-zinc-100 transition duration-300 shadow-md">
                  <h3 class="text-lg font-bold">${hit.title}</h3>
                  <p class="text-sm text-zinc-200">${hit.description}</p>
                </div>
                </a>
              `;
            })
            .join("");

          return `
            <div class="mb-8">
              <h2 class="text-xl font-bold text-black">${parent}</h2>
              ${parentHTML}
            </div>
          `;
        });

        searchResultsContainer.innerHTML = resultsHTML.join("");
      })
      .catch((err) => {
        console.error(err);
      });
  }

  // Event listener for typing in the search input
  searchInput.addEventListener("input", () => {
    const inputValue = searchInput.value.trim();

    // Toggle "hidden" class based on whether there is input in the search field
    if (inputValue !== "") {
      // Show search results container and hide page container
      searchResultsContainer.classList.remove("hidden");
      pageContainer.classList.add("hidden");

      // Trigger Algolia search with the input value
      performAlgoliaSearch(inputValue);
    } else {
      // Show page container and hide search results container
      searchResultsContainer.classList.add("hidden");
      pageContainer.classList.remove("hidden");
    }
  });

});

Source Code

HTML Output

{{ define "main" }}
  <div class="my-4">
    {{partial "navigation/breadcrumbs.html" . }}
  </div>
  <h1 class="text-black p-4">{{ .Title }}</h1>
  {{if .Content}}
  <div id="articleContent" class="p-4 text-black">
    {{ .Content }}
  </div>
  {{end}}
  <div class="p-4">
    {{ partial "article/tiles.html" . }}
    {{partial "article/next-prev.html" . }}
  </div>
{{ end }}

JSON Output

[
    {{- range $index, $page := .Pages }}
    {{- if ne $page.Type "json" }}
    {{- if and $index (gt $index 0) }},{{ end }}
    {{- partial "json.json" . }}
    {{- end }}
    {{- if eq .Kind "section"}}
        {{- template "section" . }}
    {{- end }}
    {{- end }}
]

{{- define "section" }}
{{- range .Pages }}
    {{- if ne .Type "json" }}
    ,{{- partial "json.json" . }}
    {{- end }}
    {{- if eq .Kind "section"}}
        {{- template "section" . }}
    {{- end }}
{{- end }}
{{- end }}

The sidebar-left.js file is used to manage the scroll-into-view link experience in the navigation/sidebar-left.html partial layout.

How it Works

  1. The script finds the active link via [data-current="true] attribute set by logic in the sidebar-left.html template.
  2. After a 300ms delay, it scrolls until the active link is visible.

Source Code

document.addEventListener("DOMContentLoaded", function () {
  var activeLink = document.querySelector('[data-current="true"]');

  // Function to scroll to the link with a delay
  function scrollToLink(link) {
    if (link) {
      setTimeout(function () {
        link.scrollIntoView({
          behavior: "smooth",
          block: "nearest",
          inline: "start",
        });
      }, 300);
    }
  }

  scrollToLink(activeLink);

  var links = document.querySelectorAll("a[data-level]");
  var currentUrl = window.location.href;

  // Function to handle the visibility of nested lists
  function handleVisibility() {
    // Initially hide all levels greater than 2
    links.forEach(function (link) {
      var level = parseInt(link.getAttribute("data-level"));
      if (level > 1) {
        link.closest("li").classList.add("hidden");
      }
    });

    links.forEach(function (link) {
      if (link.href === currentUrl) {
        link.classList.add("text-brand");

        // Unhide all ancestor li elements
        var ancestor = link.closest("li");
        while (ancestor) {
          if (ancestor.tagName.toLowerCase() === "li") {
            ancestor.classList.remove("hidden");
          }
          ancestor = ancestor.parentElement;
        }

        // Unhide direct siblings at the same level
        var parentLi = link.closest("li");
        var siblingLis = Array.from(parentLi.parentElement.children).filter(
          function (child) {
            return child !== parentLi;
          }
        );
        siblingLis.forEach(function (siblingLi) {
          siblingLi.classList.remove("hidden");
        });
      }
    });
  }

  // Call the handleVisibility function
  handleVisibility();
});

Source Code

HTML Output

{{ define "main" }}
  <div class="my-4">
    {{partial "navigation/breadcrumbs.html" . }}
  </div>
  <h1 class="text-black p-4">{{ .Title }}</h1>
  <div id="articleContent" class="p-4">
    {{ .Content }}
  </div>
  <div class="p-4">
    {{partial "article/next-prev.html" . }}
  </div>
{{ end }}

JSON Output

{{- partial "json.json" .}}

Tabs are a great way to organize content that is contextually relevant but divergent in format or procedure (without necessarily needing its own page). This combination of shortcodes allows you to create a tabbed interface. I first encountered this implementation strategy while reading the MiniKube docs.

How it Works

There are 5 shortcodes that make up the tabs UX.

shortcode description input
{{<tabs/container>}} This is the container for the entire tabs UX. n/a
{{<tabs/tabButtons>}} This is the container for the tab buttons. id string
{{<tabs/tab>}} This is the button that will be clicked to show the tab content. option string; state string
{{<tabs/tabContentsContainer>}} This is the container for the tab content. n/a
{{<tabs/tabContent>}} This is the content that will be shown when the tab button is clicked. markdown
Set Tab as Default
When an option has the default state of active, it will be the first tab shown.

Example

  1. Ensure your DemoTool server is running and connected.
  2. Navigate to Console.
.
{{<tabs/container>}}
{{<tabs/tabButtons id="launch-method">}}
{{<tabs/tab option="Console" state="active">}}
{{<tabs/tab option="CLI">}}
{{</tabs/tabButtons>}}
{{<tabs/tabContentsContainer>}}
{{<tabs/tabContent val1="launch-method/console">}}

1. Ensure your DemoTool server is running and connected.
2. Navigate to Console.

{{< /tabs/tabContent >}}
{{< tabs/tabContent val1="launch-method/cli" >}}

1. Run `demoCLI connect`.

{{</tabs/tabContent>}}
{{</tabs/tabContentsContainer>}}
{{</tabs/container>}}

Source Code

<!--tabs/container.html -->
<section class="bg-zinc-100 p-2 my-2 rounded-md" data-component="tabs">
        {{- .Inner -}}
</section>
.

The tabs.js file is used to manage the tabbed experience created by a combination of shortcodes found in /layouts/shortcodes/tabs.

<!--tabs/container.html -->
<section class="bg-zinc-100 p-2 my-2 rounded-md" data-component="tabs">
        {{- .Inner -}}
</section>
.

How it Works

  1. If a page has elements with [data-component="tabs"], the script collects them all into an array.
  2. For each collection of tabs, it then collects the button options ([data-tab-id])and corresponding tabbed markdown content ([data-tabcontent]).
  3. Event listeners are setup for each button; when selected, the corresponding content is revealed and the button highlighted; other options are hidden/muted.

Source Code

document.addEventListener("DOMContentLoaded", function(event) {
    if (document.querySelectorAll('[data-component="tabs"]')) {
        var tabs = document.querySelectorAll('[data-component="tabs"]');

        console.log("tabs ", tabs)

        tabs.forEach(function(tab) {
            // get each child step element with the data-wizard-id attribute
            let options = tab.querySelectorAll('[data-tab-id]');
            let optionContent = tab.querySelectorAll('[data-tabcontent]');  // Changed to lowercase 'c'
            getAnswers({tab, optionContent}) 
            // listen for click on button elements within each step
            options.forEach((option) => {
                // get all of the buttons within the step
                let buttons = option.querySelectorAll('button')

                // listen for click on each button
                buttons.forEach((button) => {
                    button.addEventListener('click', (e) => {
                        // add green class to the clicked button
                        e.target.classList.remove('bg-white', 'text-black')
                        e.target.classList.add('bg-brand', 'text-white')
                        // remove green class from the other buttons
                        buttons.forEach((button) => {
                            if (button !== e.target) {
                                button.classList.remove('bg-brand', 'text-white')
                                button.classList.add('bg-white', 'text-black')
                            }
                        })

                        getAnswers({tab, optionContent})
                    })
                }) 
            })
        });

        function getAnswers({tab, optionContent}){
            // get all buttons with the green class
            let activeButtons = tab.querySelectorAll('button.bg-brand')

            // get the data-tab-option attribute of all the buttons with the green class
            let activeButtonOptions = [] 

            console.log("activeButtonOptions ", activeButtonOptions)

            activeButtons.forEach((button) => {
                console.log("activeButton ", button)
                activeButtonOptions.push(button.getAttribute('data-tab-option'))  // Changed to data-tab-option
            })

            console.log("activeButtonOptions ", activeButtonOptions)

            let convertedText = activeButtonOptions.join('/').toLowerCase()

            // hide all other answers
            optionContent.forEach((content) => {
                let value = content.getAttribute('data-tabcontent')  
                console.log(`value: ` + value)
                console.log(`convertedText: ` + convertedText)
                if (value !== convertedText) {
                    content.classList.add('hidden')
                } else {
                    content.classList.remove('hidden')
                }
            })
        }
    }
});

How it Works

Source Code

{{if and (.Pages) (.Site.Params.article.childArticles.display)}}
{{$titleTrunc := .Site.Params.article.childArticles.titleTrunc }}
{{$descTrunc := .Site.Params.article.childArticles.descTrunc }}
{{$count := .Site.Params.article.childArticles.count }}

<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
    {{ range .Pages }}
        {{ if  .Params.hidden }}
            {{/* Do not show the hidden child page or its descendants */}}
        {{else}}
        <div class="rounded-lg shadow-md p-4 tile transition duration-300 space-y-2">
            <a href="{{ with .Params.externalRedirect}}{{.}}{{else}}{{.RelPermalink}}{{end}}" class="space-y-2">
                {{with .Params.icon}}<img src="/icons/light/{{.}}" alt="" class="w-5 h-5 icon">{{end}}
                <div class="text-xl text-black font-brand font-semibold">{{if .Params.linkTitle}} {{.Params.linkTitle}} {{else}}{{ .Title | truncate $titleTrunc}}{{end}}</div>
                {{ with .Description | truncate $descTrunc}}
                <p class="text-zinc-900 text-sm font-brand font-thin">{{ . }}</p>
                {{ end }}
            </a>
            {{if eq .BundleType "branch"}}

            <ul class="flex flex-wrap list-none p-0">
                {{ range first $count .Pages }}
                    {{ if  .Params.hidden }}
                        {{/* Do not show the hidden child page or its descendants */}}
                    {{else}}
                            <li class="my-2 mr-2">
                                <a href="{{ with .Params.externalRedirect}}{{.}}{{else}}{{.RelPermalink}}{{end}}" class="text-xs p-2 text-black font-brand font-thin hover:text-white bg-white hover:bg-brand hover:transition hover:duration-300 rounded">
                                    {{if eq .Parent.Title "Run Commands"}}{{ with replace .Title "pachctl " "" }}{{ . }}{{ end }}{{else}}{{if .Params.linkTitle}}{{.Params.linkTitle}}{{else}}{{ .Title | truncate 22 }}{{end}}{{end}}
                                </a>
                            </li>
                    {{end}}
                {{ end }}
            </ul>
            {{end}}
        </div>
        {{end}}
    {{ end }}
</div>

{{end}}

The tiles.js file is used to manage the visual “spotlight effect” experience on the article tiles found in the article/tiles.html partial layout.

How it Works

This functionality is nearly identical to the js/glossary.js functionality – with the exception of the utilized class name, tile

If the page has elements with the tile class, this script updates their background radial gradient values to mirror the position of the mouse.

The color of the spotlight effect is determined by css/colors.css, specifically:

  • inside: var(--primary-gradient-color)
  • outside: var(--secondary-gradient-color)

Source Code

document.addEventListener("DOMContentLoaded", () => {
  if (document.querySelectorAll(".tile")) {
    const cards = document.querySelectorAll(".tile");
    const positions = { x: 50, y: 50 }; // Default to center

    function animate() {
      cards.forEach((card) => {
        const rect = card.getBoundingClientRect();
        const mouseX = ((positions.x - rect.left) / rect.width) * 100;
        const mouseY = ((positions.y - rect.top) / rect.height) * 100;
        card.style.background = `radial-gradient(circle at ${mouseX}% ${mouseY}%, var(--primary-gradient-color), var(--secondary-gradient-color))`;
      });
      requestAnimationFrame(animate);
    }

    cards.forEach((card) => {
      card.addEventListener("mousemove", (e) => {
        // Update gradient position
        positions.x = e.clientX;
        positions.y = e.clientY;
      });

      card.addEventListener("mouseover", () => {
        card.style.transform = "translateY(-10px) scale(1.05)";
        card.style.boxShadow = "0 20px 30px #00000033";
      });

      card.addEventListener("mouseout", () => {
        card.style.transform = "translateY(0) scale(1.0)";
        card.style.boxShadow = "";
      });
    });

    animate();
  }
});

How it Works

Source Code

<div id="tocContainer" class="hidden toc sticky top-10 pt-4 h-[calc(100vh-4rem)] overflow-y-auto text-sm text-black transition duration-300"> 
    {{ .TableOfContents }}
</div>

The toc.js file is used to manage the “scrolling-section-highlight effect” experience on the table of contents found in the navigation/sidebar-right.html partial layout.

How it Works

If the page has an element with the ID #TableOfContents, this script collects the associated links and highlights them based on scrolling behavior:

  • Scrolling Down: it highlights the next h2 or h3 as soon as it comes into view.
  • Scrolling Up: it highlights the previous h2 or h3 as soon as the lowest one exits view.

Source Code

document.addEventListener("DOMContentLoaded", () => {
  // Get all TOC links
  const tocLinks = document.querySelectorAll("#TableOfContents a");

  // Function to highlight the currently viewed section
  function highlightInView() {
    const sections = document.querySelectorAll("h2, h3"); // You may need to adjust the selector based on your HTML structure

    // Define the top offset (50px in this example)
    const offset = 50;

    // Loop through the sections to find the currently viewed one
    sections.forEach((section) => {
      const rect = section.getBoundingClientRect();

      // Adjust the condition with the offset
      if (
        rect.top + offset >= 0 &&
        rect.bottom <= window.innerHeight + offset
      ) {
        // Section is in view
        const sectionId = section.id;
        tocLinks.forEach((link) => {
          if (link.getAttribute("href").substring(1) === sectionId) {
            // Remove the highlight class from all TOC links
            tocLinks.forEach((tocLink) => {
              tocLink.classList.remove("text-brand");
            });
            // Add the highlight class to the currently viewed TOC link
            link.classList.add("text-brand");
          }
        });
      }
    });
  }

  // Attach the scroll event listener to the window
  window.addEventListener("scroll", highlightInView);
});

If the project you are documenting must be installed, it is likely that your documentation needs to be versioned. In this scenario, it’s especially useful to have a shortcode that can also version your download links, github links, announcements, and similar assets without having to manually update them across all of your articles.

How it Works

  1. Set up a content/latest directory to begin versioning your documentation.
  2. Add the following frontmatter to content/latest/_index.md, updating the version numbers:
    cascade:
        version:
            isLatest: true
            major: 0
            minor: 0 
            patch: 0
  3. Use the {{<version>}} shortcode to print out the collection’s versions anywhere beneath the directory.

Examples

MiloDocs Theme does not actually have versioned documentation; this is just for demonstration purposes.

Local Version

The default functionality for this shortcode uses the version numbers cascading from the root of the versioned directory (e.g., content/latest, content/1.0.2).

- **{{<version>}} is now live!**
- [{{<version>}} Download](https://github.com/org/project/releases/tag/v{{<version>}})

Global Version

In cases where you want to mention or link to the latest version in older versions of your content, you can add {{<version "global">}}. This uses the site-wide parameter to determine what version number to use.

<!-- hugo.yaml -->
# Theme Feature Settings
params: 
  [...]
  version: 
    major: 0
    minor: 0
    patch: 3
- **{{<version "global">}} is now live!**
- [{{<version "global">}} Download](https://github.com/org/project/releases/tag/v{{<version "global">}})

Source Code

{{- $source := index (.Params) 0 | default "local" }}
{{- $major := ""}}
{{- $minor := ""}}
{{- $patch := ""}}
{{- $value := ""}}
{{- if eq $source "global" }}
{{- $major = .Site.Params.Version.Major }}
{{- $minor = .Site.Params.Version.Minor }}
{{- $patch = .Site.Params.Version.Patch }}
{{- else}}
{{- $major = .Page.Params.Version.Major }}
{{- $minor = .Page.Params.Version.Minor }}
{{- $patch = .Page.Params.Version.Patch }}
{{- end}}
{{- $value = print $major "." $minor "." $patch }}
{{- $value }}