Quartz integration¶
Quartz is a static site generator for Obsidian vaults, built with TypeScript and Preact. Use seoslug at build time to add JSON-LD structured data, Open Graph, Twitter Card, canonical, and robots metadata to every page.
Built-in builder (recommended)¶
seoslug ships a QuartzBuilder class at seoslug.contrib.quartz that handles content scanning, YAML frontmatter parsing, SEO payload building, and frontmatter injection in a single call:
from seoslug.contrib.quartz import QuartzBuilder
builder = QuartzBuilder(
content_dir="content",
site_url="https://yoursite.com",
site_name="Your Site",
)
builder.build()
The class accepts all SEOConfig fields as keyword arguments and exposes dry_run and debug_dir modes. The debug_dir option (e.g. debug_dir=".seo-debug") writes a per-page JSON file with the SEO payload dict, matching the Zensical extension behaviour.
See the QuartzBuilder API reference for all options.
Manual pre-build script (alternative)¶
If you prefer full control over the generation logic, write a custom script that imports seoslug directly.
How it works¶
A pre-build Python script iterates all Markdown files in your vault, builds an SEO payload for each page using seoslug, and writes the rendered HTML into the file's frontmatter under a seo_html key. A small modification to Quartz's Head.tsx component checks for this key and injects the tags when present, falling back to Quartz's built-in generation otherwise.
Setup¶
1. Write the SEO generation script¶
Create scripts/generate_seo.py. This script handles Obsidian-flavored Markdown (frontmatter delimited with ---) and maps file paths to page URLs:
"""Pre-build: generates SEO frontmatter for Quartz content 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
CONTENT_DIR = PROJECT_ROOT / "content"
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}/static/og-image.png",
width=1200,
height=630,
),
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(CONTENT_DIR)
stem = Path(*rel.parts).with_suffix("")
if stem.name == "index":
stem = stem.parent
if not str(stem):
return "/"
return "/" + str(stem) + "/"
def first_heading(text: str) -> str | None:
match = re.search(r"^#\s+(.+)", text, re.MULTILINE)
if not match:
return None
return re.sub(r"[`*_]", "", match.group(1).strip())
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(CONTENT_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 meta.get("socialDescription") 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. Modify the Head component¶
Edit quartz/components/Head.tsx to check for seo_html in the page frontmatter. When present, inject it directly. Otherwise, fall back to the default Quartz head logic:
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
export default (() => {
const Head: QuartzComponent = ({ fileData, externalResources, cfg }: QuartzComponentProps) => {
const { frontmatter } = fileData
const baseDir = cfg.baseUrl ?? ""
// If seoslug has pre-rendered the full SEO block, inject it directly
if (frontmatter?.seo_html) {
return (
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href={baseDir} />
<style>{cfg.theme?.cdnCaching ? "" : ""}</style>
<dangerouslySetInnerHTML html={frontmatter.seo_html} />
{externalResources.map((resource) => {
if (resource.css) {
return <link rel="stylesheet" href={resource.css} />
}
if (resource.js) {
return <script src={resource.js} />
}
if (resource.additionalHead) {
return <>{resource.additionalHead}</>
}
})}
</head>
)
}
// Fall through to default Quartz head generation
const title = frontmatter?.title ?? "Untitled"
const description = frontmatter?.socialDescription ?? frontmatter?.description ?? ""
const socialUrl = frontmatter?.permalink ?? `https://${baseDir}${fileData.slug!}`
return (
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="og:site_name" content={cfg.pageTitle} />
<meta property="og:title" content={title} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta property="og:description" content={description} />
<meta property="og:image:alt" content={description} />
<meta property="twitter:domain" content={baseDir} />
<meta property="og:url" content={socialUrl} />
<meta property="twitter:url" content={socialUrl} />
<meta name="description" content={description} />
<base href={baseDir} />
{externalResources.map((resource) => {
if (resource.css) {
return <link rel="stylesheet" href={resource.css} />
}
if (resource.js) {
return <script src={resource.js} />
}
if (resource.additionalHead) {
return <>{resource.additionalHead}</>
}
})}
</head>
)
}
return Head
}) satisfies QuartzComponentConstructor
When frontmatter.seo_html is present, seoslug provides the complete set of tags (title, description, canonical, robots, Open Graph, Twitter Card, JSON-LD). Pages without it use Quartz's default generation.
3. Add to package.json¶
Add the pre-build step to your package.json scripts:
{
"scripts": {
"build:seo": "python scripts/generate_seo.py",
"build": "npm run build:seo && quartz build",
"serve": "npm run build:seo && quartz build --serve"
}
}
4. Update the CI workflow¶
Add the Python setup and seoslug install to your deployment pipeline:
- uses: actions/setup-python@v5
with:
python-version: '3.x'
- run: pip install "seoslug>=2.0.1" pyyaml
- run: python scripts/generate_seo.py
- run: npm ci
- run: npm run build
What gets added¶
Quartz generates Open Graph and Twitter Card tags natively. seoslug adds:
<link rel="canonical">for canonical URL normalization<meta name="robots">with index/follow directives<script type="application/ld+json">with schema.org structured data (WebPage, Organization, BreadcrumbList)<meta name="twitter:site">for the Twitter handle<meta property="og:locale">for the content locale- Consistent URL normalization through
SEOConfig.url_policy