Zensical integration¶
Zensical is a modern static site generator with a Rust core and Python Markdown rendering. Use seoslug at build time to inject Open Graph, Twitter Card, JSON-LD, canonical, and robots metadata into every page.
The seoslug documentation site itself uses this integration. The source is available in the seoslug repository.
How it works¶
seoslug ships a built-in Zensical Markdown extension at seoslug.contrib.zensical. Register it in your zensical.toml and it runs during Zensical's Markdown rendering pipeline. No pre-build step, no files modified on disk, no extra CI commands.
A custom template override (overrides/main.html) intercepts the site_meta block and outputs the seoslug-rendered HTML directly, replacing the default <title>, <meta>, <link>, and <script> tags.
The pattern works with any MkDocs-compatible theme because the block override lives in your own overrides/ directory.
Setup (inline extension)¶
This is the recommended approach. The extension runs during zensical build with no pre-build step.
1. Register the extension¶
Add seoslug.contrib.zensical to your zensical.toml under [project.markdown_extensions]:
[project.markdown_extensions]
tables = {}
"seoslug.contrib.zensical" = {
canonical_host = "yoursite.com",
public_base_url = "https://yoursite.com/",
site_name = "Your Site",
title_template = "{title} - Your Site",
default_og_image = "https://yoursite.com/icon.png",
publisher_name = "Your Name",
locale = "en_US",
twitter_site = "@yourhandle",
auto_generate_schema = true,
debug_dir = ".seo-debug",
}
The extension accepts these configuration keys:
| Key | Required | Default | Description |
|---|---|---|---|
canonical_host |
Yes | -- | Hostname for canonical URLs (no scheme) |
public_base_url |
Yes | -- | Full deployment URL with scheme |
site_name |
No | None |
Open Graph site name |
title_template |
No | "{title}" |
Title template with {title} placeholder |
default_og_image |
No | None |
Default OG image URL |
publisher_name |
No | None |
Publisher name for JSON-LD |
locale |
No | None |
Content locale (e.g. en_US) |
twitter_site |
No | None |
Twitter handle for twitter:site |
auto_generate_schema |
No | true |
Auto-generate JSON-LD schema |
debug_dir |
No | None |
Directory for debug JSON files |
2. Create the template override¶
Create overrides/main.html that extends the base template and overrides the site_meta block:
{% extends "base.html" %}
{% block site_meta %}
{% if page.meta and page.meta._seo_head %}
{{ page.meta._seo_head | safe }}
{% else %}
{{ super() }}
{% endif %}
{% endblock %}
The extension stores the rendered HTML in page.meta["_seo_head"]. The template outputs it when present and falls back to the theme's default otherwise.
3. Configure the theme directory¶
Add custom_dir to your theme in zensical.toml:
4. Build¶
That is all. Run zensical build as normal:
# .github/workflows/deploy-docs.yml
- run: pip install "seoslug>=2.0.1" zensical
- run: zensical build --clean
No pre-build script, no frontmatter injection, no extra CI step.
Debug output¶
When debug_dir is set, the extension writes one JSON file per page with the full payload as payload.to_dict():
{
"title": "Getting Started - Your Site",
"canonical": "https://yoursite.com/getting-started/",
"robots": "index,follow",
"og": { "type": "website", "title": "...", ... },
"twitter": { "card": "summary_large_image", ... },
"schema_jsonld": { "@context": "https://schema.org", ... }
}
Add .seo-debug/ to your .gitignore; these files are for local inspection, not committed.
Alternative: pre-build script¶
If you prefer persisted frontmatter or need to inspect the SEO data before the build, use the pre-build script approach instead of the inline extension.
1. Write the SEO generation script¶
Create scripts/generate_seo.py:
"""Pre-build: generates SEO frontmatter for docs pages via seoslug."""
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
from seoslug import (
SEOConfig,
URLPolicy,
SEOEntity,
OGImage,
Robots,
build_seo_payload,
)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DOCS_DIR = PROJECT_ROOT / "docs"
SITE_URL = "https://yoursite.com"
SITE_NAME = "Your Site"
SEO_CONFIG = SEOConfig(
canonical_host="yoursite.com",
public_base_url=SITE_URL,
url_policy=URLPolicy(
enforce_https=True,
lowercase_paths=True,
trailing_slash="always",
),
site_name=SITE_NAME,
default_og_image=OGImage(
url=f"{SITE_URL}/assets/icon.png",
width=128,
height=128,
),
publisher_name="Your Name",
title_template="{title} - Your Site",
default_robots=Robots(index=True, follow=True),
locale="en_US",
twitter_site="@yourhandle",
)
FM_RE = re.compile(
r"^-{3}[ \r\t]*?\n(.*?\r?\n)(?:\.{3}|-{3})[ \r\t]*\n",
re.UNICODE | re.DOTALL,
)
def route_path_from_file(filepath: Path) -> str:
rel = filepath.relative_to(DOCS_DIR)
parts = list(rel.parts)
if parts[-1] == "index.md":
parts.pop()
if not parts:
return "/"
return "/" + "/".join(parts) + "/"
stem = Path(*parts).with_suffix("")
return "/" + str(stem) + "/"
def first_heading(text: str) -> str | None:
match = re.search(r"^#\s+(.+)", text, re.MULTILINE)
if not match:
return None
title = match.group(1).strip()
title = re.sub(r"[`*_]", "", title)
return title
def extract_excerpt(body: str, max_chars: int = 160) -> str:
body = re.sub(r"^#\s+.*\n?", "", body, count=1).strip()
para = re.split(r"\n\s*\n", body, maxsplit=1)[0].strip()
clean = re.sub(r"[`*_\[\]()>|#{}]", "", para)
clean = re.sub(r"\s+", " ", clean).strip()
if not clean:
return ""
if len(clean) <= max_chars:
return clean
break_at = clean.rfind(" ", 0, max_chars)
return clean[:break_at] + "..." if break_at > 0 else clean[:max_chars] + "..."
def read_meta_and_body(filepath: Path) -> tuple[dict, str]:
text = filepath.read_text(encoding="utf-8")
meta: dict = {}
body = text
if text.startswith("---"):
match = FM_RE.match(text)
if match:
try:
parsed = yaml.safe_load(match.group(1))
if isinstance(parsed, dict):
meta = parsed
except Exception:
pass
body = text[match.end():].lstrip("\n")
return meta, body
def main() -> int:
changed = 0
for md_file in sorted(DOCS_DIR.rglob("*.md")):
rel_path = md_file.relative_to(PROJECT_ROOT)
meta, body = read_meta_and_body(md_file)
route = route_path_from_file(md_file)
title = meta.get("title") or first_heading(body) or SITE_NAME
description = meta.get("description") or extract_excerpt(body)
entity_type = "home" if route == "/" else "page"
entity = SEOEntity(
entity_type=entity_type,
title=title,
excerpt=description or None,
)
payload = build_seo_payload(entity, route, SEO_CONFIG)
if payload is None:
continue
new_html = payload.render_html()
old_html = meta.get("seo_html", "")
if new_html == old_html:
continue
meta["seo_html"] = new_html
fm_dump = yaml.dump(meta, default_flow_style=False, allow_unicode=True, sort_keys=False)
md_file.write_text(f"---\n{fm_dump}---\n\n{body}", encoding="utf-8")
print(f" WRITE {rel_path}")
changed += 1
print(f"\nDone: {changed} changed")
return 0
if __name__ == "__main__":
sys.exit(main())
2. Create the template override¶
Create overrides/main.html that extends the Zensical base template and overrides the site_meta block:
{% extends "base.html" %}
{% block site_meta %}
{% if page.meta and page.meta.seo_html %}
{{ page.meta.seo_html | safe }}
{% else %}
{{ super() }}
{% endif %}
{% endblock %}
3. Configure Zensical¶
Add custom_dir to your theme in zensical.toml:
4. Update the CI workflow¶
Add seoslug and the pre-build script to your deployment pipeline:
- run: pip install "seoslug>=2.0.1" zensical
- run: python scripts/generate_seo.py
- run: zensical build --clean
The script runs before zensical build, so the frontmatter is already populated when Zensical renders the site.
How it looks in the frontmatter¶
After the script runs, each Markdown file has a seo_html key in its frontmatter:
---
title: Getting Started
description: How to install and configure seoslug.
seo_html: "<title>Getting Started - Your Site</title>\n<meta name=\"description\" ...>"
---
The template override injects this HTML into the <head> at build time. No runtime overhead, no JavaScript, no extra dependencies.