feat(nav): add anchor TOC in sidebar for toc-enabled pages

- Pass extracted anchors through template context
- Render anchor links under active page when page.toc is true
- Hierarchical indentation by heading level (h2-h6)
- CSS styling: smaller font, muted color, border-left indicator
- Add toc: true to templates.md as example
This commit is contained in:
Timothy DeHerrera
2026-02-01 09:37:01 -07:00
parent a59b8ff2ab
commit dcc98dccef
5 changed files with 76 additions and 6 deletions

View File

@@ -2,6 +2,7 @@
title: Tera Templates
description: Customizable templates without recompilation
weight: 1
toc: true
---
sukr uses [Tera](https://tera.netlify.app/), a Jinja2-like templating engine. Templates are loaded at runtime, so you can modify them without recompiling sukr.

39
docs/static/style.css vendored
View File

@@ -111,6 +111,45 @@ body {
font-size: 0.9rem;
}
/* Anchor navigation (TOC in sidebar) */
.sidebar .nav-anchors {
margin-left: 1rem;
padding-left: 0.5rem;
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 0.125rem;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.sidebar .nav-anchors .anchor-link {
font-size: 0.8rem;
color: var(--fg-muted);
padding: 0.125rem 0;
}
.sidebar .nav-anchors .anchor-link:hover {
color: var(--accent);
}
/* Indent anchors by heading level */
.sidebar .nav-anchors .level-3 {
margin-left: 0.5rem;
}
.sidebar .nav-anchors .level-4 {
margin-left: 1rem;
}
.sidebar .nav-anchors .level-5 {
margin-left: 1.5rem;
}
.sidebar .nav-anchors .level-6 {
margin-left: 2rem;
}
.sidebar-footer {
margin-top: auto;
padding-top: 1rem;

View File

@@ -38,11 +38,25 @@
{% set section_prefix = item.path | replace(from="index.html", to="") %}
{% set is_current_section = page_path is starting_with(section_prefix) %}
<a href="{{ prefix }}{{ item.path }}" {% if page_path==item.path %}class="active" {% endif %}>{{ item.label }}</a>
{% if page_path == item.path and page.toc and anchors %}
<div class="nav-anchors">
{% for anchor in anchors %}
<a href="#{{ anchor.id }}" class="anchor-link level-{{ anchor.level }}">{{ anchor.label }}</a>
{% endfor %}
</div>
{% endif %}
{% if config.nested_nav and item.children %}
<div class="nav-children{% if is_current_section %} expanded{% endif %}">
{% for child in item.children %}
<a href="{{ prefix }}{{ child.path }}" {% if page_path==child.path %}class="active" {% endif %}>{{ child.label
}}</a>
{% if page_path == child.path and page.toc and anchors %}
<div class="nav-anchors">
{% for anchor in anchors %}
<a href="#{{ anchor.id }}" class="anchor-link level-{{ anchor.level }}">{{ anchor.label }}</a>
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}

View File

@@ -142,10 +142,16 @@ fn run(config_path: &Path) -> Result<()> {
// Render individual content pages for all sections
for item in &items {
eprintln!(" processing: {}", item.slug);
let (html_body, _anchors) = render::markdown_to_html(&item.body);
let (html_body, anchors) = render::markdown_to_html(&item.body);
let page_path = format!("/{}", item.output_path(&content_dir).display());
let html =
engine.render_content(item, &html_body, &page_path, &config, &manifest.nav)?;
let html = engine.render_content(
item,
&html_body,
&page_path,
&config,
&manifest.nav,
&anchors,
)?;
write_output(&output_dir, &content_dir, item, html)?;
}
@@ -259,9 +265,10 @@ fn process_pages(
eprintln!("processing: {}", path.display());
let content = Content::from_path(&path, ContentKind::Page)?;
let (html_body, _anchors) = render::markdown_to_html(&content.body);
let (html_body, anchors) = render::markdown_to_html(&content.body);
let page_path = format!("/{}", content.output_path(content_dir).display());
let html = engine.render_page(&content, &html_body, &page_path, config, nav)?;
let html =
engine.render_page(&content, &html_body, &page_path, config, nav, &anchors)?;
write_output(output_dir, content_dir, &content, html)?;
}
@@ -278,13 +285,14 @@ fn generate_homepage(
) -> Result<()> {
eprintln!("generating: homepage");
let (html_body, _anchors) = render::markdown_to_html(&manifest.homepage.body);
let (html_body, anchors) = render::markdown_to_html(&manifest.homepage.body);
let html = engine.render_page(
&manifest.homepage,
&html_body,
"/index.html",
config,
&manifest.nav,
&anchors,
)?;
let out_path = output_dir.join("index.html");

View File

@@ -8,6 +8,7 @@ use tera::{Context, Tera};
use crate::config::SiteConfig;
use crate::content::{Content, NavItem};
use crate::error::{Error, Result};
use crate::render::Anchor;
/// Runtime template engine wrapping Tera.
pub struct TemplateEngine {
@@ -40,11 +41,13 @@ impl TemplateEngine {
page_path: &str,
config: &SiteConfig,
nav: &[NavItem],
anchors: &[Anchor],
) -> Result<String> {
let mut ctx = self.base_context(page_path, config, nav);
ctx.insert("title", &content.frontmatter.title);
ctx.insert("page", &FrontmatterContext::from(&content.frontmatter));
ctx.insert("content", html_body);
ctx.insert("anchors", anchors);
self.render("page.html", &ctx)
}
@@ -56,6 +59,7 @@ impl TemplateEngine {
page_path: &str,
config: &SiteConfig,
nav: &[NavItem],
anchors: &[Anchor],
) -> Result<String> {
let template = content
.frontmatter
@@ -66,6 +70,7 @@ impl TemplateEngine {
ctx.insert("title", &content.frontmatter.title);
ctx.insert("page", &FrontmatterContext::from(&content.frontmatter));
ctx.insert("content", html_body);
ctx.insert("anchors", anchors);
self.render(template, &ctx)
}
@@ -145,6 +150,8 @@ pub struct FrontmatterContext {
pub tags: Vec<String>,
pub weight: Option<i64>,
pub link_to: Option<String>,
/// Enable table of contents (anchor nav in sidebar)
pub toc: bool,
}
impl From<&crate::content::Frontmatter> for FrontmatterContext {
@@ -156,6 +163,7 @@ impl From<&crate::content::Frontmatter> for FrontmatterContext {
tags: fm.tags.clone(),
weight: fm.weight,
link_to: fm.link_to.clone(),
toc: fm.toc.unwrap_or(false),
}
}
}