Hooks and plugins¶
Hooks let you modify the SEO payload after build_seo_payload has assembled it. This is useful for adding custom JSON-LD fields, transforming descriptions per-section, injecting site-wide organization data, or any other post-processing.
How it works¶
After building the payload, build_seo_payload calls run_hooks("post_process", payload, entity, config). Each registered hook receives the payload and must return it (possibly modified). Hooks run in registration order.
Registering a hook¶
Decorator¶
from seoslug import hook
@hook("post_process")
def add_breadcrumb(payload, entity, config):
payload["breadcrumb"] = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [...],
}
return payload
Programmatic¶
from seoslug import register_hook
def add_custom_tag(payload, entity, config):
payload["custom_tag"] = f"generated-at-{entity.slug}"
return payload
register_hook("post_process", add_custom_tag)
Hook function signature¶
Every hook receives three arguments and must return a dict:
def my_hook(payload: dict, entity: SEOEntity, config: SEOConfig) -> dict:
# payload is the current state (may have been modified by previous hooks)
# entity is the original SEOEntity passed to build_seo_payload
# config is the SEOConfig used for generation
return payload
Available hook points¶
| Name | When it runs | Default registrations |
|---|---|---|
post_process |
End of build_seo_payload, before returning |
None |
The post_process hook point is the only built-in hook point. More may be added in future versions.
Scoped hooks (multi-config / multi-tenant)¶
When your application serves multiple sites or configs in the same process,
use HookRegistry to scope hooks per SEOConfig:
from seoslug import HookRegistry, SEOConfig
site_a = HookRegistry()
site_a.register("post_process", lambda p, e, c: {**p, "site": "A"})
site_b = HookRegistry()
site_b.register("post_process", lambda p, e, c: {**p, "site": "B"})
config_a = SEOConfig(canonical_host="a.com", ..., hooks=site_a)
config_b = SEOConfig(canonical_host="b.com", ..., hooks=site_b)
Global hooks (registered with register / @hook) run before scoped
hooks. Use global hooks for cross-cutting concerns (logging, instrumentation)
and scoped hooks for per-site customization.
HookRegistry is thread-safe: writes are protected by a threading.Lock.
Execution order¶
- Global hooks run in registration order (FIFO). For decorators, file import order determines registration order.
- Scoped hooks (
config.hooks) run next, in registration order.
Within a single registry, hooks are stored and executed in registration order. If multiple hooks modify the same field, the last one wins.
Thread safety¶
The module-level register() is safe for import-time registration (the
common case). Runtime register() calls from multiple threads are not
locked; for concurrent registration, use a HookRegistry instance
attached to SEOConfig, which protects writes with threading.Lock.
Reads (run()) iterate a list that is typically populated at import time
and never modified afterward, so read-safety is fine in practice.
Exception behavior¶
If a hook raises an exception, propagation is immediate. Remaining hooks in
the chain are skipped. There is no try/except wrapping. Hook
failures should be loud because they indicate a programming error.
@hook("post_process")
def broken_hook(payload, entity, config):
raise ValueError("something went wrong")
# The next hook in the chain will never run.
Mutation guarantees¶
Each hook receives a plain dict (a copy of the payload at that point).
Mutations to the dict are safe because each hook works on its own reference.
The entity and config arguments must not be mutated. Changes to
these are silently ignored by the builder.
@hook("post_process")
def safe_hook(payload, entity, config):
# payload is a copy: safe to mutate
payload["generator"] = "seoslug"
return payload
@hook("post_process")
def unsafe_hook(payload, entity, config):
# entity and config must not be mutated
# entity.title = "Hacked" # DON'T
# config.canonical_host = "X" # DON'T
return payload
Lifecycle¶
Clear hooks¶
from seoslug import clear_hooks
clear_hooks("post_process") # remove only post_process hooks
clear_hooks() # remove all hooks
Inspect registered hooks¶
from seoslug import get_registered_hooks
hooks = get_registered_hooks()
# {'post_process': [<function add_breadcrumb at 0x...>, ...]}
Best practices¶
- Keep hooks pure. Don't make HTTP requests, write files, or mutate global state inside a hook. Hooks run every time
build_seo_payloadis called. - Return the payload. If your hook forgets to return the payload, later hooks and the caller will receive
None. - Order matters. Hooks run in registration order. If you have multiple hooks that modify the same field, the last one wins.
- Use overrides first. If a transformation applies to a specific page rather than globally, prefer
SEOOverridesover a hook. Hooks are best for site-wide post-processing.
Example: inject Organization schema on every page¶
from seoslug import hook
@hook("post_process")
def inject_organization(payload, entity, config):
existing = payload.get("schema_jsonld")
org = {
"@context": "https://schema.org",
"@type": "Organization",
"name": config.publisher_name or "My Site",
"url": config.public_base_url,
}
if isinstance(existing, dict):
payload["schema_jsonld"] = [org, existing]
elif isinstance(existing, list):
payload["schema_jsonld"] = [org] + existing
else:
payload["schema_jsonld"] = org
return payload