API reference¶
Complete reference for all public classes, functions, and dataclasses.
Public imports¶
from seoslug import (
SEOConfig,
URLPolicy,
SEOEntity,
SEOEntityBuilder,
SEOOverrides,
OGImage,
Breadcrumb,
FAQItem,
Robots,
SchemaRegistry,
normalize_public_url,
normalize_path,
build_seo_payload,
build_seo_payload_dict,
build_seo_payload_async,
from_blog_post,
from_product,
from_faq,
# Exceptions
SEOError,
SEOConfigError,
SEOEntityError,
URLPolicyError,
SEOPayloadError,
# Hooks
hook,
register_hook,
clear_hooks,
get_registered_hooks,
run_hooks,
)
build_seo_payload¶
def build_seo_payload(
entity: SEOEntity,
route_path: str,
config: SEOConfig,
overrides: SEOOverrides | None = None,
) -> SEOPayload
The main entry point. Returns a SEOPayload dataclass with every SEO
field resolved through the fallback chain.
| Parameter | Description |
|---|---|
entity |
Content entity with title, excerpt, images, schema fields |
route_path |
URL path for this content (e.g. /blog/my-post) |
config |
Site-wide configuration (host, URL policy, defaults) |
overrides |
Optional per-call overrides for any field |
Raises ValueError if any input fails validation. Emits warnings.warn
for best-practice violations when config.emit_warnings is True.
build_seo_payload_dict¶
def build_seo_payload_dict(
entity: SEOEntity,
route_path: str,
config: SEOConfig,
overrides: SEOOverrides | None = None,
) -> dict
Same as build_seo_payload but returns a plain dict instead of a
SEOPayload dataclass. Convenient for template engines and JSON
serialization pipelines that expect a raw dictionary.
payload_dict = build_seo_payload_dict(entity, "/post", config)
payload_dict["title"] # "My Post"
payload_dict["og"]["type"] # "article"
build_seo_payload_async¶
from seoslug.async_builder import build_seo_payload_async
async def build_seo_payload_async(
entity: SEOEntity,
route_path: str,
config: SEOConfig,
overrides: SEOOverrides | None = None,
executor: ThreadPoolExecutor | None = None,
) -> SEOPayload
Async version of build_seo_payload. Offloads the synchronous builder
to a thread pool executor so it does not block the asyncio event loop.
| Parameter | Description |
|---|---|
executor |
Optional ThreadPoolExecutor. Defaults to a module-level executor with 4 workers |
Use set_executor(None) to reset to the default 4-worker executor.
normalize_public_url¶
Normalizes a URL or path to a canonical absolute URL. Enforces the canonical host, HTTPS scheme, trailing slash policy, and query filtering.
| Parameter | Description |
|---|---|
url_or_path |
Absolute URL or relative path (e.g. /blog/post or https://...) |
config |
SEOConfig with canonical host, URL policy, and public base URL |
Accepts both absolute URLs and relative paths. Relative paths are resolved
against config.public_base_url. If public_base_url has a sub-path
(e.g. /blog/), it is prepended to the route path.
Raises SEOPayloadError for empty input, malformed URLs, or invalid schemes.
normalize_path¶
Normalizes a URL path without host or scheme. Applies lowercase,
trailing slash, and duplicate-slash rules. Always returns a path
starting with /.
| Parameter | Description |
|---|---|
path |
URL path string (e.g. "/Blog//Post") |
policy |
URLPolicy controlling normalization behavior |
Raises URLPolicyError for non-string input.
SEOConfig¶
@dataclass(slots=True)
class SEOConfig:
canonical_host: str
public_base_url: str
url_policy: URLPolicy
default_robots: str | Robots = "index,follow"
default_og_image: str | OGImage | None = None
site_name: str | None = None
title_template: str | None = "{title}"
search_robots: str | Robots = "noindex,follow"
schema_type_map: dict[str, str | None]
auto_generate_schema: bool = True
publisher_name: str | None = None
publisher_logo: str | None = None
locale: str | None = None
locale_alternate: list[str] | None = None
twitter_site: str | None = None
schema_registry: SchemaRegistry | None = None
hooks: HookRegistry | None = None
emit_warnings: bool = False
Site-wide configuration for SEO metadata generation. Validates all input
in __post_init__.
| Field | Type | Default | Description |
|---|---|---|---|
canonical_host |
str |
required | Hostname for all canonical URLs (host-only, no scheme/path/port) |
public_base_url |
str |
required | Full absolute URL of deployment (used as base for URL resolution) |
url_policy |
URLPolicy |
required | URL normalization rules |
default_robots |
str \| Robots |
"index,follow" |
Fallback robots directive |
default_og_image |
str \| OGImage \| None |
None |
Fallback OG image |
site_name |
str \| None |
None |
og:site_name value |
title_template |
str \| None |
"{title}" |
Template string; must contain {title} |
search_robots |
str \| Robots |
"noindex,follow" |
Robots for entity_type="search" |
schema_type_map |
dict[str, str \| None] |
built-in map | Maps entity_type to schema.org type |
auto_generate_schema |
bool |
True |
Auto-generate JSON-LD schema |
publisher_name |
str \| None |
None |
Publisher name for JSON-LD |
publisher_logo |
str \| None |
None |
Publisher logo URL for JSON-LD |
locale |
str \| None |
None |
og:locale value |
locale_alternate |
list[str] \| None |
None |
og:locale:alternate values |
twitter_site |
str \| None |
None |
twitter:site value |
schema_registry |
SchemaRegistry \| None |
None |
Custom schema generators |
hooks |
HookRegistry \| None |
None |
Instance-scoped hooks for this config; global hooks run first |
emit_warnings |
bool |
False |
Emit warnings.warn for validation issues |
Default schema_type_map:
{
"post": "Article",
"page": "WebPage",
"video": "VideoObject",
"home": "WebPage",
"taxonomy": "CollectionPage",
"search": "SearchResultsPage",
"product": "Product",
"organization": "Organization",
"local_business": "LocalBusiness",
"faq": "FAQPage",
}
Validation rules:
canonical_hostmust be host-only (no scheme, path, port, query, or trailing dot). IPv6 not supported.public_base_urlmust be an absolutehttporhttpsURL.title_templatemust include the{title}placeholder.locale_alternateitems are deduplicated and stripped.url_policymust be aURLPolicyinstance.
URLPolicy¶
@dataclass(slots=True)
class URLPolicy:
enforce_https: bool = True
lowercase_paths: bool = True
trailing_slash: Literal["always", "never", "preserve"] = "never"
collapse_duplicate_slashes: bool = True
strip_tracking_params: bool = True
allowed_query_params: list[str] = field(default_factory=list)
Controls URL normalization behavior.
| Field | Type | Default | Description |
|---|---|---|---|
enforce_https |
bool |
True |
Upgrade all canonical URLs to HTTPS |
lowercase_paths |
bool |
True |
Lowercase URL paths |
trailing_slash |
Literal |
"never" |
Trailing slash policy: "always", "never", or "preserve" |
collapse_duplicate_slashes |
bool |
True |
Collapse // to / in paths |
strip_tracking_params |
bool |
True |
Remove tracking parameters (uses detrack if available, built-in regex otherwise) |
allowed_query_params |
list[str] |
[] |
Query params to preserve (all others stripped when non-empty) |
Validation:
trailing_slashmust be one of"always","never","preserve".allowed_query_paramsis deduplicated; empty strings are removed.
SEOEntity¶
@dataclass(slots=True)
class SEOEntity:
entity_type: Literal["home", "post", "page", "video", "taxonomy", "search", "other",
"product", "organization", "local_business", "faq"]
slug: str | None = None
title: str | None = None
excerpt: str | None = None
body_html: str | None = None
status: str | None = None
featured_image: str | OGImage | None = None
published_at: str | None = None
updated_at: str | None = None
author_name: str | None = None
breadcrumbs: list[Breadcrumb] | None = None
sku: str | None = None
price: str | None = None
price_currency: str | None = None
availability: str | None = None
same_as: list[str] | None = None
address: str | None = None
faq_items: list[FAQItem] | None = None
Input data for a content entity.
| Field | Type | Used in | Description |
|---|---|---|---|
entity_type |
Literal |
meta + schema | Content type; determines OG type and schema mapping |
slug |
str \| None |
metadata only | URL slug (informational, not used in output) |
title |
str \| None |
meta + schema | Page title |
excerpt |
str \| None |
meta | Meta description source |
body_html |
str \| None |
meta | Body HTML for auto-generated description snippet |
status |
str \| None |
meta | Content status; "published" sets robots to index,follow |
featured_image |
str \| OGImage \| None |
meta + schema | Featured image (string URL or structured OGImage) |
published_at |
str \| None |
schema only | ISO date for datePublished |
updated_at |
str \| None |
schema only | ISO date for dateModified |
author_name |
str \| None |
schema only | Author name for JSON-LD |
breadcrumbs |
list[Breadcrumb] \| None |
schema only | Breadcrumb trail; generates BreadcrumbList |
sku |
str \| None |
schema only | SKU for Product schema |
price |
str \| None |
schema only | Price for Product schema |
price_currency |
str \| None |
schema only | ISO 4217 currency code |
availability |
str \| None |
schema only | Schema.org availability (e.g. "InStock") |
same_as |
list[str] \| None |
schema only | SameAs URLs for Organization |
address |
str \| None |
schema only | Street address for LocalBusiness |
faq_items |
list[FAQItem] \| None |
schema only | Q&A pairs for FAQPage schema |
Validation:
entity_typemust be one of the 11 accepted values.- All optional string fields are normalized (stripped; empty strings
become
None). featured_imageaccepts a string URL orOGImageinstance.same_asis deduplicated; empty strings removed.
SEOOverrides¶
@dataclass(slots=True)
class SEOOverrides:
meta_title: str | None = None
meta_description: str | None = None
canonical_url: str | None = None
robots: str | Robots | None = None
og_title: str | None = None
og_description: str | None = None
og_image: str | OGImage | None = None
twitter_card: str | None = None
twitter_title: str | None = None
twitter_description: str | None = None
twitter_image: str | OGImage | None = None
schema_jsonld: dict | list[dict] | None = None
omit_schema: bool = False
skip_title_template: bool = False
twitter_creator: str | None = None
og_audio: str | None = None
og_video: str | None = None
Per-entity overrides for SEO metadata. Takes highest priority in each fallback chain.
| Field | Type | Default | Description |
|---|---|---|---|
meta_title |
str \| None |
None |
Override page title |
meta_description |
str \| None |
None |
Override meta description |
canonical_url |
str \| None |
None |
Override canonical URL (absolute) |
robots |
str \| Robots \| None |
None |
Override robots directive |
og_title |
str \| None |
None |
Override og:title |
og_description |
str \| None |
None |
Override og:description |
og_image |
str \| OGImage \| None |
None |
Override og:image (string URL or structured) |
twitter_card |
str \| None |
None |
Override twitter:card type |
twitter_title |
str \| None |
None |
Override twitter:title |
twitter_description |
str \| None |
None |
Override twitter:description |
twitter_image |
str \| OGImage \| None |
None |
Override twitter:image |
schema_jsonld |
dict \| list[dict] \| None |
None |
Override entire JSON-LD schema |
omit_schema |
bool |
False |
Remove schema_jsonld from output |
skip_title_template |
bool |
False |
Bypass title_template for this entity |
twitter_creator |
str \| None |
None |
Set twitter:creator |
og_audio |
str \| None |
None |
Set og:audio URL |
og_video |
str \| None |
None |
Set og:video URL |
Usage:
overrides = SEOOverrides(
meta_title="Custom Title",
skip_title_template=True,
twitter_creator="@author",
omit_schema=True,
)
OGImage¶
@dataclass(slots=True)
class OGImage:
url: str
width: int | None = None
height: int | None = None
alt: str | None = None
Structured Open Graph image with optional dimensions and alt text.
| Field | Type | Description |
|---|---|---|
url |
str |
Image URL (required, non-empty) |
width |
int \| None |
og:image:width |
height |
int \| None |
og:image:height |
alt |
str \| None |
og:image:alt |
Breadcrumb¶
A single breadcrumb trail entry.
| Field | Type | Description |
|---|---|---|
name |
str |
Display name (required, non-empty) |
url |
str |
URL or path (required, non-empty; normalized when generating BreadcrumbList) |
FAQItem¶
A single FAQ question-and-answer pair.
| Field | Type | Description |
|---|---|---|
question |
str |
Question text (required, non-empty) |
answer |
str |
Answer text (required, non-empty) |
Robots¶
@dataclass(slots=True)
class Robots:
index: bool = True
follow: bool = True
max_snippet: int | None = None
max_image_preview: str | None = None
max_video_preview: int | None = None
Structured robots directive. Serializes to the standard robots meta content
string via .serialize().
| Field | Type | Default | Description |
|---|---|---|---|
index |
bool |
True |
index or noindex |
follow |
bool |
True |
follow or nofollow |
max_snippet |
int \| None |
None |
max-snippet:N |
max_image_preview |
str \| None |
None |
max-image-preview:VALUE |
max_video_preview |
int \| None |
None |
max-video-preview:N |
robots = Robots(index=True, follow=False, max_snippet=160)
robots.serialize() # "index,nofollow,max-snippet:160"
SchemaRegistry¶
class SchemaRegistry:
def __init__(self) -> None: ...
def register(self, schema_type: str, generator: SchemaGenerator) -> None: ...
def unregister(self, schema_type: str) -> None: ...
def get(self, schema_type: str) -> SchemaGenerator | None: ...
Registry for user-registered schema generators.
from seoslug import SchemaRegistry
registry = SchemaRegistry()
registry.register("Podcast", lambda entity, config, canonical, title, description, og_image: {
"@context": "https://schema.org",
"@type": "Podcast",
"name": title,
"url": canonical,
})
Register a generator for any schema type name. When build_schema
encounters a type registered here, it calls the generator instead of the
built-in builder.
| Method | Parameters | Description |
|---|---|---|
register |
schema_type: str, generator: SchemaGenerator |
Register a generator for a schema type |
unregister |
schema_type: str |
Remove a registered generator |
get |
schema_type: str |
Look up a generator by type (returns None if not found) |
SchemaGenerator protocol:
class SchemaGenerator(Protocol):
def __call__(
self,
entity: SEOEntity,
config: SEOConfig,
canonical: str,
title: str,
description: str | None,
og_image: str | None,
) -> dict | None: ...
SEOEntityBuilder¶
class SEOEntityBuilder:
def __init__(self) -> None: ...
def entity_type(self, value: str) -> SEOEntityBuilder: ...
def slug(self, value: str) -> SEOEntityBuilder: ...
def title(self, value: str) -> SEOEntityBuilder: ...
def excerpt(self, value: str) -> SEOEntityBuilder: ...
def body_html(self, value: str) -> SEOEntityBuilder: ...
def status(self, value: str) -> SEOEntityBuilder: ...
def featured_image(self, value: str | OGImage) -> SEOEntityBuilder: ...
def published_at(self, value: str) -> SEOEntityBuilder: ...
def updated_at(self, value: str) -> SEOEntityBuilder: ...
def author_name(self, value: str) -> SEOEntityBuilder: ...
def breadcrumbs(self, value: list[Breadcrumb]) -> SEOEntityBuilder: ...
def sku(self, value: str) -> SEOEntityBuilder: ...
def price(self, value: str | float) -> SEOEntityBuilder: ...
def price_currency(self, value: str) -> SEOEntityBuilder: ...
def availability(self, value: str) -> SEOEntityBuilder: ...
def same_as(self, value: list[str]) -> SEOEntityBuilder: ...
def address(self, value: str) -> SEOEntityBuilder: ...
def faq_items(self, value: list[FAQItem]) -> SEOEntityBuilder: ...
def build(self) -> SEOEntity: ...
Fluent builder for SEOEntity. Every setter returns self for chaining.
entity = (
SEOEntityBuilder()
.entity_type("product")
.title("Widget")
.sku("W-001")
.price(29.99)
.availability("InStock")
.build()
)
All methods accept the same types as the corresponding SEOEntity field.
price() accepts str or float (converted to str). .build() raises
ValueError if entity_type was not set.
Factory functions¶
Convenience factories that create SEOEntity instances for common content
types.
from_blog_post¶
def from_blog_post(
title: str,
body_html: str,
slug: str | None = None,
author: str = "",
excerpt: str | None = None,
breadcrumbs: list[dict] | None = None,
) -> SEOEntity
Creates a blog post entity. Sets entity_type="post" and status="published".
| Parameter | Type | Description |
|---|---|---|
title |
str |
Post title |
body_html |
str |
Full HTML body (used for auto-generated description) |
slug |
str \| None |
URL slug (optional) |
author |
str |
Author name (optional, empty string becomes None) |
excerpt |
str \| None |
Explicit excerpt; falls back to body snippet when None |
breadcrumbs |
list[dict] \| None |
List of {"name": ..., "url": ...} dicts |
entity = from_blog_post(
title="Hello World",
body_html="<p>Content</p>",
author="Jane",
breadcrumbs=[{"name": "Blog", "url": "/blog"}],
)
from_product¶
def from_product(
name: str,
sku: str,
price: str | float,
currency: str = "USD",
availability: str = "InStock",
description: str | None = None,
breadcrumbs: list[dict] | None = None,
) -> SEOEntity
Creates a product entity. Sets entity_type="product" and
status="published".
| Parameter | Type | Description |
|---|---|---|
name |
str |
Product name |
sku |
str |
Stock-keeping unit |
price |
str \| float |
Price as string ("29.99") or numeric |
currency |
str |
ISO 4217 currency code (default "USD") |
availability |
str |
Schema.org availability (default "InStock") |
description |
str \| None |
Optional product description (excerpt) |
breadcrumbs |
list[dict] \| None |
List of {"name": ..., "url": ...} dicts |
from_faq¶
def from_faq(
questions: list[dict[str, str]],
title: str = "FAQ",
description: str | None = None,
breadcrumbs: list[dict] | None = None,
) -> SEOEntity
Creates an FAQ page entity. Sets entity_type="faq" and
status="published".
| Parameter | Type | Description |
|---|---|---|
questions |
list[dict[str, str]] |
List of {"question": "...", "answer": "..."} dicts |
title |
str |
Page title (default "FAQ") |
description |
str \| None |
Optional meta description |
breadcrumbs |
list[dict] \| None |
List of {"name": ..., "url": ...} dicts |
Exceptions¶
All seoslug exceptions inherit from SEOError, which itself inherits from both Exception and ValueError. This means existing code catching ValueError will continue to work.
SEOError¶
Base exception for all seoslug errors. Inherits from Exception and ValueError.
SEOConfigError¶
Raised when an SEOConfig or URLPolicy field fails validation.
For example: canonical_host contains a scheme or path, title_template is missing the {title} placeholder, or a required field is empty.
SEOEntityError¶
Raised when an SEOEntity or SEOOverrides field fails validation.
For example: entity_type is not a valid type, schema_jsonld is an invalid type, or a string field receives a non-string value.
URLPolicyError¶
Raised when a URLPolicy field fails validation or a path argument is not a string.
For example: trailing_slash is set to an invalid value, or normalize_path receives non-string input.
SEOPayloadError¶
Raised during payload generation or text processing.
For example: normalize_public_url receives empty or malformed input, or html_to_text receives a non-string argument.
Hooks¶
Hooks let you register callback functions that transform the SEO payload after it is built. See the Hooks guide for full documentation.
hook¶
Decorator that registers a function to run when a named hook point is triggered.
from seoslug import hook
@hook("post_process")
def add_breadcrumb(payload, entity, config):
payload["breadcrumb"] = {"@type": "BreadcrumbList", ...}
return payload
register_hook¶
Programmatic equivalent of the @hook decorator.
clear_hooks¶
Remove all hooks registered under name, or all hooks if name is None.
get_registered_hooks¶
Return a copy of all registered hooks keyed by hook point name. Useful for inspection and testing.
run_hooks¶
Run all hooks registered under name in registration order. Each hook receives the current payload and must return the (possibly modified) payload. Typically not called directly; build_seo_payload already calls run_hooks("post_process", ...) after building the payload.
SEOPayload¶
@dataclass(eq=False)
class SEOPayload:
title: str
description: str
canonical: str
robots: str
og: OGPayload
twitter: TwitterPayload
schema_jsonld: dict | list[dict] | None = None
Return type of build_seo_payload. Supports attribute access,
dict-style access, to_dict(), and HTML rendering.
Which access pattern to use:
| Pattern | For |
|---|---|
payload.title (attribute) |
Type-safe Python code with IDE autocomplete |
payload["title"] (bracket) |
Template engines that expect dicts |
payload["og"]["image:width"] (colon key) |
Template loops over OG/Twitter tags: key matches the HTML attribute |
payload.to_dict() |
JSON serialization, API responses, caching |
build_seo_payload_dict() |
Bypass the dataclass when you only need a dict |
| Field | Type | Description |
|---|---|---|
title |
str |
Resolved page title (with template applied) |
description |
str |
Resolved meta description |
canonical |
str |
Fully normalized canonical URL |
robots |
str |
Robots meta content string |
og |
OGPayload |
Nested Open Graph payload |
twitter |
TwitterPayload |
Nested Twitter Card payload |
schema_jsonld |
dict \| list[dict] \| None |
JSON-LD structured data (single dict or array of dicts) |
Methods:
| Method | Returns | Description |
|---|---|---|---|
| to_dict() | dict | Converts to a plain dict; drops None values from nested payloads |
| render_html() | str | Render full <head> HTML snippet (composes all methods below) |
| render_opengraph() | str | Render only <meta property="og:*"> tags |
| render_twitter() | str | Render only <meta name="twitter:*"> tags |
| render_jsonld() | str | Render only <script type="application/ld+json"> block |
| hash() | str | Deterministic SHA-256 hex digest of the serialized payload |
| etag() | str | Quoted hex string suitable for the HTTP ETag header |
Dict-style methods: get(key), keys(), __contains__ (checks value is
not None).
OGPayload¶
@dataclass(eq=False)
class OGPayload:
type: str
title: str | None
description: str | None
url: str | None
image: str | None
image_width: int | None = None
image_height: int | None = None
image_alt: str | None = None
site_name: str | None = None
locale: str | None = None
locale_alternate: list[str] | None = None
audio: str | None = None
video: str | None = None
Nested Open Graph payload. Supports dict-style access with colon-separated
keys: payload["og"]["image:width"].
| Field | Dict key | Description |
|---|---|---|
type |
type |
OG type (article or website) |
title |
title |
og:title |
description |
description |
og:description |
url |
url |
og:url (same as canonical) |
image |
image |
og:image URL |
image_width |
image:width |
og:image:width |
image_height |
image:height |
og:image:height |
image_alt |
image:alt |
og:image:alt |
site_name |
site_name |
og:site_name |
locale |
locale |
og:locale |
locale_alternate |
locale:alternate |
og:locale:alternate |
audio |
audio |
og:audio URL |
video |
video |
og:video URL |
Methods: to_dict(), get(key), keys(), __contains__.
TwitterPayload¶
@dataclass(eq=False)
class TwitterPayload:
card: str
title: str | None
description: str | None
image: str | None
image_alt: str | None = None
site: str | None = None
creator: str | None = None
Nested Twitter Card payload. Supports dict-style access with colon-separated
keys: payload["twitter"]["image:alt"].
| Field | Dict key | Description |
|---|---|---|
card |
card |
twitter:card type |
title |
title |
twitter:title |
description |
description |
twitter:description |
image |
image |
twitter:image URL |
image_alt |
image:alt |
twitter:image:alt |
site |
site |
twitter:site |
creator |
creator |
twitter:creator |
Methods: to_dict(), get(key), keys(), __contains__.
TypedDict variants¶
For type-checking compatibility with code that expects plain dicts:
These are TypedDict versions of the payload dataclasses. Use them for
type annotations when working with build_seo_payload_dict or
payload.to_dict().